vmui: logs explorer (#4484)

* feat: add a logs page

* app/vixtoria-logs: add handlers for vmui

* feat: add group logs

* feat: add logs build

* app/vixtoria-logs: update make file

* app/vixtoria-logs: cleanup make

* app/vixtoria-logs: fix description

* fix: correct url for logs

* fix: save display view in query params

* fix: change logo for logs build

* app/vixtoria-logs: remove dashboards from vlselect

* app/vixtoria-logs: enable user

---------

Co-authored-by: dmitryk-dk <kozlovdmitriyy@gmail.com>
This commit is contained in:
Yury Molodov 2023-06-21 16:57:09 +02:00 committed by GitHub
parent 039dff9f50
commit 4a7b17ed76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 1350 additions and 174 deletions

View file

@ -78,6 +78,7 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
fmt.Fprintf(w, "See docs at <a href='https://docs.victoriametrics.com/VictoriaLogs/'>https://docs.victoriametrics.com/VictoriaLogs/</a></br>")
fmt.Fprintf(w, "Useful endpoints:</br>")
httpserver.WriteAPIHelp(w, [][2]string{
{"vmui", "Web UI for VictoriaLogs"},
{"metrics", "available service metrics"},
{"flags", "command-line flags"},
})

View file

@ -1,6 +1,7 @@
package vlselect
import (
"embed"
"flag"
"fmt"
"net/http"
@ -62,12 +63,14 @@ var (
})
)
//go:embed vmui
var vmuiFiles embed.FS
var vmuiFileServer = http.FileServer(http.FS(vmuiFiles))
// RequestHandler handles select requests for VictoriaLogs
func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
path := r.URL.Path
if !strings.HasPrefix(path, "/select/") {
return false
}
path = strings.TrimPrefix(path, "/select")
path = strings.ReplaceAll(path, "//", "/")
@ -112,11 +115,24 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
}
switch {
case path == "/vmui":
// VMUI access via incomplete url without `/` in the end. Redirect to complete url.
// Use relative redirect, since, since the hostname and path prefix may be incorrect if VictoriaMetrics
// is hidden behind vmauth or similar proxy.
_ = r.ParseForm()
path = strings.TrimPrefix(path, "/")
newURL := path + "/?" + r.Form.Encode()
httpserver.Redirect(w, newURL)
return true
case path == "/logsql/query":
logsqlQueryRequests.Inc()
httpserver.EnableCORS(w, r)
logsql.ProcessQueryRequest(w, r, stopCh)
return true
case strings.HasPrefix(path, "/vmui/"):
r.URL.Path = path
vmuiFileServer.ServeHTTP(w, r)
return true
default:
return false
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,14 @@
{
"files": {
"main.css": "./static/css/main.f5cb3747.css",
"main.js": "./static/js/main.75100e6d.js",
"static/js/27.c1ccfd29.chunk.js": "./static/js/27.c1ccfd29.chunk.js",
"static/media/Lato-Regular.ttf": "./static/media/Lato-Regular.d714fec1633b69a9c2e9.ttf",
"static/media/Lato-Bold.ttf": "./static/media/Lato-Bold.32360ba4b57802daa4d6.ttf",
"index.html": "./index.html"
},
"entrypoints": [
"static/css/main.f5cb3747.css",
"static/js/main.75100e6d.js"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/><meta name="theme-color" content="#000000"/><meta name="description" content="UI for VictoriaMetrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><script src="./dashboards/index.js" type="module"></script><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="./preview.jpg"><meta name="twitter:title" content="UI for VictoriaMetrics"><meta name="twitter:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta name="twitter:site" content="@VictoriaMetrics"><meta property="og:title" content="Metric explorer for VictoriaMetrics"><meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta property="og:image" content="./preview.jpg"><meta property="og:type" content="website"><script defer="defer" src="./static/js/main.75100e6d.js"></script><link href="./static/css/main.f5cb3747.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

View file

@ -0,0 +1,20 @@
{
"short_name": "Victoria Metrics UI",
"name": "Victoria Metrics UI is a metric explorer for Victoria Metrics",
"icons": [
{
"src": "favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "apple-touch-icon.png",
"type": "image/png",
"sizes": "192x192"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View file

@ -0,0 +1 @@
"use strict";(self.webpackChunkvmui=self.webpackChunkvmui||[]).push([[27],{27:function(e,t,n){n.r(t),n.d(t,{getCLS:function(){return y},getFCP:function(){return g},getFID:function(){return C},getLCP:function(){return P},getTTFB:function(){return D}});var i,r,a,o,u=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:"v2-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){if("first-input"===e&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver((function(e){return e.getEntries().map(t)}));return n.observe({type:e,buffered:!0}),n}}catch(e){}},f=function(e,t){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(e(i),t&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},s=function(e){addEventListener("pageshow",(function(t){t.persisted&&e(t)}),!0)},m=function(e,t,n){var i;return function(r){t.value>=0&&(r||n)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},v=-1,p=function(){return"hidden"===document.visibilityState?0:1/0},d=function(){f((function(e){var t=e.timeStamp;v=t}),!0)},l=function(){return v<0&&(v=p(),d(),s((function(){setTimeout((function(){v=p(),d()}),0)}))),{get firstHiddenTime(){return v}}},g=function(e,t){var n,i=l(),r=u("FCP"),a=function(e){"first-contentful-paint"===e.name&&(f&&f.disconnect(),e.startTime<i.firstHiddenTime&&(r.value=e.startTime,r.entries.push(e),n(!0)))},o=window.performance&&performance.getEntriesByName&&performance.getEntriesByName("first-contentful-paint")[0],f=o?null:c("paint",a);(o||f)&&(n=m(e,r,t),o&&a(o),s((function(i){r=u("FCP"),n=m(e,r,t),requestAnimationFrame((function(){requestAnimationFrame((function(){r.value=performance.now()-i.timeStamp,n(!0)}))}))})))},h=!1,T=-1,y=function(e,t){h||(g((function(e){T=e.value})),h=!0);var n,i=function(t){T>-1&&e(t)},r=u("CLS",0),a=0,o=[],v=function(e){if(!e.hadRecentInput){var t=o[0],i=o[o.length-1];a&&e.startTime-i.startTime<1e3&&e.startTime-t.startTime<5e3?(a+=e.value,o.push(e)):(a=e.value,o=[e]),a>r.value&&(r.value=a,r.entries=o,n())}},p=c("layout-shift",v);p&&(n=m(i,r,t),f((function(){p.takeRecords().map(v),n(!0)})),s((function(){a=0,T=-1,r=u("CLS",0),n=m(i,r,t)})))},E={passive:!0,capture:!0},w=new Date,L=function(e,t){i||(i=t,r=e,a=new Date,F(removeEventListener),S())},S=function(){if(r>=0&&r<a-w){var e={entryType:"first-input",name:i.type,target:i.target,cancelable:i.cancelable,startTime:i.timeStamp,processingStart:i.timeStamp+r};o.forEach((function(t){t(e)})),o=[]}},b=function(e){if(e.cancelable){var t=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){L(e,t),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,E),removeEventListener("pointercancel",i,E)};addEventListener("pointerup",n,E),addEventListener("pointercancel",i,E)}(t,e):L(t,e)}},F=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(t){return e(t,b,E)}))},C=function(e,t){var n,a=l(),v=u("FID"),p=function(e){e.startTime<a.firstHiddenTime&&(v.value=e.processingStart-e.startTime,v.entries.push(e),n(!0))},d=c("first-input",p);n=m(e,v,t),d&&f((function(){d.takeRecords().map(p),d.disconnect()}),!0),d&&s((function(){var a;v=u("FID"),n=m(e,v,t),o=[],r=-1,i=null,F(addEventListener),a=p,o.push(a),S()}))},k={},P=function(e,t){var n,i=l(),r=u("LCP"),a=function(e){var t=e.startTime;t<i.firstHiddenTime&&(r.value=t,r.entries.push(e),n())},o=c("largest-contentful-paint",a);if(o){n=m(e,r,t);var v=function(){k[r.id]||(o.takeRecords().map(a),o.disconnect(),k[r.id]=!0,n(!0))};["keydown","click"].forEach((function(e){addEventListener(e,v,{once:!0,capture:!0})})),f(v,!0),s((function(i){r=u("LCP"),n=m(e,r,t),requestAnimationFrame((function(){requestAnimationFrame((function(){r.value=performance.now()-i.timeStamp,k[r.id]=!0,n(!0)}))}))}))}},D=function(e){var t,n=u("TTFB");t=function(){try{var t=performance.getEntriesByType("navigation")[0]||function(){var e=performance.timing,t={entryType:"navigation",startTime:0};for(var n in e)"navigationStart"!==n&&"toJSON"!==n&&(t[n]=Math.max(e[n]-e.navigationStart,0));return t}();if(n.value=n.delta=t.responseStart,n.value<0||n.value>performance.now())return;n.entries=[t],e(n)}catch(e){}},"complete"===document.readyState?setTimeout(t,0):addEventListener("load",(function(){return setTimeout(t,0)}))}}}]);

View file

@ -5,12 +5,20 @@ vmui-package-base-image:
vmui-build: vmui-package-base-image
docker run --rm \
--user $(shell id -u):$(shell id -g) \
--user $(shell id -u):$(shell id -g) \
--mount type=bind,src="$(shell pwd)/app/vmui",dst=/build \
-w /build/packages/vmui \
--entrypoint=/bin/bash \
vmui-builder-image -c "npm install && npm run build"
vmui-logs-build: vmui-package-base-image
docker run --rm \
--user $(shell id -u):$(shell id -g) \
--mount type=bind,src="$(shell pwd)/app/vmui",dst=/build \
-w /build/packages/vmui \
--entrypoint=/bin/bash \
vmui-builder-image -c "npm install && npm run build:logs"
vmui-release: vmui-build
docker build -t ${DOCKER_NAMESPACE}/vmui:latest -f app/vmui/Dockerfile-web ./app/vmui/packages/vmui
docker tag ${DOCKER_NAMESPACE}/vmui:latest ${DOCKER_NAMESPACE}/vmui:${PKG_TAG}
@ -23,3 +31,6 @@ vmui-publish-release: vmui-release
vmui-update: vmui-build
rm -rf app/vmselect/vmui/* && mv app/vmui/packages/vmui/build/* app/vmselect/vmui
vmui-logs-update: vmui-logs-build
rm -rf app/vlselect/vmui/* && mv app/vmui/packages/vmui/build/* app/vlselect/vmui && rm -rf app/vlselect/vmui/dashboards

View file

@ -40,6 +40,7 @@
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7",
"@typescript-eslint/eslint-plugin": "^5.15.0",
"@typescript-eslint/parser": "^5.15.0",
"cross-env": "^7.0.3",
"customize-cra": "^1.0.0",
"eslint-plugin-react": "^7.29.4",
"react-app-rewired": "^2.2.1"
@ -6748,12 +6749,29 @@
"node": ">=10"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"peer": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@ -10786,8 +10804,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.0",
@ -14463,7 +14480,6 @@
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
@ -17574,7 +17590,6 @@
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"peer": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@ -17587,7 +17602,6 @@
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
@ -19515,7 +19529,6 @@
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"peer": true,
"dependencies": {
"isexe": "^2.0.0"
},

View file

@ -34,7 +34,9 @@
},
"scripts": {
"start": "react-app-rewired start",
"start:logs": "cross-env REACT_APP_LOGS=true npm run start",
"build": "GENERATE_SOURCEMAP=false react-app-rewired build",
"build:logs": "cross-env REACT_APP_LOGS=true npm run build",
"test": "react-app-rewired test",
"lint": "eslint src --ext tsx,ts",
"lint:fix": "eslint src --ext tsx,ts --fix"
@ -61,6 +63,7 @@
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7",
"@typescript-eslint/eslint-plugin": "^5.15.0",
"@typescript-eslint/parser": "^5.15.0",
"cross-env": "^7.0.3",
"customize-cra": "^1.0.0",
"eslint-plugin-react": "^7.29.4",
"react-app-rewired": "^2.2.1"

View file

@ -13,8 +13,10 @@ import ExploreMetrics from "./pages/ExploreMetrics";
import PreviewIcons from "./components/Main/Icons/PreviewIcons";
import WithTemplate from "./pages/WithTemplate";
import Relabel from "./pages/Relabel";
import ExploreLogs from "./pages/ExploreLogs/ExploreLogs";
const App: FC = () => {
const { REACT_APP_LOGS } = process.env;
const [loadedTheme, setLoadedTheme] = useState(false);
@ -29,41 +31,49 @@ const App: FC = () => {
path={"/"}
element={<Layout/>}
>
{!REACT_APP_LOGS && (
<>
<Route
path={router.home}
element={<CustomPanel/>}
/>
<Route
path={router.metrics}
element={<ExploreMetrics/>}
/>
<Route
path={router.cardinality}
element={<CardinalityPanel/>}
/>
<Route
path={router.topQueries}
element={<TopQueries/>}
/>
<Route
path={router.trace}
element={<TracePage/>}
/>
<Route
path={router.dashboards}
element={<DashboardsLayout/>}
/>
<Route
path={router.withTemplate}
element={<WithTemplate/>}
/>
<Route
path={router.relabel}
element={<Relabel/>}
/>
<Route
path={router.icons}
element={<PreviewIcons/>}
/>
</>
)}
<Route
path={router.home}
element={<CustomPanel/>}
/>
<Route
path={router.metrics}
element={<ExploreMetrics/>}
/>
<Route
path={router.cardinality}
element={<CardinalityPanel/>}
/>
<Route
path={router.topQueries}
element={<TopQueries/>}
/>
<Route
path={router.trace}
element={<TracePage/>}
/>
<Route
path={router.dashboards}
element={<DashboardsLayout/>}
/>
<Route
path={router.withTemplate}
element={<WithTemplate/>}
/>
<Route
path={router.relabel}
element={<Relabel/>}
/>
<Route
path={router.icons}
element={<PreviewIcons/>}
path={REACT_APP_LOGS ? "/" : router.logs}
element={<ExploreLogs/>}
/>
</Route>
</Routes>

View file

@ -0,0 +1,2 @@
export const getLogsUrl = (server: string): string =>
`${server}/select/logsql/query`;

View file

@ -25,3 +25,10 @@ export interface QueryStats {
seriesFetched?: string;
resultLength?: number;
}
export interface Logs {
_msg: string;
_stream: string;
_time: string;
[key: string]: string;
}

View file

@ -2,7 +2,7 @@ import React, { FC, useMemo } from "preact/compat";
import { useNavigate } from "react-router-dom";
import router from "../../../router";
import { getAppModeEnable, getAppModeParams } from "../../../utils/app-mode";
import { LogoFullIcon } from "../../Main/Icons";
import { LogoFullIcon, LogoLogsIcon } from "../../Main/Icons";
import { getCssVariable } from "../../../utils/theme";
import "./style.scss";
import classNames from "classnames";
@ -14,6 +14,7 @@ import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useWindowSize from "../../../hooks/useWindowSize";
const Header: FC = () => {
const { REACT_APP_LOGS } = process.env;
const { isMobile } = useDeviceDetect();
const windowSize = useWindowSize();
@ -61,11 +62,14 @@ const Header: FC = () => {
<>
{!appModeEnable && (
<div
className="vm-header-logo"
className={classNames({
"vm-header-logo": true,
"vm-header-logo_logs": REACT_APP_LOGS
})}
onClick={onClickLogo}
style={{ color }}
>
<LogoFullIcon/>
{REACT_APP_LOGS ? <LogoLogsIcon/> : <LogoFullIcon/>}
</div>
)}
<HeaderNav
@ -76,11 +80,15 @@ const Header: FC = () => {
)}
{isMobile && (
<div
className="vm-header-logo vm-header-logo_mobile"
className={classNames({
"vm-header-logo": true,
"vm-header-logo_mobile": true,
"vm-header-logo_logs": REACT_APP_LOGS
})}
onClick={onClickLogo}
style={{ color }}
>
<LogoFullIcon/>
{REACT_APP_LOGS ? <LogoLogsIcon/> : <LogoFullIcon/>}
</div>
)}
<HeaderControls

View file

@ -8,6 +8,7 @@ import "./style.scss";
import NavItem from "./NavItem";
import NavSubItem from "./NavSubItem";
import classNames from "classnames";
import { defaultNavigation, logsNavigation } from "../../../../constants/navigation";
interface HeaderNavProps {
color: string
@ -16,51 +17,15 @@ interface HeaderNavProps {
}
const HeaderNav: FC<HeaderNavProps> = ({ color, background, direction }) => {
const { REACT_APP_LOGS } = process.env;
const appModeEnable = getAppModeEnable();
const { dashboardsSettings } = useDashboardsState();
const { pathname } = useLocation();
const [activeMenu, setActiveMenu] = useState(pathname);
const menu = useMemo(() => ([
{
label: routerOptions[router.home].title,
value: router.home,
},
{
label: "Explore",
submenu: [
{
label: routerOptions[router.metrics].title,
value: router.metrics,
},
{
label: routerOptions[router.cardinality].title,
value: router.cardinality,
},
{
label: routerOptions[router.topQueries].title,
value: router.topQueries,
},
]
},
{
label: "Tools",
submenu: [
{
label: routerOptions[router.trace].title,
value: router.trace,
},
{
label: routerOptions[router.withTemplate].title,
value: router.withTemplate,
},
{
label: routerOptions[router.relabel].title,
value: router.relabel,
},
]
},
const menu = useMemo(() => REACT_APP_LOGS ? logsNavigation : ([
...defaultNavigation,
{
label: routerOptions[router.dashboards].title,
value: router.dashboards,
@ -97,7 +62,7 @@ const HeaderNav: FC<HeaderNavProps> = ({ color, background, direction }) => {
<NavItem
key={m.value}
activeMenu={activeMenu}
value={m.value}
value={m.value || ""}
label={m.label || ""}
color={color}
/>

View file

@ -6,11 +6,12 @@ import Popper from "../../../Main/Popper/Popper";
import NavItem from "./NavItem";
import { useEffect } from "react";
import useBoolean from "../../../../hooks/useBoolean";
import { NavigationItem } from "../../../../constants/navigation";
interface NavItemProps {
activeMenu: string,
label: string,
submenu: {label: string | undefined, value: string}[],
submenu: NavigationItem[],
color?: string
background?: string
direction?: "row" | "column"
@ -61,7 +62,7 @@ const NavSubItem: FC<NavItemProps> = ({
<NavItem
key={sm.value}
activeMenu={activeMenu}
value={sm.value}
value={sm.value || ""}
label={sm.label || ""}
/>
))}
@ -102,7 +103,7 @@ const NavSubItem: FC<NavItemProps> = ({
<NavItem
key={sm.value}
activeMenu={activeMenu}
value={sm.value}
value={sm.value || ""}
label={sm.label || ""}
color={color}
/>

View file

@ -47,9 +47,11 @@
margin-bottom: 2px;
overflow: hidden;
@media (max-width: 1200px) {
min-width: 14px;
max-width: 14px;
&:not(&_logs) {
@media (max-width: 1200px) {
min-width: 14px;
max-width: 14px;
}
}
svg {
@ -62,5 +64,10 @@
min-width: 65px;
margin: 0 auto;
}
&_logs, &_logs svg {
max-width: 75px;
min-width: 75px;
}
}
}

View file

@ -6,11 +6,12 @@ import "./style.scss";
import { getAppModeEnable } from "../../utils/app-mode";
import classNames from "classnames";
import Footer from "./Footer/Footer";
import { routerOptions } from "../../router";
import router, { routerOptions } from "../../router";
import { useFetchDashboards } from "../../pages/PredefinedPanels/hooks/useFetchDashboards";
import useDeviceDetect from "../../hooks/useDeviceDetect";
const Layout: FC = () => {
const { REACT_APP_LOGS } = process.env;
const appModeEnable = getAppModeEnable();
const { isMobile } = useDeviceDetect();
const { pathname } = useLocation();
@ -20,7 +21,7 @@ const Layout: FC = () => {
const setDocumentTitle = () => {
const defaultTitle = "vmui";
const routeTitle = routerOptions[pathname]?.title;
const routeTitle = REACT_APP_LOGS ? routerOptions[router.logs]?.title : routerOptions[pathname]?.title;
document.title = routeTitle ? `${routeTitle} - ${defaultTitle}` : defaultTitle;
};

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,52 @@
import React, { FC } from "preact/compat";
import Button from "../../Button/Button";
import { ArrowDownIcon } from "../../Icons";
import "./style.scss";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import classNames from "classnames";
interface PaginationControlProps {
page: number;
length: number;
limit: number;
onChange: (page: number) => void;
}
const PaginationControl: FC<PaginationControlProps> = ({ page, length, limit, onChange }) => {
const { isMobile } = useDeviceDetect();
const handleChangePage = (step: number) => () => {
onChange(+page + step);
window.scrollTo(0, 0);
};
return (
<div
className={classNames({
"vm-pagination": true,
"vm-pagination_mobile": isMobile
})}
>
{page > 1 && (
<Button
variant={"text"}
onClick={handleChangePage(-1)}
startIcon={<div className="vm-pagination__icon vm-pagination__icon_prev"><ArrowDownIcon/></div>}
>
Previous
</Button>
)}
{length >= limit && (
<Button
variant={"text"}
onClick={handleChangePage(1)}
endIcon={<div className="vm-pagination__icon vm-pagination__icon_next"><ArrowDownIcon/></div>}
>
Next
</Button>
)}
</div>
);
};
export default PaginationControl;

View file

@ -0,0 +1,24 @@
@use "src/styles/variables" as *;
.vm-pagination {
position: sticky;
right: 0;
display: flex;
justify-content: flex-end;
gap: $padding-small;
padding: $padding-global 0 0;
&_mobile {
padding: $padding-global 0;
}
&__icon {
&_prev {
transform: rotate(90deg);
}
&_next {
transform: rotate(-90deg);
}
}
}

View file

@ -0,0 +1,76 @@
import React, { FC, useRef } from "preact/compat";
import Tooltip from "../../Tooltip/Tooltip";
import Button from "../../Button/Button";
import { ArrowDropDownIcon } from "../../Icons";
import useBoolean from "../../../../hooks/useBoolean";
import Popper from "../../Popper/Popper";
import classNames from "classnames";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import "./style.scss";
interface SelectLimitProps {
tooltip?: string;
limit: number | string;
onChange: (val: number) => void;
}
const defaultLimits = [10, 25, 50, 100, 250, 500, 1000];
const SelectLimit: FC<SelectLimitProps> = ({ limit, tooltip, onChange }) => {
const { isMobile } = useDeviceDetect();
const title = tooltip || "Rows per page";
const buttonRef = useRef<HTMLDivElement>(null);
const {
value: openList,
toggle: toggleOpenList,
setFalse: handleClose,
} = useBoolean(false);
const handleChangeLimit = (n: number) => () => {
onChange(n);
handleClose();
};
return (
<>
<Tooltip title={title}>
<div ref={buttonRef}>
<Button
variant="text"
endIcon={<ArrowDropDownIcon/>}
onClick={toggleOpenList}
>
{limit}
</Button>
</div>
</Tooltip>
<Popper
open={openList}
onClose={handleClose}
placement="bottom-right"
buttonRef={buttonRef}
>
<div
className="vm-select-limits"
>
{defaultLimits.map(n => (
<div
className={classNames({
"vm-list-item": true,
"vm-list-item_mobile": isMobile,
"vm-list-item_active": n === limit,
})}
key={n}
onClick={handleChangeLimit(n)}
>
{n}
</div>
))}
</div>
</Popper>
</>
);
};
export default SelectLimit;

View file

@ -0,0 +1,6 @@
@use "src/styles/variables" as *;
.vm-select-limits {
display: grid;
padding: $padding-small 0;
}

View file

@ -0,0 +1,126 @@
import React, { useState, useMemo } from "react";
import classNames from "classnames";
import { ArrowDropDownIcon, CopyIcon, DoneIcon } from "../Main/Icons";
import { getComparator, stableSort } from "../../pages/CardinalityPanel/Table/helpers";
import Tooltip from "../Main/Tooltip/Tooltip";
import Button from "../Main/Button/Button";
import { useEffect } from "preact/compat";
interface TableProps<T> {
rows: T[];
columns: { title?: string, key: keyof Partial<T>, className?: string }[];
defaultOrderBy: keyof T;
copyToClipboard?: keyof T;
// TODO: Remove when pagination is implemented on the backend.
paginationOffset: {
startIndex: number;
endIndex: number;
}
}
const Table = <T extends object>({ rows, columns, defaultOrderBy, copyToClipboard, paginationOffset }: TableProps<T>) => {
const [orderBy, setOrderBy] = useState<keyof T>(defaultOrderBy);
const [orderDir, setOrderDir] = useState<"asc" | "desc">("desc");
const [copied, setCopied] = useState<number | null>(null);
// const sortedList = useMemo(() => stableSort(rows as [], getComparator(orderDir, orderBy)),
// [rows, orderBy, orderDir]);
// TODO: Remove when pagination is implemented on the backend.
const sortedList = useMemo(() => {
const { startIndex, endIndex } = paginationOffset;
return stableSort(rows as [], getComparator(orderDir, orderBy)).slice(startIndex, endIndex);
},
[rows, orderBy, orderDir, paginationOffset]);
const createSortHandler = (key: keyof T) => () => {
setOrderDir((prev) => prev === "asc" && orderBy === key ? "desc" : "asc");
setOrderBy(key);
};
const createCopyHandler = (copyValue: string | number, rowIndex: number) => async () => {
if (copied === rowIndex) return;
try {
await navigator.clipboard.writeText(String(copyValue));
setCopied(rowIndex);
} catch (e) {
console.error(e);
}
};
useEffect(() => {
if (copied === null) return;
const timeout = setTimeout(() => setCopied(null), 2000);
return () => clearTimeout(timeout);
}, [copied]);
return (
<table className="vm-table">
<thead className="vm-table-header">
<tr className="vm-table__row vm-table__row_header">
{columns.map((col) => (
<th
className="vm-table-cell vm-table-cell_header vm-table-cell_sort"
onClick={createSortHandler(col.key)}
key={String(col.key)}
>
<div className="vm-table-cell__content">
<div>
{String(col.title || col.key)}
</div>
<div
className={classNames({
"vm-table__sort-icon": true,
"vm-table__sort-icon_active": orderBy === col.key,
"vm-table__sort-icon_desc": orderDir === "desc" && orderBy === col.key
})}
>
<ArrowDropDownIcon/>
</div>
</div>
</th>
))}
{copyToClipboard && <th className="vm-table-cell vm-table-cell_header"/>}
</tr>
</thead>
<tbody className="vm-table-body">
{sortedList.map((row, rowIndex) => (
<tr
className="vm-table__row"
key={rowIndex}
>
{columns.map((col) => (
<td
className={classNames({
"vm-table-cell": true,
[`${col.className}`]: col.className
})}
key={String(col.key)}
>
{row[col.key] || "-"}
</td>
))}
{copyToClipboard && (
<td className="vm-table-cell vm-table-cell_right">
{row[copyToClipboard] && (
<div className="vm-table-cell__content">
<Tooltip title={copied === rowIndex ? "Copied" : "Copy row"}>
<Button
variant="text"
color={copied === rowIndex ? "success" : "gray"}
size="small"
startIcon={copied === rowIndex ? <DoneIcon/> : <CopyIcon/>}
onClick={createCopyHandler(row[copyToClipboard], rowIndex)}
/>
</Tooltip>
</div>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
);
};
export default Table;

View file

@ -1,12 +1,12 @@
import React, { FC, useMemo } from "preact/compat";
import { InstantMetricResult } from "../../../api/types";
import { InstantMetricResult, Logs } from "../../../api/types";
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
import { TopQuery } from "../../../types";
import Button from "../../Main/Button/Button";
import "./style.scss";
export interface JsonViewProps {
data: InstantMetricResult[] | TopQuery[];
data: InstantMetricResult[] | TopQuery[] | Logs[];
}
const JsonView: FC<JsonViewProps> = ({ data }) => {

View file

@ -1,4 +1,4 @@
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from "preact/compat";
import React, { FC, useMemo, useRef, useState } from "preact/compat";
import { InstantMetricResult } from "../../../api/types";
import { InstantDataSeries } from "../../../types";
import { useSortedCategories } from "../../../hooks/useSortedCategories";
@ -12,8 +12,6 @@ import { getNameForMetric } from "../../../utils/metric";
import { useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext";
import "./style.scss";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useWindowSize from "../../../hooks/useWindowSize";
import useEventListener from "../../../hooks/useEventListener";
export interface GraphViewProps {
data: InstantMetricResult[];
@ -25,10 +23,7 @@ const TableView: FC<GraphViewProps> = ({ data, displayColumns }) => {
const { isMobile } = useDeviceDetect();
const { tableCompact } = useCustomPanelState();
const windowSize = useWindowSize();
const tableRef = useRef<HTMLTableElement>(null);
const [tableTop, setTableTop] = useState(0);
const [headTop, setHeadTop] = useState(0);
const [orderBy, setOrderBy] = useState("");
const [orderDir, setOrderDir] = useState<"asc" | "desc">("asc");
@ -83,20 +78,6 @@ const TableView: FC<GraphViewProps> = ({ data, displayColumns }) => {
await copyToClipboard(copyValue, "Row has been copied");
};
const handleScroll = useCallback(() => {
if (!tableRef.current) return;
const { top } = tableRef.current.getBoundingClientRect();
setHeadTop(top < 0 ? window.scrollY - tableTop : 0);
}, [tableRef, tableTop, windowSize]);
useEventListener("scroll", handleScroll);
useEffect(() => {
if (!tableRef.current) return;
const { top } = tableRef.current.getBoundingClientRect();
setTableTop(top + window.scrollY);
}, [tableRef, windowSize]);
if (!rows.length) return <Alert variant="warning">No data to show</Alert>;
return (
@ -111,10 +92,7 @@ const TableView: FC<GraphViewProps> = ({ data, displayColumns }) => {
ref={tableRef}
>
<thead className="vm-table-header">
<tr
className="vm-table__row vm-table__row_header"
style={{ transform: `translateY(${headTop}px)` }}
>
<tr className="vm-table__row vm-table__row_header">
{sortedColumns.map((col, index) => (
<td
className="vm-table-cell vm-table-cell_header vm-table-cell_sort"

View file

@ -0,0 +1,60 @@
import router, { routerOptions } from "../router";
export interface NavigationItem {
label?: string,
value?: string,
hide?: boolean
submenu?: NavigationItem[],
}
export const logsNavigation: NavigationItem[] = [
{
label: routerOptions[router.logs].title,
value: router.home,
},
];
export const defaultNavigation: NavigationItem[] = [
{
label: routerOptions[router.home].title,
value: router.home,
},
{
label: "Explore",
submenu: [
{
label: routerOptions[router.metrics].title,
value: router.metrics,
},
{
label: routerOptions[router.cardinality].title,
value: router.cardinality,
},
{
label: routerOptions[router.topQueries].title,
value: router.topQueries,
},
{
label: routerOptions[router.logs].title,
value: router.logs,
},
]
},
{
label: "Tools",
submenu: [
{
label: routerOptions[router.trace].title,
value: router.trace,
},
{
label: routerOptions[router.withTemplate].title,
value: router.withTemplate,
},
{
label: routerOptions[router.relabel].title,
value: router.relabel,
},
]
}
];

View file

@ -0,0 +1,34 @@
import { useNavigate, useSearchParams } from "react-router-dom";
import { useCallback } from "preact/compat";
const useSearchParamsFromObject = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const setSearchParamsFromKeys = useCallback((objectParams: Record<string, string | number>) => {
const hasSearchParams = !!Array.from(searchParams.values()).length;
let hasChanged = false;
Object.entries(objectParams).forEach(([key, value]) => {
if (searchParams.get(key) !== `${value}`) {
searchParams.set(key, `${value}`);
hasChanged = true;
}
});
if (!hasChanged) return;
if (hasSearchParams) {
setSearchParams(searchParams);
} else {
navigate(`?${searchParams.toString()}`, { replace: true });
}
}, [searchParams, navigate]);
return {
setSearchParamsFromKeys
};
};
export default useSearchParamsFromObject;

View file

@ -6,20 +6,23 @@ export type MetricCategory = {
variations: number;
}
export const getColumns = (data: MetricBase[]): MetricCategory[] => {
const columns: { [key: string]: { options: Set<string> } } = {};
data.forEach(d =>
Object.entries(d.metric).forEach(e =>
columns[e[0]] ? columns[e[0]].options.add(e[1]) : columns[e[0]] = { options: new Set([e[1]]) }
)
);
return Object.entries(columns).map(e => ({
key: e[0],
variations: e[1].options.size
})).sort((a1, a2) => a1.variations - a2.variations);
};
export const useSortedCategories = (data: MetricBase[], displayColumns?: string[]): MetricCategory[] => (
useMemo(() => {
const columns: { [key: string]: { options: Set<string> } } = {};
data.forEach(d =>
Object.entries(d.metric).forEach(e =>
columns[e[0]] ? columns[e[0]].options.add(e[1]) : columns[e[0]] = { options: new Set([e[1]]) }
)
);
const sortedColumns = Object.entries(columns).map(e => ({
key: e[0],
variations: e[1].options.size
})).sort((a1, a2) => a1.variations - a2.variations);
const sortedColumns = getColumns(data);
return displayColumns ? sortedColumns.filter(col => displayColumns.includes(col.key)) : sortedColumns;
}, [data, displayColumns])
);

View file

@ -0,0 +1,18 @@
import { useState, useEffect, StateUpdater } from "preact/compat";
import { useSearchParams } from "react-router-dom";
const useStateSearchParams = <T>(defaultState: T, key: string): [T, StateUpdater<T>] => {
const [searchParams] = useSearchParams();
const currentValue = searchParams.get(key) ? searchParams.get(key) as unknown as T : defaultState;
const [state, setState] = useState<T>(currentValue);
useEffect(() => {
if ((currentValue as unknown as T) !== state) {
setState(currentValue as unknown as T);
}
}, [currentValue]);
return [state, setState];
};
export default useStateSearchParams;

View file

@ -1,13 +1,10 @@
import React, { FC, useEffect, useRef, useMemo } from "preact/compat";
import { useSortedCategories } from "../../../../hooks/useSortedCategories";
import { InstantMetricResult } from "../../../../api/types";
import Button from "../../../../components/Main/Button/Button";
import { RestartIcon, SettingsIcon } from "../../../../components/Main/Icons";
import Popper from "../../../../components/Main/Popper/Popper";
import "./style.scss";
import Checkbox from "../../../../components/Main/Checkbox/Checkbox";
import Tooltip from "../../../../components/Main/Tooltip/Tooltip";
import { useCustomPanelDispatch, useCustomPanelState } from "../../../../state/customPanel/CustomPanelStateContext";
import Switch from "../../../../components/Main/Switch/Switch";
import { arrayEquals } from "../../../../utils/array";
import classNames from "classnames";
@ -17,18 +14,22 @@ import useBoolean from "../../../../hooks/useBoolean";
const title = "Table settings";
interface TableSettingsProps {
data: InstantMetricResult[];
defaultColumns?: string[]
onChange: (arr: string[]) => void
columns: string[];
defaultColumns?: string[];
tableCompact: boolean;
toggleTableCompact: () => void;
onChangeColumns: (arr: string[]) => void
}
const TableSettings: FC<TableSettingsProps> = ({ data, defaultColumns = [], onChange }) => {
const TableSettings: FC<TableSettingsProps> = ({
columns,
defaultColumns = [],
tableCompact,
onChangeColumns,
toggleTableCompact
}) => {
const { isMobile } = useDeviceDetect();
const { tableCompact } = useCustomPanelState();
const customPanelDispatch = useCustomPanelDispatch();
const columns = useSortedCategories(data);
const buttonRef = useRef<HTMLDivElement>(null);
const {
@ -40,16 +41,12 @@ const TableSettings: FC<TableSettingsProps> = ({ data, defaultColumns = [], onCh
const disabledButton = useMemo(() => !columns.length, [columns]);
const handleChange = (key: string) => {
onChange(defaultColumns.includes(key) ? defaultColumns.filter(col => col !== key) : [...defaultColumns, key]);
};
const toggleTableCompact = () => {
customPanelDispatch({ type: "TOGGLE_TABLE_COMPACT" });
onChangeColumns(defaultColumns.includes(key) ? defaultColumns.filter(col => col !== key) : [...defaultColumns, key]);
};
const handleResetColumns = () => {
handleClose();
onChange(columns.map(col => col.key));
onChangeColumns(columns);
};
const createHandlerChange = (key: string) => () => {
@ -57,9 +54,8 @@ const TableSettings: FC<TableSettingsProps> = ({ data, defaultColumns = [], onCh
};
useEffect(() => {
const values = columns.map(col => col.key);
if (arrayEquals(values, defaultColumns)) return;
onChange(values);
if (arrayEquals(columns, defaultColumns)) return;
onChangeColumns(columns);
}, [columns]);
return (
@ -110,12 +106,12 @@ const TableSettings: FC<TableSettingsProps> = ({ data, defaultColumns = [], onCh
{columns.map(col => (
<div
className="vm-table-settings-popper-list__item"
key={col.key}
key={col}
>
<Checkbox
checked={defaultColumns.includes(col.key)}
onChange={createHandlerChange(col.key)}
label={col.key}
checked={defaultColumns.includes(col)}
onChange={createHandlerChange(col)}
label={col}
disabled={tableCompact}
/>
</div>

View file

@ -38,7 +38,6 @@
&__item {
font-size: $font-size;
text-transform: capitalize;
}
}
}

View file

@ -1,4 +1,4 @@
import React, { FC, useState, useEffect } from "preact/compat";
import React, { FC, useState, useEffect, useMemo } from "preact/compat";
import GraphView from "../../components/Views/GraphView/GraphView";
import QueryConfigurator from "./QueryConfigurator/QueryConfigurator";
import { useFetchQuery } from "../../hooks/useFetchQuery";
@ -12,7 +12,7 @@ import { useFetchQueryOptions } from "../../hooks/useFetchQueryOptions";
import TracingsView from "../../components/TraceQuery/TracingsView";
import Trace from "../../components/TraceQuery/Trace";
import TableSettings from "../CardinalityPanel/Table/TableSettings/TableSettings";
import { useCustomPanelState } from "../../state/customPanel/CustomPanelStateContext";
import { useCustomPanelState, useCustomPanelDispatch } from "../../state/customPanel/CustomPanelStateContext";
import { useQueryState } from "../../state/query/QueryStateContext";
import { useTimeDispatch, useTimeState } from "../../state/time/TimeStateContext";
import { useSetQueryParams } from "./hooks/useSetQueryParams";
@ -25,6 +25,7 @@ import useDeviceDetect from "../../hooks/useDeviceDetect";
import GraphTips from "../../components/Chart/GraphTips/GraphTips";
import InstantQueryTip from "./InstantQueryTip/InstantQueryTip";
import useBoolean from "../../hooks/useBoolean";
import { getColumns } from "../../hooks/useSortedCategories";
const CustomPanel: FC = () => {
const { displayType, isTracingEnabled } = useCustomPanelState();
@ -91,6 +92,14 @@ const CustomPanel: FC = () => {
setHideError(false);
};
const columns = useMemo(() => getColumns(liveData || []).map(c => c.key), [liveData]);
const { tableCompact } = useCustomPanelState();
const customPanelDispatch = useCustomPanelDispatch();
const toggleTableCompact = () => {
customPanelDispatch({ type: "TOGGLE_TABLE_COMPACT" });
};
useEffect(() => {
if (traces) {
setTracesState([...tracesState, ...traces]);
@ -171,9 +180,11 @@ const CustomPanel: FC = () => {
)}
{displayType === "table" && (
<TableSettings
data={liveData || []}
columns={columns}
defaultColumns={displayColumns}
onChange={setDisplayColumns}
onChangeColumns={setDisplayColumns}
tableCompact={tableCompact}
toggleTableCompact={toggleTableCompact}
/>
)}
</div>

View file

@ -0,0 +1,46 @@
import React, { FC, useEffect } from "preact/compat";
import ExploreLogsBody from "./ExploreLogsBody/ExploreLogsBody";
import useStateSearchParams from "../../hooks/useStateSearchParams";
import useSearchParamsFromObject from "../../hooks/useSearchParamsFromObject";
import { useFetchLogs } from "./hooks/useFetchLogs";
import { useAppState } from "../../state/common/StateContext";
import Spinner from "../../components/Main/Spinner/Spinner";
import Alert from "../../components/Main/Alert/Alert";
import ExploreLogsHeader from "./ExploreLogsHeader/ExploreLogsHeader";
import "./style.scss";
import usePrevious from "../../hooks/usePrevious";
const ExploreLogs: FC = () => {
const { serverUrl } = useAppState();
const { setSearchParamsFromKeys } = useSearchParamsFromObject();
const [query, setQuery] = useStateSearchParams("", "query");
const prevQuery = usePrevious(query);
const { logs, isLoading, error, fetchLogs } = useFetchLogs(serverUrl, query);
const handleRunQuery = () => {
fetchLogs();
const changedQuery = prevQuery && query !== prevQuery;
const params: Record<string, string | number> = changedQuery ? { query, page: 1 } : { query };
setSearchParamsFromKeys(params);
};
useEffect(() => {
if (query) handleRunQuery();
}, []);
return (
<div className="vm-explore-logs">
<ExploreLogsHeader
query={query}
onChange={setQuery}
onRun={handleRunQuery}
/>
{isLoading && <Spinner />}
{error && <Alert variant="error">{error}</Alert>}
<ExploreLogsBody data={logs}/>
</div>
);
};
export default ExploreLogs;

View file

@ -0,0 +1,147 @@
import React, { FC, useState, useMemo } from "preact/compat";
import JsonView from "../../../components/Views/JsonView/JsonView";
import { CodeIcon, ListIcon, TableIcon } from "../../../components/Main/Icons";
import Tabs from "../../../components/Main/Tabs/Tabs";
import "./style.scss";
import classNames from "classnames";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import { Logs } from "../../../api/types";
import dayjs from "dayjs";
import { useTimeState } from "../../../state/time/TimeStateContext";
import SelectLimit from "../../../components/Main/Pagination/SelectLimit/SelectLimit";
import useStateSearchParams from "../../../hooks/useStateSearchParams";
import useSearchParamsFromObject from "../../../hooks/useSearchParamsFromObject";
import { getFromStorage, saveToStorage } from "../../../utils/storage";
import TableSettings from "../../CardinalityPanel/Table/TableSettings/TableSettings";
import useBoolean from "../../../hooks/useBoolean";
import TableLogs from "./TableLogs";
import GroupLogs from "./GroupLogs";
export interface ExploreLogBodyProps {
data: Logs[]
}
enum DisplayType {
group = "group",
table = "table",
json = "json",
}
const tabs = [
{ label: "Group", value: DisplayType.group, icon: <ListIcon /> },
{ label: "Table", value: DisplayType.table, icon: <TableIcon /> },
{ label: "JSON", value: DisplayType.json, icon: <CodeIcon /> },
];
const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data }) => {
const { isMobile } = useDeviceDetect();
const { timezone } = useTimeState();
const { setSearchParamsFromKeys } = useSearchParamsFromObject();
const [limitRows, setLimitRows] = useStateSearchParams(getFromStorage("LOGS_LIMIT") || 50, "limit");
const [activeTab, setActiveTab] = useStateSearchParams(DisplayType.group, "view");
const [displayColumns, setDisplayColumns] = useState<string[]>([]);
const { value: tableCompact, toggle: toggleTableCompact } = useBoolean(false);
const logs = useMemo(() => data.map((item) => ({
time: dayjs(item._time).tz().format("MMM DD, YYYY \nHH:mm:ss.SSS"),
data: JSON.stringify(item, null, 2),
...item,
})) as Logs[], [data, timezone]);
const columns = useMemo(() => {
if (!logs?.length) return [];
const hideColumns = ["data", "_time"];
const keys = new Set<string>();
for (const item of logs) {
for (const key in item) {
keys.add(key);
}
}
return Array.from(keys).filter((col) => !hideColumns.includes(col));
}, [logs]);
const handleChangeTab = (view: string) => {
setActiveTab(view as DisplayType);
setSearchParamsFromKeys({ view });
};
const handleChangeLimit = (limit: number) => {
setLimitRows(limit);
setSearchParamsFromKeys({ limit });
saveToStorage("LOGS_LIMIT", `${limit}`);
};
return (
<div
className={classNames({
"vm-explore-logs-body": true,
"vm-block": true,
"vm-block_mobile": isMobile,
})}
>
<div
className={classNames({
"vm-explore-logs-body-header": true,
"vm-section-header": true,
"vm-explore-logs-body-header_mobile": isMobile,
})}
>
<div className="vm-section-header__tabs">
<Tabs
activeItem={String(activeTab)}
items={tabs}
onChange={handleChangeTab}
/>
</div>
{activeTab === DisplayType.table && (
<div className="vm-explore-logs-body-header__settings">
<SelectLimit
limit={+limitRows}
onChange={handleChangeLimit}
/>
<TableSettings
columns={columns}
defaultColumns={displayColumns}
onChangeColumns={setDisplayColumns}
tableCompact={tableCompact}
toggleTableCompact={toggleTableCompact}
/>
</div>
)}
</div>
<div
className={classNames({
"vm-explore-logs-body__table": true,
"vm-explore-logs-body__table_mobile": isMobile,
})}
>
{!!data.length && (
<>
{activeTab === DisplayType.table && (
<TableLogs
logs={logs}
limitRows={+limitRows}
displayColumns={displayColumns}
tableCompact={tableCompact}
columns={columns}
/>
)}
{activeTab === DisplayType.group && (
<GroupLogs
logs={logs}
columns={columns}
/>
)}
{activeTab === DisplayType.json && (
<JsonView data={data} />
)}
</>
)}
</div>
</div>
);
};
export default ExploreLogsBody;

View file

@ -0,0 +1,65 @@
import React, { FC, useMemo } from "preact/compat";
import "./style.scss";
import { Logs } from "../../../api/types";
import Accordion from "../../../components/Main/Accordion/Accordion";
import { groupByMultipleKeys } from "../../../utils/array";
interface TableLogsProps {
logs: Logs[];
columns: string[];
}
const GroupLogs: FC<TableLogsProps> = ({ logs, columns }) => {
const groupData = useMemo(() => {
const excludeColumns = ["_msg", "time", "data", "_time"];
const keys = columns.filter((c) => !excludeColumns.includes(c as string));
return groupByMultipleKeys(logs, keys);
}, [logs]);
return (
<div className="vm-explore-logs-body-content">
{groupData.map((item) => (
<div
className="vm-explore-logs-body-content-group"
key={item.keys.join("")}
>
<Accordion
defaultExpanded={true}
title={(
<div className="vm-explore-logs-body-content-group-keys">
<span className="vm-explore-logs-body-content-group-keys__title">Group by:</span>
{item.keys.map((key) => (
<div
className="vm-explore-logs-body-content-group-keys__key"
key={key}
>
{key}
</div>
))}
</div>
)}
>
<div className="vm-explore-logs-body-content-group-rows">
{item.values.map((value) => (
<div
className="vm-explore-logs-body-content-group-rows-item"
key={`${value._msg}${value._time}`}
>
<div className="vm-explore-logs-body-content-group-rows-item__time">
{value.time}
</div>
<div className="vm-explore-logs-body-content-group-rows-item__msg">
{value._msg}
</div>
</div>
))}
</div>
</Accordion>
</div>
))}
</div>
);
};
export default GroupLogs;

View file

@ -0,0 +1,88 @@
import React, { FC, useMemo } from "preact/compat";
import "./style.scss";
import Table from "../../../components/Table/Table";
import { Logs } from "../../../api/types";
import useStateSearchParams from "../../../hooks/useStateSearchParams";
import useSearchParamsFromObject from "../../../hooks/useSearchParamsFromObject";
import PaginationControl from "../../../components/Main/Pagination/PaginationControl/PaginationControl";
interface TableLogsProps {
logs: Logs[];
limitRows: number;
displayColumns: string[];
tableCompact: boolean;
columns: string[];
}
const TableLogs: FC<TableLogsProps> = ({ logs, limitRows, displayColumns, tableCompact, columns }) => {
const { setSearchParamsFromKeys } = useSearchParamsFromObject();
const [page, setPage] = useStateSearchParams(1, "page");
const getColumnClass = (key: string) => {
switch (key) {
case "time":
return "vm-table-cell_logs-time";
case "data":
return "vm-table-cell_logs vm-table-cell_pre";
default:
return "vm-table-cell_logs";
}
};
// TODO: Remove when pagination is implemented on the backend.
const paginationOffset = useMemo(() => {
const startIndex = (page - 1) * Number(limitRows);
const endIndex = startIndex + Number(limitRows);
return {
startIndex,
endIndex
};
}, [page, limitRows]);
const tableColumns = useMemo(() => {
if (tableCompact) {
return [{
key: "data",
title: "Data",
className: getColumnClass("data")
}];
}
return columns.map((key) => ({
key: key as keyof Logs,
title: key,
className: getColumnClass(key),
}));
}, [tableCompact, columns]);
const filteredColumns = useMemo(() => {
if (!displayColumns?.length || tableCompact) return tableColumns;
return tableColumns.filter(c => displayColumns.includes(c.key as string));
}, [tableColumns, displayColumns, tableCompact]);
const handleChangePage = (page: number) => {
setPage(page);
setSearchParamsFromKeys({ page });
};
return (
<>
<Table
rows={logs}
columns={filteredColumns}
defaultOrderBy={"time"}
copyToClipboard={"data"}
paginationOffset={paginationOffset}
/>
<PaginationControl
page={page}
limit={+limitRows}
// TODO: Remove .slice() when pagination is implemented on the backend.
length={logs.slice(paginationOffset.startIndex, paginationOffset.endIndex).length}
onChange={handleChangePage}
/>
</>
);
};
export default TableLogs;

View file

@ -0,0 +1,85 @@
@use "src/styles/variables" as *;
.vm-explore-logs-body {
&-header {
margin: -$padding-medium 0-$padding-medium 0;
&_mobile {
margin: -$padding-global 0-$padding-global 0;
}
&__settings {
display: flex;
align-items: center;
gap: $padding-small;
}
}
&__table {
padding-top: $padding-medium;
width: calc(100vw - ($padding-medium * 4) - var(--scrollbar-width));
overflow: auto;
@media (max-width: 768px) {
width: calc(100vw - ($padding-medium * 2) - var(--scrollbar-width));
}
&_mobile {
width: calc(100vw - ($padding-global * 2) - var(--scrollbar-width));
}
.vm-table {
min-width: 700px;
}
}
&-content {
&-group {
&-keys {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: $padding-small;
border-bottom: $border-divider;
padding: $padding-global 0;
&__title {
font-weight: bold;
}
&__key {
padding: 4px 12px;
background-color: $color-primary;
color: $color-primary-text;
border-radius: $border-radius-small;
}
}
&-rows {
display: grid;
&-item {
display: grid;
grid-template-columns: 107px 1fr;
gap: $padding-small;
padding: $padding-global 0;
border-bottom: $border-divider;
&__time {
display: flex;
align-items: center;
justify-content: center;
line-height: 1.3;
}
&__msg {
font-family: $font-family-monospace;
overflow-wrap: anywhere;
line-height: 1.1;
}
}
}
}
}
}

View file

@ -0,0 +1,73 @@
import React, { FC } from "react";
import { InfoIcon, PlayIcon, WikiIcon } from "../../../components/Main/Icons";
import "./style.scss";
import classNames from "classnames";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import Button from "../../../components/Main/Button/Button";
import QueryEditor from "../../../components/Configurators/QueryEditor/QueryEditor";
export interface ExploreLogHeaderProps {
query: string;
onChange: (val: string) => void;
onRun: () => void;
}
const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({ query, onChange, onRun }) => {
const { isMobile } = useDeviceDetect();
return (
<div
className={classNames({
"vm-explore-logs-header": true,
"vm-block": true,
"vm-block_mobile": isMobile,
})}
>
<div className="vm-explore-logs-header__input">
<QueryEditor
value={query}
autocomplete={false}
options={[]}
onArrowUp={() => null}
onArrowDown={() => null}
onEnter={onRun}
onChange={onChange}
label={"Log query"}
/>
</div>
<div className="vm-explore-logs-header-bottom">
<div className="vm-explore-logs-header-bottom-helpful">
<a
className="vm-link vm-link_with-icon"
target="_blank"
href="https://docs.victoriametrics.com/VictoriaLogs/LogsQL.html"
rel="help noreferrer"
>
<InfoIcon/>
Query language docs
</a>
<a
className="vm-link vm-link_with-icon"
target="_blank"
href="https://docs.victoriametrics.com/VictoriaLogs/"
rel="help noreferrer"
>
<WikiIcon/>
Documentation
</a>
</div>
<div className="vm-explore-logs-header-bottom__execute">
<Button
startIcon={<PlayIcon/>}
onClick={onRun}
fullWidth
>
Execute Query
</Button>
</div>
</div>
</div>
);
};
export default ExploreLogsHeader;

View file

@ -0,0 +1,38 @@
@use "src/styles/variables" as *;
.vm-explore-logs-header {
display: grid;
align-items: center;
gap: $padding-global;
&__input {}
&-bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: $padding-global;
@media (max-width: 500px) {
display: grid;
justify-content: normal;
}
&__execute {
display: grid;
}
&-helpful {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: $padding-small $padding-global;
a {
color: $color-text-secondary;
}
}
}
}

View file

@ -0,0 +1,93 @@
import { useCallback, useMemo, useState } from "preact/compat";
import { getLogsUrl } from "../../../api/logs";
import { ErrorTypes } from "../../../types";
import { Logs } from "../../../api/types";
const MAX_LINES = 1000;
export const useFetchLogs = (server: string, query: string) => {
const [logs, setLogs] = useState<Logs[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<ErrorTypes | string>();
const url = useMemo(() => getLogsUrl(server), [server]);
const options = useMemo(() => ({
method: "POST",
headers: {
"Accept": "application/stream+json; charset=utf-8",
"Content-Type": "application/x-www-form-urlencoded",
},
body: `query=${encodeURIComponent(query.trim())}`
}), [query]);
const fetchLogs = useCallback(async () => {
setIsLoading(true);
setError(undefined);
try {
const response = await fetch(url, options);
if (!response.ok || !response.body) {
const errorText = await response.text();
setError(errorText);
setLogs([]);
setIsLoading(false);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
const result = [];
while (reader) {
const { done, value } = await reader.read();
if (done) {
// "Stream finished, no more data."
break;
}
const lines = decoder.decode(value, { stream: true }).split("\n");
result.push(...lines);
// Trim result to MAX_LINES
if (result.length > MAX_LINES) {
result.splice(0, result.length - MAX_LINES);
}
if (result.length >= MAX_LINES) {
// Reached the maximum line limit
reader.cancel();
break;
}
}
const data = result
.map((line) => {
try {
return JSON.parse(line);
} catch (e) {
return "";
}
})
.filter(line => line);
setLogs(data);
} catch (e) {
console.error(e);
setLogs([]);
if (e instanceof Error) {
setError(`${e.name}: ${e.message}`);
}
}
setIsLoading(false);
}, [url, options]);
return {
logs,
isLoading,
error,
fetchLogs,
};
};

View file

@ -0,0 +1,8 @@
@use "src/styles/variables" as *;
.vm-explore-logs {
display: grid;
grid-template-rows: auto 1fr;
align-items: flex-start;
gap: $padding-medium;
}

View file

@ -15,7 +15,7 @@ export const useFetchDashboards = (): {
error?: ErrorTypes | string,
dashboardsSettings: DashboardSettings[],
} => {
const { REACT_APP_LOGS } = process.env;
const appModeEnable = getAppModeEnable();
const { serverUrl } = useAppState();
const dispatch = useDashboardsDispatch();
@ -35,7 +35,7 @@ export const useFetchDashboards = (): {
};
const fetchRemoteDashboards = async () => {
if (!serverUrl) return;
if (!serverUrl || REACT_APP_LOGS) return;
setError("");
setIsLoading(true);

View file

@ -147,7 +147,7 @@ const TopQueries: FC = () => {
title={"Most frequently executed queries"}
columns={[
{ key: "query" },
{ key: "timeRange", sortBy: "timeRangeSeconds", title: "Query Time Interval" },
{ key: "timeRange", sortBy: "timeRangeSeconds", title: "query time interval" },
{ key: "count" }
]}
/>
@ -157,7 +157,7 @@ const TopQueries: FC = () => {
columns={[
{ key: "query" },
{ key: "avgDurationSeconds", title: "avg duration, sec" },
{ key: "timeRange", sortBy: "timeRangeSeconds", title: "Query Time Interval" },
{ key: "timeRange", sortBy: "timeRangeSeconds", title: "query time interval" },
{ key: "count" }
]}
defaultOrderBy={"avgDurationSeconds"}
@ -168,7 +168,7 @@ const TopQueries: FC = () => {
columns={[
{ key: "query" },
{ key: "sumDurationSeconds", title: "sum duration, sec" },
{ key: "timeRange", sortBy: "timeRangeSeconds", title: "Query Time Interval" },
{ key: "timeRange", sortBy: "timeRangeSeconds", title: "query time interval" },
{ key: "count" }
]}
defaultOrderBy={"sumDurationSeconds"}

View file

@ -7,6 +7,7 @@ const router = {
trace: "/trace",
withTemplate: "/expand-with-exprs",
relabel: "/relabeling",
logs: "/logs",
icons: "/icons"
};
@ -24,12 +25,14 @@ export interface RouterOptions {
header: RouterOptionsHeader
}
const { REACT_APP_LOGS } = process.env;
const routerOptionsDefault = {
header: {
tenant: true,
stepControl: true,
timeSelector: true,
executionControls: true,
stepControl: !REACT_APP_LOGS,
timeSelector: !REACT_APP_LOGS,
executionControls: !REACT_APP_LOGS,
}
};
@ -75,6 +78,10 @@ export const routerOptions: {[key: string]: RouterOptions} = {
title: "Metric relabel debug",
header: {}
},
[router.logs]: {
title: "Logs Explorer",
header: {}
},
[router.icons]: {
title: "Icons",
header: {}

View file

@ -49,7 +49,6 @@
&_header {
font-weight: bold;
text-transform: capitalize;
text-align: left;
overflow-wrap: normal;
}
@ -74,6 +73,20 @@
&_no-padding {
padding: 0;
}
&_pre {
white-space: pre-wrap;
}
&_logs-time {
white-space: pre;
overflow-wrap: normal;
}
&_logs {
font-family: $font-family-monospace;
line-height: 1.2;
}
}
&__sort-icon {

View file

@ -2,3 +2,18 @@ export const arrayEquals = (a: (string|number)[], b: (string|number)[]) => {
return a.length === b.length && a.every((val, index) => val === b[index]);
};
export function groupByMultipleKeys<T>(items: T[], keys: (keyof T)[]): { keys: string[], values: T[] }[] {
const groups = items.reduce((result, item) => {
const compositeKey = keys.map(key => `${key}: ${item[key] || "-"}`).join("|");
(result[compositeKey] = result[compositeKey] || []).push(item);
return result;
}, {} as { [key: string]: T[] });
return Object.entries(groups).map(([keyString, values]) => ({
keys: keyString.split("|"),
values
}));
}

View file

@ -1,8 +1,11 @@
import { getAppModeParams } from "./app-mode";
const { REACT_APP_LOGS } = process.env;
export const getDefaultServer = (tenantId?: string): string => {
const { serverURL } = getAppModeParams();
const logsURL = `${window.location.protocol}//${window.location.host}`;
const url = serverURL || window.location.href.replace(/\/(?:prometheus\/)?(?:graph|vmui)\/.*/, "/prometheus");
if (REACT_APP_LOGS) return logsURL;
if (tenantId) return replaceTenantId(url, tenantId);
return url;
};

View file

@ -8,6 +8,7 @@ export type StorageKeys = "BASIC_AUTH_DATA"
| "TABLE_COMPACT"
| "TIMEZONE"
| "THEME"
| "LOGS_LIMIT"
export const saveToStorage = (key: StorageKeys, value: string | boolean | Record<string, unknown>): void => {
if (value) {