mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2025-03-11 15:34:56 +00:00
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:
parent
039dff9f50
commit
4a7b17ed76
56 changed files with 1350 additions and 174 deletions
|
@ -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"},
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
BIN
app/vlselect/vmui/apple-touch-icon.png
Normal file
BIN
app/vlselect/vmui/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
14
app/vlselect/vmui/asset-manifest.json
Normal file
14
app/vlselect/vmui/asset-manifest.json
Normal 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"
|
||||
]
|
||||
}
|
BIN
app/vlselect/vmui/favicon-32x32.png
Normal file
BIN
app/vlselect/vmui/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
app/vlselect/vmui/favicon.ico
Normal file
BIN
app/vlselect/vmui/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
1
app/vlselect/vmui/index.html
Normal file
1
app/vlselect/vmui/index.html
Normal 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>
|
20
app/vlselect/vmui/manifest.json
Normal file
20
app/vlselect/vmui/manifest.json
Normal 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"
|
||||
}
|
BIN
app/vlselect/vmui/preview.jpg
Normal file
BIN
app/vlselect/vmui/preview.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 67 KiB |
3
app/vlselect/vmui/robots.txt
Normal file
3
app/vlselect/vmui/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
1
app/vlselect/vmui/static/js/27.c1ccfd29.chunk.js
Normal file
1
app/vlselect/vmui/static/js/27.c1ccfd29.chunk.js
Normal 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)}))}}}]);
|
Binary file not shown.
Binary file not shown.
|
@ -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
|
||||
|
|
27
app/vmui/packages/vmui/package-lock.json
generated
27
app/vmui/packages/vmui/package-lock.json
generated
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
2
app/vmui/packages/vmui/src/api/logs.ts
Normal file
2
app/vmui/packages/vmui/src/api/logs.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const getLogsUrl = (server: string): string =>
|
||||
`${server}/select/logsql/query`;
|
|
@ -25,3 +25,10 @@ export interface QueryStats {
|
|||
seriesFetched?: string;
|
||||
resultLength?: number;
|
||||
}
|
||||
|
||||
export interface Logs {
|
||||
_msg: string;
|
||||
_stream: string;
|
||||
_time: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,6 @@
|
|||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-select-limits {
|
||||
display: grid;
|
||||
padding: $padding-small 0;
|
||||
}
|
126
app/vmui/packages/vmui/src/components/Table/Table.tsx
Normal file
126
app/vmui/packages/vmui/src/components/Table/Table.tsx
Normal 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;
|
0
app/vmui/packages/vmui/src/components/Table/style.scss
Normal file
0
app/vmui/packages/vmui/src/components/Table/style.scss
Normal 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 }) => {
|
||||
|
|
|
@ -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"
|
||||
|
|
60
app/vmui/packages/vmui/src/constants/navigation.ts
Normal file
60
app/vmui/packages/vmui/src/constants/navigation.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
|
@ -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;
|
|
@ -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])
|
||||
);
|
||||
|
|
18
app/vmui/packages/vmui/src/hooks/useStateSearchParams.ts
Normal file
18
app/vmui/packages/vmui/src/hooks/useStateSearchParams.ts
Normal 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;
|
|
@ -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>
|
||||
|
|
|
@ -38,7 +38,6 @@
|
|||
|
||||
&__item {
|
||||
font-size: $font-size;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
46
app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx
Normal file
46
app/vmui/packages/vmui/src/pages/ExploreLogs/ExploreLogs.tsx
Normal 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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
8
app/vmui/packages/vmui/src/pages/ExploreLogs/style.scss
Normal file
8
app/vmui/packages/vmui/src/pages/ExploreLogs/style.scss
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -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: {}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue