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 (
-
- )
+/** 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)}
+
+ )}
+
+ Discard changes
+
+
+ Save
+
+ >
+ )}
+
+
+
+
+
+ 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":