Extract reporting runtime owners

This commit is contained in:
rcourtman
2026-03-20 22:12:15 +00:00
parent 27a1d51c86
commit f459bd18ab
11 changed files with 497 additions and 166 deletions
@@ -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
@@ -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"
]
},
{
@@ -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<ReportingRangeValue>[] = REPORTING_RANGE_OPTIONS.map(
(option) => ({
@@ -61,130 +47,26 @@ function FormField(props: FormFieldProps) {
}
export function ReportingPanel() {
const [selectedResources, setSelectedResources] = createSignal<SelectedResource[]>([]);
const [metricType, setMetricType] = createSignal('');
const [format, setFormat] = createSignal<'pdf' | 'csv'>('pdf');
const [range, setRange] = createSignal<ReportingRangeValue>('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 (
<div class="space-y-6">
@@ -205,7 +87,7 @@ export function ReportingPanel() {
</div>
<div class="flex flex-col sm:flex-row items-center gap-2">
<a
href={getUpgradeActionUrlOrFallback('advanced_reporting')}
href={upgradeActionUrl()}
target="_blank"
rel="noopener noreferrer"
class={getUpgradeActionButtonClass()}
@@ -231,7 +113,7 @@ export function ReportingPanel() {
</OperationsPanel>
</Show>
<Show when={licenseLoaded() && hasFeature('advanced_reporting')}>
<Show when={isReportingEnabled()}>
<OperationsPanel
title="Detailed Reporting"
description="Generate reports across infrastructure, workloads, storage, and backup resources."
@@ -31,6 +31,7 @@ import aiProviderConfigurationSectionSource from '../AIProviderConfigurationSect
import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw';
import aiSettingsModelSource from '../aiSettingsModel.ts?raw';
import reportingPanelSource from '../ReportingPanel.tsx?raw';
import reportingPanelModelSource from '../reportingPanelModel.ts?raw';
import updatesSettingsPanelSource from '../UpdatesSettingsPanel.tsx?raw';
import suggestProfileModalSource from '../SuggestProfileModal.tsx?raw';
import aiIntelligenceSource from '@/pages/AIIntelligence.tsx?raw';
@@ -43,6 +44,7 @@ import aiControlLevelPresentationSource from '@/utils/aiControlLevelPresentation
import agentProfilesPresentationSource from '@/utils/agentProfilesPresentation.ts?raw';
import agentProfileSuggestionPresentationSource from '@/utils/agentProfileSuggestionPresentation.ts?raw';
import reportingPresentationSource from '@/utils/reportingPresentation.ts?raw';
import reportingPanelStateSource from '../useReportingPanelState.ts?raw';
import aiSettingsPresentationSource from '@/utils/aiSettingsPresentation.ts?raw';
import aiSessionDiffPresentationSource from '@/utils/aiSessionDiffPresentation.ts?raw';
import settingsShellPresentationSource from '@/utils/settingsShellPresentation.ts?raw';
@@ -405,13 +407,31 @@ describe('monitored-system model guardrails', () => {
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("<For each={['24h', '7d', '30d']}>");
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';",
@@ -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');
});
});
@@ -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');
@@ -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<string, string>;
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,
}),
},
},
};
}
@@ -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<SelectedResource[]>([]);
const [metricType, setMetricType] = createSignal('');
const [format, setFormat] = createSignal<ReportingFormat>('pdf');
const [range, setRange] = createSignal<ReportingRangeValue>('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,
};
};
@@ -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');
@@ -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');
});
});
@@ -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';
}