mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-08 22:01:28 +00:00
Extract reporting runtime owners
This commit is contained in:
@@ -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."
|
||||
|
||||
+20
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user