Repository: PostHog/posthog
Pull request: Instance status configuration
## Changes

- You can now configure your instance through PostHog's UI.
- Closes https://github.com/PostHog/charts-clickhouse/issues/114


<img width="1511" alt="" src="https://user-images.githubusercontent.com/5864173/153198218-d7eb1373-9ba2-4109-81ff-e68018b4aeb5.png">


**Nice confirmation screen before saving**
    <img width="563" alt="" src="https://user-images.githubusercontent.com/5864173/153198296-66ad2741-2c56-4b44-b593-57d72a4ec6d6.png">


**Warning when changing recordings TTL (@rcmarron)**
    <img width="581" alt="" src="https://user-images.githubusercontent.com/5864173/153198342-c0da48d1-7fa2-4381-90b7-4b2a77e38987.png">


**Info when changing email settings**
    <img width="581" alt="" src="https://user-images.githubusercontent.com/5864173/153198389-07777089-9c32-4a8a-a8be-8497d7598eac.png">



## How did you test this code?
- Open the instance settings page with your user `is_staff = True`.
- Test updating single setting.
- Test updating multiple settings.
- Test updating boolean, number & string values.


Problem statement:
Email setup from the PostHog app
## Is your feature request related to a problem?

Currently in order to have email available for password reset etc it needs to be set in the values.

## Describe the solution you'd like

Ability to set the email config in the PostHog app.

## Describe alternatives you've considered

As is.

## Additional context

In order for the email to stick around during upgrades etc we'll need to store the account info in the (Postgres) DB as we can't create a k8s secret from the app afaik.

#### *Thank you* for your feature request – we love each and every one!

Hints from issue discussion:
@tiina303 can you add more info about this? Which secret are we trying to create? 
@paolodamico has been working on this, not sure if there was an issue tracking it already?
@hazzadous I'm inclined to close this as part of https://github.com/PostHog/charts-clickhouse/pull/286. What do you think?
I think this is actually a different issue @guidoiaquinti , assuming that the other linked PR is relevant. i.e. the desire is to setup email via the app UI(?)
Yes but I think it was to avoid passing email settings via env variables, right @tiina303 ?

If yes, this issue should be in `posthog/posthog`
I already have a PR that accomplishes this almost ready to go. Just need to do some final cleanup. 
In order to not need to pass email via envs here we needed it to be in the app. Not sure if we should keep the email/pass env option in the chart - it would be nice to be able to put them there for folks who setup multiple instances via automation but having a password as raw text might not be what we want to be doing. Happy to leave this for @guidoiaquinti & @hazzadous to decide.
@tiina303 it's great for reproducible environments, which is the reason I put in the fix. The fix I've put in is actually to make it such that we don't need to expose CI for deploy at least to these secrets, and rather [provision a k8s secret separately](https://github.com/PostHog/posthog-cloud-infra/blob/main/terraform/modules/posthog/main.tf#L107)

Having these passed as env vars to the app I think is ok, but maybe there's a better way?
I also think we should keep the ability to configure email settings via ENV variables as it’s good for several use cases.

My main question is if: 

> Ability to set the email config in the PostHog app.

is something belonging to this repo or not? My take is that it doesn’t as the solution described in this ticket is a PostHog app feature. In that case, we should simply close this issue.

Commit message: post-merge fixes

Patch to review:
diff --git a/cypress/integration/systemStatus.js b/cypress/integration/systemStatus.js
index 1cf440f2a696b..9a0c52d3b6f06 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 settings')
         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..53d5888f8e48d 100644
--- a/frontend/src/layout/navigation/SideBar/SideBar.tsx
+++ b/frontend/src/layout/navigation/SideBar/SideBar.tsx
@@ -3,7 +3,7 @@ import { useActions, useValues } from 'kea'
 import { Link } from 'lib/components/Link'
 import React, { useState } from 'react'
 import { sceneConfigurations } from 'scenes/scenes'
-import { PushpinOutlined } from '@ant-design/icons'
+import { PushpinOutlined, DatabaseOutlined } from '@ant-design/icons'
 import { ProjectSwitcherOverlay } from '~/layout/navigation/ProjectSwitcher'
 import {
     IconBarChart,
@@ -137,6 +137,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 +239,17 @@ function Pages(): JSX.Element {
             )}
             <PageButton icon={<IconTools />} identifier={Scene.ToolbarLaunch} to={urls.toolbarLaunch()} />
             <PageButton icon={<IconSettings />} identifier={Scene.ProjectSettings} to={urls.projectSettings()} />
+            {user?.is_staff && (
+                <>
+                    <LemonSpacer />
+                    <PageButton
+                        title="Instance settings"
+                        icon={<DatabaseOutlined />}
+                        identifier={Scene.SystemStatus}
+                        to={urls.systemStatus('configuration')}
+                    />
+                </>
+            )}
         </div>
     )
 }
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 (
+        <div className={clsx('lemon-alert-message', type)} style={style}>
+            <div className="lemon-alert-message__icon"> {type === 'warning' ? <WarningOutlined /> : <IconInfo />}</div>
+            <div>{children}</div>
+        </div>
+    )
+}
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 (
-        <div className="info-message" style={style}>
-            <IconInfo className="info-message__icon" />
-            <div>{children}</div>
-        </div>
-    )
+/** DEPRECATED: Use `AlertMessage` instead with type = 'info' */
+export function InfoMessage(props: AlertMessageInterface): JSX.Element {
+    return <AlertMessage {...props} />
 }
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<preflightLogicType<PreflightMode>>({
+export interface EnvironmentConfigOption {
+    key: string
+    metric: string
+    value: string
+}
+
+export const preflightLogic = kea<preflightLogicType<EnvironmentConfigOption, PreflightMode>>({
     path: ['scenes', 'PreflightCheck', 'preflightLogic'],
     loaders: {
         preflight: [
@@ -52,7 +58,7 @@ export const preflightLogic = kea<preflightLogicType<PreflightMode>>({
         ],
         configOptions: [
             (s) => [s.preflight],
-            (preflight): Record<string, string>[] => {
+            (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<preflightLogicType<PreflightMode>>({
                         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..ae3a59f504bbc
--- /dev/null
+++ b/frontend/src/scenes/instance/SystemStatus/InstanceConfigSaveModal.tsx
@@ -0,0 +1,86 @@
+import { Modal } from 'antd'
+import { useActions, useValues } from 'kea'
+import { AlertMessage } from 'lib/components/InfoMessage/AlertMessage'
+import React from 'react'
+import { RenderMetricValue } from './RenderMetricValue'
+import { MetricRow, systemStatusLogic } from './systemStatusLogic'
+
+interface ChangeRowInterface extends Pick<MetricRow, 'value'> {
+    old_value: any
+    metricKey: string
+}
+
+function ChangeRow({ metricKey, old_value, value }: ChangeRowInterface): JSX.Element {
+    return (
+        <div
+            style={{ backgroundColor: 'var(--border-light)', borderRadius: 'var(--radius)', padding: 8, marginTop: 16 }}
+        >
+            <div>
+                <code>{metricKey}</code>
+            </div>
+            <div style={{ color: 'var(--text-muted)' }}>
+                Value will be changed from{' '}
+                <span style={{ color: 'var(--text-default)', fontWeight: 'bold' }}>
+                    {RenderMetricValue({ key: metricKey, value: old_value })}
+                </span>{' '}
+                to{' '}
+                <span style={{ color: 'var(--text-default)', fontWeight: 'bold' }}>
+                    {RenderMetricValue({ key: metricKey, value })}
+                </span>
+            </div>
+        </div>
+    )
+}
+
+export function InstanceConfigSaveModal({ onClose }: { onClose: () => void }): JSX.Element {
+    const { instanceConfigEditingState, editableInstanceSettings, updatedInstanceConfigCount } =
+        useValues(systemStatusLogic)
+    const { saveInstanceConfig } = useActions(systemStatusLogic)
+    const loading = updatedInstanceConfigCount !== null
+    return (
+        <Modal
+            title="Confirm new changes"
+            visible
+            okText="Apply changes"
+            okType="danger"
+            onCancel={onClose}
+            maskClosable={false}
+            onOk={saveInstanceConfig}
+            okButtonProps={{ loading }}
+            cancelButtonProps={{ loading }}
+            closable={!loading}
+        >
+            {Object.keys(instanceConfigEditingState).find((key) => key.startsWith('EMAIL')) && (
+                <AlertMessage style={{ marginBottom: 16 }}>
+                    <>
+                        As you are changing email settings, we'll attempt to send a <b>test email</b> so you can verify
+                        everything works (unless you are turning email off).
+                    </>
+                </AlertMessage>
+            )}
+            {Object.keys(instanceConfigEditingState).includes('RECORDINGS_TTL_WEEKS') && (
+                <AlertMessage style={{ marginBottom: 16 }} type="warning">
+                    <>
+                        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 happen asynchronously.
+                    </>
+                </AlertMessage>
+            )}
+            <div>The following changes will be immediately applied to your instance.</div>
+            {Object.keys(instanceConfigEditingState).map((key) => (
+                <ChangeRow
+                    key={key}
+                    metricKey={key}
+                    value={instanceConfigEditingState[key]}
+                    old_value={editableInstanceSettings.find((record) => record.key === key)?.value}
+                />
+            ))}
+            {loading && (
+                <div className="mt text-success">
+                    <b>{updatedInstanceConfigCount} changes updated successfully.</b>
+                </div>
+            )}
+        </Modal>
+    )
+}
diff --git a/frontend/src/scenes/instance/SystemStatus/InstanceConfigTab.tsx b/frontend/src/scenes/instance/SystemStatus/InstanceConfigTab.tsx
new file mode 100644
index 0000000000000..10d69f7bb1e6f
--- /dev/null
+++ b/frontend/src/scenes/instance/SystemStatus/InstanceConfigTab.tsx
@@ -0,0 +1,167 @@
+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'
+
+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<InstanceSetting> = [
+        {
+            title: 'Key',
+            dataIndex: 'key',
+            render: function render(value) {
+                return <code>{value}</code>
+            },
+        },
+        {
+            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<EnvironmentConfigOption> = [
+        {
+            key: 'metric',
+            title: 'Metric',
+            dataIndex: 'metric',
+        },
+        {
+            title: 'Value',
+            dataIndex: 'value',
+        },
+    ]
+
+    return (
+        <div>
+            <div className="flex-center">
+                <div style={{ flexGrow: 1 }}>
+                    <h3 className="l3" style={{ marginTop: 16 }}>
+                        Instance configuration
+                    </h3>
+                    <div className="mb text-muted">
+                        Changing these settings will take effect on your entire instance.{' '}
+                        <a href="https://posthog.com/docs/self-host/configure/instance-settings" target="_blank">
+                            Learn more
+                        </a>
+                        .
+                    </div>
+                </div>
+                {instanceConfigMode === ConfigMode.View ? (
+                    <>
+                        <HotkeyButton
+                            type="primary"
+                            onClick={() => setInstanceConfigMode(ConfigMode.Edit)}
+                            data-attr="instance-config-edit-button"
+                            hotkey="e"
+                            disabled={instanceSettingsLoading}
+                        >
+                            Edit
+                        </HotkeyButton>
+                    </>
+                ) : (
+                    <>
+                        {Object.keys(instanceConfigEditingState).length > 0 && (
+                            <span style={{ color: 'var(--warning)' }}>
+                                <WarningOutlined /> You have <b>{Object.keys(instanceConfigEditingState).length}</b>{' '}
+                                unapplied changes
+                            </span>
+                        )}
+                        <Button type="link" disabled={instanceSettingsLoading} onClick={discard}>
+                            Discard changes
+                        </Button>
+                        <Button type="primary" disabled={instanceSettingsLoading} onClick={save}>
+                            Save
+                        </Button>
+                    </>
+                )}
+            </div>
+
+            <LemonTable
+                dataSource={editableInstanceSettings}
+                columns={columns}
+                loading={instanceSettingsLoading}
+                rowKey="key"
+            />
+
+            <h3 className="l3" style={{ marginTop: 32 }}>
+                Environment configuration
+            </h3>
+            <p>
+                These settings can only be modified by environment variables.{' '}
+                <a href="https://posthog.com/docs/self-host/configure/environment-variables" target="_blank">
+                    Learn more <IconOpenInNew style={{ verticalAlign: 'middle' }} />
+                </a>
+            </p>
+            <LemonTable dataSource={configOptions} columns={envColumns} loading={preflightLoading} rowKey="key" />
+            {instanceConfigMode === ConfigMode.Saving && (
+                <InstanceConfigSaveModal onClose={() => setInstanceConfigMode(ConfigMode.Edit)} />
+            )}
+        </div>
+    )
+}
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 <Tag color={value ? 'success' : 'error'}>{value ? 'Yes' : 'No'}</Tag>
-    }
-
-    if (typeof value === 'number') {
-        return value.toLocaleString('en-US')
-    }
-
-    if (value === null || value === undefined || value === '') {
-        return <Tag>Unknown</Tag>
-    }
-
-    return value.toString()
-}
-
 function RenderMetric(metricRow: MetricRow): JSX.Element {
     return (
         <span>
@@ -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,
                     }}
                 />
-
-                <h3 className="l3" style={{ marginTop: 32 }}>
-                    Configuration options
-                </h3>
-                <p>
-                    <a
-                        href="https://posthog.com/docs/self-host#Configure?utm_medium=in-product"
-                        rel="noopener"
-                        target="_blank"
-                    >
-                        Learn more <IconOpenInNew style={{ verticalAlign: 'middle' }} />
-                    </a>
-                </p>
-                <Table
-                    className="system-config-table"
-                    size="small"
-                    rowKey="metric"
-                    pagination={false}
-                    dataSource={configOptions}
-                    columns={columns}
-                    loading={preflightLoading}
-                />
             </Card>
         </>
     )
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<MetricRow, 'key' | 'value'> & Partial<Pick<InstanceSetting, 'value_type'>>
+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 <LemonTag type={value ? 'success' : 'danger'}>{value ? 'Yes' : 'No'}</LemonTag>
+    }
+
+    if (value_type === 'int' || typeof value === 'number') {
+        return value.toLocaleString('en-US')
+    }
+
+    if (value === null || value === undefined || value === '') {
+        return <LemonTag style={{ color: 'var(--text-muted)' }}>{emptyNullLabel ?? 'Unknown'}</LemonTag>
+    }
+
+    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 (
+            <>
+                <Checkbox defaultChecked={value} onChange={(e) => onValueChanged(key, e.target.checked)} />
+                <LemonTag style={{ marginLeft: 4 }} type={value ? 'success' : 'danger'}>
+                    {value ? 'Yes' : 'No'}
+                </LemonTag>
+            </>
+        )
+    }
+
+    return (
+        <Input
+            defaultValue={value}
+            type={value_type === 'int' ? 'number' : 'text'}
+            onBlur={(e) => 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..bd5f48d993f4f 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,8 @@ 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'
 
 export const scene: SceneExport = {
     component: SystemStatus,
@@ -20,12 +22,13 @@ 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 (
         <div className="system-status-scene">
             <PageHeader
-                title="System Status"
-                caption="Here you can find all the critical runtime details about your PostHog installation."
+                title="Instance settings"
+                caption="Here you can find all the critical runtime details and settings of your PostHog instance."
             />
             {error && (
                 <Alert
@@ -66,18 +69,26 @@ export function SystemStatus(): JSX.Element {
                 />
             )}
 
-            {systemStatus?.internal_metrics.clickhouse ? (
-                <Tabs tabPosition="top" animated={false} activeKey={tab} onTabClick={(key) => setTab(key as TabName)}>
-                    <Tabs.TabPane tab="Overview" key="overview">
-                        <OverviewTab />
+            <Tabs
+                tabPosition="top"
+                animated={false}
+                activeKey={tab}
+                onTabClick={(key) => setTab(key as InstanceStatusTabName)}
+            >
+                <Tabs.TabPane tab="System overview" key="overview">
+                    <OverviewTab />
+                </Tabs.TabPane>
+                {user?.is_staff && (
+                    <Tabs.TabPane tab="Configuration" key="configuration">
+                        <InstanceConfigTab />
                     </Tabs.TabPane>
+                )}
+                {systemStatus?.internal_metrics.clickhouse && (
                     <Tabs.TabPane tab="Internal metrics" key="internal_metrics">
                         <InternalMetricsTab />
                     </Tabs.TabPane>
-                </Tabs>
-            ) : (
-                <OverviewTab />
-            )}
+                )}
+            </Tabs>
         </div>
     )
 }
diff --git a/frontend/src/scenes/instance/SystemStatus/systemStatusLogic.ts b/frontend/src/scenes/instance/SystemStatus/systemStatusLogic.ts
index 177e3cf740090..3fb9eef8f7704 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<systemStatusLogicType<TabName>>({
+export type InstanceStatusTabName = 'overview' | 'internal_metrics' | 'configuration'
+
+/**
+ * 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<systemStatusLogicType<ConfigMode, InstanceStatusTabName>>({
     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<systemStatusLogicType<TabName>>({
                 },
             },
         ],
+        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<systemStatusLogicType<TabName>>({
     }),
     reducers: {
         tab: [
-            'overview' as TabName,
+            'overview' as InstanceStatusTabName,
             {
                 setTab: (_, { tab }) => tab,
             },
@@ -96,6 +140,27 @@ export const systemStatusLogic = kea<systemStatusLogicType<TabName>>({
                 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<string, string | boolean | number>,
+            {
+                updateInstanceConfigValue: (s, { key, value }) => ({ ...s, [key]: value }),
+                clearInstanceConfigEditing: () => ({}),
+            },
+        ],
+        updatedInstanceConfigCount: [
+            null as number | null, // Number of config items that have been updated; `null` means no update is in progress
+            {
+                setUpdatedInstanceConfigCount: (_, { count }) => count,
+                increaseUpdatedInstanceConfigCount: (state) => (state ?? 0) + 1,
+            },
+        ],
     },
 
     selectors: () => ({
@@ -116,13 +181,50 @@ export const systemStatusLogic = kea<systemStatusLogicType<TabName>>({
                 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 }) => {
+    listeners: ({ actions, values }) => ({
+        setTab: ({ tab }: { tab: InstanceStatusTabName }) => {
             if (tab === 'internal_metrics') {
                 actions.loadQueries()
             }
+            actions.setInstanceConfigMode(ConfigMode.View)
+        },
+        saveInstanceConfig: async (_, breakpoint) => {
+            actions.setUpdatedInstanceConfigCount(0)
+            Object.entries(values.instanceConfigEditingState).map(async ([key, value]) => {
+                try {
+                    console.log({ value })
+                    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.setUpdatedInstanceConfigCount(null)
+                    actions.loadInstanceSettings()
+                }
+            })
+            await breakpoint(1000)
+            if (values.updatedInstanceConfigCount === Object.keys(values.instanceConfigEditingState).length) {
+                actions.loadInstanceSettings()
+                actions.setUpdatedInstanceConfigCount(null)
+                actions.clearInstanceConfigEditing()
+                actions.setInstanceConfigMode(ConfigMode.View)
+                successToast(
+                    'Instance settings updated!',
+                    'Your settings have been updated and should take effect soon.'
+                )
+            }
         },
     }),
 
@@ -137,7 +239,7 @@ export const systemStatusLogic = kea<systemStatusLogicType<TabName>>({
     }),
 
     urlToAction: ({ actions, values }) => ({
-        '/instance/status(/:tab)': ({ tab }: { tab?: TabName }) => {
+        '/instance/status(/:tab)': ({ tab }: { tab?: InstanceStatusTabName }) => {
             const currentTab = tab || 'overview'
             if (currentTab !== values.tab) {
                 actions.setTab(currentTab)
diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts
index 2e36ec2d02896..550e05986cd95 100644
--- a/frontend/src/scenes/urls.ts
+++ b/frontend/src/scenes/urls.ts
@@ -1,6 +1,7 @@
 /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
 import { FilterType, InsightShortId } from '~/types'
 import { combineUrl } from 'kea-router'
+import { InstanceStatusTabName } from './instance/SystemStatus/systemStatusLogic'
 
 export const urls = {
     default: () => '/',
@@ -58,7 +59,7 @@ export const urls = {
     billingSubscribed: () => '/organization/billing/subscribed',
     // Self-hosted only
     instanceLicenses: () => '/instance/licenses',
-    systemStatus: () => '/instance/status',
+    systemStatus: (tab?: InstanceStatusTabName) => (tab ? `/instance/status/${tab}` : '/instance/status'),
     systemStatusPage: (page: string) => `/instance/status/${page}`,
     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":
 


Code review comments:
diff_hunk: @@ -238,6 +239,17 @@ function Pages(): JSX.Element {
             )}
             <PageButton icon={<IconTools />} identifier={Scene.ToolbarLaunch} to={urls.toolbarLaunch()} />
             <PageButton icon={<IconSettings />} identifier={Scene.ProjectSettings} to={urls.projectSettings()} />
+            {user?.is_staff && (
+                <>
+                    <LemonSpacer />
+                    <PageButton
+                        title="Instance settings"
path: frontend/src/layout/navigation/SideBar/SideBar.tsx
text: @user1:
I would not have guessed that this is reachable from this button:
<img width="302" alt="Screen Shot 2022-02-15 at 15 36 23" src="https://user-images.githubusercontent.com/4550621/154083630-ebd4115d-10a8-4abc-9c53-6cf1a1a45f7b.png">
Perhaps it should be renamed from "System status" to "Manage instance"? (though seems like this will require making `SitePopover` 1 rem wide)
 

@author:
Well as we now have an additional menu item if the user is staff, I think it's fine to keep this way. If you're accessing the page from the site popover, it makes sense that you want to focus on the system status.

@user1:
Ah, honestly I didn't notice the sidebar button. This raises more questions – it's kind of weird:
- there are now two buttons that take you to this page – one in the sidebar, and one in the popover
- the sidebar button takes you to instance settings… but that's the third tab of the page, not the first, which is a surprising destination for a pattern that's meant for top-level pages
- "Settings" is just one of a few tabs – a sidebar item should cover all tabs of the page it leads too (otherwise the highlighting is confusing when you navigate to another page tab for example)

If we go the sidebar item route, I strongly propose making instance settings their own page, with status/metrics reachable from the popover like it is currently.

diff_hunk: @@ -0,0 +1,167 @@
+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'
+
+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<InstanceSetting> = [
+        {
+            title: 'Key',
+            dataIndex: 'key',
+            render: function render(value) {
+                return <code>{value}</code>
+            },
+        },
+        {
+            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<EnvironmentConfigOption> = [
+        {
+            key: 'metric',
+            title: 'Metric',
+            dataIndex: 'metric',
+        },
+        {
+            title: 'Value',
+            dataIndex: 'value',
+        },
+    ]
+
+    return (
+        <div>
+            <div className="flex-center">
+                <div style={{ flexGrow: 1 }}>
+                    <h3 className="l3" style={{ marginTop: 16 }}>
+                        Instance configuration
+                    </h3>
+                    <div className="mb text-muted">
+                        Changing these settings will take effect on your entire instance.{' '}
+                        <a href="https://posthog.com/docs/self-host/configure/instance-settings" target="_blank">
+                            Learn more
+                        </a>
+                        .
+                    </div>
+                </div>
+                {instanceConfigMode === ConfigMode.View ? (
+                    <>
+                        <HotkeyButton
+                            type="primary"
+                            onClick={() => setInstanceConfigMode(ConfigMode.Edit)}
+                            data-attr="instance-config-edit-button"
+                            hotkey="e"
+                            disabled={instanceSettingsLoading}
+                        >
+                            Edit
+                        </HotkeyButton>
+                    </>
+                ) : (
+                    <>
+                        {Object.keys(instanceConfigEditingState).length > 0 && (
+                            <span style={{ color: 'var(--warning)' }}>
+                                <WarningOutlined /> You have <b>{Object.keys(instanceConfigEditingState).length}</b>{' '}
+                                unapplied changes
path: frontend/src/scenes/instance/SystemStatus/InstanceConfigTab.tsx
text: @user1:
Pluralization

diff_hunk: @@ -0,0 +1,86 @@
+import { Modal } from 'antd'
+import { useActions, useValues } from 'kea'
+import { AlertMessage } from 'lib/components/InfoMessage/AlertMessage'
+import React from 'react'
+import { RenderMetricValue } from './RenderMetricValue'
+import { MetricRow, systemStatusLogic } from './systemStatusLogic'
+
+interface ChangeRowInterface extends Pick<MetricRow, 'value'> {
+    old_value: any
+    metricKey: string
+}
+
+function ChangeRow({ metricKey, old_value, value }: ChangeRowInterface): JSX.Element {
+    return (
+        <div
+            style={{ backgroundColor: 'var(--border-light)', borderRadius: 'var(--radius)', padding: 8, marginTop: 16 }}
+        >
+            <div>
+                <code>{metricKey}</code>
+            </div>
+            <div style={{ color: 'var(--text-muted)' }}>
+                Value will be changed from{' '}
+                <span style={{ color: 'var(--text-default)', fontWeight: 'bold' }}>
+                    {RenderMetricValue({ key: metricKey, value: old_value })}
+                </span>{' '}
+                to{' '}
+                <span style={{ color: 'var(--text-default)', fontWeight: 'bold' }}>
+                    {RenderMetricValue({ key: metricKey, value })}
+                </span>
+            </div>
+        </div>
+    )
+}
+
+export function InstanceConfigSaveModal({ onClose }: { onClose: () => void }): JSX.Element {
+    const { instanceConfigEditingState, editableInstanceSettings, updatedInstanceConfigCount } =
+        useValues(systemStatusLogic)
+    const { saveInstanceConfig } = useActions(systemStatusLogic)
+    const loading = updatedInstanceConfigCount !== null
+    return (
+        <Modal
+            title="Confirm new changes"
+            visible
+            okText="Apply changes"
+            okType="danger"
+            onCancel={onClose}
+            maskClosable={false}
+            onOk={saveInstanceConfig}
+            okButtonProps={{ loading }}
+            cancelButtonProps={{ loading }}
+            closable={!loading}
+        >
+            {Object.keys(instanceConfigEditingState).find((key) => key.startsWith('EMAIL')) && (
+                <AlertMessage style={{ marginBottom: 16 }}>
+                    <>
+                        As you are changing email settings, we'll attempt to send a <b>test email</b> so you can verify
+                        everything works (unless you are turning email off).
+                    </>
+                </AlertMessage>
+            )}
+            {Object.keys(instanceConfigEditingState).includes('RECORDINGS_TTL_WEEKS') && (
+                <AlertMessage style={{ marginBottom: 16 }} type="warning">
+                    <>
+                        Changing your recordings TTL requires Clickhouse to have enough free space to perform the
path: frontend/src/scenes/instance/SystemStatus/InstanceConfigSaveModal.tsx
text: @user1:
Click*H*ouse

diff_hunk: @@ -0,0 +1,167 @@
+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'
+
+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<InstanceSetting> = [
+        {
+            title: 'Key',
+            dataIndex: 'key',
+            render: function render(value) {
+                return <code>{value}</code>
+            },
+        },
+        {
+            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<EnvironmentConfigOption> = [
+        {
+            key: 'metric',
+            title: 'Metric',
+            dataIndex: 'metric',
+        },
+        {
+            title: 'Value',
+            dataIndex: 'value',
+        },
+    ]
+
+    return (
+        <div>
+            <div className="flex-center">
+                <div style={{ flexGrow: 1 }}>
+                    <h3 className="l3" style={{ marginTop: 16 }}>
+                        Instance configuration
+                    </h3>
+                    <div className="mb text-muted">
+                        Changing these settings will take effect on your entire instance.{' '}
+                        <a href="https://posthog.com/docs/self-host/configure/instance-settings" target="_blank">
+                            Learn more
+                        </a>
+                        .
+                    </div>
+                </div>
+                {instanceConfigMode === ConfigMode.View ? (
+                    <>
+                        <HotkeyButton
+                            type="primary"
+                            onClick={() => setInstanceConfigMode(ConfigMode.Edit)}
+                            data-attr="instance-config-edit-button"
+                            hotkey="e"
+                            disabled={instanceSettingsLoading}
+                        >
+                            Edit
+                        </HotkeyButton>
+                    </>
+                ) : (
+                    <>
+                        {Object.keys(instanceConfigEditingState).length > 0 && (
+                            <span style={{ color: 'var(--warning)' }}>
+                                <WarningOutlined /> You have <b>{Object.keys(instanceConfigEditingState).length}</b>{' '}
+                                unapplied changes
+                            </span>
+                        )}
+                        <Button type="link" disabled={instanceSettingsLoading} onClick={discard}>
+                            Discard changes
+                        </Button>
+                        <Button type="primary" disabled={instanceSettingsLoading} onClick={save}>
+                            Save
+                        </Button>
+                    </>
+                )}
+            </div>
+
+            <LemonTable
+                dataSource={editableInstanceSettings}
+                columns={columns}
+                loading={instanceSettingsLoading}
+                rowKey="key"
+            />
+
+            <h3 className="l3" style={{ marginTop: 32 }}>
+                Environment configuration
+            </h3>
+            <p>
+                These settings can only be modified by environment variables.{' '}
+                <a href="https://posthog.com/docs/self-host/configure/environment-variables" target="_blank">
+                    Learn more <IconOpenInNew style={{ verticalAlign: 'middle' }} />
+                </a>
+            </p>
path: frontend/src/scenes/instance/SystemStatus/InstanceConfigTab.tsx
text: @user1:
This caption has a different style than the one of the section above (text color, punctuation, link icon):

<img width="513" alt="Screen Shot 2022-02-15 at 18 50 07" src="https://user-images.githubusercontent.com/4550621/154119900-5d452e95-96ef-4eb6-94fd-2e0f2b12025d.png">
vs.
<img width="510" alt="Screen Shot 2022-02-15 at 18 50 04" src="https://user-images.githubusercontent.com/4550621/154119897-f1a4d784-1b8e-4f6f-8585-69d5a636fdfb.png">

diff_hunk: @@ -0,0 +1,86 @@
+import { Modal } from 'antd'
+import { useActions, useValues } from 'kea'
+import { AlertMessage } from 'lib/components/InfoMessage/AlertMessage'
+import React from 'react'
+import { RenderMetricValue } from './RenderMetricValue'
+import { MetricRow, systemStatusLogic } from './systemStatusLogic'
+
+interface ChangeRowInterface extends Pick<MetricRow, 'value'> {
+    old_value: any
+    metricKey: string
+}
+
+function ChangeRow({ metricKey, old_value, value }: ChangeRowInterface): JSX.Element {
+    return (
+        <div
+            style={{ backgroundColor: 'var(--border-light)', borderRadius: 'var(--radius)', padding: 8, marginTop: 16 }}
+        >
+            <div>
+                <code>{metricKey}</code>
+            </div>
+            <div style={{ color: 'var(--text-muted)' }}>
+                Value will be changed from{' '}
+                <span style={{ color: 'var(--text-default)', fontWeight: 'bold' }}>
+                    {RenderMetricValue({ key: metricKey, value: old_value })}
+                </span>{' '}
+                to{' '}
+                <span style={{ color: 'var(--text-default)', fontWeight: 'bold' }}>
+                    {RenderMetricValue({ key: metricKey, value })}
+                </span>
+            </div>
+        </div>
+    )
+}
+
+export function InstanceConfigSaveModal({ onClose }: { onClose: () => void }): JSX.Element {
+    const { instanceConfigEditingState, editableInstanceSettings, updatedInstanceConfigCount } =
+        useValues(systemStatusLogic)
+    const { saveInstanceConfig } = useActions(systemStatusLogic)
+    const loading = updatedInstanceConfigCount !== null
+    return (
+        <Modal
+            title="Confirm new changes"
+            visible
+            okText="Apply changes"
+            okType="danger"
+            onCancel={onClose}
+            maskClosable={false}
+            onOk={saveInstanceConfig}
+            okButtonProps={{ loading }}
+            cancelButtonProps={{ loading }}
+            closable={!loading}
+        >
+            {Object.keys(instanceConfigEditingState).find((key) => key.startsWith('EMAIL')) && (
+                <AlertMessage style={{ marginBottom: 16 }}>
+                    <>
+                        As you are changing email settings, we'll attempt to send a <b>test email</b> so you can verify
+                        everything works (unless you are turning email off).
+                    </>
+                </AlertMessage>
+            )}
+            {Object.keys(instanceConfigEditingState).includes('RECORDINGS_TTL_WEEKS') && (
+                <AlertMessage style={{ marginBottom: 16 }} type="warning">
+                    <>
+                        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 happen asynchronously.
+                    </>
+                </AlertMessage>
+            )}
+            <div>The following changes will be immediately applied to your instance.</div>
+            {Object.keys(instanceConfigEditingState).map((key) => (
+                <ChangeRow
+                    key={key}
+                    metricKey={key}
+                    value={instanceConfigEditingState[key]}
+                    old_value={editableInstanceSettings.find((record) => record.key === key)?.value}
+                />
+            ))}
+            {loading && (
+                <div className="mt text-success">
+                    <b>{updatedInstanceConfigCount} changes updated successfully.</b>
path: frontend/src/scenes/instance/SystemStatus/InstanceConfigSaveModal.tsx
text: @user1:
Pluralization

diff_hunk: @@ -0,0 +1,86 @@
+import { Modal } from 'antd'
+import { useActions, useValues } from 'kea'
+import { AlertMessage } from 'lib/components/InfoMessage/AlertMessage'
+import React from 'react'
+import { RenderMetricValue } from './RenderMetricValue'
+import { MetricRow, systemStatusLogic } from './systemStatusLogic'
+
+interface ChangeRowInterface extends Pick<MetricRow, 'value'> {
+    old_value: any
+    metricKey: string
+}
+
+function ChangeRow({ metricKey, old_value, value }: ChangeRowInterface): JSX.Element {
+    return (
+        <div
+            style={{ backgroundColor: 'var(--border-light)', borderRadius: 'var(--radius)', padding: 8, marginTop: 16 }}
+        >
+            <div>
+                <code>{metricKey}</code>
+            </div>
+            <div style={{ color: 'var(--text-muted)' }}>
+                Value will be changed from{' '}
+                <span style={{ color: 'var(--text-default)', fontWeight: 'bold' }}>
+                    {RenderMetricValue({ key: metricKey, value: old_value })}
+                </span>{' '}
+                to{' '}
+                <span style={{ color: 'var(--text-default)', fontWeight: 'bold' }}>
+                    {RenderMetricValue({ key: metricKey, value })}
+                </span>
+            </div>
+        </div>
+    )
+}
+
+export function InstanceConfigSaveModal({ onClose }: { onClose: () => void }): JSX.Element {
+    const { instanceConfigEditingState, editableInstanceSettings, updatedInstanceConfigCount } =
+        useValues(systemStatusLogic)
+    const { saveInstanceConfig } = useActions(systemStatusLogic)
+    const loading = updatedInstanceConfigCount !== null
+    return (
+        <Modal
+            title="Confirm new changes"
+            visible
+            okText="Apply changes"
+            okType="danger"
+            onCancel={onClose}
+            maskClosable={false}
+            onOk={saveInstanceConfig}
+            okButtonProps={{ loading }}
+            cancelButtonProps={{ loading }}
+            closable={!loading}
+        >
+            {Object.keys(instanceConfigEditingState).find((key) => key.startsWith('EMAIL')) && (
+                <AlertMessage style={{ marginBottom: 16 }}>
+                    <>
+                        As you are changing email settings, we'll attempt to send a <b>test email</b> so you can verify
+                        everything works (unless you are turning email off).
+                    </>
+                </AlertMessage>
+            )}
+            {Object.keys(instanceConfigEditingState).includes('RECORDINGS_TTL_WEEKS') && (
+                <AlertMessage style={{ marginBottom: 16 }} type="warning">
+                    <>
+                        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 happen asynchronously.
path: frontend/src/scenes/instance/SystemStatus/InstanceConfigSaveModal.tsx
text: @user1:
Maybe "old recordings will be removed asynchronously, not immediately"

diff_hunk: @@ -0,0 +1,86 @@
+import { Modal } from 'antd'
+import { useActions, useValues } from 'kea'
+import { AlertMessage } from 'lib/components/InfoMessage/AlertMessage'
+import React from 'react'
+import { RenderMetricValue } from './RenderMetricValue'
+import { MetricRow, systemStatusLogic } from './systemStatusLogic'
+
+interface ChangeRowInterface extends Pick<MetricRow, 'value'> {
+    old_value: any
path: frontend/src/scenes/instance/SystemStatus/InstanceConfigSaveModal.tsx
text: @user1:
`snake_case` is very non-JS
```suggestion
    oldValue: any
```

diff_hunk: @@ -58,7 +59,7 @@ export const urls = {
     billingSubscribed: () => '/organization/billing/subscribed',
     // Self-hosted only
     instanceLicenses: () => '/instance/licenses',
-    systemStatus: () => '/instance/status',
+    systemStatus: (tab?: InstanceStatusTabName) => (tab ? `/instance/status/${tab}` : '/instance/status'),
path: frontend/src/scenes/urls.ts
text: @user1:
I really think these should be separate URLs. `/instance/status/configuration` seems like a bit of a mashup, in reality the logical structure is more like `/instance/status` AND `/instance/configuration`.