mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-21 14:44:00 +00:00
vmui/refactor (#3298)
* refactor: change structure project * refactor: change structure project * fix: add hooks for set query params * refactor: add index for pages * docs: add TESTCASES.md * refactor: restructure components * feat: add base components * feat: add reset styles * feat: add styles for custom panel page * feat: add styles for predefined panels * feat: add style for TracingsView.tsx * feat: add Alerts * feat: add Tooltip.tsx * fix: correct styles * feat: add DatePicker.tsx * feat: add tables * feat: add theme provider * fix: replace using callbacks as props to handlers * fix: correct update time * fix: change TimePicker.tsx * fix: correct styles * fix: update packages * vmui: refactor code, remove material-ui Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
This commit is contained in:
parent
0cc59b9f95
commit
423eb73051
234 changed files with 13819 additions and 10063 deletions
|
@ -1,3 +1,4 @@
|
|||
// eslint-disable-next-line no-undef
|
||||
module.exports = {
|
||||
"env": {
|
||||
"browser": true,
|
||||
|
@ -10,9 +11,7 @@ module.exports = {
|
|||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaFeatures": { "jsx": true },
|
||||
"ecmaVersion": 12,
|
||||
"sourceType": "module"
|
||||
},
|
||||
|
@ -21,32 +20,15 @@ module.exports = {
|
|||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
"indent": [
|
||||
"error",
|
||||
2,
|
||||
{ "SwitchCase": 1 }
|
||||
],
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"double"
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"react/prop-types": 0,
|
||||
"max-lines": [
|
||||
"error",
|
||||
{
|
||||
"max": 1000,
|
||||
"skipBlankLines": true,
|
||||
"skipComments": true,
|
||||
}
|
||||
]
|
||||
"react/jsx-closing-bracket-location": [1, "line-aligned"],
|
||||
"react/jsx-max-props-per-line":[1, { "maximum": 1 }],
|
||||
"react/jsx-first-prop-new-line": [1, "multiline"],
|
||||
"object-curly-spacing": [2, "always"],
|
||||
"indent": ["error", 2, { "SwitchCase": 1 }],
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"quotes": ["error", "double"],
|
||||
"semi": ["error", "always"],
|
||||
"react/prop-types": 0
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
|
@ -56,7 +38,10 @@ module.exports = {
|
|||
"linkComponents": [
|
||||
// Components used as alternatives to <a> for linking, eg. <Link to={ url } />
|
||||
"Hyperlink",
|
||||
{"name": "Link", "linkAttribute": "to"}
|
||||
{
|
||||
"name": "Link", "linkAttribute": "to"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -57,11 +57,11 @@ VMUI can be used to paste into other applications
|
|||
#### Options (JSON):
|
||||
|
||||
| Name | Default | Description |
|
||||
|:------------------------|:--------------:|--------------------------------------------------------------------------------------:|
|
||||
|:------------------------|:-----------:|--------------------------------------------------------------------------------------:|
|
||||
| serverURL | domain name | Can't be changed from the UI |
|
||||
| inputTenantID | - | If the flag is present, the "Tenant ID" field is displayed |
|
||||
| headerStyles.background | `#FFFFFF` | Header background color |
|
||||
| headerStyles.color | `primary.main` | Header font color |
|
||||
| headerStyles.color | `#3F51B5` | Header font color |
|
||||
| palette.primary | `#3F51B5` | used to represent primary interface elements for a user |
|
||||
| palette.secondary | `#F50057` | used to represent secondary interface elements for a user |
|
||||
| palette.error | `#FF4141` | used to represent interface elements that the user should be made aware of |
|
||||
|
@ -75,8 +75,8 @@ VMUI can be used to paste into other applications
|
|||
"serverURL": "http://localhost:8428",
|
||||
"inputTenantID": "true",
|
||||
"headerStyles": {
|
||||
"background": "#fff",
|
||||
"color": "primary.main"
|
||||
"background": "#FFFFFF",
|
||||
"color": "#538DE8"
|
||||
},
|
||||
"palette": {
|
||||
"primary": "#538DE8",
|
||||
|
@ -92,7 +92,7 @@ VMUI can be used to paste into other applications
|
|||
|
||||
#### HTML example:
|
||||
```html
|
||||
<div id="root" data-params='{"serverURL":"http://localhost:8428","inputTenantID":"true","headerStyles":{"background":"#fff","color":"primary.main"},"palette":{"primary":"#538DE8","secondary":"#F76F8E","error":"#FD151B","warning":"#FFB30F","success":"#7BE622","info":"#0F5BFF"}}'></div>
|
||||
<div id="root" data-params='{"serverURL":"http://localhost:8428","inputTenantID":"true","headerStyles":{"background":"#FFFFFF","color":"#538DE8"},"palette":{"primary":"#538DE8","secondary":"#F76F8E","error":"#FD151B","warning":"#FFB30F","success":"#7BE622","info":"#0F5BFF"}}'></div>
|
||||
```
|
||||
|
||||
|
||||
|
|
98
app/vmui/packages/vmui/TESTCASES.md
Normal file
98
app/vmui/packages/vmui/TESTCASES.md
Normal file
|
@ -0,0 +1,98 @@
|
|||
# Test cases
|
||||
|
||||
----
|
||||
|
||||
**Name:** Force execution of a queries
|
||||
|
||||
**Steps:**
|
||||
1. click to button `Execute query`
|
||||
2. click to icon `Refresh dashboard`
|
||||
3. press `enter` on the query field
|
||||
|
||||
**Expected Result:**
|
||||
For each step sends a request and render new data
|
||||
|
||||
----
|
||||
|
||||
**Name:** Time Range with auto refresh
|
||||
|
||||
**Steps:**
|
||||
1. Set absolute time range
|
||||
2. Enable auto refresh
|
||||
3. Change delay auto refresh
|
||||
4. Disable auto refresh
|
||||
|
||||
**Expected Result:**
|
||||
Time range has not changed
|
||||
|
||||
----
|
||||
|
||||
**Name:** Query history
|
||||
|
||||
**Steps:**
|
||||
1. Run query one by one: `1`, `2`, `3`
|
||||
2. Press `Ctrl + ArrowUp`/`Ctrl + ArrowDown` when the query field focus
|
||||
|
||||
**Expected Result:**
|
||||
Query value changes according to execution order (Preserve execution order).
|
||||
<br/>
|
||||
`Ctrl + ArrowUp` - set prev value, `Ctrl + ArrowDown` - set next value
|
||||
|
||||
----
|
||||
|
||||
**Name:** Absolute time range fields
|
||||
|
||||
**Steps:**
|
||||
1. Open `Time range controls`
|
||||
2. Change `From` or `Until` time value
|
||||
3. Click to `Apply`
|
||||
|
||||
**Expected Result:**
|
||||
When you change one of the fields, the second does not change
|
||||
|
||||
----
|
||||
|
||||
**Name:** Auto update after query delete
|
||||
|
||||
**Steps:**
|
||||
1. Add multiple query
|
||||
2. Execute queries
|
||||
3. Delete one of the queries
|
||||
|
||||
**Expected Result:**
|
||||
Graph is automatically updated after the query delete
|
||||
|
||||
----
|
||||
|
||||
**Name:** Query URL params
|
||||
|
||||
**Steps:**
|
||||
1. [Open graph](http://localhost:3000/?g0.range_input=1d&g0.end_input=2022-10-26T14%3A00%3A00&g0.step_input=180&g0.relative_time=none&g0.tab=chart&g0.expr=1&g1.range_input=1d&g1.end_input=2022-10-26T14%3A00%3A00&g1.step_input=180&g1.relative_time=none&g1.tab=chart&g1.expr=2#/) with params:
|
||||
> ?g0.range_input=1d&g0.end_input=2022-10-26T14%3A00%3A00&g0.step_input=180&g0.relative_time=none&g0.tab=chart&g0.expr=1&g1.range_input=1d&g1.end_input=2022-10-26T14%3A00%3A00&g1.step_input=180&g1.relative_time=none&g1.tab=chart&g1.expr=2#/
|
||||
|
||||
**Expected Result:**
|
||||
Executed two query with params:
|
||||
```
|
||||
query: 1 and 2
|
||||
start: 1666706400
|
||||
end: 1666792800
|
||||
step: from "Step value" field (depends on screen width)
|
||||
```
|
||||
- Display two queries: `1` and `2`
|
||||
- Time range from `2022-10-25 16:00:00` to `2022-10-26 16:00:00` (:warning: by UTC +2)
|
||||
- Display tab `Table`
|
||||
|
||||
----
|
||||
|
||||
**Name:** Prometheus query URL params
|
||||
|
||||
**Steps:**
|
||||
1. [Open graph](http://localhost:3000/?g0.expr=node_arp_entries&g0.tab=1&g0.stacked=0&g0.range_input=30m&g0.end_input=2021-09-11%2000%3A00%3A00&g0.moment_input=2021-09-11%2000%3A00%3A00&g0.step_input=6&g1.expr=node_cpu_guest_seconds_total&g1.tab=1&g1.stacked=0&g1.range_input=30m&g1.end_input=2022-12-01%2014%3A00%3A00&g1.moment_input=2022-12-01%2014%3A00%3A00&g1.step_input=6) with params:
|
||||
> ?g0.expr=node_arp_entries&g0.tab=1&g0.stacked=0&g0.range_input=30m&g0.end_input=2021-09-11%2000%3A00%3A00&g0.moment_input=2021-09-11%2000%3A00%3A00&g0.step_input=6&g1.expr=node_cpu_guest_seconds_total&g1.tab=1&g1.stacked=0&g1.range_input=30m&g1.end_input=2022-12-01%2014%3A00%3A00&g1.moment_input=2022-12-01%2014%3A00%3A00&g1.step_input=6
|
||||
|
||||
**Expected Result:**
|
||||
- Display two queries: `node_arp_entries` and `node_cpu_guest_seconds_total`
|
||||
- Time range from `2021-09-11 01:30:00` to `2021-09-11 02:00:00` (:warning: by UTC +2)
|
||||
- Display tab `Table`
|
||||
|
||||
----
|
10796
app/vmui/packages/vmui/package-lock.json
generated
10796
app/vmui/packages/vmui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -4,11 +4,6 @@
|
|||
"private": true,
|
||||
"homepage": "./",
|
||||
"dependencies": {
|
||||
"@date-io/dayjs": "^2.13.1",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@mui/icons-material": "^5.6.0",
|
||||
"@mui/lab": "^5.0.0-alpha.73",
|
||||
"@mui/material": "^5.5.1",
|
||||
"@testing-library/jest-dom": "^5.16.2",
|
||||
"@testing-library/react": "^13.0.0",
|
||||
"@testing-library/user-event": "^14.0.4",
|
||||
|
@ -21,6 +16,7 @@
|
|||
"@types/qs": "^6.9.7",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/webpack-env": "^1.16.3",
|
||||
"classnames": "^2.3.2",
|
||||
"dayjs": "^1.11.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.get": "^4.4.2",
|
||||
|
@ -29,6 +25,7 @@
|
|||
"preact": "^10.7.1",
|
||||
"qs": "^6.10.3",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"sass": "^1.56.0",
|
||||
"typescript": "~4.6.2",
|
||||
"uplot": "^1.6.19",
|
||||
"web-vitals": "^2.1.4"
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
<title>VM UI</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&family=Lato:wght@300;400;700&display=swap" rel="stylesheet">
|
||||
<script src="%PUBLIC_URL%/dashboards/index.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from "preact/compat";
|
||||
import {render, screen} from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
|
||||
test("renders header", () => {
|
||||
|
|
|
@ -1,56 +1,53 @@
|
|||
import React, {FC} from "preact/compat";
|
||||
import {HashRouter, Route, Routes} from "react-router-dom";
|
||||
import {SnackbarProvider} from "./contexts/Snackbar";
|
||||
import {StateProvider} from "./state/common/StateContext";
|
||||
import {AuthStateProvider} from "./state/auth/AuthStateContext";
|
||||
import {GraphStateProvider} from "./state/graph/GraphStateContext";
|
||||
import {CardinalityStateProvider} from "./state/cardinality/CardinalityStateContext";
|
||||
import {TopQueriesStateProvider} from "./state/topQueries/TopQueriesStateContext";
|
||||
import THEME from "./theme/theme";
|
||||
import { ThemeProvider, StyledEngineProvider } from "@mui/material/styles";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import LocalizationProvider from "@mui/lab/LocalizationProvider";
|
||||
import DayjsUtils from "@date-io/dayjs";
|
||||
import router from "./router/index";
|
||||
|
||||
import CustomPanel from "./components/CustomPanel/CustomPanel";
|
||||
import React, { FC, useState } from "preact/compat";
|
||||
import { HashRouter, Route, Routes } from "react-router-dom";
|
||||
import router from "./router";
|
||||
import AppContextProvider from "./contexts/AppContextProvider";
|
||||
import HomeLayout from "./components/Home/HomeLayout";
|
||||
import DashboardsLayout from "./components/PredefinedPanels/DashboardsLayout";
|
||||
import CardinalityPanel from "./components/CardinalityPanel/CardinalityPanel";
|
||||
import TopQueries from "./components/TopQueries/TopQueries";
|
||||
|
||||
import CustomPanel from "./pages/CustomPanel";
|
||||
import DashboardsLayout from "./pages/PredefinedPanels";
|
||||
import CardinalityPanel from "./pages/CardinalityPanel";
|
||||
import TopQueries from "./pages/TopQueries";
|
||||
import ThemeProvider from "./components/Main/ThemeProvider/ThemeProvider";
|
||||
import Spinner from "./components/Main/Spinner/Spinner";
|
||||
|
||||
const App: FC = () => {
|
||||
|
||||
const [loadingTheme, setLoadingTheme] = useState(true);
|
||||
|
||||
if (loadingTheme) return (
|
||||
<>
|
||||
<Spinner/>
|
||||
<ThemeProvider setLoadingTheme={setLoadingTheme}/>;
|
||||
</>
|
||||
);
|
||||
|
||||
return <>
|
||||
<HashRouter>
|
||||
<CssBaseline /> {/* CSS Baseline: kind of normalize.css made by materialUI team - can be scoped */}
|
||||
<LocalizationProvider dateAdapter={DayjsUtils}> {/* Allows datepicker to work with DayJS */}
|
||||
<StyledEngineProvider injectFirst>
|
||||
<ThemeProvider theme={THEME}> {/* Material UI theme customization */}
|
||||
<StateProvider> {/* Serialized into query string, common app settings */}
|
||||
<AuthStateProvider> {/* Auth related info - optionally persisted to Local Storage */}
|
||||
<GraphStateProvider> {/* Graph settings */}
|
||||
<CardinalityStateProvider> {/* Cardinality settings */}
|
||||
<TopQueriesStateProvider> {/* Top Queries settings */}
|
||||
<SnackbarProvider> {/* Display various snackbars */}
|
||||
<AppContextProvider>
|
||||
<Routes>
|
||||
<Route path={"/"} element={<HomeLayout/>}>
|
||||
<Route path={router.home} element={<CustomPanel/>}/>
|
||||
<Route path={router.dashboards} element={<DashboardsLayout/>}/>
|
||||
<Route path={router.cardinality} element={<CardinalityPanel/>} />
|
||||
<Route path={router.topQueries} element={<TopQueries/>} />
|
||||
<Route
|
||||
path={"/"}
|
||||
element={<HomeLayout/>}
|
||||
>
|
||||
<Route
|
||||
path={router.home}
|
||||
element={<CustomPanel/>}
|
||||
/>
|
||||
<Route
|
||||
path={router.dashboards}
|
||||
element={<DashboardsLayout/>}
|
||||
/>
|
||||
<Route
|
||||
path={router.cardinality}
|
||||
element={<CardinalityPanel/>}
|
||||
/>
|
||||
<Route
|
||||
path={router.topQueries}
|
||||
element={<TopQueries/>}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</SnackbarProvider>
|
||||
</TopQueriesStateProvider>
|
||||
</CardinalityStateProvider>
|
||||
</GraphStateProvider>
|
||||
</AuthStateProvider>
|
||||
</StateProvider>
|
||||
</ThemeProvider>
|
||||
</StyledEngineProvider>
|
||||
</LocalizationProvider>
|
||||
</AppContextProvider>
|
||||
</HashRouter>
|
||||
</>;
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {TimeParams} from "../types";
|
||||
import { TimeParams } from "../types";
|
||||
|
||||
export const getQueryRangeUrl = (server: string, query: string, period: TimeParams, nocache: boolean, queryTracing: boolean): string =>
|
||||
`${server}/api/v1/query_range?query=${encodeURIComponent(query)}&start=${period.start}&end=${period.end}&step=${period.step}${nocache ? "&nocache=1" : ""}${queryTracing ? "&trace=1" : ""}`;
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
import React from "preact/compat";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import LinearProgressWithLabel, {linearProgressClasses, LinearProgressProps} from "@mui/material/LinearProgress";
|
||||
import {Box, Typography} from "@mui/material";
|
||||
|
||||
export const BorderLinearProgress = styled(LinearProgressWithLabel)(({ theme }) => ({
|
||||
height: 20,
|
||||
borderRadius: 5,
|
||||
[`&.${linearProgressClasses.colorPrimary}`]: {
|
||||
backgroundColor: theme.palette.grey[theme.palette.mode === "light" ? 200 : 800],
|
||||
},
|
||||
[`& .${linearProgressClasses.bar}`]: {
|
||||
borderRadius: 5,
|
||||
backgroundColor: theme.palette.mode === "light" ? "#1a90ff" : "#308fe8",
|
||||
},
|
||||
}));
|
||||
|
||||
export const BorderLinearProgressWithLabel = (props: LinearProgressProps & { value: number }) => (
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Box sx={{ width: "100%", mr: 1 }}>
|
||||
<BorderLinearProgress variant="determinate" {...props} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 35 }}>
|
||||
<Typography variant="body2" color="text.secondary">{`${props.value.toFixed(2)}%`}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
|
@ -1,103 +0,0 @@
|
|||
import React, {ChangeEvent, FC} from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import QueryEditor from "../../CustomPanel/Configurator/Query/QueryEditor";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline";
|
||||
import {useFetchQueryOptions} from "../../../hooks/useFetchQueryOptions";
|
||||
import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import BasicSwitch from "../../../theme/switch";
|
||||
import {saveToStorage} from "../../../utils/storage";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {ErrorTypes} from "../../../types";
|
||||
|
||||
export interface CardinalityConfiguratorProps {
|
||||
onSetHistory: (step: number, index: number) => void;
|
||||
onSetQuery: (query: string, index: number) => void;
|
||||
onRunQuery: () => void;
|
||||
onTopNChange: (e: ChangeEvent<HTMLTextAreaElement|HTMLInputElement>) => void;
|
||||
onFocusLabelChange: (e: ChangeEvent<HTMLTextAreaElement|HTMLInputElement>) => void;
|
||||
query: string;
|
||||
topN: number;
|
||||
error?: ErrorTypes | string;
|
||||
totalSeries: number;
|
||||
totalLabelValuePairs: number;
|
||||
date: string | null;
|
||||
match: string | null;
|
||||
focusLabel: string | null;
|
||||
}
|
||||
|
||||
const CardinalityConfigurator: FC<CardinalityConfiguratorProps> = ({
|
||||
topN,
|
||||
error,
|
||||
query,
|
||||
onSetHistory,
|
||||
onRunQuery,
|
||||
onSetQuery,
|
||||
onTopNChange,
|
||||
onFocusLabelChange,
|
||||
totalSeries,
|
||||
totalLabelValuePairs,
|
||||
date,
|
||||
match,
|
||||
focusLabel
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const {queryControls: {autocomplete}} = useAppState();
|
||||
const {queryOptions} = useFetchQueryOptions();
|
||||
|
||||
const onChangeAutocomplete = () => {
|
||||
dispatch({type: "TOGGLE_AUTOCOMPLETE"});
|
||||
saveToStorage("AUTOCOMPLETE", !autocomplete);
|
||||
};
|
||||
|
||||
return <Box boxShadow="rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;" p={4} pb={2} mb={2}>
|
||||
<Box>
|
||||
<Box display="grid" gridTemplateColumns="1fr auto auto auto auto" gap="4px" width="100%" mb={4}>
|
||||
<QueryEditor
|
||||
query={query} index={0} autocomplete={autocomplete} queryOptions={queryOptions}
|
||||
error={error} setHistoryIndex={onSetHistory} runQuery={onRunQuery} setQuery={onSetQuery}
|
||||
label={"Time series selector"}
|
||||
/>
|
||||
<Box mr={2}>
|
||||
<TextField
|
||||
label="Number of entries per table"
|
||||
type="number"
|
||||
size="medium"
|
||||
variant="outlined"
|
||||
value={topN}
|
||||
error={topN < 1}
|
||||
helperText={topN < 1 ? "Number must be bigger than zero" : " "}
|
||||
onChange={onTopNChange}/>
|
||||
</Box>
|
||||
<Box mr={2}>
|
||||
<TextField
|
||||
label="Focus label"
|
||||
type="text"
|
||||
size="medium"
|
||||
variant="outlined"
|
||||
value={focusLabel}
|
||||
onChange={onFocusLabelChange} />
|
||||
</Box>
|
||||
<Box>
|
||||
<FormControlLabel label="Autocomplete"
|
||||
control={<BasicSwitch checked={autocomplete} onChange={onChangeAutocomplete}/>}
|
||||
/>
|
||||
</Box>
|
||||
<Tooltip title="Execute Query">
|
||||
<IconButton onClick={onRunQuery} sx={{height: "49px", width: "49px"}}>
|
||||
<PlayCircleOutlineIcon/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
Analyzed <b>{totalSeries}</b> series with <b>{totalLabelValuePairs}</b> "label=value" pairs
|
||||
at <b>{date}</b> {match && <span>for series selector <b>{match}</b></span>}.
|
||||
Show top {topN} entries per table.
|
||||
</Box>
|
||||
</Box>;
|
||||
};
|
||||
|
||||
export default CardinalityConfigurator;
|
|
@ -1,119 +0,0 @@
|
|||
import React, {ChangeEvent, FC, useState} from "react";
|
||||
import {SyntheticEvent} from "react";
|
||||
import {Alert} from "@mui/material";
|
||||
import {useFetchQuery} from "../../hooks/useCardinalityFetch";
|
||||
import {queryUpdater} from "./helpers";
|
||||
import {Data} from "../Table/types";
|
||||
import CardinalityConfigurator from "./CardinalityConfigurator/CardinalityConfigurator";
|
||||
import Spinner from "../common/Spinner";
|
||||
import {useCardinalityDispatch, useCardinalityState} from "../../state/cardinality/CardinalityStateContext";
|
||||
import MetricsContent from "./MetricsContent/MetricsContent";
|
||||
import {DefaultActiveTab, Tabs, TSDBStatus, Containers} from "./types";
|
||||
|
||||
const spinnerContainerStyles = (height: string) => {
|
||||
return {
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
position: "absolute",
|
||||
height: height ?? "50%",
|
||||
background: "rgba(255, 255, 255, 0.7)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1000,
|
||||
};
|
||||
};
|
||||
|
||||
const CardinalityPanel: FC = () => {
|
||||
const cardinalityDispatch = useCardinalityDispatch();
|
||||
|
||||
const {topN, match, date, focusLabel} = useCardinalityState();
|
||||
const configError = "";
|
||||
const [query, setQuery] = useState(match || "");
|
||||
const [queryHistoryIndex, setQueryHistoryIndex] = useState(0);
|
||||
const [queryHistory, setQueryHistory] = useState<string[]>([]);
|
||||
|
||||
const onRunQuery = () => {
|
||||
setQueryHistory(prev => [...prev, query]);
|
||||
setQueryHistoryIndex(prev => prev + 1);
|
||||
cardinalityDispatch({type: "SET_MATCH", payload: query});
|
||||
cardinalityDispatch({type: "RUN_QUERY"});
|
||||
};
|
||||
|
||||
const onSetQuery = (query: string) => {
|
||||
setQuery(query);
|
||||
};
|
||||
|
||||
const onSetHistory = (step: number) => {
|
||||
const newIndexHistory = queryHistoryIndex + step;
|
||||
if (newIndexHistory < 0 || newIndexHistory >= queryHistory.length) return;
|
||||
setQueryHistoryIndex(newIndexHistory);
|
||||
setQuery(queryHistory[newIndexHistory]);
|
||||
};
|
||||
|
||||
const onTopNChange = (e: ChangeEvent<HTMLTextAreaElement|HTMLInputElement>) => {
|
||||
cardinalityDispatch({type: "SET_TOP_N", payload: +e.target.value});
|
||||
};
|
||||
|
||||
const onFocusLabelChange = (e: ChangeEvent<HTMLTextAreaElement|HTMLInputElement>) => {
|
||||
cardinalityDispatch({type: "SET_FOCUS_LABEL", payload: e.target.value});
|
||||
};
|
||||
|
||||
const {isLoading, appConfigurator, error} = useFetchQuery();
|
||||
const [stateTabs, setTab] = useState(appConfigurator.defaultState.defaultActiveTab);
|
||||
const {tsdbStatusData, defaultState, tablesHeaders} = appConfigurator;
|
||||
const handleTabChange = (e: SyntheticEvent, newValue: number) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
setTab({...stateTabs, [e.target.id]: newValue});
|
||||
};
|
||||
|
||||
const handleFilterClick = (key: string) => (e: SyntheticEvent) => {
|
||||
const name = e.currentTarget.id;
|
||||
const query = queryUpdater[key](focusLabel, name);
|
||||
setQuery(query);
|
||||
setQueryHistory(prev => [...prev, query]);
|
||||
setQueryHistoryIndex(prev => prev + 1);
|
||||
cardinalityDispatch({type: "SET_MATCH", payload: query});
|
||||
let newFocusLabel = "";
|
||||
if (key === "labelValueCountByLabelName" || key == "seriesCountByLabelName") {
|
||||
newFocusLabel = name;
|
||||
}
|
||||
cardinalityDispatch({type: "SET_FOCUS_LABEL", payload: newFocusLabel});
|
||||
cardinalityDispatch({type: "RUN_QUERY"});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <Spinner
|
||||
isLoading={isLoading}
|
||||
height={"800px"}
|
||||
containerStyles={spinnerContainerStyles("100%")}
|
||||
title={<Alert color="error" severity="error" sx={{whiteSpace: "pre-wrap", mt: 2}}>
|
||||
Please wait while cardinality stats is calculated. This may take some time if the db contains big number of time series
|
||||
</Alert>}
|
||||
/>}
|
||||
<CardinalityConfigurator error={configError} query={query} onRunQuery={onRunQuery} onSetQuery={onSetQuery}
|
||||
onSetHistory={onSetHistory} onTopNChange={onTopNChange} topN={topN} date={date} match={match}
|
||||
totalSeries={tsdbStatusData.totalSeries} totalLabelValuePairs={tsdbStatusData.totalLabelValuePairs}
|
||||
focusLabel={focusLabel} onFocusLabelChange={onFocusLabelChange}
|
||||
/>
|
||||
{error && <Alert color="error" severity="error" sx={{whiteSpace: "pre-wrap", mt: 2}}>{error}</Alert>}
|
||||
{appConfigurator.keys(focusLabel).map((keyName) => (
|
||||
<MetricsContent
|
||||
key={keyName}
|
||||
sectionTitle={appConfigurator.sectionsTitles(focusLabel)[keyName]}
|
||||
activeTab={stateTabs[keyName as keyof DefaultActiveTab]}
|
||||
rows={tsdbStatusData[keyName as keyof TSDBStatus] as unknown as Data[]}
|
||||
onChange={handleTabChange}
|
||||
onActionClick={handleFilterClick(keyName)}
|
||||
tabs={defaultState.tabs[keyName as keyof Tabs]}
|
||||
chartContainer={defaultState.containerRefs[keyName as keyof Containers<HTMLDivElement>]}
|
||||
totalSeries={appConfigurator.totalSeries(keyName)}
|
||||
tabId={keyName}
|
||||
tableHeaderCells={tablesHeaders[keyName]}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardinalityPanel;
|
|
@ -1,96 +0,0 @@
|
|||
import {FC} from "react";
|
||||
import {Box, Grid, Tab, Tabs, Typography} from "@mui/material";
|
||||
import TableChartIcon from "@mui/icons-material/TableChart";
|
||||
import ShowChartIcon from "@mui/icons-material/ShowChart";
|
||||
import TabPanel from "../../TabPanel/TabPanel";
|
||||
import EnhancedTable from "../../Table/Table";
|
||||
import TableCells from "../TableCells/TableCells";
|
||||
import BarChart from "../../BarChart/BarChart";
|
||||
import {barOptions} from "../../BarChart/consts";
|
||||
import React, {SyntheticEvent} from "react";
|
||||
import {Data, HeadCell} from "../../Table/types";
|
||||
import {MutableRef} from "preact/hooks";
|
||||
|
||||
interface MetricsProperties {
|
||||
rows: Data[];
|
||||
activeTab: number;
|
||||
onChange: (e: SyntheticEvent, newValue: number) => void;
|
||||
onActionClick: (e: SyntheticEvent) => void;
|
||||
tabs: string[];
|
||||
chartContainer: MutableRef<HTMLDivElement> | undefined;
|
||||
totalSeries: number,
|
||||
tabId: string;
|
||||
sectionTitle: string;
|
||||
tableHeaderCells: HeadCell[];
|
||||
}
|
||||
|
||||
const MetricsContent: FC<MetricsProperties> = ({
|
||||
rows,
|
||||
activeTab,
|
||||
onChange,
|
||||
tabs,
|
||||
chartContainer,
|
||||
totalSeries,
|
||||
tabId,
|
||||
onActionClick,
|
||||
sectionTitle,
|
||||
tableHeaderCells,
|
||||
}) => {
|
||||
const tableCells = (row: Data) => (
|
||||
<TableCells
|
||||
row={row}
|
||||
totalSeries={totalSeries}
|
||||
onActionClick={onActionClick}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2} sx={{px: 2}}>
|
||||
<Grid item xs={12} md={12} lg={12}>
|
||||
<Typography gutterBottom variant="h5" component="h5">{sectionTitle}</Typography>
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={onChange} aria-label="basic tabs example">
|
||||
{tabs.map((title: string, i: number) =>
|
||||
<Tab
|
||||
key={title}
|
||||
label={title}
|
||||
aria-controls={`tabpanel-${i}`}
|
||||
id={tabId}
|
||||
iconPosition={"start"}
|
||||
icon={ i === 0 ? <TableChartIcon /> : <ShowChartIcon /> } />
|
||||
)}
|
||||
</Tabs>
|
||||
</Box>
|
||||
{tabs.map((_,idx) =>
|
||||
<div
|
||||
ref={chartContainer}
|
||||
style={{width: "100%", paddingRight: idx !== 0 ? "40px" : 0 }} key={`chart-${idx}`}>
|
||||
<TabPanel value={activeTab} index={idx}>
|
||||
{activeTab === 0 ? <EnhancedTable
|
||||
rows={rows}
|
||||
headerCells={tableHeaderCells}
|
||||
defaultSortColumn={"value"}
|
||||
tableCells={tableCells}
|
||||
/>: <BarChart
|
||||
data={[
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
rows.map((v) => v.name),
|
||||
rows.map((v) => v.value),
|
||||
rows.map((_, i) => i % 12 == 0 ? 1 : i % 10 == 0 ? 2 : 0),
|
||||
]}
|
||||
container={chartContainer?.current || null}
|
||||
configs={barOptions}
|
||||
/>}
|
||||
</TabPanel>
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricsContent;
|
|
@ -1,39 +0,0 @@
|
|||
import {SyntheticEvent} from "react";
|
||||
import React, {FC} from "preact/compat";
|
||||
import {TableCell, ButtonGroup} from "@mui/material";
|
||||
import {Data} from "../../Table/types";
|
||||
import {BorderLinearProgressWithLabel} from "../../BorderLineProgress/BorderLinearProgress";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
|
||||
interface CardinalityTableCells {
|
||||
row: Data,
|
||||
totalSeries: number;
|
||||
onActionClick: (e: SyntheticEvent) => void;
|
||||
}
|
||||
|
||||
const TableCells: FC<CardinalityTableCells> = ({ row, totalSeries, onActionClick }) => {
|
||||
const progress = totalSeries > 0 ? row.value / totalSeries * 100 : -1;
|
||||
return <>
|
||||
<TableCell key={row.name}>{row.name}</TableCell>
|
||||
<TableCell key={row.value}>{row.value}</TableCell>
|
||||
{progress > 0 ? <TableCell key={row.progressValue}>
|
||||
<BorderLinearProgressWithLabel variant="determinate" value={progress} />
|
||||
</TableCell> : null}
|
||||
<TableCell key={"action"}>
|
||||
<ButtonGroup variant="contained">
|
||||
<Tooltip title={`Filter by ${row.name}`}>
|
||||
<IconButton
|
||||
id={row.name}
|
||||
onClick={onActionClick}
|
||||
sx={{height: "20px", width: "20px"}}>
|
||||
<PlayCircleOutlineIcon/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default TableCells;
|
|
@ -1,15 +1,15 @@
|
|||
import React, {FC, useEffect, useRef, useState} from "preact/compat";
|
||||
import uPlot, {Options as uPlotOptions} from "uplot";
|
||||
import useResize from "../../hooks/useResize";
|
||||
import {BarChartProps} from "./types";
|
||||
import React, { FC, useEffect, useRef, useState } from "preact/compat";
|
||||
import uPlot, { Options as uPlotOptions } from "uplot";
|
||||
import useResize from "../../../hooks/useResize";
|
||||
import { BarChartProps } from "./types";
|
||||
import "./style.scss";
|
||||
|
||||
const BarChart: FC<BarChartProps> = ({
|
||||
data,
|
||||
container,
|
||||
configs}) => {
|
||||
configs }) => {
|
||||
|
||||
const uPlotRef = useRef<HTMLDivElement>(null);
|
||||
const [isPanning] = useState(false);
|
||||
const [uPlotInst, setUPlotInst] = useState<uPlot>();
|
||||
const layoutSize = useResize(container);
|
||||
|
||||
|
@ -21,7 +21,6 @@ const BarChart: FC<BarChartProps> = ({
|
|||
const updateChart = (): void => {
|
||||
if (!uPlotInst) return;
|
||||
uPlotInst.setData(data);
|
||||
if (!isPanning) uPlotInst.redraw();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -33,7 +32,7 @@ const BarChart: FC<BarChartProps> = ({
|
|||
|
||||
useEffect(() => updateChart(), [data]);
|
||||
|
||||
return <div style={{pointerEvents: isPanning ? "none" : "auto", height: "100%"}}>
|
||||
return <div style={{ height: "100%" }}>
|
||||
<div ref={uPlotRef}/>
|
||||
</div>;
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
import {seriesBarsPlugin} from "../../utils/uplot/plugin";
|
||||
import {barDisp, getBarSeries} from "../../utils/uplot/series";
|
||||
import {Fill, Stroke} from "../../utils/uplot/types";
|
||||
import {PaddingSide, Series} from "uplot";
|
||||
import { seriesBarsPlugin } from "../../../utils/uplot/plugin";
|
||||
import { barDisp, getBarSeries } from "../../../utils/uplot/series";
|
||||
import { Fill, Stroke } from "../../../utils/uplot/types";
|
||||
import { PaddingSide, Series } from "uplot";
|
||||
|
||||
|
||||
const stroke: Stroke = {
|
||||
|
@ -36,14 +36,14 @@ export const barOptions = {
|
|||
const idxs = u.legend.idxs || [];
|
||||
|
||||
if (u.data === null || idxs.length === 0)
|
||||
return {"Name": null, "Value": null,};
|
||||
return { "Name": null, "Value": null, };
|
||||
|
||||
const dataIdx = idxs[seriesIdx] || 0;
|
||||
|
||||
const build = u.data[0][dataIdx];
|
||||
const duration = u.data[seriesIdx][dataIdx];
|
||||
|
||||
return {"Name": build, "Value": duration};
|
||||
return { "Name": build, "Value": duration };
|
||||
}
|
||||
},
|
||||
] as Series[],
|
|
@ -0,0 +1,36 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.u-legend {
|
||||
font-family: $font-family-global;
|
||||
font-size: $font-size-medium;
|
||||
color: $color-text;
|
||||
|
||||
.u-thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.u-series {
|
||||
display: flex;
|
||||
gap: $padding-small;
|
||||
|
||||
th {
|
||||
display: none;
|
||||
}
|
||||
|
||||
td {
|
||||
&:nth-child(2) {
|
||||
&:after {
|
||||
content: ':';
|
||||
margin-left: $padding-small;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.u-value {
|
||||
display: block;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import {AlignedData as uPlotData, Options as uPlotOptions} from "uplot";
|
||||
import { AlignedData as uPlotData, Options as uPlotOptions } from "uplot";
|
||||
|
||||
export interface BarChartProps {
|
||||
data: uPlotData;
|
|
@ -0,0 +1,41 @@
|
|||
import React, { FC, useMemo } from "preact/compat";
|
||||
import { LegendItemType } from "../../../utils/uplot/types";
|
||||
import LegendItem from "./LegendItem/LegendItem";
|
||||
import "./style.scss";
|
||||
|
||||
interface LegendProps {
|
||||
labels: LegendItemType[];
|
||||
query: string[];
|
||||
onChange: (item: LegendItemType, metaKey: boolean) => void;
|
||||
}
|
||||
|
||||
const Legend: FC<LegendProps> = ({ labels, query, onChange }) => {
|
||||
const groups = useMemo(() => {
|
||||
return Array.from(new Set(labels.map(l => l.group)));
|
||||
}, [labels]);
|
||||
|
||||
return <>
|
||||
<div className="vm-legend">
|
||||
{groups.map((group) => <div
|
||||
className="vm-legend-group"
|
||||
key={group}
|
||||
>
|
||||
<div className="vm-legend-group-title">
|
||||
<span className="vm-legend-group-title__count">Query {group}: </span>
|
||||
<span className="vm-legend-group-title__query">{query[group - 1]}</span>
|
||||
</div>
|
||||
<div>
|
||||
{labels.filter(l => l.group === group).map((legendItem: LegendItemType) =>
|
||||
<LegendItem
|
||||
key={legendItem.label}
|
||||
legend={legendItem}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default Legend;
|
|
@ -0,0 +1,75 @@
|
|||
import React, { FC, useState, useMemo } from "preact/compat";
|
||||
import { MouseEvent } from "react";
|
||||
import { LegendItemType } from "../../../../utils/uplot/types";
|
||||
import { getLegendLabel } from "../../../../utils/uplot/helpers";
|
||||
import "./style.scss";
|
||||
import classNames from "classnames";
|
||||
import Tooltip from "../../../Main/Tooltip/Tooltip";
|
||||
import { getFreeFields } from "./helpers";
|
||||
|
||||
interface LegendItemProps {
|
||||
legend: LegendItemType;
|
||||
onChange: (item: LegendItemType, metaKey: boolean) => void;
|
||||
}
|
||||
|
||||
const LegendItem: FC<LegendItemProps> = ({ legend, onChange }) => {
|
||||
const [copiedValue, setCopiedValue] = useState("");
|
||||
const freeFormFields = useMemo(() => getFreeFields(legend), [legend]);
|
||||
|
||||
const handleClickFreeField = async (val: string, id: string) => {
|
||||
await navigator.clipboard.writeText(val);
|
||||
setCopiedValue(id);
|
||||
setTimeout(() => setCopiedValue(""), 2000);
|
||||
};
|
||||
|
||||
const createHandlerClick = (legend: LegendItemType) => (e: MouseEvent<HTMLDivElement>) => {
|
||||
onChange(legend, e.ctrlKey || e.metaKey);
|
||||
};
|
||||
|
||||
const createHandlerCopy = (freeField: string, id: string) => (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
handleClickFreeField(freeField, id);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-legend-item": true,
|
||||
"vm-legend-item_hide": !legend.checked,
|
||||
})}
|
||||
onClick={createHandlerClick(legend)}
|
||||
>
|
||||
<div
|
||||
className="vm-legend-item__marker"
|
||||
style={{ backgroundColor: legend.color }}
|
||||
/>
|
||||
<div className="vm-legend-item-info">
|
||||
<span className="vm-legend-item-info__label">
|
||||
{getLegendLabel(legend.label)}
|
||||
</span>
|
||||
|
||||
 {
|
||||
{freeFormFields.map(f => (
|
||||
<Tooltip
|
||||
key={f.id}
|
||||
open={copiedValue === f.id}
|
||||
title={"Copied!"}
|
||||
placement="top-center"
|
||||
>
|
||||
<span
|
||||
className="vm-legend-item-info__free-fields"
|
||||
key={f.key}
|
||||
onClick={createHandlerCopy(f.freeField, f.id)}
|
||||
>
|
||||
{f.freeField}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LegendItem;
|
|
@ -0,0 +1,16 @@
|
|||
import { LegendItemType } from "../../../../utils/uplot/types";
|
||||
|
||||
export const getFreeFields = (legend: LegendItemType) => {
|
||||
const keys = Object.keys(legend.freeFormFields).filter(f => f !== "__name__");
|
||||
|
||||
return keys.map(f => {
|
||||
const freeField = `${f}="${legend.freeFormFields[f]}"`;
|
||||
const id = `${legend.label}.${freeField}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
freeField,
|
||||
key: f
|
||||
};
|
||||
});
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-legend-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
grid-gap: $padding-small;
|
||||
align-items: start;
|
||||
justify-content: start;
|
||||
padding: $padding-small $padding-large $padding-small $padding-small;
|
||||
background-color: $color-background-block;
|
||||
cursor: pointer;
|
||||
transition: 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&_hide {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__marker {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
box-sizing: border-box;
|
||||
transition: 0.2s ease;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&-info {
|
||||
font-weight: normal;
|
||||
|
||||
&__label {
|
||||
|
||||
}
|
||||
|
||||
&__free-fields {
|
||||
padding: 3px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:not(:last-child):after {
|
||||
content: ",";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-legend {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: $padding-medium;
|
||||
cursor: default;
|
||||
|
||||
&-group {
|
||||
min-width: 23%;
|
||||
margin: 0 $padding-global $padding-global 0;
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 $padding-small $padding-small;
|
||||
margin-bottom: 1px;
|
||||
border-bottom: $border-divider;
|
||||
|
||||
&__count {
|
||||
font-weight: bold;
|
||||
margin-right: $padding-small;
|
||||
}
|
||||
|
||||
&__query {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,17 +1,18 @@
|
|||
import React, {FC, useCallback, useEffect, useRef, useState} from "preact/compat";
|
||||
import uPlot, {AlignedData as uPlotData, Options as uPlotOptions, Series as uPlotSeries, Range, Scales, Scale} from "uplot";
|
||||
import {defaultOptions} from "../../utils/uplot/helpers";
|
||||
import {dragChart} from "../../utils/uplot/events";
|
||||
import {getAxes, getMinMaxBuffer} from "../../utils/uplot/axes";
|
||||
import {setTooltip} from "../../utils/uplot/tooltip";
|
||||
import {MetricResult} from "../../api/types";
|
||||
import {limitsDurations} from "../../utils/time";
|
||||
import React, { FC, useCallback, useEffect, useRef, useState } from "preact/compat";
|
||||
import uPlot, { AlignedData as uPlotData, Options as uPlotOptions, Series as uPlotSeries, Range, Scales, Scale } from "uplot";
|
||||
import { defaultOptions } from "../../../utils/uplot/helpers";
|
||||
import { dragChart } from "../../../utils/uplot/events";
|
||||
import { getAxes, getMinMaxBuffer } from "../../../utils/uplot/axes";
|
||||
import { setTooltip } from "../../../utils/uplot/tooltip";
|
||||
import { MetricResult } from "../../../api/types";
|
||||
import { limitsDurations } from "../../../utils/time";
|
||||
import throttle from "lodash.throttle";
|
||||
import useResize from "../../../hooks/useResize";
|
||||
import { TimeParams } from "../../../types";
|
||||
import { YaxisState } from "../../../state/graph/reducer";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import "./tooltip.css";
|
||||
import useResize from "../../hooks/useResize";
|
||||
import {TimeParams} from "../../types";
|
||||
import {YaxisState} from "../../state/graph/reducer";
|
||||
import "./style.scss";
|
||||
import classNames from "classnames";
|
||||
|
||||
export interface LineChartProps {
|
||||
metrics: MetricResult[];
|
||||
|
@ -20,35 +21,35 @@ export interface LineChartProps {
|
|||
yaxis: YaxisState;
|
||||
series: uPlotSeries[];
|
||||
unit?: string;
|
||||
setPeriod: ({from, to}: {from: Date, to: Date}) => void;
|
||||
setPeriod: ({ from, to }: {from: Date, to: Date}) => void;
|
||||
container: HTMLDivElement | null
|
||||
}
|
||||
enum typeChartUpdate {xRange = "xRange", yRange = "yRange", data = "data"}
|
||||
|
||||
const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
|
||||
period, yaxis, unit, setPeriod, container}) => {
|
||||
const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
|
||||
period, yaxis, unit, setPeriod, container }) => {
|
||||
|
||||
const uPlotRef = useRef<HTMLDivElement>(null);
|
||||
const [isPanning, setPanning] = useState(false);
|
||||
const [xRange, setXRange] = useState({min: period.start, max: period.end});
|
||||
const [xRange, setXRange] = useState({ min: period.start, max: period.end });
|
||||
const [uPlotInst, setUPlotInst] = useState<uPlot>();
|
||||
const layoutSize = useResize(container);
|
||||
|
||||
const tooltip = document.createElement("div");
|
||||
tooltip.className = "u-tooltip";
|
||||
const tooltipIdx: {seriesIdx: number | null, dataIdx: number | undefined} = {seriesIdx: null, dataIdx: undefined};
|
||||
const tooltipOffset = {left: 0, top: 0};
|
||||
const tooltipIdx: {seriesIdx: number | null, dataIdx: number | undefined} = { seriesIdx: null, dataIdx: undefined };
|
||||
const tooltipOffset = { left: 0, top: 0 };
|
||||
|
||||
const setScale = ({min, max}: { min: number, max: number }): void => {
|
||||
setPeriod({from: new Date(min * 1000), to: new Date(max * 1000)});
|
||||
const setScale = ({ min, max }: { min: number, max: number }): void => {
|
||||
setPeriod({ from: new Date(min * 1000), to: new Date(max * 1000) });
|
||||
};
|
||||
const throttledSetScale = useCallback(throttle(setScale, 500), []);
|
||||
const setPlotScale = ({u, min, max}: { u: uPlot, min: number, max: number }) => {
|
||||
const setPlotScale = ({ u, min, max }: { u: uPlot, min: number, max: number }) => {
|
||||
const delta = (max - min) * 1000;
|
||||
if ((delta < limitsDurations.min) || (delta > limitsDurations.max)) return;
|
||||
u.setScale("x", {min, max});
|
||||
setXRange({min, max});
|
||||
throttledSetScale({min, max});
|
||||
u.setScale("x", { min, max });
|
||||
setXRange({ min, max });
|
||||
throttledSetScale({ min, max });
|
||||
};
|
||||
|
||||
const onReadyChart = (u: uPlot) => {
|
||||
|
@ -57,31 +58,31 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
|
|||
tooltipOffset.top = parseFloat(u.over.style.top);
|
||||
u.root.querySelector(".u-wrap")?.appendChild(tooltip);
|
||||
u.over.addEventListener("mousedown", e => {
|
||||
const {ctrlKey, metaKey} = e;
|
||||
const { ctrlKey, metaKey } = e;
|
||||
const leftClick = e.button === 0;
|
||||
const leftClickWithMeta = leftClick && (ctrlKey || metaKey);
|
||||
if (leftClickWithMeta) {
|
||||
// drag pan
|
||||
dragChart({u, e, setPanning, setPlotScale, factor});
|
||||
dragChart({ u, e, setPanning, setPlotScale, factor });
|
||||
}
|
||||
});
|
||||
|
||||
u.over.addEventListener("wheel", e => {
|
||||
if (!e.ctrlKey && !e.metaKey) return;
|
||||
e.preventDefault();
|
||||
const {width} = u.over.getBoundingClientRect();
|
||||
const { width } = u.over.getBoundingClientRect();
|
||||
const zoomPos = u.cursor.left && u.cursor.left > 0 ? u.cursor.left : 0;
|
||||
const xVal = u.posToVal(zoomPos, "x");
|
||||
const oxRange = (u.scales.x.max || 0) - (u.scales.x.min || 0);
|
||||
const nxRange = e.deltaY < 0 ? oxRange * factor : oxRange / factor;
|
||||
const min = xVal - (zoomPos / width) * nxRange;
|
||||
const max = min + nxRange;
|
||||
u.batch(() => setPlotScale({u, min, max}));
|
||||
u.batch(() => setPlotScale({ u, min, max }));
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const {target, ctrlKey, metaKey, key} = e;
|
||||
const { target, ctrlKey, metaKey, key } = e;
|
||||
const isInput = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
|
||||
if (!uPlotInst || isInput) return;
|
||||
const minus = key === "-";
|
||||
|
@ -101,7 +102,7 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
|
|||
if (tooltipIdx.dataIdx === u.cursor.idx) return;
|
||||
tooltipIdx.dataIdx = u.cursor.idx || 0;
|
||||
if (tooltipIdx.seriesIdx !== null && tooltipIdx.dataIdx !== undefined) {
|
||||
setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit});
|
||||
setTooltip({ u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit });
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -109,7 +110,7 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
|
|||
if (tooltipIdx.seriesIdx === sidx) return;
|
||||
tooltipIdx.seriesIdx = sidx;
|
||||
sidx && tooltipIdx.dataIdx !== undefined
|
||||
? setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit})
|
||||
? setTooltip({ u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit })
|
||||
: tooltip.style.display = "none";
|
||||
};
|
||||
const getRangeX = (): Range.MinMax => [xRange.min, xRange.max];
|
||||
|
@ -119,10 +120,10 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
|
|||
};
|
||||
|
||||
const getScales = (): Scales => {
|
||||
const scales: { [key: string]: { range: Scale.Range } } = {x: {range: getRangeX}};
|
||||
const scales: { [key: string]: { range: Scale.Range } } = { x: { range: getRangeX } };
|
||||
const ranges = Object.keys(yaxis.limits.range);
|
||||
(ranges.length ? ranges : ["1"]).forEach(axis => {
|
||||
scales[axis] = {range: (u: uPlot, min = 0, max = 1) => getRangeY(u, min, max, axis)};
|
||||
scales[axis] = { range: (u: uPlot, min = 0, max = 1) => getRangeY(u, min, max, axis) };
|
||||
});
|
||||
return scales;
|
||||
};
|
||||
|
@ -130,16 +131,16 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
|
|||
const options: uPlotOptions = {
|
||||
...defaultOptions,
|
||||
series,
|
||||
axes: getAxes( [{}, {scale: "1"}], unit),
|
||||
scales: {...getScales()},
|
||||
axes: getAxes( [{}, { scale: "1" }], unit),
|
||||
scales: { ...getScales() },
|
||||
width: layoutSize.width || 400,
|
||||
plugins: [{hooks: {ready: onReadyChart, setCursor, setSeries: seriesFocus}}],
|
||||
plugins: [{ hooks: { ready: onReadyChart, setCursor, setSeries: seriesFocus } }],
|
||||
hooks: {
|
||||
setSelect: [
|
||||
(u) => {
|
||||
const min = u.posToVal(u.select.left, "x");
|
||||
const max = u.posToVal(u.select.left + u.select.width, "x");
|
||||
setPlotScale({u, min, max});
|
||||
setPlotScale({ u, min, max });
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -164,13 +165,13 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
|
|||
if (!isPanning) uPlotInst.redraw();
|
||||
};
|
||||
|
||||
useEffect(() => setXRange({min: period.start, max: period.end}), [period]);
|
||||
useEffect(() => setXRange({ min: period.start, max: period.end }), [period]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uPlotRef.current) return;
|
||||
const u = new uPlot(options, data, uPlotRef.current);
|
||||
setUPlotInst(u);
|
||||
setXRange({min: period.start, max: period.end});
|
||||
setXRange({ min: period.start, max: period.end });
|
||||
return u.destroy;
|
||||
}, [uPlotRef.current, series, layoutSize]);
|
||||
|
||||
|
@ -186,9 +187,16 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
|
|||
useEffect(() => updateChart(typeChartUpdate.xRange), [xRange]);
|
||||
useEffect(() => updateChart(typeChartUpdate.yRange), [yaxis]);
|
||||
|
||||
return <div style={{pointerEvents: isPanning ? "none" : "auto", height: "500px"}}>
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-line-chart": true,
|
||||
"vm-line-chart_panning": isPanning
|
||||
})}
|
||||
>
|
||||
<div ref={uPlotRef}/>
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LineChart;
|
|
@ -0,0 +1,51 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-line-chart {
|
||||
height: 500px;
|
||||
pointer-events: auto;
|
||||
|
||||
&_panning {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.u-tooltip {
|
||||
position: absolute;
|
||||
display: none;
|
||||
grid-gap: $padding-global;
|
||||
max-width: 300px;
|
||||
padding: $padding-small;
|
||||
border-radius: $border-radius-medium;
|
||||
background: $color-background-tooltip;
|
||||
color: $color-white;
|
||||
font-size: $font-size-small;
|
||||
font-weight: normal;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
|
||||
&-data {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
line-height: 150%;
|
||||
|
||||
&__value {
|
||||
padding: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: grid;
|
||||
grid-gap: 4px;
|
||||
}
|
||||
|
||||
&__marker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: $padding-small;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import React, { FC } from "preact/compat";
|
||||
import StepConfigurator from "../StepConfigurator/StepConfigurator";
|
||||
import { useGraphDispatch } from "../../../state/graph/GraphStateContext";
|
||||
import { getAppModeParams } from "../../../utils/app-mode";
|
||||
import TenantsConfiguration from "../TenantsConfiguration/TenantsConfiguration";
|
||||
import { useCustomPanelDispatch, useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext";
|
||||
import { useTimeState } from "../../../state/time/TimeStateContext";
|
||||
import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext";
|
||||
import "./style.scss";
|
||||
import Switch from "../../Main/Switch/Switch";
|
||||
|
||||
const AdditionalSettings: FC = () => {
|
||||
|
||||
const graphDispatch = useGraphDispatch();
|
||||
const { inputTenantID } = getAppModeParams();
|
||||
|
||||
const { autocomplete } = useQueryState();
|
||||
const queryDispatch = useQueryDispatch();
|
||||
|
||||
const { nocache, isTracingEnabled } = useCustomPanelState();
|
||||
const customPanelDispatch = useCustomPanelDispatch();
|
||||
|
||||
const { period: { step } } = useTimeState();
|
||||
|
||||
const onChangeCache = () => {
|
||||
customPanelDispatch({ type: "TOGGLE_NO_CACHE" });
|
||||
};
|
||||
|
||||
const onChangeQueryTracing = () => {
|
||||
customPanelDispatch({ type: "TOGGLE_QUERY_TRACING" });
|
||||
};
|
||||
|
||||
const onChangeAutocomplete = () => {
|
||||
queryDispatch({ type: "TOGGLE_AUTOCOMPLETE" });
|
||||
};
|
||||
|
||||
const onChangeStep = (value: number) => {
|
||||
graphDispatch({ type: "SET_CUSTOM_STEP", payload: value });
|
||||
};
|
||||
|
||||
return <div className="vm-additional-settings">
|
||||
<Switch
|
||||
label={"Autocomplete"}
|
||||
value={autocomplete}
|
||||
onChange={onChangeAutocomplete}
|
||||
/>
|
||||
<Switch
|
||||
label={"Disable cache"}
|
||||
value={nocache}
|
||||
onChange={onChangeCache}
|
||||
/>
|
||||
<Switch
|
||||
label={"Trace query"}
|
||||
value={isTracingEnabled}
|
||||
onChange={onChangeQueryTracing}
|
||||
/>
|
||||
<div className="vm-additional-settings__input">
|
||||
<StepConfigurator
|
||||
defaultStep={step}
|
||||
setStep={onChangeStep}
|
||||
/>
|
||||
</div>
|
||||
{!!inputTenantID && (
|
||||
<div className="vm-additional-settings__input">
|
||||
<TenantsConfiguration/>
|
||||
</div>
|
||||
)}
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default AdditionalSettings;
|
|
@ -0,0 +1,14 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-additional-settings {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
|
||||
&__input {
|
||||
flex-basis: 160px;
|
||||
margin-bottom: -6px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import React, { FC, useMemo, useRef } from "preact/compat";
|
||||
import { useCardinalityState, useCardinalityDispatch } from "../../../state/cardinality/CardinalityStateContext";
|
||||
import dayjs from "dayjs";
|
||||
import Button from "../../Main/Button/Button";
|
||||
import { CalendarIcon } from "../../Main/Icons";
|
||||
import Tooltip from "../../Main/Tooltip/Tooltip";
|
||||
import { getAppModeEnable } from "../../../utils/app-mode";
|
||||
import { DATE_FORMAT } from "../../../constants/date";
|
||||
import DatePicker from "../../Main/DatePicker/DatePicker";
|
||||
|
||||
const CardinalityDatePicker: FC = () => {
|
||||
const appModeEnable = getAppModeEnable();
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { date } = useCardinalityState();
|
||||
const cardinalityDispatch = useCardinalityDispatch();
|
||||
|
||||
const dateFormatted = useMemo(() => dayjs(date).format(DATE_FORMAT), [date]);
|
||||
|
||||
const handleChangeDate = (val: string) => {
|
||||
cardinalityDispatch({ type: "SET_DATE", payload: val });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={buttonRef}>
|
||||
<Tooltip title="Date control">
|
||||
<Button
|
||||
className={appModeEnable ? "" : "vm-header-button"}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<CalendarIcon/>}
|
||||
>
|
||||
{dateFormatted}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<DatePicker
|
||||
date={date || ""}
|
||||
format={DATE_FORMAT}
|
||||
onChange={handleChangeDate}
|
||||
targetRef={buttonRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardinalityDatePicker;
|
|
@ -0,0 +1,74 @@
|
|||
import React, { FC, useState } from "preact/compat";
|
||||
import ServerConfigurator from "./ServerConfigurator/ServerConfigurator";
|
||||
import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
|
||||
import { SettingsIcon } from "../../Main/Icons";
|
||||
import Button from "../../Main/Button/Button";
|
||||
import Modal from "../../Main/Modal/Modal";
|
||||
import "./style.scss";
|
||||
import Tooltip from "../../Main/Tooltip/Tooltip";
|
||||
|
||||
const title = "Setting Server URL";
|
||||
|
||||
const GlobalSettings: FC = () => {
|
||||
|
||||
const { serverUrl } = useAppState();
|
||||
const dispatch = useAppDispatch();
|
||||
const [changedServerUrl, setChangedServerUrl] = useState(serverUrl);
|
||||
|
||||
const setServer = (url?: string) => {
|
||||
dispatch({ type: "SET_SERVER", payload: url || changedServerUrl });
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const createSetServer = () => () => {
|
||||
setServer();
|
||||
};
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleOpen = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
return <>
|
||||
<Tooltip title={title}>
|
||||
<Button
|
||||
className="vm-header-button"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<SettingsIcon/>}
|
||||
onClick={handleOpen}
|
||||
/>
|
||||
</Tooltip>
|
||||
{open && (
|
||||
<Modal
|
||||
title={title}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="vm-server-configurator">
|
||||
<div className="vm-server-configurator__input">
|
||||
<ServerConfigurator
|
||||
setServer={setChangedServerUrl}
|
||||
onEnter={setServer}
|
||||
/>
|
||||
</div>
|
||||
<div className="vm-server-configurator__footer">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={createSetServer()}
|
||||
>
|
||||
apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>;
|
||||
};
|
||||
|
||||
export default GlobalSettings;
|
|
@ -0,0 +1,43 @@
|
|||
import React, { FC, useState } from "preact/compat";
|
||||
import { useAppState } from "../../../../state/common/StateContext";
|
||||
import { ErrorTypes } from "../../../../types";
|
||||
import TextField from "../../../Main/TextField/TextField";
|
||||
import { isValidHttpUrl } from "../../../../utils/url";
|
||||
|
||||
export interface ServerConfiguratorProps {
|
||||
setServer: (url: string) => void
|
||||
onEnter: (url: string) => void
|
||||
}
|
||||
|
||||
const ServerConfigurator: FC<ServerConfiguratorProps> = ({ setServer , onEnter }) => {
|
||||
|
||||
const { serverUrl } = useAppState();
|
||||
const [error, setError] = useState("");
|
||||
const [changedServerUrl, setChangedServerUrl] = useState(serverUrl);
|
||||
|
||||
const onChangeServer = (val: string) => {
|
||||
const value = val || "";
|
||||
setChangedServerUrl(value);
|
||||
setServer(value);
|
||||
setError("");
|
||||
if (!value) setError(ErrorTypes.emptyServer);
|
||||
if (!isValidHttpUrl(value)) setError(ErrorTypes.validServer);
|
||||
};
|
||||
|
||||
const handleEnter = () => {
|
||||
onEnter(changedServerUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<TextField
|
||||
autofocus
|
||||
label="Server URL"
|
||||
value={changedServerUrl}
|
||||
error={error}
|
||||
onChange={onChangeServer}
|
||||
onEnter={handleEnter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerConfigurator;
|
|
@ -0,0 +1,22 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-server-configurator {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: $padding-global;
|
||||
width: 600px;
|
||||
|
||||
&__input {
|
||||
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: $padding-small;
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import React, { FC, useCallback, useMemo } from "preact/compat";
|
||||
import debounce from "lodash.debounce";
|
||||
import { AxisRange, YaxisState } from "../../../../state/graph/reducer";
|
||||
import "./style.scss";
|
||||
import TextField from "../../../Main/TextField/TextField";
|
||||
import Switch from "../../../Main/Switch/Switch";
|
||||
|
||||
interface AxesLimitsConfiguratorProps {
|
||||
yaxis: YaxisState,
|
||||
setYaxisLimits: (limits: AxisRange) => void,
|
||||
toggleEnableLimits: () => void
|
||||
}
|
||||
|
||||
const AxesLimitsConfigurator: FC<AxesLimitsConfiguratorProps> = ({ yaxis, setYaxisLimits, toggleEnableLimits }) => {
|
||||
|
||||
const axes = useMemo(() => Object.keys(yaxis.limits.range), [yaxis.limits.range]);
|
||||
|
||||
const onChangeLimit = (value: string, axis: string, index: number) => {
|
||||
const newLimits = yaxis.limits.range;
|
||||
newLimits[axis][index] = +value;
|
||||
if (newLimits[axis][0] === newLimits[axis][1] || newLimits[axis][0] > newLimits[axis][1]) return;
|
||||
setYaxisLimits(newLimits);
|
||||
};
|
||||
const debouncedOnChangeLimit = useCallback(debounce(onChangeLimit, 500), [yaxis.limits.range]);
|
||||
|
||||
const createHandlerOnchangeAxis = (axis: string, index: number) => (val: string) => {
|
||||
debouncedOnChangeLimit(val, axis, index);
|
||||
};
|
||||
|
||||
return <div className="vm-axes-limits">
|
||||
<Switch
|
||||
value={yaxis.limits.enable}
|
||||
onChange={toggleEnableLimits}
|
||||
label="Fix the limits for y-axis"
|
||||
/>
|
||||
<div className="vm-axes-limits-list">
|
||||
{axes.map(axis => (
|
||||
<div
|
||||
className="vm-axes-limits-list__inputs"
|
||||
key={axis}
|
||||
>
|
||||
<TextField
|
||||
label={`Min ${axis}`}
|
||||
type="number"
|
||||
disabled={!yaxis.limits.enable}
|
||||
value={yaxis.limits.range[axis][0]}
|
||||
onChange={createHandlerOnchangeAxis(axis, 0)}
|
||||
/>
|
||||
<TextField
|
||||
label={`Max ${axis}`}
|
||||
type="number"
|
||||
disabled={!yaxis.limits.enable}
|
||||
value={yaxis.limits.range[axis][1]}
|
||||
onChange={createHandlerOnchangeAxis(axis, 1)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default AxesLimitsConfigurator;
|
|
@ -0,0 +1,20 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-axes-limits {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: $padding-global;
|
||||
max-width: 300px;
|
||||
|
||||
&-list {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: $padding-global;
|
||||
|
||||
&__inputs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 120px);
|
||||
gap: $padding-small;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import React, { FC, useRef, useState } from "preact/compat";
|
||||
import AxesLimitsConfigurator from "./AxesLimitsConfigurator/AxesLimitsConfigurator";
|
||||
import { AxisRange, YaxisState } from "../../../state/graph/reducer";
|
||||
import { CloseIcon, SettingsIcon } from "../../Main/Icons";
|
||||
import Button from "../../Main/Button/Button";
|
||||
import useClickOutside from "../../../hooks/useClickOutside";
|
||||
import Popper from "../../Main/Popper/Popper";
|
||||
import "./style.scss";
|
||||
import Tooltip from "../../Main/Tooltip/Tooltip";
|
||||
|
||||
const title = "Axes settings";
|
||||
|
||||
interface GraphSettingsProps {
|
||||
yaxis: YaxisState,
|
||||
setYaxisLimits: (limits: AxisRange) => void,
|
||||
toggleEnableLimits: () => void
|
||||
}
|
||||
|
||||
const GraphSettings: FC<GraphSettingsProps> = ({ yaxis, setYaxisLimits, toggleEnableLimits }) => {
|
||||
const popperRef = useRef<HTMLDivElement>(null);
|
||||
const [openPopper, setOpenPopper] = useState(false);
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(popperRef, () => setOpenPopper(false), buttonRef);
|
||||
|
||||
const toggleOpen = () => {
|
||||
setOpenPopper(prev => !prev);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpenPopper(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-graph-settings">
|
||||
<Tooltip title={title}>
|
||||
<div ref={buttonRef}>
|
||||
<Button
|
||||
variant="text"
|
||||
startIcon={<SettingsIcon/>}
|
||||
onClick={toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Popper
|
||||
open={openPopper}
|
||||
buttonRef={buttonRef}
|
||||
placement="bottom-right"
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div
|
||||
className="vm-graph-settings-popper"
|
||||
ref={popperRef}
|
||||
>
|
||||
<div className="vm-popper-header">
|
||||
<h3 className="vm-popper-header__title">
|
||||
{title}
|
||||
</h3>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<CloseIcon/>}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
</div>
|
||||
<div className="vm-graph-settings-popper__body">
|
||||
<AxesLimitsConfigurator
|
||||
yaxis={yaxis}
|
||||
setYaxisLimits={setYaxisLimits}
|
||||
toggleEnableLimits={toggleEnableLimits}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GraphSettings;
|
|
@ -0,0 +1,15 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-graph-settings {
|
||||
&-popper {
|
||||
display: grid;
|
||||
gap: $padding-global;
|
||||
padding: 0 0 $padding-global;
|
||||
|
||||
&__body {
|
||||
display: grid;
|
||||
gap: $padding-small;
|
||||
padding: 0 $padding-global;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,158 @@
|
|||
import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
|
||||
import { KeyboardEvent } from "react";
|
||||
import { ErrorTypes } from "../../../types";
|
||||
import TextField from "../../Main/TextField/TextField";
|
||||
import Popper from "../../Main/Popper/Popper";
|
||||
import useClickOutside from "../../../hooks/useClickOutside";
|
||||
import "./style.scss";
|
||||
import classNames from "classnames";
|
||||
|
||||
export interface QueryEditorProps {
|
||||
onChange: (query: string) => void;
|
||||
onEnter: () => void;
|
||||
onArrowUp: () => void;
|
||||
onArrowDown: () => void;
|
||||
value: string;
|
||||
oneLiner?: boolean;
|
||||
autocomplete: boolean;
|
||||
error?: ErrorTypes | string;
|
||||
options: string[];
|
||||
label: string;
|
||||
size?: "small" | "medium" | undefined;
|
||||
}
|
||||
|
||||
const QueryEditor: FC<QueryEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onEnter,
|
||||
onArrowUp,
|
||||
onArrowDown,
|
||||
autocomplete,
|
||||
error,
|
||||
options,
|
||||
label,
|
||||
}) => {
|
||||
|
||||
const [focusOption, setFocusOption] = useState(-1);
|
||||
const [openAutocomplete, setOpenAutocomplete] = useState(false);
|
||||
|
||||
const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
|
||||
const wrapperEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
const foundOptions = useMemo(() => {
|
||||
setFocusOption(0);
|
||||
if (!openAutocomplete) return [];
|
||||
try {
|
||||
const regexp = new RegExp(String(value), "i");
|
||||
const found = options.filter((item) => regexp.test(item) && (item !== value));
|
||||
return found.sort((a,b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0));
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}, [openAutocomplete, options]);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const { key, ctrlKey, metaKey, shiftKey } = e;
|
||||
|
||||
const ctrlMetaKey = ctrlKey || metaKey;
|
||||
const arrowUp = key === "ArrowUp";
|
||||
const arrowDown = key === "ArrowDown";
|
||||
const enter = key === "Enter";
|
||||
|
||||
const hasAutocomplete = openAutocomplete && foundOptions.length;
|
||||
|
||||
const isArrows = arrowUp || arrowDown;
|
||||
const arrowsByOptions = isArrows && hasAutocomplete;
|
||||
const arrowsByHistory = isArrows && ctrlMetaKey;
|
||||
const enterByOptions = enter && hasAutocomplete;
|
||||
|
||||
if (arrowsByOptions || arrowsByHistory || enterByOptions) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// ArrowUp
|
||||
if (arrowUp && hasAutocomplete && !ctrlMetaKey) {
|
||||
setFocusOption((prev) => prev === 0 ? 0 : prev - 1);
|
||||
} else if (arrowUp && ctrlMetaKey) {
|
||||
onArrowUp();
|
||||
}
|
||||
|
||||
// ArrowDown
|
||||
if (arrowDown && hasAutocomplete && !ctrlMetaKey) {
|
||||
setFocusOption((prev) => prev >= foundOptions.length - 1 ? foundOptions.length - 1 : prev + 1);
|
||||
} else if (arrowDown && ctrlMetaKey) {
|
||||
onArrowDown();
|
||||
}
|
||||
|
||||
// Enter
|
||||
if (enter && hasAutocomplete && !shiftKey && !ctrlMetaKey) {
|
||||
onChange(foundOptions[focusOption]);
|
||||
setOpenAutocomplete(false);
|
||||
} else if (enter && !shiftKey) {
|
||||
onEnter();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseAutocomplete = () => {
|
||||
setOpenAutocomplete(false);
|
||||
};
|
||||
|
||||
const createHandlerOnChangeAutocomplete = (item: string) => () => {
|
||||
onChange(item);
|
||||
handleCloseAutocomplete();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const words = (value.match(/[a-zA-Z_:.][a-zA-Z0-9_:.]*/gm) || []).length;
|
||||
setOpenAutocomplete(autocomplete && value.length > 2 && words <= 1);
|
||||
}, [autocomplete, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wrapperEl.current) return;
|
||||
const target = wrapperEl.current.childNodes[focusOption] as HTMLElement;
|
||||
if (target?.scrollIntoView) target.scrollIntoView({ block: "center" });
|
||||
}, [focusOption]);
|
||||
|
||||
useClickOutside(autocompleteAnchorEl, () => setOpenAutocomplete(false), wrapperEl);
|
||||
|
||||
return <div
|
||||
className="vm-query-editor"
|
||||
ref={autocompleteAnchorEl}
|
||||
>
|
||||
<TextField
|
||||
value={value}
|
||||
label={label}
|
||||
type={"textarea"}
|
||||
autofocus={!!value}
|
||||
error={error}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Popper
|
||||
open={openAutocomplete}
|
||||
buttonRef={autocompleteAnchorEl}
|
||||
placement="bottom-left"
|
||||
onClose={handleCloseAutocomplete}
|
||||
>
|
||||
<div
|
||||
className="vm-query-editor-autocomplete"
|
||||
ref={wrapperEl}
|
||||
>
|
||||
{foundOptions.map((item, i) =>
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-list__item": true,
|
||||
"vm-list__item_active": i === focusOption
|
||||
})}
|
||||
id={`$autocomplete$${item}`}
|
||||
key={item}
|
||||
onClick={createHandlerOnChangeAutocomplete(item)}
|
||||
>
|
||||
{item}
|
||||
</div>)}
|
||||
</div>
|
||||
</Popper>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default QueryEditor;
|
|
@ -0,0 +1,9 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-query-editor {
|
||||
|
||||
&-autocomplete {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
import React, { FC, useCallback, useState } from "preact/compat";
|
||||
import { useEffect } from "react";
|
||||
import debounce from "lodash.debounce";
|
||||
import { RestartIcon } from "../../Main/Icons";
|
||||
import TextField from "../../Main/TextField/TextField";
|
||||
import Button from "../../Main/Button/Button";
|
||||
import Tooltip from "../../Main/Tooltip/Tooltip";
|
||||
|
||||
interface StepConfiguratorProps {
|
||||
defaultStep?: number,
|
||||
setStep: (step: number) => void,
|
||||
}
|
||||
|
||||
const StepConfigurator: FC<StepConfiguratorProps> = ({ defaultStep, setStep }) => {
|
||||
|
||||
const [customStep, setCustomStep] = useState(defaultStep);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleApply = (step: number) => setStep(step || 1);
|
||||
const debouncedHandleApply = useCallback(debounce(handleApply, 700), []);
|
||||
|
||||
const onChangeStep = (val: string) => {
|
||||
const value = +val;
|
||||
if (!value) return;
|
||||
handleSetStep(value);
|
||||
};
|
||||
|
||||
const handleSetStep = (value: number) => {
|
||||
if (value > 0) {
|
||||
setCustomStep(value);
|
||||
debouncedHandleApply(value);
|
||||
setError("");
|
||||
} else {
|
||||
setError("step is out of allowed range");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
handleSetStep(defaultStep || 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultStep) handleSetStep(defaultStep);
|
||||
}, [defaultStep]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label="Step value"
|
||||
type="number"
|
||||
value={customStep}
|
||||
error={error}
|
||||
onChange={onChangeStep}
|
||||
endIcon={(
|
||||
<Tooltip title="Reset step to default">
|
||||
<Button
|
||||
variant={"text"}
|
||||
size={"small"}
|
||||
startIcon={<RestartIcon/>}
|
||||
onClick={handleReset}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepConfigurator;
|
|
@ -0,0 +1,58 @@
|
|||
import React, { FC, useState, useEffect, useCallback } from "preact/compat";
|
||||
import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
|
||||
import debounce from "lodash.debounce";
|
||||
import { getAppModeParams } from "../../../utils/app-mode";
|
||||
import { useTimeDispatch } from "../../../state/time/TimeStateContext";
|
||||
import { InfoIcon } from "../../Main/Icons";
|
||||
import TextField from "../../Main/TextField/TextField";
|
||||
import Button from "../../Main/Button/Button";
|
||||
import Tooltip from "../../Main/Tooltip/Tooltip";
|
||||
|
||||
const TenantsConfiguration: FC = () => {
|
||||
const { serverURL } = getAppModeParams();
|
||||
const { tenantId: tenantIdState } = useAppState();
|
||||
const dispatch = useAppDispatch();
|
||||
const timeDispatch = useTimeDispatch();
|
||||
|
||||
const [tenantId, setTenantId] = useState<string | number>(tenantIdState || 0);
|
||||
|
||||
const handleApply = (value: string | number) => {
|
||||
const tenantId = Number(value);
|
||||
dispatch({ type: "SET_TENANT_ID", payload: tenantId });
|
||||
if (serverURL) {
|
||||
const updateServerUrl = serverURL.replace(/(\/select\/)([\d]+)(\/prometheus)/gmi, `$1${tenantId}$3`);
|
||||
dispatch({ type: "SET_SERVER", payload: updateServerUrl });
|
||||
timeDispatch({ type: "RUN_QUERY" });
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedHandleApply = useCallback(debounce(handleApply, 700), []);
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setTenantId(value);
|
||||
debouncedHandleApply(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantId === tenantIdState) return;
|
||||
setTenantId(tenantIdState);
|
||||
}, [tenantIdState]);
|
||||
|
||||
return <TextField
|
||||
label="Tenant ID"
|
||||
type="number"
|
||||
value={tenantId}
|
||||
onChange={handleChange}
|
||||
endIcon={(
|
||||
<Tooltip title={"Define tenant id if you need request to another storage"}>
|
||||
<Button
|
||||
variant={"text"}
|
||||
size={"small"}
|
||||
startIcon={<InfoIcon/>}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>;
|
||||
};
|
||||
|
||||
export default TenantsConfiguration;
|
|
@ -0,0 +1,143 @@
|
|||
import React, { FC, useEffect, useRef, useState } from "preact/compat";
|
||||
import { useTimeDispatch } from "../../../../state/time/TimeStateContext";
|
||||
import { getAppModeEnable } from "../../../../utils/app-mode";
|
||||
import Button from "../../../Main/Button/Button";
|
||||
import { ArrowDownIcon, RefreshIcon } from "../../../Main/Icons";
|
||||
import Popper from "../../../Main/Popper/Popper";
|
||||
import "./style.scss";
|
||||
import classNames from "classnames";
|
||||
import Tooltip from "../../../Main/Tooltip/Tooltip";
|
||||
|
||||
interface AutoRefreshOption {
|
||||
seconds: number
|
||||
title: string
|
||||
}
|
||||
|
||||
const delayOptions: AutoRefreshOption[] = [
|
||||
{ seconds: 0, title: "Off" },
|
||||
{ seconds: 1, title: "1s" },
|
||||
{ seconds: 2, title: "2s" },
|
||||
{ seconds: 5, title: "5s" },
|
||||
{ seconds: 10, title: "10s" },
|
||||
{ seconds: 30, title: "30s" },
|
||||
{ seconds: 60, title: "1m" },
|
||||
{ seconds: 300, title: "5m" },
|
||||
{ seconds: 900, title: "15m" },
|
||||
{ seconds: 1800, title: "30m" },
|
||||
{ seconds: 3600, title: "1h" },
|
||||
{ seconds: 7200, title: "2h" }
|
||||
];
|
||||
|
||||
export const ExecutionControls: FC = () => {
|
||||
|
||||
const dispatch = useTimeDispatch();
|
||||
const appModeEnable = getAppModeEnable();
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
|
||||
const [selectedDelay, setSelectedDelay] = useState<AutoRefreshOption>(delayOptions[0]);
|
||||
|
||||
const handleChange = (d: AutoRefreshOption) => {
|
||||
if ((autoRefresh && !d.seconds) || (!autoRefresh && d.seconds)) {
|
||||
setAutoRefresh(prev => !prev);
|
||||
}
|
||||
setSelectedDelay(d);
|
||||
setOpenOptions(false);
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
dispatch({ type: "RUN_QUERY" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const delay = selectedDelay.seconds;
|
||||
let timer: number;
|
||||
if (autoRefresh) {
|
||||
timer = setInterval(() => {
|
||||
dispatch({ type: "RUN_QUERY" });
|
||||
}, delay * 1000) as unknown as number;
|
||||
} else {
|
||||
setSelectedDelay(delayOptions[0]);
|
||||
}
|
||||
return () => {
|
||||
timer && clearInterval(timer);
|
||||
};
|
||||
}, [selectedDelay, autoRefresh]);
|
||||
|
||||
const [openOptions, setOpenOptions] = useState(false);
|
||||
const optionsButtonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggleOpenOptions = () => {
|
||||
setOpenOptions(prev => !prev);
|
||||
};
|
||||
|
||||
const handleCloseOptions = () => {
|
||||
setOpenOptions(false);
|
||||
};
|
||||
|
||||
const createHandlerChange = (d: AutoRefreshOption) => () => {
|
||||
handleChange(d);
|
||||
};
|
||||
|
||||
return <>
|
||||
<div className="vm-execution-controls">
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-execution-controls-buttons": true,
|
||||
"vm-header-button": !appModeEnable
|
||||
})}
|
||||
>
|
||||
<Tooltip title="Refresh dashboard">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleUpdate}
|
||||
startIcon={<RefreshIcon/>}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Auto-refresh control">
|
||||
<div ref={optionsButtonRef}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth
|
||||
endIcon={(
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-execution-controls-buttons__arrow": true,
|
||||
"vm-execution-controls-buttons__arrow_open": openOptions,
|
||||
})}
|
||||
>
|
||||
<ArrowDownIcon/>
|
||||
</div>
|
||||
)}
|
||||
onClick={toggleOpenOptions}
|
||||
>
|
||||
{selectedDelay.title}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Popper
|
||||
open={openOptions}
|
||||
placement="bottom-right"
|
||||
onClose={handleCloseOptions}
|
||||
buttonRef={optionsButtonRef}
|
||||
>
|
||||
<div className="vm-execution-controls-list">
|
||||
{delayOptions.map(d => (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-list__item": true,
|
||||
"vm-list__item_active": d.seconds === selectedDelay.seconds
|
||||
})}
|
||||
key={d.seconds}
|
||||
onClick={createHandlerChange(d)}
|
||||
>
|
||||
{d.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popper>
|
||||
</>;
|
||||
};
|
|
@ -0,0 +1,32 @@
|
|||
@use "src/styles/variables" as *;
|
||||
@use "src/components/Main/Button/style" as *;
|
||||
|
||||
.vm-execution-controls {
|
||||
|
||||
&-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-radius: calc($button-radius + 1px);
|
||||
min-width: 107px;
|
||||
|
||||
&__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: rotate(0);
|
||||
transition: transform 200ms ease-in-out;
|
||||
|
||||
&_open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-list {
|
||||
width: 124px;
|
||||
max-height: 208px;
|
||||
overflow: auto;
|
||||
padding: $padding-small 0;
|
||||
font-size: $font-size;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import React, { FC } from "preact/compat";
|
||||
import { relativeTimeOptions } from "../../../../utils/time";
|
||||
import "./style.scss";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface TimeDurationSelector {
|
||||
setDuration: ({ duration, until, id }: {duration: string, until: Date, id: string}) => void;
|
||||
relativeTime: string;
|
||||
}
|
||||
|
||||
const TimeDurationSelector: FC<TimeDurationSelector> = ({ relativeTime, setDuration }) => {
|
||||
|
||||
const createHandlerClick = (value: { duration: string, until: Date, id: string }) => () => {
|
||||
setDuration(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-time-duration">
|
||||
{relativeTimeOptions.map(({ id, duration, until, title }) => (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-list__item": true,
|
||||
"vm-list__item_active": id === relativeTime
|
||||
})}
|
||||
key={id}
|
||||
onClick={createHandlerClick({ duration, until: until(), id })}
|
||||
>
|
||||
{title || duration}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeDurationSelector;
|
|
@ -0,0 +1,7 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-time-duration {
|
||||
max-height: 168px;
|
||||
overflow: auto;
|
||||
font-size: $font-size;
|
||||
}
|
|
@ -0,0 +1,192 @@
|
|||
import React, { FC, useEffect, useState, useMemo, useRef } from "preact/compat";
|
||||
import { dateFromSeconds, formatDateForNativeInput } from "../../../../utils/time";
|
||||
import TimeDurationSelector from "../TimeDurationSelector/TimeDurationSelector";
|
||||
import dayjs from "dayjs";
|
||||
import { getAppModeEnable } from "../../../../utils/app-mode";
|
||||
import { useTimeDispatch, useTimeState } from "../../../../state/time/TimeStateContext";
|
||||
import { AlarmIcon, CalendarIcon, ClockIcon } from "../../../Main/Icons";
|
||||
import Button from "../../../Main/Button/Button";
|
||||
import Popper from "../../../Main/Popper/Popper";
|
||||
import Tooltip from "../../../Main/Tooltip/Tooltip";
|
||||
import { DATE_TIME_FORMAT } from "../../../../constants/date";
|
||||
import useResize from "../../../../hooks/useResize";
|
||||
import DatePicker from "../../../Main/DatePicker/DatePicker";
|
||||
import "./style.scss";
|
||||
import useClickOutside from "../../../../hooks/useClickOutside";
|
||||
|
||||
export const TimeSelector: FC = () => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const documentSize = useResize(document.body);
|
||||
const displayFullDate = useMemo(() => documentSize.width > 1120, [documentSize]);
|
||||
|
||||
const [until, setUntil] = useState<string>();
|
||||
const [from, setFrom] = useState<string>();
|
||||
|
||||
const formFormat = useMemo(() => dayjs(from).format(DATE_TIME_FORMAT), [from]);
|
||||
const untilFormat = useMemo(() => dayjs(until).format(DATE_TIME_FORMAT), [until]);
|
||||
|
||||
const { period: { end, start }, relativeTime } = useTimeState();
|
||||
const dispatch = useTimeDispatch();
|
||||
const appModeEnable = getAppModeEnable();
|
||||
|
||||
useEffect(() => {
|
||||
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
|
||||
}, [end]);
|
||||
|
||||
useEffect(() => {
|
||||
setFrom(formatDateForNativeInput(dateFromSeconds(start)));
|
||||
}, [start]);
|
||||
|
||||
const setDuration = ({ duration, until, id }: {duration: string, until: Date, id: string}) => {
|
||||
dispatch({ type: "SET_RELATIVE_TIME", payload: { duration, until, id } });
|
||||
setOpenOptions(false);
|
||||
};
|
||||
|
||||
const formatRange = useMemo(() => {
|
||||
const startFormat = dayjs(dateFromSeconds(start)).format(DATE_TIME_FORMAT);
|
||||
const endFormat = dayjs(dateFromSeconds(end)).format(DATE_TIME_FORMAT);
|
||||
return {
|
||||
start: startFormat,
|
||||
end: endFormat
|
||||
};
|
||||
}, [start, end]);
|
||||
|
||||
const dateTitle = useMemo(() => {
|
||||
const isRelativeTime = relativeTime && relativeTime !== "none";
|
||||
return isRelativeTime ? relativeTime.replace(/_/g, " ") : `${formatRange.start} - ${formatRange.end}`;
|
||||
}, [relativeTime, formatRange]);
|
||||
|
||||
const fromRef = useRef<HTMLDivElement>(null);
|
||||
const untilRef = useRef<HTMLDivElement>(null);
|
||||
const fromPickerRef = useRef<HTMLDivElement>(null);
|
||||
const untilPickerRef = useRef<HTMLDivElement>(null);
|
||||
const [openOptions, setOpenOptions] = useState(false);
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setTimeAndClosePicker = () => {
|
||||
if (from && until) {
|
||||
dispatch({ type: "SET_PERIOD", payload: { from: new Date(from), to: new Date(until) } });
|
||||
}
|
||||
setOpenOptions(false);
|
||||
};
|
||||
const handleFromChange = (from: string) => setFrom(from);
|
||||
|
||||
const handleUntilChange = (until: string) => setUntil(until);
|
||||
|
||||
const onApplyClick = () => setTimeAndClosePicker();
|
||||
|
||||
const onSwitchToNow = () => dispatch({ type: "RUN_QUERY_TO_NOW" });
|
||||
|
||||
const onCancelClick = () => {
|
||||
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
|
||||
setFrom(formatDateForNativeInput(dateFromSeconds(start)));
|
||||
setOpenOptions(false);
|
||||
};
|
||||
|
||||
const toggleOpenOptions = () => {
|
||||
setOpenOptions(prev => !prev);
|
||||
};
|
||||
|
||||
const handleCloseOptions = () => {
|
||||
setOpenOptions(false);
|
||||
};
|
||||
|
||||
useClickOutside(wrapperRef, (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isFromButton = fromRef?.current && fromRef.current.contains(target);
|
||||
const isUntilButton = untilRef?.current && untilRef.current.contains(target);
|
||||
const isFromPicker = fromPickerRef?.current && fromPickerRef?.current?.contains(target);
|
||||
const isUntilPicker = untilPickerRef?.current && untilPickerRef?.current?.contains(target);
|
||||
if (isFromButton || isUntilButton || isFromPicker || isUntilPicker) return;
|
||||
handleCloseOptions();
|
||||
});
|
||||
|
||||
return <>
|
||||
<div ref={buttonRef}>
|
||||
<Tooltip title="Time range controls">
|
||||
<Button
|
||||
className={appModeEnable ? "" : "vm-header-button"}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<ClockIcon/>}
|
||||
onClick={toggleOpenOptions}
|
||||
>
|
||||
{displayFullDate && <span>{dateTitle}</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Popper
|
||||
open={openOptions}
|
||||
buttonRef={buttonRef}
|
||||
placement="bottom-right"
|
||||
onClose={handleCloseOptions}
|
||||
clickOutside={false}
|
||||
>
|
||||
<div
|
||||
className="vm-time-selector"
|
||||
ref={wrapperRef}
|
||||
>
|
||||
<div className="vm-time-selector-left">
|
||||
<div className="vm-time-selector-left-inputs">
|
||||
<div
|
||||
className="vm-time-selector-left-inputs__date"
|
||||
ref={fromRef}
|
||||
>
|
||||
<label>From:</label>
|
||||
<span>{formFormat}</span>
|
||||
<CalendarIcon/>
|
||||
<DatePicker
|
||||
ref={fromPickerRef}
|
||||
date={from || ""}
|
||||
onChange={handleFromChange}
|
||||
targetRef={fromRef}
|
||||
timepicker={true}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="vm-time-selector-left-inputs__date"
|
||||
ref={untilRef}
|
||||
>
|
||||
<label>To:</label>
|
||||
<span>{untilFormat}</span>
|
||||
<CalendarIcon/>
|
||||
<DatePicker
|
||||
ref={untilPickerRef}
|
||||
date={until || ""}
|
||||
onChange={handleUntilChange}
|
||||
targetRef={untilRef}
|
||||
timepicker={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="text"
|
||||
startIcon={<AlarmIcon />}
|
||||
onClick={onSwitchToNow}
|
||||
>
|
||||
switch to now
|
||||
</Button>
|
||||
<div className="vm-time-selector-left__controls">
|
||||
<Button
|
||||
color="error"
|
||||
variant="outlined"
|
||||
onClick={onCancelClick}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={onApplyClick}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TimeDurationSelector
|
||||
relativeTime={relativeTime || ""}
|
||||
setDuration={setDuration}
|
||||
/>
|
||||
</div>
|
||||
</Popper>
|
||||
</>;
|
||||
};
|
|
@ -0,0 +1,61 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-time-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 230px);
|
||||
padding: $padding-global 0;
|
||||
|
||||
&-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $padding-small;
|
||||
border-right: $border-divider;
|
||||
padding: 0 $padding-global;
|
||||
|
||||
&-inputs {
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
align-items: flex-start;
|
||||
justify-content: stretch;
|
||||
|
||||
&__date {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 14px;
|
||||
gap: $padding-small;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: $padding-small;
|
||||
margin-bottom: $padding-global;
|
||||
border-bottom: $border-divider;
|
||||
cursor: pointer;
|
||||
transition: color 200ms ease-in-out, border-bottom-color 300ms ease;
|
||||
|
||||
&:hover {
|
||||
border-bottom-color: $color-primary;
|
||||
}
|
||||
|
||||
&:hover svg,
|
||||
&:hover {
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
label {
|
||||
grid-column: 1/3;
|
||||
font-size: $font-size-small;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: $color-text-secondary;
|
||||
transition: color 200ms ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: $padding-small;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,205 +0,0 @@
|
|||
/* eslint max-lines: ["error", {"max": 300}] */
|
||||
|
||||
import React, {useState} from "preact/compat";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import FormHelperText from "@mui/material/FormHelperText";
|
||||
import Input from "@mui/material/Input";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import InputLabel from "@mui/material/InputLabel";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import TabPanel from "./AuthTabPanel";
|
||||
import PersonIcon from "@mui/icons-material/Person";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import {useAuthDispatch, useAuthState} from "../../../../state/auth/AuthStateContext";
|
||||
import {AUTH_METHOD, WithCheckbox} from "../../../../state/auth/reducer";
|
||||
import {ChangeEvent, ClipboardEvent} from "react";
|
||||
|
||||
// TODO: make generic when creating second dialog
|
||||
export interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface AuthTab {
|
||||
title: string;
|
||||
id: AUTH_METHOD;
|
||||
}
|
||||
|
||||
const BEARER_PREFIX = "Bearer ";
|
||||
|
||||
const tabs: AuthTab[] = [
|
||||
{title: "No auth", id: "NO_AUTH"},
|
||||
{title: "Basic Auth", id: "BASIC_AUTH"},
|
||||
{title: "Bearer Token", id: "BEARER_AUTH"}
|
||||
];
|
||||
|
||||
export const AuthDialog: React.FC<DialogProps> = (props) => {
|
||||
|
||||
const {onClose, open} = props;
|
||||
|
||||
const {saveAuthLocally, basicData, bearerData, authMethod} = useAuthState();
|
||||
const dispatch = useAuthDispatch();
|
||||
|
||||
const [authCheckbox, setAuthCheckbox] = useState(saveAuthLocally);
|
||||
|
||||
const [basicValue, setBasicValue] = useState(basicData || {password: "", login: ""});
|
||||
|
||||
const [bearerValue, setBearerValue] = useState(bearerData?.token || BEARER_PREFIX);
|
||||
|
||||
const [tabIndex, setTabIndex] = useState(tabs.findIndex(el => el.id === authMethod) || 0);
|
||||
|
||||
const handleChange = (event: unknown, newValue: number) => {
|
||||
setTabIndex(newValue);
|
||||
};
|
||||
|
||||
const handleBearerChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const newVal = event.target.value;
|
||||
if (newVal.startsWith(BEARER_PREFIX)) {
|
||||
setBearerValue(newVal);
|
||||
} else {
|
||||
setBearerValue(BEARER_PREFIX);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onBearerPaste = (e: ClipboardEvent) => {
|
||||
// if you're pasting token word Bearer will be added automagically
|
||||
const newVal = e.clipboardData.getData("text/plain");
|
||||
if (newVal.startsWith(BEARER_PREFIX)) {
|
||||
setBearerValue(newVal);
|
||||
} else {
|
||||
setBearerValue(BEARER_PREFIX + newVal);
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
// TODO: handle validation/required fields
|
||||
switch (tabIndex) {
|
||||
case 0:
|
||||
dispatch({type: "SET_NO_AUTH", payload: {checkbox: authCheckbox} as WithCheckbox});
|
||||
break;
|
||||
case 1:
|
||||
dispatch({type: "SET_BASIC_AUTH", payload: { checkbox: authCheckbox, value: basicValue}});
|
||||
break;
|
||||
case 2:
|
||||
dispatch({type: "SET_BEARER_AUTH", payload: {checkbox: authCheckbox, value: {token: bearerValue}}});
|
||||
break;
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onClose={handleClose} aria-labelledby="simple-dialog-title" open={open}>
|
||||
<DialogTitle id="simple-dialog-title">Request Auth Settings</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This affects Authorization header sent to the server you specify. Not shown in URL and can be optionally stored on a client side
|
||||
</DialogContentText>
|
||||
|
||||
<Tabs
|
||||
value={tabIndex}
|
||||
onChange={handleChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
>
|
||||
{
|
||||
tabs.map(t => <Tab key={t.id} label={t.title} />)
|
||||
}
|
||||
</Tabs>
|
||||
<Box p={0} display="flex" flexDirection="column" sx={{height: "200px"}}>
|
||||
<Box flexGrow={1}>
|
||||
<TabPanel value={tabIndex} index={0}>
|
||||
<Typography style={{fontStyle: "italic"}}>
|
||||
No Authorization Header
|
||||
</Typography>
|
||||
</TabPanel>
|
||||
<TabPanel value={tabIndex} index={1}>
|
||||
<FormControl margin="dense" fullWidth={true}>
|
||||
<InputLabel htmlFor="basic-login">User</InputLabel>
|
||||
<Input
|
||||
id="basic-login"
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<PersonIcon />
|
||||
</InputAdornment>
|
||||
}
|
||||
required
|
||||
onChange={e => setBasicValue(prev => ({...prev, login: e.target.value || ""}))}
|
||||
value={basicValue?.login || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl margin="dense" fullWidth={true}>
|
||||
<InputLabel htmlFor="basic-pass">Password</InputLabel>
|
||||
<Input
|
||||
id="basic-pass"
|
||||
// type="password" // Basic auth is not super secure in any case :)
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<LockIcon />
|
||||
</InputAdornment>
|
||||
}
|
||||
onChange={e => setBasicValue(prev => ({...prev, password: e.target.value || ""}))}
|
||||
value={basicValue?.password || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
</TabPanel>
|
||||
<TabPanel value={tabIndex} index={2}>
|
||||
<TextField
|
||||
id="bearer-auth"
|
||||
label="Bearer token"
|
||||
multiline
|
||||
fullWidth={true}
|
||||
value={bearerValue}
|
||||
onChange={handleBearerChange}
|
||||
InputProps={{
|
||||
onPaste: onBearerPaste
|
||||
}}
|
||||
maxRows={6}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={authCheckbox}
|
||||
onChange={() => setAuthCheckbox(prev => !prev)}
|
||||
name="checkedB"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Persist Auth Data Locally"
|
||||
/>
|
||||
<FormHelperText>
|
||||
{authCheckbox ? "Auth Data and the Selected method will be saved to LocalStorage" : "Auth Data won't be saved. All previously saved Auth Data will be removed"}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
</Box>
|
||||
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleApply} color="primary">
|
||||
Apply
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
|
@ -1,31 +0,0 @@
|
|||
import React from "preact/compat";
|
||||
import Box from "@mui/material/Box";
|
||||
import {ReactNode} from "react";
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const AuthTabPanel: React.FC<TabPanelProps> = (props) => {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`auth-config-tabpanel-${index}`}
|
||||
aria-labelledby={`auth-config-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box py={2}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthTabPanel;
|
|
@ -1,40 +0,0 @@
|
|||
import React, {FC} from "preact/compat";
|
||||
import TableChartIcon from "@mui/icons-material/TableChart";
|
||||
import ShowChartIcon from "@mui/icons-material/ShowChart";
|
||||
import CodeIcon from "@mui/icons-material/Code";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
|
||||
import {SyntheticEvent} from "react";
|
||||
|
||||
export type DisplayType = "table" | "chart" | "code";
|
||||
|
||||
export const displayTypeTabs = [
|
||||
{value: "chart", icon: <ShowChartIcon/>, label: "Graph", prometheusCode: 0},
|
||||
{value: "code", icon: <CodeIcon/>, label: "JSON"},
|
||||
{value: "table", icon: <TableChartIcon/>, label: "Table", prometheusCode: 1}
|
||||
];
|
||||
|
||||
export const DisplayTypeSwitch: FC = () => {
|
||||
|
||||
const {displayType} = useAppState();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleChange = (event: SyntheticEvent, newValue: DisplayType) => {
|
||||
dispatch({type: "SET_DISPLAY_TYPE", payload: newValue ?? displayType});
|
||||
};
|
||||
|
||||
return <Tabs
|
||||
value={displayType}
|
||||
onChange={handleChange}
|
||||
sx={{minHeight: "0", marginBottom: "-1px"}}
|
||||
>
|
||||
{displayTypeTabs.map(t =>
|
||||
<Tab key={t.value}
|
||||
icon={t.icon}
|
||||
iconPosition="start"
|
||||
label={t.label} value={t.value}
|
||||
sx={{minHeight: "41px"}}
|
||||
/>)}
|
||||
</Tabs>;
|
||||
};
|
|
@ -1,48 +0,0 @@
|
|||
import React, {FC, useCallback, useMemo} from "preact/compat";
|
||||
import {ChangeEvent} from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import debounce from "lodash.debounce";
|
||||
import BasicSwitch from "../../../../theme/switch";
|
||||
import {AxisRange, YaxisState} from "../../../../state/graph/reducer";
|
||||
|
||||
interface AxesLimitsConfiguratorProps {
|
||||
yaxis: YaxisState,
|
||||
setYaxisLimits: (limits: AxisRange) => void,
|
||||
toggleEnableLimits: () => void
|
||||
}
|
||||
|
||||
const AxesLimitsConfigurator: FC<AxesLimitsConfiguratorProps> = ({yaxis, setYaxisLimits, toggleEnableLimits}) => {
|
||||
|
||||
const axes = useMemo(() => Object.keys(yaxis.limits.range), [yaxis.limits.range]);
|
||||
|
||||
const onChangeLimit = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, axis: string, index: number) => {
|
||||
const newLimits = yaxis.limits.range;
|
||||
newLimits[axis][index] = +e.target.value;
|
||||
if (newLimits[axis][0] === newLimits[axis][1] || newLimits[axis][0] > newLimits[axis][1]) return;
|
||||
setYaxisLimits(newLimits);
|
||||
};
|
||||
const debouncedOnChangeLimit = useCallback(debounce(onChangeLimit, 500), [yaxis.limits.range]);
|
||||
|
||||
return <Box display="grid" alignItems="center" gap={2}>
|
||||
<FormControlLabel
|
||||
control={<BasicSwitch checked={yaxis.limits.enable} onChange={toggleEnableLimits}/>}
|
||||
label="Fix the limits for y-axis"
|
||||
/>
|
||||
<Box display="grid" alignItems="center" gap={2}>
|
||||
{axes.map(axis => <Box display="grid" gridTemplateColumns="120px 120px" gap={1} key={axis}>
|
||||
<TextField label={`Min ${axis}`} type="number" size="small" variant="outlined"
|
||||
disabled={!yaxis.limits.enable}
|
||||
defaultValue={yaxis.limits.range[axis][0]}
|
||||
onChange={(e) => debouncedOnChangeLimit(e, axis, 0)}/>
|
||||
<TextField label={`Max ${axis}`} type="number" size="small" variant="outlined"
|
||||
disabled={!yaxis.limits.enable}
|
||||
defaultValue={yaxis.limits.range[axis][1]}
|
||||
onChange={(e) => debouncedOnChangeLimit(e, axis, 1)} />
|
||||
</Box>)}
|
||||
</Box>
|
||||
</Box>;
|
||||
};
|
||||
|
||||
export default AxesLimitsConfigurator;
|
|
@ -1,80 +0,0 @@
|
|||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import React, {FC, useState} from "preact/compat";
|
||||
import AxesLimitsConfigurator from "./AxesLimitsConfigurator";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Popper from "@mui/material/Popper";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import ClickAwayListener from "@mui/material/ClickAwayListener";
|
||||
import {AxisRange, YaxisState} from "../../../../state/graph/reducer";
|
||||
|
||||
const classes = {
|
||||
popover: {
|
||||
display: "grid",
|
||||
gridGap: "16px",
|
||||
padding: "0 0 25px",
|
||||
},
|
||||
popoverHeader: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
backgroundColor: "primary.main",
|
||||
padding: "6px 6px 6px 12px",
|
||||
borderRadius: "4px 4px 0 0",
|
||||
color: "primary.contrastText",
|
||||
},
|
||||
popoverBody: {
|
||||
display: "grid",
|
||||
gridGap: "6px",
|
||||
padding: "0 14px",
|
||||
}
|
||||
};
|
||||
|
||||
const title = "Axes Settings";
|
||||
|
||||
interface GraphSettingsProps {
|
||||
yaxis: YaxisState,
|
||||
setYaxisLimits: (limits: AxisRange) => void,
|
||||
toggleEnableLimits: () => void
|
||||
}
|
||||
|
||||
const GraphSettings: FC<GraphSettingsProps> = ({yaxis, setYaxisLimits, toggleEnableLimits}) => {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return <Box>
|
||||
<Tooltip title={title}>
|
||||
<IconButton onClick={(e) => setAnchorEl(e.currentTarget)}>
|
||||
<SettingsIcon/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
placement="left-start"
|
||||
modifiers={[{name: "offset", options: {offset: [0, 6]}}]}>
|
||||
<ClickAwayListener onClickAway={() => setAnchorEl(null)}>
|
||||
<Paper elevation={3} sx={classes.popover}>
|
||||
<Box id="handle" sx={classes.popoverHeader}>
|
||||
<Typography variant="body1"><b>{title}</b></Typography>
|
||||
<IconButton size="small" onClick={() => setAnchorEl(null)}>
|
||||
<CloseIcon style={{color: "white"}}/>
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={classes.popoverBody}>
|
||||
<AxesLimitsConfigurator
|
||||
yaxis={yaxis}
|
||||
setYaxisLimits={setYaxisLimits}
|
||||
toggleEnableLimits={toggleEnableLimits}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
</Box>;
|
||||
};
|
||||
|
||||
export default GraphSettings;
|
|
@ -1,62 +0,0 @@
|
|||
import React, {FC} from "preact/compat";
|
||||
import Box from "@mui/material/Box";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import {saveToStorage} from "../../../../utils/storage";
|
||||
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
|
||||
import BasicSwitch from "../../../../theme/switch";
|
||||
import StepConfigurator from "./StepConfigurator";
|
||||
import {useGraphDispatch} from "../../../../state/graph/GraphStateContext";
|
||||
import {getAppModeParams} from "../../../../utils/app-mode";
|
||||
import TenantsConfiguration from "../Settings/TenantsConfiguration";
|
||||
|
||||
const AdditionalSettings: FC = () => {
|
||||
|
||||
const graphDispatch = useGraphDispatch();
|
||||
const {inputTenantID} = getAppModeParams();
|
||||
|
||||
const {queryControls: {autocomplete, nocache, isTracingEnabled}, time: {period: {step}}} = useAppState();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onChangeAutocomplete = () => {
|
||||
dispatch({type: "TOGGLE_AUTOCOMPLETE"});
|
||||
saveToStorage("AUTOCOMPLETE", !autocomplete);
|
||||
};
|
||||
|
||||
const onChangeCache = () => {
|
||||
dispatch({type: "NO_CACHE"});
|
||||
saveToStorage("NO_CACHE", !nocache);
|
||||
};
|
||||
|
||||
const onChangeQueryTracing = () => {
|
||||
dispatch({type: "TOGGLE_QUERY_TRACING"});
|
||||
saveToStorage("QUERY_TRACING", !isTracingEnabled);
|
||||
};
|
||||
|
||||
return <Box display="flex" alignItems="center" flexWrap="wrap" gap={2}>
|
||||
<Box>
|
||||
<FormControlLabel label="Autocomplete" sx={{m: 0}}
|
||||
control={<BasicSwitch checked={autocomplete} onChange={onChangeAutocomplete}/>}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<FormControlLabel label="Disable cache" sx={{m: 0}}
|
||||
control={<BasicSwitch checked={nocache} onChange={onChangeCache}/>}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<FormControlLabel label="Trace query" sx={{m: 0}}
|
||||
control={<BasicSwitch checked={isTracingEnabled} onChange={onChangeQueryTracing} />}
|
||||
/>
|
||||
</Box>
|
||||
<Box ml={2}>
|
||||
<StepConfigurator defaultStep={step}
|
||||
setStep={(value) => {
|
||||
graphDispatch({type: "SET_CUSTOM_STEP", payload: value});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{!!inputTenantID && <Box ml={2}><TenantsConfiguration/></Box>}
|
||||
</Box>;
|
||||
};
|
||||
|
||||
export default AdditionalSettings;
|
|
@ -1,110 +0,0 @@
|
|||
import React, {FC, useState, useEffect} from "preact/compat";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import QueryEditor from "./QueryEditor";
|
||||
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import AdditionalSettings from "./AdditionalSettings";
|
||||
import {ErrorTypes} from "../../../../types";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import usePrevious from "../../../../hooks/usePrevious";
|
||||
import {MAX_QUERY_FIELDS} from "../../../../config";
|
||||
|
||||
export interface QueryConfiguratorProps {
|
||||
error?: ErrorTypes | string;
|
||||
queryOptions: string[]
|
||||
}
|
||||
|
||||
|
||||
const QueryConfigurator: FC<QueryConfiguratorProps> = ({error, queryOptions}) => {
|
||||
|
||||
const {query, queryHistory, queryControls: {autocomplete}} = useAppState();
|
||||
const [stateQuery, setStateQuery] = useState(query || []);
|
||||
const prevStateQuery = usePrevious(stateQuery) as (undefined | string[]);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const updateHistory = () => {
|
||||
dispatch({
|
||||
type: "SET_QUERY_HISTORY", payload: stateQuery.map((q, i) => {
|
||||
const h = queryHistory[i] || {values: []};
|
||||
const queryEqual = q === h.values[h.values.length - 1];
|
||||
return {
|
||||
index: h.values.length - Number(queryEqual),
|
||||
values: !queryEqual && q ? [...h.values, q] : h.values
|
||||
};
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const onRunQuery = () => {
|
||||
updateHistory();
|
||||
dispatch({type: "SET_QUERY", payload: stateQuery});
|
||||
dispatch({type: "RUN_QUERY"});
|
||||
};
|
||||
|
||||
const onAddQuery = () => {
|
||||
setStateQuery(prev => [...prev, ""]);
|
||||
};
|
||||
|
||||
const onRemoveQuery = (index: number) => {
|
||||
setStateQuery(prev => prev.filter((q, i) => i !== index));
|
||||
};
|
||||
|
||||
const onSetQuery = (value: string, index: number) => {
|
||||
setStateQuery(prev => prev.map((q, i) => i === index ? value : q));
|
||||
};
|
||||
|
||||
const setHistoryIndex = (step: number, indexQuery: number) => {
|
||||
const {index, values} = queryHistory[indexQuery];
|
||||
const newIndexHistory = index + step;
|
||||
if (newIndexHistory < 0 || newIndexHistory >= values.length) return;
|
||||
onSetQuery(values[newIndexHistory] || "", indexQuery);
|
||||
dispatch({
|
||||
type: "SET_QUERY_HISTORY_BY_INDEX",
|
||||
payload: {value: {values, index: newIndexHistory}, queryNumber: indexQuery}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (prevStateQuery && (stateQuery.length < prevStateQuery.filter(q => q).length)) {
|
||||
onRunQuery();
|
||||
}
|
||||
}, [stateQuery]);
|
||||
|
||||
return <Box>
|
||||
<Box>
|
||||
{stateQuery.map((q, i) =>
|
||||
<Box key={i} display="grid" gridTemplateColumns="1fr auto" gap="4px" width="100%" position="relative"
|
||||
mb={i === stateQuery.length - 1 ? 0 : 2}>
|
||||
<QueryEditor
|
||||
query={stateQuery[i]} index={i} autocomplete={autocomplete} queryOptions={queryOptions}
|
||||
error={error} setHistoryIndex={setHistoryIndex} runQuery={onRunQuery} setQuery={onSetQuery}
|
||||
label={`Query ${i + 1}`} size={"small"}/>
|
||||
{stateQuery.length > 1 && <Tooltip title="Remove Query">
|
||||
<IconButton onClick={() => onRemoveQuery(i)} sx={{height: "33px", width: "33px", padding: 0}} color={"error"}>
|
||||
<DeleteIcon fontSize={"small"}/>
|
||||
</IconButton>
|
||||
</Tooltip>}
|
||||
</Box>)}
|
||||
</Box>
|
||||
<Box mt={3} display="grid" gridTemplateColumns="1fr auto" alignItems="start" gap={4}>
|
||||
<AdditionalSettings/>
|
||||
<Box display="grid" gridTemplateColumns="repeat(2, auto)" gap={1}>
|
||||
{stateQuery.length < MAX_QUERY_FIELDS && (
|
||||
<Button variant="outlined" onClick={onAddQuery} startIcon={<AddIcon/>}>
|
||||
<Typography lineHeight={"20px"} fontWeight="500">Add Query</Typography>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="contained" onClick={onRunQuery} startIcon={<PlayArrowIcon/>}>
|
||||
<Typography lineHeight={"20px"} fontWeight="500">Execute Query</Typography>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>;
|
||||
};
|
||||
|
||||
export default QueryConfigurator;
|
|
@ -1,142 +0,0 @@
|
|||
import React, {FC, useEffect, useMemo, useRef, useState} from "preact/compat";
|
||||
import {KeyboardEvent} from "react";
|
||||
import {ErrorTypes} from "../../../../types";
|
||||
import Popper from "@mui/material/Popper";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Box from "@mui/material/Box";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import MenuList from "@mui/material/MenuList";
|
||||
import ClickAwayListener from "@mui/material/ClickAwayListener";
|
||||
|
||||
export interface QueryEditorProps {
|
||||
setHistoryIndex: (step: number, index: number) => void;
|
||||
setQuery: (query: string, index: number) => void;
|
||||
runQuery: () => void;
|
||||
query: string;
|
||||
index: number;
|
||||
oneLiner?: boolean;
|
||||
autocomplete: boolean;
|
||||
error?: ErrorTypes | string;
|
||||
queryOptions: string[];
|
||||
label: string;
|
||||
size?: "small" | "medium" | undefined;
|
||||
}
|
||||
|
||||
const QueryEditor: FC<QueryEditorProps> = ({
|
||||
index,
|
||||
query,
|
||||
setHistoryIndex,
|
||||
setQuery,
|
||||
runQuery,
|
||||
autocomplete,
|
||||
error,
|
||||
queryOptions,
|
||||
label,
|
||||
size = "medium"
|
||||
}) => {
|
||||
|
||||
const [focusField, setFocusField] = useState(false);
|
||||
const [focusOption, setFocusOption] = useState(-1);
|
||||
const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
|
||||
const wrapperEl = useRef<HTMLUListElement>(null);
|
||||
const [openAutocomplete, setOpenAutocomplete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusField) return;
|
||||
const words = (query.match(/[a-zA-Z_:.][a-zA-Z0-9_:.]*/gm) || []).length;
|
||||
setOpenAutocomplete(!(!autocomplete || query.length < 2 || words > 1));
|
||||
},
|
||||
[autocomplete, query]);
|
||||
|
||||
const actualOptions = useMemo(() => {
|
||||
setFocusOption(0);
|
||||
if (!openAutocomplete) return [];
|
||||
try {
|
||||
const regexp = new RegExp(String(query), "i");
|
||||
const options = queryOptions.filter((item) => regexp.test(item) && (item !== query));
|
||||
return options.sort((a,b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0));
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}, [autocomplete, query, queryOptions]);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const {key, ctrlKey, metaKey, shiftKey} = e;
|
||||
|
||||
const ctrlMetaKey = ctrlKey || metaKey;
|
||||
const arrowUp = key === "ArrowUp";
|
||||
const arrowDown = key === "ArrowDown";
|
||||
const enter = key === "Enter";
|
||||
|
||||
const hasAutocomplete = openAutocomplete && actualOptions.length;
|
||||
|
||||
if (((arrowUp || arrowDown) && (hasAutocomplete || ctrlMetaKey)) || (enter && (hasAutocomplete || ctrlMetaKey || !shiftKey))) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// ArrowUp
|
||||
if (arrowUp && hasAutocomplete && !ctrlMetaKey) {
|
||||
setFocusOption((prev) => prev === 0 ? 0 : prev - 1);
|
||||
} else if (arrowUp && ctrlMetaKey) {
|
||||
setHistoryIndex(-1, index);
|
||||
}
|
||||
|
||||
// ArrowDown
|
||||
if (arrowDown && hasAutocomplete && !ctrlMetaKey) {
|
||||
setFocusOption((prev) => prev >= actualOptions.length - 1 ? actualOptions.length - 1 : prev + 1);
|
||||
} else if (arrowDown && ctrlMetaKey) {
|
||||
setHistoryIndex(1, index);
|
||||
}
|
||||
|
||||
// Enter
|
||||
if (enter && hasAutocomplete && !shiftKey && !ctrlMetaKey) {
|
||||
setQuery(actualOptions[focusOption], index);
|
||||
} else if (enter && !shiftKey) {
|
||||
runQuery();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!wrapperEl.current) return;
|
||||
const target = wrapperEl.current.childNodes[focusOption] as HTMLElement;
|
||||
if (target?.scrollIntoView) target.scrollIntoView({block: "center"});
|
||||
}, [focusOption]);
|
||||
|
||||
return <Box ref={autocompleteAnchorEl}>
|
||||
<TextField
|
||||
defaultValue={query}
|
||||
fullWidth
|
||||
label={label}
|
||||
multiline
|
||||
focused={!!query}
|
||||
error={!!error}
|
||||
onFocus={() => setFocusField(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={(e) => setQuery(e.target.value, index)}
|
||||
size={size}
|
||||
/>
|
||||
<Popper open={openAutocomplete} anchorEl={autocompleteAnchorEl.current} placement="bottom-start" sx={{zIndex: 3}}>
|
||||
<ClickAwayListener onClickAway={() => setOpenAutocomplete(false)}>
|
||||
<Paper elevation={3} sx={{ maxHeight: 300, overflow: "auto" }}>
|
||||
<MenuList ref={wrapperEl} dense>
|
||||
{actualOptions.map((item, i) =>
|
||||
<MenuItem
|
||||
id={`$autocomplete$${item}`}
|
||||
key={item}
|
||||
sx={{bgcolor: `rgba(0, 0, 0, ${i === focusOption ? 0.12 : 0})`}}
|
||||
onClick={() => {
|
||||
setQuery(item, index);
|
||||
setOpenAutocomplete(false);
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</MenuItem>)}
|
||||
</MenuList>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
</Box>;
|
||||
};
|
||||
|
||||
export default QueryEditor;
|
|
@ -1,67 +0,0 @@
|
|||
import React, {FC, useCallback, useState} from "preact/compat";
|
||||
import {ChangeEvent, useEffect} from "react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import debounce from "lodash.debounce";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
|
||||
interface StepConfiguratorProps {
|
||||
defaultStep?: number,
|
||||
setStep: (step: number) => void,
|
||||
}
|
||||
|
||||
const StepConfigurator: FC<StepConfiguratorProps> = ({defaultStep, setStep}) => {
|
||||
|
||||
const [customStep, setCustomStep] = useState(defaultStep);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const handleApply = (step: number) => setStep(step || 1);
|
||||
const debouncedHandleApply = useCallback(debounce(handleApply, 700), []);
|
||||
|
||||
const onChangeStep = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const value = +e.target.value;
|
||||
if (!value) return;
|
||||
handleSetStep(value);
|
||||
};
|
||||
|
||||
const handleSetStep = (value: number) => {
|
||||
if (value > 0) {
|
||||
setCustomStep(value);
|
||||
debouncedHandleApply(value);
|
||||
setError(false);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultStep) handleSetStep(defaultStep);
|
||||
}, [defaultStep]);
|
||||
|
||||
return <TextField
|
||||
label="Step value"
|
||||
type="number"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
value={customStep}
|
||||
error={error}
|
||||
helperText={error ? "step is out of allowed range" : " "}
|
||||
onChange={onChangeStep}
|
||||
InputProps={{
|
||||
inputProps: {min: 0},
|
||||
endAdornment: (
|
||||
<InputAdornment position="start" sx={{mr: -0.5, cursor: "pointer"}}>
|
||||
<Tooltip title={"Reset step to default"}>
|
||||
<IconButton size={"small"} onClick={() => handleSetStep(defaultStep || 1)}>
|
||||
<RestartAltIcon fontSize={"small"} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>;
|
||||
};
|
||||
|
||||
export default StepConfigurator;
|
|
@ -1,80 +0,0 @@
|
|||
import React, {FC, useState} from "preact/compat";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import Button from "@mui/material/Button";
|
||||
import Box from "@mui/material/Box";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import ServerConfigurator from "./ServerConfigurator";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
|
||||
|
||||
const modalStyle = {
|
||||
position: "absolute" as const,
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
bgcolor: "background.paper",
|
||||
p: 3,
|
||||
borderRadius: "4px",
|
||||
width: "80%",
|
||||
maxWidth: "800px"
|
||||
};
|
||||
|
||||
const title = "Setting Server URL";
|
||||
|
||||
const GlobalSettings: FC = () => {
|
||||
|
||||
const {serverUrl} = useAppState();
|
||||
const dispatch = useAppDispatch();
|
||||
const [changedServerUrl, setChangedServerUrl] = useState(serverUrl);
|
||||
|
||||
const setServer = (url?: string) => {
|
||||
dispatch({type: "SET_SERVER", payload: url || changedServerUrl});
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const handleOpen = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
return <>
|
||||
<Tooltip title={title}>
|
||||
<Button variant="contained" color="primary"
|
||||
sx={{
|
||||
color: "white",
|
||||
border: "1px solid rgba(0, 0, 0, 0.2)",
|
||||
minWidth: "34px",
|
||||
padding: "6px 8px",
|
||||
boxShadow: "none",
|
||||
}}
|
||||
startIcon={<SettingsIcon style={{marginRight: "-8px", marginLeft: "4px"}}/>}
|
||||
onClick={handleOpen}>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Modal open={open} onClose={handleClose}>
|
||||
<Box sx={modalStyle}>
|
||||
<Box display="grid" gridTemplateColumns="1fr auto" alignItems="center" mb={4}>
|
||||
<Typography id="modal-modal-title" variant="h6" component="h2">
|
||||
{title}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={handleClose}>
|
||||
<CloseIcon/>
|
||||
</IconButton>
|
||||
</Box>
|
||||
<ServerConfigurator setServer={setChangedServerUrl} onEnter={setServer}/>
|
||||
<Box display="grid" gridTemplateColumns="auto auto" gap={1} justifyContent="end" mt={4}>
|
||||
<Button variant="outlined" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" onClick={() => setServer()}>
|
||||
apply
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default GlobalSettings;
|
|
@ -1,44 +0,0 @@
|
|||
import React, {FC, useState} from "preact/compat";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {useAppState} from "../../../../state/common/StateContext";
|
||||
import {ErrorTypes} from "../../../../types";
|
||||
import {ChangeEvent, KeyboardEvent} from "react";
|
||||
|
||||
export interface ServerConfiguratorProps {
|
||||
error?: ErrorTypes | string;
|
||||
setServer: (url: string) => void
|
||||
onEnter: (url: string) => void
|
||||
}
|
||||
|
||||
const ServerConfigurator: FC<ServerConfiguratorProps> = ({error, setServer, onEnter}) => {
|
||||
|
||||
const {serverUrl} = useAppState();
|
||||
const [changedServerUrl, setChangedServerUrl] = useState(serverUrl);
|
||||
|
||||
const onChangeServer = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||
const value = e.target.value || "";
|
||||
setChangedServerUrl(value);
|
||||
setServer(value);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onEnter(changedServerUrl);
|
||||
}
|
||||
};
|
||||
|
||||
return <TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
label="Server URL"
|
||||
value={changedServerUrl || ""}
|
||||
error={error === ErrorTypes.validServer || error === ErrorTypes.emptyServer}
|
||||
inputProps={{style: {fontFamily: "Monospace"}}}
|
||||
onChange={onChangeServer}
|
||||
onKeyDown={onKeyDown}
|
||||
/>;
|
||||
};
|
||||
|
||||
export default ServerConfigurator;
|
|
@ -1,60 +0,0 @@
|
|||
import React, {FC, useState, useEffect, useCallback} from "preact/compat";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import InfoIcon from "@mui/icons-material/Info";
|
||||
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
|
||||
import {ChangeEvent} from "react";
|
||||
import debounce from "lodash.debounce";
|
||||
import {getAppModeParams} from "../../../../utils/app-mode";
|
||||
|
||||
const TenantsConfiguration: FC = () => {
|
||||
const {serverURL} = getAppModeParams();
|
||||
const {tenantId: tenantIdState} = useAppState();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [tenantId, setTenantId] = useState<string | number>(tenantIdState || 0);
|
||||
|
||||
const handleApply = (value: string | number) => {
|
||||
const tenantId = Number(value);
|
||||
dispatch({type: "SET_TENANT_ID", payload: tenantId});
|
||||
if (serverURL) {
|
||||
const updateServerUrl = serverURL.replace(/(\/select\/)([\d]+)(\/prometheus)/gmi, `$1${tenantId}$3`);
|
||||
dispatch({type: "SET_SERVER", payload: updateServerUrl});
|
||||
dispatch({type: "RUN_QUERY"});
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedHandleApply = useCallback(debounce(handleApply, 700), []);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setTenantId(e.target.value);
|
||||
debouncedHandleApply(e.target.value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantId === tenantIdState) return;
|
||||
setTenantId(tenantIdState);
|
||||
}, [tenantIdState]);
|
||||
|
||||
return <TextField
|
||||
label="Tenant ID"
|
||||
type="number"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
value={tenantId}
|
||||
onChange={handleChange}
|
||||
InputProps={{
|
||||
inputProps: {min: 0},
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Tooltip title={"Define tenant id if you need request to another storage"}>
|
||||
<InfoIcon fontSize={"small"} />
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>;
|
||||
};
|
||||
|
||||
export default TenantsConfiguration;
|
|
@ -1,125 +0,0 @@
|
|||
import React, {FC, useEffect, useState} from "preact/compat";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
|
||||
import Button from "@mui/material/Button";
|
||||
import Popper from "@mui/material/Popper";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import ClickAwayListener from "@mui/material/ClickAwayListener";
|
||||
import AutorenewIcon from "@mui/icons-material/Autorenew";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import {useLocation} from "react-router-dom";
|
||||
import {getAppModeEnable} from "../../../../utils/app-mode";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
interface AutoRefreshOption {
|
||||
seconds: number
|
||||
title: string
|
||||
}
|
||||
|
||||
const delayOptions: AutoRefreshOption[] = [
|
||||
{seconds: 0, title: "Off"},
|
||||
{seconds: 1, title: "1s"},
|
||||
{seconds: 2, title: "2s"},
|
||||
{seconds: 5, title: "5s"},
|
||||
{seconds: 10, title: "10s"},
|
||||
{seconds: 30, title: "30s"},
|
||||
{seconds: 60, title: "1m"},
|
||||
{seconds: 300, title: "5m"},
|
||||
{seconds: 900, title: "15m"},
|
||||
{seconds: 1800, title: "30m"},
|
||||
{seconds: 3600, title: "1h"},
|
||||
{seconds: 7200, title: "2h"}
|
||||
];
|
||||
|
||||
export const ExecutionControls: FC = () => {
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const appModeEnable = getAppModeEnable();
|
||||
const {queryControls: {autoRefresh}} = useAppState();
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefresh) dispatch({type: "TOGGLE_AUTOREFRESH"});
|
||||
}, [location]);
|
||||
|
||||
const [selectedDelay, setSelectedDelay] = useState<AutoRefreshOption>(delayOptions[0]);
|
||||
|
||||
const handleChange = (d: AutoRefreshOption) => {
|
||||
if ((autoRefresh && !d.seconds) || (!autoRefresh && d.seconds)) {
|
||||
dispatch({type: "TOGGLE_AUTOREFRESH"});
|
||||
}
|
||||
setSelectedDelay(d);
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleUpdate = () => {
|
||||
dispatch({type: "RUN_QUERY"});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const delay = selectedDelay.seconds;
|
||||
let timer: number;
|
||||
if (autoRefresh) {
|
||||
timer = setInterval(() => {
|
||||
dispatch({type: "RUN_QUERY"});
|
||||
}, delay * 1000) as unknown as number;
|
||||
} else {
|
||||
setSelectedDelay(delayOptions[0]);
|
||||
}
|
||||
return () => {
|
||||
timer && clearInterval(timer);
|
||||
};
|
||||
}, [selectedDelay, autoRefresh]);
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return <>
|
||||
<Box sx={{
|
||||
minWidth: "110px",
|
||||
color: "white",
|
||||
border: appModeEnable ? "none" : "1px solid rgba(0, 0, 0, 0.2)",
|
||||
justifyContent: "space-between",
|
||||
boxShadow: "none",
|
||||
borderRadius: "4px",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "auto 1fr"
|
||||
}}>
|
||||
<Tooltip title="Refresh dashboard">
|
||||
<Button variant="contained" color="primary"
|
||||
sx={{color: "white", minWidth: "34px", boxShadow: "none", borderRadius: "3px 0 0 3px", p: "6px 6px"}}
|
||||
startIcon={<AutorenewIcon fontSize={"small"} style={{marginRight: "-8px", marginLeft: "4px"}}/>}
|
||||
onClick={handleUpdate}
|
||||
>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Auto-refresh control">
|
||||
<Button variant="contained" color="primary" sx={{boxShadow: "none", borderRadius: "0 3px 3px 0"}} fullWidth
|
||||
endIcon={<KeyboardArrowDownIcon sx={{transform: open ? "rotate(180deg)" : "none"}}/>}
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
>
|
||||
{selectedDelay.title}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
placement="bottom-end"
|
||||
modifiers={[{name: "offset", options: {offset: [0, 6]}}]}>
|
||||
<ClickAwayListener onClickAway={() => setAnchorEl(null)}>
|
||||
<Paper elevation={3}>
|
||||
<List style={{minWidth: "110px", maxHeight: "208px", overflow: "auto", padding: "20px 0"}}>
|
||||
{delayOptions.map(d =>
|
||||
<ListItem key={d.seconds} button onClick={() => handleChange(d)}>
|
||||
<ListItemText primary={d.title}/>
|
||||
</ListItem>)}
|
||||
</List>
|
||||
</Paper>
|
||||
</ClickAwayListener></Popper>
|
||||
</>;
|
||||
};
|
|
@ -1,21 +0,0 @@
|
|||
import React, {FC} from "preact/compat";
|
||||
import List from "@mui/material/List";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import {relativeTimeOptions} from "../../../../utils/time";
|
||||
|
||||
interface TimeDurationSelector {
|
||||
setDuration: ({duration, until, id}: {duration: string, until: Date, id: string}) => void;
|
||||
}
|
||||
|
||||
const TimeDurationSelector: FC<TimeDurationSelector> = ({setDuration}) => {
|
||||
|
||||
return <List style={{maxHeight: "168px", overflow: "auto", paddingRight: "15px"}}>
|
||||
{relativeTimeOptions.map(({id, duration, until, title}) =>
|
||||
<ListItemButton key={id} onClick={() => setDuration({duration, until: until(), id})}>
|
||||
<ListItemText primary={title || duration}/>
|
||||
</ListItemButton>)}
|
||||
</List>;
|
||||
};
|
||||
|
||||
export default TimeDurationSelector;
|
|
@ -1,173 +0,0 @@
|
|||
import React, {FC, useEffect, useState, useMemo} from "preact/compat";
|
||||
import {KeyboardEvent} from "react";
|
||||
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
|
||||
import {dateFromSeconds, formatDateForNativeInput} from "../../../../utils/time";
|
||||
import TimeDurationSelector from "./TimeDurationSelector";
|
||||
import dayjs from "dayjs";
|
||||
import QueryBuilderIcon from "@mui/icons-material/QueryBuilder";
|
||||
import Box from "@mui/material/Box";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import DateTimePicker from "@mui/lab/DateTimePicker";
|
||||
import Button from "@mui/material/Button";
|
||||
import Popper from "@mui/material/Popper";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import ClickAwayListener from "@mui/material/ClickAwayListener";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import AlarmAdd from "@mui/icons-material/AlarmAdd";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import {getAppModeEnable} from "../../../../utils/app-mode";
|
||||
|
||||
const formatDate = "YYYY-MM-DD HH:mm:ss";
|
||||
|
||||
const classes = {
|
||||
container: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "200px auto 200px",
|
||||
gridGap: "10px",
|
||||
padding: "20px",
|
||||
},
|
||||
timeControls: {
|
||||
display: "grid",
|
||||
gridTemplateRows: "auto 1fr auto",
|
||||
gridGap: "16px 0",
|
||||
},
|
||||
datePickerItem: {
|
||||
minWidth: "200px",
|
||||
},
|
||||
};
|
||||
|
||||
export const TimeSelector: FC = () => {
|
||||
|
||||
const displayFullDate = useMediaQuery("(min-width: 1120px)");
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
const [until, setUntil] = useState<string>();
|
||||
const [from, setFrom] = useState<string>();
|
||||
|
||||
const {time: {period: {end, start}, relativeTime}} = useAppState();
|
||||
const dispatch = useAppDispatch();
|
||||
const appModeEnable = getAppModeEnable();
|
||||
|
||||
useEffect(() => {
|
||||
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
|
||||
}, [end]);
|
||||
|
||||
useEffect(() => {
|
||||
setFrom(formatDateForNativeInput(dateFromSeconds(start)));
|
||||
}, [start]);
|
||||
|
||||
const setDuration = ({duration, until, id}: {duration: string, until: Date, id: string}) => {
|
||||
dispatch({type: "SET_RELATIVE_TIME", payload: {duration, until, id}});
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const formatRange = useMemo(() => {
|
||||
const startFormat = dayjs(dateFromSeconds(start)).format(formatDate);
|
||||
const endFormat = dayjs(dateFromSeconds(end)).format(formatDate);
|
||||
return {
|
||||
start: startFormat,
|
||||
end: endFormat
|
||||
};
|
||||
}, [start, end]);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
const setTimeAndClosePicker = () => {
|
||||
if (from && until) {
|
||||
dispatch({type: "SET_PERIOD", payload: {from: new Date(from), to: new Date(until)}});
|
||||
}
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const onFromChange = (from: dayjs.Dayjs | null) => setFrom(from?.format(formatDate));
|
||||
const onUntilChange = (until: dayjs.Dayjs | null) => setUntil(until?.format(formatDate));
|
||||
const onApplyClick = () => setTimeAndClosePicker();
|
||||
const onSwitchToNow = () => dispatch({type: "RUN_QUERY_TO_NOW"});
|
||||
const onCancelClick = () => {
|
||||
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
|
||||
setFrom(formatDateForNativeInput(dateFromSeconds(start)));
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.keyCode === 13) {
|
||||
setTimeAndClosePicker();
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<Tooltip title="Time range controls">
|
||||
<Button variant="contained" color="primary"
|
||||
sx={{
|
||||
color: "white",
|
||||
border: appModeEnable ? "none" : "1px solid rgba(0, 0, 0, 0.2)",
|
||||
boxShadow: "none",
|
||||
minWidth: "34px",
|
||||
padding: displayFullDate ? "" : "6px 8px",
|
||||
}}
|
||||
startIcon={<QueryBuilderIcon style={displayFullDate ? {} : {marginRight: "-8px", marginLeft: "4px"}}/>}
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}>
|
||||
{displayFullDate && <span>
|
||||
{relativeTime && relativeTime !== "none"
|
||||
? relativeTime.replace(/_/g, " ")
|
||||
: `${formatRange.start} - ${formatRange.end}`}
|
||||
</span>}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
placement="bottom-end"
|
||||
modifiers={[{name: "offset", options: {offset: [0, 6]}}]}
|
||||
sx={{zIndex: 3, position: "relative"}}
|
||||
>
|
||||
<ClickAwayListener onClickAway={() => setAnchorEl(null)}>
|
||||
<Paper elevation={3}>
|
||||
<Box sx={classes.container}>
|
||||
<Box sx={classes.timeControls}>
|
||||
<Box sx={classes.datePickerItem}>
|
||||
<DateTimePicker
|
||||
label="From"
|
||||
ampm={false}
|
||||
value={from}
|
||||
onChange={onFromChange}
|
||||
onError={console.log}
|
||||
inputFormat={formatDate}
|
||||
mask="____-__-__ __:__:__"
|
||||
renderInput={(params) => <TextField {...params} variant="standard" onKeyDown={onKeyDown}/>}
|
||||
maxDate={dayjs(until)}
|
||||
PopperProps={{disablePortal: true}}/>
|
||||
</Box>
|
||||
<Box sx={classes.datePickerItem}>
|
||||
<DateTimePicker
|
||||
label="To"
|
||||
ampm={false}
|
||||
value={until}
|
||||
onChange={onUntilChange}
|
||||
onError={console.log}
|
||||
inputFormat={formatDate}
|
||||
mask="____-__-__ __:__:__"
|
||||
renderInput={(params) => <TextField {...params} variant="standard" onKeyDown={onKeyDown}/>}
|
||||
PopperProps={{disablePortal: true}}/>
|
||||
</Box>
|
||||
<Box display="grid" gridTemplateColumns="auto 1fr" gap={1}>
|
||||
<Button variant="outlined" onClick={onCancelClick}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={onApplyClick} color={"success"}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button startIcon={<AlarmAdd />} onClick={onSwitchToNow}>
|
||||
switch to now
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{/*setup duration*/}
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Box>
|
||||
<TimeDurationSelector setDuration={setDuration}/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
</>;
|
||||
};
|
|
@ -1,105 +0,0 @@
|
|||
import React, {FC, useState, useEffect} from "preact/compat";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Box from "@mui/material/Box";
|
||||
import GraphView from "./Views/GraphView";
|
||||
import TableView from "./Views/TableView";
|
||||
import {useAppDispatch, useAppState} from "../../state/common/StateContext";
|
||||
import QueryConfigurator from "./Configurator/Query/QueryConfigurator";
|
||||
import {useFetchQuery} from "../../hooks/useFetchQuery";
|
||||
import JsonView from "./Views/JsonView";
|
||||
import {DisplayTypeSwitch} from "./Configurator/DisplayTypeSwitch";
|
||||
import GraphSettings from "./Configurator/Graph/GraphSettings";
|
||||
import {useGraphDispatch, useGraphState} from "../../state/graph/GraphStateContext";
|
||||
import {AxisRange} from "../../state/graph/reducer";
|
||||
import Spinner from "../common/Spinner";
|
||||
import {useFetchQueryOptions} from "../../hooks/useFetchQueryOptions";
|
||||
import TracingsView from "./Views/TracingsView";
|
||||
import Trace from "./Trace/Trace";
|
||||
import TableSettings from "../Table/TableSettings";
|
||||
|
||||
const CustomPanel: FC = () => {
|
||||
|
||||
const [displayColumns, setDisplayColumns] = useState<string[]>();
|
||||
const [tracesState, setTracesState] = useState<Trace[]>([]);
|
||||
const {displayType, time: {period}, query, queryControls: {isTracingEnabled}} = useAppState();
|
||||
const { customStep, yaxis } = useGraphState();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const graphDispatch = useGraphDispatch();
|
||||
|
||||
const setYaxisLimits = (limits: AxisRange) => {
|
||||
graphDispatch({type: "SET_YAXIS_LIMITS", payload: limits});
|
||||
};
|
||||
|
||||
const toggleEnableLimits = () => {
|
||||
graphDispatch({type: "TOGGLE_ENABLE_YAXIS_LIMITS"});
|
||||
};
|
||||
|
||||
const setPeriod = ({from, to}: {from: Date, to: Date}) => {
|
||||
dispatch({type: "SET_PERIOD", payload: {from, to}});
|
||||
};
|
||||
|
||||
const {queryOptions} = useFetchQueryOptions();
|
||||
const {isLoading, liveData, graphData, error, warning, traces} = useFetchQuery({
|
||||
visible: true,
|
||||
customStep
|
||||
});
|
||||
|
||||
const handleTraceDelete = (trace: Trace) => {
|
||||
const updatedTraces = tracesState.filter((data) => data.idValue !== trace.idValue);
|
||||
setTracesState([...updatedTraces]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (traces) {
|
||||
setTracesState([...tracesState, ...traces]);
|
||||
}
|
||||
}, [traces]);
|
||||
|
||||
useEffect(() => {
|
||||
setTracesState([]);
|
||||
}, [displayType]);
|
||||
|
||||
return (
|
||||
<Box p={4} display="grid" gridTemplateRows="auto 1fr" style={{minHeight: "calc(100vh - 64px)"}}>
|
||||
<Box boxShadow="rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;" p={4} pb={2} m={-4} mb={2}>
|
||||
<QueryConfigurator error={error} queryOptions={queryOptions}/>
|
||||
</Box>
|
||||
<Box height="100%">
|
||||
{isLoading && <Spinner isLoading={isLoading} height={"500px"}/>}
|
||||
{<Box height={"100%"} bgcolor={"#fff"}>
|
||||
<Box display="grid" gridTemplateColumns="1fr auto" alignItems="center" mb={2}
|
||||
borderBottom={1} borderColor="divider">
|
||||
<DisplayTypeSwitch/>
|
||||
<Box display={"flex"}>
|
||||
{displayType === "chart" && <GraphSettings
|
||||
yaxis={yaxis}
|
||||
setYaxisLimits={setYaxisLimits}
|
||||
toggleEnableLimits={toggleEnableLimits}
|
||||
/>}
|
||||
{displayType === "table" && <TableSettings
|
||||
data={liveData || []}
|
||||
defaultColumns={displayColumns}
|
||||
onChange={setDisplayColumns}
|
||||
/>}
|
||||
</Box>
|
||||
</Box>
|
||||
{error && <Alert color="error" severity="error" sx={{whiteSpace: "pre-wrap", mt: 2}}>{error}</Alert>}
|
||||
{warning && <Alert color="warning" severity="warning" sx={{whiteSpace: "pre-wrap", my: 2}}>{warning}</Alert>}
|
||||
{isTracingEnabled && <TracingsView
|
||||
traces={tracesState}
|
||||
onDeleteClick={handleTraceDelete}
|
||||
/>}
|
||||
{graphData && period && (displayType === "chart") && <>
|
||||
<GraphView data={graphData} period={period} customStep={customStep} query={query} yaxis={yaxis}
|
||||
setYaxisLimits={setYaxisLimits} setPeriod={setPeriod}/>
|
||||
</>}
|
||||
{liveData && (displayType === "code") && <JsonView data={liveData}/>}
|
||||
{liveData && (displayType === "table") && <TableView data={liveData} displayColumns={displayColumns}/>}
|
||||
</Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomPanel;
|
|
@ -1,69 +0,0 @@
|
|||
import React, {FC, useState} from "preact/compat";
|
||||
import Box from "@mui/material/Box";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ExpandLess from "@mui/icons-material/ExpandLess";
|
||||
import AddCircleRoundedIcon from "@mui/icons-material/AddCircleRounded";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import List from "@mui/material/List";
|
||||
import {BorderLinearProgressWithLabel} from "../../BorderLineProgress/BorderLinearProgress";
|
||||
import Trace from "../Trace/Trace";
|
||||
|
||||
interface RecursiveProps {
|
||||
trace: Trace;
|
||||
totalMsec: number;
|
||||
}
|
||||
|
||||
interface OpenLevels {
|
||||
[x: number]: boolean
|
||||
}
|
||||
|
||||
const NestedNav: FC<RecursiveProps> = ({ trace, totalMsec}) => {
|
||||
const [openLevels, setOpenLevels] = useState({} as OpenLevels);
|
||||
|
||||
const handleListClick = (level: number) => () => {
|
||||
setOpenLevels((prevState:OpenLevels) => {
|
||||
return {...prevState, [level]: !prevState[level]};
|
||||
});
|
||||
};
|
||||
const hasChildren = trace.children && trace.children.length;
|
||||
const progress = trace.duration / totalMsec * 100;
|
||||
return (
|
||||
<Box sx={{ bgcolor: "rgba(201, 227, 246, 0.4)" }}>
|
||||
<ListItem onClick={handleListClick(trace.idValue)} sx={!hasChildren ? {p:0, pl: 7} : {p:0}}>
|
||||
<ListItemButton alignItems={"flex-start"} sx={{ pt: 0, pb: 0}} style={{ userSelect: "text" }} disableRipple>
|
||||
{hasChildren ? <ListItemIcon>
|
||||
{openLevels[trace.idValue] ?
|
||||
<ExpandLess fontSize={"large"} color={"info"} /> :
|
||||
<AddCircleRoundedIcon fontSize={"large"} color={"info"} />}
|
||||
</ListItemIcon>: null}
|
||||
<Box display="flex" flexDirection="column" flexGrow={0.5} sx={{ ml: 4, mr: 4, width: "100%" }}>
|
||||
<ListItemText>
|
||||
<BorderLinearProgressWithLabel variant="determinate" value={progress} />
|
||||
</ListItemText>
|
||||
<ListItemText
|
||||
primary={trace.message}
|
||||
secondary={`duration: ${trace.duration} ms`}
|
||||
/>
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<>
|
||||
<Collapse in={openLevels[trace.idValue]} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding sx={{ pl: 4 }}>
|
||||
{hasChildren ?
|
||||
trace.children.map((trace) => <NestedNav
|
||||
key={trace.duration}
|
||||
trace={trace}
|
||||
totalMsec={totalMsec}
|
||||
/>) : null}
|
||||
</List>
|
||||
</Collapse>
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NestedNav;
|
|
@ -1,29 +0,0 @@
|
|||
import React, {FC} from "preact/compat";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import FileCopyIcon from "@mui/icons-material/FileCopy";
|
||||
import {useSnack} from "../../contexts/Snackbar";
|
||||
|
||||
interface UrlCopyProps {
|
||||
url?: string
|
||||
}
|
||||
|
||||
export const UrlCopy: FC<UrlCopyProps> = ({url}) => {
|
||||
|
||||
const {showInfoMessage} = useSnack();
|
||||
|
||||
return <Box pl={2} py={1} flexShrink={0} display="flex">
|
||||
<Tooltip title="Copy Query URL">
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
if (url) {
|
||||
navigator.clipboard.writeText(url);
|
||||
showInfoMessage("Value has been copied");
|
||||
e.preventDefault(); // needed to avoid snackbar immediate disappearing
|
||||
}
|
||||
}}>
|
||||
<FileCopyIcon style={{color: "white"}}/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>;
|
||||
};
|
|
@ -1,44 +0,0 @@
|
|||
import React, {FC} from "preact/compat";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {useSnack} from "../../contexts/Snackbar";
|
||||
|
||||
interface UrlLineProps {
|
||||
url?: string
|
||||
}
|
||||
|
||||
export const UrlLine: FC<UrlLineProps> = ({url}) => {
|
||||
|
||||
const {showInfoMessage} = useSnack();
|
||||
|
||||
return <Box style={{backgroundColor: "#eee", width: "100%"}}>
|
||||
<Box flexDirection="row" display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Box pl={2} py={1} display="flex" style={{
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
}}>
|
||||
<Typography style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
fontStyle: "italic",
|
||||
fontSize: "small",
|
||||
color: "#555"
|
||||
}}>
|
||||
Currently showing {url}
|
||||
</Typography>
|
||||
|
||||
</Box>
|
||||
<Box px={2} py={1} flexShrink={0} display="flex">
|
||||
<Button size="small" onClick={(e) => {
|
||||
if (url) {
|
||||
navigator.clipboard.writeText(url);
|
||||
showInfoMessage("Value has been copied");
|
||||
e.preventDefault(); // needed to avoid snackbar immediate disappearing
|
||||
}
|
||||
}}>Copy Query Url</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>;
|
||||
};
|
|
@ -1,41 +0,0 @@
|
|||
import React, {FC, useMemo} from "preact/compat";
|
||||
import {InstantMetricResult} from "../../../api/types";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import {useSnack} from "../../../contexts/Snackbar";
|
||||
import {TopQuery} from "../../../types";
|
||||
|
||||
export interface JsonViewProps {
|
||||
data: InstantMetricResult[] | TopQuery[];
|
||||
}
|
||||
|
||||
const JsonView: FC<JsonViewProps> = ({data}) => {
|
||||
const {showInfoMessage} = useSnack();
|
||||
|
||||
const formattedJson = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Box
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: "16px",
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
}}>
|
||||
<Button variant="outlined"
|
||||
fullWidth={false}
|
||||
onClick={(e) => {
|
||||
navigator.clipboard.writeText(formattedJson);
|
||||
showInfoMessage("Formatted JSON has been copied");
|
||||
e.preventDefault(); // needed to avoid snackbar immediate disappearing
|
||||
}}>
|
||||
Copy JSON
|
||||
</Button>
|
||||
</Box>
|
||||
<pre style={{margin: 0}}>{formattedJson}</pre>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default JsonView;
|
|
@ -1,110 +0,0 @@
|
|||
import React, {FC, useEffect, useMemo, useRef, useState} from "preact/compat";
|
||||
import {InstantMetricResult} from "../../../api/types";
|
||||
import {InstantDataSeries} from "../../../types";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import TableSortLabel from "@mui/material/TableSortLabel";
|
||||
import {useSortedCategories} from "../../../hooks/useSortedCategories";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import {useAppState} from "../../../state/common/StateContext";
|
||||
|
||||
export interface GraphViewProps {
|
||||
data: InstantMetricResult[];
|
||||
displayColumns?: string[]
|
||||
}
|
||||
|
||||
const TableView: FC<GraphViewProps> = ({data, displayColumns}) => {
|
||||
|
||||
const sortedColumns = useSortedCategories(data, displayColumns);
|
||||
|
||||
const [orderBy, setOrderBy] = useState("");
|
||||
const [orderDir, setOrderDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const rows: InstantDataSeries[] = useMemo(() => {
|
||||
const rows = data?.map(d => ({
|
||||
metadata: sortedColumns.map(c => d.metric[c.key] || "-"),
|
||||
value: d.value ? d.value[1] : "-"
|
||||
}));
|
||||
const orderByValue = orderBy === "Value";
|
||||
const rowIndex = sortedColumns.findIndex(c => c.key === orderBy);
|
||||
if (!orderByValue && rowIndex === -1) return rows;
|
||||
return rows.sort((a,b) => {
|
||||
const n1 = orderByValue ? Number(a.value) : a.metadata[rowIndex];
|
||||
const n2 = orderByValue ? Number(b.value) : b.metadata[rowIndex];
|
||||
const asc = orderDir === "asc" ? n1 < n2 : n1 > n2;
|
||||
return asc ? -1 : 1;
|
||||
});
|
||||
}, [sortedColumns, data, orderBy, orderDir]);
|
||||
|
||||
const sortHandler = (key: string) => {
|
||||
setOrderDir((prev) => prev === "asc" && orderBy === key ? "desc" : "asc");
|
||||
setOrderBy(key);
|
||||
};
|
||||
|
||||
const {query} = useAppState();
|
||||
const [tableContainerHeight, setTableContainerHeight] = useState("");
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!tableContainerRef.current) return;
|
||||
const {top} = tableContainerRef.current.getBoundingClientRect();
|
||||
setTableContainerHeight(`calc(100vh - ${top + 32}px)`);
|
||||
}, [tableContainerRef, query]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(rows.length > 0)
|
||||
? <TableContainer ref={tableContainerRef} sx={{width: "calc(100vw - 68px)", height: tableContainerHeight}}>
|
||||
<Table stickyHeader aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{sortedColumns.map((col, index) => (
|
||||
<TableCell key={index} style={{textTransform: "capitalize", paddingTop: 0}}>
|
||||
<TableSortLabel
|
||||
active={orderBy === col.key}
|
||||
direction={orderDir}
|
||||
onClick={() => sortHandler(col.key)}
|
||||
>
|
||||
{col.key}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="right">
|
||||
<TableSortLabel
|
||||
active={orderBy === "Value"}
|
||||
direction={orderDir}
|
||||
onClick={() => sortHandler("Value")}
|
||||
>
|
||||
Value
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row, index) => (
|
||||
<TableRow key={index} hover>
|
||||
{row.metadata.map((rowMeta, index2) => {
|
||||
const prevRowValue = rows[index - 1] && rows[index - 1].metadata[index2];
|
||||
return (
|
||||
<TableCell
|
||||
sx={prevRowValue === rowMeta ? {opacity: 0.4} : {}}
|
||||
style={{whiteSpace: "nowrap"}}
|
||||
key={index2}>{rowMeta}</TableCell>
|
||||
);
|
||||
}
|
||||
)}
|
||||
<TableCell align="right">{row.value}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
: <Alert color="warning" severity="warning" sx={{mt: 2}}>No data to show</Alert>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableView;
|
|
@ -1,17 +0,0 @@
|
|||
import React, {FC} from "preact/compat";
|
||||
import List from "@mui/material/List";
|
||||
import NestedNav from "../NestedNav/NestedNav";
|
||||
import Trace from "../Trace/Trace";
|
||||
|
||||
interface TraceViewProps {
|
||||
trace: Trace;
|
||||
}
|
||||
|
||||
const TraceView: FC<TraceViewProps> = ({trace}) => {
|
||||
|
||||
return (<List sx={{ width: "100%" }} component="nav">
|
||||
<NestedNav trace={trace} totalMsec={trace.duration} />
|
||||
</List>);
|
||||
};
|
||||
|
||||
export default TraceView;
|
|
@ -1,38 +0,0 @@
|
|||
import React, {FC} from "preact/compat";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import TraceView from "./TraceView";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import RemoveCircleIcon from "@mui/icons-material/RemoveCircle";
|
||||
import Button from "@mui/material/Button";
|
||||
import Trace from "../Trace/Trace";
|
||||
|
||||
interface TraceViewProps {
|
||||
traces: Trace[];
|
||||
onDeleteClick: (trace: Trace) => void;
|
||||
}
|
||||
|
||||
const TracingsView: FC<TraceViewProps> = ({traces, onDeleteClick}) => {
|
||||
if (!traces.length) {
|
||||
return (
|
||||
<Alert color={"info"} severity="info" sx={{whiteSpace: "pre-wrap", mt: 2}}>
|
||||
Please re-run the query to see results of the tracing
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDeleteClick = (tracingData: Trace) => () => {
|
||||
onDeleteClick(tracingData);
|
||||
};
|
||||
|
||||
return <>{traces.map((trace: Trace) => <>
|
||||
<Typography variant="h5" component="div">
|
||||
Trace for <b>{trace.queryValue}</b>
|
||||
<Button onClick={handleDeleteClick(trace)}>
|
||||
<RemoveCircleIcon fontSize={"medium"} color={"error"} />
|
||||
</Button>
|
||||
</Typography>
|
||||
<TraceView trace={trace} />
|
||||
</>)}</>;
|
||||
};
|
||||
|
||||
export default TracingsView;
|
|
@ -1,74 +1,31 @@
|
|||
import React, {FC, useMemo, useState} from "preact/compat";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Link from "@mui/material/Link";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import {ExecutionControls} from "../CustomPanel/Configurator/Time/ExecutionControls";
|
||||
import Logo from "../common/Logo";
|
||||
import {setQueryStringWithoutPageReload} from "../../utils/query-string";
|
||||
import {TimeSelector} from "../CustomPanel/Configurator/Time/TimeSelector";
|
||||
import GlobalSettings from "../CustomPanel/Configurator/Settings/GlobalSettings";
|
||||
import {Link as RouterLink, useLocation, useNavigate} from "react-router-dom";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import router, {RouterOptions, routerOptions} from "../../router/index";
|
||||
import DatePicker from "../Main/DatePicker/DatePicker";
|
||||
import {useCardinalityState, useCardinalityDispatch} from "../../state/cardinality/CardinalityStateContext";
|
||||
import {useEffect} from "react";
|
||||
import ShortcutKeys from "../ShortcutKeys/ShortcutKeys";
|
||||
import {getAppModeEnable, getAppModeParams} from "../../utils/app-mode";
|
||||
|
||||
const classes = {
|
||||
logo: {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
marginBottom: "2px"
|
||||
},
|
||||
issueLink: {
|
||||
textAlign: "center",
|
||||
fontSize: "10px",
|
||||
opacity: ".4",
|
||||
color: "inherit",
|
||||
textDecoration: "underline",
|
||||
transition: ".2s opacity",
|
||||
whiteSpace: "nowrap",
|
||||
"&:hover": {
|
||||
opacity: ".8",
|
||||
}
|
||||
},
|
||||
menuLink: {
|
||||
display: "block",
|
||||
padding: "16px 8px",
|
||||
color: "white",
|
||||
fontSize: "11px",
|
||||
textDecoration: "none",
|
||||
cursor: "pointer",
|
||||
textTransform: "uppercase",
|
||||
borderRadius: "4px",
|
||||
transition: ".2s background",
|
||||
"&:hover": {
|
||||
boxShadow: "rgba(0, 0, 0, 0.15) 0px 2px 8px"
|
||||
}
|
||||
}
|
||||
};
|
||||
import React, { FC, useMemo, useState } from "preact/compat";
|
||||
import { ExecutionControls } from "../Configurators/TimeRangeSettings/ExecutionControls/ExecutionControls";
|
||||
import { setQueryStringWithoutPageReload } from "../../utils/query-string";
|
||||
import { TimeSelector } from "../Configurators/TimeRangeSettings/TimeSelector/TimeSelector";
|
||||
import GlobalSettings from "../Configurators/GlobalSettings/GlobalSettings";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import router, { RouterOptions, routerOptions } from "../../router";
|
||||
import { useEffect } from "react";
|
||||
import ShortcutKeys from "../Main/ShortcutKeys/ShortcutKeys";
|
||||
import { getAppModeEnable, getAppModeParams } from "../../utils/app-mode";
|
||||
import CardinalityDatePicker from "../Configurators/CardinalityDatePicker/CardinalityDatePicker";
|
||||
import { LogoIcon } from "../Main/Icons";
|
||||
import { getCssVariable } from "../../utils/theme";
|
||||
import Tabs from "../Main/Tabs/Tabs";
|
||||
import "./style.scss";
|
||||
import classNames from "classnames";
|
||||
|
||||
const Header: FC = () => {
|
||||
|
||||
const primaryColor = getCssVariable("color-primary");
|
||||
const appModeEnable = getAppModeEnable();
|
||||
const {headerStyles: {
|
||||
background = appModeEnable ? "#FFF" : "primary.main",
|
||||
color = appModeEnable ? "primary.main" : "#FFF",
|
||||
} = {}} = getAppModeParams();
|
||||
|
||||
const {date} = useCardinalityState();
|
||||
const cardinalityDispatch = useCardinalityDispatch();
|
||||
const { headerStyles: {
|
||||
background = appModeEnable ? "#FFF" : primaryColor,
|
||||
color = appModeEnable ? primaryColor : "#FFF",
|
||||
} = {} } = getAppModeParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const {search, pathname} = useLocation();
|
||||
const { search, pathname } = useLocation();
|
||||
const routes = useMemo(() => ([
|
||||
{
|
||||
label: "Custom panel",
|
||||
|
@ -91,66 +48,73 @@ const Header: FC = () => {
|
|||
|
||||
const [activeMenu, setActiveMenu] = useState(pathname);
|
||||
|
||||
const handleChangeTab = (value: string) => {
|
||||
setActiveMenu(value);
|
||||
navigate(value);
|
||||
};
|
||||
|
||||
const headerSetup = useMemo(() => {
|
||||
return ((routerOptions[pathname] || {}) as RouterOptions).header || {};
|
||||
}, [pathname]);
|
||||
|
||||
const onClickLogo = () => {
|
||||
navigateHandler(router.home);
|
||||
setQueryStringWithoutPageReload("");
|
||||
setQueryStringWithoutPageReload({});
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const navigateHandler = (pathname: string) => {
|
||||
navigate({pathname, search: search});
|
||||
navigate({ pathname, search: search });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setActiveMenu(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
return <AppBar position="static" sx={{px: 1, boxShadow: "none", bgcolor: background, color}}>
|
||||
<Toolbar>
|
||||
return <header
|
||||
className={classNames({
|
||||
"vm-header": true,
|
||||
"vm-header_app": appModeEnable
|
||||
})}
|
||||
style={{ background, color }}
|
||||
>
|
||||
{!appModeEnable && (
|
||||
<Box display="grid" alignItems="center" justifyContent="center">
|
||||
<Box onClick={onClickLogo} sx={classes.logo}>
|
||||
<Logo style={{color: "inherit", width: "100%"}}/>
|
||||
</Box>
|
||||
<Link sx={classes.issueLink} target="_blank"
|
||||
href="https://github.com/VictoriaMetrics/VictoriaMetrics/issues/new">
|
||||
<div
|
||||
className="vm-header-logo"
|
||||
style={{ color }}
|
||||
>
|
||||
<div
|
||||
className="vm-header-logo__icon"
|
||||
onClick={onClickLogo}
|
||||
>
|
||||
<LogoIcon/>
|
||||
</div>
|
||||
<a
|
||||
className="vm-header-logo__issue"
|
||||
target="_blank"
|
||||
href="https://github.com/VictoriaMetrics/VictoriaMetrics/issues/new"
|
||||
rel="noreferrer"
|
||||
>
|
||||
create an issue
|
||||
</Link>
|
||||
</Box>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<Box ml={appModeEnable ? 0 : 8} flexGrow={1}>
|
||||
<Tabs value={activeMenu} textColor="inherit" TabIndicatorProps={{style: {background: color}}}
|
||||
onChange={(e, val) => setActiveMenu(val)}>
|
||||
{routes.filter(r => !r.hide).map(r => (
|
||||
<Tab
|
||||
key={`${r.label}_${r.value}`}
|
||||
label={r.label}
|
||||
value={r.value}
|
||||
component={RouterLink}
|
||||
to={`${r.value}${search}`}
|
||||
sx={{color}}
|
||||
<div className="vm-header-nav">
|
||||
<Tabs
|
||||
activeItem={activeMenu}
|
||||
items={routes.filter(r => !r.hide)}
|
||||
color={color}
|
||||
onChange={handleChangeTab}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
<Box display="flex" gap={1} alignItems="center" mr={0} ml={4}>
|
||||
</div>
|
||||
<div className="vm-header__settings">
|
||||
{headerSetup?.timeSelector && <TimeSelector/>}
|
||||
{headerSetup?.datePicker && (
|
||||
<DatePicker
|
||||
date={date}
|
||||
onChange={(val) => cardinalityDispatch({type: "SET_DATE", payload: val})}
|
||||
/>
|
||||
)}
|
||||
{headerSetup?.cardinalityDatePicker && <CardinalityDatePicker/>}
|
||||
{headerSetup?.executionControls && <ExecutionControls/>}
|
||||
{headerSetup?.globalSettings && !appModeEnable && <GlobalSettings/>}
|
||||
<ShortcutKeys/>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>;
|
||||
</div>
|
||||
</header>;
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
|
56
app/vmui/packages/vmui/src/components/Header/style.scss
Normal file
56
app/vmui/packages/vmui/src/components/Header/style.scss
Normal file
|
@ -0,0 +1,56 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: $padding-small $padding-medium;
|
||||
gap: $padding-large;
|
||||
|
||||
&_app {
|
||||
padding: $padding-small 0;
|
||||
}
|
||||
|
||||
&-logo {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&__icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&__issue {
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
opacity: 0.4;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
transition: 0.2s opacity;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-nav {
|
||||
font-size: $font-size-small;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: $padding-small;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
|
@ -1,13 +1,24 @@
|
|||
import Header from "../Header/Header";
|
||||
import React, {FC} from "preact/compat";
|
||||
import Box from "@mui/material/Box";
|
||||
import React, { FC } from "preact/compat";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import "./style.scss";
|
||||
import { getAppModeEnable } from "../../utils/app-mode";
|
||||
import classNames from "classnames";
|
||||
|
||||
const HomeLayout: FC = () => {
|
||||
return <Box>
|
||||
const appModeEnable = getAppModeEnable();
|
||||
|
||||
return <section className="vm-container">
|
||||
<Header/>
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-container-body": true,
|
||||
"vm-container-body_app": appModeEnable
|
||||
})}
|
||||
>
|
||||
<Outlet/>
|
||||
</Box>;
|
||||
</div>
|
||||
</section>;
|
||||
};
|
||||
|
||||
export default HomeLayout;
|
||||
|
|
19
app/vmui/packages/vmui/src/components/Home/style.scss
Normal file
19
app/vmui/packages/vmui/src/components/Home/style.scss
Normal file
|
@ -0,0 +1,19 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - var(--scrollbar-height));
|
||||
|
||||
&-body {
|
||||
flex-grow: 1;
|
||||
min-height: 100%;
|
||||
padding: $padding-medium;
|
||||
background-color: $color-background-body;
|
||||
|
||||
&_app {
|
||||
padding: $padding-small 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
import React, {FC, useMemo, useState} from "preact/compat";
|
||||
import {LegendItem} from "../../utils/uplot/types";
|
||||
import "./legend.css";
|
||||
import {getLegendLabel} from "../../utils/uplot/helpers";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
|
||||
export interface LegendProps {
|
||||
labels: LegendItem[];
|
||||
query: string[];
|
||||
onChange: (item: LegendItem, metaKey: boolean) => void;
|
||||
}
|
||||
|
||||
const Legend: FC<LegendProps> = ({labels, query, onChange}) => {
|
||||
const [copiedValue, setCopiedValue] = useState("");
|
||||
|
||||
const groups = useMemo(() => {
|
||||
return Array.from(new Set(labels.map(l => l.group)));
|
||||
}, [labels]);
|
||||
|
||||
const handleClickFreeField = async (val: string, id: string) => {
|
||||
await navigator.clipboard.writeText(val);
|
||||
setCopiedValue(id);
|
||||
setTimeout(() => setCopiedValue(""), 2000);
|
||||
};
|
||||
|
||||
return <>
|
||||
<div className="legendWrapper">
|
||||
{groups.map((group) => <div className="legendGroup" key={group}>
|
||||
<div className="legendGroupTitle">
|
||||
<span className="legendGroupQuery">Query {group}</span>
|
||||
<span>("{query[group - 1]}")</span>
|
||||
</div>
|
||||
<div>
|
||||
{labels.filter(l => l.group === group).map((legendItem: LegendItem) =>
|
||||
<div className={legendItem.checked ? "legendItem" : "legendItem legendItemHide"}
|
||||
key={legendItem.label}
|
||||
onClick={(e) => onChange(legendItem, e.ctrlKey || e.metaKey)}>
|
||||
<div className="legendMarker" style={{backgroundColor: legendItem.color}}/>
|
||||
<div className="legendLabel">
|
||||
{getLegendLabel(legendItem.label)}
|
||||
{!!Object.keys(legendItem.freeFormFields).length && <>
|
||||
 {
|
||||
{Object.keys(legendItem.freeFormFields).filter(f => f !== "__name__").map((f) => {
|
||||
const freeField = `${f}="${legendItem.freeFormFields[f]}"`;
|
||||
const fieldId = `${legendItem.label}.${freeField}`;
|
||||
return <Tooltip arrow key={f} open={copiedValue === fieldId} title={"Copied!"}>
|
||||
<span className="legendFreeFields" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClickFreeField(freeField, fieldId);
|
||||
}}>
|
||||
{freeField}
|
||||
</span>
|
||||
</Tooltip>;
|
||||
})}
|
||||
}
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default Legend;
|
|
@ -1,77 +0,0 @@
|
|||
.legendWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.legendGroup {
|
||||
margin: 0 12px 0 0;
|
||||
padding: 10px 6px;
|
||||
}
|
||||
|
||||
.legendGroupTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px 5px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 11px;
|
||||
border-bottom: 1px solid #ECEBE6;
|
||||
}
|
||||
|
||||
.legendGroupQuery {
|
||||
font-weight: bold;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.legendGroupLine {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.legendItem {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
grid-gap: 6px;
|
||||
align-items: start;
|
||||
justify-content: start;
|
||||
padding: 7px 50px 7px 10px;
|
||||
background-color: #FFF;
|
||||
cursor: pointer;
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
|
||||
.legendItemHide {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.legendItem:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.legendMarker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
|
||||
.legendLabel {
|
||||
font-size: 11px;
|
||||
line-height: 12px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.legendFreeFields {
|
||||
padding: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.legendFreeFields:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.legendFreeFields:not(:last-child):after {
|
||||
content: ",";
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
.u-tooltip {
|
||||
position: absolute;
|
||||
display: none;
|
||||
grid-gap: 12px;
|
||||
max-width: 300px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(57, 57, 57, 0.9);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1.4em;
|
||||
font-weight: bold;
|
||||
word-wrap: break-word;
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.u-tooltip-data {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
line-height: 150%;
|
||||
}
|
||||
|
||||
.u-tooltip-data__value {
|
||||
padding: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.u-tooltip__info {
|
||||
display: grid;
|
||||
grid-gap: 4px;
|
||||
}
|
||||
|
||||
.u-tooltip__marker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 4px;
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import React, { FC, useState, useEffect } from "preact/compat";
|
||||
import { ArrowDownIcon } from "../Icons";
|
||||
import "./style.scss";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface AccordionProps {
|
||||
title: ReactNode
|
||||
children: ReactNode
|
||||
defaultExpanded?: boolean
|
||||
onChange?: (value: boolean) => void
|
||||
}
|
||||
|
||||
const Accordion: FC<AccordionProps> = ({
|
||||
defaultExpanded = false,
|
||||
onChange,
|
||||
title,
|
||||
children
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultExpanded);
|
||||
|
||||
const toggleOpen = () => {
|
||||
setIsOpen(prev => !prev);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onChange && onChange(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={`vm-accordion-header ${isOpen && "vm-accordion-header_open"}`}
|
||||
onClick={toggleOpen}
|
||||
>
|
||||
{title}
|
||||
<div className={`vm-accordion-header__arrow ${isOpen && "vm-accordion-header__arrow_open"}`}>
|
||||
<ArrowDownIcon />
|
||||
</div>
|
||||
</header>
|
||||
{isOpen && (
|
||||
<section
|
||||
className="vm-accordion-section"
|
||||
key="content"
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Accordion;
|
|
@ -0,0 +1,33 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-accordion-header {
|
||||
position: relative;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
|
||||
&__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: auto;
|
||||
transform: rotate(0);
|
||||
transition: transform 200ms ease-in-out;
|
||||
|
||||
&_open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-section {
|
||||
overflow: hidden;
|
||||
}
|
36
app/vmui/packages/vmui/src/components/Main/Alert/Alert.tsx
Normal file
36
app/vmui/packages/vmui/src/components/Main/Alert/Alert.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import React, { FC } from "preact/compat";
|
||||
import { ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
import "./style.scss";
|
||||
import { ErrorIcon, InfoIcon, SuccessIcon, WarningIcon } from "../Icons";
|
||||
|
||||
interface AlertProps {
|
||||
variant?: "success" | "error" | "info" | "warning"
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const icons = {
|
||||
success: <SuccessIcon/>,
|
||||
error: <ErrorIcon/>,
|
||||
warning: <WarningIcon/>,
|
||||
info: <InfoIcon/>
|
||||
};
|
||||
|
||||
const Alert: FC<AlertProps> = ({
|
||||
variant,
|
||||
children }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-alert": true,
|
||||
[`vm-alert_${variant}`]: variant
|
||||
})}
|
||||
>
|
||||
<div className="vm-alert__icon">{icons[variant || "info"]}</div>
|
||||
<div className="vm-alert__content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alert;
|
77
app/vmui/packages/vmui/src/components/Main/Alert/style.scss
Normal file
77
app/vmui/packages/vmui/src/components/Main/Alert/style.scss
Normal file
|
@ -0,0 +1,77 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-alert {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
align-items: center;
|
||||
gap: $padding-small;
|
||||
padding: $padding-global;
|
||||
background-color: $color-background-block;
|
||||
border-radius: $border-radius-medium;
|
||||
box-shadow: $box-shadow;
|
||||
font-size: $font-size-medium;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
line-height: 1.3;
|
||||
|
||||
&:after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: $border-radius-medium;
|
||||
z-index: 1;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
&__icon,
|
||||
&__content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__content {
|
||||
filter: brightness(0.6);
|
||||
}
|
||||
|
||||
&_success {
|
||||
color: $color-success;
|
||||
|
||||
&:after {
|
||||
background-color: $color-success;
|
||||
}
|
||||
}
|
||||
|
||||
&_error {
|
||||
color: $color-error;
|
||||
|
||||
&:after {
|
||||
background-color: $color-error;
|
||||
}
|
||||
}
|
||||
|
||||
&_info {
|
||||
color: $color-info;
|
||||
|
||||
&:after {
|
||||
background-color: $color-info;
|
||||
}
|
||||
}
|
||||
|
||||
&_warning {
|
||||
color: $color-warning;
|
||||
|
||||
&:after {
|
||||
background-color: $color-warning;
|
||||
}
|
||||
}
|
||||
}
|
58
app/vmui/packages/vmui/src/components/Main/Button/Button.tsx
Normal file
58
app/vmui/packages/vmui/src/components/Main/Button/Button.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import React, { FC } from "preact/compat";
|
||||
import classNames from "classnames";
|
||||
import { MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
import "./style.scss";
|
||||
|
||||
interface ButtonProps {
|
||||
variant?: "contained" | "outlined" | "text"
|
||||
color?: "primary" | "secondary" | "success" | "error"
|
||||
size?: "small" | "medium" | "large"
|
||||
endIcon?: ReactNode
|
||||
startIcon?: ReactNode
|
||||
fullWidth?: boolean
|
||||
disabled?: boolean
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
onClick?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void
|
||||
}
|
||||
|
||||
const Button: FC<ButtonProps> = ({
|
||||
variant = "contained",
|
||||
color = "primary",
|
||||
size = "medium",
|
||||
children,
|
||||
endIcon,
|
||||
startIcon,
|
||||
fullWidth = false,
|
||||
className,
|
||||
disabled,
|
||||
onClick,
|
||||
}) => {
|
||||
|
||||
const classesButton = classNames({
|
||||
"vm-button": true,
|
||||
[`vm-button_${variant}_${color}`]: true,
|
||||
[`vm-button_${size}`]: size,
|
||||
"vm-button_icon": (startIcon || endIcon) && !children,
|
||||
"vm-button_full-width": fullWidth,
|
||||
"vm-button_with-icon": startIcon || endIcon,
|
||||
"vm-button_disabled": disabled,
|
||||
[className || ""]: className
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classesButton}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<>
|
||||
{startIcon && <span className="vm-button__start-icon">{startIcon}</span>}
|
||||
{children && <span>{children}</span>}
|
||||
{endIcon && <span className="vm-button__end-icon">{endIcon}</span>}
|
||||
</>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
177
app/vmui/packages/vmui/src/components/Main/Button/style.scss
Normal file
177
app/vmui/packages/vmui/src/components/Main/Button/style.scss
Normal file
|
@ -0,0 +1,177 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
$button-radius: 6px;
|
||||
|
||||
.vm-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 14px;
|
||||
font-size: $font-size-small;
|
||||
line-height: 15px;
|
||||
font-weight: 500;
|
||||
min-height: 31px;
|
||||
border-radius: $button-radius;
|
||||
color: $color-white;
|
||||
transform-style: preserve-3d;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:after {
|
||||
background-color: rgba($color-black, 0.05);
|
||||
}
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: background-color 200ms ease;
|
||||
border-radius: $button-radius;
|
||||
}
|
||||
|
||||
&:before {
|
||||
transform: translateZ(-2px);
|
||||
}
|
||||
|
||||
&:after {
|
||||
background-color: transparent;
|
||||
transform: translateZ(-1px);
|
||||
}
|
||||
|
||||
span {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
&__start-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&__end-icon {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
&_disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&_icon {
|
||||
padding: 6px $padding-small;
|
||||
}
|
||||
|
||||
&_icon &__start-icon,
|
||||
&_icon &__end-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* size SMALL */
|
||||
&_small {
|
||||
padding: 4px 6px;
|
||||
min-height: 25px;
|
||||
|
||||
span {
|
||||
svg {
|
||||
width: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* variant CONTAINED */
|
||||
&_contained_primary {
|
||||
color: $color-primary-text;
|
||||
|
||||
&:before {
|
||||
background-color: $color-primary;
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
background-color: rgba($color-black, 0.2)
|
||||
}
|
||||
}
|
||||
|
||||
&_contained_secondary {
|
||||
color: $color-secondary-text;
|
||||
|
||||
&:before {
|
||||
background-color: $color-secondary;
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
background-color: rgba($color-black, 0.2)
|
||||
}
|
||||
}
|
||||
|
||||
&_contained_success {
|
||||
color: $color-success-text;
|
||||
|
||||
&:before {
|
||||
background-color: $color-success;
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
background-color: rgba($color-black, 0.2)
|
||||
}
|
||||
}
|
||||
|
||||
&_contained_error {
|
||||
color: $color-error-text;
|
||||
|
||||
&:before {
|
||||
background-color: $color-error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* variant TEXT */
|
||||
&_text_primary {
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
&_text_secondary {
|
||||
color: $color-secondary;
|
||||
}
|
||||
|
||||
&_text_success {
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
&_text_error {
|
||||
color: $color-error;
|
||||
}
|
||||
|
||||
|
||||
/* variant OUTLINED */
|
||||
&_outlined_primary {
|
||||
border: 1px solid $color-primary;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
&_outlined_error {
|
||||
border: 1px solid $color-error;
|
||||
color: $color-error;
|
||||
}
|
||||
|
||||
&_outlined_secondary {
|
||||
border: 1px solid $color-secondary;
|
||||
color: $color-secondary;
|
||||
}
|
||||
|
||||
&_outlined_success {
|
||||
border: 1px solid $color-success;
|
||||
color: $color-success;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
import "./style.scss";
|
||||
import { FC } from "preact/compat";
|
||||
import { DoneIcon } from "../Icons";
|
||||
|
||||
interface CheckboxProps {
|
||||
checked: boolean
|
||||
color?: "primary" | "secondary" | "error"
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
onChange: (value: boolean) => void
|
||||
}
|
||||
|
||||
const Checkbox: FC<CheckboxProps> = ({
|
||||
checked = false, disabled = false, label, color = "secondary", onChange
|
||||
}) => {
|
||||
const toggleCheckbox = () => {
|
||||
if (disabled) return;
|
||||
onChange(!checked);
|
||||
};
|
||||
|
||||
const checkboxClasses = classNames({
|
||||
"vm-checkbox": true,
|
||||
"vm-checkbox_disabled": disabled,
|
||||
"vm-checkbox_active": checked,
|
||||
[`vm-checkbox_${color}_active`]: checked,
|
||||
[`vm-checkbox_${color}`]: color
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={checkboxClasses}
|
||||
onClick={toggleCheckbox}
|
||||
>
|
||||
<div className="vm-checkbox-track">
|
||||
<div className="vm-checkbox-track__thumb">
|
||||
<DoneIcon/>
|
||||
</div>
|
||||
</div>
|
||||
{label && <span className="vm-checkbox__label">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkbox;
|
|
@ -0,0 +1,81 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
$checkbox-size: 16px;
|
||||
$checkbox-padding: 2px;
|
||||
$checkbox-handle-size: $checkbox-size - ($checkbox-padding * 2);
|
||||
$checkbox-border-radius: $border-radius-small;
|
||||
|
||||
.vm-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&_disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&_secondary_active &-track {
|
||||
background-color: $color-secondary;
|
||||
}
|
||||
|
||||
&_secondary &-track {
|
||||
border: 1px solid $color-secondary;
|
||||
}
|
||||
|
||||
&_primary_active &-track {
|
||||
background-color: $color-primary;
|
||||
}
|
||||
|
||||
&_primary &-track {
|
||||
border: 1px solid $color-primary;
|
||||
}
|
||||
|
||||
&_active &-track {
|
||||
&__thumb {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &-track {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&-track {
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $checkbox-border-radius;
|
||||
padding: $checkbox-padding;
|
||||
width: $checkbox-size;
|
||||
height: $checkbox-size;
|
||||
transition: background-color 200ms ease, opacity 300ms ease-out;
|
||||
|
||||
&__thumb {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: $checkbox-handle-size;
|
||||
height: $checkbox-handle-size;
|
||||
color: $color-white;
|
||||
transform: scale(0);
|
||||
transition: transform 100ms ease-in-out;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
white-space: nowrap;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
margin-left: $padding-small;
|
||||
transition: color 200ms ease;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
import React, { FC, useEffect, useState } from "preact/compat";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import CalendarHeader from "./CalendarHeader/CalendarHeader";
|
||||
import CalendarBody from "./CalendarBody/CalendarBody";
|
||||
import YearsList from "./YearsList/YearsList";
|
||||
import TimePicker from "../TImePicker/TimePicker";
|
||||
import { DATE_TIME_FORMAT } from "../../../../constants/date";
|
||||
import "./style.scss";
|
||||
import { CalendarIcon, ClockIcon } from "../../Icons";
|
||||
import Tabs from "../../Tabs/Tabs";
|
||||
|
||||
interface DatePickerProps {
|
||||
date: Date | Dayjs
|
||||
format?: string
|
||||
timepicker?: boolean,
|
||||
onChange: (date: string) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ value: "date", icon: <CalendarIcon/> },
|
||||
{ value: "time", icon: <ClockIcon/> }
|
||||
];
|
||||
|
||||
const Calendar: FC<DatePickerProps> = ({
|
||||
date,
|
||||
timepicker = false,
|
||||
format = DATE_TIME_FORMAT,
|
||||
onChange,
|
||||
onClose
|
||||
}) => {
|
||||
const [displayYears, setDisplayYears] = useState(false);
|
||||
const [viewDate, setViewDate] = useState(dayjs(date));
|
||||
const [selectDate, setSelectDate] = useState(dayjs(date));
|
||||
const [tab, setTab] = useState(tabs[0].value);
|
||||
|
||||
const toggleDisplayYears = () => {
|
||||
setDisplayYears(prev => !prev);
|
||||
};
|
||||
|
||||
const handleChangeViewDate = (date: Dayjs) => {
|
||||
setViewDate(date);
|
||||
setDisplayYears(false);
|
||||
};
|
||||
|
||||
const handleChangeSelectDate = (date: Dayjs) => {
|
||||
setSelectDate(date);
|
||||
if (timepicker) setTab("time");
|
||||
};
|
||||
|
||||
const handleChangeTime = (time: string) => {
|
||||
const [hour, minute, second] = time.split(":");
|
||||
setSelectDate(prev => prev.set("hour", +hour).set("minute", +minute).set("second", +second));
|
||||
};
|
||||
|
||||
const handleChangeTab = (value: string) => {
|
||||
setTab(value);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose && onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectDate.format() === dayjs(date).format()) return;
|
||||
onChange(selectDate.format(format));
|
||||
}, [selectDate]);
|
||||
|
||||
return (
|
||||
<div className="vm-calendar">
|
||||
{tab === "date" && (
|
||||
<CalendarHeader
|
||||
viewDate={viewDate}
|
||||
onChangeViewDate={handleChangeViewDate}
|
||||
toggleDisplayYears={toggleDisplayYears}
|
||||
displayYears={displayYears}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "date" && (
|
||||
<>
|
||||
{!displayYears && (
|
||||
<CalendarBody
|
||||
viewDate={viewDate}
|
||||
selectDate={selectDate}
|
||||
onChangeSelectDate={handleChangeSelectDate}
|
||||
/>
|
||||
)}
|
||||
{displayYears && (
|
||||
<YearsList
|
||||
viewDate={viewDate}
|
||||
onChangeViewDate={handleChangeViewDate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "time" && (
|
||||
<TimePicker
|
||||
selectDate={selectDate}
|
||||
onChangeTime={handleChangeTime}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{timepicker && (
|
||||
<div className="vm-calendar__tabs">
|
||||
<Tabs
|
||||
activeItem={tab}
|
||||
items={tabs}
|
||||
onChange={handleChangeTab}
|
||||
indicatorPlacement="top"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calendar;
|
|
@ -0,0 +1,61 @@
|
|||
import React, { FC, useMemo } from "preact/compat";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface CalendarBodyProps {
|
||||
viewDate: Dayjs
|
||||
selectDate: Dayjs
|
||||
onChangeSelectDate: (date: Dayjs) => void
|
||||
}
|
||||
|
||||
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
const CalendarBody: FC<CalendarBodyProps> = ({ viewDate, selectDate, onChangeSelectDate }) => {
|
||||
const today = dayjs().startOf("day");
|
||||
|
||||
const days: (Dayjs|null)[] = useMemo(() => {
|
||||
const result = new Array(42).fill(null);
|
||||
const startDate = viewDate.startOf("month");
|
||||
const endDate = viewDate.endOf("month");
|
||||
const days = endDate.diff(startDate, "day") + 1;
|
||||
const monthDays = new Array(days).fill(startDate).map((d,i) => d.add(i, "day"));
|
||||
const startOfWeek = startDate.day();
|
||||
result.splice(startOfWeek, days, ...monthDays);
|
||||
return result;
|
||||
}, [viewDate]);
|
||||
|
||||
const createHandlerSelectDate = (d: Dayjs | null) => () => {
|
||||
if (d) onChangeSelectDate(d);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-calendar-body">
|
||||
{weekday.map(w => (
|
||||
<div
|
||||
className="vm-calendar-body-cell vm-calendar-body-cell_weekday"
|
||||
key={w}
|
||||
>
|
||||
{w[0]}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{days.map((d, i) => (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-calendar-body-cell": true,
|
||||
"vm-calendar-body-cell_day": true,
|
||||
"vm-calendar-body-cell_day_empty": !d,
|
||||
"vm-calendar-body-cell_day_active": (d && d.toISOString()) === selectDate.startOf("day").toISOString(),
|
||||
"vm-calendar-body-cell_day_today": (d && d.toISOString()) === today.toISOString()
|
||||
})}
|
||||
key={d ? d.toISOString() : i}
|
||||
onClick={createHandlerSelectDate(d)}
|
||||
>
|
||||
{d && d.format("D")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarBody;
|
|
@ -0,0 +1,55 @@
|
|||
import React, { FC } from "preact/compat";
|
||||
import { Dayjs } from "dayjs";
|
||||
import { ArrowDownIcon, ArrowDropDownIcon } from "../../../Icons";
|
||||
|
||||
interface CalendarHeaderProps {
|
||||
viewDate: Dayjs
|
||||
onChangeViewDate: (date: Dayjs) => void
|
||||
displayYears: boolean
|
||||
toggleDisplayYears: () => void
|
||||
}
|
||||
|
||||
const CalendarHeader: FC<CalendarHeaderProps> = ({ viewDate, displayYears, onChangeViewDate, toggleDisplayYears }) => {
|
||||
|
||||
const setPrevMonth = () => {
|
||||
onChangeViewDate(viewDate.subtract(1, "month"));
|
||||
};
|
||||
|
||||
const setNextMonth = () => {
|
||||
onChangeViewDate(viewDate.add(1, "month"));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-calendar-header">
|
||||
<div
|
||||
className="vm-calendar-header-left"
|
||||
onClick={toggleDisplayYears}
|
||||
>
|
||||
<span className="vm-calendar-header-left__date">
|
||||
{viewDate.format("MMMM YYYY")}
|
||||
</span>
|
||||
<div className="vm-calendar-header-left__select-year">
|
||||
<ArrowDropDownIcon/>
|
||||
</div>
|
||||
</div>
|
||||
{!displayYears && (
|
||||
<div className="vm-calendar-header-right">
|
||||
<div
|
||||
className="vm-calendar-header-right__prev"
|
||||
onClick={setPrevMonth}
|
||||
>
|
||||
<ArrowDownIcon/>
|
||||
</div>
|
||||
<div
|
||||
className="vm-calendar-header-right__next"
|
||||
onClick={setNextMonth}
|
||||
>
|
||||
<ArrowDownIcon/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarHeader;
|
|
@ -0,0 +1,49 @@
|
|||
import React, { FC, useEffect, useMemo } from "preact/compat";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface CalendarYearsProps {
|
||||
viewDate: Dayjs
|
||||
onChangeViewDate: (date: Dayjs) => void
|
||||
}
|
||||
|
||||
const YearsList: FC<CalendarYearsProps> = ({ viewDate, onChangeViewDate }) => {
|
||||
|
||||
const currentYear = useMemo(() => viewDate.format("YYYY"), [viewDate]);
|
||||
const years: Dayjs[] = useMemo(() => {
|
||||
const displayYears = 206;
|
||||
const year = dayjs();
|
||||
const startYear = year.subtract(displayYears/2, "year");
|
||||
return new Array(displayYears).fill(startYear).map((d, i) => d.add(i, "year"));
|
||||
}, [viewDate]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedEl = document.getElementById(`vm-calendar-year-${currentYear}`);
|
||||
if (!selectedEl) return;
|
||||
selectedEl.scrollIntoView({ block: "center" });
|
||||
}, []);
|
||||
|
||||
const createHandlerClick = (year: Dayjs) => () => {
|
||||
onChangeViewDate(year);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-calendar-years">
|
||||
{years.map(y => (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-calendar-years__year": true,
|
||||
"vm-calendar-years__year_selected": y.format("YYYY") === currentYear
|
||||
})}
|
||||
id={`vm-calendar-year-${y.format("YYYY")}`}
|
||||
key={y.format("YYYY")}
|
||||
onClick={createHandlerClick(y)}
|
||||
>
|
||||
{y.format("YYYY")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default YearsList;
|
|
@ -0,0 +1,295 @@
|
|||
@use "../../../../styles/variables" as *;
|
||||
|
||||
.vm-calendar {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
padding: $padding-global;
|
||||
font-size: $font-size;
|
||||
user-select: none;
|
||||
background-color: $color-background-block;
|
||||
border-radius: $border-radius-medium;
|
||||
|
||||
&__tabs {
|
||||
margin: 0 0-$padding-global 0-$padding-global;
|
||||
border-top: $border-divider;
|
||||
margin-top: $padding-global;
|
||||
}
|
||||
|
||||
&-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $padding-medium;
|
||||
padding-bottom: $padding-global;
|
||||
min-height: 36px;
|
||||
|
||||
&-left {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: $padding-small;
|
||||
cursor: pointer;
|
||||
transition: opacity 200ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: $font-size;
|
||||
color: $color-text;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__select-year {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-right {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $padding-small;
|
||||
|
||||
&__prev,
|
||||
&__next {
|
||||
cursor: pointer;
|
||||
transition: opacity 200ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__prev {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&__next {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 32px);
|
||||
grid-template-rows: repeat(6, 32px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
|
||||
&-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
height: 100%;
|
||||
|
||||
&_weekday {
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
&_day {
|
||||
cursor: pointer;
|
||||
transition: color 200ms ease, background-color 300ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($color-black, 0.05);
|
||||
}
|
||||
|
||||
&_empty {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&_active {
|
||||
background-color: $color-primary;
|
||||
color: $color-white;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&_today {
|
||||
border: 1px solid $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-years {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $padding-small;
|
||||
overflow: auto;
|
||||
|
||||
&__year {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: $border-radius-medium;
|
||||
cursor: pointer;
|
||||
transition: color 200ms ease, background-color 300ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($color-black, 0.05);
|
||||
}
|
||||
|
||||
&_selected {
|
||||
background-color: $color-primary;
|
||||
color: $color-white;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-time-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&-clock {
|
||||
$clock-size: 230px;
|
||||
$clock-offset: 42px;
|
||||
|
||||
position: relative;
|
||||
height: $clock-size;
|
||||
width: $clock-size;
|
||||
border-radius: 50%;
|
||||
border: $border-divider;
|
||||
box-shadow: $box-shadow;
|
||||
box-sizing: content-box;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: $color-primary;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(($clock-size/2) - 1px);
|
||||
width: 2px;
|
||||
margin-top: $padding-small;
|
||||
height: calc(($clock-size/2) - $padding-small);
|
||||
background-color: $color-primary;
|
||||
transform-origin: bottom;
|
||||
transition: transform 200ms ease-in-out;
|
||||
opacity: 0.8;
|
||||
z-index: 0;
|
||||
|
||||
&_offset {
|
||||
margin-top: $clock-offset;
|
||||
height: calc(($clock-size/2) - $clock-offset);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-color: $color-primary;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__time {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding-top: $padding-small;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 30px;
|
||||
left: calc(($clock-size/2) - 15px);
|
||||
height: calc($clock-size/2);
|
||||
transform-origin: bottom;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
|
||||
&_hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&_offset {
|
||||
padding: 0;
|
||||
margin-top: $clock-offset;
|
||||
height: calc(($clock-size/2) - $clock-offset);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:hover span {
|
||||
background-color: rgba($color-black, 0.1);
|
||||
}
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
border-radius: 50%;
|
||||
transform-origin: center;
|
||||
transition: background-color 300ms ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-fields {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: $padding-global;
|
||||
|
||||
span {
|
||||
margin: 0 $padding-small;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 64px;
|
||||
height: 32px;
|
||||
border: 1px solid $color-alto;
|
||||
border-radius: $border-radius-small;
|
||||
font-size: $font-size-medium;
|
||||
padding: 2px $padding-small;
|
||||
text-align: center;
|
||||
|
||||
&:focus {
|
||||
border-color: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,69 +1,78 @@
|
|||
import React, {FC} from "react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {useState} from "preact/compat";
|
||||
import StaticDatePicker from "@mui/lab/StaticDatePicker";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import dayjs from "dayjs";
|
||||
import Popper from "@mui/material/Popper";
|
||||
import ClickAwayListener from "@mui/material/ClickAwayListener";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import EventIcon from "@mui/icons-material/Event";
|
||||
import {getAppModeEnable} from "../../../utils/app-mode";
|
||||
|
||||
const formatDate = "YYYY-MM-DD";
|
||||
import React, { Ref, useEffect, useMemo, useState, forwardRef } from "preact/compat";
|
||||
import Calendar from "../../Main/DatePicker/Calendar/Calendar";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import Popper from "../../Main/Popper/Popper";
|
||||
import { DATE_TIME_FORMAT } from "../../../constants/date";
|
||||
|
||||
interface DatePickerProps {
|
||||
date: string | null,
|
||||
onChange: (val: string | null) => void
|
||||
date: string | Date | Dayjs,
|
||||
targetRef: Ref<HTMLElement>
|
||||
format?: string
|
||||
timepicker?: boolean
|
||||
onChange: (val: string) => void
|
||||
}
|
||||
|
||||
const DatePicker: FC<DatePickerProps> = ({date, onChange}) => {
|
||||
const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(({
|
||||
date,
|
||||
targetRef,
|
||||
format = DATE_TIME_FORMAT,
|
||||
timepicker,
|
||||
onChange,
|
||||
}, ref) => {
|
||||
const [openCalendar, setOpenCalendar] = useState(false);
|
||||
const dateDayjs = useMemo(() => date ? dayjs(date) : dayjs(), [date]);
|
||||
|
||||
const appModeEnable = getAppModeEnable();
|
||||
const dateFormatted = date ? dayjs(date).format(formatDate) : null;
|
||||
const toggleOpenCalendar = () => {
|
||||
setOpenCalendar(prev => !prev);
|
||||
};
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleCloseCalendar = () => {
|
||||
setOpenCalendar(false);
|
||||
};
|
||||
|
||||
return <>
|
||||
<Tooltip title="Date control">
|
||||
<Button variant="contained" color="primary"
|
||||
sx={{
|
||||
color: "white",
|
||||
border: appModeEnable ? "none" : "1px solid rgba(0, 0, 0, 0.2)",
|
||||
boxShadow: "none"
|
||||
}}
|
||||
startIcon={<EventIcon/>}
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}>
|
||||
{dateFormatted}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
const handleChangeDate = (val: string) => {
|
||||
if (!timepicker) handleCloseCalendar();
|
||||
onChange(val);
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" || e.key === "Enter") handleCloseCalendar();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
targetRef.current?.addEventListener("click", toggleOpenCalendar);
|
||||
|
||||
return () => {
|
||||
targetRef.current?.removeEventListener("click", toggleOpenCalendar);
|
||||
};
|
||||
}, [targetRef]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (<>
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
placement="bottom-end"
|
||||
modifiers={[{name: "offset", options: {offset: [0, 6]}}]}>
|
||||
<ClickAwayListener onClickAway={() => setAnchorEl(null)}>
|
||||
<Paper elevation={3}>
|
||||
<Box>
|
||||
<StaticDatePicker
|
||||
displayStaticWrapperAs="desktop"
|
||||
inputFormat={formatDate}
|
||||
mask="____-__-__"
|
||||
value={date}
|
||||
onChange={(newDate) => {
|
||||
onChange(newDate ? dayjs(newDate).format(formatDate) : null);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params}/>}
|
||||
open={openCalendar}
|
||||
buttonRef={targetRef}
|
||||
placement="bottom-right"
|
||||
onClose={handleCloseCalendar}
|
||||
>
|
||||
<div ref={ref}>
|
||||
<Calendar
|
||||
date={dateDayjs}
|
||||
format={format}
|
||||
timepicker={timepicker}
|
||||
onChange={handleChangeDate}
|
||||
onClose={handleCloseCalendar}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</div>
|
||||
</Popper>
|
||||
</>;
|
||||
};
|
||||
</>);
|
||||
});
|
||||
|
||||
export default DatePicker;
|
||||
|
|
|
@ -0,0 +1,195 @@
|
|||
import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
|
||||
import { Dayjs } from "dayjs";
|
||||
import { FormEvent, FocusEvent } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface CalendarTimepickerProps {
|
||||
selectDate: Dayjs
|
||||
onChangeTime: (time: string) => void,
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
enum TimeUnits { hour, minutes, seconds }
|
||||
|
||||
|
||||
const TimePicker: FC<CalendarTimepickerProps>= ({ selectDate, onChangeTime, onClose }) => {
|
||||
|
||||
const [activeField, setActiveField] = useState<TimeUnits>(TimeUnits.hour);
|
||||
const [hours, setHours] = useState(selectDate.format("HH"));
|
||||
const [minutes, setMinutes] = useState(selectDate.format("mm"));
|
||||
const [seconds, setSeconds] = useState(selectDate.format("ss"));
|
||||
|
||||
const times = useMemo(() => {
|
||||
switch (activeField) {
|
||||
case TimeUnits.hour:
|
||||
return new Array(24).fill("00").map((h, i) => ({
|
||||
value: i,
|
||||
degrees: (i / 12) * 360,
|
||||
offset: i === 0 || i > 12,
|
||||
title: i ? `${i}` : h
|
||||
}));
|
||||
default:
|
||||
return new Array(60).fill("00").map((h, i) => ({
|
||||
value: i,
|
||||
degrees: (i / 60) * 360,
|
||||
offset: false,
|
||||
title: i ? `${i}` : h
|
||||
}));
|
||||
}
|
||||
}, [activeField, hours, minutes, seconds]);
|
||||
|
||||
const arrowDegrees = useMemo(() => {
|
||||
switch (activeField) {
|
||||
case TimeUnits.hour:
|
||||
return (+hours / 12) * 360;
|
||||
case TimeUnits.minutes:
|
||||
return (+minutes / 60) * 360;
|
||||
case TimeUnits.seconds:
|
||||
return (+seconds / 60) * 360;
|
||||
}
|
||||
}, [activeField, hours, minutes, seconds]);
|
||||
|
||||
const hoursRef = useRef<HTMLInputElement>(null);
|
||||
const minutesRef = useRef<HTMLInputElement>(null);
|
||||
const secondsRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleChangeHours = (e: FormEvent<HTMLInputElement>) => {
|
||||
const el = e.target as HTMLInputElement;
|
||||
const value = el.value;
|
||||
const validValue = +value > 23 ? "23" : value;
|
||||
el.value = validValue;
|
||||
setHours(validValue);
|
||||
if (value.length > 1 && minutesRef.current) {
|
||||
minutesRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeMinutes = (e: FormEvent<HTMLInputElement>) => {
|
||||
const el = e.target as HTMLInputElement;
|
||||
const value = el.value;
|
||||
const validValue = +value > 59 ? "59" : value;
|
||||
el.value = validValue;
|
||||
setMinutes(validValue);
|
||||
if (value.length > 1 && secondsRef.current) {
|
||||
secondsRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeSeconds = (e: FormEvent<HTMLInputElement>) => {
|
||||
const el = e.target as HTMLInputElement;
|
||||
const value = el.value;
|
||||
const validValue = +value > 59 ? "59" : value;
|
||||
el.value = validValue;
|
||||
setSeconds(validValue);
|
||||
if (value.length > 1 && secondsRef.current) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusInput = (unit: TimeUnits, e: FocusEvent<HTMLInputElement>) => {
|
||||
e.target.select();
|
||||
setActiveField(unit);
|
||||
};
|
||||
|
||||
const createHandlerFocusInput = (unit: TimeUnits) => (e: FocusEvent<HTMLInputElement>) => {
|
||||
handleFocusInput(unit, e);
|
||||
};
|
||||
|
||||
const createHandlerClick = (value: number) => () => {
|
||||
const valString = String(value);
|
||||
switch (activeField) {
|
||||
case TimeUnits.hour:
|
||||
setHours(valString);
|
||||
minutesRef.current && minutesRef.current.focus();
|
||||
break;
|
||||
case TimeUnits.minutes:
|
||||
setMinutes(valString);
|
||||
secondsRef.current && secondsRef.current.focus();
|
||||
break;
|
||||
case TimeUnits.seconds:
|
||||
setSeconds(valString);
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onChangeTime(`${hours}:${minutes}:${seconds}`);
|
||||
}, [hours, minutes, seconds]);
|
||||
|
||||
useEffect(() => {
|
||||
setHours(selectDate.format("HH"));
|
||||
setMinutes(selectDate.format("mm"));
|
||||
setSeconds(selectDate.format("ss"));
|
||||
}, [selectDate]);
|
||||
|
||||
useEffect(() => {
|
||||
hoursRef.current && hoursRef.current.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="vm-calendar-time-picker">
|
||||
<div className="vm-calendar-time-picker-clock">
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-calendar-time-picker-clock__arrow": true,
|
||||
"vm-calendar-time-picker-clock__arrow_offset": activeField === TimeUnits.hour && (hours === "00" || +hours > 12)
|
||||
})}
|
||||
style={{ transform: `rotate(${arrowDegrees}deg)` }}
|
||||
/>
|
||||
{times.map(t => (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-calendar-time-picker-clock__time": true,
|
||||
"vm-calendar-time-picker-clock__time_offset": t.offset,
|
||||
"vm-calendar-time-picker-clock__time_hide": times.length > 24 && t.value%5
|
||||
})}
|
||||
key={t.value}
|
||||
style={{ transform: `rotate(${t.degrees}deg)` }}
|
||||
onClick={createHandlerClick(t.value)}
|
||||
>
|
||||
<span style={{ transform: `rotate(-${t.degrees}deg)` }}>
|
||||
{t.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="vm-calendar-time-picker-fields">
|
||||
<input
|
||||
className="vm-calendar-time-picker-fields__input"
|
||||
value={hours}
|
||||
onChange={handleChangeHours}
|
||||
onFocus={createHandlerFocusInput(TimeUnits.hour)}
|
||||
ref={hoursRef}
|
||||
type="number"
|
||||
min={0}
|
||||
max={24}
|
||||
/>
|
||||
<span>:</span>
|
||||
<input
|
||||
className="vm-calendar-time-picker-fields__input"
|
||||
value={minutes}
|
||||
onChange={handleChangeMinutes}
|
||||
onFocus={createHandlerFocusInput(TimeUnits.minutes)}
|
||||
ref={minutesRef}
|
||||
type="number"
|
||||
min={0}
|
||||
max={60}
|
||||
/>
|
||||
<span>:</span>
|
||||
<input
|
||||
className="vm-calendar-time-picker-fields__input"
|
||||
value={seconds}
|
||||
onChange={handleChangeSeconds}
|
||||
onFocus={createHandlerFocusInput(TimeUnits.seconds)}
|
||||
ref={secondsRef}
|
||||
type="number"
|
||||
min={0}
|
||||
max={60}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimePicker;
|
269
app/vmui/packages/vmui/src/components/Main/Icons/index.tsx
Normal file
269
app/vmui/packages/vmui/src/components/Main/Icons/index.tsx
Normal file
|
@ -0,0 +1,269 @@
|
|||
import React from "react";
|
||||
|
||||
export const LogoIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 74 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M6.11771 9.47563C6.4774 9.7554 6.91935 9.90875 7.37507 9.9119H7.42685C7.9076 9.90451 8.38836 9.71964 8.67681 9.46823C10.1856 8.18898 14.5568 4.18115 14.5568 4.18115C15.7254 3.09415 12.4637 2.00716 7.42685 1.99977H7.36768C2.33084 2.00716 -0.930893 3.09415 0.237711 4.18115C0.237711 4.18115 4.60888 8.18898 6.11771 9.47563ZM8.67681 11.6422C8.31807 11.9246 7.87603 12.0806 7.41945 12.0859H7.37507C6.91849 12.0806 6.47645 11.9246 6.11771 11.6422C5.08224 10.7549 1.38413 7.41995 0.00103198 6.14809V8.07806C0.00103198 8.2925 0.0823905 8.57349 0.222919 8.70659L0.293358 8.77097L0.293386 8.77099C1.33788 9.72556 4.83907 12.9253 6.11771 14.0159C6.47645 14.2983 6.91849 14.4543 7.37507 14.4595H7.41945C7.9076 14.4447 8.38096 14.2599 8.67681 14.0159C9.98594 12.9067 13.6249 9.57175 14.5642 8.70659C14.7121 8.57349 14.7861 8.2925 14.7861 8.07806V6.14809C12.7662 7.99781 10.7297 9.82926 8.67681 11.6422ZM7.41945 16.6261C7.87517 16.623 8.31712 16.4696 8.67681 16.1898C10.7298 14.3744 12.7663 12.5405 14.7861 10.6883V12.6257C14.7861 12.8327 14.7121 13.1137 14.5642 13.2468C13.6249 14.1194 9.98594 17.4469 8.67681 18.5561C8.38096 18.8075 7.9076 18.9924 7.41945 18.9998H7.37507C6.91935 18.9966 6.4774 18.8433 6.11771 18.5635C4.91431 17.5371 1.74223 14.6362 0.502336 13.5023C0.3934 13.4027 0.299379 13.3167 0.222919 13.2468C0.0823905 13.1137 0.00103198 12.8327 0.00103198 12.6257V10.6883C1.38413 11.9528 5.08224 15.2951 6.11771 16.1825C6.47645 16.4649 6.91849 16.6209 7.37507 16.6261H7.41945Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M35 3.54L29.16 18H26.73L20.89 3.54H23.05C23.2833 3.54 23.4733 3.59667 23.62 3.71C23.7667 3.82333 23.8767 3.97 23.95 4.15L27.36 12.97C27.4733 13.2567 27.58 13.5733 27.68 13.92C27.7867 14.26 27.8867 14.6167 27.98 14.99C28.06 14.6167 28.1467 14.26 28.24 13.92C28.3333 13.5733 28.4367 13.2567 28.55 12.97L31.94 4.15C31.9933 3.99667 32.0967 3.85667 32.25 3.73C32.41 3.60333 32.6033 3.54 32.83 3.54H35ZM52.1767 3.54V18H49.8067V8.66C49.8067 8.28667 49.8267 7.88333 49.8667 7.45L45.4967 15.66C45.2901 16.0533 44.9734 16.25 44.5467 16.25H44.1667C43.7401 16.25 43.4234 16.0533 43.2167 15.66L38.7967 7.42C38.8167 7.64 38.8334 7.85667 38.8467 8.07C38.8601 8.28333 38.8667 8.48 38.8667 8.66V18H36.4967V3.54H38.5267C38.6467 3.54 38.7501 3.54333 38.8367 3.55C38.9234 3.55667 39.0001 3.57333 39.0667 3.6C39.1401 3.62667 39.2034 3.67 39.2567 3.73C39.3167 3.79 39.3734 3.87 39.4267 3.97L43.7567 12C43.8701 12.2133 43.9734 12.4333 44.0667 12.66C44.1667 12.8867 44.2634 13.12 44.3567 13.36C44.4501 13.1133 44.5467 12.8767 44.6467 12.65C44.7467 12.4167 44.8534 12.1933 44.9667 11.98L49.2367 3.97C49.2901 3.87 49.3467 3.79 49.4067 3.73C49.4667 3.67 49.5301 3.62667 49.5967 3.6C49.6701 3.57333 49.7501 3.55667 49.8367 3.55C49.9234 3.54333 50.0267 3.54 50.1467 3.54H52.1767ZM61.063 17.27C61.743 17.27 62.3496 17.1533 62.883 16.92C63.423 16.68 63.8796 16.35 64.253 15.93C64.6263 15.51 64.9096 15.0167 65.103 14.45C65.303 13.8767 65.403 13.26 65.403 12.6V3.85H66.423V12.6C66.423 13.38 66.2996 14.11 66.053 14.79C65.8063 15.4633 65.4496 16.0533 64.983 16.56C64.523 17.06 63.9596 17.4533 63.293 17.74C62.633 18.0267 61.8896 18.17 61.063 18.17C60.2363 18.17 59.4896 18.0267 58.823 17.74C58.163 17.4533 57.5996 17.06 57.133 16.56C56.673 16.0533 56.3196 15.4633 56.073 14.79C55.8263 14.11 55.703 13.38 55.703 12.6V3.85H56.733V12.59C56.733 13.25 56.8296 13.8667 57.023 14.44C57.223 15.0067 57.5063 15.5 57.873 15.92C58.2463 16.34 58.6996 16.67 59.233 16.91C59.773 17.15 60.383 17.27 61.063 17.27ZM71.4442 18H70.4142V3.85H71.4442V18Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SettingsIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CloseIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const RestartIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 5V2L8 6l4 4V7c3.31 0 6 2.69 6 6 0 2.97-2.17 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93 0-4.42-3.58-8-8-8zm-6 8c0-1.65.67-3.15 1.76-4.24L6.34 7.34C4.9 8.79 4 10.79 4 13c0 4.08 3.05 7.44 7 7.93v-2.02c-2.83-.48-5-2.94-5-5.91z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const InfoIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const WarningIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ErrorIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SuccessIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const RefreshIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ArrowDownIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ArrowUpIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="m12 8-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ArrowDropDownIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="m7 10 5 5 5-5z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlusCircleFillIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ClockIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"
|
||||
></path>
|
||||
<path d="M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CalendarIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const AlarmIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="m22 5.72-4.6-3.86-1.29 1.53 4.6 3.86L22 5.72zM7.88 3.39 6.6 1.86 2 5.71l1.29 1.53 4.59-3.85zM12.5 8H11v6l4.75 2.85.75-1.23-4-2.37V8zM12 4c-4.97 0-9 4.03-9 9s4.02 9 9 9c4.97 0 9-4.03 9-9s-4.03-9-9-9zm0 16c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const KeyboardIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm9 7H8v-2h8v2zm0-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const RemoveCircleIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11H7v-2h10v2z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlayIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 5v14l11-7z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlayCircleOutlineIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="m10 16.5 6-4.5-6-4.5v9zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChartIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="m3.5 18.49 6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TableIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M10 10.02h5V21h-5zM17 21h3c1.1 0 2-.9 2-2v-9h-5v11zm3-18H5c-1.1 0-2 .9-2 2v3h19V5c0-1.1-.9-2-2-2zM3 19c0 1.1.9 2 2 2h3V10H3v9z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CodeIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M9.4 16.6 4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0 4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DeleteIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlusIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DoneIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M8.9999 14.7854L18.8928 4.8925C19.0803 4.70497 19.3347 4.59961 19.5999 4.59961C19.8651 4.59961 20.1195 4.70497 20.307 4.8925L21.707 6.2925C22.0975 6.68303 22.0975 7.31619 21.707 7.70672L9.70701 19.7067C9.31648 20.0972 8.68332 20.0972 8.2928 19.7067L2.6928 14.1067C2.50526 13.9192 2.3999 13.6648 2.3999 13.3996C2.3999 13.1344 2.50526 12.88 2.6928 12.6925L4.0928 11.2925C4.48332 10.902 5.11648 10.902 5.50701 11.2925L8.9999 14.7854Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
|
@ -0,0 +1,16 @@
|
|||
import React from "preact/compat";
|
||||
import "./style.scss";
|
||||
|
||||
const LineProgress = ({ value }: {value: number}) => (
|
||||
<div className="vm-line-progress">
|
||||
<div className="vm-line-progress-track">
|
||||
<div
|
||||
className="vm-line-progress-track__thumb"
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{value.toFixed(2)}%</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default LineProgress;
|
|
@ -0,0 +1,23 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-line-progress {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $padding-small;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&-track {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background-color: rgba($color-black, 0.05);
|
||||
border-radius: $border-radius-small;
|
||||
|
||||
&__thumb {
|
||||
height: 100%;
|
||||
background-color: $color-dodger-blue;
|
||||
border-radius: $border-radius-small;
|
||||
}
|
||||
}
|
||||
}
|
65
app/vmui/packages/vmui/src/components/Main/Modal/Modal.tsx
Normal file
65
app/vmui/packages/vmui/src/components/Main/Modal/Modal.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import React, { FC, useEffect } from "preact/compat";
|
||||
import ReactDOM from "react-dom";
|
||||
import { CloseIcon } from "../Icons";
|
||||
import Button from "../Button/Button";
|
||||
import { ReactNode, MouseEvent } from "react";
|
||||
import "./style.scss";
|
||||
|
||||
interface ModalProps {
|
||||
title?: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const Modal: FC<ModalProps> = ({ title, children, onClose }) => {
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ReactDOM.createPortal((
|
||||
<div
|
||||
className="vm-modal"
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
<div className="vm-modal-content">
|
||||
<div className="vm-modal-content-header">
|
||||
{title && (
|
||||
<div className="vm-modal-content-header__title">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className="vm-modal-header__close">
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
>
|
||||
<CloseIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="vm-modal-content-body"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body);
|
||||
};
|
||||
|
||||
export default Modal;
|
54
app/vmui/packages/vmui/src/components/Main/Modal/style.scss
Normal file
54
app/vmui/packages/vmui/src/components/Main/Modal/style.scss
Normal file
|
@ -0,0 +1,54 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
$padding-modal: 22px;
|
||||
|
||||
.vm-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba($color-black, 0.55);
|
||||
|
||||
&-content {
|
||||
padding: $padding-modal;
|
||||
max-height: 85vh;
|
||||
background: $color-white;
|
||||
box-shadow: 0 0 24px rgba($color-black, 0.07);
|
||||
border-radius: $border-radius-small;
|
||||
overflow: hidden;
|
||||
|
||||
&-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
margin-bottom: $padding-modal ;
|
||||
|
||||
&__title {
|
||||
font-weight: bold;
|
||||
font-size: $font-size-medium;
|
||||
}
|
||||
|
||||
&__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
padding: 10px;
|
||||
box-sizing: content-box;
|
||||
color: $color-white;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
124
app/vmui/packages/vmui/src/components/Main/Popper/Popper.tsx
Normal file
124
app/vmui/packages/vmui/src/components/Main/Popper/Popper.tsx
Normal file
|
@ -0,0 +1,124 @@
|
|||
import React, { FC, ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import ReactDOM from "react-dom";
|
||||
import "./style.scss";
|
||||
import useClickOutside from "../../../hooks/useClickOutside";
|
||||
|
||||
interface PopperProps {
|
||||
children: ReactNode
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
buttonRef: React.RefObject<HTMLElement>
|
||||
placement?: "bottom-right" | "bottom-left" | "top-left" | "top-right"
|
||||
animation?: string
|
||||
offset?: {top: number, left: number}
|
||||
clickOutside?: boolean
|
||||
}
|
||||
|
||||
const Popper: FC<PopperProps> = ({
|
||||
children,
|
||||
buttonRef,
|
||||
placement = "bottom-left",
|
||||
open = false,
|
||||
onClose,
|
||||
animation,
|
||||
offset = { top: 6, left: 0 },
|
||||
clickOutside = true
|
||||
}) => {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [popperSize, setPopperSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
const popperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onScrollWindow = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("scroll", onScrollWindow);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScrollWindow);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(open);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen && onClose) onClose();
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
setPopperSize({
|
||||
width: popperRef?.current?.clientWidth || 0,
|
||||
height: popperRef?.current?.clientHeight || 0
|
||||
});
|
||||
setIsOpen(false);
|
||||
}, [popperRef]);
|
||||
|
||||
const popperStyle = useMemo(() => {
|
||||
const buttonEl = buttonRef.current;
|
||||
|
||||
if (!buttonEl|| !isOpen) return {};
|
||||
|
||||
const buttonPos = buttonEl.getBoundingClientRect();
|
||||
|
||||
const position = {
|
||||
top: 0,
|
||||
left: 0
|
||||
};
|
||||
|
||||
const needAlignRight = placement === "bottom-right" || placement === "top-right";
|
||||
const needAlignTop = placement?.includes("top");
|
||||
|
||||
const offsetTop = offset?.top || 0;
|
||||
const offsetLeft = offset?.left || 0;
|
||||
|
||||
position.left = position.left = buttonPos.left + offsetLeft;
|
||||
position.top = buttonPos.height + buttonPos.top + offsetTop;
|
||||
|
||||
if (needAlignRight) position.left = buttonPos.right - popperSize.width;
|
||||
if (needAlignTop) position.top = buttonPos.top - popperSize.height - offsetTop;
|
||||
|
||||
const { innerWidth, innerHeight } = window;
|
||||
const margin = 20;
|
||||
|
||||
const isOverflowBottom = (position.top + popperSize.height + margin) > innerHeight;
|
||||
const isOverflowTop = (position.top - margin) < 0;
|
||||
const isOverflowRight = (position.left + popperSize.width + margin) > innerWidth;
|
||||
const isOverflowLeft = (position.left - margin) < 0;
|
||||
|
||||
if (isOverflowBottom) position.top = buttonPos.top - popperSize.height - offsetTop;
|
||||
if (isOverflowTop) position.top = buttonPos.height + buttonPos.top + offsetTop;
|
||||
if (isOverflowRight) position.left = buttonPos.right - popperSize.width - offsetLeft;
|
||||
if (isOverflowLeft) position.left = buttonPos.left + offsetLeft;
|
||||
|
||||
return position;
|
||||
},[buttonRef, placement, isOpen, children]);
|
||||
|
||||
if (clickOutside) useClickOutside(popperRef, () => setIsOpen(false), buttonRef);
|
||||
|
||||
const popperClasses = classNames({
|
||||
"vm-popper": true,
|
||||
"vm-popper_open": isOpen,
|
||||
[`vm-popper_open_${animation}`]: animation,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && ReactDOM.createPortal((
|
||||
<div
|
||||
className={popperClasses}
|
||||
ref={popperRef}
|
||||
style={popperStyle}
|
||||
>
|
||||
{children}
|
||||
</div>), document.body)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Popper;
|
24
app/vmui/packages/vmui/src/components/Main/Popper/style.scss
Normal file
24
app/vmui/packages/vmui/src/components/Main/Popper/style.scss
Normal file
|
@ -0,0 +1,24 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-popper {
|
||||
position: fixed;
|
||||
background-color: $color-background-block;
|
||||
box-shadow: $box-shadow-popper;
|
||||
z-index: -99;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
border-radius: $border-radius-small;
|
||||
|
||||
&_open {
|
||||
z-index: 101;
|
||||
opacity: 1;
|
||||
animation: scale 150ms cubic-bezier(0.280, 0.840, 0.420, 1);
|
||||
pointer-events: auto;
|
||||
|
||||
&_slider {
|
||||
transform-origin: top center;
|
||||
animation: slidePopper 0.3s cubic-bezier(0.280, 0.840, 0.420, 1.1);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
import React, { FC, useState } from "preact/compat";
|
||||
import { isMacOs } from "../../../utils/detect-os";
|
||||
import { getAppModeEnable } from "../../../utils/app-mode";
|
||||
import Button from "../Button/Button";
|
||||
import { KeyboardIcon } from "../Icons";
|
||||
import Modal from "../Modal/Modal";
|
||||
import "./style.scss";
|
||||
import Tooltip from "../Tooltip/Tooltip";
|
||||
|
||||
const ctrlMeta = isMacOs() ? "Cmd" : "Ctrl";
|
||||
|
||||
const keyList = [
|
||||
{
|
||||
title: "Query",
|
||||
list: [
|
||||
{
|
||||
keys: ["Enter"],
|
||||
description: "Run"
|
||||
},
|
||||
{
|
||||
keys: ["Shift", "Enter"],
|
||||
description: "Multi-line queries"
|
||||
},
|
||||
{
|
||||
keys: [ctrlMeta, "Arrow Up"],
|
||||
description: "Previous command from the Query history"
|
||||
},
|
||||
{
|
||||
keys: [ctrlMeta, "Arrow Down"],
|
||||
description: "Next command from the Query history"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Graph",
|
||||
list: [
|
||||
{
|
||||
keys: [ctrlMeta, "Scroll Up"],
|
||||
description: "Zoom in"
|
||||
},
|
||||
{
|
||||
keys: [ctrlMeta, "Scroll Down"],
|
||||
description: "Zoom out"
|
||||
},
|
||||
{
|
||||
keys: [ctrlMeta, "Click and Drag"],
|
||||
description: "Move the graph left/right"
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Legend",
|
||||
list: [
|
||||
{
|
||||
keys: ["Mouse Click"],
|
||||
description: "Select series"
|
||||
},
|
||||
{
|
||||
keys: [ctrlMeta, "Mouse Click"],
|
||||
description: "Toggle multiple series"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const ShortcutKeys: FC = () => {
|
||||
const [openList, setOpenList] = useState(false);
|
||||
const appModeEnable = getAppModeEnable();
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpenList(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpenList(false);
|
||||
};
|
||||
|
||||
return <>
|
||||
<Tooltip
|
||||
title="Shortcut keys"
|
||||
placement="bottom-center"
|
||||
>
|
||||
<Button
|
||||
className={appModeEnable ? "" : "vm-header-button"}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<KeyboardIcon/>}
|
||||
onClick={handleOpen}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{openList && (
|
||||
<Modal
|
||||
title={"Shortcut keys"}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="vm-shortcuts">
|
||||
{keyList.map(section => (
|
||||
<div
|
||||
className="vm-shortcuts-section"
|
||||
key={section.title}
|
||||
>
|
||||
<h3 className="vm-shortcuts-section__title">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div className="vm-shortcuts-section-list">
|
||||
{section.list.map(l => (
|
||||
<div
|
||||
className="vm-shortcuts-section-list-item"
|
||||
key={l.keys.join("+")}
|
||||
>
|
||||
<div className="vm-shortcuts-section-list-item__key">
|
||||
{l.keys.map((k, i) => (
|
||||
<>
|
||||
<code key={k}>
|
||||
{k}
|
||||
</code>
|
||||
{i !== l.keys.length - 1 ? "+" : ""}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<p className="vm-shortcuts-section-list-item__description">
|
||||
{l.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>;
|
||||
};
|
||||
|
||||
export default ShortcutKeys;
|
|
@ -0,0 +1,51 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-shortcuts {
|
||||
min-width: 400px;
|
||||
|
||||
&-section {
|
||||
margin-bottom: $padding-medium;
|
||||
|
||||
&__title {
|
||||
font-weight: bold;
|
||||
padding: $padding-small 0;
|
||||
margin-bottom: $padding-global;
|
||||
border-bottom: $border-divider;
|
||||
}
|
||||
|
||||
&-list {
|
||||
display: grid;
|
||||
gap: $padding-global;
|
||||
|
||||
&-item {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
align-items: center;
|
||||
gap: $padding-small;
|
||||
|
||||
&__key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
padding: 2px $padding-small 0;
|
||||
font-size: $font-size-small;
|
||||
line-height: 2;
|
||||
color: $color-text;
|
||||
text-align: center;
|
||||
background-color: $color-white;
|
||||
background-repeat: repeat-x;
|
||||
border: $border-divider;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: $font-size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import React, { CSSProperties, FC } from "preact/compat";
|
||||
import "./style.scss";
|
||||
|
||||
interface SpinnerProps {
|
||||
containerStyles?: CSSProperties;
|
||||
message?: string
|
||||
}
|
||||
|
||||
const Spinner: FC<SpinnerProps> = ({ containerStyles = {}, message }) => (
|
||||
<div
|
||||
className="vm-spinner"
|
||||
style={containerStyles && {}}
|
||||
>
|
||||
<div className="half-circle-spinner">
|
||||
<div className="circle circle-1"></div>
|
||||
<div className="circle circle-2"></div>
|
||||
</div>
|
||||
{message && <div className="vm-spinner__message">{message}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Spinner;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue