diff --git a/cypress/integration/systemStatus.js b/cypress/integration/systemStatus.js index 1cf440f2a696b..3a0904206a7fa 100644 --- a/cypress/integration/systemStatus.js +++ b/cypress/integration/systemStatus.js @@ -6,7 +6,7 @@ describe('System Status', () => { cy.wait(500) cy.get('[data-attr=top-menu-toggle]').click() cy.get('[data-attr=system-status-badge]').click() - cy.get('h1').should('contain', 'System Status') + cy.get('h1').should('contain', 'Instance status') cy.get('table').should('contain', 'Events in ClickHouse') }) }) diff --git a/frontend/src/layout/navigation/SideBar/SideBar.tsx b/frontend/src/layout/navigation/SideBar/SideBar.tsx index 1df72d74f2f60..4574078c02ba9 100644 --- a/frontend/src/layout/navigation/SideBar/SideBar.tsx +++ b/frontend/src/layout/navigation/SideBar/SideBar.tsx @@ -17,6 +17,7 @@ import { IconPerson, IconPlus, IconRecording, + IconServer, IconSettings, IconTools, } from 'lib/components/icons' @@ -137,6 +138,7 @@ function Pages(): JSX.Element { const { pinnedDashboards } = useValues(dashboardsModel) const { featureFlags } = useValues(featureFlagLogic) const { showGroupsOptions } = useValues(groupsModel) + const { user } = useValues(userLogic) const { hasAvailableFeature } = useValues(userLogic) const { preflight } = useValues(preflightLogic) @@ -238,6 +240,17 @@ function Pages(): JSX.Element { )} } identifier={Scene.ToolbarLaunch} to={urls.toolbarLaunch()} /> } identifier={Scene.ProjectSettings} to={urls.projectSettings()} /> + {user?.is_staff && ( + <> + + } + identifier={Scene.SystemStatus} + to={urls.instanceStatus()} + /> + + )} ) } diff --git a/frontend/src/layout/navigation/TopBar/SitePopover.tsx b/frontend/src/layout/navigation/TopBar/SitePopover.tsx index 981352fb73780..cfb74252e84af 100644 --- a/frontend/src/layout/navigation/TopBar/SitePopover.tsx +++ b/frontend/src/layout/navigation/TopBar/SitePopover.tsx @@ -160,12 +160,12 @@ function SystemStatus(): JSX.Element { {systemStatus ? 'All systems operational' : 'Potential system issue'} - System status + Instance status diff --git a/frontend/src/layout/navigation/TopBar/TopBar.scss b/frontend/src/layout/navigation/TopBar/TopBar.scss index 3d125bc83b0cd..1c8f7bc2676a6 100644 --- a/frontend/src/layout/navigation/TopBar/TopBar.scss +++ b/frontend/src/layout/navigation/TopBar/TopBar.scss @@ -132,7 +132,7 @@ } .SitePopover { - width: 20rem; + max-width: 22rem; } .SitePopover__main-info { diff --git a/frontend/src/lib/components/CommandPalette/commandPaletteLogic.ts b/frontend/src/lib/components/CommandPalette/commandPaletteLogic.ts index c019526a600bb..d158b8103e376 100644 --- a/frontend/src/lib/components/CommandPalette/commandPaletteLogic.ts +++ b/frontend/src/lib/components/CommandPalette/commandPaletteLogic.ts @@ -533,10 +533,10 @@ export const commandPaletteLogic = kea< }, { icon: DatabaseOutlined, - display: 'Go to System status page', + display: 'Go to Instance status & settings', synonyms: ['redis', 'celery', 'django', 'postgres', 'backend', 'service', 'online'], executor: () => { - push(urls.systemStatus()) + push(urls.instanceStatus()) }, }, { diff --git a/frontend/src/lib/components/InfoMessage/AlertMessage.scss b/frontend/src/lib/components/InfoMessage/AlertMessage.scss new file mode 100644 index 0000000000000..1257b6b076fe6 --- /dev/null +++ b/frontend/src/lib/components/InfoMessage/AlertMessage.scss @@ -0,0 +1,26 @@ +@import '~/vars'; + +.lemon-alert-message { + background-color: var(--radius-light); + border-radius: var(--radius); + padding: 0.5rem 1rem; + color: $primary_alt; + font-weight: 500; + display: flex; + align-items: center; + + .lemon-alert-message__icon { + flex-shrink: 0; + font-size: 1.5rem; + margin-right: 0.5rem; + } + + &.info { + background-color: rgba($primary_alt, 0.09); + } + + &.warning { + background-color: rgba($warning, 0.09); + color: $warning; + } +} diff --git a/frontend/src/lib/components/InfoMessage/AlertMessage.tsx b/frontend/src/lib/components/InfoMessage/AlertMessage.tsx new file mode 100644 index 0000000000000..e347ed203a349 --- /dev/null +++ b/frontend/src/lib/components/InfoMessage/AlertMessage.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import './AlertMessage.scss' +import { WarningOutlined } from '@ant-design/icons' +import { IconInfo } from '../icons' +import clsx from 'clsx' + +export interface AlertMessageInterface { + children: string | JSX.Element + style?: React.CSSProperties + type?: 'info' | 'warning' +} + +/** Generic alert message. Substitutes Ant's `Alert` component. */ +export function AlertMessage({ children, style, type = 'info' }: AlertMessageInterface): JSX.Element { + return ( +
+
{type === 'warning' ? : }
+
{children}
+
+ ) +} diff --git a/frontend/src/lib/components/InfoMessage/InfoMessage.scss b/frontend/src/lib/components/InfoMessage/InfoMessage.scss deleted file mode 100644 index 8506c5046a32c..0000000000000 --- a/frontend/src/lib/components/InfoMessage/InfoMessage.scss +++ /dev/null @@ -1,17 +0,0 @@ -@import '~/vars'; - -.info-message { - background-color: rgba($primary_alt, 0.09); - border-radius: var(--radius); - padding: 0.5rem 1rem; - color: $primary_alt; - font-weight: 500; - display: flex; - align-items: center; -} - -.info-message__icon { - flex-shrink: 0; - font-size: 1.5rem; - margin-right: 0.5rem; -} diff --git a/frontend/src/lib/components/InfoMessage/InfoMessage.tsx b/frontend/src/lib/components/InfoMessage/InfoMessage.tsx index 1f28d87d961db..674d3f9e2a29a 100644 --- a/frontend/src/lib/components/InfoMessage/InfoMessage.tsx +++ b/frontend/src/lib/components/InfoMessage/InfoMessage.tsx @@ -1,19 +1,7 @@ import React from 'react' -import './InfoMessage.scss' -import { IconInfo } from '../icons' +import { AlertMessage, AlertMessageInterface } from './AlertMessage' -/** An informative message. */ -export function InfoMessage({ - children, - style, -}: { - children: string | JSX.Element - style?: React.CSSProperties -}): JSX.Element { - return ( -
- -
{children}
-
- ) +/** DEPRECATED: Use `AlertMessage` instead with type = 'info' */ +export function InfoMessage(props: AlertMessageInterface): JSX.Element { + return } diff --git a/frontend/src/lib/components/icons.tsx b/frontend/src/lib/components/icons.tsx index 8ed856847529b..00f0c07779b9c 100644 --- a/frontend/src/lib/components/icons.tsx +++ b/frontend/src/lib/components/icons.tsx @@ -321,6 +321,19 @@ export function IconSettings({ style }: { style?: CSSProperties }): JSX.Element ) } +/** Material Design Dns icon. */ +export function IconServer({ style }: { style?: CSSProperties }): JSX.Element { + return ( + + + + + ) +} + /** Material Design Menu icon. */ export function IconMenu(): JSX.Element { return ( diff --git a/frontend/src/scenes/PreflightCheck/logic.ts b/frontend/src/scenes/PreflightCheck/logic.ts index 680835d900ca9..f7ce394992578 100644 --- a/frontend/src/scenes/PreflightCheck/logic.ts +++ b/frontend/src/scenes/PreflightCheck/logic.ts @@ -7,7 +7,13 @@ import { getAppContext } from 'lib/utils/getAppContext' type PreflightMode = 'experimentation' | 'live' -export const preflightLogic = kea>({ +export interface EnvironmentConfigOption { + key: string + metric: string + value: string +} + +export const preflightLogic = kea>({ path: ['scenes', 'PreflightCheck', 'preflightLogic'], loaders: { preflight: [ @@ -52,7 +58,7 @@ export const preflightLogic = kea>({ ], configOptions: [ (s) => [s.preflight], - (preflight): Record[] => { + (preflight): EnvironmentConfigOption[] => { // Returns the preflight config options to display in the /instance/status page const RELEVANT_CONFIGS = [ @@ -60,14 +66,17 @@ export const preflightLogic = kea>({ key: 'site_url', label: 'Site URL', }, - { key: 'email_service_available', label: 'Email service available' }, ] if (!preflight) { return [] } // @ts-ignore - return RELEVANT_CONFIGS.map((config) => ({ metric: config.label, value: preflight[config.key] })) + return RELEVANT_CONFIGS.map((config) => ({ + key: config.key, + metric: config.label, + value: preflight[config.key], + })) }, ], }, diff --git a/frontend/src/scenes/instance/AsyncMigrations/asyncMigrationsLogic.ts b/frontend/src/scenes/instance/AsyncMigrations/asyncMigrationsLogic.ts index d010b84bbe701..005c7d4d9ed0d 100644 --- a/frontend/src/scenes/instance/AsyncMigrations/asyncMigrationsLogic.ts +++ b/frontend/src/scenes/instance/AsyncMigrations/asyncMigrationsLogic.ts @@ -172,6 +172,7 @@ export const asyncMigrationsLogic = kea< } }, updateSetting: async ({ settingKey, newValue }) => { + // TODO: Use systemStatusLogic.ts for consistency try { await api.update(`/api/instance_settings/${settingKey}`, { value: newValue, diff --git a/frontend/src/scenes/instance/SystemStatus/InstanceConfigSaveModal.tsx b/frontend/src/scenes/instance/SystemStatus/InstanceConfigSaveModal.tsx new file mode 100644 index 0000000000000..2dc6b6c63feb7 --- /dev/null +++ b/frontend/src/scenes/instance/SystemStatus/InstanceConfigSaveModal.tsx @@ -0,0 +1,91 @@ +import { Modal } from 'antd' +import { useActions, useValues } from 'kea' +import { AlertMessage } from 'lib/components/InfoMessage/AlertMessage' +import { pluralize } from 'lib/utils' +import React from 'react' +import { RenderMetricValue } from './RenderMetricValue' +import { MetricRow, systemStatusLogic } from './systemStatusLogic' + +interface ChangeRowInterface extends Pick { + oldValue: any + metricKey: string +} + +function ChangeRow({ metricKey, oldValue, value }: ChangeRowInterface): JSX.Element | null { + if (value.toString() === oldValue.toString()) { + return null + } + + return ( +
+
+ {metricKey} +
+
+ Value will be changed from{' '} + + {RenderMetricValue({ key: metricKey, value: oldValue })} + {' '} + to{' '} + + {RenderMetricValue({ key: metricKey, value })} + +
+
+ ) +} + +export function InstanceConfigSaveModal({ onClose }: { onClose: () => void }): JSX.Element { + const { instanceConfigEditingState, editableInstanceSettings, updatedInstanceConfigCount } = + useValues(systemStatusLogic) + const { saveInstanceConfig } = useActions(systemStatusLogic) + const loading = updatedInstanceConfigCount !== null + return ( + + {Object.keys(instanceConfigEditingState).find((key) => key.startsWith('EMAIL')) && ( + + <> + As you are changing email settings, we'll attempt to send a test email so you can verify + everything works (unless you are turning email off). + + + )} + {Object.keys(instanceConfigEditingState).includes('RECORDINGS_TTL_WEEKS') && ( + + <> + Changing your recordings TTL requires ClickHouse to have enough free space to perform the + operation (even when reducing this value). In addition, please mind that removing old recordings + will be removed asynchronously, not immediately. + + + )} +
The following changes will be immediately applied to your instance.
+ {Object.keys(instanceConfigEditingState).map((key) => ( + record.key === key)?.value} + /> + ))} + {loading && ( +
+ {pluralize(updatedInstanceConfigCount || 0, 'change')} updated successfully. +
+ )} +
+ ) +} diff --git a/frontend/src/scenes/instance/SystemStatus/InstanceConfigTab.tsx b/frontend/src/scenes/instance/SystemStatus/InstanceConfigTab.tsx new file mode 100644 index 0000000000000..0d34dcb01e19a --- /dev/null +++ b/frontend/src/scenes/instance/SystemStatus/InstanceConfigTab.tsx @@ -0,0 +1,170 @@ +import { Button } from 'antd' +import { useActions, useValues } from 'kea' +import { HotkeyButton } from 'lib/components/HotkeyButton/HotkeyButton' +import { IconOpenInNew } from 'lib/components/icons' +import { LemonTable, LemonTableColumns } from 'lib/components/LemonTable' +import { useKeyboardHotkeys } from 'lib/hooks/useKeyboardHotkeys' +import React, { useEffect } from 'react' +import { EnvironmentConfigOption, preflightLogic } from 'scenes/PreflightCheck/logic' +import { InstanceSetting } from '~/types' +import { RenderMetricValue } from './RenderMetricValue' +import { RenderMetricValueEdit } from './RenderMetricValueEdit' +import { ConfigMode, systemStatusLogic } from './systemStatusLogic' +import { WarningOutlined } from '@ant-design/icons' +import { InstanceConfigSaveModal } from './InstanceConfigSaveModal' +import { pluralize } from 'lib/utils' + +export function InstanceConfigTab(): JSX.Element { + const { configOptions, preflightLoading } = useValues(preflightLogic) + const { editableInstanceSettings, instanceSettingsLoading, instanceConfigMode, instanceConfigEditingState } = + useValues(systemStatusLogic) + const { loadInstanceSettings, setInstanceConfigMode, updateInstanceConfigValue, clearInstanceConfigEditing } = + useActions(systemStatusLogic) + + useKeyboardHotkeys({ + e: { + action: () => setInstanceConfigMode(ConfigMode.Edit), + disabled: instanceConfigMode !== ConfigMode.View || instanceSettingsLoading, + }, + escape: { + action: () => discard(), + disabled: instanceConfigMode !== ConfigMode.Edit || instanceSettingsLoading, + }, + enter: { + action: () => save(), + disabled: instanceConfigMode !== ConfigMode.Edit || instanceSettingsLoading, + }, + }) + + const save = (): void => { + if (Object.keys(instanceConfigEditingState).length) { + setInstanceConfigMode(ConfigMode.Saving) + } else { + setInstanceConfigMode(ConfigMode.View) + } + } + + const discard = (): void => { + setInstanceConfigMode(ConfigMode.View) + clearInstanceConfigEditing() + } + + useEffect(() => { + loadInstanceSettings() + }, []) + + const columns: LemonTableColumns = [ + { + title: 'Key', + dataIndex: 'key', + render: function render(value) { + return {value} + }, + }, + { + title: 'Description', + dataIndex: 'description', + }, + { + title: 'Value', + render: function renderValue(_, record) { + const props = { + value: record.value, + key: record.key, + emptyNullLabel: 'Unset', + value_type: record.value_type, + } + return instanceConfigMode === ConfigMode.View + ? RenderMetricValue(props) + : RenderMetricValueEdit({ + ...props, + value: instanceConfigEditingState[record.key] ?? record.value, + onValueChanged: updateInstanceConfigValue, + }) + }, + width: 300, + }, + ] + + const envColumns: LemonTableColumns = [ + { + key: 'metric', + title: 'Metric', + dataIndex: 'metric', + }, + { + title: 'Value', + dataIndex: 'value', + }, + ] + + return ( +
+
+
+

+ Instance configuration +

+
+ Changing these settings will take effect on your entire instance.{' '} + + Learn more + + . +
+
+ {instanceConfigMode === ConfigMode.View ? ( + <> + setInstanceConfigMode(ConfigMode.Edit)} + data-attr="instance-config-edit-button" + hotkey="e" + disabled={instanceSettingsLoading} + > + Edit + + + ) : ( + <> + {Object.keys(instanceConfigEditingState).length > 0 && ( + + You have {Object.keys(instanceConfigEditingState).length}{' '} + unapplied{' '} + {pluralize(Object.keys(instanceConfigEditingState).length, 'change', undefined, false)} + + )} + + + + )} +
+ + + +

+ Environment configuration +

+
+ These settings can only be modified by environment variables.{' '} + + Learn more + + . +
+ + {instanceConfigMode === ConfigMode.Saving && ( + setInstanceConfigMode(ConfigMode.Edit)} /> + )} +
+ ) +} diff --git a/frontend/src/scenes/instance/SystemStatus/OverviewTab.tsx b/frontend/src/scenes/instance/SystemStatus/OverviewTab.tsx index c51621a025e51..2791779375427 100644 --- a/frontend/src/scenes/instance/SystemStatus/OverviewTab.tsx +++ b/frontend/src/scenes/instance/SystemStatus/OverviewTab.tsx @@ -1,50 +1,16 @@ import React from 'react' -import { Table, Tag, Card } from 'antd' -import { systemStatusLogic } from './systemStatusLogic' +import { Table, Card } from 'antd' +import { MetricRow, systemStatusLogic } from './systemStatusLogic' import { useValues } from 'kea' import { SystemStatusSubrows } from '~/types' -import { preflightLogic } from 'scenes/PreflightCheck/logic' import { IconOpenInNew } from 'lib/components/icons' import { Link } from 'lib/components/Link' -import { humanFriendlyDetailedTime } from 'lib/utils' - -interface MetricRow { - metric: string - key: string - value: any -} +import { RenderMetricValue } from './RenderMetricValue' const METRIC_KEY_TO_INTERNAL_LINK = { async_migrations_ok: '/instance/async_migrations', } -const TIMESTAMP_VALUES = new Set(['last_event_ingested_timestamp']) - -function RenderValue(metricRow: MetricRow): JSX.Element | string { - const value = metricRow.value - - if (TIMESTAMP_VALUES.has(metricRow.key)) { - if (new Date(value).getTime() === new Date('1970-01-01T00:00:00').getTime()) { - return 'Never' - } - return humanFriendlyDetailedTime(value) - } - - if (typeof value === 'boolean') { - return {value ? 'Yes' : 'No'} - } - - if (typeof value === 'number') { - return value.toLocaleString('en-US') - } - - if (value === null || value === undefined || value === '') { - return Unknown - } - - return value.toString() -} - function RenderMetric(metricRow: MetricRow): JSX.Element { return ( @@ -60,7 +26,6 @@ function RenderMetric(metricRow: MetricRow): JSX.Element { export function OverviewTab(): JSX.Element { const { overview, systemStatusLoading } = useValues(systemStatusLogic) - const { configOptions, preflightLoading } = useValues(preflightLogic) const columns = [ { @@ -70,7 +35,7 @@ export function OverviewTab(): JSX.Element { }, { title: 'Value', - render: RenderValue, + render: RenderMetricValue, }, ] @@ -94,28 +59,6 @@ export function OverviewTab(): JSX.Element { expandRowByClick: true, }} /> - -

- Configuration options -

-

- - Learn more - -

- ) diff --git a/frontend/src/scenes/instance/SystemStatus/RenderMetricValue.tsx b/frontend/src/scenes/instance/SystemStatus/RenderMetricValue.tsx new file mode 100644 index 0000000000000..726cc384dc18e --- /dev/null +++ b/frontend/src/scenes/instance/SystemStatus/RenderMetricValue.tsx @@ -0,0 +1,40 @@ +import { LemonTag } from 'lib/components/LemonTag/LemonTag' +import { humanFriendlyDetailedTime } from 'lib/utils' +import React from 'react' +import { InstanceSetting } from '~/types' +import { MetricRow } from './systemStatusLogic' + +const TIMESTAMP_VALUES = new Set(['last_event_ingested_timestamp']) + +type BaseValueInterface = Pick & Partial> +export interface MetricValueInterface extends BaseValueInterface { + emptyNullLabel?: string +} + +export function RenderMetricValue({ + key, + value, + value_type, + emptyNullLabel, +}: MetricValueInterface): JSX.Element | string { + if (TIMESTAMP_VALUES.has(key)) { + if (new Date(value).getTime() === new Date('1970-01-01T00:00:00').getTime()) { + return 'Never' + } + return humanFriendlyDetailedTime(value) + } + + if (value_type === 'bool' || typeof value === 'boolean') { + return {value ? 'Yes' : 'No'} + } + + if (value_type === 'int' || typeof value === 'number') { + return value.toLocaleString('en-US') + } + + if (value === null || value === undefined || value === '') { + return {emptyNullLabel ?? 'Unknown'} + } + + return value.toString() +} diff --git a/frontend/src/scenes/instance/SystemStatus/RenderMetricValueEdit.tsx b/frontend/src/scenes/instance/SystemStatus/RenderMetricValueEdit.tsx new file mode 100644 index 0000000000000..7e1bebb99e0d1 --- /dev/null +++ b/frontend/src/scenes/instance/SystemStatus/RenderMetricValueEdit.tsx @@ -0,0 +1,34 @@ +import { Checkbox, Input } from 'antd' +import { LemonTag } from 'lib/components/LemonTag/LemonTag' +import React from 'react' +import { MetricValueInterface } from './RenderMetricValue' + +interface MetricValueEditInterface extends MetricValueInterface { + onValueChanged: (key: string, value: any) => void +} + +export function RenderMetricValueEdit({ + key, + value, + value_type, + onValueChanged, +}: MetricValueEditInterface): JSX.Element | string { + if (value_type === 'bool') { + return ( + <> + onValueChanged(key, e.target.checked)} /> + + {value ? 'Yes' : 'No'} + + + ) + } + + return ( + onValueChanged(key, e.target.value)} + /> + ) +} diff --git a/frontend/src/scenes/instance/SystemStatus/index.tsx b/frontend/src/scenes/instance/SystemStatus/index.tsx index e5c3dfd84350d..a6ce157e19907 100644 --- a/frontend/src/scenes/instance/SystemStatus/index.tsx +++ b/frontend/src/scenes/instance/SystemStatus/index.tsx @@ -2,7 +2,7 @@ import './index.scss' import React from 'react' import { Alert, Tabs } from 'antd' -import { systemStatusLogic, TabName } from './systemStatusLogic' +import { systemStatusLogic, InstanceStatusTabName } from './systemStatusLogic' import { useActions, useValues } from 'kea' import { PageHeader } from 'lib/components/PageHeader' import { preflightLogic } from 'scenes/PreflightCheck/logic' @@ -10,6 +10,9 @@ import { IconOpenInNew } from 'lib/components/icons' import { OverviewTab } from 'scenes/instance/SystemStatus/OverviewTab' import { InternalMetricsTab } from 'scenes/instance/SystemStatus/InternalMetricsTab' import { SceneExport } from 'scenes/sceneTypes' +import { InstanceConfigTab } from './InstanceConfigTab' +import { userLogic } from 'scenes/userLogic' +import { LemonTag } from 'lib/components/LemonTag/LemonTag' export const scene: SceneExport = { component: SystemStatus, @@ -20,12 +23,26 @@ export function SystemStatus(): JSX.Element { const { tab, error, systemStatus } = useValues(systemStatusLogic) const { setTab } = useActions(systemStatusLogic) const { preflight, siteUrlMisconfigured } = useValues(preflightLogic) + const { user } = useValues(userLogic) return (
+ Here you can find all the critical runtime details and settings of your PostHog instance. You + have access to this because you're a staff user.{' '} + + Learn more + + . + + } /> {error && ( )} - {systemStatus?.internal_metrics.clickhouse ? ( - setTab(key as TabName)}> - - + setTab(key as InstanceStatusTabName)} + > + + + + {systemStatus?.internal_metrics.clickhouse || + (true && ( + + + + ))} + {user?.is_staff && ( + + Settings Beta + + } + key="settings" + > + - - - - - ) : ( - - )} + )} +
) } diff --git a/frontend/src/scenes/instance/SystemStatus/systemStatusLogic.ts b/frontend/src/scenes/instance/SystemStatus/systemStatusLogic.ts index 177e3cf740090..be6dd64106f3d 100644 --- a/frontend/src/scenes/instance/SystemStatus/systemStatusLogic.ts +++ b/frontend/src/scenes/instance/SystemStatus/systemStatusLogic.ts @@ -10,22 +10,58 @@ import { OrganizationType, UserType, PreflightStatus, + InstanceSetting, } from '~/types' import { preflightLogic } from 'scenes/PreflightCheck/logic' import { organizationLogic } from 'scenes/organizationLogic' import { OrganizationMembershipLevel } from 'lib/constants' -import { isUserLoggedIn } from 'lib/utils' +import { errorToast, isUserLoggedIn, successToast } from 'lib/utils' -export type TabName = 'overview' | 'internal_metrics' +export enum ConfigMode { + View = 'view', + Edit = 'edit', + Saving = 'saving', +} +export interface MetricRow { + metric: string + key: string + value: any +} -export const systemStatusLogic = kea>({ +export type InstanceStatusTabName = 'overview' | 'metrics' | 'settings' + +/** + * We whitelist the specific instance settings that can be edited via the /instance/status page. + * Even if some settings are editable in the frontend according to the API, we may don't want to expose them here. + * For example: async migrations settings are handled in their own page. + */ +const EDITABLE_INSTANCE_SETTINGS = [ + 'RECORDINGS_TTL_WEEKS', + 'EMAIL_ENABLED', + 'EMAIL_HOST', + 'EMAIL_PORT', + 'EMAIL_HOST_USER', + 'EMAIL_HOST_PASSWORD', + 'EMAIL_USE_TLS', + 'EMAIL_USE_SSL', + 'EMAIL_DEFAULT_FROM', + 'EMAIL_REPLY_TO', +] + +export const systemStatusLogic = kea>({ path: ['scenes', 'instance', 'SystemStatus', 'systemStatusLogic'], actions: { - setTab: (tab: TabName) => ({ tab }), + setTab: (tab: InstanceStatusTabName) => ({ tab }), setOpenSections: (sections: string[]) => ({ sections }), setAnalyzeModalOpen: (isOpen: boolean) => ({ isOpen }), setAnalyzeQuery: (query: string) => ({ query }), openAnalyzeModalWithQuery: (query: string) => ({ query }), + setInstanceConfigMode: (mode: ConfigMode) => ({ mode }), + updateInstanceConfigValue: (key: string, value?: string | boolean | number) => ({ key, value }), + clearInstanceConfigEditing: true, + saveInstanceConfig: true, + setUpdatedInstanceConfigCount: (count: number | null) => ({ count }), + increaseUpdatedInstanceConfigCount: true, }, loaders: ({ values }) => ({ systemStatus: [ @@ -45,6 +81,14 @@ export const systemStatusLogic = kea>({ }, }, ], + instanceSettings: [ + [] as InstanceSetting[], + { + loadInstanceSettings: async () => { + return (await api.get('api/instance_settings')).results ?? [] + }, + }, + ], queries: [ null as SystemStatusQueriesResult | null, { @@ -64,7 +108,7 @@ export const systemStatusLogic = kea>({ }), reducers: { tab: [ - 'overview' as TabName, + 'overview' as InstanceStatusTabName, { setTab: (_, { tab }) => tab, }, @@ -96,6 +140,35 @@ export const systemStatusLogic = kea>({ openAnalyzeModalWithQuery: (_, { query }) => query, }, ], + instanceConfigMode: [ + // Determines whether the Instance Configuration table on "Configuration" tab is on edit or view mode + ConfigMode.View, + { + setInstanceConfigMode: (_, { mode }) => mode, + }, + ], + instanceConfigEditingState: [ + {} as Record, + { + updateInstanceConfigValue: (s, { key, value }) => { + if (value) { + return { ...s, [key]: value } + } + const newState = { ...s } + delete newState[key] + return newState + }, + clearInstanceConfigEditing: () => ({}), + }, + ], + updatedInstanceConfigCount: [ + null as number | null, // Number of config items that have been updated; `null` means no update is in progress + { + setUpdatedInstanceConfigCount: (_, { count }) => count, + loadInstanceSettings: () => null, + increaseUpdatedInstanceConfigCount: (state) => (state ?? 0) + 1, + }, + ], }, selectors: () => ({ @@ -116,13 +189,55 @@ export const systemStatusLogic = kea>({ return !!org?.membership_level && org.membership_level >= OrganizationMembershipLevel.Admin }, ], + editableInstanceSettings: [ + (s) => [s.instanceSettings], + (instanceSettings): InstanceSetting[] => + instanceSettings.filter((item) => item.editable && EDITABLE_INSTANCE_SETTINGS.includes(item.key)), + ], }), - listeners: ({ actions }) => ({ - setTab: ({ tab }: { tab: TabName }) => { - if (tab === 'internal_metrics') { + listeners: ({ actions, values }) => ({ + setTab: ({ tab }: { tab: InstanceStatusTabName }) => { + if (tab === 'metrics') { actions.loadQueries() } + actions.setInstanceConfigMode(ConfigMode.View) + }, + updateInstanceConfigValue: ({ key, value }) => { + if ( + value && + values.editableInstanceSettings.find((item) => item.key === key)?.value.toString() === value.toString() + ) { + actions.updateInstanceConfigValue(key, undefined) + } + }, + saveInstanceConfig: async (_, breakpoint) => { + actions.setUpdatedInstanceConfigCount(0) + Object.entries(values.instanceConfigEditingState).map(async ([key, value]) => { + try { + await api.update(`api/instance_settings/${key}`, { + value, + }) + actions.increaseUpdatedInstanceConfigCount() + } catch { + errorToast( + 'Error updating settings', + 'There was an error updating all your settings. Please try again.' + ) + await breakpoint(1000) + actions.loadInstanceSettings() + } + }) + await breakpoint(1000) + if (values.updatedInstanceConfigCount === Object.keys(values.instanceConfigEditingState).length) { + actions.loadInstanceSettings() + actions.clearInstanceConfigEditing() + actions.setInstanceConfigMode(ConfigMode.View) + successToast( + 'Instance settings updated!', + 'Your settings have been updated and should take effect soon.' + ) + } }, }), @@ -133,12 +248,13 @@ export const systemStatusLogic = kea>({ }), actionToUrl: ({ values }) => ({ - setTab: () => '/instance/status' + (values.tab === 'overview' ? '' : '/' + values.tab), + setTab: () => '/instance/' + (values.tab === 'overview' ? 'status' : values.tab), }), urlToAction: ({ actions, values }) => ({ - '/instance/status(/:tab)': ({ tab }: { tab?: TabName }) => { - const currentTab = tab || 'overview' + '/instance(/:tab)': ({ tab }: { tab?: InstanceStatusTabName }) => { + const currentTab = tab && ['metrics', 'settings'].includes(tab) ? tab : 'overview' + console.log(currentTab) if (currentTab !== values.tab) { actions.setTab(currentTab) } diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index ebc60b92afc69..b7a159ceb0f84 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -172,6 +172,7 @@ export const sceneConfigurations: Partial> = { // Instance management routes [Scene.SystemStatus]: { instanceLevel: true, + name: 'Instance status & settings', }, [Scene.Licenses]: { instanceLevel: true, @@ -241,8 +242,9 @@ export const routes: Record = { [urls.billingSubscribed()]: Scene.BillingSubscribed, [urls.organizationCreateFirst()]: Scene.OrganizationCreateFirst, [urls.instanceLicenses()]: Scene.Licenses, - [urls.systemStatus()]: Scene.SystemStatus, - [urls.systemStatusPage(':id')]: Scene.SystemStatus, + [urls.instanceStatus()]: Scene.SystemStatus, + [urls.instanceSettings()]: Scene.SystemStatus, + [urls.instanceMetrics()]: Scene.SystemStatus, [urls.asyncMigrations()]: Scene.AsyncMigrations, [urls.deadLetterQueue()]: Scene.DeadLetterQueue, [urls.mySettings()]: Scene.MySettings, diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index 2e36ec2d02896..988b40043ebdd 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -58,8 +58,9 @@ export const urls = { billingSubscribed: () => '/organization/billing/subscribed', // Self-hosted only instanceLicenses: () => '/instance/licenses', - systemStatus: () => '/instance/status', - systemStatusPage: (page: string) => `/instance/status/${page}`, + instanceStatus: () => '/instance/status', + instanceSettings: () => '/instance/settings', + instanceMetrics: () => `/instance/metrics`, asyncMigrations: () => '/instance/async_migrations', deadLetterQueue: () => '/instance/dead_letter_queue', } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 51be86a200481..6a1961c3556a1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1333,6 +1333,7 @@ export type HotKeys = | 'y' | 'z' | 'escape' + | 'enter' export interface LicenseType { id: number diff --git a/posthog/api/instance_settings.py b/posthog/api/instance_settings.py index 0d11f21961f58..0e4a5ff6109db 100644 --- a/posthog/api/instance_settings.py +++ b/posthog/api/instance_settings.py @@ -58,11 +58,14 @@ def update(self, instance: InstanceSetting, validated_data: Dict[str, Any]) -> I if instance.key not in SETTINGS_ALLOWING_API_OVERRIDE: raise serializers.ValidationError("This setting cannot be updated from the API.", code="no_api_override") - if not validated_data["value"]: + if validated_data["value"] is None: raise serializers.ValidationError({"value": "This field is required."}, code="required") target_type = settings.CONFIG[instance.key][2] - new_value_parsed = cast_str_to_desired_type(validated_data["value"], target_type) + if target_type == "bool" and isinstance(validated_data["value"], bool): + new_value_parsed = validated_data["value"] + else: + new_value_parsed = cast_str_to_desired_type(validated_data["value"], target_type) if instance.key == "RECORDINGS_TTL_WEEKS":