diff --git a/pkg/server/server_http.go b/pkg/server/server_http.go index b1bd3dcb3486..280c12d2b0c8 100644 --- a/pkg/server/server_http.go +++ b/pkg/server/server_http.go @@ -178,7 +178,8 @@ func (s *httpServer) setupRoutes( } return nil }, - Flags: flags, + Flags: flags, + Settings: s.cfg.Settings, }) // The authentication mux used here is created in "allow anonymous" mode so that the UI diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 39e7fdebb6e0..ba1b2349137d 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -757,7 +757,7 @@ Binary built without web UI. respBytes, err = io.ReadAll(resp.Body) require.NoError(t, err) expected := fmt.Sprintf( - `{"Insecure":true,"LoggedInUser":null,"Tag":"%s","Version":"%s","NodeID":"%d","OIDCAutoLogin":false,"OIDCLoginEnabled":false,"OIDCButtonText":"","FeatureFlags":{"can_view_kv_metric_dashboards":true},"OIDCGenerateJWTAuthTokenEnabled":false}`, + `{"Insecure":true,"LoggedInUser":null,"Tag":"%s","Version":"%s","NodeID":"%d","OIDCAutoLogin":false,"OIDCLoginEnabled":false,"OIDCButtonText":"","FeatureFlags":{"can_view_kv_metric_dashboards":true},"OIDCGenerateJWTAuthTokenEnabled":false,"LicenseType":"OSS","SecondsUntilLicenseExpiry":0}`, build.GetInfo().Tag, build.BinaryVersionPrefix(), 1, @@ -785,7 +785,7 @@ Binary built without web UI. { loggedInClient, fmt.Sprintf( - `{"Insecure":false,"LoggedInUser":"authentic_user","Tag":"%s","Version":"%s","NodeID":"%d","OIDCAutoLogin":false,"OIDCLoginEnabled":false,"OIDCButtonText":"","FeatureFlags":{"can_view_kv_metric_dashboards":true},"OIDCGenerateJWTAuthTokenEnabled":false}`, + `{"Insecure":false,"LoggedInUser":"authentic_user","Tag":"%s","Version":"%s","NodeID":"%d","OIDCAutoLogin":false,"OIDCLoginEnabled":false,"OIDCButtonText":"","FeatureFlags":{"can_view_kv_metric_dashboards":true},"OIDCGenerateJWTAuthTokenEnabled":false,"LicenseType":"OSS","SecondsUntilLicenseExpiry":0}`, build.GetInfo().Tag, build.BinaryVersionPrefix(), 1, @@ -794,7 +794,7 @@ Binary built without web UI. { loggedOutClient, fmt.Sprintf( - `{"Insecure":false,"LoggedInUser":null,"Tag":"%s","Version":"%s","NodeID":"%d","OIDCAutoLogin":false,"OIDCLoginEnabled":false,"OIDCButtonText":"","FeatureFlags":{"can_view_kv_metric_dashboards":true},"OIDCGenerateJWTAuthTokenEnabled":false}`, + `{"Insecure":false,"LoggedInUser":null,"Tag":"%s","Version":"%s","NodeID":"%d","OIDCAutoLogin":false,"OIDCLoginEnabled":false,"OIDCButtonText":"","FeatureFlags":{"can_view_kv_metric_dashboards":true},"OIDCGenerateJWTAuthTokenEnabled":false,"LicenseType":"OSS","SecondsUntilLicenseExpiry":0}`, build.GetInfo().Tag, build.BinaryVersionPrefix(), 1, diff --git a/pkg/ui/BUILD.bazel b/pkg/ui/BUILD.bazel index 5f4438084faf..1d72a85aa200 100644 --- a/pkg/ui/BUILD.bazel +++ b/pkg/ui/BUILD.bazel @@ -20,6 +20,7 @@ go_library( "//pkg/build", "//pkg/server/serverpb", "//pkg/settings", + "//pkg/settings/cluster", "//pkg/util/httputil", "//pkg/util/log", ], diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go index f579a4dbb310..23e0871069ef 100644 --- a/pkg/ui/ui.go +++ b/pkg/ui/ui.go @@ -29,6 +29,7 @@ import ( "github.com/cockroachdb/cockroach/pkg/build" "github.com/cockroachdb/cockroach/pkg/server/serverpb" "github.com/cockroachdb/cockroach/pkg/settings" + "github.com/cockroachdb/cockroach/pkg/settings/cluster" "github.com/cockroachdb/cockroach/pkg/util/httputil" "github.com/cockroachdb/cockroach/pkg/util/log" ) @@ -92,6 +93,9 @@ type indexHTMLArgs struct { FeatureFlags serverpb.FeatureFlags OIDCGenerateJWTAuthTokenEnabled bool + + LicenseType string + SecondsUntilLicenseExpiry int64 } // OIDCUIConf is a variable that stores data required by the @@ -129,6 +133,7 @@ type Config struct { GetUser func(ctx context.Context) *string OIDC OIDCUI Flags serverpb.FeatureFlags + Settings *cluster.Settings } var uiConfigPath = regexp.MustCompile("^/uiconfig$") @@ -158,6 +163,11 @@ func Handler(cfg Config) http.Handler { buildInfo := build.GetInfo() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + licenseType, err := base.LicenseType(cfg.Settings) + if err != nil { + log.Errorf(context.Background(), "unable to get license type: %+v", err) + } + licenseTTL := base.LicenseTTL.Value() oidcConf := cfg.OIDC.GetOIDCConf() args := indexHTMLArgs{ Insecure: cfg.Insecure, @@ -170,6 +180,9 @@ func Handler(cfg Config) http.Handler { FeatureFlags: cfg.Flags, OIDCGenerateJWTAuthTokenEnabled: oidcConf.GenerateJWTAuthTokenEnabled, + + LicenseType: licenseType, + SecondsUntilLicenseExpiry: licenseTTL, } if cfg.NodeID != nil { args.NodeID = cfg.NodeID.String() diff --git a/pkg/ui/workspaces/cluster-ui/src/util/dataFromServer.ts b/pkg/ui/workspaces/cluster-ui/src/util/dataFromServer.ts index 9ab79f896c2c..1ae945a12613 100644 --- a/pkg/ui/workspaces/cluster-ui/src/util/dataFromServer.ts +++ b/pkg/ui/workspaces/cluster-ui/src/util/dataFromServer.ts @@ -22,6 +22,8 @@ export interface DataFromServer { OIDCButtonText: string; OIDCGenerateJWTAuthTokenEnabled: boolean; FeatureFlags: FeatureFlags; + LicenseType: string; + SecondsUntilLicenseExpiry: number; } // Tell TypeScript about `window.dataFromServer`, which is set in a script diff --git a/pkg/ui/workspaces/db-console/src/redux/alerts.spec.ts b/pkg/ui/workspaces/db-console/src/redux/alerts.spec.ts index ed8ec55b89b3..f7c0028f0cb1 100644 --- a/pkg/ui/workspaces/db-console/src/redux/alerts.spec.ts +++ b/pkg/ui/workspaces/db-console/src/redux/alerts.spec.ts @@ -15,6 +15,7 @@ import { createHashHistory } from "history"; import * as protos from "src/js/protos"; import { cockroach } from "src/js/protos"; import { API_PREFIX } from "src/util/api"; +import { setDataFromServer } from "src/util/dataFromServer"; import fetchMock from "src/util/fetch-mock"; import { AdminUIState, AppDispatch, createAdminUIStore } from "./state"; @@ -31,11 +32,13 @@ import { emailSubscriptionAlertSelector, clusterPreserveDowngradeOptionDismissedSetting, clusterPreserveDowngradeOptionOvertimeSelector, + licenseUpdateNotificationSelector, } from "./alerts"; import { versionsSelector } from "src/redux/nodes"; import { VERSION_DISMISSED_KEY, INSTRUCTIONS_BOX_COLLAPSED_KEY, + LICENSE_UPDATE_DISMISSED_KEY, setUIDataKey, isInFlight, } from "./uiData"; @@ -47,6 +50,7 @@ import { healthReducerObj, settingsReducerObj, } from "./apiReducers"; + import Long from "long"; import MembershipStatus = cockroach.kv.kvserver.liveness.livenesspb.MembershipStatus; import { loginSuccess } from "./login"; @@ -257,6 +261,31 @@ describe("alerts", function () { }); }); + describe("licence update notification", function () { + it("displays the alert when nothing is done", function () { + dispatch(setUIDataKey(LICENSE_UPDATE_DISMISSED_KEY, null)); + const alert = licenseUpdateNotificationSelector(state()); + expect(typeof alert).toBe("object"); + expect(alert.level).toEqual(AlertLevel.INFORMATION); + expect(alert.text).toEqual( + "Important changes to CockroachDB’s licensing model.", + ); + }); + + it("hides the alert when dismissed timestamp is present", function () { + dispatch(setUIDataKey(LICENSE_UPDATE_DISMISSED_KEY, moment())); + expect(licenseUpdateNotificationSelector(state())).toBeUndefined(); + }); + + it("hides the alert when license is enterprise", function () { + dispatch(setUIDataKey(LICENSE_UPDATE_DISMISSED_KEY, null)); + setDataFromServer({ + LicenseType: "Enterprise", + } as any); + expect(licenseUpdateNotificationSelector(state())).toBeUndefined(); + }); + }); + describe("new version available notification", function () { it("displays nothing when versions have not yet been loaded", function () { dispatch(setUIDataKey(VERSION_DISMISSED_KEY, null)); @@ -630,6 +659,7 @@ describe("alerts", function () { ); dispatch(setUIDataKey(VERSION_DISMISSED_KEY, "blank")); dispatch(setUIDataKey(INSTRUCTIONS_BOX_COLLAPSED_KEY, false)); + dispatch(setUIDataKey(LICENSE_UPDATE_DISMISSED_KEY, moment())); dispatch( versionReducerObj.receiveData({ details: [], diff --git a/pkg/ui/workspaces/db-console/src/redux/alerts.ts b/pkg/ui/workspaces/db-console/src/redux/alerts.ts index e23dd006cccf..5f2a5f4b9cc2 100644 --- a/pkg/ui/workspaces/db-console/src/redux/alerts.ts +++ b/pkg/ui/workspaces/db-console/src/redux/alerts.ts @@ -21,6 +21,7 @@ import { ThunkAction } from "redux-thunk"; import { LocalSetting } from "./localsettings"; import { + LICENSE_UPDATE_DISMISSED_KEY, VERSION_DISMISSED_KEY, INSTRUCTIONS_BOX_COLLAPSED_KEY, saveUIData, @@ -55,6 +56,7 @@ export enum AlertLevel { WARNING, CRITICAL, SUCCESS, + INFORMATION, } export interface AlertInfo { @@ -629,20 +631,6 @@ export const upgradeNotFinalizedWarningSelector = createSelector( }, ); -/** - * Selector which returns an array of all active alerts which should be - * displayed in the overview list page, these should be non-critical alerts. - */ - -export const overviewListAlertsSelector = createSelector( - staggeredVersionWarningSelector, - clusterPreserveDowngradeOptionOvertimeSelector, - upgradeNotFinalizedWarningSelector, - (...alerts: Alert[]): Alert[] => { - return _.without(alerts, null, undefined); - }, -); - /** * Selector which returns an array of all active alerts which should be * displayed in the alerts panel, which is embedded within the cluster overview @@ -684,6 +672,176 @@ export const dataFromServerAlertSelector = createSelector( }, ); +const licenseTypeNames = new Map< + string, + "Trial" | "Enterprise" | "Non-Commercial" | "None" +>([ + ["Evaluation", "Trial"], + ["Enterprise", "Enterprise"], + ["NonCommercial", "Non-Commercial"], + ["OSS", "None"], + ["BSD", "None"], +]); + +// licenseTypeSelector returns user-friendly names of license types. +export const licenseTypeSelector = createSelector( + getDataFromServer, + data => licenseTypeNames.get(data.LicenseType) || "None", +); + +export const licenseUpdateDismissedLocalSetting = new LocalSetting( + "license_update_dismissed", + localSettingsSelector, + moment(0), +); + +const licenseUpdateDismissedPersistentLoadedSelector = createSelector( + (state: AdminUIState) => state.uiData, + uiData => uiData && Object.hasOwn(uiData, LICENSE_UPDATE_DISMISSED_KEY), +); + +const licenseUpdateDismissedPersistentSelector = createSelector( + (state: AdminUIState) => state.uiData, + uiData => moment(uiData?.[LICENSE_UPDATE_DISMISSED_KEY]?.data ?? 0), +); + +export const licenseUpdateNotificationSelector = createSelector( + licenseTypeSelector, + licenseUpdateDismissedLocalSetting.selector, + licenseUpdateDismissedPersistentSelector, + licenseUpdateDismissedPersistentLoadedSelector, + ( + licenseType, + licenseUpdateDismissed, + licenseUpdateDismissedPersistent, + licenseUpdateDismissedPersistentLoaded, + ): Alert => { + // If customer has Enterprise license they don't need to worry about this. + if (licenseType === "Enterprise") { + return undefined; + } + + // If the notification has been dismissed based on the session storage + // timestamp, don't show it.' + // + // Note: `licenseUpdateDismissed` is wrapped in `moment()` because + // the local storage selector won't convert it back from a string. + // We omit fixing that here since this change is being backported + // to many versions. + if (moment(licenseUpdateDismissed).isAfter(moment(0))) { + return undefined; + } + + // If the notification has been dismissed based on the uiData + // storage in the cluster, don't show it. Note that this is + // different from how version upgrade notifications work, this one + // is dismissed forever and won't return even if you upgrade + // further or time passes. + if ( + licenseUpdateDismissedPersistentLoaded && + licenseUpdateDismissedPersistent && + licenseUpdateDismissedPersistent.isAfter(moment(0)) + ) { + return undefined; + } + + return { + level: AlertLevel.INFORMATION, + title: "Coming November 18, 2024", + text: "Important changes to CockroachDB’s licensing model.", + link: docsURL.enterpriseLicenseUpdate, + dismiss: (dispatch: any) => { + const dismissedAt = moment(); + // Note(davidh): I haven't been able to find historical context + // for why some alerts have both a "local" and a "persistent" + // dismissal. My thinking is that just the persistent dismissal + // should be adequate, but I'm preserving that behavior here to + // match the version upgrade notification. + + // Dismiss locally. + dispatch(licenseUpdateDismissedLocalSetting.set(dismissedAt)); + // Dismiss persistently. + return dispatch( + saveUIData({ + key: LICENSE_UPDATE_DISMISSED_KEY, + value: dismissedAt.valueOf(), + }), + ); + }, + }; + }, +); + +/** + * Selector which returns an array of all active alerts which should be + * displayed in the overview list page, these should be non-critical alerts. + */ + +export const overviewListAlertsSelector = createSelector( + staggeredVersionWarningSelector, + clusterPreserveDowngradeOptionOvertimeSelector, + upgradeNotFinalizedWarningSelector, + licenseUpdateNotificationSelector, + (...alerts: Alert[]): Alert[] => { + return _.without(alerts, null, undefined); + }, +); + +// daysUntilLicenseExpiresSelector returns number of days remaining before license expires. +export const daysUntilLicenseExpiresSelector = createSelector( + getDataFromServer, + data => { + return Math.ceil(data.SecondsUntilLicenseExpiry / 86400); // seconds in 1 day + }, +); + +export const showLicenseTTLLocalSetting = new LocalSetting( + "show_license_ttl", + localSettingsSelector, + { show: true }, +); + +export const showLicenseTTLAlertSelector = createSelector( + showLicenseTTLLocalSetting.selector, + daysUntilLicenseExpiresSelector, + licenseTypeSelector, + (showLicenseTTL, daysUntilLicenseExpired, licenseType): Alert => { + if (!showLicenseTTL.show) { + return; + } + if (licenseType === "None") { + return; + } + const daysToShowAlert = 14; + let title: string; + let level: AlertLevel; + + if (daysUntilLicenseExpired > daysToShowAlert) { + return; + } else if (daysUntilLicenseExpired < 0) { + title = `License expired ${Math.abs(daysUntilLicenseExpired)} days ago`; + level = AlertLevel.CRITICAL; + } else if (daysUntilLicenseExpired === 0) { + title = `License expired`; + level = AlertLevel.CRITICAL; + } else if (daysUntilLicenseExpired <= daysToShowAlert) { + title = `License expires in ${daysUntilLicenseExpired} days`; + level = AlertLevel.WARNING; + } + return { + level: level, + title: title, + showAsAlert: true, + autoClose: false, + closable: true, + dismiss: (dispatch: Dispatch) => { + dispatch(showLicenseTTLLocalSetting.set({ show: false })); + return Promise.resolve(); + }, + }; + }, +); + /** * Selector which returns an array of all active alerts which should be * displayed as a banner, which appears at the top of the page and overlaps @@ -698,6 +856,7 @@ export const bannerAlertsSelector = createSelector( terminateSessionAlertSelector, terminateQueryAlertSelector, dataFromServerAlertSelector, + showLicenseTTLAlertSelector, (...alerts: Alert[]): Alert[] => { return _.without(alerts, null, undefined); }, @@ -744,6 +903,7 @@ export function alertDataSync(store: Store) { const keysToMaybeLoad = [ VERSION_DISMISSED_KEY, INSTRUCTIONS_BOX_COLLAPSED_KEY, + LICENSE_UPDATE_DISMISSED_KEY, ]; const keysToLoad = _.filter(keysToMaybeLoad, key => { return !(_.has(uiData, key) || isInFlight(state, key)); diff --git a/pkg/ui/workspaces/db-console/src/redux/state.ts b/pkg/ui/workspaces/db-console/src/redux/state.ts index 0dd8c6e680e0..028514d32ff7 100644 --- a/pkg/ui/workspaces/db-console/src/redux/state.ts +++ b/pkg/ui/workspaces/db-console/src/redux/state.ts @@ -66,6 +66,8 @@ const emptyDataFromServer: DataFromServer = { OIDCGenerateJWTAuthTokenEnabled: false, Tag: "", Version: "", + LicenseType: "OSS", + SecondsUntilLicenseExpiry: 0, }; export const featureFlagSelector = createSelector( diff --git a/pkg/ui/workspaces/db-console/src/redux/uiData.ts b/pkg/ui/workspaces/db-console/src/redux/uiData.ts index afda71bb7c93..0eb3fc1c4300 100644 --- a/pkg/ui/workspaces/db-console/src/redux/uiData.ts +++ b/pkg/ui/workspaces/db-console/src/redux/uiData.ts @@ -56,6 +56,11 @@ export class OptInAttributes { // was last dismissed. export const VERSION_DISMISSED_KEY = "version_dismissed"; +// LICENSE_UPDATE_DISMISSED_KEY is the uiData key on the server that tracks when the licence +// update banner was last dismissed. This banner notifies the user that we've changed our +// licensing if they're deployed without an active license. +export const LICENSE_UPDATE_DISMISSED_KEY = "license_update_dismissed"; + // INSTRUCTIONS_BOX_COLLAPSED_KEY is the uiData key on the server that tracks whether the // instructions box on the cluster viz has been collapsed or not. export const INSTRUCTIONS_BOX_COLLAPSED_KEY = diff --git a/pkg/ui/workspaces/db-console/src/util/dataFromServer.ts b/pkg/ui/workspaces/db-console/src/util/dataFromServer.ts index e1dac5af337b..d52a8866f378 100644 --- a/pkg/ui/workspaces/db-console/src/util/dataFromServer.ts +++ b/pkg/ui/workspaces/db-console/src/util/dataFromServer.ts @@ -22,6 +22,8 @@ export interface DataFromServer { OIDCButtonText: string; OIDCGenerateJWTAuthTokenEnabled: boolean; FeatureFlags: FeatureFlags; + LicenseType: string; + SecondsUntilLicenseExpiry: number; } // Tell TypeScript about `window.dataFromServer`, which is set in a script // tag in index.html, the contents of which are generated in a Go template diff --git a/pkg/ui/workspaces/db-console/src/util/docs.ts b/pkg/ui/workspaces/db-console/src/util/docs.ts index ed10e8a36cf8..c964dab29cf8 100644 --- a/pkg/ui/workspaces/db-console/src/util/docs.ts +++ b/pkg/ui/workspaces/db-console/src/util/docs.ts @@ -62,6 +62,8 @@ export let sessionsTable: string; export let upgradeTroubleshooting: string; // Note that these explicitly don't use the current version, since we want to // link to the most up-to-date documentation available. +export const enterpriseLicenseUpdate = + "https://www.cockroachlabs.com/enterprise-license-update/"; export const upgradeCockroachVersion = "https://www.cockroachlabs.com/docs/stable/upgrade-cockroach-version.html"; export const enterpriseLicensing = diff --git a/pkg/ui/workspaces/db-console/src/views/shared/components/alertBox/alertbox.styl b/pkg/ui/workspaces/db-console/src/views/shared/components/alertBox/alertbox.styl index 3b80a4e2bd34..d0773a910572 100644 --- a/pkg/ui/workspaces/db-console/src/views/shared/components/alertBox/alertbox.styl +++ b/pkg/ui/workspaces/db-console/src/views/shared/components/alertBox/alertbox.styl @@ -46,6 +46,13 @@ border-color $notification-info-border-color background-color $notification-info-fill-color + &--information + color $body-color + border-color $notification-info-border-color + background-color $notification-info-fill-color + path + fill $colors--primary-blue-3 + &--warning color $body-color border-color $notification-warning-border-color diff --git a/pkg/ui/workspaces/db-console/src/views/shared/components/alertBox/index.tsx b/pkg/ui/workspaces/db-console/src/views/shared/components/alertBox/index.tsx index b1e7e600cf5e..5478349ca7cd 100644 --- a/pkg/ui/workspaces/db-console/src/views/shared/components/alertBox/index.tsx +++ b/pkg/ui/workspaces/db-console/src/views/shared/components/alertBox/index.tsx @@ -18,6 +18,7 @@ import { warningIcon, notificationIcon, criticalIcon, + informationIcon, } from "src/views/shared/components/icons"; import { trustIcon } from "src/util/trust"; @@ -27,6 +28,8 @@ function alertIcon(level: AlertLevel) { return trustIcon(criticalIcon); case AlertLevel.WARNING: return trustIcon(warningIcon); + case AlertLevel.INFORMATION: + return trustIcon(informationIcon); default: return trustIcon(notificationIcon); } @@ -49,7 +52,7 @@ export class AlertBox extends React.Component { const learnMore = this.props.link && ( - Learn More. + Learn More ); content = ( diff --git a/pkg/ui/workspaces/db-console/src/views/shared/components/icons/index.tsx b/pkg/ui/workspaces/db-console/src/views/shared/components/icons/index.tsx index 35bb1ec73f31..996caf04f651 100644 --- a/pkg/ui/workspaces/db-console/src/views/shared/components/icons/index.tsx +++ b/pkg/ui/workspaces/db-console/src/views/shared/components/icons/index.tsx @@ -98,7 +98,20 @@ export const warningIcon: string = ` + + + + + + + + + +`; + +export const notificationIcon = ` CL Mark