diff --git a/frontend/src/component/admin/auth/AuthSettings.tsx b/frontend/src/component/admin/auth/AuthSettings.tsx
index 3f9629c1ede2..af024707dca4 100644
--- a/frontend/src/component/admin/auth/AuthSettings.tsx
+++ b/frontend/src/component/admin/auth/AuthSettings.tsx
@@ -12,6 +12,7 @@ import { ADMIN } from '@server/types/permissions';
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
import { useState } from 'react';
import { TabPanel } from 'component/common/TabNav/TabPanel/TabPanel';
+import { usePageTitle } from 'hooks/usePageTitle';
export const AuthSettings = () => {
const { authenticationType } = useUiConfig().uiConfig;
@@ -46,6 +47,7 @@ export const AuthSettings = () => {
}
const [activeTab, setActiveTab] = useState(0);
+ usePageTitle(`Single sign-on: ${tabs[activeTab].label}`);
return (
diff --git a/frontend/src/component/admin/roles/RoleForm/useRoleForm.test.ts b/frontend/src/component/admin/roles/RoleForm/useRoleForm.test.ts
index c9e8b431c0b1..d25da27c6b1d 100644
--- a/frontend/src/component/admin/roles/RoleForm/useRoleForm.test.ts
+++ b/frontend/src/component/admin/roles/RoleForm/useRoleForm.test.ts
@@ -14,14 +14,14 @@ describe('trim names and description', () => {
expect(result.current.name).toBe('my role');
});
- test('description is trimmed before being set', () => {
+ test('description is not trimmed before being set', () => {
const { result } = renderHook(() => useRoleForm());
act(() => {
result.current.setDescription(' my description ');
});
- expect(result.current.description).toBe('my description');
+ expect(result.current.description).toBe(' my description ');
});
test('name that is just whitespace triggers an error', () => {
diff --git a/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts b/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts
index f16f3e11dce9..fc077fe76e8b 100644
--- a/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts
+++ b/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts
@@ -29,8 +29,6 @@ export const useRoleForm = (
const [name, setName] = useState(initialName);
const setTrimmedName = (newName: string) => setName(newName.trim());
const [description, setDescription] = useState(initialDescription);
- const setTrimmedDescription = (newDescription: string) =>
- setDescription(newDescription.trim());
const [checkedPermissions, setCheckedPermissions] =
useState
({});
const [errors, setErrors] = useState(DEFAULT_ERRORS);
@@ -147,7 +145,7 @@ export const useRoleForm = (
setName: setTrimmedName,
validateName,
description,
- setDescription: setTrimmedDescription,
+ setDescription,
validateDescription,
checkedPermissions,
setCheckedPermissions,
diff --git a/frontend/src/component/admin/roles/RolesPage.tsx b/frontend/src/component/admin/roles/RolesPage.tsx
index 4299b0a48ae8..84813c8c94b1 100644
--- a/frontend/src/component/admin/roles/RolesPage.tsx
+++ b/frontend/src/component/admin/roles/RolesPage.tsx
@@ -14,6 +14,7 @@ import Add from '@mui/icons-material/Add';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import type { IRole } from 'interfaces/role';
import { TabLink } from 'component/common/TabNav/TabLink';
+import { usePageTitle } from 'hooks/usePageTitle';
const StyledHeader = styled('div')(() => ({
display: 'flex',
@@ -31,6 +32,7 @@ const StyledActions = styled('div')({
});
export const RolesPage = () => {
+ usePageTitle('Roles');
const { pathname } = useLocation();
const { roles, projectRoles, loading } = useRoles();
diff --git a/frontend/src/component/featureTypes/FeatureTypesList.tsx b/frontend/src/component/featureTypes/FeatureTypesList.tsx
index 6190f78740e7..2d9f3953bcde 100644
--- a/frontend/src/component/featureTypes/FeatureTypesList.tsx
+++ b/frontend/src/component/featureTypes/FeatureTypesList.tsx
@@ -5,7 +5,7 @@ import { sortTypes } from 'utils/sortTypes';
import { PageContent } from 'component/common/PageContent/PageContent';
import useFeatureTypes from 'hooks/api/getters/useFeatureTypes/useFeatureTypes';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
-import { Box, Typography } from '@mui/material';
+import { Box } from '@mui/material';
import {
Table,
TableBody,
@@ -150,18 +150,7 @@ export const FeatureTypesList = () => {
return (
- ({
- fontSize: theme.fontSizes.mainHeader,
- })}
- >
- Feature flag types
-
-
- }
+ header={}
>
diff --git a/frontend/src/component/insights/components/InsightsHeader/InsightsHeader.tsx b/frontend/src/component/insights/components/InsightsHeader/InsightsHeader.tsx
index 7a2dee00e29e..b9e5e07ac123 100644
--- a/frontend/src/component/insights/components/InsightsHeader/InsightsHeader.tsx
+++ b/frontend/src/component/insights/components/InsightsHeader/InsightsHeader.tsx
@@ -69,6 +69,7 @@ export const InsightsHeader: VFC = ({ actions }) => {
return (
<>
= {
'/admin/cors': CorsIcon,
'/admin/billing': BillingIcon,
'/history': EventLogIcon,
+ '/releases-management': LaunchIcon,
+ '/personal': PersonalDashboardIcon,
GitHub: GitHubIcon,
Documentation: LibraryBooksIcon,
};
diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap
index c536b1a46d46..3652622dc286 100644
--- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap
+++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap
@@ -230,6 +230,20 @@ exports[`returns all baseRoutes 1`] = `
"title": "Strategy types",
"type": "protected",
},
+ {
+ "component": [Function],
+ "enterprise": true,
+ "flag": "releasePlans",
+ "menu": {
+ "advanced": true,
+ "mode": [
+ "enterprise",
+ ],
+ },
+ "path": "/releases-management",
+ "title": "Release management",
+ "type": "protected",
+ },
{
"component": [Function],
"menu": {},
diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts
index 9428131149a0..07090d77c119 100644
--- a/frontend/src/component/menu/routes.ts
+++ b/frontend/src/component/menu/routes.ts
@@ -48,6 +48,7 @@ import { Application } from 'component/application/Application';
import { Signals } from 'component/signals/Signals';
import { LazyCreateProject } from '../project/Project/CreateProject/LazyCreateProject';
import { PersonalDashboard } from '../personalDashboard/PersonalDashboard';
+import { ReleaseManagement } from 'component/releases/ReleaseManagement';
export const routes: IRoute[] = [
// Splash
@@ -246,6 +247,15 @@ export const routes: IRoute[] = [
type: 'protected',
menu: { mobile: true, advanced: true },
},
+ {
+ path: '/releases-management',
+ title: 'Release management',
+ component: ReleaseManagement,
+ type: 'protected',
+ menu: { advanced: true, mode: ['enterprise'] },
+ flag: 'releasePlans',
+ enterprise: true,
+ },
{
path: '/environments/create',
title: 'Environments',
diff --git a/frontend/src/component/personalDashboard/PersonalDashboard.tsx b/frontend/src/component/personalDashboard/PersonalDashboard.tsx
index 21a1c998f3d2..bba512e01923 100644
--- a/frontend/src/component/personalDashboard/PersonalDashboard.tsx
+++ b/frontend/src/component/personalDashboard/PersonalDashboard.tsx
@@ -19,6 +19,7 @@ import useSplashApi from 'hooks/api/actions/useSplashApi/useSplashApi';
import { useAuthSplash } from 'hooks/api/getters/useAuth/useAuthSplash';
import { useDashboardState } from './useDashboardState';
import { MyFlags } from './MyFlags';
+import { usePageTitle } from 'hooks/usePageTitle';
const WelcomeSection = styled('div')(({ theme }) => ({
display: 'flex',
@@ -103,9 +104,10 @@ export const PersonalDashboard = () => {
const { trackEvent } = usePlausibleTracker();
const { setSplashSeen } = useSplashApi();
const { splash } = useAuthSplash();
-
const name = user?.name;
+ usePageTitle(`Dashboard: ${name}`);
+
const { personalDashboard, refetch: refetchDashboard } =
usePersonalDashboard();
diff --git a/frontend/src/component/releases/ReleaseManagement.tsx b/frontend/src/component/releases/ReleaseManagement.tsx
new file mode 100644
index 000000000000..929eedca3711
--- /dev/null
+++ b/frontend/src/component/releases/ReleaseManagement.tsx
@@ -0,0 +1,3 @@
+export const ReleaseManagement = () => {
+ return null;
+};
diff --git a/frontend/src/component/strategies/StrategiesList/StrategiesList.tsx b/frontend/src/component/strategies/StrategiesList/StrategiesList.tsx
index 0ee82dba8ea0..06593e98e03f 100644
--- a/frontend/src/component/strategies/StrategiesList/StrategiesList.tsx
+++ b/frontend/src/component/strategies/StrategiesList/StrategiesList.tsx
@@ -397,9 +397,10 @@ export const StrategiesList = () => {
-
-
+ }
+ title='Strategy types'
+ />
}
>
diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts
index a627e8895e8c..ca6c80965b00 100644
--- a/frontend/src/interfaces/uiConfig.ts
+++ b/frontend/src/interfaces/uiConfig.ts
@@ -92,6 +92,7 @@ export type UiFlags = {
personalDashboardUI?: boolean;
purchaseAdditionalEnvironments?: boolean;
unleashAI?: boolean;
+ releasePlans?: boolean;
};
export interface IVersionInfo {
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 1d3c1d2271c5..a879ec856a14 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -2743,20 +2743,20 @@ __metadata:
linkType: hard
"@types/node@npm:*":
- version: 22.5.5
- resolution: "@types/node@npm:22.5.5"
+ version: 22.7.6
+ resolution: "@types/node@npm:22.7.6"
dependencies:
undici-types: "npm:~6.19.2"
- checksum: 10c0/ead9495cfc6b1da5e7025856dcce2591e9bae635357410c0d2dd619fce797d2a1d402887580ca4b336cb78168b195224869967de370a23f61663cf1e4836121c
+ checksum: 10c0/d4406a63afce981c363fb1d1954aaf1759ad2d487c0833ebf667565ea4e45ff217d6fab4b5343badbdeccdf9d2e4a0841d633e0c929ceabcb33c288663dd0c73
languageName: node
linkType: hard
"@types/node@npm:^20.12.12, @types/node@npm:^20.12.13":
- version: 20.16.5
- resolution: "@types/node@npm:20.16.5"
+ version: 20.16.12
+ resolution: "@types/node@npm:20.16.12"
dependencies:
undici-types: "npm:~6.19.2"
- checksum: 10c0/6af7994129815010bcbc4cf8221865559c8116ff43e74a6549525c2108267596fc2d18aff5d5ecfe089fb60a119f975631343e2c65c52bfa0955ed9dc56733d6
+ checksum: 10c0/f6a3c90c6745881d47f8dae7eb39d0dd6df9a4060c8f1ab7004690f60b9b183d862c9c6b380b9e8d5a17dd670ac7b19d6318f24c170897c85a9729f9804f47cf
languageName: node
linkType: hard
diff --git a/package.json b/package.json
index c86836658ee8..6d86bc66ad6e 100644
--- a/package.json
+++ b/package.json
@@ -173,10 +173,10 @@
},
"devDependencies": {
"@apidevtools/swagger-parser": "10.1.0",
- "@babel/core": "7.25.2",
+ "@babel/core": "7.25.8",
"@biomejs/biome": "^1.8.3",
"@cyclonedx/yarn-plugin-cyclonedx": "^1.0.0-rc.7",
- "@swc/core": "1.7.26",
+ "@swc/core": "1.7.35",
"@swc/jest": "0.2.36",
"@types/bcryptjs": "2.4.6",
"@types/cors": "2.8.17",
diff --git a/src/lib/features/instance-stats/instance-stats-service.test.ts b/src/lib/features/instance-stats/instance-stats-service.test.ts
index d3f423bec1a6..26a5f7009502 100644
--- a/src/lib/features/instance-stats/instance-stats-service.test.ts
+++ b/src/lib/features/instance-stats/instance-stats-service.test.ts
@@ -4,11 +4,18 @@ import createStores from '../../../test/fixtures/store';
import VersionService from '../../services/version-service';
import { createFakeGetActiveUsers } from './getActiveUsers';
import { createFakeGetProductionChanges } from './getProductionChanges';
-
+import { registerPrometheusMetrics } from '../../metrics';
+import { register } from 'prom-client';
+import type { IClientInstanceStore } from '../../types';
let instanceStatsService: InstanceStatsService;
let versionService: VersionService;
-
+let clientInstanceStore: IClientInstanceStore;
+let updateMetrics: () => Promise;
beforeEach(() => {
+ jest.clearAllMocks();
+
+ register.clear();
+
const config = createTestConfig();
const stores = createStores();
versionService = new VersionService(
@@ -17,6 +24,7 @@ beforeEach(() => {
createFakeGetActiveUsers(),
createFakeGetProductionChanges(),
);
+ clientInstanceStore = stores.clientInstanceStore;
instanceStatsService = new InstanceStatsService(
stores,
config,
@@ -25,23 +33,28 @@ beforeEach(() => {
createFakeGetProductionChanges(),
);
- jest.spyOn(instanceStatsService, 'refreshAppCountSnapshot');
- jest.spyOn(instanceStatsService, 'getLabeledAppCounts');
+ const { collectDbMetrics } = registerPrometheusMetrics(
+ config,
+ stores,
+ undefined as unknown as string,
+ config.eventBus,
+ instanceStatsService,
+ );
+ updateMetrics = collectDbMetrics;
+
+ jest.spyOn(clientInstanceStore, 'getDistinctApplicationsCount');
jest.spyOn(instanceStatsService, 'getStats');
- // validate initial state without calls to these methods
- expect(instanceStatsService.refreshAppCountSnapshot).toHaveBeenCalledTimes(
- 0,
- );
expect(instanceStatsService.getStats).toHaveBeenCalledTimes(0);
});
test('get snapshot should not call getStats', async () => {
- await instanceStatsService.refreshAppCountSnapshot();
- expect(instanceStatsService.getLabeledAppCounts).toHaveBeenCalledTimes(1);
+ await updateMetrics();
+ expect(
+ clientInstanceStore.getDistinctApplicationsCount,
+ ).toHaveBeenCalledTimes(3);
expect(instanceStatsService.getStats).toHaveBeenCalledTimes(0);
- // subsequent calls to getStatsSnapshot don't call getStats
for (let i = 0; i < 3; i++) {
const { clientApps } = await instanceStatsService.getStats();
expect(clientApps).toStrictEqual([
@@ -51,12 +64,11 @@ test('get snapshot should not call getStats', async () => {
]);
}
// after querying the stats snapshot no call to getStats should be issued
- expect(instanceStatsService.getLabeledAppCounts).toHaveBeenCalledTimes(1);
+ expect(
+ clientInstanceStore.getDistinctApplicationsCount,
+ ).toHaveBeenCalledTimes(3);
});
test('before the snapshot is refreshed we can still get the appCount', async () => {
- expect(instanceStatsService.refreshAppCountSnapshot).toHaveBeenCalledTimes(
- 0,
- );
expect(instanceStatsService.getAppCountSnapshot('7d')).toBeUndefined();
});
diff --git a/src/lib/features/instance-stats/instance-stats-service.ts b/src/lib/features/instance-stats/instance-stats-service.ts
index 3fb505fd6480..24e04ef3dfaa 100644
--- a/src/lib/features/instance-stats/instance-stats-service.ts
+++ b/src/lib/features/instance-stats/instance-stats-service.ts
@@ -109,9 +109,9 @@ export class InstanceStatsService {
private appCount?: Partial<{ [key in TimeRange]: number }>;
- private getActiveUsers: GetActiveUsers;
+ getActiveUsers: GetActiveUsers;
- private getProductionChanges: GetProductionChanges;
+ getProductionChanges: GetProductionChanges;
private featureStrategiesReadModel: IFeatureStrategiesReadModel;
@@ -180,25 +180,6 @@ export class InstanceStatsService {
this.featureStrategiesReadModel = featureStrategiesReadModel;
}
- async refreshAppCountSnapshot(): Promise<
- Partial<{ [key in TimeRange]: number }>
- > {
- try {
- this.appCount = await this.getLabeledAppCounts();
- return this.appCount;
- } catch (error) {
- this.logger.warn(
- 'Unable to retrieve statistics. This will be retried',
- error,
- );
- return {
- '7d': 0,
- '30d': 0,
- allTime: 0,
- };
- }
- }
-
getProjectModeCount(): Promise {
return this.projectStore.getProjectModeCounts();
}
@@ -231,9 +212,6 @@ export class InstanceStatsService {
return settings?.enabled || false;
}
- /**
- * use getStatsSnapshot for low latency, sacrificing data-freshness
- */
async getStats(): Promise {
const versionInfo = await this.versionService.getVersionInfo();
const [
@@ -265,22 +243,22 @@ export class InstanceStatsService {
] = await Promise.all([
this.getToggleCount(),
this.getArchivedToggleCount(),
- this.userStore.count(),
- this.userStore.countServiceAccounts(),
- this.apiTokenStore.countByType(),
+ this.getRegisteredUsers(),
+ this.countServiceAccounts(),
+ this.countApiTokensByType(),
this.getActiveUsers(),
this.getProjectModeCount(),
- this.contextFieldStore.count(),
- this.groupStore.count(),
- this.roleStore.count(),
- this.roleStore.filteredCount({ type: CUSTOM_ROOT_ROLE_TYPE }),
- this.roleStore.filteredCountInUse({ type: CUSTOM_ROOT_ROLE_TYPE }),
- this.environmentStore.count(),
- this.segmentStore.count(),
- this.strategyStore.count(),
+ this.contextFieldCount(),
+ this.groupCount(),
+ this.roleCount(),
+ this.customRolesCount(),
+ this.customRolesCountInUse(),
+ this.environmentCount(),
+ this.segmentCount(),
+ this.strategiesCount(),
this.hasSAML(),
this.hasOIDC(),
- this.appCount ? this.appCount : this.refreshAppCountSnapshot(),
+ this.appCount ? this.appCount : this.getLabeledAppCounts(),
this.eventStore.deprecatedFilteredCount({
type: FEATURES_EXPORTED,
}),
@@ -288,7 +266,7 @@ export class InstanceStatsService {
type: FEATURES_IMPORTED,
}),
this.getProductionChanges(),
- this.clientMetricsStore.countPreviousDayHourlyMetricsBuckets(),
+ this.countPreviousDayHourlyMetricsBuckets(),
this.featureStrategiesReadModel.getMaxFeatureEnvironmentStrategies(),
this.featureStrategiesReadModel.getMaxConstraintValues(),
this.featureStrategiesReadModel.getMaxConstraintsPerStrategy(),
@@ -330,6 +308,59 @@ export class InstanceStatsService {
};
}
+ groupCount(): Promise {
+ return this.groupStore.count();
+ }
+
+ roleCount(): Promise {
+ return this.roleStore.count();
+ }
+
+ customRolesCount(): Promise {
+ return this.roleStore.filteredCount({ type: CUSTOM_ROOT_ROLE_TYPE });
+ }
+
+ customRolesCountInUse(): Promise {
+ return this.roleStore.filteredCountInUse({
+ type: CUSTOM_ROOT_ROLE_TYPE,
+ });
+ }
+
+ segmentCount(): Promise {
+ return this.segmentStore.count();
+ }
+
+ contextFieldCount(): Promise {
+ return this.contextFieldStore.count();
+ }
+
+ strategiesCount(): Promise {
+ return this.strategyStore.count();
+ }
+
+ environmentCount(): Promise {
+ return this.environmentStore.count();
+ }
+
+ countPreviousDayHourlyMetricsBuckets(): Promise<{
+ enabledCount: number;
+ variantCount: number;
+ }> {
+ return this.clientMetricsStore.countPreviousDayHourlyMetricsBuckets();
+ }
+
+ countApiTokensByType(): Promise