diff --git a/packages/browser-tests/cypress/integration/auth/auth.spec.js b/packages/browser-tests/cypress/integration/auth/auth.spec.js
index fb738a32c..4707e0257 100644
--- a/packages/browser-tests/cypress/integration/auth/auth.spec.js
+++ b/packages/browser-tests/cypress/integration/auth/auth.spec.js
@@ -12,8 +12,10 @@ const interceptSettings = (payload) => {
describe("OSS", () => {
before(() => {
interceptSettings({
- "release.type": "OSS",
- "release.version": "1.2.3",
+ "config": {
+ "release.type": "OSS",
+ "release.version": "1.2.3",
+ }
});
cy.visit(baseUrl);
});
@@ -27,16 +29,18 @@ describe("OSS", () => {
describe("Auth - UI", () => {
before(() => {
interceptSettings({
- "release.type": "EE",
- "release.version": "1.2.3",
- "acl.enabled": true,
- "acl.basic.auth.realm.enabled": false,
- "acl.oidc.enabled": false,
- "acl.oidc.client.id": null,
- "acl.oidc.authorization.endpoint": null,
- "acl.oidc.token.endpoint": null,
- "acl.oidc.pkce.required": null,
- "acl.oidc.groups.encoded.in.token": false,
+ "config": {
+ "release.type": "EE",
+ "release.version": "1.2.3",
+ "acl.enabled": true,
+ "acl.basic.auth.realm.enabled": false,
+ "acl.oidc.enabled": false,
+ "acl.oidc.client.id": null,
+ "acl.oidc.authorization.endpoint": null,
+ "acl.oidc.token.endpoint": null,
+ "acl.oidc.pkce.required": null,
+ "acl.oidc.groups.encoded.in.token": false,
+ }
});
cy.visit(baseUrl);
});
@@ -52,16 +56,18 @@ describe("Auth - UI", () => {
describe("Auth - OIDC", () => {
before(() => {
interceptSettings({
- "release.type": "EE",
- "release.version": "1.2.3",
- "acl.enabled": true,
- "acl.basic.auth.realm.enabled": false,
- "acl.oidc.enabled": true,
- "acl.oidc.client.id": "test",
- "acl.oidc.authorization.endpoint": "https://host:9999/auth",
- "acl.oidc.token.endpoint": "https://host:9999/token",
- "acl.oidc.pkce.required": true,
- "acl.oidc.groups.encoded.in.token": false,
+ "config": {
+ "release.type": "EE",
+ "release.version": "1.2.3",
+ "acl.enabled": true,
+ "acl.basic.auth.realm.enabled": false,
+ "acl.oidc.enabled": true,
+ "acl.oidc.client.id": "test",
+ "acl.oidc.authorization.endpoint": "https://host:9999/auth",
+ "acl.oidc.token.endpoint": "https://host:9999/token",
+ "acl.oidc.pkce.required": true,
+ "acl.oidc.groups.encoded.in.token": false,
+ }
});
cy.visit(baseUrl);
});
@@ -77,16 +83,18 @@ describe("Auth - OIDC", () => {
describe("Auth - Basic", () => {
before(() => {
interceptSettings({
- "release.type": "EE",
- "release.version": "1.2.3",
- "acl.enabled": true,
- "acl.basic.auth.realm.enabled": true,
- "acl.oidc.enabled": false,
- "acl.oidc.client.id": null,
- "acl.oidc.authorization.endpoint": null,
- "acl.oidc.token.endpoint": null,
- "acl.oidc.pkce.required": null,
- "acl.oidc.groups.encoded.in.token": false,
+ "config": {
+ "release.type": "EE",
+ "release.version": "1.2.3",
+ "acl.enabled": true,
+ "acl.basic.auth.realm.enabled": true,
+ "acl.oidc.enabled": false,
+ "acl.oidc.client.id": null,
+ "acl.oidc.authorization.endpoint": null,
+ "acl.oidc.token.endpoint": null,
+ "acl.oidc.pkce.required": null,
+ "acl.oidc.groups.encoded.in.token": false,
+ }
});
cy.visit(baseUrl);
});
@@ -100,16 +108,18 @@ describe("Auth - Basic", () => {
describe("Auth - Disabled", () => {
before(() => {
interceptSettings({
- "release.type": "EE",
- "release.version": "1.2.3",
- "acl.enabled": false,
- "acl.basic.auth.realm.enabled": true,
- "acl.oidc.enabled": false,
- "acl.oidc.client.id": null,
- "acl.oidc.authorization.endpoint": null,
- "acl.oidc.token.endpoint": null,
- "acl.oidc.pkce.required": null,
- "acl.oidc.groups.encoded.in.token": false,
+ "config": {
+ "release.type": "EE",
+ "release.version": "1.2.3",
+ "acl.enabled": false,
+ "acl.basic.auth.realm.enabled": true,
+ "acl.oidc.enabled": false,
+ "acl.oidc.client.id": null,
+ "acl.oidc.authorization.endpoint": null,
+ "acl.oidc.token.endpoint": null,
+ "acl.oidc.pkce.required": null,
+ "acl.oidc.groups.encoded.in.token": false,
+ }
});
cy.visit(baseUrl);
});
diff --git a/packages/browser-tests/cypress/integration/console/telemetry.spec.js b/packages/browser-tests/cypress/integration/console/telemetry.spec.js
index 924bbbb96..dec180847 100644
--- a/packages/browser-tests/cypress/integration/console/telemetry.spec.js
+++ b/packages/browser-tests/cypress/integration/console/telemetry.spec.js
@@ -22,7 +22,7 @@ describe("telemetry config", () => {
cy.wait("@telemetryConfig").then(({ response }) => {
const columnNames = response.body.columns.map((c) => c.name);
expect(response.statusCode).to.equal(200);
- ["id", "enabled", "version", "os", "package"].forEach((name) => {
+ ["id", "enabled", "version", "os", "package", "instance_name", "instance_type", "instance_desc"].forEach((name) => {
expect(columnNames).to.include(name);
});
expect(response.body.dataset[0][0]).to.be.string;
@@ -34,6 +34,9 @@ describe("telemetry config", () => {
expect(typeof response.body.dataset[0][4]).to.satisfy(
(v) => v === null || typeof v === "string"
);
+ expect(response.body.dataset[0][5]).to.be.string;
+ expect(response.body.dataset[0][6]).to.be.string;
+ expect(response.body.dataset[0][7]).to.be.string;
});
});
});
@@ -62,7 +65,7 @@ describe("telemetry enabled", () => {
it("should start telemetry when enabled", () => {
cy.wait("@telemetryConfig").then(({ response }) => {
cy.wait("@addTelemetry").then(({ request }) => {
- const payload = JSON.parse(request.body);
+ const payload = request.body;
expect(payload.id).to.equal(response.body.dataset[0][0]);
expect(payload.version).to.equal(response.body.dataset[0][2]);
expect(payload.os).to.equal(response.body.dataset[0][3]);
diff --git a/packages/browser-tests/cypress/integration/console/topbar.spec.js b/packages/browser-tests/cypress/integration/console/topbar.spec.js
new file mode 100644
index 000000000..5aac8a446
--- /dev/null
+++ b/packages/browser-tests/cypress/integration/console/topbar.spec.js
@@ -0,0 +1,64 @@
+///
+
+describe("TopBar", () => {
+ beforeEach(() => {
+ cy.loadConsoleWithAuth();
+ });
+
+ it("should show the instance warning and no description", () => {
+ cy.getByDataHook("topbar-instance-name").should(
+ "have.text",
+ "Instance name is not set"
+ );
+ cy.getByDataHook("topbar-instance-badge").should(
+ "have.css",
+ "background-color",
+ "rgb(40, 42, 54)"
+ );
+ });
+
+ it("should preview the color, show error when instance name is empty, and don't save changes on cancel", () => {
+ cy.getByDataHook("topbar-instance-name").realHover();
+ cy.getByDataHook("topbar-instance-edit-icon").should("be.visible");
+ cy.getByDataHook("topbar-instance-edit-icon").click();
+ cy.getByDataHook("topbar-instance-color-option-r").click();
+ cy.getByDataHook("topbar-instance-badge").should(
+ "have.css",
+ "background-color",
+ "rgb(199, 7, 45)"
+ );
+ cy.getByDataHook("topbar-instance-save-button").click();
+ cy.contains("Instance name is required").should("be.visible");
+ cy.getByDataHook("topbar-instance-cancel-button").click();
+ cy.getByDataHook("topbar-instance-name").should(
+ "have.text",
+ "Instance name is not set"
+ );
+ cy.getByDataHook("topbar-instance-badge").should(
+ "have.css",
+ "background-color",
+ "rgb(40, 42, 54)"
+ );
+ });
+
+ it("should change the instance name, description, and type", () => {
+ cy.getByDataHook("topbar-instance-badge").realHover();
+ cy.getByDataHook("topbar-instance-edit-icon").should("be.visible");
+ cy.getByDataHook("topbar-instance-edit-icon").click();
+ cy.getByDataHook("topbar-instance-name-input").type("test-instance");
+ cy.getByDataHook("topbar-instance-type-select").select("production");
+ cy.getByDataHook("topbar-instance-description-input").type(
+ "test description of the test instance"
+ );
+ cy.getByDataHook("topbar-instance-color-option-g").click();
+ cy.getByDataHook("topbar-instance-save-button").click();
+ cy.getByDataHook("topbar-instance-save-button").should("not.exist");
+ cy.getByDataHook("topbar-instance-name").should("contain", "Production");
+ cy.getByDataHook("topbar-instance-name").should("contain", "test-instance");
+ cy.getByDataHook("topbar-instance-icon").realHover();
+ cy.contains("test description of the test instance").should("be.visible");
+ cy.contains(
+ "You are connected to a QuestDB instance for production"
+ ).should("be.visible");
+ });
+});
diff --git a/packages/browser-tests/cypress/integration/enterprise/oidc.spec.js b/packages/browser-tests/cypress/integration/enterprise/oidc.spec.js
index 5eeeea786..3d05a2201 100644
--- a/packages/browser-tests/cypress/integration/enterprise/oidc.spec.js
+++ b/packages/browser-tests/cypress/integration/enterprise/oidc.spec.js
@@ -40,17 +40,19 @@ describe("OIDC authentication", () => {
// load login page
interceptSettings({
- "release.type": "EE",
- "release.version": "1.2.3",
- "acl.enabled": true,
- "acl.basic.auth.realm.enabled": false,
- "acl.oidc.enabled": true,
- "acl.oidc.client.id": "client1",
- "acl.oidc.authorization.endpoint": oidcAuthorizationCodeUrl,
- "acl.oidc.token.endpoint": oidcTokenUrl,
- "acl.oidc.pkce.required": true,
- "acl.oidc.state.required": false,
- "acl.oidc.groups.encoded.in.token": false,
+ "config": {
+ "release.type": "EE",
+ "release.version": "1.2.3",
+ "acl.enabled": true,
+ "acl.basic.auth.realm.enabled": false,
+ "acl.oidc.enabled": true,
+ "acl.oidc.client.id": "client1",
+ "acl.oidc.authorization.endpoint": oidcAuthorizationCodeUrl,
+ "acl.oidc.token.endpoint": oidcTokenUrl,
+ "acl.oidc.pkce.required": true,
+ "acl.oidc.state.required": false,
+ "acl.oidc.groups.encoded.in.token": false,
+ }
});
cy.visit(baseUrl);
diff --git a/packages/browser-tests/questdb b/packages/browser-tests/questdb
index 66e288007..9374c7857 160000
--- a/packages/browser-tests/questdb
+++ b/packages/browser-tests/questdb
@@ -1 +1 @@
-Subproject commit 66e288007684bdc035884a485979da7396de5dae
+Subproject commit 9374c78570f2dc089aff8665316eea294b4ac8a2
diff --git a/packages/web-console/src/components/PopperToggle/index.tsx b/packages/web-console/src/components/PopperToggle/index.tsx
index bbbc53595..1e48d550f 100644
--- a/packages/web-console/src/components/PopperToggle/index.tsx
+++ b/packages/web-console/src/components/PopperToggle/index.tsx
@@ -86,13 +86,27 @@ export const PopperToggle = ({
return
}
- setActive(false)
+ if (_active) {
+ setActive(false)
+ if (onToggle) {
+ onToggle(false)
+ }
+ }
+ },
+ [container, onToggle, triggerElement, _active],
+ )
- if (onToggle) {
- onToggle(false)
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key === "Escape" && _active) {
+ setActive(false)
+
+ if (onToggle) {
+ onToggle(false)
+ }
}
},
- [container, onToggle, triggerElement],
+ [_active, onToggle],
)
usePopperStyles(container, styles.popper)
@@ -100,21 +114,33 @@ export const PopperToggle = ({
useTransition(container, _active, transitionTimeoutId)
useEffect(() => {
- setActive(typeof active === "undefined" ? _active || false : active)
- }, [active, _active])
+ if (typeof active !== "undefined") {
+ setActive(active)
+ }
+ }, [active])
+
+ useEffect(() => {
+ document.body.appendChild(container)
+
+ return () => {
+ clearTimeout(transitionTimeoutId.current)
+ if (document.body.contains(container)) {
+ document.body.removeChild(container)
+ }
+ }
+ }, [container])
useEffect(() => {
document.addEventListener("mousedown", handleMouseDown)
document.addEventListener("touchstart", handleMouseDown)
+ document.addEventListener("keydown", handleKeyDown)
return () => {
- // eslint-disable-next-line react-hooks/exhaustive-deps
- clearTimeout(transitionTimeoutId.current)
document.removeEventListener("mousedown", handleMouseDown)
document.removeEventListener("touchstart", handleMouseDown)
- document.body.contains(container) && document.body.removeChild(container)
+ document.removeEventListener("keydown", handleKeyDown)
}
- }, [container, handleMouseDown])
+ }, [handleMouseDown, handleKeyDown])
return (
<>
diff --git a/packages/web-console/src/components/Toast/index.tsx b/packages/web-console/src/components/Toast/index.tsx
index eea81c39c..4468e7ad0 100644
--- a/packages/web-console/src/components/Toast/index.tsx
+++ b/packages/web-console/src/components/Toast/index.tsx
@@ -2,22 +2,24 @@ import React from "react"
import {
ToastContainer as RTToastContainer,
ToastContainerProps,
- toast as _toast,
+ toast as rtToast,
Slide,
ToastOptions as RTToastOptions,
+ ToastContent,
} from "react-toastify"
import { useNotificationCenter as RTNotificationCenter } from "react-toastify/addons/use-notification-center"
import { NotificationCenterItem as RNotificationCenterItem } from "react-toastify/addons/use-notification-center/useNotificationCenter"
import { BadgeType } from "../../scenes/Import/ImportCSVFiles/types"
import {
- Check,
CloseCircle,
ErrorWarning,
Information,
} from "@styled-icons/remix-line"
+import { CheckmarkOutline } from "@styled-icons/evaicons-outline"
import { theme } from "../../theme"
import "react-toastify/dist/ReactToastify.css"
+import "../../styles/_toast.scss"
interface StyledIconProps
extends React.PropsWithRef> {
@@ -25,33 +27,95 @@ interface StyledIconProps
title?: string | null
}
-export const toast = _toast
+export type ToastOptions = RTToastOptions
export const useNotificationCenter = RTNotificationCenter
export type NotificationCenterItem = RNotificationCenterItem
-export type ToastOptions = RTToastOptions
-
export const ToastIcon = ({
type,
+ size = 18,
...props
}: StyledIconProps & {
type: BadgeType
}) => {
switch (type) {
case BadgeType.SUCCESS:
- return
+ return
case BadgeType.WARNING:
- return
+ return
case BadgeType.ERROR:
- return
+ return
case BadgeType.INFO:
default:
- return
+ return
}
}
+const toast = {
+ info: (content: ToastContent, options?: ToastOptions) => {
+ return rtToast.info(content, {
+ icon: ,
+ className: "toast-info-container",
+ progressStyle: {
+ background: theme.color.cyan,
+ },
+ style: {
+ borderColor: theme.color.cyan,
+ background: theme.color.backgroundLighter,
+ },
+ ...options,
+ })
+ },
+ success: (content: ToastContent, options?: ToastOptions) => {
+ return rtToast.success(content, {
+ icon: ,
+ className: "toast-success-container",
+ progressStyle: {
+ background: theme.color.green,
+ },
+ style: {
+ borderColor: theme.color.green,
+ background: theme.color.backgroundLighter,
+ },
+ ...options,
+ })
+ },
+ warning: (content: ToastContent, options?: ToastOptions) => {
+ return rtToast.warning(content, {
+ icon: ,
+ className: "toast-warning-container",
+ progressStyle: {
+ background: theme.color.orange,
+ },
+ style: {
+ borderColor: theme.color.orange,
+ background: theme.color.backgroundLighter,
+ },
+ ...options,
+ })
+ },
+ error: (content: ToastContent, options?: ToastOptions) => {
+ return rtToast.error(content, {
+ icon: ,
+ progressStyle: {
+ background: theme.color.red,
+ },
+ className: "toast-error-container",
+ style: {
+ borderColor: theme.color.red,
+ background: theme.color.backgroundLighter,
+ },
+ ...options,
+ })
+ },
+ dismiss: rtToast.dismiss,
+ isActive: rtToast.isActive,
+}
+
+export { toast }
+
export const ToastContainer = (props?: ToastContainerProps) => {
const mergedProps: ToastContainerProps = {
autoClose: 3000,
@@ -59,6 +123,11 @@ export const ToastContainer = (props?: ToastContainerProps) => {
position: "top-right",
theme: "dark",
transition: Slide,
+ hideProgressBar: false,
+ closeButton: true,
+ closeOnClick: true,
+ pauseOnHover: true,
+ pauseOnFocusLoss: false,
...props,
}
diff --git a/packages/web-console/src/components/TopBar/InstanceSettingsPopper.tsx b/packages/web-console/src/components/TopBar/InstanceSettingsPopper.tsx
new file mode 100644
index 000000000..20177165b
--- /dev/null
+++ b/packages/web-console/src/components/TopBar/InstanceSettingsPopper.tsx
@@ -0,0 +1,473 @@
+import React, { useState, FormEvent, ReactNode, useRef, useEffect } from "react"
+import styled from "styled-components"
+import { Box, Button, Input, Loader, Select } from "@questdb/react-components"
+import { Text } from "../Text"
+import { PopperToggle } from "../PopperToggle"
+import { Preferences, InstanceType } from "../../utils/questdb/types"
+
+const Wrapper = styled.div`
+ position: absolute;
+ margin-top: 0.5rem;
+ background: ${({ theme }) => theme.color.backgroundDarker};
+ border: 1px solid ${({ theme }) => theme.color.gray1};
+ border-radius: 0.4rem;
+ padding: 1.5rem;
+ width: 32rem;
+ z-index: 1000;
+`
+
+const ColorSelector = styled.div`
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+ align-self: center;
+`
+
+const ColorOption = styled.button<{ colorValue: string; selected: boolean; customColor?: string }>`
+ width: 3rem;
+ height: 3rem;
+ border-radius: 0.4rem;
+ cursor: pointer;
+ border: 2px solid ${({ theme, selected }) => (selected ? theme.color.foreground : "transparent")};
+ padding: 0;
+ background: ${({ colorValue, theme, customColor }) => {
+ if (customColor) return customColor;
+
+ switch (colorValue) {
+ case "r":
+ return "#c7072d"
+ case "g":
+ return "#00aa3b"
+ case "b":
+ return "#007aff"
+ case "default":
+ return theme.color.backgroundLighter
+ default:
+ return "transparent"
+ }
+ }};
+
+ &:focus-visible {
+ outline: 1px solid ${({ theme }) => theme.color.cyan};
+ }
+`
+
+const ColorWheelOption = styled.button<{ selected: boolean }>`
+ position: relative;
+ width: 3rem;
+ height: 3rem;
+ border-radius: 0.4rem;
+ cursor: pointer;
+ border: ${({ selected, theme }) => selected ? `2px solid ${theme.color.foreground}` : "0"};
+ padding: 0;
+ overflow: hidden;
+
+ &:focus-visible {
+ outline: 1px solid ${({ theme }) => theme.color.cyan};
+ }
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: conic-gradient(
+ #ff0000,
+ #ffff00,
+ #00ff00,
+ #00ffff,
+ #0000ff,
+ #ff00ff,
+ #ff0000
+ );
+ }
+`
+
+const ColorPickerContainer = styled.div`
+ margin-top: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ align-self: center;
+`
+
+const ColorInputRow = styled.div`
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+`
+
+const ColorSlider = styled.input.attrs({ type: 'range', min: 0, max: 255 })`
+ flex: 1;
+ height: 1rem;
+ appearance: none;
+ background: linear-gradient(to right, rgb(0,0,0), rgb(255,0,0));
+ border-radius: 0.5rem;
+
+ &::-webkit-slider-thumb {
+ appearance: none;
+ width: 1.8rem;
+ height: 1.8rem;
+ border-radius: 50%;
+ background: white;
+ cursor: pointer;
+ border: 1px solid ${({ theme }) => theme.color.gray1};
+ }
+
+ &.red {
+ background: linear-gradient(to right, rgb(0,0,0), rgb(255,0,0));
+ }
+
+ &.green {
+ background: linear-gradient(to right, rgb(0,0,0), rgb(0,255,0));
+ }
+
+ &.blue {
+ background: linear-gradient(to right, rgb(0,0,0), rgb(0,0,255));
+ }
+`
+
+const ColorValueInput = styled.input.attrs({ type: 'number', min: 0, max: 255 })`
+ width: 6rem;
+ padding: 0.5rem;
+ background: ${({ theme }) => theme.color.selection};
+ border: 1px solid ${({ theme }) => theme.color.gray1};
+ border-radius: 0.4rem;
+ color: ${({ theme }) => theme.color.foreground};
+
+ &:focus {
+ outline: none;
+ border-color: ${({ theme }) => theme.color.cyan};
+ }
+`
+
+const StyledForm = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+`
+
+const StyledButton = styled(Button)`
+ font-size: 1.6rem;
+ &:focus-visible {
+ outline: 2px solid ${({ theme }) => theme.color.cyan};
+ }
+`
+
+const StyledSelect = styled(Select)`
+ &:focus-visible {
+ border: 1px solid ${({ theme }) => theme.color.cyan};
+ }
+`
+
+const Buttons = styled(Box)`
+ margin-top: 1.5rem;
+ gap: 1rem;
+ justify-content: flex-end;
+ flex-direction: row-reverse;
+`
+
+const FormGroup = styled(Box).attrs({ flexDirection: "column", gap: "0.5rem" })`
+ width: 100%;
+ align-items: flex-start;
+`
+
+const StyledInput = styled(Input)`
+ width: 100%;
+ background: ${({ theme }) => theme.color.selection};
+ border: 1px solid ${({ theme }) => theme.color.gray1};
+ border-radius: 0.4rem;
+ color: ${({ theme }) => theme.color.foreground};
+
+ &:focus {
+ outline: none;
+ border-color: ${({ theme }) => theme.color.cyan};
+ }
+
+ &::placeholder {
+ color: ${({ theme }) => theme.color.gray2};
+ }
+`
+
+const TextArea = styled.textarea`
+ width: 100%;
+ min-height: 8rem;
+ padding: 0.8rem;
+ background: ${({ theme }) => theme.color.selection};
+ border: 1px solid ${({ theme }) => theme.color.gray1};
+ border-radius: 0.4rem;
+ color: ${({ theme }) => theme.color.foreground};
+ font-family: inherit;
+ font-size: 1.4rem;
+ line-height: 1.5;
+ resize: vertical;
+
+ &:focus {
+ outline: none;
+ border-color: ${({ theme }) => theme.color.cyan};
+ }
+
+ &::placeholder {
+ color: ${({ theme }) => theme.color.gray2};
+ }
+`
+
+const FormLabel = styled.label<{ align?: 'left' | 'center' }>`
+ text-align: ${props => props.align || 'left'};
+ width: 100%;
+ font-size: 1.6rem;
+`
+
+const ErrorText = styled(Text)`
+ color: ${({ theme }) => theme.color.red};
+ font-size: 1.2rem;
+ margin-top: 0.2rem;
+`
+
+type Props = {
+ active: boolean
+ onToggle: (active: boolean) => void
+ values: Preferences
+ onSave: (values: Preferences) => Promise
+ onValuesChange: (values: Preferences) => void
+ trigger: ReactNode
+}
+
+export const InstanceSettingsPopper = ({
+ active,
+ onToggle,
+ values,
+ onSave,
+ onValuesChange,
+ trigger,
+}: Props) => {
+ const [isSaving, setIsSaving] = useState(false)
+ const [instanceNameError, setInstanceNameError] = useState(null)
+ const [showCustomColor, setShowCustomColor] = useState(false)
+ const [rgbValues, setRgbValues] = useState({ r: 0, g: 0, b: 0 })
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ if (values.instance_rgb && values.instance_rgb.startsWith('rgb')) {
+ setShowCustomColor(true)
+ const matches = values.instance_rgb.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
+ if (matches) {
+ setRgbValues({
+ r: parseInt(matches[1], 10),
+ g: parseInt(matches[2], 10),
+ b: parseInt(matches[3], 10)
+ })
+ }
+ } else {
+ setShowCustomColor(false)
+ }
+ }, [values.instance_rgb])
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+
+ if (!values?.instance_name?.trim()) {
+ setInstanceNameError("Instance name is required")
+ return
+ }
+ setInstanceNameError(null)
+
+ setIsSaving(true)
+ await onSave(values) // Errors are handled in the parent component
+ setIsSaving(false)
+ }
+
+ const handleNameChange = (e: React.ChangeEvent) => {
+ const newValues = { ...values, instance_name: e.target.value }
+ onValuesChange(newValues)
+ }
+
+ const handleColorSelect = (color: string | undefined) => {
+ onValuesChange({ ...values, instance_rgb: color })
+ if (color !== 'custom') {
+ setShowCustomColor(false)
+ }
+ }
+
+ const handleCustomColorSelect = () => {
+ setShowCustomColor(true)
+ onValuesChange({ ...values, instance_rgb: rgbColorString })
+ }
+
+ const handleRgbChange = (component: 'r' | 'g' | 'b', value: number) => {
+ const newValues = { ...rgbValues, [component]: value }
+ setRgbValues(newValues)
+ const newRgbColor = `rgb(${newValues.r}, ${newValues.g}, ${newValues.b})`
+ onValuesChange({ ...values, instance_rgb: newRgbColor })
+ }
+
+ useEffect(() => {
+ if (!values.instance_type) {
+ onValuesChange({ ...values, instance_type: "development" })
+ }
+ if (active) {
+ setTimeout(() => {
+ inputRef.current?.focus()
+ }, 100)
+ }
+ }, [active])
+
+ const rgbColorString = `rgb(${rgbValues.r}, ${rgbValues.g}, ${rgbValues.b})`
+
+ return (
+
+
+
+
+ Instance Name
+
+ {instanceNameError && {instanceNameError}}
+
+
+ Instance Type
+ onValuesChange({ ...values, instance_type: e.target.value as InstanceType })}
+ />
+
+
+ Description
+
+
+ Color
+
+ handleColorSelect('')}
+ data-hook="topbar-instance-color-option-default"
+ />
+ handleColorSelect("r")}
+ data-hook="topbar-instance-color-option-r"
+ />
+ handleColorSelect("g")}
+ data-hook="topbar-instance-color-option-g"
+ />
+ handleColorSelect("b")}
+ data-hook="topbar-instance-color-option-b"
+ />
+
+
+
+ {showCustomColor && (
+
+
+ R
+ handleRgbChange('r', parseInt(e.target.value, 10))}
+ data-hook="topbar-instance-color-slider-r"
+ />
+ handleRgbChange('r', parseInt(e.target.value, 10))}
+ data-hook="topbar-instance-color-input-r"
+ />
+
+
+ G
+ handleRgbChange('g', parseInt(e.target.value, 10))}
+ data-hook="topbar-instance-color-slider-g"
+ />
+ handleRgbChange('g', parseInt(e.target.value, 10))}
+ data-hook="topbar-instance-color-input-g"
+ />
+
+
+ B
+ handleRgbChange('b', parseInt(e.target.value, 10))}
+ data-hook="topbar-instance-color-slider-b"
+ />
+ handleRgbChange('b', parseInt(e.target.value, 10))}
+ data-hook="topbar-instance-color-input-b"
+ />
+
+
+ )}
+
+
+ : undefined}
+ data-hook="topbar-instance-save-button"
+ >
+ Save
+
+ onToggle(false)}
+ skin="secondary"
+ data-hook="topbar-instance-cancel-button"
+ >
+ Cancel
+
+
+
+
+
+ )
+}
diff --git a/packages/web-console/src/components/TopBar/toolbar.tsx b/packages/web-console/src/components/TopBar/toolbar.tsx
index 885f05385..382776733 100644
--- a/packages/web-console/src/components/TopBar/toolbar.tsx
+++ b/packages/web-console/src/components/TopBar/toolbar.tsx
@@ -1,9 +1,13 @@
-import React, { useContext, useEffect, useState } from "react"
+import React, { useContext, useEffect, useState, useCallback } from "react"
import styled from "styled-components"
import { QuestContext, useAuth, useSettings } from "../../providers"
import { Box, Button } from "@questdb/react-components"
import * as QuestDB from "../../utils/questdb"
-import { User as UserIcon, LogoutCircle } from "@styled-icons/remix-line"
+import { User as UserIcon, LogoutCircle, Edit } from "@styled-icons/remix-line"
+import { InfoCircle, Error as ErrorIcon } from "@styled-icons/boxicons-regular"
+import { RocketTakeoff, Tools } from "@styled-icons/bootstrap"
+import { Flask } from "@styled-icons/boxicons-solid"
+import { toast } from '../'
import { Text } from "../Text"
import { selectors } from "../../store"
import { useSelector } from "react-redux"
@@ -11,55 +15,150 @@ import { IconWithTooltip } from "../IconWithTooltip"
import { hasUIAuth, setSSOUserNameWithClientID } from "../../modules/OAuth2/utils"
import { getValue } from "../../utils/localStorage"
import { StoreKey } from "../../utils/localStorage/types"
+import { InstanceSettingsPopper } from "./InstanceSettingsPopper"
+import { Preferences, InstanceType } from "../../utils"
+import { PopperHover, Placement } from "../"
+import { useTheme } from "styled-components"
+import { TelemetryTable } from "../../consts";
+import { TelemetryConfigShape } from "../../store/Telemetry/types";
+import { sendServerInfoTelemetry } from "../../utils/telemetry";
-type ServerDetails = {
- instance_name: string | null
- instance_rgb: string | null
- current_user: string | null
-}
+const EnvIconWrapper = styled.div<{ $background?: string }>`
+ display: flex;
+ align-items: center;
+ padding: 0.3rem;
+ background: ${({ $background }) => $background ?? 'inherit'};
+ border-radius: 0.4rem;
+`
const Root = styled(Box).attrs({ align: "center" })`
gap: 1.5rem;
- flex-shrink: 0;
padding-left: 1.5rem;
white-space: nowrap;
+ display: flex;
+ overflow: hidden;
`
-const Tag = styled(Box).attrs({ align: "center" })`
- height: 2.8rem;
+const CustomTooltipWrapper = styled.div<{ $badgeColors: { primary: string, secondary: string } }>`
+ display: flex;
+ flex-direction: column;
+ padding: 1rem 0;
+ background: ${({ theme }) => theme.color.background};
+ font-size: 1.4rem;
border-radius: 0.8rem;
+ border: 1px solid ${({ $badgeColors }) => $badgeColors.primary};
+`
+
+const FlexRow = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+`
+
+const Title = styled.h4`
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid ${({ theme }) => theme.color.gray1};
+ padding: 0 1rem 1rem;
+ gap: 0.8rem;
+ font-size: 1.4rem;
+ margin-bottom: 0;
+`
+
+const FlexCol = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+`
+
+const Info = styled.div`
+ display: flex;
+ flex-direction: column;
padding: 0 1rem;
- font-family: ${({ theme }) => theme.fontMonospace};
- font-size: 1.6rem;
- font-weight: 600;
+ gap: 1rem;
`
-const Badge = styled(Tag)<{ instance_rgb: ServerDetails["instance_rgb"] }>`
- color: #191a21;
- background: #bbbbbb;
+const Badge = styled(Box)<{ $badgeColors: { primary: string, secondary: string } }>`
+ display: flex;
+ align-items: center;
+ padding: 0 1rem;
+ padding-left: 0.3rem;
+ height: 3rem;
+ border-radius: 0.4rem;
+ flex-shrink: 1;
+ min-width: 0;
+ gap: 0;
+ transition: opacity 0.1s ease;
- ${({ theme, instance_rgb }) =>
- instance_rgb === "r" &&
- `
- color: ${theme.color.foreground};
- background: #c7072d;
- `}
+ ${({ $badgeColors }) => `
+ background: ${$badgeColors.primary};
- ${({ theme, instance_rgb }) =>
- instance_rgb === "g" &&
- `
- color: ${theme.color.foreground};
- background: #00aa3b;
- `}
+ .instance-name {
+ color: ${$badgeColors.secondary};
+ }
+
+ .edit-icon {
+ color: ${$badgeColors.secondary};
- ${({ theme, instance_rgb }) =>
- instance_rgb === "b" &&
- `
- color: ${theme.color.foreground};
- background: #007aff;
+ &:hover {
+ color: ${$badgeColors.primary};
+ background: ${$badgeColors.secondary};
+ }
+ }
`}
-`
+ .instance-name {
+ font-size: 1.6rem;
+ display: inline-flex;
+ gap: 0;
+ align-items: center;
+ vertical-align: middle;
+ overflow: hidden;
+ flex-shrink: 1;
+ margin-left: 0.3rem;
+
+ &-text {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ flex-shrink: 1;
+ min-width: 0;
+ color: inherit;
+ }
+
+ &-type {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ flex-shrink: 0;
+ color: inherit;
+ }
+
+ &.placeholder {
+ color: ${({ theme }) => theme.color.orange};
+ }
+ }
+
+ .edit-icon {
+ cursor: pointer;
+ display: inline;
+ width: 2.2rem;
+ margin-left: 1rem;
+ padding: 0.1rem;
+ background: inherit;
+ border-radius: 0.4rem;
+ flex-shrink: 0;
+
+ &.placeholder {
+ color: ${({ theme }) => theme.color.orange};
+
+ &:hover {
+ color: ${({ theme }) => theme.color.backgroundLighter};
+ background: ${({ theme }) => theme.color.orange};
+ }
+ }
+ }
+`
const User = styled(Box).attrs({ gap: "0.5rem" })`
background: ${({ theme }) => theme.color.backgroundLighter};
border-radius: 0.4rem;
@@ -67,7 +166,6 @@ const User = styled(Box).attrs({ gap: "0.5rem" })`
padding: 0 1rem;
font-weight: 600;
`
-
const EnterpriseBadge = styled.span`
padding: 0 4px;
background: ${({ theme }) => theme.color.pinkDarker};
@@ -79,50 +177,305 @@ const EnterpriseBadge = styled.span`
}
`
+const Separator = styled.span<{ $color: string }>`
+ display: inline-block;
+ flex-shrink: 0;
+ width: 0.15rem;
+ margin: 0 1rem;
+ height: 1.8rem;
+ background: ${({ $color }) => $color};
+
+`
+
+const getSecondaryBadgeColor = (primaryColor: string | null, theme?: any): string => {
+ if (!primaryColor || !primaryColor.startsWith('rgb')) {
+ return theme?.color.foreground || "inherit";
+ }
+
+ const matches = primaryColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
+ if (matches) {
+ const r = parseInt(matches[1], 10) / 255
+ const g = parseInt(matches[2], 10) / 255
+ const b = parseInt(matches[3], 10) / 255
+
+ // Convert RGB to sRGB for better perceptual accuracy
+ const R = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4)
+ const G = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4)
+ const B = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4)
+
+ // Calculate relative luminance using WCAG formula
+ const luminance = 0.2126 * R + 0.7152 * G + 0.0722 * B
+
+ if (luminance < 0.25) {
+ return theme?.color.foreground;
+ } else if (luminance < 0.50) {
+ return theme?.color.gray2;
+ } else if (luminance < 0.75) {
+ return theme?.color.gray1;
+ } else {
+ return theme?.color.background;
+ }
+ }
+ return theme?.color.foreground || "inherit";
+}
+
+const useBadgeColors = (instance_rgb: string | null) => {
+ const theme = useTheme()
+ if (!instance_rgb) {
+ return {
+ primary: theme.color.backgroundLighter,
+ secondary: theme.color.foreground,
+ }
+ }
+
+ if (instance_rgb.startsWith('rgb')) {
+ return {
+ primary: instance_rgb,
+ secondary: getSecondaryBadgeColor(instance_rgb, theme),
+ }
+ }
+
+ if (instance_rgb === 'r') {
+ return {
+ primary: 'rgb(199, 7, 45)',
+ secondary: theme.color.foreground,
+ }
+ }
+
+ if (instance_rgb === 'g') {
+ return {
+ primary: 'rgb(0, 170, 59)',
+ secondary: theme.color.foreground,
+ }
+ }
+
+ if (instance_rgb === 'b') {
+ return {
+ primary: 'rgb(0, 122, 255)',
+ secondary: theme.color.foreground,
+ }
+ }
+
+ return {
+ primary: theme.color.backgroundLighter,
+ secondary: theme.color.foreground,
+ }
+}
+
+const EnvironmentIcon = ({ instanceType, color }: { instanceType: InstanceType | undefined, color?: string }) => {
+ switch (instanceType) {
+ case "development":
+ return
+ case "production":
+ return
+ case "testing":
+ return
+ default:
+ return
+ }
+};
+
+const CustomIconWithTooltip = ({
+ icon,
+ placement,
+ shownValues,
+}: {
+ icon: React.ReactNode,
+ placement: Placement,
+ shownValues: Preferences | null,
+}) => {
+ const badgeColors = useBadgeColors(shownValues?.instance_rgb ?? null)
+
+ return (
+
+
+
+ {shownValues?.instance_type && (
+
+
+
+
+ You are connected to a QuestDB instance for {shownValues?.instance_type}
+
+ )}
+
+
+ Instance Name:
+ {shownValues?.instance_name}
+
+ {shownValues?.instance_description && (
+
+ Description:
+ {shownValues?.instance_description}
+
+ )}
+
+
+
+
+ )
+}
+
+const animateBadgeUpdate = (badge: HTMLElement) => {
+ badge.style.opacity = "0"
+ setTimeout(() => {
+ badge.style.opacity = "1"
+ }, 200)
+ setTimeout(() => {
+ badge.style.opacity = "0"
+ }, 400)
+ setTimeout(() => {
+ badge.style.opacity = "1"
+ }, 600)
+ setTimeout(() => {
+ badge.style.opacity = "0"
+ }, 800)
+ setTimeout(() => {
+ badge.style.opacity = "1"
+ }, 1000)
+}
+
export const Toolbar = () => {
const { quest } = useContext(QuestContext)
- const { settings } = useSettings()
+ const { settings, preferences, refreshSettingsAndPreferences } = useSettings()
const { logout } = useAuth()
const result = useSelector(selectors.query.getResult)
- const [serverDetails, setServerDetails] = useState(null)
+ const [currentUser, setCurrentUser] = useState(null)
+ const [settingsPopperActive, setSettingsPopperActive] = useState(false)
+ const [previewValues, setPreviewValues] = useState(null)
+ const [canEditInstanceName, setCanEditInstanceName] = useState(false)
+ const shownValues = settingsPopperActive ? previewValues : preferences
+ const instanceTypeReadable = shownValues?.instance_type
+ ? shownValues.instance_type.charAt(0).toUpperCase() + shownValues.instance_type.slice(1)
+ : ''
+ const badgeColors = useBadgeColors(shownValues?.instance_rgb ?? null)
+ const theme = useTheme()
const fetchServerDetails = async () => {
try {
- const response = await quest.query(
- "SELECT instance_name, instance_rgb, current_user",
+ const response = await quest.query<{ current_user: string }>(
+ "SELECT current_user",
{
limit: "0,1",
},
)
if (response.type === QuestDB.Type.DQL && response.count === 1) {
const currentUser = response.data[0].current_user
- setServerDetails({
- instance_name: response.data[0].instance_name,
- instance_rgb: response.data[0].instance_rgb,
- current_user: currentUser,
- })
+ setCurrentUser(currentUser)
// an SSO user is logged in, update the SSO username
const authPayload = getValue(StoreKey.AUTH_PAYLOAD)
if (authPayload && currentUser && settings["acl.oidc.client.id"]) {
setSSOUserNameWithClientID(settings["acl.oidc.client.id"], currentUser)
}
+ return currentUser
}
+ return null
} catch (e) {
+ return null
+ }
+ }
+
+ const fetchEditSettingsPermission = async (currentUser: string | null) => {
+ // RBAC is not enabled, everyone can edit the instance name
+ if (!settings["acl.enabled"] || settings['release.type'] === 'OSS') {
+ setCanEditInstanceName(true)
+ return
+ }
+
+ if (!currentUser) {
+ setCanEditInstanceName(false)
return
}
+
+ try {
+ const response = await quest.showPermissions(currentUser)
+ // Admin user has no permissions listed
+ const canEdit = response.type === QuestDB.Type.DQL
+ && (response.count === 0 || response.data.some(d => d.permission === 'SETTINGS'))
+ setCanEditInstanceName(canEdit)
+ } catch (e) {
+ setCanEditInstanceName(false)
+ }
}
useEffect(() => {
- fetchServerDetails()
+ fetchServerDetails().then(fetchEditSettingsPermission)
+ refreshSettingsAndPreferences()
}, [])
useEffect(() => {
if (result && result.type === QuestDB.Type.DDL) {
fetchServerDetails()
+ refreshSettingsAndPreferences()
}
}, [result])
+ const handleSaveSettings = async (values: Preferences) => {
+ try {
+ const result = await quest.savePreferences(values)
+ if (result.success) {
+ await handleToggle(false)
+ toast.success("Instance information updated successfully.")
+
+ const response = await quest.query(`${TelemetryTable.CONFIG} limit -1`)
+ if (response.type === QuestDB.Type.DQL && response.count === 1) {
+ const serverInfo = response.data[0] as TelemetryConfigShape
+ sendServerInfoTelemetry(serverInfo)
+ }
+ return
+ }
+
+ const { preferences: newPreferences } = await refreshSettingsAndPreferences()
+ setPreviewValues(newPreferences)
+ if (result.status === 409) {
+ toast.error("Instance information is updated with the latest changes from the server. Please try updating it again.", { autoClose: 5000 })
+ return
+ }
+
+ throw new Error(result.message)
+ } catch (e) {
+ toast.error("Failed to update instance information: " + e, { autoClose: 5000 })
+ }
+ }
+
+ const handleUpdateInstanceInfo = useCallback(async (inform: boolean = true) => {
+ const currentVersion = preferences?.version
+ const { preferences: newPreferences } = await refreshSettingsAndPreferences()
+ if (currentVersion !== newPreferences.version && inform) {
+ toast.info("Instance information is updated with the latest changes from the server.", { autoClose: 5000 })
+ const badge = document.querySelector('[data-hook="topbar-instance-badge"]')
+ if (badge) {
+ animateBadgeUpdate(badge as HTMLElement)
+ }
+ }
+ return newPreferences
+ }, [refreshSettingsAndPreferences, preferences])
+
+ const handleUpdateInstanceInfoWithInform = useCallback(async () => {
+ const newPreferences = await handleUpdateInstanceInfo(true)
+ if (settingsPopperActive && previewValues?.version !== newPreferences.version) {
+ setPreviewValues(newPreferences)
+ }
+ }, [handleUpdateInstanceInfo, settingsPopperActive, previewValues])
+
+ const handleToggle = useCallback(async (active: boolean) => {
+ const newPreferences = await handleUpdateInstanceInfo(active)
+ setPreviewValues(active ? newPreferences : null)
+ setSettingsPopperActive(active)
+ }, [handleUpdateInstanceInfo])
+
+ useEffect(() => {
+ window.addEventListener("focus", handleUpdateInstanceInfoWithInform)
+
+ return () => {
+ window.removeEventListener("focus", handleUpdateInstanceInfoWithInform)
+ }
+ }, [handleUpdateInstanceInfoWithInform])
+
return (
@@ -135,16 +488,47 @@ export const Toolbar = () => {
/>
)}
+ {preferences && (
+
+
+ {(shownValues?.instance_type) ? (
+ }
+ placement="bottom"
+ shownValues={shownValues}
+ />
+ ) : (
+
+ )}
+
+ {shownValues?.instance_name
+ ?
+ {instanceTypeReadable}
+
+ {shownValues?.instance_name}
+
+ : Instance name is not set
+ }
+ {canEditInstanceName && (
+ }
+ />
+ )}
+
+ )}
- {serverDetails && serverDetails.instance_name && (
-
- {serverDetails.instance_name}
-
- )}
- {settings["acl.enabled"] && serverDetails && serverDetails.current_user && (
+ {settings["acl.enabled"] && currentUser && (
- {serverDetails.current_user}
+ {currentUser}
)}
{hasUIAuth(settings) && (
diff --git a/packages/web-console/src/components/index.ts b/packages/web-console/src/components/index.ts
index acdffcf71..e8c085f35 100644
--- a/packages/web-console/src/components/index.ts
+++ b/packages/web-console/src/components/index.ts
@@ -38,7 +38,6 @@ export * from "./PopperHover"
export * from "./PopperToggle"
export * from "./SwitchButton"
export * from "./Text"
-export * from "./Toast"
export * from "./ToggleButton"
export * from "./Tooltip"
export * from "./Transition"
diff --git a/packages/web-console/src/providers/SettingsProvider/index.tsx b/packages/web-console/src/providers/SettingsProvider/index.tsx
index 233a6d68f..8307808df 100644
--- a/packages/web-console/src/providers/SettingsProvider/index.tsx
+++ b/packages/web-console/src/providers/SettingsProvider/index.tsx
@@ -11,6 +11,7 @@ import { Box, Button, Text } from "@questdb/react-components"
import { Refresh } from "@styled-icons/remix-line"
import {setValue} from "../../utils/localStorage";
import {StoreKey} from "../../utils/localStorage/types";
+import { Preferences } from '../../utils'
enum View {
loading = 0,
@@ -26,9 +27,11 @@ const reducer = (s: State, n: Partial) => ({ ...s, ...n })
const SettingContext = createContext<{
settings: Settings
+ preferences: Preferences
consoleConfig: ConsoleConfig
warnings: Warning[]
-}>({ settings: {}, consoleConfig: {}, warnings: [] })
+ refreshSettingsAndPreferences: () => Promise<{ settings: Settings, preferences: Preferences }>
+}>({ settings: {}, preferences: {}, consoleConfig: {}, warnings: [], refreshSettingsAndPreferences: () => Promise.resolve({ settings: {}, preferences: {} }) })
const connectionError = (
<>
@@ -47,13 +50,14 @@ export const SettingsProvider = ({
}) => {
const [state, dispatch] = useReducer(reducer, initialState)
const [settings, setSettings] = useState({})
+ const [preferences, _setPreferences] = useState({})
const [warnings, setWarnings] = useState([])
const [consoleConfig, setConsoleConfig] = useState({})
const views: { [key in View]: () => React.ReactNode } = {
[View.loading]: () => null,
[View.ready]: () => (
-
+
{children}
),
@@ -101,6 +105,36 @@ export const SettingsProvider = ({
}
}
+ const setPreferences = (preferences: Preferences) => {
+ if (preferences?.instance_name) {
+ const suffix = preferences?.instance_type ? `${preferences.instance_type.charAt(0).toUpperCase()}${preferences.instance_type.slice(1)}` : 'QuestDB'
+ const newTitle = `${preferences.instance_name} | ${suffix}`
+ if (document.title !== newTitle) {
+ document.title = newTitle
+ }
+ }
+ _setPreferences(preferences)
+ }
+
+ const refreshSettingsAndPreferences = async () => {
+ const result = await fetchEndpoint("settings", connectionError)
+ const newSettings = result.config
+ const newPreferences = { version: result["preferences.version"], ...result.preferences }
+ if (result) {
+ setSettings(newSettings)
+ setPreferences(newPreferences)
+ return {
+ settings: newSettings,
+ preferences: newPreferences
+ }
+ }
+
+ return {
+ settings: {},
+ preferences: {}
+ }
+ }
+
useEffect(() => {
const fetchAll = async () => {
const settings = await fetchEndpoint("settings", connectionError)
@@ -110,8 +144,9 @@ export const SettingsProvider = ({
consoleConfigError,
)
if (settings) {
- setSettings(settings)
- setValue(StoreKey.RELEASE_TYPE, settings["release.type"])
+ setSettings(settings.config)
+ setPreferences({ version: settings["preferences.version"], ...settings.preferences })
+ setValue(StoreKey.RELEASE_TYPE, settings.config["release.type"])
}
if (warnings) {
setWarnings(warnings)
diff --git a/packages/web-console/src/scenes/Editor/Menu/index.tsx b/packages/web-console/src/scenes/Editor/Menu/index.tsx
index 8e7c4cb2a..361f7f9dd 100644
--- a/packages/web-console/src/scenes/Editor/Menu/index.tsx
+++ b/packages/web-console/src/scenes/Editor/Menu/index.tsx
@@ -51,10 +51,10 @@ import { Button } from "@questdb/react-components"
import "@docsearch/css"
const Wrapper = styled(PaneMenu)<{ _display: string }>`
- width: 100%;
background: transparent;
z-index: 15;
padding-right: 1rem;
+ flex-shrink: 0;
.algolia-autocomplete {
display: ${({ _display }) => _display} !important;
diff --git a/packages/web-console/src/store/Telemetry/epics.ts b/packages/web-console/src/store/Telemetry/epics.ts
index 968641932..ff1d969f0 100644
--- a/packages/web-console/src/store/Telemetry/epics.ts
+++ b/packages/web-console/src/store/Telemetry/epics.ts
@@ -36,7 +36,6 @@ import {
TelemetryAction,
TelemetryAT,
TelemetryConfigShape,
- TelemetryRemoteConfigShape,
} from "../../types"
import { fromFetch } from "../../utils"
@@ -44,10 +43,11 @@ import * as QuestDB from "../../utils/questdb"
import { getValue } from "../../utils/localStorage"
import { StoreKey } from "../../utils/localStorage/types"
import { AuthPayload } from "../../modules/OAuth2/types"
+import { sendServerInfoTelemetry, getTelemetryTimestamp } from "../../utils/telemetry";
const quest = new QuestDB.Client()
-export const getConfig: Epic = (
+export const getServerInfo: Epic = (
action$,
) =>
action$.pipe(
@@ -91,7 +91,7 @@ export const getConfig: Epic = (
}),
)
-export const getRemoteConfig: Epic = (
+export const getLatestTelemetryTimestamp: Epic = (
action$,
state$,
) =>
@@ -99,32 +99,11 @@ export const getRemoteConfig: Epic = (
ofType(TelemetryAT.SET_CONFIG),
withLatestFrom(state$),
switchMap(([_, state]) => {
- const config = selectors.telemetry.getConfig(state)
-
- const isEE = getValue(StoreKey.RELEASE_TYPE) === "EE"
- if (isEE) {
- fetch(`${API}/add-ent`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(config),
- }).catch( () => {
- })
+ const serverInfo = selectors.telemetry.getConfig(state)
+ if (serverInfo) {
+ sendServerInfoTelemetry(serverInfo)
}
-
- if (config?.enabled) {
- return fromFetch>(
- `${API}/config`,
- {
- method: "POST",
- body: JSON.stringify(config),
- },
- false,
- )
- }
-
- return NEVER
+ return getTelemetryTimestamp(serverInfo)
}),
switchMap((response) => {
if (response.error) {
@@ -232,4 +211,4 @@ export const startTelemetry: Epic = (
}),
)
-export default [getConfig, getRemoteConfig, startTelemetry]
+export default [getServerInfo, getLatestTelemetryTimestamp, startTelemetry]
diff --git a/packages/web-console/src/store/Telemetry/types.ts b/packages/web-console/src/store/Telemetry/types.ts
index 5d899c8d9..5d2198d79 100644
--- a/packages/web-console/src/store/Telemetry/types.ts
+++ b/packages/web-console/src/store/Telemetry/types.ts
@@ -28,6 +28,9 @@ export type TelemetryConfigShape = Readonly<{
version: string
os: string
package: string
+ instance_name: string
+ instance_type: string
+ instance_desc: string
}>
export type TelemetryRemoteConfigShape = Readonly<{
diff --git a/packages/web-console/src/styles/_toast.scss b/packages/web-console/src/styles/_toast.scss
new file mode 100644
index 000000000..6f2d2a60b
--- /dev/null
+++ b/packages/web-console/src/styles/_toast.scss
@@ -0,0 +1,49 @@
+.Toastify__toast-container {
+ top: 4.5rem;
+}
+
+.Toastify__toast {
+ font-family: inherit;
+ font-size: 1.4rem;
+ border: 1px solid transparent;
+ border-radius: 1rem;
+ opacity: 1;
+ align-items: center;
+ display: flex;
+ padding: 0 1.2rem;
+ gap: 1rem;
+}
+
+.Toastify__toast-body {
+ padding: 1rem 0 !important;
+}
+
+.Toastify__toast-icon {
+ margin-right: 1rem;
+ width: 1.8rem;
+ height: 1.8rem;
+}
+
+.Toastify__progress-bar {
+ height: 0.3rem;
+}
+
+.Toastify__close-button {
+ opacity: 0.7;
+ align-self: center;
+ transition: none;
+}
+
+.Toastify__close-button:hover {
+ opacity: 1;
+}
+
+.Toastify__toast-body {
+ display: flex;
+ align-items: center;
+ padding: 0.4rem 0;
+}
+
+.Toastify--animate {
+ animation-duration: 0.2s !important;
+}
\ No newline at end of file
diff --git a/packages/web-console/src/utils/questdb/client.ts b/packages/web-console/src/utils/questdb/client.ts
index 9081ed87a..1736021ab 100644
--- a/packages/web-console/src/utils/questdb/client.ts
+++ b/packages/web-console/src/utils/questdb/client.ts
@@ -22,6 +22,8 @@ import {
UploadOptions,
UploadResult,
Value,
+ Preferences,
+ Permission,
} from "./types"
export class Client {
@@ -336,6 +338,10 @@ export class Client {
return response
}
+ async showPermissions(user: string): Promise> {
+ return await this.query(`SHOW PERMISSIONS ${user};`)
+ }
+
async showColumns(table: string): Promise> {
return await this.query(`SHOW COLUMNS FROM '${table}';`)
}
@@ -427,6 +433,28 @@ export class Client {
})
}
+ async savePreferences(preferences: Preferences): Promise<{ status: number, message?: string, success: boolean }> {
+ const { version, ...prefs } = preferences;
+ const response: Response = await fetch(
+ `settings?version=${version}`,
+ {
+ method: "PUT",
+ headers: this.commonHeaders,
+ body: JSON.stringify(prefs),
+ },
+ )
+ if (!response.ok) {
+ let errorMessage: string
+ try {
+ errorMessage = await extractErrorMessage(response)
+ } catch (e) {
+ errorMessage = response.statusText
+ }
+ return { status: response.status, message: errorMessage, success: false }
+ }
+ return { status: response.status, success: true }
+ }
+
async exportQueryToCsv(query: string) {
try {
const response: Response = await fetch(
@@ -510,3 +538,13 @@ export class Client {
}
}
}
+
+async function extractErrorMessage(response: Response) {
+ const contentType = response.headers.get('Content-Type')
+ if (contentType?.includes('application/json')) {
+ const { error } = await response.json()
+ return error
+ } else {
+ return response.text()
+ }
+}
diff --git a/packages/web-console/src/utils/questdb/types.ts b/packages/web-console/src/utils/questdb/types.ts
index 34d6222ac..6f8054a94 100644
--- a/packages/web-console/src/utils/questdb/types.ts
+++ b/packages/web-console/src/utils/questdb/types.ts
@@ -11,6 +11,24 @@ export enum Type {
NOTICE = "notice",
}
+export type InstanceType = "development" | "production" | "testing"
+
+export type Preferences = Partial<{
+ version: number
+ instance_name: string
+ instance_rgb: string
+ instance_description: string
+ instance_type: InstanceType
+}>
+
+export type Permission = {
+ grant_option: boolean
+ origin: string
+ permission: string
+ table_name: string | null
+ column_name: string | null
+}
+
export type Timings = {
compiler: number
authentication: number
diff --git a/packages/web-console/src/utils/telemetry.ts b/packages/web-console/src/utils/telemetry.ts
new file mode 100644
index 000000000..dd026bcbb
--- /dev/null
+++ b/packages/web-console/src/utils/telemetry.ts
@@ -0,0 +1,34 @@
+import { getValue } from "./localStorage";
+import { StoreKey } from "./localStorage/types";
+import { API } from "../consts";
+import { TelemetryConfigShape, TelemetryRemoteConfigShape } from "../store/Telemetry/types";
+import { fromFetch } from "./fromFetch";
+import { NEVER } from "rxjs"
+
+export const sendServerInfoTelemetry = (serverInfo: Readonly) => {
+ const releaseType = getValue(StoreKey.RELEASE_TYPE);
+ if (releaseType === "EE" || serverInfo?.enabled) {
+ fetch(`${API}/add-ent`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({...serverInfo, releaseType}),
+ }).catch(() => {
+ })
+ }
+}
+
+export const getTelemetryTimestamp = (serverInfo: Readonly | undefined) => {
+ if (serverInfo?.enabled) {
+ return fromFetch>(
+ `${API}/config`,
+ {
+ method: "POST",
+ body: JSON.stringify(serverInfo),
+ },
+ false,
+ )
+ }
+ return NEVER
+}
diff --git a/packages/web-console/webpack.config.js b/packages/web-console/webpack.config.js
index 69541ebab..1923b8e9d 100644
--- a/packages/web-console/webpack.config.js
+++ b/packages/web-console/webpack.config.js
@@ -85,7 +85,8 @@ module.exports = {
{
context: [
config.contextPath + "/imp", config.contextPath + "/exp", config.contextPath + "/exec",
- config.contextPath + "/chk", config.contextPath + "/settings", config.contextPath + "/warnings"
+ config.contextPath + "/chk", config.contextPath + "/settings", config.contextPath + "/warnings",
+ config.contextPath + "/preferences"
],
target: config.backendUrl,
},