diff --git a/Dockerfile b/Dockerfile index f8bf469e3..1b13066fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,6 @@ ARG VERSION RUN CGO_ENABLED=0 make -C /src VERSION="${VERSION:-dev}" karma FROM gcr.io/distroless/base -COPY ./docs/dark.css /themes/dark.css COPY --from=go-builder /src/karma /karma EXPOSE 8080 ENTRYPOINT ["/karma"] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f2155049b..6d529915a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -770,6 +770,7 @@ ui: refresh: duration hideFiltersWhenIdle: bool colorTitlebar: bool + darkTheme: bool minimalGroupWidth: integer alertsPerGroup: integer collapseGroups: string @@ -781,6 +782,8 @@ ui: user inactivity - `colorTitlebar` - if enabled alert group title bar color will be set to follow alerts in that group +- `darkTheme` - if enabled dark mode will be enabled. + Note: dark mode is *experimental* and might be buggy. - `minimalGroupWidth` - minimal width (in pixels) for each alert group rendered on the grid. This value is used to calculate the number of columns rendered on the grid. @@ -799,6 +802,7 @@ ui: refresh: 30s hideFiltersWhenIdle: true colorTitlebar: false + darkTheme: false minimalGroupWidth: 420 alertsPerGroup: 5 collapseGroups: collapsedOnMobile @@ -832,14 +836,6 @@ custom: Use at your own risk and be aware that used CSS class names might change without warning. This feature is provided as is without any guarantees. -There is an example `dark.css` file providing a dark theme. It's included in the -docker image as `/themes/dark.css` and can be enabled by passing environment -variable via docker: - -```shell --e CUSTOM_CSS=/themes/dark.css -``` - ## Command line flags Config file options are mapped to command line flags, so `alertmanager:interval` diff --git a/docs/dark.css b/docs/dark.css deleted file mode 100644 index 572954638..000000000 --- a/docs/dark.css +++ /dev/null @@ -1,43 +0,0 @@ -/* Example dark theme */ - -body, -.bg-primary-transparent { - background-color: #2e2727 !important; -} - -.card, -.list-group-item, -.dropdown-menu, -.modal-content { - background-color: #455a64 !important; -} - -.input-group-text, -.components-filterinput { - background-color: #455a64 !important; -} - -.components-grid-annotation { - background-color: #95a5a6 !important; -} - -.badge:not(.components-label-bright), -.form-group > label, -.nav-link { - color: #ccc !important; -} - -.nav > .nav-link { - color: #ccc !important; -} - -.nav > .nav-link.active, -.nav-item > .nav-link.active { - color: #fff !important; - background-color: #455a64 !important; -} - -.badge.badge-light { - color: #666; - background-color: #444; -} diff --git a/internal/config/config.go b/internal/config/config.go index 9e2a6b1a1..a3e13b663 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -96,6 +96,7 @@ func init() { pflag.Duration("ui.refresh", time.Second*30, "UI refresh interval") pflag.Bool("ui.hideFiltersWhenIdle", true, "Hide the filters bar when idle") pflag.Bool("ui.colorTitlebar", false, "Color alert group titlebar based on alert state") + pflag.Bool("ui.darkTheme", false, "Enable dark theme") pflag.Int("ui.minimalGroupWidth", 420, "Minimal width for each alert group on the grid") pflag.Int("ui.alertsPerGroup", 5, "Default number of alerts to show for each alert group") pflag.String("ui.collapseGroups", "collapsedOnMobile", "Default state for alert groups") @@ -186,6 +187,7 @@ func (config *configSchema) Read() { config.UI.Refresh = v.GetDuration("ui.refresh") config.UI.HideFiltersWhenIdle = v.GetBool("ui.hideFiltersWhenIdle") config.UI.ColorTitlebar = v.GetBool("ui.colorTitlebar") + config.UI.DarkTheme = v.GetBool("ui.darkTheme") config.UI.MinimalGroupWidth = v.GetInt("ui.minimalGroupWidth") config.UI.AlertsPerGroup = v.GetInt("ui.alertsPerGroup") config.UI.CollapseGroups = v.GetString("ui.collapseGroups") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0cb2a78ea..5b0ea0ab6 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -136,6 +136,7 @@ ui: refresh: 30s hideFiltersWhenIdle: true colorTitlebar: false + darkTheme: false minimalGroupWidth: 420 alertsPerGroup: 5 collapseGroups: collapsedOnMobile diff --git a/internal/config/models.go b/internal/config/models.go index c3620dfaa..df00f43cb 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -112,6 +112,7 @@ type configSchema struct { Refresh time.Duration HideFiltersWhenIdle bool `yaml:"hideFiltersWhenIdle" mapstructure:"hideFiltersWhenIdle"` ColorTitlebar bool `yaml:"colorTitlebar" mapstructure:"colorTitlebar"` + DarkTheme bool `yaml:"darkTheme" mapstructure:"darkTheme"` MinimalGroupWidth int `yaml:"minimalGroupWidth" mapstructure:"minimalGroupWidth"` AlertsPerGroup int `yaml:"alertsPerGroup" mapstructure:"alertsPerGroup"` CollapseGroups string `yaml:"collapseGroups" mapstructure:"collapseGroups"` diff --git a/ui/src/App.test.js b/ui/src/App.test.js index 36f95d8d4..03fe95049 100644 --- a/ui/src/App.test.js +++ b/ui/src/App.test.js @@ -154,4 +154,16 @@ describe("", () => { let event = new PopStateEvent("popstate"); window.onpopstate(event); }); + + it("appends 'dark-theme' class to #root if dark mode is enabled", () => { + const tree = shallow( + + ); + tree.instance().componentWillUnmount(); + + expect(document.body.className.split(" ")).toContain("dark-theme"); + }); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 00f962665..f3e7abd5f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -12,11 +12,13 @@ import { FaviconBadge } from "Components/FaviconBadge"; import { ErrorBoundary } from "./ErrorBoundary"; import "./App.scss"; +import "./DarkTheme.scss"; interface UIDefaults { Refresh: number; HideFiltersWhenIdle: boolean; ColorTitlebar: boolean; + DarkTheme: boolean; MinimalGroupWidth: number; AlertsPerGroup: number; CollapseGroups: "expanded" | "collapsed" | "collapsedOnMobile"; @@ -70,6 +72,11 @@ class App extends Component { componentDidMount() { window.onpopstate = this.onPopState; + + document.body.classList.toggle( + "dark-theme", + this.settingsStore.themeConfig.config.darkTheme + ); } componentWillUnmount() { diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js index 5441f759e..410edd3e3 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js @@ -224,7 +224,7 @@ const AlertGroup = observer( setIsMenuOpen={this.renderConfig.setIsMenuOpen} /> {this.collapse.value ? null : ( -
+
    {group.alerts .slice(0, this.renderConfig.alertsToRender) diff --git a/ui/src/Components/MainModal/Configuration/AlertGroupTitleBarColor.js b/ui/src/Components/MainModal/Configuration/AlertGroupTitleBarColor.js index 8bf71aca2..9270cec1a 100644 --- a/ui/src/Components/MainModal/Configuration/AlertGroupTitleBarColor.js +++ b/ui/src/Components/MainModal/Configuration/AlertGroupTitleBarColor.js @@ -22,7 +22,7 @@ const AlertGroupTitleBarColor = observer( const { settingsStore } = this.props; return ( -
    +
    { + const { settingsStore } = this.props; + settingsStore.themeConfig.config.darkTheme = event.target.checked; + + document.body.classList.toggle( + "dark-theme", + settingsStore.themeConfig.config.darkTheme + ); + }); + + render() { + const { settingsStore } = this.props; + + return ( +
    +
    + + + + + Experimental + + +
    +
    + ); + } + } +); + +export { ThemeConfiguration }; diff --git a/ui/src/Components/MainModal/Configuration/ThemeConfiguration.test.js b/ui/src/Components/MainModal/Configuration/ThemeConfiguration.test.js new file mode 100644 index 000000000..6e73f4117 --- /dev/null +++ b/ui/src/Components/MainModal/Configuration/ThemeConfiguration.test.js @@ -0,0 +1,54 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import toDiffableHtml from "diffable-html"; + +import { Settings } from "Stores/Settings"; +import { ThemeConfiguration } from "./ThemeConfiguration"; + +let settingsStore; +beforeEach(() => { + settingsStore = new Settings(); +}); + +const FakeConfiguration = () => { + return mount(); +}; + +describe("", () => { + it("matches snapshot with default values", () => { + const tree = FakeConfiguration(); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); + }); + + it("darkTheme is 'false' by default", () => { + expect(settingsStore.themeConfig.config.darkTheme).toBe(false); + }); + + it("unchecking the checkbox sets stored darkTheme value to 'false'", done => { + const tree = FakeConfiguration(); + const checkbox = tree.find("#configuration-theme"); + + settingsStore.themeConfig.config.darkTheme = true; + expect(settingsStore.themeConfig.config.darkTheme).toBe(true); + checkbox.simulate("change", { target: { checked: false } }); + setTimeout(() => { + expect(settingsStore.themeConfig.config.darkTheme).toBe(false); + done(); + }, 200); + }); + + it("checking the checkbox sets stored darkTheme value to 'true'", done => { + const tree = FakeConfiguration(); + const checkbox = tree.find("#configuration-theme"); + + settingsStore.themeConfig.config.darkTheme = false; + expect(settingsStore.themeConfig.config.darkTheme).toBe(false); + checkbox.simulate("change", { target: { checked: true } }); + setTimeout(() => { + expect(settingsStore.themeConfig.config.darkTheme).toBe(true); + done(); + }, 200); + }); +}); diff --git a/ui/src/Components/MainModal/Configuration/__snapshots__/AlertGroupTitleBarColor.test.js.snap b/ui/src/Components/MainModal/Configuration/__snapshots__/AlertGroupTitleBarColor.test.js.snap index 1dc05774d..db92ecc3f 100644 --- a/ui/src/Components/MainModal/Configuration/__snapshots__/AlertGroupTitleBarColor.test.js.snap +++ b/ui/src/Components/MainModal/Configuration/__snapshots__/AlertGroupTitleBarColor.test.js.snap @@ -2,7 +2,7 @@ exports[` matches snapshot with default values 1`] = ` " -
    +
    matches snapshot with default values 1`] = ` +" +
    +
    + + + + + Experimental + + +
    +
    +" +`; diff --git a/ui/src/Components/MainModal/Configuration/__snapshots__/index.test.js.snap b/ui/src/Components/MainModal/Configuration/__snapshots__/index.test.js.snap index aa5924138..d6542ef7d 100644 --- a/ui/src/Components/MainModal/Configuration/__snapshots__/index.test.js.snap +++ b/ui/src/Components/MainModal/Configuration/__snapshots__/index.test.js.snap @@ -126,7 +126,7 @@ exports[` matches snapshot 1`] = `
    - Alert group titlebar configuration + Theme
    matches snapshot 1`] = ` style=\\"height:0;-webkit-transition:height 50ms linear;-ms-transition:height 50ms linear;transition:height 50ms linear;overflow:hidden\\" >
    -
    +
    matches snapshot 1`] = `
    +
    +
    + + + + + Experimental + + +
    +
    diff --git a/ui/src/Components/MainModal/Configuration/index.js b/ui/src/Components/MainModal/Configuration/index.js index 75c2014aa..9f7065dcb 100644 --- a/ui/src/Components/MainModal/Configuration/index.js +++ b/ui/src/Components/MainModal/Configuration/index.js @@ -10,6 +10,7 @@ import { AlertGroupWidthConfiguration } from "./AlertGroupWidthConfiguration"; import { AlertGroupSortConfiguration } from "./AlertGroupSortConfiguration"; import { AlertGroupCollapseConfiguration } from "./AlertGroupCollapseConfiguration"; import { AlertGroupTitleBarColor } from "./AlertGroupTitleBarColor"; +import { ThemeConfiguration } from "./ThemeConfiguration"; const Configuration = ({ settingsStore }) => (
    @@ -23,8 +24,13 @@ const Configuration = ({ settingsStore }) => ( content={} /> } + text="Theme" + content={ + + + + + } /> matches snapshot 1`] = `
    - Alert group titlebar configuration + Theme
    matches snapshot 1`] = ` style=\\"height: 0px; transition: height 50ms linear; overflow: hidden;\\" >
    -
    +
    matches snapshot 1`] = `
    +
    +
    + + + + + Experimental + + +
    +
    diff --git a/ui/src/DarkTheme.scss b/ui/src/DarkTheme.scss new file mode 100644 index 000000000..69abd4616 --- /dev/null +++ b/ui/src/DarkTheme.scss @@ -0,0 +1,159 @@ +@import "src/App.scss"; + +$alertgroup-body-bg: #5a6e7b; +$alertgroup-header-bg: #515658; +$alertgroup-footer-bg: #728998; +$alertgroup-border-inside: #485862; + +$silence-bg: #c3cdd5; +$silence-progress-bg: #eaeef0; + +$badge-light-bg: #d2dae0; + +.dark-theme .card.bg-light { + border-color: #3c3e3e !important; +} +.dark-theme .components-grid-alertgrid-card { + &.card-body, + & > .list-group, + & > .list-group > .list-group-item { + background-color: $alertgroup-body-bg !important; + } +} +.dark-theme .card.bg-light > .card-header { + background-color: $alertgroup-header-bg !important; +} +.dark-theme .card > .card-header { + border-bottom-color: $alertgroup-border-inside !important; +} +.dark-theme .bg-card-footer-default { + background-color: $alertgroup-footer-bg !important; + border-top-color: $alertgroup-border-inside !important; +} + +.dark-theme .btn.bg-white { + background-color: $alertgroup-body-bg !important; +} + +.dark-theme { + & .components-grid-annotation .text-muted, + & .components-managed-silence-cite .text-muted, + & .components-managed-silence svg.text-muted, + & .text-muted.fa-bell-slash { + color: $dark !important; + } + & .text-muted { + color: $white !important; + } +} + +.dark-theme .components-managed-silence { + border-left-color: $dark; + + & > .card-header, + & > .card-body { + background-color: $silence-bg !important; + } +} + +.dark-theme { + & .modal-content { + background-color: $alertgroup-body-bg; + + & .list-group-item { + background-color: $alertgroup-body-bg; + } + } + & .modal-header { + border-bottom-color: $alertgroup-border-inside; + } + & .modal-footer { + border-top-color: $alertgroup-border-inside; + } + + & .components-grid-annotation.bg-light { + background-color: $silence-bg !important; + } + + & .badge.badge-light { + background-color: $badge-light-bg !important; + } + & .components-filteredinputlabel > .badge-pill.badge-light { + background-color: $badge-light-bg !important; + filter: none; + } + + & .dropdown-menu { + background-color: $silence-bg !important; + } + & .dropdown-header { + color: $dark !important; + } + + & .progress.bg-white { + background-color: $silence-progress-bg !important; + } +} + +.dark-theme { + & .react-select__control, + & .react-select__indicators, + & .react-select__value-container { + background-color: $alertgroup-body-bg !important; + } + & .react-select__menu { + background-color: $silence-bg !important; + } + + & .tab-content, + & .tab-content .text-muted, + & .nav-item:not(.components-tab-inactive), + & .custom-control-label, + & .react-select__placeholder { + color: $white !important; + } + + & .nav-link.active { + color: $black !important; + background-color: $silence-bg !important; + } + + & .form-control { + color: $gray-200 !important; + background-color: $alertgroup-body-bg !important; + } + & .input-group-text { + background-color: $silence-bg !important; + } + + & .react-datepicker, + & .react-datepicker__header, + & .react-datepicker__today-button { + background-color: $silence-bg !important; + } +} + +.dark-theme { + & .jumbotron.bg-white { + background-color: $alertgroup-body-bg !important; + } +} + +.dark-theme { + & .collapse, + & .collapse > .card-body { + color: $white; + background-color: $alertgroup-body-bg !important; + } + & .Collapsible > .card-header { + background-color: $alertgroup-footer-bg !important; + border-bottom-color: $alertgroup-border-inside !important; + } + & .input-range__label, + & .Collapsible__trigger.bg-light { + color: $white; + } + & .Collapsible__trigger.is-closed { + color: $black; + } +} diff --git a/ui/src/Stores/Settings.js b/ui/src/Stores/Settings.js index b9ac01396..4a714617c 100644 --- a/ui/src/Stores/Settings.js +++ b/ui/src/Stores/Settings.js @@ -110,6 +110,20 @@ class FilterBarConfig { } } +class ThemeConfig { + constructor(darkTheme) { + this.config = localStored( + "themeConfig", + { + darkTheme: darkTheme + }, + { + delay: 100 + } + ); + } +} + class Settings { constructor(defaults) { let defaultSettings; @@ -118,6 +132,7 @@ class Settings { Refresh: 30 * 1000 * 1000 * 1000, HideFiltersWhenIdle: true, ColorTitlebar: false, + DarkTheme: false, MinimalGroupWidth: 420, AlertsPerGroup: 5, CollapseGroups: "collapsedOnMobile" @@ -140,6 +155,7 @@ class Settings { this.filterBarConfig = new FilterBarConfig( defaultSettings.HideFiltersWhenIdle ); + this.themeConfig = new ThemeConfig(defaultSettings.DarkTheme); } } diff --git a/ui/src/__mocks__/Defaults.js b/ui/src/__mocks__/Defaults.js index d647135c3..381458357 100644 --- a/ui/src/__mocks__/Defaults.js +++ b/ui/src/__mocks__/Defaults.js @@ -1,9 +1,10 @@ const DefaultsBase64 = - "eyJSZWZyZXNoIjo0NTAwMDAwMDAwMCwiSGlkZUZpbHRlcnNXaGVuSWRsZSI6ZmFsc2UsIkNvbG9yVGl0bGViYXIiOmZhbHNlLCJNaW5pbWFsR3JvdXBXaWR0aCI6NTU1LCJBbGVydHNQZXJHcm91cCI6MTUsIkNvbGxhcHNlR3JvdXBzIjoiZXhwYW5kZWQifQo="; + "eyJSZWZyZXNoIjo0NTAwMDAwMDAwMCwiSGlkZUZpbHRlcnNXaGVuSWRsZSI6ZmFsc2UsIkNvbG9yVGl0bGViYXIiOmZhbHNlLCJEYXJrTW9kZSI6ZmFsc2UsIk1pbmltYWxHcm91cFdpZHRoIjo1NTUsIkFsZXJ0c1Blckdyb3VwIjoxNSwiQ29sbGFwc2VHcm91cHMiOiJleHBhbmRlZCJ9Cg=="; const DefaultsObject = { Refresh: 45000000000, HideFiltersWhenIdle: false, ColorTitlebar: false, + DarkMode: false, MinimalGroupWidth: 555, AlertsPerGroup: 15, CollapseGroups: "expanded"