diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 7352f68f6..70067b0b5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -52,22 +52,25 @@ work extends shared components instead of creating new local variants. 29. `frontend-modern/src/components/Settings/UpdateInstallGuide.tsx` 30. `frontend-modern/src/components/Settings/updatesSettingsModel.ts` 31. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` -32. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts` -33. `tests/integration/tests/15-settings-shell-consistency.spec.ts` -34. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts` -35. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts` -36. `frontend-modern/src/features/` -37. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx` -38. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx` -39. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx` -40. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx` -41. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx` -42. `frontend-modern/src/components/Settings/ReportingPanel.tsx` -43. `frontend-modern/src/components/Settings/SystemLogsPanel.tsx` -44. `frontend-modern/src/features/operations/OperationsPageSurface.tsx` -45. `frontend-modern/src/features/operations/operationsPageModel.ts` -46. `frontend-modern/src/pages/Operations.tsx` -47. `frontend-modern/src/pages/__tests__/Operations.helpers.test.ts` +32. `frontend-modern/src/components/Settings/ReportingPanel.tsx` +33. `frontend-modern/src/components/Settings/reportingPanelModel.ts` +34. `frontend-modern/src/components/Settings/useReportingPanelState.ts` +35. `frontend-modern/src/utils/reportingPresentation.ts` +36. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts` +37. `tests/integration/tests/15-settings-shell-consistency.spec.ts` +38. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts` +39. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts` +40. `frontend-modern/src/features/` +41. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx` +42. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx` +43. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx` +44. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx` +45. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx` +46. `frontend-modern/src/components/Settings/SystemLogsPanel.tsx` +47. `frontend-modern/src/features/operations/OperationsPageSurface.tsx` +48. `frontend-modern/src/features/operations/operationsPageModel.ts` +49. `frontend-modern/src/pages/Operations.tsx` +50. `frontend-modern/src/pages/__tests__/Operations.helpers.test.ts` ## Shared Boundaries @@ -174,6 +177,16 @@ deployment-specific install guide, copy-command block, and update-channel/instal model data. The panel shell must not rebuild copy-to-clipboard command cards or deployment instruction trees inline. +The reporting operations surface now follows the same shell-state-model rule. +`frontend-modern/src/components/Settings/ReportingPanel.tsx` stays the +operations-panel shell, while +`frontend-modern/src/components/Settings/useReportingPanelState.ts` owns the +license/trial lifecycle and report generation flow, +`frontend-modern/src/components/Settings/reportingPanelModel.ts` owns the +request/range/filename model, and `frontend-modern/src/utils/reportingPresentation.ts` +owns the user-facing range/status copy. The shell must not re-accumulate +license bootstrapping, inline report API requests, or blob-download plumbing. + General settings segmented selectors for theme preference and temperature unit must now also route through the shared `FilterButtonGroup` primitive instead of maintaining local button-group styling forks inside diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index aeeb7a255..169d502d5 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1655,6 +1655,7 @@ "frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx", "frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx", "frontend-modern/src/components/Settings/ReportingPanel.tsx", + "frontend-modern/src/components/Settings/reportingPanelModel.ts", "frontend-modern/src/components/Settings/SecurityAuthPanel.tsx", "frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx", "frontend-modern/src/components/Settings/Settings.tsx", @@ -1670,12 +1671,14 @@ "frontend-modern/src/components/Settings/useAuditLogPanelState.ts", "frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts", "frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts", + "frontend-modern/src/components/Settings/useReportingPanelState.ts", "frontend-modern/src/components/Settings/useSSOProvidersState.ts", "frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx", "frontend-modern/src/components/SetupWizard/SetupWizard.tsx", "frontend-modern/src/features/operations/operationsPageModel.ts", "frontend-modern/src/features/operations/OperationsPageSurface.tsx", - "frontend-modern/src/pages/Operations.tsx" + "frontend-modern/src/pages/Operations.tsx", + "frontend-modern/src/utils/reportingPresentation.ts" ], "verification": { "allow_same_subsystem_tests": false, @@ -1753,17 +1756,23 @@ "match_prefixes": [], "match_files": [ "frontend-modern/src/components/Settings/ReportingPanel.tsx", + "frontend-modern/src/components/Settings/reportingPanelModel.ts", "frontend-modern/src/components/Settings/SystemLogsPanel.tsx", + "frontend-modern/src/components/Settings/useReportingPanelState.ts", "frontend-modern/src/features/operations/operationsPageModel.ts", "frontend-modern/src/features/operations/OperationsPageSurface.tsx", - "frontend-modern/src/pages/Operations.tsx" + "frontend-modern/src/pages/Operations.tsx", + "frontend-modern/src/utils/reportingPresentation.ts" ], "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ "frontend-modern/src/__tests__/App.architecture.test.ts", + "frontend-modern/src/components/Settings/__tests__/reportingPanelModel.test.ts", + "frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts", "frontend-modern/src/pages/__tests__/Operations.helpers.test.ts", - "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts" + "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts", + "frontend-modern/src/utils/__tests__/reportingPresentation.test.ts" ] }, { diff --git a/frontend-modern/src/components/Settings/ReportingPanel.tsx b/frontend-modern/src/components/Settings/ReportingPanel.tsx index b18cc0056..43aec17ac 100644 --- a/frontend-modern/src/components/Settings/ReportingPanel.tsx +++ b/frontend-modern/src/components/Settings/ReportingPanel.tsx @@ -1,4 +1,4 @@ -import { createSignal, createEffect, Show, JSX } from 'solid-js'; +import { Show, JSX } from 'solid-js'; import FileText from 'lucide-solid/icons/file-text'; import Download from 'lucide-solid/icons/download'; import BarChart from 'lucide-solid/icons/bar-chart'; @@ -6,31 +6,17 @@ import OperationsPanel from '@/components/Settings/OperationsPanel'; import { CalloutCard } from '@/components/shared/CalloutCard'; import { formField, formLabel, formHelpText, formControl } from '@/components/shared/Form'; import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup'; -import { showSuccess, showWarning } from '@/utils/toast'; -import { apiFetch } from '@/utils/apiClient'; -import { ResourcePicker, type SelectedResource } from './ResourcePicker'; +import { ResourcePicker } from './ResourcePicker'; +import { trackUpgradeClicked } from '@/utils/upgradeMetrics'; +import { REPORTING_RANGE_OPTIONS } from '@/utils/reportingPresentation'; import { - hasFeature, - licenseLoaded, - loadLicenseStatus, - getUpgradeActionUrlOrFallback, - startProTrial, - entitlements, -} from '@/stores/license'; -import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; -import { toReportingResourceType } from '@/utils/reportingResourceTypes'; -import { REPORTING_RANGE_OPTIONS, type ReportingRangeOption } from '@/utils/reportingPresentation'; -import { - getProTrialStartedMessage, - getTrialAlreadyUsedMessage, - getTrialStartErrorMessage, getUpgradeActionButtonClass, UPGRADE_ACTION_LABEL, UPGRADE_TRIAL_LABEL, UPGRADE_TRIAL_LINK_CLASS, } from '@/utils/upgradePresentation'; - -type ReportingRangeValue = ReportingRangeOption['value']; +import { useReportingPanelState } from '@/components/Settings/useReportingPanelState'; +import { type ReportingRangeValue } from '@/components/Settings/reportingPanelModel'; const REPORTING_RANGE_FILTER_OPTIONS: FilterOption[] = REPORTING_RANGE_OPTIONS.map( (option) => ({ @@ -61,130 +47,26 @@ function FormField(props: FormFieldProps) { } export function ReportingPanel() { - const [selectedResources, setSelectedResources] = createSignal([]); - const [metricType, setMetricType] = createSignal(''); - const [format, setFormat] = createSignal<'pdf' | 'csv'>('pdf'); - const [range, setRange] = createSignal('24h'); - const [generating, setGenerating] = createSignal(false); - const [title, setTitle] = createSignal(''); - const [startingTrial, setStartingTrial] = createSignal(false); - - loadLicenseStatus(); - - const isLocked = () => licenseLoaded() && !hasFeature('advanced_reporting'); - const canStartTrial = () => entitlements()?.trial_eligible !== false; - - createEffect((wasVisible: boolean) => { - const visible = isLocked(); - if (visible && !wasVisible) { - trackPaywallViewed('advanced_reporting', 'settings_reporting_panel'); - } - return visible; - }, false); - - const handleStartTrial = async () => { - if (startingTrial()) return; - setStartingTrial(true); - try { - const result = await startProTrial(); - if (result?.outcome === 'redirect') { - window.location.href = result.actionUrl; - return; - } - showSuccess(getProTrialStartedMessage()); - } catch (err) { - const statusCode = (err as { status?: number } | null)?.status; - if (statusCode === 409) { - showWarning(getTrialAlreadyUsedMessage()); - } else { - showWarning(getTrialStartErrorMessage(err instanceof Error ? err.message : undefined)); - } - } finally { - setStartingTrial(false); - } - }; - - const handleGenerate = async () => { - const resources = selectedResources(); - if (resources.length === 0) { - showWarning('Please select at least one resource'); - return; - } - - setGenerating(true); - try { - const end = new Date().toISOString(); - let start = new Date(); - if (range() === '24h') start.setHours(start.getHours() - 24); - else if (range() === '7d') start.setDate(start.getDate() - 7); - else if (range() === '30d') start.setDate(start.getDate() - 30); - - const startStr = start.toISOString(); - - let response: Response; - let filename: string; - - if (resources.length === 1) { - const res = resources[0]; - const params = new URLSearchParams({ - resourceType: toReportingResourceType(res.type), - resourceId: res.id, - format: format(), - start: startStr, - end: end, - title: title() || `Pulse Report - ${res.name}`, - }); - - if (metricType()) { - params.append('metricType', metricType()); - } - - response = await apiFetch(`/api/admin/reports/generate?${params.toString()}`); - filename = `report-${res.name}-${new Date().toISOString().split('T')[0]}.${format()}`; - } else { - const body = { - resources: resources.map((r) => ({ - resourceType: toReportingResourceType(r.type), - resourceId: r.id, - })), - format: format(), - start: startStr, - end: end, - title: title() || 'Pulse Fleet Report', - metricType: metricType() || undefined, - }; - - response = await apiFetch('/api/admin/reports/generate-multi', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - filename = `fleet-report-${new Date().toISOString().split('T')[0]}.${format()}`; - } - - if (!response.ok) { - const text = await response.text(); - throw new Error(text || 'Failed to generate report'); - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - - showSuccess('Report generated successfully'); - } catch (err) { - console.error('Report generation error:', err); - showWarning(err instanceof Error ? err.message : 'Failed to generate report'); - } finally { - setGenerating(false); - } - }; + const { + canStartTrial, + format, + generating, + handleGenerate, + handleStartTrial, + isLocked, + isReportingEnabled, + metricType, + range, + selectedResources, + setFormat, + setMetricType, + setRange, + setSelectedResources, + setTitle, + startingTrial, + title, + upgradeActionUrl, + } = useReportingPanelState(); return (
@@ -205,7 +87,7 @@ export function ReportingPanel() {
- + { expect(reportingPanelSource).toContain('CalloutCard'); expect(reportingPanelSource).toContain('variant="prominent"'); expect(reportingPanelSource).toContain('@/utils/upgradePresentation'); + expect(reportingPanelSource).toContain('@/components/Settings/useReportingPanelState'); + expect(reportingPanelSource).toContain('@/components/Settings/reportingPanelModel'); expect(reportingPanelSource).toContain('getUpgradeActionButtonClass'); expect(reportingPanelSource).toContain('UPGRADE_ACTION_LABEL'); expect(reportingPanelSource).toContain('UPGRADE_TRIAL_LABEL'); expect(reportingPanelSource).not.toContain('>Upgrade to Pro<'); expect(reportingPanelSource).not.toContain('>Start free trial<'); expect(reportingPanelSource).not.toContain(""); + expect(reportingPanelSource).not.toContain('window.URL.createObjectURL'); + expect(reportingPanelStateSource).toContain('buildReportingRequest'); + expect(reportingPanelStateSource).toContain('getReportingGenerateSelectionRequiredMessage'); + expect(reportingPanelStateSource).toContain('getReportingGenerateSuccessMessage'); + expect(reportingPanelStateSource).toContain('getReportingGenerateErrorMessage'); + expect(reportingPanelModelSource).toContain('export function getReportingRangeStart'); + expect(reportingPanelModelSource).toContain('export function buildReportingRequest'); expect(reportingPresentationSource).toContain('export const REPORTING_RANGE_OPTIONS'); + expect(reportingPresentationSource).toContain( + 'export function getReportingGenerateSelectionRequiredMessage', + ); + expect(reportingPresentationSource).toContain( + 'export function getReportingGenerateSuccessMessage', + ); + expect(reportingPresentationSource).toContain( + 'export function getReportingGenerateErrorMessage', + ); expect(reportingPresentationSource).not.toContain('getReportingToggleButtonClass'); expect(aiIntelligenceSource).toContain( "import { PatrolIntelligenceSurface } from '@/features/patrol/PatrolIntelligenceSurface';", diff --git a/frontend-modern/src/components/Settings/__tests__/reportingPanelModel.test.ts b/frontend-modern/src/components/Settings/__tests__/reportingPanelModel.test.ts new file mode 100644 index 000000000..f84e3fa19 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/reportingPanelModel.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import type { SelectedResource } from '../ResourcePicker'; +import { + buildReportingRequest, + getReportingRangeStart, +} from '../reportingPanelModel'; + +describe('reporting panel model', () => { + it('builds a single-resource reporting request and filename', () => { + const now = new Date('2026-03-20T12:34:56.000Z'); + const resources: SelectedResource[] = [ + { + id: 'agent-1', + type: 'agent', + name: 'node-a', + }, + ]; + + const request = buildReportingRequest({ + end: now.toISOString(), + format: 'pdf', + metricType: 'cpu', + now, + resources, + start: '2026-03-19T12:34:56.000Z', + title: '', + }); + + expect(request.filename).toBe('report-node-a-2026-03-20.pdf'); + expect(request.request.init).toBeUndefined(); + expect(request.request.url).toContain('/api/admin/reports/generate?'); + expect(request.request.url).toContain('resourceType=agent'); + expect(request.request.url).toContain('resourceId=agent-1'); + expect(request.request.url).toContain('metricType=cpu'); + expect(request.request.url).toContain('title=Pulse+Report+-+node-a'); + }); + + it('builds a fleet reporting request body and filename', () => { + const now = new Date('2026-03-20T12:34:56.000Z'); + const resources: SelectedResource[] = [ + { + id: 'agent-1', + type: 'agent', + name: 'node-a', + }, + { + id: 'vm-1', + type: 'vm', + name: 'vm-a', + }, + ]; + + const request = buildReportingRequest({ + end: now.toISOString(), + format: 'csv', + metricType: '', + now, + resources, + start: '2026-03-19T12:34:56.000Z', + title: '', + }); + + expect(request.filename).toBe('fleet-report-2026-03-20.csv'); + expect(request.request.url).toBe('/api/admin/reports/generate-multi'); + expect(request.request.init).toMatchObject({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + expect(request.request.init?.body).toBe( + JSON.stringify({ + resources: [ + { resourceType: 'agent', resourceId: 'agent-1' }, + { resourceType: 'vm', resourceId: 'vm-1' }, + ], + format: 'csv', + start: '2026-03-19T12:34:56.000Z', + end: now.toISOString(), + title: 'Pulse Fleet Report', + metricType: undefined, + }), + ); + }); + + it('derives canonical range starts from the selected preset', () => { + const now = new Date('2026-03-20T12:00:00.000Z'); + + expect(getReportingRangeStart('24h', now).toISOString()).toBe('2026-03-19T12:00:00.000Z'); + expect(getReportingRangeStart('7d', now).toISOString()).toBe('2026-03-13T12:00:00.000Z'); + expect(getReportingRangeStart('30d', now).toISOString()).toBe('2026-02-18T12:00:00.000Z'); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index ac41fbe20..0b380d221 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -45,6 +45,9 @@ import ssoProvidersPanelSource from '../SSOProvidersPanel.tsx?raw'; import ssoProvidersStateSource from '../useSSOProvidersState.ts?raw'; import ssoProvidersModelSource from '../ssoProvidersModel.ts?raw'; import diagnosticsStateSource from '../useDiagnosticsPanelState.ts?raw'; +import reportingPanelModelSource from '../reportingPanelModel.ts?raw'; +import reportingPanelSource from '../ReportingPanel.tsx?raw'; +import reportingPanelStateSource from '../useReportingPanelState.ts?raw'; import rolesPanelSource from '../RolesPanel.tsx?raw'; import userAssignmentsPanelSource from '../UserAssignmentsPanel.tsx?raw'; import { SETTINGS_HEADER_META } from '../settingsHeaderMeta'; @@ -75,8 +78,11 @@ const extractedModules = [ '../DiagnosticsResultsPanel.tsx', '../CopyCommandBlock.tsx', '../UpdateInstallGuide.tsx', + '../ReportingPanel.tsx', + '../reportingPanelModel.ts', '../updatesSettingsModel.ts', '../useDiagnosticsPanelState.ts', + '../useReportingPanelState.ts', '../useSSOProvidersState.ts', '../ssoProvidersModel.ts', '../ProxmoxSettingsPanel.tsx', @@ -396,6 +402,23 @@ describe('Settings architecture guardrails', () => { expect(diagnosticsModelSource).toContain('export function formatUptime'); }); + it('keeps the reporting shell behind extracted runtime and model owners', () => { + expect(reportingPanelSource).toContain('@/components/Settings/useReportingPanelState'); + expect(reportingPanelSource).toContain('@/components/Settings/reportingPanelModel'); + expect(reportingPanelSource).not.toContain('loadLicenseStatus()'); + expect(reportingPanelSource).not.toContain('startProTrial()'); + expect(reportingPanelSource).not.toContain("apiFetch('/api/admin/reports/generate"); + expect(reportingPanelSource).not.toContain('window.URL.createObjectURL'); + expect(reportingPanelStateSource).toContain('export const useReportingPanelState ='); + expect(reportingPanelStateSource).toContain('loadLicenseStatus'); + expect(reportingPanelStateSource).toContain('startProTrial'); + expect(reportingPanelStateSource).toContain('buildReportingRequest'); + expect(reportingPanelStateSource).toContain('getReportingGenerateSuccessMessage'); + expect(reportingPanelModelSource).toContain('export function getReportingRangeStart'); + expect(reportingPanelModelSource).toContain('export function buildReportingRequest'); + expect(reportingPanelModelSource).toContain('export function buildReportingFilename'); + }); + it('keeps the audit log shell behind an extracted runtime owner', () => { expect(auditLogPanelSource).toContain('@/components/Settings/useAuditLogPanelState'); expect(auditLogPanelSource).not.toContain('createLocalStorageStringSignal'); diff --git a/frontend-modern/src/components/Settings/reportingPanelModel.ts b/frontend-modern/src/components/Settings/reportingPanelModel.ts new file mode 100644 index 000000000..d0ab608ae --- /dev/null +++ b/frontend-modern/src/components/Settings/reportingPanelModel.ts @@ -0,0 +1,103 @@ +import type { ReportingRangeOption } from '@/utils/reportingPresentation'; +import { toReportingResourceType } from '@/utils/reportingResourceTypes'; +import type { SelectedResource } from '@/components/Settings/ResourcePicker'; + +export type ReportingRangeValue = ReportingRangeOption['value']; +export type ReportingFormat = 'pdf' | 'csv'; + +export interface ReportingRequestContext { + end: string; + format: ReportingFormat; + metricType: string; + now: Date; + resources: SelectedResource[]; + start: string; + title: string; +} + +export interface ReportingRequestDefinition { + filename: string; + request: { + init?: { + body?: string; + headers?: Record; + method?: string; + }; + url: string; + }; +} + +export function getReportingRangeStart(range: ReportingRangeValue, now: Date): Date { + const start = new Date(now); + if (range === '24h') start.setHours(start.getHours() - 24); + else if (range === '7d') start.setDate(start.getDate() - 7); + else if (range === '30d') start.setDate(start.getDate() - 30); + return start; +} + +export function buildSingleReportTitle(title: string, resourceName: string): string { + return title || `Pulse Report - ${resourceName}`; +} + +export function buildFleetReportTitle(title: string): string { + return title || 'Pulse Fleet Report'; +} + +export function buildReportingFilename( + format: ReportingFormat, + resourceName: string | null, + now: Date, +): string { + const date = now.toISOString().split('T')[0]; + if (resourceName) { + return `report-${resourceName}-${date}.${format}`; + } + return `fleet-report-${date}.${format}`; +} + +export function buildReportingRequest(context: ReportingRequestContext): ReportingRequestDefinition { + if (context.resources.length === 1) { + const resource = context.resources[0]; + const params = new URLSearchParams({ + resourceType: toReportingResourceType(resource.type), + resourceId: resource.id, + format: context.format, + start: context.start, + end: context.end, + title: buildSingleReportTitle(context.title, resource.name), + }); + + if (context.metricType) { + params.append('metricType', context.metricType); + } + + return { + filename: buildReportingFilename(context.format, resource.name, context.now), + request: { + url: `/api/admin/reports/generate?${params.toString()}`, + }, + }; + } + + return { + filename: buildReportingFilename(context.format, null, context.now), + request: { + url: '/api/admin/reports/generate-multi', + init: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + resources: context.resources.map((resource) => ({ + resourceType: toReportingResourceType(resource.type), + resourceId: resource.id, + })), + format: context.format, + start: context.start, + end: context.end, + title: buildFleetReportTitle(context.title), + metricType: context.metricType || undefined, + }), + }, + }, + }; +} diff --git a/frontend-modern/src/components/Settings/useReportingPanelState.ts b/frontend-modern/src/components/Settings/useReportingPanelState.ts new file mode 100644 index 000000000..f95e63378 --- /dev/null +++ b/frontend-modern/src/components/Settings/useReportingPanelState.ts @@ -0,0 +1,145 @@ +import { createEffect, createSignal, onMount } from 'solid-js'; +import { apiFetch } from '@/utils/apiClient'; +import { showSuccess, showWarning } from '@/utils/toast'; +import type { SelectedResource } from '@/components/Settings/ResourcePicker'; +import { + entitlements, + getUpgradeActionUrlOrFallback, + hasFeature, + licenseLoaded, + loadLicenseStatus, + startProTrial, +} from '@/stores/license'; +import { trackPaywallViewed } from '@/utils/upgradeMetrics'; +import { + getProTrialStartedMessage, + getTrialAlreadyUsedMessage, + getTrialStartErrorMessage, +} from '@/utils/upgradePresentation'; +import { + getReportingGenerateErrorMessage, + getReportingGenerateSelectionRequiredMessage, + getReportingGenerateSuccessMessage, +} from '@/utils/reportingPresentation'; +import { + buildReportingRequest, + getReportingRangeStart, + type ReportingFormat, + type ReportingRangeValue, +} from '@/components/Settings/reportingPanelModel'; + +export const useReportingPanelState = () => { + const [selectedResources, setSelectedResources] = createSignal([]); + const [metricType, setMetricType] = createSignal(''); + const [format, setFormat] = createSignal('pdf'); + const [range, setRange] = createSignal('24h'); + const [generating, setGenerating] = createSignal(false); + const [title, setTitle] = createSignal(''); + const [startingTrial, setStartingTrial] = createSignal(false); + + const isLocked = () => licenseLoaded() && !hasFeature('advanced_reporting'); + const canStartTrial = () => entitlements()?.trial_eligible !== false; + const isReportingEnabled = () => licenseLoaded() && hasFeature('advanced_reporting'); + const upgradeActionUrl = () => getUpgradeActionUrlOrFallback('advanced_reporting'); + + onMount(() => { + loadLicenseStatus(); + }); + + createEffect((wasVisible: boolean) => { + const visible = isLocked(); + if (visible && !wasVisible) { + trackPaywallViewed('advanced_reporting', 'settings_reporting_panel'); + } + return visible; + }, false); + + const handleStartTrial = async () => { + if (startingTrial()) return; + setStartingTrial(true); + try { + const result = await startProTrial(); + if (result?.outcome === 'redirect') { + window.location.href = result.actionUrl; + return; + } + showSuccess(getProTrialStartedMessage()); + } catch (error) { + const statusCode = (error as { status?: number } | null)?.status; + if (statusCode === 409) { + showWarning(getTrialAlreadyUsedMessage()); + } else { + showWarning(getTrialStartErrorMessage(error instanceof Error ? error.message : undefined)); + } + } finally { + setStartingTrial(false); + } + }; + + const handleGenerate = async () => { + const resources = selectedResources(); + if (resources.length === 0) { + showWarning(getReportingGenerateSelectionRequiredMessage()); + return; + } + + setGenerating(true); + try { + const now = new Date(); + const start = getReportingRangeStart(range(), now); + const request = buildReportingRequest({ + end: now.toISOString(), + format: format(), + metricType: metricType(), + now, + resources, + start: start.toISOString(), + title: title(), + }); + + const response = await apiFetch(request.request.url, request.request.init); + if (!response.ok) { + const text = await response.text(); + throw new Error(text || getReportingGenerateErrorMessage()); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = request.filename; + document.body.appendChild(anchor); + anchor.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(anchor); + + showSuccess(getReportingGenerateSuccessMessage()); + } catch (error) { + console.error('Report generation error:', error); + showWarning(error instanceof Error ? error.message : getReportingGenerateErrorMessage()); + } finally { + setGenerating(false); + } + }; + + return { + canStartTrial, + format, + generating, + handleGenerate, + handleStartTrial, + isLocked, + isReportingEnabled, + metricType, + range, + selectedResources, + setFormat, + setMetricType, + setRange, + setSelectedResources, + setTitle, + startingTrial, + title, + upgradeActionUrl, + }; +}; diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index b2e7f750d..87d1c3756 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -21,10 +21,13 @@ import systemSettingsPresentationSource from '@/utils/systemSettingsPresentation import updatesSettingsPanelSource from '@/components/Settings/UpdatesSettingsPanel.tsx?raw'; import copyCommandBlockSource from '@/components/Settings/CopyCommandBlock.tsx?raw'; import updateInstallGuideSource from '@/components/Settings/UpdateInstallGuide.tsx?raw'; +import reportingPanelModelSource from '@/components/Settings/reportingPanelModel.ts?raw'; +import reportingPanelStateSource from '@/components/Settings/useReportingPanelState.ts?raw'; import updatesSettingsModelSource from '@/components/Settings/updatesSettingsModel.ts?raw'; import diagnosticsModelSource from '@/components/Settings/diagnosticsModel.ts?raw'; import diagnosticsResultsPanelSource from '@/components/Settings/DiagnosticsResultsPanel.tsx?raw'; import diagnosticsStateSource from '@/components/Settings/useDiagnosticsPanelState.ts?raw'; +import reportingPresentationSource from '@/utils/reportingPresentation.ts?raw'; import updatesPresentationSource from '@/utils/updatesPresentation.ts?raw'; import environmentLockBadgeSource from '@/components/shared/EnvironmentLockBadge.tsx?raw'; import environmentLockPresentationSource from '@/utils/environmentLockPresentation.ts?raw'; @@ -784,11 +787,14 @@ describe('frontend resource type boundaries', () => { expect(systemSettingsPresentationSource).toContain('export function getBackupIntervalSummary'); expect(systemSettingsPresentationSource).toContain('export const COMMON_DISCOVERY_SUBNETS'); expect(reportingPanelSource).toContain('@/utils/upgradePresentation'); + expect(reportingPanelSource).toContain('@/components/Settings/useReportingPanelState'); + expect(reportingPanelSource).toContain('@/components/Settings/reportingPanelModel'); expect(reportingPanelSource).toContain('getUpgradeActionButtonClass'); expect(reportingPanelSource).toContain('UPGRADE_ACTION_LABEL'); expect(reportingPanelSource).toContain('UPGRADE_TRIAL_LABEL'); expect(reportingPanelSource).not.toContain('>Upgrade to Pro<'); expect(reportingPanelSource).not.toContain('>Start free trial<'); + expect(reportingPanelSource).not.toContain('window.URL.createObjectURL'); expect(rolesPanelSource).toContain('@/utils/upgradePresentation'); expect(userAssignmentsPanelSource).toContain('@/utils/upgradePresentation'); expect(agentProfilesPanelSource).toContain('@/utils/upgradePresentation'); @@ -2735,6 +2741,20 @@ describe('frontend resource type boundaries', () => { expect(copyCommandBlockSource).toContain("aria-label=\"Copy to clipboard\""); expect(updatesSettingsModelSource).toContain('export function getUpdateChannelCardOptions'); expect(updatesSettingsModelSource).toContain('export function buildUpdateInstallGuide'); + expect(reportingPanelStateSource).toContain('buildReportingRequest'); + expect(reportingPanelStateSource).toContain('getReportingGenerateSuccessMessage'); + expect(reportingPanelStateSource).toContain('getReportingGenerateErrorMessage'); + expect(reportingPanelModelSource).toContain('export function getReportingRangeStart'); + expect(reportingPanelModelSource).toContain('export function buildReportingRequest'); + expect(reportingPresentationSource).toContain( + 'export function getReportingGenerateSelectionRequiredMessage', + ); + expect(reportingPresentationSource).toContain( + 'export function getReportingGenerateSuccessMessage', + ); + expect(reportingPresentationSource).toContain( + 'export function getReportingGenerateErrorMessage', + ); expect(updatesPresentationSource).toContain('export function getUpdateBuildBadges'); expect(updatesPresentationSource).toContain('export function getUpdateAvailabilityHeading'); expect(updatesPresentationSource).toContain('export function getUpdatePrimaryStatusLabel'); diff --git a/frontend-modern/src/utils/__tests__/reportingPresentation.test.ts b/frontend-modern/src/utils/__tests__/reportingPresentation.test.ts index fc6b15e54..f4e32d854 100644 --- a/frontend-modern/src/utils/__tests__/reportingPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/reportingPresentation.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { REPORTING_RANGE_OPTIONS } from '@/utils/reportingPresentation'; +import { + getReportingGenerateErrorMessage, + getReportingGenerateSelectionRequiredMessage, + getReportingGenerateSuccessMessage, + REPORTING_RANGE_OPTIONS, +} from '@/utils/reportingPresentation'; describe('reportingPresentation', () => { it('returns canonical reporting range options', () => { @@ -9,4 +14,12 @@ describe('reportingPresentation', () => { { value: '30d', label: 'Last 30 Days' }, ]); }); + + it('returns canonical reporting status copy', () => { + expect(getReportingGenerateSelectionRequiredMessage()).toBe( + 'Please select at least one resource', + ); + expect(getReportingGenerateSuccessMessage()).toBe('Report generated successfully'); + expect(getReportingGenerateErrorMessage()).toBe('Failed to generate report'); + }); }); diff --git a/frontend-modern/src/utils/reportingPresentation.ts b/frontend-modern/src/utils/reportingPresentation.ts index f4ab5dd51..210098bef 100644 --- a/frontend-modern/src/utils/reportingPresentation.ts +++ b/frontend-modern/src/utils/reportingPresentation.ts @@ -8,3 +8,15 @@ export const REPORTING_RANGE_OPTIONS: readonly ReportingRangeOption[] = [ { value: '7d', label: 'Last 7 Days' }, { value: '30d', label: 'Last 30 Days' }, ]; + +export function getReportingGenerateSelectionRequiredMessage(): string { + return 'Please select at least one resource'; +} + +export function getReportingGenerateSuccessMessage(): string { + return 'Report generated successfully'; +} + +export function getReportingGenerateErrorMessage(): string { + return 'Failed to generate report'; +}