mirror of
https://github.com/akvorado/akvorado.git
synced 2025-12-11 22:14:02 +01:00
console/frontend: convert from JavaScript to TypeScript
I thought it would be easier!
TypeScript typing is a bit different than Python one. Mostly, I think
that because JavaScript did not have named arguments, libs tried to be
smart by allowing polymorph arguments, the typing system has to be
powerful enough to handle complex conditions. However, sometimes, it's
quite complex to express things and sometimes, this can be a bit
buggy (many issues on GitHub about that). For example, you can narrow a
type with "if" and "switch", but for the later, you can't have several
cases for the same body. However, the ternary operator does not allow to
narrow the type. Moreover, in functional programming, lambdas are often
used but because TypeScript is not smart enough to know if a callback is
executed immediatly, it cannot assume the previous narrowing of a type
is still valid inside the lambda.
Also, eCharts typing is quite difficult because it accepts objects with
many different types. Without TypeScript, you know that you will get one
specific form, but with TypeScript, you need to either force or
enumerate cases you won't have. Moreover, the types are not all exposed,
so you have to dig them inside the `d.ts` files.
Another difficulty is that Vue 3 LSP server ("volar") does not work like
the one for generic TypeScript. In Emacs, it does not provide
information automatically in the modeline. Also, I did not find a way to
expand a type. Sometimes, it just says that a variable is
SomeRandomType, but you need to lookup yourself what could be
SomeRandomType. When there are errors, TypeScript does a good job to
explain where the incompatibility is. However, the errors are quite
verbose and the end is the most interesting hint. I suppose the LSP
server could have a condensed version of the error message ("options →
tooltip → formatter should be type X, not type Y") to help.
Emit signals are also not typed on the destination component. I don't
know if this is expected.
The migration has two benefits:
1. the code should be more robust (still not tested, but types are a
great help to catch problems)
2. the values sent from one component to another are now correctly
specified (previously, `console.log` was heavily used to check that we
get what we want) and I can use `null` when I don't have a value instead
of using an empty object and putting question marks everywhere
This commit is contained in:
4
Makefile
4
Makefile
@@ -173,7 +173,7 @@ lint: .lint-go~ .lint-js~ ## Run linting
|
|||||||
.lint-go~: $(shell $(LSFILES) '*.go' 2> /dev/null) | $(REVIVE) ; $(info $(M) running golint…)
|
.lint-go~: $(shell $(LSFILES) '*.go' 2> /dev/null) | $(REVIVE) ; $(info $(M) running golint…)
|
||||||
$Q $(REVIVE) -formatter friendly -set_exit_status ./...
|
$Q $(REVIVE) -formatter friendly -set_exit_status ./...
|
||||||
$Q touch $@
|
$Q touch $@
|
||||||
.lint-js~: $(shell $(LSFILES) '*.js' '*.vue' '*.html' 2> /dev/null)
|
.lint-js~: $(shell $(LSFILES) '*.js' '*.ts' '*.vue' '*.html' 2> /dev/null)
|
||||||
.lint-js~: $(GENERATED_JS) ; $(info $(M) running jslint…)
|
.lint-js~: $(GENERATED_JS) ; $(info $(M) running jslint…)
|
||||||
$Q cd console/frontend && npm run --silent lint
|
$Q cd console/frontend && npm run --silent lint
|
||||||
$Q touch $@
|
$Q touch $@
|
||||||
@@ -183,7 +183,7 @@ fmt: .fmt-go~ .fmt-js~ ## Format all source files
|
|||||||
.fmt-go~: $(shell $(LSFILES) '*.go' 2> /dev/null) | $(GOIMPORTS) ; $(info $(M) formatting Go code…)
|
.fmt-go~: $(shell $(LSFILES) '*.go' 2> /dev/null) | $(GOIMPORTS) ; $(info $(M) formatting Go code…)
|
||||||
$Q $(GOIMPORTS) -local $(MODULE) -w $? < /dev/null
|
$Q $(GOIMPORTS) -local $(MODULE) -w $? < /dev/null
|
||||||
$Q touch $@
|
$Q touch $@
|
||||||
.fmt-js~: $(shell $(LSFILES) '*.js' '*.vue' '*.html' 2> /dev/null)
|
.fmt-js~: $(shell $(LSFILES) '*.js' '*.ts' '*.vue' '*.html' 2> /dev/null)
|
||||||
.fmt-js~: $(GENERATED_JS) ; $(info $(M) formatting JS code…)
|
.fmt-js~: $(GENERATED_JS) ; $(info $(M) formatting JS code…)
|
||||||
$Q cd console/frontend && npm run --silent format
|
$Q cd console/frontend && npm run --silent format
|
||||||
$Q touch $@
|
$Q touch $@
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ details.
|
|||||||
- 🩹 *console*: use configured dimensions limit for “Visualize” tab
|
- 🩹 *console*: use configured dimensions limit for “Visualize” tab
|
||||||
- 🌱 *inlet*: optimize BMP collector (see above)
|
- 🌱 *inlet*: optimize BMP collector (see above)
|
||||||
- 🌱 *inlet*: replace LRU cache for classifiers by a time-based cache
|
- 🌱 *inlet*: replace LRU cache for classifiers by a time-based cache
|
||||||
- 🌱 *console*: <kbd>Ctrl-Enter</kbd> or <kbd>Cmd-Enter</kbd> when editing a filter now applies the changes.
|
- 🌱 *console*: <kbd>Ctrl-Enter</kbd> or <kbd>Cmd-Enter</kbd> when editing a filter now applies the changes
|
||||||
|
- 🌱 *console*: switch to TypeScript for the frontend code
|
||||||
|
|
||||||
## 1.6.2 - 2022-11-03
|
## 1.6.2 - 2022-11-03
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ module.exports = {
|
|||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 2021,
|
ecmaVersion: 2021,
|
||||||
},
|
},
|
||||||
extends: ["plugin:vue/vue3-recommended", "eslint:recommended", "prettier"],
|
extends: [
|
||||||
|
"plugin:vue/vue3-recommended",
|
||||||
|
"eslint:recommended",
|
||||||
|
"@vue/eslint-config-typescript",
|
||||||
|
"@vue/eslint-config-prettier",
|
||||||
|
],
|
||||||
rules: {
|
rules: {
|
||||||
"vue/no-unused-vars": "error",
|
"vue/no-unused-vars": "error",
|
||||||
"vue/no-v-html": "off",
|
"vue/no-v-html": "off",
|
||||||
|
|||||||
15
console/frontend/env.d.ts
vendored
Normal file
15
console/frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// Missing types for vue-resizer
|
||||||
|
declare module "vue-resizer" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
declare const ResizeRow: DefineComponent<{
|
||||||
|
sliderWidth?: number;
|
||||||
|
height?: number;
|
||||||
|
width?: number | "auto";
|
||||||
|
sliderColor?: string;
|
||||||
|
sliderBgColor?: string;
|
||||||
|
sliderHoverColor?: string;
|
||||||
|
sliderBgHoverColor?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -10,6 +10,6 @@
|
|||||||
class="h-full bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
class="h-full bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<div id="app" class="h-full"></div>
|
<div id="app" class="h-full"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"include": ["src/**/*.js", "src/**/*.vue"]
|
|
||||||
}
|
|
||||||
5017
console/frontend/package-lock.json
generated
5017
console/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,14 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "run-p type-check build-only",
|
||||||
|
"build-only": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint '{src/**/,}*.{js,vue}'",
|
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||||
"format": "prettier --loglevel warn --write '{src/**/,}*.{js,vue,html}'",
|
"lint": "eslint '{src/**/,}*.{js,ts,vue}'",
|
||||||
"test": "vitest run"
|
"format": "prettier --loglevel warn --write '{src/**/,}*.{ts,js,vue,html}'",
|
||||||
|
"test": "vitest run",
|
||||||
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.0.1",
|
"@codemirror/autocomplete": "^6.0.1",
|
||||||
@@ -35,18 +38,31 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@headlessui/tailwindcss": "^0.1.1",
|
"@headlessui/tailwindcss": "^0.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.2",
|
"@tailwindcss/typography": "^0.5.2",
|
||||||
|
"@types/jsdom": "^20.0.0",
|
||||||
|
"@types/lodash-es": "^4.17.6",
|
||||||
|
"@types/lz-string": "^1.3.34",
|
||||||
|
"@types/node": "^16.11.68",
|
||||||
|
"@types/uuid": "^8.3.4",
|
||||||
"@vitejs/plugin-vue": "^3.0.0",
|
"@vitejs/plugin-vue": "^3.0.0",
|
||||||
"@vitest/coverage-c8": "^0.25.0",
|
"@vitest/coverage-c8": "^0.25.0",
|
||||||
|
"@volar/vue-language-server": "^1.0.9",
|
||||||
|
"@vue/eslint-config-prettier": "^7.0.0",
|
||||||
|
"@vue/eslint-config-typescript": "^11.0.0",
|
||||||
|
"@vue/tsconfig": "^0.1.3",
|
||||||
"autoprefixer": "^10.4.7",
|
"autoprefixer": "^10.4.7",
|
||||||
"eslint": "^8.19.0",
|
"eslint": "^8.19.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
|
||||||
"eslint-plugin-vue": "^9.2.0",
|
"eslint-plugin-vue": "^9.2.0",
|
||||||
"license-compliance": "^1.2.3",
|
"license-compliance": "^1.2.3",
|
||||||
|
"npm-run-all": "^4.1.5",
|
||||||
|
"patch-package": "^6.5.0",
|
||||||
"postcss": "^8.4.14",
|
"postcss": "^8.4.14",
|
||||||
"prettier": "^2.7.0",
|
"prettier": "^2.7.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.1.11",
|
"prettier-plugin-tailwindcss": "^0.1.11",
|
||||||
"tailwindcss": "^3.1.2",
|
"tailwindcss": "^3.1.2",
|
||||||
|
"typescript": "~4.9.3",
|
||||||
|
"typescript-language-server": "^2.1.0",
|
||||||
"vite": "^3.0.0",
|
"vite": "^3.0.0",
|
||||||
"vitest": "^0.25.1"
|
"vitest": "^0.25.1",
|
||||||
|
"vue-tsc": "^1.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
console/frontend/patches/echarts+5.4.0.patch
Normal file
13
console/frontend/patches/echarts+5.4.0.patch
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
diff --git a/node_modules/echarts/types/src/component/tooltip/TooltipView.d.ts b/node_modules/echarts/types/src/component/tooltip/TooltipView.d.ts
|
||||||
|
index 3dafdab..067ea0b 100644
|
||||||
|
--- a/node_modules/echarts/types/src/component/tooltip/TooltipView.d.ts
|
||||||
|
+++ b/node_modules/echarts/types/src/component/tooltip/TooltipView.d.ts
|
||||||
|
@@ -29,7 +29,7 @@ interface HideTipPayload {
|
||||||
|
from?: string;
|
||||||
|
dispatchAction?: ExtensionAPI['dispatchAction'];
|
||||||
|
}
|
||||||
|
-declare type TooltipCallbackDataParams = CallbackDataParams & {
|
||||||
|
+export declare type TooltipCallbackDataParams = CallbackDataParams & {
|
||||||
|
axisDim?: string;
|
||||||
|
axisIndex?: number;
|
||||||
|
axisType?: string;
|
||||||
14
console/frontend/patches/sugar-date+2.0.6.patch
Normal file
14
console/frontend/patches/sugar-date+2.0.6.patch
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
diff --git a/node_modules/sugar-date/sugar.d.ts b/node_modules/sugar-date/sugar.d.ts
|
||||||
|
index 7dc9665..a0d33d3 100644
|
||||||
|
--- a/node_modules/sugar-date/sugar.d.ts
|
||||||
|
+++ b/node_modules/sugar-date/sugar.d.ts
|
||||||
|
@@ -704,4 +1304,9 @@ declare module "sugar" {
|
||||||
|
export = Sugar;
|
||||||
|
}
|
||||||
|
|
||||||
|
+declare module "sugar-date" {
|
||||||
|
+ const Sugar: sugarjs.Sugar;
|
||||||
|
+ export = Sugar;
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
declare var Sugar: sugarjs.Sugar;
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
</ServerConfigProvider>
|
</ServerConfigProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import "./tailwind.css";
|
import "./tailwind.css";
|
||||||
|
|
||||||
import NavigationBar from "@/components/NavigationBar.vue";
|
import NavigationBar from "@/components/NavigationBar.vue";
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2022 Free Mobile
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { EditorState } from "@codemirror/state";
|
import { EditorState } from "@codemirror/state";
|
||||||
import { CompletionContext, autocompletion } from "@codemirror/autocomplete";
|
import { CompletionContext, autocompletion } from "@codemirror/autocomplete";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { filterLanguage, filterCompletion } from ".";
|
import { filterLanguage, filterCompletion } from ".";
|
||||||
|
import type { complete } from "./complete";
|
||||||
|
|
||||||
async function get(doc) {
|
async function get(doc: string) {
|
||||||
let cur = doc.indexOf("|");
|
const cur = doc.indexOf("|");
|
||||||
doc = doc.slice(0, cur) + doc.slice(cur + 1);
|
doc = doc.slice(0, cur) + doc.slice(cur + 1);
|
||||||
let state = EditorState.create({
|
const state = EditorState.create({
|
||||||
doc,
|
doc,
|
||||||
selection: { anchor: cur },
|
selection: { anchor: cur },
|
||||||
extensions: [filterLanguage(), filterCompletion(), autocompletion()],
|
extensions: [filterLanguage(), filterCompletion(), autocompletion()],
|
||||||
});
|
});
|
||||||
return await state.languageDataAt("autocomplete", cur)[0](
|
return await state.languageDataAt<typeof complete>("autocomplete", cur)[0](
|
||||||
new CompletionContext(state, cur, true)
|
new CompletionContext(state, cur, true)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("filter completion", () => {
|
describe("filter completion", () => {
|
||||||
let fetchOptions = {};
|
let fetchOptions: RequestInit = {};
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
fetchOptions = {};
|
fetchOptions = {};
|
||||||
@@ -25,9 +29,13 @@ describe("filter completion", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn((url, options) => {
|
vi.fn((_: string, options: RequestInit) => {
|
||||||
fetchOptions = options;
|
fetchOptions = options;
|
||||||
const body = JSON.parse(options.body);
|
const body: {
|
||||||
|
what: "column" | "operator" | "value";
|
||||||
|
column?: string;
|
||||||
|
prefix?: string;
|
||||||
|
} = JSON.parse(options.body!.toString());
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
async json() {
|
async json() {
|
||||||
@@ -119,9 +127,9 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes column names", async () => {
|
it("completes column names", async () => {
|
||||||
let { from, to, options } = await get("S|");
|
const { from, to, options } = await get("S|");
|
||||||
expect(fetchOptions.method).toEqual("POST");
|
expect(fetchOptions.method).toEqual("POST");
|
||||||
expect(JSON.parse(fetchOptions.body)).toEqual({
|
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
|
||||||
what: "column",
|
what: "column",
|
||||||
prefix: "S",
|
prefix: "S",
|
||||||
});
|
});
|
||||||
@@ -137,9 +145,9 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes inside column names", async () => {
|
it("completes inside column names", async () => {
|
||||||
let { from, to, options } = await get("S|rc =");
|
const { from, to, options } = await get("S|rc =");
|
||||||
expect(fetchOptions.method).toEqual("POST");
|
expect(fetchOptions.method).toEqual("POST");
|
||||||
expect(JSON.parse(fetchOptions.body)).toEqual({
|
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
|
||||||
what: "column",
|
what: "column",
|
||||||
prefix: "Src",
|
prefix: "Src",
|
||||||
});
|
});
|
||||||
@@ -155,8 +163,8 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes operator names", async () => {
|
it("completes operator names", async () => {
|
||||||
let { from, to, options } = await get("SrcAS |");
|
const { from, to, options } = await get("SrcAS |");
|
||||||
expect(JSON.parse(fetchOptions.body)).toEqual({
|
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
|
||||||
what: "operator",
|
what: "operator",
|
||||||
column: "SrcAS",
|
column: "SrcAS",
|
||||||
});
|
});
|
||||||
@@ -172,8 +180,8 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes values", async () => {
|
it("completes values", async () => {
|
||||||
let { from, to, options } = await get("SrcAS = fac|");
|
const { from, to, options } = await get("SrcAS = fac|");
|
||||||
expect(JSON.parse(fetchOptions.body)).toEqual({
|
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
|
||||||
what: "value",
|
what: "value",
|
||||||
column: "SrcAS",
|
column: "SrcAS",
|
||||||
prefix: "fac",
|
prefix: "fac",
|
||||||
@@ -190,8 +198,8 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes quoted values", async () => {
|
it("completes quoted values", async () => {
|
||||||
let { from, to, options } = await get('DstNetName = "so|');
|
const { from, to, options } = await get('DstNetName = "so|');
|
||||||
expect(JSON.parse(fetchOptions.body)).toEqual({
|
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
|
||||||
what: "value",
|
what: "value",
|
||||||
column: "DstNetName",
|
column: "DstNetName",
|
||||||
prefix: "so",
|
prefix: "so",
|
||||||
@@ -206,8 +214,8 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes quoted values even when not quoted", async () => {
|
it("completes quoted values even when not quoted", async () => {
|
||||||
let { from, to, options } = await get("DstNetName = so|");
|
const { from, to, options } = await get("DstNetName = so|");
|
||||||
expect(JSON.parse(fetchOptions.body)).toEqual({
|
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
|
||||||
what: "value",
|
what: "value",
|
||||||
column: "DstNetName",
|
column: "DstNetName",
|
||||||
prefix: "so",
|
prefix: "so",
|
||||||
@@ -222,7 +230,7 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes logic operator", async () => {
|
it("completes logic operator", async () => {
|
||||||
let { from, to, options } = await get("SrcAS = 1000 A|");
|
const { from, to, options } = await get("SrcAS = 1000 A|");
|
||||||
expect(fetchOptions).toEqual({});
|
expect(fetchOptions).toEqual({});
|
||||||
expect({ from, to, options }).toEqual({
|
expect({ from, to, options }).toEqual({
|
||||||
from: 13,
|
from: 13,
|
||||||
@@ -237,7 +245,7 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not complete comments", async () => {
|
it("does not complete comments", async () => {
|
||||||
let { from, to, options } = await get("SrcAS = 1000 -- h|");
|
const { from, to, options } = await get("SrcAS = 1000 -- h|");
|
||||||
expect(fetchOptions).toEqual({});
|
expect(fetchOptions).toEqual({});
|
||||||
expect({ from, to, options }).toEqual({
|
expect({ from, to, options }).toEqual({
|
||||||
from: 17,
|
from: 17,
|
||||||
@@ -247,8 +255,8 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes inside operator", async () => {
|
it("completes inside operator", async () => {
|
||||||
let { from, to, options } = await get("SrcAS I|");
|
const { from, to, options } = await get("SrcAS I|");
|
||||||
expect(JSON.parse(fetchOptions.body)).toEqual({
|
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
|
||||||
what: "operator",
|
what: "operator",
|
||||||
prefix: "I",
|
prefix: "I",
|
||||||
column: "SrcAS",
|
column: "SrcAS",
|
||||||
@@ -261,15 +269,13 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes empty list of values", async () => {
|
it("completes empty list of values", async () => {
|
||||||
let { from, to, options } = await get("SrcAS IN (|");
|
const { from, to, options } = await get("SrcAS IN (|");
|
||||||
expect(JSON.parse(fetchOptions.body)).toEqual({
|
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
|
||||||
what: "value",
|
what: "value",
|
||||||
column: "SrcAS",
|
column: "SrcAS",
|
||||||
prefix: null,
|
|
||||||
});
|
});
|
||||||
expect({ from, to, options }).toEqual({
|
expect({ from, to, options }).toEqual({
|
||||||
from: 10,
|
from: 10,
|
||||||
to: null,
|
|
||||||
options: [
|
options: [
|
||||||
{ apply: "AS65403, ", detail: "AS number", label: "AS65403" },
|
{ apply: "AS65403, ", detail: "AS number", label: "AS65403" },
|
||||||
{ apply: "AS65404, ", detail: "AS number", label: "AS65404" },
|
{ apply: "AS65404, ", detail: "AS number", label: "AS65404" },
|
||||||
@@ -279,15 +285,13 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes non-empty list of values", async () => {
|
it("completes non-empty list of values", async () => {
|
||||||
let { from, to, options } = await get("SrcAS IN (100,|");
|
const { from, to, options } = await get("SrcAS IN (100,|");
|
||||||
expect(JSON.parse(fetchOptions.body)).toEqual({
|
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
|
||||||
what: "value",
|
what: "value",
|
||||||
column: "SrcAS",
|
column: "SrcAS",
|
||||||
prefix: null,
|
|
||||||
});
|
});
|
||||||
expect({ from, to, options }).toEqual({
|
expect({ from, to, options }).toEqual({
|
||||||
from: 14,
|
from: 14,
|
||||||
to: null,
|
|
||||||
options: [
|
options: [
|
||||||
{ apply: " AS65403, ", detail: "AS number", label: "AS65403" },
|
{ apply: " AS65403, ", detail: "AS number", label: "AS65403" },
|
||||||
{ apply: " AS65404, ", detail: "AS number", label: "AS65404" },
|
{ apply: " AS65404, ", detail: "AS number", label: "AS65404" },
|
||||||
@@ -297,8 +301,8 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes NOT", async () => {
|
it("completes NOT", async () => {
|
||||||
let { from, to, options } = await get("SrcAS = 100 AND |");
|
const { from, to, options } = await get("SrcAS = 100 AND |");
|
||||||
expect(JSON.parse(fetchOptions.body)).toEqual({
|
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
|
||||||
what: "column",
|
what: "column",
|
||||||
});
|
});
|
||||||
expect({ from, to, options }).toEqual({
|
expect({ from, to, options }).toEqual({
|
||||||
@@ -317,8 +321,8 @@ describe("filter completion", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes column after logic operator", async () => {
|
it("completes column after logic operator", async () => {
|
||||||
let { from, to, options } = await get("SrcAS = 100 AND S|");
|
const { from, to, options } = await get("SrcAS = 100 AND S|");
|
||||||
expect(JSON.parse(fetchOptions.body)).toEqual({
|
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
|
||||||
what: "column",
|
what: "column",
|
||||||
prefix: "S",
|
prefix: "S",
|
||||||
});
|
});
|
||||||
@@ -3,24 +3,36 @@
|
|||||||
|
|
||||||
import { syntaxTree } from "@codemirror/language";
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
|
||||||
export const complete = async (ctx) => {
|
import type {
|
||||||
|
CompletionContext,
|
||||||
|
CompletionResult,
|
||||||
|
} from "@codemirror/autocomplete";
|
||||||
|
import type { SyntaxNode } from "@lezer/common";
|
||||||
|
type apiCompleteResult = {
|
||||||
|
completions: Array<{ label: string; detail?: string; quoted: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const complete = async (ctx: CompletionContext) => {
|
||||||
const tree = syntaxTree(ctx.state);
|
const tree = syntaxTree(ctx.state);
|
||||||
|
|
||||||
let completion = {
|
const completion: CompletionResult = {
|
||||||
from: ctx.pos,
|
from: ctx.pos,
|
||||||
filter: false,
|
filter: false,
|
||||||
options: [],
|
options: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remote completion
|
// Remote completion
|
||||||
const remote = async (payload, transform = (x) => x) => {
|
const remote = async (
|
||||||
|
payload: { what: string; column?: string; prefix?: string },
|
||||||
|
transform = (x: { label: string; detail?: string }) => x
|
||||||
|
) => {
|
||||||
const response = await fetch("/api/v0/console/filter/complete", {
|
const response = await fetch("/api/v0/console/filter/complete", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const data = await response.json();
|
const data: apiCompleteResult = await response.json();
|
||||||
completion.options = [
|
completion.options = [
|
||||||
...completion.options,
|
...completion.options,
|
||||||
...(data.completions ?? []).map(({ label, detail, quoted }) =>
|
...(data.completions ?? []).map(({ label, detail, quoted }) =>
|
||||||
@@ -33,7 +45,7 @@ export const complete = async (ctx) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Some helpers to match nodes.
|
// Some helpers to match nodes.
|
||||||
const nodeAncestor = (node, names) => {
|
const nodeAncestor = (node: SyntaxNode | null, names: string[]) => {
|
||||||
for (let n = node; n; n = n.parent) {
|
for (let n = node; n; n = n.parent) {
|
||||||
if (names.includes(n.name)) {
|
if (names.includes(n.name)) {
|
||||||
return n;
|
return n;
|
||||||
@@ -41,7 +53,7 @@ export const complete = async (ctx) => {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
const nodePrevSibling = (node) => {
|
const nodePrevSibling = (node: SyntaxNode | null) => {
|
||||||
for (let n = node?.prevSibling; n; n = n.prevSibling) {
|
for (let n = node?.prevSibling; n; n = n.prevSibling) {
|
||||||
if (!["LineComment", "BlockComment"].includes(n.name)) {
|
if (!["LineComment", "BlockComment"].includes(n.name)) {
|
||||||
return n;
|
return n;
|
||||||
@@ -49,10 +61,11 @@ export const complete = async (ctx) => {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
const nodeRightMostChildBefore = (node, pos) => {
|
const nodeRightMostChildBefore = (node: SyntaxNode | null, pos: number) => {
|
||||||
// Go to the right most child
|
// Go to the right most child
|
||||||
let n = node;
|
let n = node;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
if (!n) break;
|
||||||
if (!n.lastChild) {
|
if (!n.lastChild) {
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
@@ -60,13 +73,12 @@ export const complete = async (ctx) => {
|
|||||||
while (n && n.to > pos) {
|
while (n && n.to > pos) {
|
||||||
n = n.prevSibling;
|
n = n.prevSibling;
|
||||||
}
|
}
|
||||||
if (!n) break;
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let nodeBefore = tree.resolve(ctx.pos, -1);
|
let nodeBefore: SyntaxNode | null = tree.resolve(ctx.pos, -1);
|
||||||
let n = null;
|
let n: SyntaxNode | null = null;
|
||||||
if (["LineComment", "BlockComment"].includes(nodeBefore.name)) {
|
if (["LineComment", "BlockComment"].includes(nodeBefore.name)) {
|
||||||
// Do not complete !
|
// Do not complete !
|
||||||
} else if ((n = nodeAncestor(nodeBefore, ["Column"]))) {
|
} else if ((n = nodeAncestor(nodeBefore, ["Column"]))) {
|
||||||
@@ -93,16 +105,19 @@ export const complete = async (ctx) => {
|
|||||||
) {
|
) {
|
||||||
const c = nodePrevSibling(nodePrevSibling(n));
|
const c = nodePrevSibling(nodePrevSibling(n));
|
||||||
if (c?.name === "Column") {
|
if (c?.name === "Column") {
|
||||||
let prefix = ctx.state.sliceDoc(nodeBefore.from, nodeBefore.to);
|
let prefix: string | undefined = ctx.state.sliceDoc(
|
||||||
|
nodeBefore.from,
|
||||||
|
nodeBefore.to
|
||||||
|
);
|
||||||
completion.from = nodeBefore.from;
|
completion.from = nodeBefore.from;
|
||||||
completion.to = nodeBefore.to;
|
completion.to = nodeBefore.to;
|
||||||
if (
|
if (
|
||||||
["ValueLParen", "ValueComma", "ListOfValues"].includes(nodeBefore.name)
|
["ValueLParen", "ValueComma", "ListOfValues"].includes(nodeBefore.name)
|
||||||
) {
|
) {
|
||||||
// Empty term
|
// Empty term
|
||||||
prefix = null;
|
prefix = undefined;
|
||||||
completion.from = ctx.pos;
|
completion.from = ctx.pos;
|
||||||
completion.to = null;
|
completion.to = undefined;
|
||||||
} else if (nodeBefore.name === "String") {
|
} else if (nodeBefore.name === "String") {
|
||||||
prefix = prefix.replace(/^["']/, "").replace(/["']$/, "");
|
prefix = prefix.replace(/^["']/, "").replace(/["']$/, "");
|
||||||
}
|
}
|
||||||
@@ -149,10 +164,13 @@ export const complete = async (ctx) => {
|
|||||||
];
|
];
|
||||||
} else if ((n = nodeAncestor(nodeBefore, ["Or", "And", "Not"]))) {
|
} else if ((n = nodeAncestor(nodeBefore, ["Or", "And", "Not"]))) {
|
||||||
if (n.name !== "Not") {
|
if (n.name !== "Not") {
|
||||||
completion.options.push({
|
completion.options = [
|
||||||
label: "NOT",
|
...completion.options,
|
||||||
detail: "logic operator",
|
{
|
||||||
});
|
label: "NOT",
|
||||||
|
detail: "logic operator",
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
await remote({ what: "column" });
|
await remote({ what: "column" });
|
||||||
}
|
}
|
||||||
@@ -160,7 +178,7 @@ export const complete = async (ctx) => {
|
|||||||
|
|
||||||
completion.options.forEach((option) => {
|
completion.options.forEach((option) => {
|
||||||
const from = completion.from;
|
const from = completion.from;
|
||||||
option.apply = option.apply ?? option.label;
|
option.apply = (option.apply as string) ?? option.label;
|
||||||
// Insert space before if no space or "("
|
// Insert space before if no space or "("
|
||||||
if (
|
if (
|
||||||
completion.from > 0 &&
|
completion.from > 0 &&
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2022 Free Mobile
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { parser } from "./syntax.grammar";
|
import { parser } from "./syntax.grammar";
|
||||||
import { fileTests } from "@lezer/generator/dist/test";
|
import { fileTests } from "@lezer/generator/dist/test";
|
||||||
import { describe, it } from "vitest";
|
import { describe, it } from "vitest";
|
||||||
@@ -11,7 +14,7 @@ const caseFile = path.join(
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe("filter parsing", () => {
|
describe("filter parsing", () => {
|
||||||
for (let { name, run } of fileTests(
|
for (const { name, run } of fileTests(
|
||||||
fs.readFileSync(caseFile, "utf8"),
|
fs.readFileSync(caseFile, "utf8"),
|
||||||
"grammar.test.txt"
|
"grammar.test.txt"
|
||||||
))
|
))
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2022 Free Mobile
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import { syntaxTree } from "@codemirror/language";
|
|
||||||
|
|
||||||
export const linterSource = async (view) => {
|
|
||||||
const code = view.state.doc.toString();
|
|
||||||
const response = await fetch("/api/v0/console/filter/validate", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ filter: code }),
|
|
||||||
});
|
|
||||||
if (!response.ok) return [];
|
|
||||||
const data = await response.json();
|
|
||||||
const diagnostic =
|
|
||||||
data.errors?.map(({ offset, message }) => {
|
|
||||||
const syntaxNode = syntaxTree(view.state).resolve(offset, 1);
|
|
||||||
const word = view.state.wordAt(offset);
|
|
||||||
const { from, to } = {
|
|
||||||
from:
|
|
||||||
(syntaxNode.name !== "Filter" && syntaxNode?.from) ||
|
|
||||||
word?.from ||
|
|
||||||
offset,
|
|
||||||
to:
|
|
||||||
(syntaxNode.name !== "Filter" && syntaxNode?.to) ||
|
|
||||||
word?.to ||
|
|
||||||
offset,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
from: from === to ? from - 1 : from,
|
|
||||||
to,
|
|
||||||
severity: "error",
|
|
||||||
message: message,
|
|
||||||
};
|
|
||||||
}) || [];
|
|
||||||
return diagnostic;
|
|
||||||
};
|
|
||||||
40
console/frontend/src/codemirror/lang-filter/linter.ts
Normal file
40
console/frontend/src/codemirror/lang-filter/linter.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2022 Free Mobile
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
|
||||||
|
export const linterSource = async (view: EditorView) => {
|
||||||
|
const code = view.state.doc.toString();
|
||||||
|
const response = await fetch("/api/v0/console/filter/validate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ filter: code }),
|
||||||
|
});
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const data = await response.json();
|
||||||
|
const diagnostic =
|
||||||
|
data.errors?.map(
|
||||||
|
({ offset, message }: { offset: number; message: string }) => {
|
||||||
|
const syntaxNode = syntaxTree(view.state).resolve(offset, 1);
|
||||||
|
const word = view.state.wordAt(offset);
|
||||||
|
const { from, to } = {
|
||||||
|
from:
|
||||||
|
(syntaxNode.name !== "Filter" && syntaxNode?.from) ||
|
||||||
|
word?.from ||
|
||||||
|
offset,
|
||||||
|
to:
|
||||||
|
(syntaxNode.name !== "Filter" && syntaxNode?.to) ||
|
||||||
|
word?.to ||
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
from: from === to ? from - 1 : from,
|
||||||
|
to,
|
||||||
|
severity: "error",
|
||||||
|
message: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
) || [];
|
||||||
|
return diagnostic;
|
||||||
|
};
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2022 Free Mobile
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
@top Filter {
|
@top Filter {
|
||||||
expression
|
expression
|
||||||
}
|
}
|
||||||
|
|||||||
6
console/frontend/src/codemirror/lang-filter/syntax.grammar.d.ts
vendored
Normal file
6
console/frontend/src/codemirror/lang-filter/syntax.grammar.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2022 Free Mobile
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { LRParser } from "@lezer/lr";
|
||||||
|
|
||||||
|
export declare const parser: LRParser;
|
||||||
@@ -12,8 +12,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import { inject } from "vue";
|
import { inject } from "vue";
|
||||||
import { SunIcon, MoonIcon } from "@heroicons/vue/solid";
|
import { SunIcon, MoonIcon } from "@heroicons/vue/solid";
|
||||||
const { isDark, toggleDark } = inject("theme");
|
import { ThemeKey } from "@/components/ThemeProvider.vue";
|
||||||
|
const { isDark, toggleDark } = inject(ThemeKey)!;
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,31 +8,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
kind: {
|
|
||||||
type: String,
|
|
||||||
default: "error",
|
|
||||||
validator(value) {
|
|
||||||
return ["info", "danger", "success", "warning"].includes(value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { InformationCircleIcon } from "@heroicons/vue/solid";
|
import { InformationCircleIcon } from "@heroicons/vue/solid";
|
||||||
|
|
||||||
const classes = computed(() => {
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
kind?: "info" | "error" | "success" | "warning";
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
kind: "error",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const classes = computed<string>(() => {
|
||||||
switch (props.kind) {
|
switch (props.kind) {
|
||||||
case "info":
|
case "info":
|
||||||
return "text-blue-700 bg-blue-100 dark:bg-blue-200 dark:text-blue-800";
|
return "text-blue-700 bg-blue-100 dark:bg-blue-200 dark:text-blue-800";
|
||||||
case "danger":
|
case "error":
|
||||||
return "text-red-700 bg-red-100 dark:bg-red-200 dark:text-red-800";
|
return "text-red-700 bg-red-100 dark:bg-red-200 dark:text-red-800";
|
||||||
case "success":
|
case "success":
|
||||||
return "text-green-700 bg-green-100 dark:bg-green-200 dark:text-green-800";
|
return "text-green-700 bg-green-100 dark:bg-green-200 dark:text-green-800";
|
||||||
case "warning":
|
case "warning":
|
||||||
return "text-yellow-700 bg-yellow-100 dark:bg-yellow-200 dark:text-yellow-800";
|
return "text-yellow-700 bg-yellow-100 dark:bg-yellow-200 dark:text-yellow-800";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
return "";
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -33,18 +33,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
label: "",
|
||||||
|
error: "",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -38,38 +38,23 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
defineProps({
|
|
||||||
attrType: {
|
|
||||||
type: String,
|
|
||||||
default: "button",
|
|
||||||
validator(value) {
|
|
||||||
return ["submit", "button", "reset"].includes(value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
default: "primary",
|
|
||||||
validator(value) {
|
|
||||||
return ["alternative", "primary", "warning", "danger"].includes(value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
type: String,
|
|
||||||
default: "normal",
|
|
||||||
validator(value) {
|
|
||||||
return ["normal", "small"].includes(value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
attrType?: "submit" | "button" | "reset";
|
||||||
|
type?: "alternative" | "primary" | "warning" | "danger";
|
||||||
|
size?: "normal" | "small";
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
attrType: "button",
|
||||||
|
type: "primary",
|
||||||
|
size: "normal",
|
||||||
|
disabled: false,
|
||||||
|
floading: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
<!-- SPDX-FileCopyrightText: 2022 Free Mobile -->
|
||||||
|
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<label :for="id" class="flex items-center">
|
<label :for="id" class="flex items-center">
|
||||||
@@ -6,7 +9,12 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="modelValue"
|
:checked="modelValue"
|
||||||
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-blue-600"
|
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-blue-600"
|
||||||
@change="$emit('update:modelValue', $event.target.checked)"
|
@change="
|
||||||
|
$emit(
|
||||||
|
'update:modelValue',
|
||||||
|
($event.target as HTMLInputElement).checked
|
||||||
|
)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<span class="ml-1 text-sm font-medium text-gray-900 dark:text-gray-300">
|
<span class="ml-1 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
@@ -15,19 +23,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
modelValue: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
defineEmits(["update:modelValue"]);
|
|
||||||
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
label: string;
|
||||||
|
modelValue: boolean;
|
||||||
|
}>();
|
||||||
|
defineEmits<{
|
||||||
|
(e: "update:modelValue", value: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,17 +9,20 @@
|
|||||||
role="group"
|
role="group"
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
v-for="({ name, label: blabel }, idx) in choices"
|
v-for="(choice, idx) in choices"
|
||||||
:key="name"
|
:key="choice.name"
|
||||||
:for="id(name)"
|
:for="id(choice.name)"
|
||||||
class="cursor-pointer first:rounded-l-md last:rounded-r-md focus-within:z-10 focus-within:ring-2 focus-within:ring-blue-300 dark:focus-within:ring-blue-800"
|
class="cursor-pointer first:rounded-l-md last:rounded-r-md focus-within:z-10 focus-within:ring-2 focus-within:ring-blue-300 dark:focus-within:ring-blue-800"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
:id="id(name)"
|
:id="id(choice.name)"
|
||||||
type="radio"
|
type="radio"
|
||||||
:checked="modelValue === name"
|
:checked="modelValue === choice.name"
|
||||||
class="peer sr-only"
|
class="peer sr-only"
|
||||||
@change="$event.target.checked && $emit('update:modelValue', name)"
|
@change="
|
||||||
|
($event.target as HTMLInputElement).checked &&
|
||||||
|
$emit('update:modelValue', choice.name)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
@@ -28,31 +31,25 @@
|
|||||||
}"
|
}"
|
||||||
class="border-t border-b border-gray-200 bg-white py-0.5 px-1 text-sm font-medium text-gray-900 hover:bg-gray-100 hover:text-blue-700 peer-checked:bg-blue-700 peer-checked:bg-blue-700 peer-checked:text-white peer-checked:hover:bg-blue-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:hover:text-white peer-checked:dark:bg-blue-600 peer-checked:dark:hover:bg-blue-700"
|
class="border-t border-b border-gray-200 bg-white py-0.5 px-1 text-sm font-medium text-gray-900 hover:bg-gray-100 hover:text-blue-700 peer-checked:bg-blue-700 peer-checked:bg-blue-700 peer-checked:text-white peer-checked:hover:bg-blue-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:hover:text-white peer-checked:dark:bg-blue-600 peer-checked:dark:hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
{{ blabel }}
|
{{ choice.label }}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
choices: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
defineEmits(["update:modelValue"]);
|
|
||||||
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
label: string;
|
||||||
|
choices: Array<{ name: string; label: string }>;
|
||||||
|
modelValue: string;
|
||||||
|
}>();
|
||||||
|
defineEmits<{
|
||||||
|
(e: "update:modelValue", value: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
const baseID = uuidv4();
|
const baseID = uuidv4();
|
||||||
const id = (name) => `${baseID}-${name}`;
|
const id = (name: string) => `${baseID}-${name}`;
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -50,33 +50,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
// selected: selected dimensions (names)
|
|
||||||
// limit: limit as an integer
|
|
||||||
// errors: is there an input error?
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
minDimensions: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
|
||||||
|
|
||||||
import { ref, watch, computed, inject } from "vue";
|
import { ref, watch, computed, inject } from "vue";
|
||||||
import draggable from "vuedraggable";
|
import draggable from "vuedraggable";
|
||||||
import { XIcon, SelectorIcon } from "@heroicons/vue/solid";
|
import { XIcon, SelectorIcon } from "@heroicons/vue/solid";
|
||||||
import { dataColor } from "@/utils";
|
import { dataColor } from "@/utils";
|
||||||
import InputString from "@/components/InputString.vue";
|
import InputString from "@/components/InputString.vue";
|
||||||
import InputListBox from "@/components/InputListBox.vue";
|
import InputListBox from "@/components/InputListBox.vue";
|
||||||
|
import { ServerConfigKey } from "@/components/ServerConfigProvider.vue";
|
||||||
import fields from "@data/fields.json";
|
import fields from "@data/fields.json";
|
||||||
import { isEqual } from "lodash-es";
|
import { isEqual } from "lodash-es";
|
||||||
|
|
||||||
const serverConfiguration = inject("server-configuration");
|
const props = withDefaults(
|
||||||
const selectedDimensions = ref([]);
|
defineProps<{
|
||||||
|
modelValue: ModelType;
|
||||||
|
minDimensions?: number;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
minDimensions: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: typeof props.modelValue): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const serverConfiguration = inject(ServerConfigKey)!;
|
||||||
|
const selectedDimensions = ref<Array<typeof dimensions[0]>>([]);
|
||||||
const dimensionsError = computed(() => {
|
const dimensionsError = computed(() => {
|
||||||
if (selectedDimensions.value.length < props.minDimensions) {
|
if (selectedDimensions.value.length < props.minDimensions) {
|
||||||
return "At least two dimensions are required";
|
return "At least two dimensions are required";
|
||||||
@@ -110,7 +109,7 @@ const dimensions = fields.map((v, idx) => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const removeDimension = (dimension) => {
|
const removeDimension = (dimension: typeof dimensions[0]) => {
|
||||||
selectedDimensions.value = selectedDimensions.value.filter(
|
selectedDimensions.value = selectedDimensions.value.filter(
|
||||||
(d) => d !== dimension
|
(d) => d !== dimension
|
||||||
);
|
);
|
||||||
@@ -119,19 +118,22 @@ const removeDimension = (dimension) => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(value) => {
|
(value) => {
|
||||||
limit.value = value.limit.toString();
|
if (value) {
|
||||||
selectedDimensions.value = value.selected
|
limit.value = value.limit.toString();
|
||||||
.map((name) => dimensions.find((d) => d.name === name))
|
}
|
||||||
.filter((d) => d !== undefined);
|
if (value)
|
||||||
|
selectedDimensions.value = value.selected
|
||||||
|
.map((name) => dimensions.find((d) => d.name === name))
|
||||||
|
.filter((d): d is typeof dimensions[0] => !!d);
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
);
|
);
|
||||||
watch(
|
watch(
|
||||||
[selectedDimensions, limit, hasErrors],
|
[selectedDimensions, limit, hasErrors] as const,
|
||||||
([selected, limit, hasErrors]) => {
|
([selected, limit, hasErrors]) => {
|
||||||
const updated = {
|
const updated = {
|
||||||
selected: selected.map((d) => d.name),
|
selected: selected.map((d) => d.name),
|
||||||
limit: parseInt(limit) || limit,
|
limit: parseInt(limit) || 10,
|
||||||
errors: hasErrors,
|
errors: hasErrors,
|
||||||
};
|
};
|
||||||
if (!isEqual(updated, props.modelValue)) {
|
if (!isEqual(updated, props.modelValue)) {
|
||||||
@@ -140,3 +142,11 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export type ModelType = {
|
||||||
|
selected: string[];
|
||||||
|
limit: number;
|
||||||
|
errors?: boolean;
|
||||||
|
} | null;
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -63,65 +63,22 @@
|
|||||||
</InputListBox>
|
</InputListBox>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts" setup>
|
||||||
export default {
|
|
||||||
inheritAttrs: false,
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
// expression: filter expression
|
|
||||||
// errors: boolean if there are errors
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const emit = defineEmits(["update:modelValue", "submit"]);
|
|
||||||
|
|
||||||
import { ref, inject, watch, computed, onMounted, onBeforeUnmount } from "vue";
|
import { ref, inject, watch, computed, onMounted, onBeforeUnmount } from "vue";
|
||||||
import { useFetch } from "@vueuse/core";
|
import { useFetch } from "@vueuse/core";
|
||||||
|
import { TrashIcon, EyeIcon, EyeOffIcon } from "@heroicons/vue/solid";
|
||||||
import InputBase from "@/components/InputBase.vue";
|
import InputBase from "@/components/InputBase.vue";
|
||||||
const { isDark } = inject("theme");
|
|
||||||
const { user: currentUser } = inject("user");
|
|
||||||
|
|
||||||
// # Saved filters
|
|
||||||
import InputListBox from "@/components/InputListBox.vue";
|
import InputListBox from "@/components/InputListBox.vue";
|
||||||
import InputButton from "@/components/InputButton.vue";
|
import InputButton from "@/components/InputButton.vue";
|
||||||
import { TrashIcon, EyeIcon, EyeOffIcon } from "@heroicons/vue/solid";
|
import { ThemeKey } from "@/components/ThemeProvider.vue";
|
||||||
|
import { UserKey } from "@/components/UserProvider.vue";
|
||||||
|
|
||||||
const selectedSavedFilter = ref({});
|
import {
|
||||||
const { data: rawSavedFilters, execute: refreshSavedFilters } = useFetch(
|
EditorState,
|
||||||
`/api/v0/console/filter/saved`
|
StateEffect,
|
||||||
).json();
|
Compartment,
|
||||||
const savedFilters = computed(() => rawSavedFilters.value?.filters ?? []);
|
type Extension,
|
||||||
watch(selectedSavedFilter, (filter) => {
|
} from "@codemirror/state";
|
||||||
if (!filter.content) return;
|
|
||||||
expression.value = filter.content;
|
|
||||||
selectedSavedFilter.value = {};
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteFilter = async (id) => {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/v0/console/filter/saved/${id}`, { method: "DELETE" });
|
|
||||||
} finally {
|
|
||||||
refreshSavedFilters();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const addFilter = async ({ description, shared }) => {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/v0/console/filter/saved`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ description, shared, content: expression.value }),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
refreshSavedFilters();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// # Editor
|
|
||||||
import { EditorState, StateEffect, Compartment } from "@codemirror/state";
|
|
||||||
import { EditorView, keymap, placeholder } from "@codemirror/view";
|
import { EditorView, keymap, placeholder } from "@codemirror/view";
|
||||||
import { syntaxHighlighting, HighlightStyle } from "@codemirror/language";
|
import { syntaxHighlighting, HighlightStyle } from "@codemirror/language";
|
||||||
import { standardKeymap, history } from "@codemirror/commands";
|
import { standardKeymap, history } from "@codemirror/commands";
|
||||||
@@ -135,16 +92,75 @@ import {
|
|||||||
} from "@/codemirror/lang-filter";
|
} from "@/codemirror/lang-filter";
|
||||||
import { isEqual } from "lodash-es";
|
import { isEqual } from "lodash-es";
|
||||||
|
|
||||||
const elEditor = ref(null);
|
const props = defineProps<{
|
||||||
|
modelValue: ModelType;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: typeof props.modelValue): void;
|
||||||
|
(e: "submit"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { isDark } = inject(ThemeKey)!;
|
||||||
|
const { user: currentUser } = inject(UserKey)!;
|
||||||
|
|
||||||
|
// # Saved filters
|
||||||
|
type SavedFilter = {
|
||||||
|
id: number;
|
||||||
|
user: string;
|
||||||
|
shared: boolean;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedSavedFilter = ref<SavedFilter | null>(null);
|
||||||
|
const { data: rawSavedFilters, execute: refreshSavedFilters } = useFetch(
|
||||||
|
`/api/v0/console/filter/saved`
|
||||||
|
).json<{
|
||||||
|
filters: Array<SavedFilter>;
|
||||||
|
}>();
|
||||||
|
const savedFilters = computed(() => rawSavedFilters.value?.filters ?? []);
|
||||||
|
watch(selectedSavedFilter, (filter) => {
|
||||||
|
if (!filter?.content) return;
|
||||||
|
expression.value = filter.content;
|
||||||
|
selectedSavedFilter.value = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteFilter = async (id: SavedFilter["id"]) => {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/v0/console/filter/saved/${id}`, { method: "DELETE" });
|
||||||
|
} finally {
|
||||||
|
refreshSavedFilters();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const addFilter = async ({
|
||||||
|
description,
|
||||||
|
shared,
|
||||||
|
}: Pick<SavedFilter, "description" | "shared">) => {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/v0/console/filter/saved`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ description, shared, content: expression.value }),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
refreshSavedFilters();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// # Editor
|
||||||
|
const elEditor = ref<HTMLDivElement | null>(null);
|
||||||
const expression = ref(""); // Keep in sync with modelValue.expression
|
const expression = ref(""); // Keep in sync with modelValue.expression
|
||||||
const error = ref(""); // Keep in sync with modelValue.errors
|
const error = ref(""); // Keep in sync with modelValue.errors
|
||||||
const component = {
|
let component:
|
||||||
|
| { view: EditorView; state: EditorState }
|
||||||
|
| { view: null; state: null } = {
|
||||||
view: null,
|
view: null,
|
||||||
state: null,
|
state: null,
|
||||||
};
|
};
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(model) => (expression.value = model.expression),
|
(model) => {
|
||||||
|
if (model) expression.value = model.expression;
|
||||||
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
watch(
|
watch(
|
||||||
@@ -160,7 +176,7 @@ watch(
|
|||||||
// https://github.com/surmon-china/vue-codemirror/blob/59598ff72327ab6c5ee70a640edc9e2eb2518775/src/codemirror.ts#L52
|
// https://github.com/surmon-china/vue-codemirror/blob/59598ff72327ab6c5ee70a640edc9e2eb2518775/src/codemirror.ts#L52
|
||||||
const rerunExtension = () => {
|
const rerunExtension = () => {
|
||||||
const compartment = new Compartment();
|
const compartment = new Compartment();
|
||||||
const run = (view, extension) => {
|
const run = (view: EditorView, extension: Extension) => {
|
||||||
if (compartment.get(view.state)) {
|
if (compartment.get(view.state)) {
|
||||||
// reconfigure
|
// reconfigure
|
||||||
view.dispatch({ effects: compartment.reconfigure(extension) });
|
view.dispatch({ effects: compartment.reconfigure(extension) });
|
||||||
@@ -188,14 +204,15 @@ const filterTheme = computed(() => [
|
|||||||
EditorView.theme({}, { dark: isDark.value }),
|
EditorView.theme({}, { dark: isDark.value }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const submitFilter = () => {
|
const submitFilter = (_: EditorView): boolean => {
|
||||||
emit("submit");
|
emit("submit");
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Create Code mirror instance
|
// Create Code mirror instance
|
||||||
component.state = EditorState.create({
|
const state = EditorState.create({
|
||||||
doc: props.modelValue.expression,
|
doc: props.modelValue?.expression ?? "",
|
||||||
extensions: [
|
extensions: [
|
||||||
filterLanguage(),
|
filterLanguage(),
|
||||||
filterCompletion(),
|
filterCompletion(),
|
||||||
@@ -235,16 +252,20 @@ onMounted(() => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
component.view = new EditorView({
|
const view = new EditorView({
|
||||||
state: component.state,
|
state: state,
|
||||||
parent: elEditor.value,
|
parent: elEditor.value!,
|
||||||
});
|
});
|
||||||
|
component = {
|
||||||
|
state,
|
||||||
|
view,
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
expression,
|
expression,
|
||||||
(expression) => {
|
(expression) => {
|
||||||
if (expression !== component.view.state.doc.toString()) {
|
if (expression !== component.view?.state.doc.toString()) {
|
||||||
component.view.dispatch({
|
component.view?.dispatch({
|
||||||
changes: {
|
changes: {
|
||||||
from: 0,
|
from: 0,
|
||||||
to: component.view.state.doc.length,
|
to: component.view.state.doc.length,
|
||||||
@@ -263,10 +284,20 @@ onMounted(() => {
|
|||||||
extensions,
|
extensions,
|
||||||
(extensions) => {
|
(extensions) => {
|
||||||
const exts = extensions.filter((e) => !!e);
|
const exts = extensions.filter((e) => !!e);
|
||||||
dynamicExtensions(component.view, exts);
|
if (component.view !== null) dynamicExtensions(component.view, exts);
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
onBeforeUnmount(() => component.view?.destroy());
|
onBeforeUnmount(() => component.view?.destroy());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
inheritAttrs: false,
|
||||||
|
};
|
||||||
|
export type ModelType = {
|
||||||
|
expression: string;
|
||||||
|
errors?: boolean;
|
||||||
|
} | null;
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
:class="$attrs['class']"
|
:class="$attrs['class']"
|
||||||
:multiple="multiple"
|
:multiple="multiple"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
@update:model-value="(item) => $emit('update:modelValue', item)"
|
@update:model-value="(selected: any) => $emit('update:modelValue', selected)"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<InputBase v-slot="{ id, childClass }" v-bind="otherAttrs" :error="error">
|
<InputBase v-slot="{ id, childClass }" v-bind="otherAttrs" :error="error">
|
||||||
@@ -84,40 +84,13 @@
|
|||||||
</component>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
export default {
|
export default {
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
// Each item in the array is expected to have "id".
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
multiple: {
|
|
||||||
// Allow to select multiple values.
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
filter: {
|
|
||||||
// Enable filtering on the provided property.
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
defineEmits(["update:modelValue"]);
|
|
||||||
|
|
||||||
import { ref, computed, useAttrs } from "vue";
|
import { ref, computed, useAttrs } from "vue";
|
||||||
import {
|
import {
|
||||||
Listbox,
|
Listbox,
|
||||||
@@ -133,6 +106,24 @@ import {
|
|||||||
import { CheckIcon, SelectorIcon } from "@heroicons/vue/solid";
|
import { CheckIcon, SelectorIcon } from "@heroicons/vue/solid";
|
||||||
import InputBase from "@/components/InputBase.vue";
|
import InputBase from "@/components/InputBase.vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: any; // vue is not smart enough to use any | any[]
|
||||||
|
multiple?: boolean;
|
||||||
|
filter?: string | null; // should be keyof items
|
||||||
|
items: Array<{ id: number; [n: string]: any }>;
|
||||||
|
error?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
filter: null,
|
||||||
|
error: "",
|
||||||
|
multiple: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
defineEmits<{
|
||||||
|
(e: "update:modelValue", value: typeof props.modelValue): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
const query = ref("");
|
const query = ref("");
|
||||||
const component = computed(() =>
|
const component = computed(() =>
|
||||||
@@ -153,16 +144,15 @@ const component = computed(() =>
|
|||||||
Input: ComboboxInput,
|
Input: ComboboxInput,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const filteredItems = computed(() =>
|
const filteredItems = computed(() => {
|
||||||
props.filter === null
|
if (props.filter === null) return props.items;
|
||||||
? props.items
|
return props.items.filter((it) =>
|
||||||
: props.items.filter((it) =>
|
query.value
|
||||||
query.value
|
.toLowerCase()
|
||||||
.toLowerCase()
|
.split(/\W+/)
|
||||||
.split(/\W+/)
|
.every((w) => `${it[props.filter!]}`.toLowerCase().includes(w))
|
||||||
.every((w) => it[props.filter].toLowerCase().includes(w))
|
);
|
||||||
)
|
});
|
||||||
);
|
|
||||||
const otherAttrs = computed(() => {
|
const otherAttrs = computed(() => {
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
const { class: _, ...others } = attrs;
|
const { class: _, ...others } = attrs;
|
||||||
|
|||||||
@@ -9,19 +9,20 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder=" "
|
placeholder=" "
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
@input="$emit('update:modelValue', $event.target.value)"
|
@input="
|
||||||
|
$emit('update:modelValue', ($event.target as HTMLInputElement).value)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</InputBase>
|
</InputBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
defineEmits(["update:modelValue"]);
|
|
||||||
|
|
||||||
import InputBase from "@/components/InputBase.vue";
|
import InputBase from "@/components/InputBase.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
}>();
|
||||||
|
defineEmits<{
|
||||||
|
(e: "update:modelValue", value: string): void;
|
||||||
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -18,35 +18,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
// start: start time
|
|
||||||
// end: end time
|
|
||||||
// errors: is there an input error?
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
|
||||||
|
|
||||||
import { ref, computed, watch } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
import { Date as SugarDate } from "sugar-date";
|
import { Date as SugarDate } from "sugar-date";
|
||||||
import InputString from "@/components/InputString.vue";
|
import InputString from "@/components/InputString.vue";
|
||||||
import InputListBox from "@/components/InputListBox.vue";
|
import InputListBox from "@/components/InputListBox.vue";
|
||||||
import { isEqual } from "lodash-es";
|
import { isEqual } from "lodash-es";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: ModelType;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: typeof props.modelValue): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
const startTime = ref("");
|
const startTime = ref("");
|
||||||
const endTime = ref("");
|
const endTime = ref("");
|
||||||
const parsedStartTime = computed(() => SugarDate.create(startTime.value));
|
const parsedStartTime = computed(() => SugarDate.create(startTime.value));
|
||||||
const parsedEndTime = computed(() => SugarDate.create(endTime.value));
|
const parsedEndTime = computed(() => SugarDate.create(endTime.value));
|
||||||
const startTimeError = computed(() =>
|
const startTimeError = computed(() =>
|
||||||
isNaN(parsedStartTime.value) ? "Invalid date" : ""
|
isNaN(parsedStartTime.value.valueOf()) ? "Invalid date" : ""
|
||||||
);
|
);
|
||||||
const endTimeError = computed(
|
const endTimeError = computed(
|
||||||
() =>
|
() =>
|
||||||
(isNaN(parsedEndTime.value) ? "Invalid date" : "") ||
|
(isNaN(parsedEndTime.value.valueOf()) ? "Invalid date" : "") ||
|
||||||
(!isNaN(parsedStartTime.value) &&
|
(!isNaN(parsedStartTime.value.valueOf()) &&
|
||||||
parsedStartTime.value > parsedEndTime.value &&
|
parsedStartTime.value > parsedEndTime.value &&
|
||||||
"End date should be before start date") ||
|
"End date should be before start date") ||
|
||||||
""
|
""
|
||||||
@@ -124,13 +120,15 @@ watch(selectedPreset, (preset) => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(m) => {
|
(m) => {
|
||||||
startTime.value = m.start;
|
if (m) {
|
||||||
endTime.value = m.end;
|
startTime.value = m.start;
|
||||||
|
endTime.value = m.end;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
);
|
);
|
||||||
watch(
|
watch(
|
||||||
[startTime, endTime, hasErrors],
|
[startTime, endTime, hasErrors] as const,
|
||||||
([start, end, errors]) => {
|
([start, end, errors]) => {
|
||||||
// Find the right preset
|
// Find the right preset
|
||||||
const newPreset =
|
const newPreset =
|
||||||
@@ -152,3 +150,11 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export type ModelType = {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
errors?: boolean;
|
||||||
|
} | null;
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -18,13 +18,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
defineProps({
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
loading: boolean;
|
||||||
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
</Disclosure>
|
</Disclosure>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import { computed, inject } from "vue";
|
import { computed, inject } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue";
|
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue";
|
||||||
@@ -78,8 +78,9 @@ import {
|
|||||||
} from "@heroicons/vue/solid";
|
} from "@heroicons/vue/solid";
|
||||||
import DarkModeSwitcher from "@/components/DarkModeSwitcher.vue";
|
import DarkModeSwitcher from "@/components/DarkModeSwitcher.vue";
|
||||||
import UserMenu from "@/components/UserMenu.vue";
|
import UserMenu from "@/components/UserMenu.vue";
|
||||||
|
import { ServerConfigKey } from "@/components/ServerConfigProvider.vue";
|
||||||
|
|
||||||
const serverConfiguration = inject("server-configuration");
|
const serverConfiguration = inject(ServerConfigKey);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const navigation = computed(() => [
|
const navigation = computed(() => [
|
||||||
{ name: "Home", icon: HomeIcon, link: "/", current: route.path == "/" },
|
{ name: "Home", icon: HomeIcon, link: "/", current: route.path == "/" },
|
||||||
|
|||||||
@@ -5,12 +5,32 @@
|
|||||||
<slot></slot>
|
<slot></slot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import { provide, readonly } from "vue";
|
import { provide, readonly } from "vue";
|
||||||
import { useFetch } from "@vueuse/core";
|
import { useFetch } from "@vueuse/core";
|
||||||
|
|
||||||
// TODO: handle error
|
const { data } = useFetch("/api/v0/console/configuration")
|
||||||
const { data } = useFetch("/api/v0/console/configuration").get().json();
|
.get()
|
||||||
|
.json<ServerConfig>();
|
||||||
|
|
||||||
provide("server-configuration", readonly(data));
|
provide(ServerConfigKey, readonly(data));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { InjectionKey, Ref } from "vue";
|
||||||
|
|
||||||
|
type ServerConfig = {
|
||||||
|
version: string;
|
||||||
|
defaultVisualizeOptions: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
filter: string;
|
||||||
|
dimensions: string[];
|
||||||
|
};
|
||||||
|
dimensionsLimit: number;
|
||||||
|
homepageTopWidgets: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ServerConfigKey: InjectionKey<Readonly<Ref<ServerConfig>>> =
|
||||||
|
Symbol();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,15 +5,23 @@
|
|||||||
<slot></slot>
|
<slot></slot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import { provide, readonly } from "vue";
|
import { provide, readonly } from "vue";
|
||||||
import { useDark, useToggle } from "@vueuse/core";
|
import { useDark, useToggle } from "@vueuse/core";
|
||||||
|
|
||||||
const isDark = useDark();
|
const isDark = useDark();
|
||||||
const toggleDark = useToggle(isDark);
|
const toggleDark = useToggle(isDark);
|
||||||
|
|
||||||
provide("theme", {
|
provide(ThemeKey, {
|
||||||
isDark: readonly(isDark),
|
isDark: readonly(isDark),
|
||||||
toggleDark,
|
toggleDark,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { InjectionKey, Ref } from "vue";
|
||||||
|
export const ThemeKey: InjectionKey<{
|
||||||
|
isDark: Readonly<Ref<boolean>>;
|
||||||
|
toggleDark: () => void;
|
||||||
|
}> = Symbol();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<slot></slot>
|
<slot></slot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import { provide, computed, ref } from "vue";
|
import { provide, computed, ref } from "vue";
|
||||||
import { useTitle } from "@vueuse/core";
|
import { useTitle } from "@vueuse/core";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
@@ -17,7 +17,7 @@ import { useRouter, useRoute } from "vue-router";
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const applicationName = "Akvorado";
|
const applicationName = "Akvorado";
|
||||||
const viewName = computed(() => route.meta?.title);
|
const viewName = computed(() => route.meta?.title);
|
||||||
const documentTitle = ref(null);
|
const documentTitle = ref<string | null>(null);
|
||||||
const title = computed(() =>
|
const title = computed(() =>
|
||||||
[applicationName, viewName.value, documentTitle.value]
|
[applicationName, viewName.value, documentTitle.value]
|
||||||
.filter((k) => !!k)
|
.filter((k) => !!k)
|
||||||
@@ -30,5 +30,12 @@ useRouter().beforeEach((to, from) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
provide("title", { set: (t) => (documentTitle.value = t) });
|
provide(TitleKey, { set: (t: string) => (documentTitle.value = t) });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { InjectionKey } from "vue";
|
||||||
|
export const TitleKey: InjectionKey<{
|
||||||
|
set: (t: string) => void;
|
||||||
|
}> = Symbol();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -47,10 +47,11 @@
|
|||||||
</Popover>
|
</Popover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import { inject } from "vue";
|
import { inject } from "vue";
|
||||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue";
|
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue";
|
||||||
|
import { UserKey } from "@/components/UserProvider.vue";
|
||||||
|
|
||||||
const { user } = inject("user");
|
const { user } = inject(UserKey)!;
|
||||||
const avatarURL = "/api/v0/console/user/avatar";
|
const avatarURL = "/api/v0/console/user/avatar";
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<slot></slot>
|
<slot></slot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import { provide, readonly, watch } from "vue";
|
import { provide, readonly, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useFetch } from "@vueuse/core";
|
import { useFetch } from "@vueuse/core";
|
||||||
@@ -13,7 +13,7 @@ import { useFetch } from "@vueuse/core";
|
|||||||
const { data, execute } = useFetch("/api/v0/console/user/info", {
|
const { data, execute } = useFetch("/api/v0/console/user/info", {
|
||||||
immediate: false,
|
immediate: false,
|
||||||
onFetchError(ctx) {
|
onFetchError(ctx) {
|
||||||
if (ctx.response.status === 401) {
|
if (ctx.response?.status === 401) {
|
||||||
// TODO: avoid component flash.
|
// TODO: avoid component flash.
|
||||||
router.replace({ name: "401", query: { redirect: route.path } });
|
router.replace({ name: "401", query: { redirect: route.path } });
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ const { data, execute } = useFetch("/api/v0/console/user/info", {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.get()
|
.get()
|
||||||
.json();
|
.json<UserInfo>();
|
||||||
|
|
||||||
// Handle verification on route change.
|
// Handle verification on route change.
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -36,7 +36,21 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
provide("user", {
|
provide(UserKey, {
|
||||||
user: readonly(data),
|
user: readonly(data),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { InjectionKey, Ref } from "vue";
|
||||||
|
|
||||||
|
export type UserInfo = {
|
||||||
|
login: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
"logout-url"?: string;
|
||||||
|
};
|
||||||
|
export const UserKey: InjectionKey<{
|
||||||
|
user: Readonly<Ref<UserInfo>>;
|
||||||
|
}> = Symbol();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import VisualizePage from "@/views/VisualizePage.vue";
|
|||||||
import DocumentationPage from "@/views/DocumentationPage.vue";
|
import DocumentationPage from "@/views/DocumentationPage.vue";
|
||||||
import ErrorPage from "@/views/ErrorPage.vue";
|
import ErrorPage from "@/views/ErrorPage.vue";
|
||||||
|
|
||||||
|
declare module "vue-router" {
|
||||||
|
interface RouteMeta {
|
||||||
|
title: string;
|
||||||
|
notAuthenticated?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-FileCopyrightText: 2022 Free Mobile
|
// SPDX-FileCopyrightText: 2022 Free Mobile
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
export function formatXps(value) {
|
export function formatXps(value: number) {
|
||||||
value = Math.abs(value);
|
value = Math.abs(value);
|
||||||
const suffixes = ["", "K", "M", "G", "T"];
|
const suffixes = ["", "K", "M", "G", "T"];
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
@@ -9,13 +9,12 @@ export function formatXps(value) {
|
|||||||
value /= 1000;
|
value /= 1000;
|
||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
value = value.toFixed(2);
|
return `${value.toFixed(2)}${suffixes[idx]}`;
|
||||||
return `${value}${suffixes[idx]}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order function for field names
|
// Order function for field names
|
||||||
export function compareFields(f1, f2) {
|
export function compareFields(f1: string, f2: string) {
|
||||||
const metric = {
|
const metric: { [prefix: string]: number } = {
|
||||||
Dat: 1,
|
Dat: 1,
|
||||||
Tim: 2,
|
Tim: 2,
|
||||||
Byt: 3,
|
Byt: 3,
|
||||||
@@ -70,15 +70,19 @@ const colors = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderedColors = ["blue", "orange", "aqua", "green", "magenta"];
|
const orderedColors = ["blue", "orange", "aqua", "green", "magenta"] as const;
|
||||||
|
|
||||||
const darkPalette = [5, 6, 7, 8, 9, 10]
|
const darkPalette = [5, 6, 7, 8, 9, 10]
|
||||||
.map((idx) => orderedColors.map((colorName) => colors[colorName][idx]))
|
.map((idx) =>
|
||||||
|
orderedColors.map(
|
||||||
|
(colorName: keyof typeof colors) => colors[colorName][idx]
|
||||||
|
)
|
||||||
|
)
|
||||||
.flat();
|
.flat();
|
||||||
const lightPalette = [5, 4, 3, 2, 1, 0]
|
const lightPalette = [5, 4, 3, 2, 1, 0]
|
||||||
.map((idx) => orderedColors.map((colorName) => colors[colorName][idx]))
|
.map((idx) => orderedColors.map((colorName) => colors[colorName][idx]))
|
||||||
.flat();
|
.flat();
|
||||||
const lightenColor = (color, amount) =>
|
const lightenColor = (color: string, amount: number) =>
|
||||||
"#" +
|
"#" +
|
||||||
color
|
color
|
||||||
.replace(/^#/, "")
|
.replace(/^#/, "")
|
||||||
@@ -86,10 +90,14 @@ const lightenColor = (color, amount) =>
|
|||||||
(
|
(
|
||||||
"0" +
|
"0" +
|
||||||
Math.min(255, Math.max(0, parseInt(color, 16) + amount)).toString(16)
|
Math.min(255, Math.max(0, parseInt(color, 16) + amount)).toString(16)
|
||||||
).substr(-2)
|
).slice(-2)
|
||||||
);
|
);
|
||||||
|
|
||||||
export function dataColor(index, alternate = false, theme = "light") {
|
export function dataColor(
|
||||||
|
index: number,
|
||||||
|
alternate = false,
|
||||||
|
theme: "light" | "dark" = "light"
|
||||||
|
) {
|
||||||
const palette = theme === "light" ? lightPalette : darkPalette;
|
const palette = theme === "light" ? lightPalette : darkPalette;
|
||||||
const correctedIndex = index % 2 === 0 ? index : index + orderedColors.length;
|
const correctedIndex = index % 2 === 0 ? index : index + orderedColors.length;
|
||||||
const computed = palette[correctedIndex % palette.length];
|
const computed = palette[correctedIndex % palette.length];
|
||||||
@@ -99,7 +107,11 @@ export function dataColor(index, alternate = false, theme = "light") {
|
|||||||
return lightenColor(computed, 20);
|
return lightenColor(computed, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dataColorGrey(index, alternate = false, theme = "light") {
|
export function dataColorGrey(
|
||||||
|
index: number,
|
||||||
|
alternate = false,
|
||||||
|
theme: "light" | "dark" = "light"
|
||||||
|
) {
|
||||||
const palette =
|
const palette =
|
||||||
theme === "light"
|
theme === "light"
|
||||||
? ["#aaaaaa", "#bbbbbb", "#999999", "#cccccc", "#888888"]
|
? ["#aaaaaa", "#bbbbbb", "#999999", "#cccccc", "#888888"]
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
class="flex grow md:relative md:overflow-y-auto md:shadow-md md:dark:shadow-white/10"
|
class="flex grow md:relative md:overflow-y-auto md:shadow-md md:dark:shadow-white/10"
|
||||||
>
|
>
|
||||||
<div class="max-w-full py-4 px-4 md:px-16">
|
<div class="max-w-full py-4 px-4 md:px-16">
|
||||||
<InfoBox v-if="errorMessage" kind="danger">
|
<InfoBox v-if="errorMessage" kind="error">
|
||||||
<strong>Unable to fetch documentation page!</strong>
|
<strong>Unable to fetch documentation page!</strong>
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</InfoBox>
|
</InfoBox>
|
||||||
@@ -72,47 +72,70 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import { ref, computed, watch, inject, nextTick } from "vue";
|
import { ref, computed, watch, inject, nextTick } from "vue";
|
||||||
import { useFetch } from "@vueuse/core";
|
import { useFetch } from "@vueuse/core";
|
||||||
import { useRouteHash } from "@vueuse/router";
|
import { useRouteHash } from "@vueuse/router";
|
||||||
import InfoBox from "@/components/InfoBox.vue";
|
import InfoBox from "@/components/InfoBox.vue";
|
||||||
|
import { TitleKey } from "@/components/TitleProvider.vue";
|
||||||
|
|
||||||
const title = inject("title");
|
const props = defineProps<{ id: string }>();
|
||||||
|
const title = inject(TitleKey)!;
|
||||||
|
|
||||||
// Grab document
|
// Grab document
|
||||||
const url = computed(() => `/api/v0/console/docs/${props.id}`);
|
const url = computed(() => `/api/v0/console/docs/${props.id}`);
|
||||||
const { data, error } = useFetch(url, { refetch: true }).get().json();
|
const { data, error } = useFetch(url, { refetch: true }).get().json<
|
||||||
|
| { message: string } // on error
|
||||||
|
| {
|
||||||
|
markdown: string;
|
||||||
|
toc: Array<{
|
||||||
|
name: string;
|
||||||
|
headers: Array<{ level: number; id: string; title: string }>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
const errorMessage = computed(
|
const errorMessage = computed(
|
||||||
() =>
|
() =>
|
||||||
(error.value &&
|
(error.value &&
|
||||||
(data.value?.message || `Server returned an error: ${error.value}`)) ||
|
data.value &&
|
||||||
|
"message" in data.value &&
|
||||||
|
(data.value.message || `Server returned an error: ${error.value}`)) ||
|
||||||
""
|
""
|
||||||
);
|
);
|
||||||
const markdown = computed(() => (!error.value && data.value?.markdown) || "");
|
const markdown = computed(
|
||||||
const toc = computed(() => (!error.value && data.value?.toc) || []);
|
() =>
|
||||||
|
(!error.value &&
|
||||||
|
data.value &&
|
||||||
|
"markdown" in data.value &&
|
||||||
|
data.value.markdown) ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
const toc = computed(
|
||||||
|
() =>
|
||||||
|
(!error.value && data.value && "toc" in data.value && data.value.toc) || []
|
||||||
|
);
|
||||||
const activeDocument = computed(() => props.id || null);
|
const activeDocument = computed(() => props.id || null);
|
||||||
const activeSlug = useRouteHash();
|
const activeSlug = useRouteHash();
|
||||||
|
|
||||||
// Scroll to the right anchor after loading markdown
|
// Scroll to the right anchor after loading markdown
|
||||||
const contentEl = ref(null);
|
const contentEl = ref<HTMLElement | null>(null);
|
||||||
watch([markdown, activeSlug], async () => {
|
watch([markdown, activeSlug] as const, async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
if (contentEl.value === null) return;
|
||||||
let scrollEl = contentEl.value;
|
let scrollEl = contentEl.value;
|
||||||
while (window.getComputedStyle(scrollEl).position === "static") {
|
while (
|
||||||
|
window.getComputedStyle(scrollEl).position === "static" &&
|
||||||
|
scrollEl.parentNode instanceof HTMLElement
|
||||||
|
) {
|
||||||
scrollEl = scrollEl.parentNode;
|
scrollEl = scrollEl.parentNode;
|
||||||
}
|
}
|
||||||
const top =
|
const top =
|
||||||
(activeSlug.value &&
|
(activeSlug.value &&
|
||||||
document.querySelector(`#${CSS.escape(activeSlug.value.slice(1))}`)
|
(
|
||||||
?.offsetTop) ||
|
document.querySelector(
|
||||||
|
`#${CSS.escape(activeSlug.value.slice(1))}`
|
||||||
|
) as HTMLElement | null
|
||||||
|
)?.offsetTop) ||
|
||||||
0;
|
0;
|
||||||
scrollEl.scrollTo(0, top);
|
scrollEl.scrollTo(0, top);
|
||||||
});
|
});
|
||||||
@@ -120,6 +143,7 @@ watch([markdown, activeSlug], async () => {
|
|||||||
// Update title
|
// Update title
|
||||||
watch(markdown, async () => {
|
watch(markdown, async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
title.set(contentEl.value?.querySelector("h1")?.textContent);
|
const t = contentEl.value?.querySelector("h1")?.textContent;
|
||||||
|
if (t) title.set(t);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,11 +11,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
defineProps({
|
defineProps<{
|
||||||
error: {
|
error: string;
|
||||||
type: String,
|
}>();
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
import { inject, computed } from "vue";
|
import { inject, computed } from "vue";
|
||||||
import { useInterval } from "@vueuse/core";
|
import { useInterval } from "@vueuse/core";
|
||||||
import WidgetLastFlow from "./HomePage/WidgetLastFlow.vue";
|
import WidgetLastFlow from "./HomePage/WidgetLastFlow.vue";
|
||||||
@@ -54,12 +54,13 @@ import WidgetFlowRate from "./HomePage/WidgetFlowRate.vue";
|
|||||||
import WidgetExporters from "./HomePage/WidgetExporters.vue";
|
import WidgetExporters from "./HomePage/WidgetExporters.vue";
|
||||||
import WidgetTop from "./HomePage/WidgetTop.vue";
|
import WidgetTop from "./HomePage/WidgetTop.vue";
|
||||||
import WidgetGraph from "./HomePage/WidgetGraph.vue";
|
import WidgetGraph from "./HomePage/WidgetGraph.vue";
|
||||||
|
import { ServerConfigKey } from "@/components/ServerConfigProvider.vue";
|
||||||
|
|
||||||
const serverConfiguration = inject("server-configuration");
|
const serverConfiguration = inject(ServerConfigKey)!;
|
||||||
const topWidgets = computed(
|
const topWidgets = computed(
|
||||||
() => serverConfiguration.value?.homepageTopWidgets ?? []
|
() => serverConfiguration.value?.homepageTopWidgets ?? []
|
||||||
);
|
);
|
||||||
const widgetTitle = (name) =>
|
const widgetTitle = (name: string) =>
|
||||||
({
|
({
|
||||||
"src-as": "Top source AS",
|
"src-as": "Top source AS",
|
||||||
"dst-as": "Top destination AS",
|
"dst-as": "Top destination AS",
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useFetch } from "@vueuse/core";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
refresh: {
|
refresh: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@@ -20,10 +23,9 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
import { computed } from "vue";
|
|
||||||
import { useFetch } from "@vueuse/core";
|
|
||||||
|
|
||||||
const url = computed(() => "/api/v0/console/widget/exporters?" + props.refresh);
|
const url = computed(() => "/api/v0/console/widget/exporters?" + props.refresh);
|
||||||
const { data } = useFetch(url, { refetch: true }).get().json();
|
const { data } = useFetch(url, { refetch: true })
|
||||||
const exporters = computed(() => data?.value?.exporters?.length || "???");
|
.get()
|
||||||
|
.json<{ exporters: string[] }>();
|
||||||
|
const exporters = computed(() => data.value?.exporters.length || "???");
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -12,29 +12,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
refresh: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useFetch } from "@vueuse/core";
|
import { useFetch } from "@vueuse/core";
|
||||||
|
|
||||||
const url = computed(() => "/api/v0/console/widget/flow-rate?" + props.refresh);
|
const props = withDefaults(
|
||||||
const { data } = useFetch(url, { refetch: true }).get().json();
|
defineProps<{
|
||||||
|
refresh?: number;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
refresh: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = computed(() => `/api/v0/console/widget/flow-rate?${props.refresh}`);
|
||||||
|
const { data } = useFetch(url, { refetch: true }).get().json<{
|
||||||
|
rate: number;
|
||||||
|
period: string;
|
||||||
|
}>();
|
||||||
const rate = computed(() => {
|
const rate = computed(() => {
|
||||||
|
if (!data.value?.rate) {
|
||||||
|
return "???";
|
||||||
|
}
|
||||||
if (data.value?.rate > 1_500_000) {
|
if (data.value?.rate > 1_500_000) {
|
||||||
return (data.value.rate / 1_000_000).toFixed(1) + "M";
|
return (data.value.rate / 1_000_000).toFixed(1) + "M";
|
||||||
}
|
}
|
||||||
if (data.value?.rate > 1_500) {
|
if (data.value?.rate > 1_500) {
|
||||||
return (data.value.rate / 1_000).toFixed(1) + "K";
|
return (data.value.rate / 1_000).toFixed(1) + "K";
|
||||||
}
|
}
|
||||||
if (data.value?.rate >= 0) {
|
return data.value.rate.toFixed(0);
|
||||||
return data.value.rate.toFixed(0);
|
|
||||||
}
|
|
||||||
return "???";
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,77 +4,96 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="h-[300px]">
|
<div class="h-[300px]">
|
||||||
<v-chart :option="options" :theme="isDark ? 'dark' : null" autoresize />
|
<v-chart
|
||||||
|
:option="option"
|
||||||
|
:theme="isDark ? 'dark' : undefined"
|
||||||
|
autoresize
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
refresh: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import { computed, inject } from "vue";
|
import { computed, inject } from "vue";
|
||||||
import { useFetch } from "@vueuse/core";
|
import { useFetch } from "@vueuse/core";
|
||||||
import { use, graphic } from "echarts/core";
|
import { ThemeKey } from "@/components/ThemeProvider.vue";
|
||||||
|
import { use, graphic, type ComposeOption } from "echarts/core";
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
import { LineChart } from "echarts/charts";
|
import { LineChart, type LineSeriesOption } from "echarts/charts";
|
||||||
import { TooltipComponent, GridComponent } from "echarts/components";
|
import {
|
||||||
|
TooltipComponent,
|
||||||
|
GridComponent,
|
||||||
|
type TooltipComponentOption,
|
||||||
|
type GridComponentOption,
|
||||||
|
} from "echarts/components";
|
||||||
import VChart from "vue-echarts";
|
import VChart from "vue-echarts";
|
||||||
import { dataColor, formatXps } from "../../utils";
|
import { dataColor, formatXps } from "../../utils";
|
||||||
const { isDark } = inject("theme");
|
const { isDark } = inject(ThemeKey)!;
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
refresh?: number;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
refresh: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type ECOption = ComposeOption<
|
||||||
|
LineSeriesOption | TooltipComponentOption | GridComponentOption
|
||||||
|
>;
|
||||||
use([CanvasRenderer, LineChart, TooltipComponent, GridComponent]);
|
use([CanvasRenderer, LineChart, TooltipComponent, GridComponent]);
|
||||||
|
|
||||||
const formatGbps = (value) => formatXps(value * 1_000_000_000);
|
const formatGbps = (value: number) => formatXps(value * 1_000_000_000);
|
||||||
|
|
||||||
const url = computed(() => `/api/v0/console/widget/graph?${props.refresh}`);
|
const url = computed(() => `/api/v0/console/widget/graph?${props.refresh}`);
|
||||||
const { data } = useFetch(url, { refetch: true }).get().json();
|
const { data } = useFetch(url, { refetch: true })
|
||||||
const options = computed(() => ({
|
.get()
|
||||||
darkMode: isDark.value,
|
.json<{ data: Array<{ t: string; gbps: number }> }>();
|
||||||
backgroundColor: "transparent",
|
const option = computed(
|
||||||
xAxis: { type: "time" },
|
(): ECOption => ({
|
||||||
yAxis: {
|
darkMode: isDark.value,
|
||||||
type: "value",
|
backgroundColor: "transparent",
|
||||||
min: 0,
|
xAxis: { type: "time" },
|
||||||
axisLabel: { formatter: formatGbps },
|
yAxis: {
|
||||||
},
|
type: "value",
|
||||||
tooltip: {
|
min: 0,
|
||||||
confine: true,
|
axisLabel: { formatter: formatGbps },
|
||||||
trigger: "axis",
|
|
||||||
axisPointer: {
|
|
||||||
type: "cross",
|
|
||||||
label: { backgroundColor: "#6a7985" },
|
|
||||||
},
|
},
|
||||||
valueFormatter: formatGbps,
|
tooltip: {
|
||||||
},
|
confine: true,
|
||||||
series: [
|
trigger: "axis",
|
||||||
{
|
axisPointer: {
|
||||||
type: "line",
|
type: "cross",
|
||||||
symbol: "none",
|
label: { backgroundColor: "#6a7985" },
|
||||||
lineStyle: {
|
|
||||||
width: 0,
|
|
||||||
},
|
},
|
||||||
areaStyle: {
|
valueFormatter: (value) => formatGbps(value.valueOf() as number),
|
||||||
opacity: 0.9,
|
|
||||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: dataColor(0, false, isDark.value ? "dark" : "light"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: dataColor(0, true, isDark.value ? "dark" : "light"),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
data: (data.value?.data || [])
|
|
||||||
.map(({ t, gbps }) => [t, gbps])
|
|
||||||
.slice(0, -1),
|
|
||||||
},
|
},
|
||||||
],
|
series: [
|
||||||
}));
|
{
|
||||||
|
type: "line",
|
||||||
|
symbol: "none",
|
||||||
|
lineStyle: {
|
||||||
|
width: 0,
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
opacity: 0.9,
|
||||||
|
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: dataColor(0, false, isDark.value ? "dark" : "light"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: dataColor(0, true, isDark.value ? "dark" : "light"),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
data: (data.value?.data || [])
|
||||||
|
.map(({ t, gbps }) => [t, gbps])
|
||||||
|
.slice(0, -1),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,21 +19,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
refresh: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useFetch } from "@vueuse/core";
|
import { useFetch } from "@vueuse/core";
|
||||||
import { compareFields } from "../../utils";
|
import { compareFields } from "../../utils";
|
||||||
|
|
||||||
const url = computed(() => "/api/v0/console/widget/flow-last?" + props.refresh);
|
const props = withDefaults(
|
||||||
const { data } = useFetch(url, { refetch: true }).get().json();
|
defineProps<{
|
||||||
const lastFlow = computed(() => ({
|
refresh?: number;
|
||||||
|
}>(),
|
||||||
|
{ refresh: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = computed(() => `/api/v0/console/widget/flow-last?${props.refresh}`);
|
||||||
|
const { data } = useFetch(url, { refetch: true })
|
||||||
|
.get()
|
||||||
|
.json<Record<string, string | number>>();
|
||||||
|
const lastFlow = computed((): [string, string | number][] => ({
|
||||||
...(lastFlow.value || {}),
|
...(lastFlow.value || {}),
|
||||||
...Object.entries(data.value || {}).sort(([f1], [f2]) =>
|
...Object.entries(data.value || {}).sort(([f1], [f2]) =>
|
||||||
compareFields(f1, f2)
|
compareFields(f1, f2)
|
||||||
|
|||||||
@@ -5,98 +5,107 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="font-semibold leading-relaxed">{{ title }}</h1>
|
<h1 class="font-semibold leading-relaxed">{{ title }}</h1>
|
||||||
<div class="h-[200px]">
|
<div class="h-[200px]">
|
||||||
<v-chart :option="options" :theme="isDark ? 'dark' : null" autoresize />
|
<v-chart
|
||||||
|
:option="options"
|
||||||
|
:theme="isDark ? 'dark' : undefined"
|
||||||
|
autoresize
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
refresh: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
what: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import { computed, inject } from "vue";
|
import { computed, inject } from "vue";
|
||||||
import { useFetch } from "@vueuse/core";
|
import { useFetch } from "@vueuse/core";
|
||||||
import { use } from "echarts/core";
|
import { ThemeKey } from "@/components/ThemeProvider.vue";
|
||||||
|
import { use, type ComposeOption } from "echarts/core";
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
import { PieChart } from "echarts/charts";
|
import { PieChart, type PieSeriesOption } from "echarts/charts";
|
||||||
import { TooltipComponent, LegendComponent } from "echarts/components";
|
import {
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
type TooltipComponentOption,
|
||||||
|
type LegendComponentOption,
|
||||||
|
} from "echarts/components";
|
||||||
import VChart from "vue-echarts";
|
import VChart from "vue-echarts";
|
||||||
import { dataColor, dataColorGrey } from "../../utils";
|
import { dataColor, dataColorGrey } from "../../utils";
|
||||||
const { isDark } = inject("theme");
|
const { isDark } = inject(ThemeKey)!;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
refresh: number;
|
||||||
|
what: string;
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent]);
|
use([CanvasRenderer, PieChart, TooltipComponent, LegendComponent]);
|
||||||
|
|
||||||
|
type ECOption = ComposeOption<
|
||||||
|
PieSeriesOption | TooltipComponentOption | LegendComponentOption
|
||||||
|
>;
|
||||||
|
|
||||||
const url = computed(
|
const url = computed(
|
||||||
() => `/api/v0/console/widget/top/${props.what}?${props.refresh}`
|
() => `/api/v0/console/widget/top/${props.what}?${props.refresh}`
|
||||||
);
|
);
|
||||||
const { data } = useFetch(url, { refetch: true }).get().json();
|
const { data } = useFetch(url, { refetch: true })
|
||||||
const options = computed(() => ({
|
.get()
|
||||||
darkMode: isDark.value,
|
.json<{ top: Array<{ name: string; percent: number }> }>();
|
||||||
backgroundColor: "transparent",
|
const options = computed(
|
||||||
tooltip: {
|
(): ECOption => ({
|
||||||
trigger: "item",
|
darkMode: isDark.value,
|
||||||
confine: true,
|
backgroundColor: "transparent",
|
||||||
valueFormatter(value) {
|
tooltip: {
|
||||||
return value.toFixed(2) + "%";
|
trigger: "item",
|
||||||
},
|
confine: true,
|
||||||
},
|
valueFormatter(value) {
|
||||||
legend: {
|
return (value.valueOf() as number).toFixed(2) + "%";
|
||||||
orient: "horizontal",
|
|
||||||
bottom: "bottom",
|
|
||||||
itemGap: 5,
|
|
||||||
itemWidth: 14,
|
|
||||||
itemHeight: 14,
|
|
||||||
textStyle: {
|
|
||||||
fontSize: 10,
|
|
||||||
color: isDark.value ? "#eee" : "#111",
|
|
||||||
},
|
|
||||||
formatter(name) {
|
|
||||||
return name.split(": ")[0];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: "pie",
|
|
||||||
label: { show: false },
|
|
||||||
center: ["50%", "40%"],
|
|
||||||
radius: "60%",
|
|
||||||
data: [
|
|
||||||
...(data.value?.top || [])
|
|
||||||
.filter(({ percent }) => percent > 0)
|
|
||||||
.map(({ name, percent }) => ({
|
|
||||||
name,
|
|
||||||
value: percent,
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
name: "Others",
|
|
||||||
value: Math.max(
|
|
||||||
100 - (data.value?.top || []).reduce((c, n) => c + n.percent, 0),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
},
|
|
||||||
].filter(({ value }) => value > 0.05),
|
|
||||||
itemStyle: {
|
|
||||||
color({ name, dataIndex }) {
|
|
||||||
const theme = isDark.value ? "dark" : "light";
|
|
||||||
if (name === "Others") {
|
|
||||||
return dataColorGrey(0, false, theme);
|
|
||||||
}
|
|
||||||
return dataColor(dataIndex, false, theme);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
legend: {
|
||||||
}));
|
orient: "horizontal",
|
||||||
|
bottom: "bottom",
|
||||||
|
itemGap: 5,
|
||||||
|
itemWidth: 14,
|
||||||
|
itemHeight: 14,
|
||||||
|
textStyle: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: isDark.value ? "#eee" : "#111",
|
||||||
|
},
|
||||||
|
formatter(name: string) {
|
||||||
|
return name.split(": ")[0];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "pie",
|
||||||
|
label: { show: false },
|
||||||
|
center: ["50%", "40%"],
|
||||||
|
radius: "60%",
|
||||||
|
data: [
|
||||||
|
...(data.value?.top || [])
|
||||||
|
.filter(({ percent }) => percent > 0)
|
||||||
|
.map(({ name, percent }) => ({
|
||||||
|
name,
|
||||||
|
value: percent,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: "Others",
|
||||||
|
value: Math.max(
|
||||||
|
100 - (data.value?.top || []).reduce((c, n) => c + n.percent, 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
},
|
||||||
|
].filter(({ value }) => value > 0.05),
|
||||||
|
itemStyle: {
|
||||||
|
color({ name, dataIndex }: { name: string; dataIndex: number }) {
|
||||||
|
const theme = isDark.value ? "dark" : "light";
|
||||||
|
if (name === "Others") {
|
||||||
|
return dataColorGrey(0, false, theme);
|
||||||
|
}
|
||||||
|
return dataColor(dataIndex, false, theme);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<LoadingOverlay :loading="isFetching">
|
<LoadingOverlay :loading="isFetching">
|
||||||
<RequestSummary :request="request" />
|
<RequestSummary :request="request" />
|
||||||
<div class="mx-4 my-2">
|
<div class="mx-4 my-2">
|
||||||
<InfoBox v-if="errorMessage" kind="danger">
|
<InfoBox v-if="errorMessage" kind="error">
|
||||||
<strong>Unable to fetch data! </strong>{{ errorMessage }}
|
<strong>Unable to fetch data! </strong>{{ errorMessage }}
|
||||||
</InfoBox>
|
</InfoBox>
|
||||||
<ResizeRow
|
<ResizeRow
|
||||||
@@ -41,56 +41,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
routeState: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import { ref, watch, computed } from "vue";
|
import { ref, watch, computed } from "vue";
|
||||||
import { useFetch } from "@vueuse/core";
|
import { useFetch, type AfterFetchContext } from "@vueuse/core";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { Date as SugarDate } from "sugar-date";
|
import { Date as SugarDate } from "sugar-date";
|
||||||
import { ResizeRow } from "vue-resizer";
|
import { ResizeRow } from "vue-resizer";
|
||||||
import LZString from "lz-string";
|
import LZString from "lz-string";
|
||||||
import InfoBox from "@/components/InfoBox.vue";
|
import InfoBox from "@/components/InfoBox.vue";
|
||||||
import LoadingOverlay from "@/components/LoadingOverlay.vue";
|
import LoadingOverlay from "@/components/LoadingOverlay.vue";
|
||||||
|
import RequestSummary from "./VisualizePage/RequestSummary.vue";
|
||||||
import DataTable from "./VisualizePage/DataTable.vue";
|
import DataTable from "./VisualizePage/DataTable.vue";
|
||||||
import DataGraph from "./VisualizePage/DataGraph.vue";
|
import DataGraph from "./VisualizePage/DataGraph.vue";
|
||||||
import OptionsPanel from "./VisualizePage/OptionsPanel.vue";
|
import {
|
||||||
import RequestSummary from "./VisualizePage/RequestSummary.vue";
|
default as OptionsPanel,
|
||||||
import { graphTypes } from "./VisualizePage/constants";
|
type ModelType,
|
||||||
import { isEqual, omit } from "lodash-es";
|
} from "./VisualizePage/OptionsPanel.vue";
|
||||||
|
import type { GraphType } from "./VisualizePage/constants";
|
||||||
|
import type {
|
||||||
|
SankeyHandlerInput,
|
||||||
|
GraphHandlerInput,
|
||||||
|
SankeyHandlerOutput,
|
||||||
|
GraphHandlerOutput,
|
||||||
|
SankeyHandlerResult,
|
||||||
|
GraphHandlerResult,
|
||||||
|
} from "./VisualizePage";
|
||||||
|
import { isEqual, omit, pick } from "lodash-es";
|
||||||
|
|
||||||
|
const props = defineProps<{ routeState?: string }>();
|
||||||
|
|
||||||
const graphHeight = ref(500);
|
const graphHeight = ref(500);
|
||||||
const highlightedSerie = ref(null);
|
const highlightedSerie = ref<number | null>(null);
|
||||||
|
|
||||||
const updateTimeRange = ([start, end]) => {
|
const updateTimeRange = ([start, end]: [Date, Date]) => {
|
||||||
|
if (state.value === null) return;
|
||||||
state.value.start = start.toISOString();
|
state.value.start = start.toISOString();
|
||||||
state.value.end = end.toISOString();
|
state.value.end = end.toISOString();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Main state
|
// Main state
|
||||||
const state = ref({});
|
const state = ref<ModelType>(null);
|
||||||
|
|
||||||
// Load data from URL
|
// Load data from URL
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const decodeState = (serialized) => {
|
const decodeState = (serialized: string | undefined): ModelType => {
|
||||||
try {
|
try {
|
||||||
if (!serialized) {
|
if (!serialized) {
|
||||||
console.debug("no state");
|
console.debug("no state");
|
||||||
return {};
|
return null;
|
||||||
}
|
}
|
||||||
return JSON.parse(LZString.decompressFromBase64(serialized));
|
const unserialized = LZString.decompressFromBase64(serialized);
|
||||||
|
if (!unserialized) {
|
||||||
|
console.debug("empty state");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(unserialized);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("cannot decode state:", error);
|
console.error("cannot decode state:", error);
|
||||||
return {};
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const encodeState = (state) => {
|
const encodeState = (state: ModelType) => {
|
||||||
|
if (state === null) return "";
|
||||||
return LZString.compressToBase64(
|
return LZString.compressToBase64(
|
||||||
JSON.stringify(state, Object.keys(state).sort())
|
JSON.stringify(state, Object.keys(state).sort())
|
||||||
);
|
);
|
||||||
@@ -108,50 +121,88 @@ watch(
|
|||||||
const encodedState = computed(() => encodeState(state.value));
|
const encodedState = computed(() => encodeState(state.value));
|
||||||
|
|
||||||
// Fetch data
|
// Fetch data
|
||||||
const fetchedData = ref({});
|
const fetchedData = ref<GraphHandlerResult | SankeyHandlerResult | null>(null);
|
||||||
const finalState = computed(() => ({
|
const finalState = computed((): ModelType => {
|
||||||
...state.value,
|
return state.value === null
|
||||||
start: SugarDate.create(state.value.start),
|
? null
|
||||||
end: SugarDate.create(state.value.end),
|
: {
|
||||||
}));
|
...state.value,
|
||||||
const jsonPayload = computed(() => ({
|
start: SugarDate.create(state.value.start).toISOString(),
|
||||||
...omit(finalState.value, ["previousPeriod", "graphType"]),
|
end: SugarDate.create(state.value.end).toISOString(),
|
||||||
"previous-period": finalState.value.previousPeriod,
|
};
|
||||||
}));
|
});
|
||||||
const request = ref({}); // Same as finalState, but once request is successful
|
const jsonPayload = computed(
|
||||||
|
(): SankeyHandlerInput | GraphHandlerInput | null => {
|
||||||
|
if (finalState.value === null) return null;
|
||||||
|
if (finalState.value.graphType === "sankey") {
|
||||||
|
const input: SankeyHandlerInput = omit(finalState.value, [
|
||||||
|
"graphType",
|
||||||
|
"bidirectional",
|
||||||
|
"previousPeriod",
|
||||||
|
]);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
const input: GraphHandlerInput = {
|
||||||
|
...omit(finalState.value, ["graphType", "previousPeriod"]),
|
||||||
|
"previous-period": finalState.value.previousPeriod,
|
||||||
|
points: finalState.value.graphType === "grid" ? 50 : 200,
|
||||||
|
};
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const request = ref<ModelType>(null); // Same as finalState, but once request is successful
|
||||||
const { data, isFetching, aborted, abort, canAbort, error } = useFetch("", {
|
const { data, isFetching, aborted, abort, canAbort, error } = useFetch("", {
|
||||||
beforeFetch(ctx) {
|
beforeFetch(ctx) {
|
||||||
// Add the URL. Not a computed value as if we change both payload
|
// Add the URL. Not a computed value as if we change both payload
|
||||||
// and URL, the query will be triggered twice.
|
// and URL, the query will be triggered twice.
|
||||||
const { cancel } = ctx;
|
const { cancel } = ctx;
|
||||||
const endpoint = {
|
if (finalState.value === null) {
|
||||||
[graphTypes.stacked]: "graph",
|
|
||||||
[graphTypes.lines]: "graph",
|
|
||||||
[graphTypes.grid]: "graph",
|
|
||||||
[graphTypes.sankey]: "sankey",
|
|
||||||
};
|
|
||||||
const url = endpoint[state.value.graphType];
|
|
||||||
if (url === undefined) {
|
|
||||||
cancel();
|
cancel();
|
||||||
|
return ctx;
|
||||||
}
|
}
|
||||||
|
const endpoint: Record<GraphType, string> = {
|
||||||
|
stacked: "graph",
|
||||||
|
lines: "graph",
|
||||||
|
grid: "graph",
|
||||||
|
sankey: "sankey",
|
||||||
|
};
|
||||||
|
const url = endpoint[finalState.value.graphType];
|
||||||
return {
|
return {
|
||||||
...ctx,
|
...ctx,
|
||||||
url: `/api/v0/console/${url}`,
|
url: `/api/v0/console/${url}`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async afterFetch(ctx) {
|
async afterFetch(
|
||||||
|
ctx: AfterFetchContext<GraphHandlerOutput | SankeyHandlerOutput>
|
||||||
|
) {
|
||||||
// Update data. Not done in a computed value as we want to keep the
|
// Update data. Not done in a computed value as we want to keep the
|
||||||
// previous data in case of errors.
|
// previous data in case of errors.
|
||||||
const { data, response } = ctx;
|
const { data, response } = ctx;
|
||||||
|
if (data === null || !finalState.value) return ctx;
|
||||||
console.groupCollapsed("SQL query");
|
console.groupCollapsed("SQL query");
|
||||||
console.info(
|
console.info(
|
||||||
response.headers.get("x-sql-query").replace(/ {2}( )*/g, "\n$1")
|
response.headers.get("x-sql-query")?.replace(/ {2}( )*/g, "\n$1")
|
||||||
);
|
);
|
||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
fetchedData.value = {
|
if (finalState.value.graphType === "sankey") {
|
||||||
...data,
|
fetchedData.value = {
|
||||||
...omit(finalState.value, ["limit", "filter", "points"]),
|
graphType: "sankey",
|
||||||
};
|
...(data as SankeyHandlerOutput),
|
||||||
|
...pick(finalState.value, ["start", "end", "dimensions", "units"]),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
fetchedData.value = {
|
||||||
|
graphType: finalState.value.graphType,
|
||||||
|
...(data as GraphHandlerOutput),
|
||||||
|
...pick(finalState.value, [
|
||||||
|
"start",
|
||||||
|
"end",
|
||||||
|
"dimensions",
|
||||||
|
"units",
|
||||||
|
"bidirectional",
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Also update URL.
|
// Also update URL.
|
||||||
const routeTarget = {
|
const routeTarget = {
|
||||||
@@ -171,13 +222,14 @@ const { data, isFetching, aborted, abort, canAbort, error } = useFetch("", {
|
|||||||
},
|
},
|
||||||
refetch: true,
|
refetch: true,
|
||||||
})
|
})
|
||||||
.post(jsonPayload)
|
.post(jsonPayload, "json") // this will trigger a refetch
|
||||||
.json();
|
.json<GraphHandlerOutput | SankeyHandlerOutput | { message: string }>();
|
||||||
const errorMessage = computed(
|
const errorMessage = computed(
|
||||||
() =>
|
() =>
|
||||||
(error.value &&
|
(error.value &&
|
||||||
!aborted.value &&
|
!aborted.value &&
|
||||||
(data.value?.message || `Server returned an error: ${error.value}`)) ||
|
((data.value && "message" in data.value && data.value.message) ||
|
||||||
|
`Server returned an error: ${error.value}`)) ||
|
||||||
""
|
""
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,33 +4,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
:is="component"
|
:is="component"
|
||||||
:theme="isDark ? 'dark' : null"
|
:theme="isDark ? 'dark' : undefined"
|
||||||
:data="data"
|
:data="data"
|
||||||
autoresize
|
autoresize
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
data: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import { computed, inject } from "vue";
|
import { computed, inject } from "vue";
|
||||||
import { graphTypes } from "./constants";
|
|
||||||
import DataGraphTimeSeries from "./DataGraphTimeSeries.vue";
|
import DataGraphTimeSeries from "./DataGraphTimeSeries.vue";
|
||||||
import DataGraphSankey from "./DataGraphSankey.vue";
|
import DataGraphSankey from "./DataGraphSankey.vue";
|
||||||
const { isDark } = inject("theme");
|
import type { GraphHandlerResult, SankeyHandlerResult } from ".";
|
||||||
|
import { ThemeKey } from "@/components/ThemeProvider.vue";
|
||||||
|
const { isDark } = inject(ThemeKey)!;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: GraphHandlerResult | SankeyHandlerResult | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
const component = computed(() => {
|
const component = computed(() => {
|
||||||
const { stacked, lines, grid, sankey } = graphTypes;
|
switch (props.data?.graphType) {
|
||||||
if ([stacked, lines, grid].includes(props.data.graphType)) {
|
case "stacked":
|
||||||
return DataGraphTimeSeries;
|
case "lines":
|
||||||
}
|
case "grid":
|
||||||
if ([sankey].includes(props.data.graphType)) {
|
return DataGraphTimeSeries;
|
||||||
return DataGraphSankey;
|
case "sankey":
|
||||||
|
return DataGraphSankey;
|
||||||
}
|
}
|
||||||
return "div";
|
return "div";
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,30 +2,37 @@
|
|||||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-chart :option="graph" />
|
<v-chart :option="option" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
data: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import { inject, computed } from "vue";
|
import { inject, computed } from "vue";
|
||||||
import { formatXps, dataColor, dataColorGrey } from "@/utils";
|
import { formatXps, dataColor, dataColorGrey } from "@/utils";
|
||||||
const { isDark } = inject("theme");
|
import { ThemeKey } from "@/components/ThemeProvider.vue";
|
||||||
|
import type { SankeyHandlerResult } from ".";
|
||||||
import { use } from "echarts/core";
|
import { use, type ComposeOption } from "echarts/core";
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
import { SankeyChart } from "echarts/charts";
|
import { SankeyChart, type SankeySeriesOption } from "echarts/charts";
|
||||||
import { TooltipComponent } from "echarts/components";
|
import {
|
||||||
|
TooltipComponent,
|
||||||
|
type TooltipComponentOption,
|
||||||
|
type GridComponentOption,
|
||||||
|
} from "echarts/components";
|
||||||
|
import type { TooltipCallbackDataParams } from "echarts/types/src/component/tooltip/TooltipView";
|
||||||
import VChart from "vue-echarts";
|
import VChart from "vue-echarts";
|
||||||
use([CanvasRenderer, SankeyChart, TooltipComponent]);
|
use([CanvasRenderer, SankeyChart, TooltipComponent]);
|
||||||
|
type ECOption = ComposeOption<
|
||||||
|
SankeySeriesOption | TooltipComponentOption | GridComponentOption
|
||||||
|
>;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: SankeyHandlerResult;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { isDark } = inject(ThemeKey)!;
|
||||||
|
|
||||||
// Graph component
|
// Graph component
|
||||||
const graph = computed(() => {
|
const option = computed((): ECOption => {
|
||||||
const theme = isDark.value ? "dark" : "light";
|
const theme = isDark.value ? "dark" : "light";
|
||||||
const data = props.data || {};
|
const data = props.data || {};
|
||||||
if (!data.xps) return {};
|
if (!data.xps) return {};
|
||||||
@@ -37,27 +44,39 @@ const graph = computed(() => {
|
|||||||
confine: true,
|
confine: true,
|
||||||
trigger: "item",
|
trigger: "item",
|
||||||
triggerOn: "mousemove",
|
triggerOn: "mousemove",
|
||||||
formatter({ dataType, marker, data, value }) {
|
formatter(params) {
|
||||||
|
if (Array.isArray(params)) return "";
|
||||||
|
const { dataType, marker, data, value } =
|
||||||
|
params as TooltipCallbackDataParams;
|
||||||
if (dataType === "node") {
|
if (dataType === "node") {
|
||||||
|
const nodeData = data as NonNullable<SankeySeriesOption["nodes"]>[0];
|
||||||
return [
|
return [
|
||||||
marker,
|
marker,
|
||||||
`<span style="display:inline-block;margin-left:1em;">${data.name}</span>`,
|
`<span style="display:inline-block;margin-left:1em;">${nodeData.name}</span>`,
|
||||||
`<span style="display:inline-block;margin-left:2em;font-weight:bold;">${formatXps(
|
`<span style="display:inline-block;margin-left:2em;font-weight:bold;">${formatXps(
|
||||||
value
|
value.valueOf() as number
|
||||||
)}`,
|
)}`,
|
||||||
].join("");
|
].join("");
|
||||||
} else if (dataType === "edge") {
|
} else if (dataType === "edge") {
|
||||||
const source = data.source.split(": ").slice(1).join(": ");
|
const edgeData = data as NonNullable<SankeySeriesOption["edges"]>[0];
|
||||||
const target = data.target.split(": ").slice(1).join(": ");
|
const source =
|
||||||
return [
|
edgeData.source?.toString().split(": ").slice(1).join(": ") ??
|
||||||
`${source} → ${target}`,
|
"???";
|
||||||
`<span style="display:inline-block;margin-left:2em;font-weight:bold;">${formatXps(
|
const target =
|
||||||
data.value
|
edgeData.target?.toString().split(": ").slice(1).join(": ") ??
|
||||||
)}`,
|
"???";
|
||||||
].join("");
|
return value
|
||||||
|
? [
|
||||||
|
`${source} → ${target}`,
|
||||||
|
`<span style="display:inline-block;margin-left:2em;font-weight:bold;">${formatXps(
|
||||||
|
value.valueOf() as number
|
||||||
|
)}`,
|
||||||
|
].join("")
|
||||||
|
: "";
|
||||||
}
|
}
|
||||||
|
return "";
|
||||||
},
|
},
|
||||||
valueFormatter: formatXps,
|
valueFormatter: (value) => formatXps(value.valueOf() as number),
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,43 +4,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-chart
|
<v-chart
|
||||||
ref="chartComponent"
|
ref="chartComponent"
|
||||||
:option="echartsOptions"
|
:option="option"
|
||||||
:update-options="{ notMerge: true }"
|
:update-options="{ notMerge: true }"
|
||||||
@brush-end="updateTimeRange"
|
@brush-end="updateTimeRange"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
data: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {},
|
|
||||||
},
|
|
||||||
highlight: {
|
|
||||||
type: Number,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const emit = defineEmits(["update:timeRange"]);
|
|
||||||
|
|
||||||
import { ref, watch, inject, computed, onMounted, nextTick } from "vue";
|
import { ref, watch, inject, computed, onMounted, nextTick } from "vue";
|
||||||
import { useMediaQuery } from "@vueuse/core";
|
import { useMediaQuery } from "@vueuse/core";
|
||||||
import { formatXps, dataColor, dataColorGrey } from "@/utils";
|
import { formatXps, dataColor, dataColorGrey } from "@/utils";
|
||||||
import { graphTypes } from "./constants";
|
import { ThemeKey } from "@/components/ThemeProvider.vue";
|
||||||
const { isDark } = inject("theme");
|
import type { GraphHandlerResult } from ".";
|
||||||
|
|
||||||
import { uniqWith, isEqual, findIndex } from "lodash-es";
|
import { uniqWith, isEqual, findIndex } from "lodash-es";
|
||||||
import { use, graphic } from "echarts/core";
|
import { use, graphic, type ComposeOption } from "echarts/core";
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
import { LineChart } from "echarts/charts";
|
import { LineChart, type LineSeriesOption } from "echarts/charts";
|
||||||
import {
|
import {
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
|
type TooltipComponentOption,
|
||||||
GridComponent,
|
GridComponent,
|
||||||
|
type GridComponentOption,
|
||||||
BrushComponent,
|
BrushComponent,
|
||||||
|
type BrushComponentOption,
|
||||||
ToolboxComponent,
|
ToolboxComponent,
|
||||||
|
type ToolboxComponentOption,
|
||||||
DatasetComponent,
|
DatasetComponent,
|
||||||
|
type DatasetComponentOption,
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
|
type TitleComponentOption,
|
||||||
} from "echarts/components";
|
} from "echarts/components";
|
||||||
|
import type { default as BrushModel } from "echarts/types/src/component/brush/BrushModel";
|
||||||
|
import type { TooltipCallbackDataParams } from "echarts/types/src/component/tooltip/TooltipView";
|
||||||
import VChart from "vue-echarts";
|
import VChart from "vue-echarts";
|
||||||
use([
|
use([
|
||||||
CanvasRenderer,
|
CanvasRenderer,
|
||||||
@@ -52,10 +47,29 @@ use([
|
|||||||
DatasetComponent,
|
DatasetComponent,
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
]);
|
]);
|
||||||
|
type ECOption = ComposeOption<
|
||||||
|
| LineSeriesOption
|
||||||
|
| TooltipComponentOption
|
||||||
|
| GridComponentOption
|
||||||
|
| BrushComponentOption
|
||||||
|
| ToolboxComponentOption
|
||||||
|
| DatasetComponentOption
|
||||||
|
| TitleComponentOption
|
||||||
|
>;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: GraphHandlerResult;
|
||||||
|
highlight: number | null;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:timeRange", range: [Date, Date]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { isDark } = inject(ThemeKey)!;
|
||||||
|
|
||||||
// Graph component
|
// Graph component
|
||||||
const chartComponent = ref(null);
|
const chartComponent = ref<typeof VChart | null>(null);
|
||||||
const commonGraph = {
|
const commonGraph: ECOption = {
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
animationDuration: 500,
|
animationDuration: 500,
|
||||||
toolbox: {
|
toolbox: {
|
||||||
@@ -65,42 +79,48 @@ const commonGraph = {
|
|||||||
xAxisIndex: "all",
|
xAxisIndex: "all",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const graph = computed(() => {
|
const graph = computed((): ECOption => {
|
||||||
const theme = isDark.value ? "dark" : "light";
|
const theme = isDark.value ? "dark" : "light";
|
||||||
const data = props.data || {};
|
const data = props.data;
|
||||||
if (!data.t) return {};
|
if (!data) return {};
|
||||||
const rowName = (row) => row.join(" — ") || "Total",
|
const rowName = (row: string[]) => row.join(" — ") || "Total";
|
||||||
dataset = {
|
const source: [string, ...number[]][] = [
|
||||||
|
...data.t
|
||||||
|
.map((t, timeIdx) => {
|
||||||
|
const result: [string, ...number[]] = [
|
||||||
|
t,
|
||||||
|
...data.points.map(
|
||||||
|
// Unfortunately, eCharts does not seem to make it easy
|
||||||
|
// to inverse an axis and put the result below. Therefore,
|
||||||
|
// we use negative values for the second axis.
|
||||||
|
(row, rowIdx) => row[timeIdx] * (data.axis[rowIdx] % 2 ? 1 : -1)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.slice(0, -1), // trim last point
|
||||||
|
];
|
||||||
|
const dataset = {
|
||||||
sourceHeader: false,
|
sourceHeader: false,
|
||||||
dimensions: ["time", ...data.rows.map(rowName)],
|
dimensions: ["time", ...data.rows.map(rowName)],
|
||||||
source: [
|
source,
|
||||||
...data.t
|
|
||||||
.map((t, timeIdx) => [
|
|
||||||
t,
|
|
||||||
...data.points.map(
|
|
||||||
// Unfortunately, eCharts does not seem to make it easy
|
|
||||||
// to inverse an axis and put the result below. Therefore,
|
|
||||||
// we use negative values for the second axis.
|
|
||||||
(row, rowIdx) => row[timeIdx] * (data.axis[rowIdx] % 2 ? 1 : -1)
|
|
||||||
),
|
|
||||||
])
|
|
||||||
.slice(0, -1),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
xAxis = {
|
xAxis: ECOption["xAxis"] = {
|
||||||
type: "time",
|
type: "time",
|
||||||
min: data.start,
|
min: data.start,
|
||||||
max: data.end,
|
max: data.end,
|
||||||
},
|
},
|
||||||
yAxis = {
|
yAxis: ECOption["yAxis"] = {
|
||||||
type: "value",
|
type: "value",
|
||||||
min: data.bidirectional ? undefined : 0,
|
min: data.bidirectional ? undefined : 0,
|
||||||
axisLabel: { formatter: formatXps },
|
axisLabel: { formatter: formatXps },
|
||||||
axisPointer: {
|
axisPointer: {
|
||||||
label: { formatter: ({ value }) => formatXps(value) },
|
label: {
|
||||||
|
formatter: ({ value }) => formatXps(value.valueOf() as number),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tooltip = {
|
tooltip: ECOption["tooltip"] = {
|
||||||
confine: true,
|
confine: true,
|
||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
axisPointer: {
|
axisPointer: {
|
||||||
@@ -111,14 +131,22 @@ const graph = computed(() => {
|
|||||||
textStyle: isDark.value ? { color: "#ddd" } : { color: "#222" },
|
textStyle: isDark.value ? { color: "#ddd" } : { color: "#222" },
|
||||||
formatter: (params) => {
|
formatter: (params) => {
|
||||||
// We will use a custom formatter, notably to handle bidirectional tooltips.
|
// We will use a custom formatter, notably to handle bidirectional tooltips.
|
||||||
if (params.length === 0) return;
|
if (!Array.isArray(params) || params.length === 0) return "";
|
||||||
|
|
||||||
let table = [];
|
let table: {
|
||||||
params.forEach((param) => {
|
key: string;
|
||||||
|
seriesName: string;
|
||||||
|
marker: typeof params[0]["marker"];
|
||||||
|
up: number;
|
||||||
|
down: number;
|
||||||
|
}[] = [];
|
||||||
|
(params as TooltipCallbackDataParams[]).forEach((param) => {
|
||||||
|
if (!param.seriesIndex) return;
|
||||||
const axis = data.axis[param.seriesIndex];
|
const axis = data.axis[param.seriesIndex];
|
||||||
const seriesName = [1, 2].includes(axis)
|
const seriesName = [1, 2].includes(axis)
|
||||||
? param.seriesName
|
? param.seriesName
|
||||||
: data["axis-names"][axis];
|
: data["axis-names"][axis];
|
||||||
|
if (!seriesName) return;
|
||||||
const key = `${Math.floor((axis - 1) / 2)}-${seriesName}`;
|
const key = `${Math.floor((axis - 1) / 2)}-${seriesName}`;
|
||||||
let idx = findIndex(table, (r) => r.key === key);
|
let idx = findIndex(table, (r) => r.key === key);
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
@@ -131,7 +159,7 @@ const graph = computed(() => {
|
|||||||
});
|
});
|
||||||
idx = table.length - 1;
|
idx = table.length - 1;
|
||||||
}
|
}
|
||||||
const val = param.value[param.seriesIndex + 1];
|
const val = (param.value as number[])[param.seriesIndex + 1];
|
||||||
if (axis % 2 == 1) table[idx].up = val;
|
if (axis % 2 == 1) table[idx].up = val;
|
||||||
else table[idx].down = val;
|
else table[idx].down = val;
|
||||||
});
|
});
|
||||||
@@ -141,23 +169,26 @@ const graph = computed(() => {
|
|||||||
`<tr>`,
|
`<tr>`,
|
||||||
`<td>${row.marker} ${row.seriesName}</td>`,
|
`<td>${row.marker} ${row.seriesName}</td>`,
|
||||||
`<td class="pl-2">${data.bidirectional ? "↑" : ""}<b>${formatXps(
|
`<td class="pl-2">${data.bidirectional ? "↑" : ""}<b>${formatXps(
|
||||||
row.up || 0
|
row.up
|
||||||
)}</b></td>`,
|
)}</b></td>`,
|
||||||
data.bidirectional
|
data.bidirectional
|
||||||
? `<td class="pl-2">↓<b>${formatXps(row.down || 0)}</b></td>`
|
? `<td class="pl-2">↓<b>${formatXps(row.down)}</b></td>`
|
||||||
: "",
|
: "",
|
||||||
`</tr>`,
|
`</tr>`,
|
||||||
].join("")
|
].join("")
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
return `${params[0].axisValueLabel}<table>${rows}</table>`;
|
return `${
|
||||||
|
(params as TooltipCallbackDataParams[])[0].axisValueLabel
|
||||||
|
}<table>${rows}</table>`;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lines and stacked areas
|
// Lines and stacked areas
|
||||||
if ([graphTypes.stacked, graphTypes.lines].includes(data.graphType)) {
|
if (data.graphType === "stacked" || data.graphType === "lines") {
|
||||||
const uniqRows = uniqWith(data.rows, isEqual),
|
const uniqRows = uniqWith(data.rows, isEqual),
|
||||||
uniqRowIndex = (row) => findIndex(uniqRows, (orow) => isEqual(row, orow));
|
uniqRowIndex = (row: string[]) =>
|
||||||
|
findIndex(uniqRows, (orow) => isEqual(row, orow));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
grid: {
|
grid: {
|
||||||
@@ -174,10 +205,10 @@ const graph = computed(() => {
|
|||||||
.map((row, idx) => {
|
.map((row, idx) => {
|
||||||
const isOther = row.some((name) => name === "Other"),
|
const isOther = row.some((name) => name === "Other"),
|
||||||
color = isOther ? dataColorGrey : dataColor;
|
color = isOther ? dataColorGrey : dataColor;
|
||||||
if (data.graphType === graphTypes.lines && isOther) {
|
if (data.graphType === "lines" && isOther) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
let serie = {
|
let serie: LineSeriesOption = {
|
||||||
type: "line",
|
type: "line",
|
||||||
symbol: "none",
|
symbol: "none",
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
@@ -214,13 +245,10 @@ const graph = computed(() => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (
|
if (data.graphType === "stacked" && [1, 2].includes(data.axis[idx])) {
|
||||||
data.graphType === graphTypes.stacked &&
|
|
||||||
[1, 2].includes(data.axis[idx])
|
|
||||||
) {
|
|
||||||
serie = {
|
serie = {
|
||||||
...serie,
|
...serie,
|
||||||
stack: data.axis[idx],
|
stack: data.axis[idx].toString(),
|
||||||
lineStyle:
|
lineStyle:
|
||||||
idx == data.rows.length - 1 ||
|
idx == data.rows.length - 1 ||
|
||||||
data.axis[idx] != data.axis[idx + 1]
|
data.axis[idx] != data.axis[idx + 1]
|
||||||
@@ -243,26 +271,28 @@ const graph = computed(() => {
|
|||||||
}
|
}
|
||||||
return serie;
|
return serie;
|
||||||
})
|
})
|
||||||
.filter((s) => s !== undefined),
|
.filter((s): s is LineSeriesOption => !!s),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (data.graphType === graphTypes.grid) {
|
if (data.graphType === "grid") {
|
||||||
const uniqRows = uniqWith(data.rows, isEqual).filter((row) =>
|
const uniqRows = uniqWith(data.rows, isEqual).filter((row) =>
|
||||||
row.some((name) => name !== "Other")
|
row.some((name) => name !== "Other")
|
||||||
),
|
),
|
||||||
uniqRowIndex = (row) => findIndex(uniqRows, (orow) => isEqual(row, orow)),
|
uniqRowIndex = (row: string[]) =>
|
||||||
|
findIndex(uniqRows, (orow) => isEqual(row, orow)),
|
||||||
otherIndexes = data.rows
|
otherIndexes = data.rows
|
||||||
.map((row, idx) => (row.some((name) => name === "Other") ? idx : -1))
|
.map((row, idx) => (row.some((name) => name === "Other") ? idx : -1))
|
||||||
.filter((idx) => idx >= 0),
|
.filter((idx) => idx >= 0),
|
||||||
somethingY = (fn) =>
|
somethingY = (fn: (...n: number[]) => number) =>
|
||||||
fn(
|
fn.apply(
|
||||||
...dataset.source.map((row) =>
|
null,
|
||||||
fn(
|
dataset.source.map((row) => {
|
||||||
...row
|
const [, ...cdr] = row;
|
||||||
.slice(1)
|
return fn.apply(
|
||||||
.filter((_, idx) => !otherIndexes.includes(idx + 1))
|
null,
|
||||||
)
|
cdr.filter((_, idx) => !otherIndexes.includes(idx + 1))
|
||||||
)
|
);
|
||||||
|
})
|
||||||
),
|
),
|
||||||
maxY = somethingY(Math.max),
|
maxY = somethingY(Math.max),
|
||||||
minY = somethingY(Math.min);
|
minY = somethingY(Math.min);
|
||||||
@@ -313,7 +343,7 @@ const graph = computed(() => {
|
|||||||
dataset,
|
dataset,
|
||||||
series: data.rows
|
series: data.rows
|
||||||
.map((row, idx) => {
|
.map((row, idx) => {
|
||||||
let serie = {
|
let serie: LineSeriesOption = {
|
||||||
type: "line",
|
type: "line",
|
||||||
symbol: "none",
|
symbol: "none",
|
||||||
xAxisIndex: uniqRowIndex(row),
|
xAxisIndex: uniqRowIndex(row),
|
||||||
@@ -346,12 +376,12 @@ const graph = computed(() => {
|
|||||||
};
|
};
|
||||||
return serie;
|
return serie;
|
||||||
})
|
})
|
||||||
.filter((s) => s.xAxisIndex >= 0),
|
.filter((s) => s.xAxisIndex! >= 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
const echartsOptions = computed(() => ({ ...commonGraph, ...graph.value }));
|
const option = computed((): ECOption => ({ ...commonGraph, ...graph.value }));
|
||||||
|
|
||||||
// Enable and handle brush
|
// Enable and handle brush
|
||||||
const isTouchScreen = useMediaQuery("(pointer: coarse");
|
const isTouchScreen = useMediaQuery("(pointer: coarse");
|
||||||
@@ -367,22 +397,28 @@ const enableBrush = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
onMounted(enableBrush);
|
onMounted(enableBrush);
|
||||||
const updateTimeRange = (evt) => {
|
const updateTimeRange = (evt: BrushModel) => {
|
||||||
if (evt.areas.length === 0) {
|
if (
|
||||||
|
!chartComponent.value ||
|
||||||
|
evt.areas.length === 0 ||
|
||||||
|
!evt.areas[0].coordRange
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [start, end] = evt.areas[0].coordRange.map((t) => new Date(t));
|
const [start, end] = evt.areas[0].coordRange.map(
|
||||||
|
(t) => new Date(t as number)
|
||||||
|
);
|
||||||
chartComponent.value.dispatchAction({
|
chartComponent.value.dispatchAction({
|
||||||
type: "brush",
|
type: "brush",
|
||||||
areas: [],
|
areas: [],
|
||||||
});
|
});
|
||||||
emit("update:timeRange", [start, end]);
|
emit("update:timeRange", [start, end]);
|
||||||
};
|
};
|
||||||
watch([graph, isTouchScreen], enableBrush);
|
watch([graph, isTouchScreen] as const, enableBrush);
|
||||||
|
|
||||||
// Highlight selected indexes
|
// Highlight selected indexes
|
||||||
watch(
|
watch(
|
||||||
() => [props.highlight, props.data],
|
() => [props.highlight, props.data] as const,
|
||||||
([index]) => {
|
([index]) => {
|
||||||
chartComponent.value?.dispatchAction({
|
chartComponent.value?.dispatchAction({
|
||||||
type: "highlight",
|
type: "highlight",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<!-- Axis selection -->
|
<!-- Axis selection -->
|
||||||
<div
|
<div
|
||||||
v-if="axes.length > 1"
|
v-if="axes && axes.length > 1"
|
||||||
class="border-b border-gray-200 text-center text-sm font-medium text-gray-500 dark:border-gray-700 dark:text-gray-400"
|
class="border-b border-gray-200 text-center text-sm font-medium text-gray-500 dark:border-gray-700 dark:text-gray-400"
|
||||||
>
|
>
|
||||||
<ul class="flex flex-wrap">
|
<ul class="flex flex-wrap">
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
'active border-blue-600 text-blue-600 dark:border-blue-500 dark:text-blue-500':
|
'active border-blue-600 text-blue-600 dark:border-blue-500 dark:text-blue-500':
|
||||||
displayedAxis === axis,
|
displayedAxis === axis,
|
||||||
}"
|
}"
|
||||||
:aria-current="displayedAxis === axis ? 'page' : null"
|
:aria-current="displayedAxis === axis ? 'page' : undefined"
|
||||||
@click="selectedAxis = axis"
|
@click="selectedAxis = axis"
|
||||||
>
|
>
|
||||||
{{ name }}
|
{{ name }}
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
class="relative overflow-x-auto shadow-md dark:shadow-white/10 sm:rounded-lg"
|
class="relative overflow-x-auto shadow-md dark:shadow-white/10 sm:rounded-lg"
|
||||||
>
|
>
|
||||||
<table
|
<table
|
||||||
|
v-if="table"
|
||||||
class="w-full max-w-full text-left text-sm text-gray-700 dark:text-gray-200"
|
class="w-full max-w-full text-left text-sm text-gray-700 dark:text-gray-200"
|
||||||
>
|
>
|
||||||
<thead class="bg-gray-50 text-xs uppercase dark:bg-gray-700">
|
<thead class="bg-gray-50 text-xs uppercase dark:bg-gray-700">
|
||||||
@@ -84,123 +85,136 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
data: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const emit = defineEmits(["highlighted"]);
|
|
||||||
|
|
||||||
import { computed, inject, ref } from "vue";
|
import { computed, inject, ref } from "vue";
|
||||||
import { formatXps, dataColor, dataColorGrey } from "@/utils";
|
|
||||||
import { graphTypes } from "./constants";
|
|
||||||
const { isDark } = inject("theme");
|
|
||||||
const { stacked, lines, grid, sankey } = graphTypes;
|
|
||||||
|
|
||||||
import { uniqWith, isEqual, findIndex, takeWhile, toPairs } from "lodash-es";
|
import { uniqWith, isEqual, findIndex, takeWhile, toPairs } from "lodash-es";
|
||||||
|
import { formatXps, dataColor, dataColorGrey } from "@/utils";
|
||||||
|
import { ThemeKey } from "@/components/ThemeProvider.vue";
|
||||||
|
import type { GraphHandlerResult, SankeyHandlerResult } from ".";
|
||||||
|
const { isDark } = inject(ThemeKey)!;
|
||||||
|
|
||||||
const highlight = (index) => {
|
const props = defineProps<{
|
||||||
if (index === null) {
|
data: GraphHandlerResult | SankeyHandlerResult | null;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "highlighted", index: number | null): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const highlight = (index: number | null) => {
|
||||||
|
if (
|
||||||
|
index === null ||
|
||||||
|
props.data == null ||
|
||||||
|
props.data.graphType == "sankey"
|
||||||
|
) {
|
||||||
emit("highlighted", null);
|
emit("highlighted", null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (![stacked, lines, grid].includes(props.data?.graphType)) return;
|
|
||||||
// The index provided is the one in the filtered data. We want the original index.
|
// The index provided is the one in the filtered data. We want the original index.
|
||||||
|
const axis = props.data.axis;
|
||||||
const originalIndex = takeWhile(
|
const originalIndex = takeWhile(
|
||||||
props.data.rows,
|
props.data.rows,
|
||||||
(() => {
|
(() => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
return (_, idx) =>
|
return (_, idx) => axis[idx] != displayedAxis.value || count++ < index;
|
||||||
props.data.axis[idx] != displayedAxis.value || count++ < index;
|
|
||||||
})()
|
})()
|
||||||
).length;
|
).length;
|
||||||
emit("highlighted", originalIndex);
|
emit("highlighted", originalIndex);
|
||||||
};
|
};
|
||||||
const axes = computed(() =>
|
const axes = computed(() => {
|
||||||
toPairs(props.data["axis-names"])
|
if (!props.data || props.data.graphType === "sankey") return null;
|
||||||
|
return toPairs(props.data["axis-names"])
|
||||||
.map(([k, v]) => ({ id: Number(k), name: v }))
|
.map(([k, v]) => ({ id: Number(k), name: v }))
|
||||||
.filter(({ id }) => [1, 2].includes(id))
|
.filter(({ id }) => [1, 2].includes(id))
|
||||||
.sort(({ id: id1 }, { id: id2 }) => id1 - id2)
|
.sort(({ id: id1 }, { id: id2 }) => id1 - id2);
|
||||||
);
|
|
||||||
const selectedAxis = ref(1);
|
|
||||||
const displayedAxis = computed(() =>
|
|
||||||
axes.value.some((axis) => axis.id === selectedAxis.value)
|
|
||||||
? selectedAxis.value
|
|
||||||
: 1
|
|
||||||
);
|
|
||||||
const table = computed(() => {
|
|
||||||
const theme = isDark.value ? "dark" : "light";
|
|
||||||
const data = props.data || {};
|
|
||||||
if ([stacked, lines, grid].includes(data.graphType)) {
|
|
||||||
const uniqRows = uniqWith(data.rows, isEqual),
|
|
||||||
uniqRowIndex = (row) => findIndex(uniqRows, (orow) => isEqual(row, orow));
|
|
||||||
return {
|
|
||||||
columns: [
|
|
||||||
// Dimensions
|
|
||||||
...(data.dimensions?.map((col) => ({
|
|
||||||
name: col.replace(/([a-z])([A-Z])/g, "$1 $2"),
|
|
||||||
})) || []),
|
|
||||||
// Stats
|
|
||||||
{ name: "Min", classNames: "text-right" },
|
|
||||||
{ name: "Max", classNames: "text-right" },
|
|
||||||
{ name: "Average", classNames: "text-right" },
|
|
||||||
{ name: "~95th", classNames: "text-right" },
|
|
||||||
],
|
|
||||||
rows:
|
|
||||||
data.rows
|
|
||||||
?.map((row, idx) => {
|
|
||||||
const color = row.some((name) => name === "Other")
|
|
||||||
? dataColorGrey
|
|
||||||
: dataColor;
|
|
||||||
return {
|
|
||||||
values: [
|
|
||||||
// Dimensions
|
|
||||||
...row.map((r) => ({ value: r })),
|
|
||||||
// Stats
|
|
||||||
...[
|
|
||||||
data.min[idx],
|
|
||||||
data.max[idx],
|
|
||||||
data.average[idx],
|
|
||||||
data["95th"][idx],
|
|
||||||
].map((d) => ({
|
|
||||||
value: formatXps(d) + data.units.slice(-3),
|
|
||||||
classNames: "text-right tabular-nums",
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
color: color(uniqRowIndex(row), false, theme),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((_, idx) => data.axis[idx] == displayedAxis.value) || [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if ([sankey].includes(data.graphType)) {
|
|
||||||
return {
|
|
||||||
columns: [
|
|
||||||
// Dimensions
|
|
||||||
...(data.dimensions?.map((col) => ({
|
|
||||||
name: col.replace(/([a-z])([A-Z])/, "$1 $2"),
|
|
||||||
})) || []),
|
|
||||||
// Average
|
|
||||||
{ name: "Average", classNames: "text-right" },
|
|
||||||
],
|
|
||||||
rows: data.rows?.map((row, idx) => ({
|
|
||||||
values: [
|
|
||||||
// Dimensions
|
|
||||||
...row.map((r) => ({ value: r })),
|
|
||||||
// Average
|
|
||||||
{
|
|
||||||
value: formatXps(data.xps[idx]) + data.units.slice(-3),
|
|
||||||
classNames: "text-right tabular-nums",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
columns: [],
|
|
||||||
rows: [],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
const selectedAxis = ref(1);
|
||||||
|
const displayedAxis = computed(() => {
|
||||||
|
if (!axes.value) return null;
|
||||||
|
return axes.value.some((axis) => axis.id === selectedAxis.value)
|
||||||
|
? selectedAxis.value
|
||||||
|
: 1;
|
||||||
|
});
|
||||||
|
const table = computed(
|
||||||
|
(): {
|
||||||
|
columns: { name: string; classNames?: string }[];
|
||||||
|
rows: {
|
||||||
|
values: { value: string; classNames?: string }[];
|
||||||
|
color?: string;
|
||||||
|
}[];
|
||||||
|
} | null => {
|
||||||
|
const theme = isDark.value ? "dark" : "light";
|
||||||
|
const data = props.data;
|
||||||
|
if (data === null) return null;
|
||||||
|
if (
|
||||||
|
data.graphType === "stacked" ||
|
||||||
|
data.graphType === "lines" ||
|
||||||
|
data.graphType === "grid"
|
||||||
|
) {
|
||||||
|
const uniqRows = uniqWith(data.rows, isEqual),
|
||||||
|
uniqRowIndex = (row: string[]) =>
|
||||||
|
findIndex(uniqRows, (orow) => isEqual(row, orow));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
// Dimensions
|
||||||
|
...(data.dimensions.map((col) => ({
|
||||||
|
name: col.replace(/([a-z])([A-Z])/g, "$1 $2"),
|
||||||
|
})) || []),
|
||||||
|
// Stats
|
||||||
|
{ name: "Min", classNames: "text-right" },
|
||||||
|
{ name: "Max", classNames: "text-right" },
|
||||||
|
{ name: "Average", classNames: "text-right" },
|
||||||
|
{ name: "~95th", classNames: "text-right" },
|
||||||
|
],
|
||||||
|
rows:
|
||||||
|
data.rows
|
||||||
|
.map((row, idx) => {
|
||||||
|
const color = row.some((name) => name === "Other")
|
||||||
|
? dataColorGrey
|
||||||
|
: dataColor;
|
||||||
|
return {
|
||||||
|
values: [
|
||||||
|
// Dimensions
|
||||||
|
...row.map((r) => ({ value: r })),
|
||||||
|
// Stats
|
||||||
|
...[
|
||||||
|
data.min[idx],
|
||||||
|
data.max[idx],
|
||||||
|
data.average[idx],
|
||||||
|
data["95th"][idx],
|
||||||
|
].map((d) => ({
|
||||||
|
value: formatXps(d) + data.units.slice(-3),
|
||||||
|
classNames: "text-right tabular-nums",
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
color: color(uniqRowIndex(row), false, theme),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((_, idx) => data.axis[idx] == displayedAxis.value) || [],
|
||||||
|
};
|
||||||
|
} else if (data.graphType === "sankey") {
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
// Dimensions
|
||||||
|
...(data.dimensions?.map((col) => ({
|
||||||
|
name: col.replace(/([a-z])([A-Z])/, "$1 $2"),
|
||||||
|
})) || []),
|
||||||
|
// Average
|
||||||
|
{ name: "Average", classNames: "text-right" },
|
||||||
|
],
|
||||||
|
rows: data.rows?.map((row, idx) => ({
|
||||||
|
values: [
|
||||||
|
// Dimensions
|
||||||
|
...row.map((r) => ({ value: r })),
|
||||||
|
// Average
|
||||||
|
{
|
||||||
|
value: formatXps(data.xps[idx]) + data.units.slice(-3),
|
||||||
|
classNames: "text-right tabular-nums",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -52,19 +52,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts">
|
||||||
export default {
|
export default {
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
defineProps({
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import { graphTypes } from "./constants.js";
|
import { graphTypes } from "./constants.js";
|
||||||
|
|
||||||
|
defineProps<{ name: string }>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -72,12 +72,16 @@
|
|||||||
class="order-4 flex grow flex-row justify-between gap-x-3 sm:max-lg:order-2 sm:max-lg:grow-0 sm:max-lg:flex-col"
|
class="order-4 flex grow flex-row justify-between gap-x-3 sm:max-lg:order-2 sm:max-lg:grow-0 sm:max-lg:flex-col"
|
||||||
>
|
>
|
||||||
<InputCheckbox
|
<InputCheckbox
|
||||||
v-if="[stacked, lines, grid].includes(graphType.name)"
|
v-if="
|
||||||
|
graphType.type === 'stacked' ||
|
||||||
|
graphType.type === 'lines' ||
|
||||||
|
graphType.type === 'grid'
|
||||||
|
"
|
||||||
v-model="bidirectional"
|
v-model="bidirectional"
|
||||||
label="Bidirectional"
|
label="Bidirectional"
|
||||||
/>
|
/>
|
||||||
<InputCheckbox
|
<InputCheckbox
|
||||||
v-if="[stacked].includes(graphType.name)"
|
v-if="graphType.type === 'stacked'"
|
||||||
v-model="previousPeriod"
|
v-model="previousPeriod"
|
||||||
label="Previous period"
|
label="Previous period"
|
||||||
/>
|
/>
|
||||||
@@ -106,45 +110,57 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
import { ref, watch, computed, inject, toRaw } from "vue";
|
||||||
modelValue: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const emit = defineEmits(["update:modelValue", "cancel"]);
|
|
||||||
|
|
||||||
import { ref, watch, computed, inject } from "vue";
|
|
||||||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/vue/solid";
|
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/vue/solid";
|
||||||
import InputTimeRange from "@/components/InputTimeRange.vue";
|
import {
|
||||||
import InputDimensions from "@/components/InputDimensions.vue";
|
default as InputTimeRange,
|
||||||
|
type ModelType as InputTimeRangeModelType,
|
||||||
|
} from "@/components/InputTimeRange.vue";
|
||||||
|
import {
|
||||||
|
default as InputDimensions,
|
||||||
|
type ModelType as InputDimensionsModelType,
|
||||||
|
} from "@/components/InputDimensions.vue";
|
||||||
import InputListBox from "@/components/InputListBox.vue";
|
import InputListBox from "@/components/InputListBox.vue";
|
||||||
import InputButton from "@/components/InputButton.vue";
|
import InputButton from "@/components/InputButton.vue";
|
||||||
import InputCheckbox from "@/components/InputCheckbox.vue";
|
import InputCheckbox from "@/components/InputCheckbox.vue";
|
||||||
import InputChoice from "@/components/InputChoice.vue";
|
import InputChoice from "@/components/InputChoice.vue";
|
||||||
import InputFilter from "@/components/InputFilter.vue";
|
import {
|
||||||
|
default as InputFilter,
|
||||||
|
type ModelType as InputFilterModelType,
|
||||||
|
} from "@/components/InputFilter.vue";
|
||||||
|
import { ServerConfigKey } from "@/components/ServerConfigProvider.vue";
|
||||||
import SectionLabel from "./SectionLabel.vue";
|
import SectionLabel from "./SectionLabel.vue";
|
||||||
import GraphIcon from "./GraphIcon.vue";
|
import GraphIcon from "./GraphIcon.vue";
|
||||||
import { graphTypes } from "./constants";
|
import type { Units } from ".";
|
||||||
import { isEqual } from "lodash-es";
|
import { isEqual } from "lodash-es";
|
||||||
|
|
||||||
const graphTypeList = Object.entries(graphTypes).map(([, v], idx) => ({
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: ModelType;
|
||||||
|
loading?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: typeof props.modelValue): void;
|
||||||
|
(e: "cancel"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const graphTypeList = Object.entries(graphTypes).map(([k, v], idx) => ({
|
||||||
id: idx + 1,
|
id: idx + 1,
|
||||||
|
type: k as keyof typeof graphTypes, // why isn't it infered?
|
||||||
name: v,
|
name: v,
|
||||||
}));
|
}));
|
||||||
const { stacked, lines, grid } = graphTypes;
|
|
||||||
|
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const graphType = ref(graphTypeList[0]);
|
const graphType = ref(graphTypeList[0]);
|
||||||
const timeRange = ref({});
|
const timeRange = ref<InputTimeRangeModelType>(null);
|
||||||
const dimensions = ref([]);
|
const dimensions = ref<InputDimensionsModelType>(null);
|
||||||
const filter = ref({});
|
const filter = ref<InputFilterModelType>(null);
|
||||||
const units = ref("l3bps");
|
const units = ref<Units>("l3bps");
|
||||||
const bidirectional = ref(false);
|
const bidirectional = ref(false);
|
||||||
const previousPeriod = ref(false);
|
const previousPeriod = ref(false);
|
||||||
|
|
||||||
@@ -156,71 +172,84 @@ const submitOptions = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = computed(() => ({
|
const options = computed((): ModelType => {
|
||||||
// Common to all graph types
|
if (!timeRange.value || !dimensions.value || !filter.value) {
|
||||||
graphType: graphType.value.name,
|
return options.value;
|
||||||
start: timeRange.value.start,
|
}
|
||||||
end: timeRange.value.end,
|
return {
|
||||||
dimensions: dimensions.value.selected,
|
graphType: graphType.value.type,
|
||||||
limit: dimensions.value.limit,
|
start: timeRange.value?.start,
|
||||||
filter: filter.value.expression,
|
end: timeRange.value?.end,
|
||||||
units: units.value,
|
dimensions: dimensions.value?.selected,
|
||||||
// Depending on the graph type...
|
limit: dimensions.value?.limit,
|
||||||
...([stacked, lines].includes(graphType.value.name) && {
|
filter: filter.value?.expression,
|
||||||
bidirectional: bidirectional.value,
|
units: units.value,
|
||||||
previousPeriod:
|
bidirectional: false,
|
||||||
graphType.value.name === stacked ? previousPeriod.value : false,
|
|
||||||
points: 200,
|
|
||||||
}),
|
|
||||||
...(graphType.value.name === grid && {
|
|
||||||
bidirectional: bidirectional.value,
|
|
||||||
previousPeriod: false,
|
previousPeriod: false,
|
||||||
points: 50,
|
// Depending on the graph type...
|
||||||
}),
|
...(graphType.value.type === "stacked" && {
|
||||||
}));
|
bidirectional: bidirectional.value,
|
||||||
|
previousPeriod: previousPeriod.value,
|
||||||
|
}),
|
||||||
|
...(graphType.value.type === "lines" && {
|
||||||
|
bidirectional: bidirectional.value,
|
||||||
|
}),
|
||||||
|
...(graphType.value.type === "grid" && {
|
||||||
|
bidirectional: bidirectional.value,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
const applyLabel = computed(() =>
|
const applyLabel = computed(() =>
|
||||||
isEqual(options.value, props.modelValue) ? "Refresh" : "Apply"
|
isEqual(options.value, props.modelValue) ? "Refresh" : "Apply"
|
||||||
);
|
);
|
||||||
const hasErrors = computed(
|
const hasErrors = computed(
|
||||||
() =>
|
() =>
|
||||||
!!(timeRange.value.errors || dimensions.value.errors || filter.value.errors)
|
!!(
|
||||||
|
timeRange.value?.errors ||
|
||||||
|
dimensions.value?.errors ||
|
||||||
|
filter.value?.errors
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const serverConfiguration = inject("server-configuration");
|
const serverConfiguration = inject(ServerConfigKey)!;
|
||||||
watch(
|
watch(
|
||||||
() => [props.modelValue, serverConfiguration.value?.defaultVisualizeOptions],
|
() =>
|
||||||
|
[
|
||||||
|
props.modelValue,
|
||||||
|
serverConfiguration.value?.defaultVisualizeOptions,
|
||||||
|
] as const,
|
||||||
([modelValue, defaultOptions]) => {
|
([modelValue, defaultOptions]) => {
|
||||||
if (defaultOptions === undefined) return;
|
if (!defaultOptions) return;
|
||||||
const {
|
const currentValue: NonNullable<ModelType> = modelValue ?? {
|
||||||
graphType: _graphType = graphTypes.stacked,
|
graphType: "stacked",
|
||||||
start = defaultOptions?.start,
|
start: defaultOptions.start,
|
||||||
end = defaultOptions?.end,
|
end: defaultOptions.end,
|
||||||
dimensions: _dimensions = defaultOptions?.dimensions,
|
dimensions: toRaw(defaultOptions.dimensions),
|
||||||
limit = 10,
|
limit: 10,
|
||||||
points /* eslint-disable-line no-unused-vars */,
|
filter: defaultOptions.filter,
|
||||||
filter: _filter = defaultOptions?.filter,
|
units: "l3bps",
|
||||||
units: _units = "l3bps",
|
bidirectional: false,
|
||||||
bidirectional: _bidirectional = false,
|
previousPeriod: false,
|
||||||
previousPeriod: _previousPeriod = false,
|
};
|
||||||
} = modelValue;
|
|
||||||
|
|
||||||
// Dispatch values in refs
|
// Dispatch values in refs
|
||||||
|
const t = currentValue.graphType;
|
||||||
graphType.value =
|
graphType.value =
|
||||||
graphTypeList.find(({ name }) => name === _graphType) || graphTypeList[0];
|
graphTypeList.find(({ type }) => type === t) || graphTypeList[0];
|
||||||
timeRange.value = { start, end };
|
timeRange.value = { start: currentValue.start, end: currentValue.end };
|
||||||
dimensions.value = {
|
dimensions.value = {
|
||||||
selected: [..._dimensions],
|
selected: [...currentValue.dimensions],
|
||||||
limit,
|
limit: currentValue.limit,
|
||||||
};
|
};
|
||||||
filter.value = { expression: _filter };
|
filter.value = { expression: currentValue.filter };
|
||||||
units.value = _units;
|
units.value = currentValue.units;
|
||||||
bidirectional.value = _bidirectional;
|
bidirectional.value = currentValue.bidirectional;
|
||||||
previousPeriod.value = _previousPeriod;
|
previousPeriod.value = currentValue.previousPeriod;
|
||||||
|
|
||||||
// A bit risky, but it seems to work.
|
// A bit risky, but it seems to work.
|
||||||
if (!isEqual(modelValue, options.value)) {
|
if (!isEqual(modelValue, options.value)) {
|
||||||
open.value = true;
|
open.value = true;
|
||||||
if (!hasErrors.value && start) {
|
if (!hasErrors.value) {
|
||||||
emit("update:modelValue", options.value);
|
emit("update:modelValue", options.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,3 +257,19 @@ watch(
|
|||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { graphTypes } from "./constants";
|
||||||
|
|
||||||
|
export type ModelType = {
|
||||||
|
graphType: keyof typeof graphTypes;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
dimensions: string[];
|
||||||
|
limit: number;
|
||||||
|
filter: string;
|
||||||
|
units: Units;
|
||||||
|
bidirectional: boolean;
|
||||||
|
previousPeriod: boolean;
|
||||||
|
} | null;
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -3,29 +3,29 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
v-if="request"
|
||||||
class="z-10 flex w-full flex-wrap items-center gap-x-3 whitespace-nowrap border-b border-gray-300 bg-gray-100 px-4 text-xs text-gray-400 dark:border-slate-700 dark:bg-slate-800 dark:text-gray-500 sm:flex-nowrap print:sm:flex-wrap"
|
class="z-10 flex w-full flex-wrap items-center gap-x-3 whitespace-nowrap border-b border-gray-300 bg-gray-100 px-4 text-xs text-gray-400 dark:border-slate-700 dark:bg-slate-800 dark:text-gray-500 sm:flex-nowrap print:sm:flex-wrap"
|
||||||
>
|
>
|
||||||
<span v-if="request.start && request.end" class="shrink-0 py-0.5">
|
<span class="shrink-0 py-0.5">
|
||||||
<CalendarIcon class="inline h-4 px-1 align-middle" />
|
<CalendarIcon class="inline h-4 px-1 align-middle" />
|
||||||
<span class="align-middle">{{ start }} — {{ end }}</span>
|
<span class="align-middle">{{ start }} — {{ end }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="request.graphType" class="shrink-0 py-0.5">
|
<span class="shrink-0 py-0.5">
|
||||||
<ChartPieIcon class="inline h-4 px-1 align-middle" />
|
<ChartPieIcon class="inline h-4 px-1 align-middle" />
|
||||||
<span class="align-middle">{{ request.graphType }}</span>
|
<span class="align-middle">{{ graphTypes[request.graphType] }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="request.limit" class="min-w-[4 shrink-0 py-0.5">
|
<span class="min-w-[4 shrink-0 py-0.5">
|
||||||
<ArrowUpIcon class="inline h-4 px-1 align-middle" />
|
<ArrowUpIcon class="inline h-4 px-1 align-middle" />
|
||||||
<span class="align-middle">{{ request.limit }}</span>
|
<span class="align-middle">{{ request.limit }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="request.units" class="min-w-[4 shrink-0 py-0.5">
|
<span class="min-w-[4 shrink-0 py-0.5">
|
||||||
<HashtagIcon class="inline h-4 px-1 align-middle" />
|
<HashtagIcon class="inline h-4 px-1 align-middle" />
|
||||||
<span class="align-middle">{{
|
<span class="align-middle">{{
|
||||||
{ l3bps: "L3ᵇ⁄ₛ", l2bps: "L2ᵇ⁄ₛ", pps: "ᵖ⁄ₛ" }[request.units] ||
|
{ l3bps: "L3ᵇ⁄ₛ", l2bps: "L2ᵇ⁄ₛ", pps: "ᵖ⁄ₛ" }[request.units]
|
||||||
requests.units
|
|
||||||
}}</span>
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="request.dimensions && request.dimensions.length > 0"
|
v-if="request.dimensions.length > 0"
|
||||||
class="min-w-[3rem] truncate py-0.5"
|
class="min-w-[3rem] truncate py-0.5"
|
||||||
:title="request.dimensions.join(', ')"
|
:title="request.dimensions.join(', ')"
|
||||||
>
|
>
|
||||||
@@ -43,14 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
const props = defineProps({
|
|
||||||
request: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
import { computed, watch, inject } from "vue";
|
import { computed, watch, inject } from "vue";
|
||||||
import {
|
import {
|
||||||
ChartPieIcon,
|
ChartPieIcon,
|
||||||
@@ -61,23 +54,33 @@ import {
|
|||||||
HashtagIcon,
|
HashtagIcon,
|
||||||
} from "@heroicons/vue/solid";
|
} from "@heroicons/vue/solid";
|
||||||
import { Date as SugarDate } from "sugar-date";
|
import { Date as SugarDate } from "sugar-date";
|
||||||
|
import type { ModelType } from "./OptionsPanel.vue";
|
||||||
|
import { graphTypes } from "./constants";
|
||||||
|
import { TitleKey } from "@/components/TitleProvider.vue";
|
||||||
|
|
||||||
const start = computed(() => SugarDate(props.request.start).long());
|
const props = defineProps<{ request: ModelType }>();
|
||||||
|
|
||||||
|
const start = computed(() =>
|
||||||
|
props.request ? SugarDate(props.request.start).long() : null
|
||||||
|
);
|
||||||
const end = computed(() =>
|
const end = computed(() =>
|
||||||
SugarDate(props.request.end).format(
|
props.request
|
||||||
props.request.start?.toDateString() === props.request.end?.toDateString()
|
? SugarDate(props.request.end).format(
|
||||||
? "%X"
|
SugarDate(props.request.start).toDateString() ===
|
||||||
: "{long}"
|
SugarDate(props.request.end).toDateString()
|
||||||
)
|
? "%X"
|
||||||
|
: "{long}"
|
||||||
|
)
|
||||||
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Also set title
|
// Also set title
|
||||||
const title = inject("title");
|
const title = inject(TitleKey)!;
|
||||||
const computedTitle = computed(() =>
|
const computedTitle = computed(() =>
|
||||||
[
|
[
|
||||||
props.request.graphType,
|
props.request ? graphTypes[props.request?.graphType] : null,
|
||||||
props.request?.dimensions?.join(","),
|
props.request?.dimensions?.join(","),
|
||||||
props.request.filter,
|
props.request?.filter,
|
||||||
start.value,
|
start.value,
|
||||||
end.value,
|
end.value,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export const graphTypes = {
|
|||||||
lines: "Lines",
|
lines: "Lines",
|
||||||
grid: "Grid",
|
grid: "Grid",
|
||||||
sankey: "Sankey",
|
sankey: "Sankey",
|
||||||
};
|
} as const;
|
||||||
|
export type GraphType = keyof typeof graphTypes;
|
||||||
49
console/frontend/src/views/VisualizePage/index.d.ts
vendored
Normal file
49
console/frontend/src/views/VisualizePage/index.d.ts
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2022 Free Mobile
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { GraphType } from "./constants";
|
||||||
|
|
||||||
|
export type Units = "l3bps" | "l2bps" | "pps";
|
||||||
|
export type SankeyHandlerInput = {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
dimensions: string[];
|
||||||
|
limit: number;
|
||||||
|
filter: string;
|
||||||
|
units: Units;
|
||||||
|
};
|
||||||
|
export type GraphHandlerInput = SankeyHandlerInput & {
|
||||||
|
points: number;
|
||||||
|
bidirectional: boolean;
|
||||||
|
"previous-period": boolean;
|
||||||
|
};
|
||||||
|
export type SankeyHandlerOutput = {
|
||||||
|
rows: string[][];
|
||||||
|
xps: number[];
|
||||||
|
nodes: string[];
|
||||||
|
links: {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
xps: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
export type GraphHandlerOutput = {
|
||||||
|
t: string[];
|
||||||
|
rows: string[][];
|
||||||
|
points: number[][];
|
||||||
|
axis: number[];
|
||||||
|
"axis-names": Record<number, string>;
|
||||||
|
average: number[];
|
||||||
|
min: number[];
|
||||||
|
max: number[];
|
||||||
|
"95th": number[];
|
||||||
|
};
|
||||||
|
export type SankeyHandlerResult = SankeyHandlerOutput & {
|
||||||
|
graphType: Extract<GraphType, "sankey">;
|
||||||
|
} & Pick<SankeyHandlerInput, "start" | "end" | "dimensions" | "units">;
|
||||||
|
export type GraphHandlerResult = GraphHandlerOutput & {
|
||||||
|
graphType: Exclude<GraphType, "sankey">;
|
||||||
|
} & Pick<
|
||||||
|
GraphHandlerInput,
|
||||||
|
"start" | "end" | "dimensions" | "units" | "bidirectional"
|
||||||
|
>;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-FileCopyrightText: 2022 Free Mobile
|
// SPDX-FileCopyrightText: 2022 Free Mobile
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
|
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
|
||||||
darkMode: "class",
|
darkMode: "class",
|
||||||
|
|||||||
13
console/frontend/tsconfig.app.json
Normal file
13
console/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "data/*.json"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@data/*": ["./data/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
console/frontend/tsconfig.config.json
Normal file
8
console/frontend/tsconfig.config.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.node.json",
|
||||||
|
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
14
console/frontend/tsconfig.json
Normal file
14
console/frontend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.config.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.vitest.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
console/frontend/tsconfig.vitest.json
Normal file
9
console/frontend/tsconfig.vitest.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"exclude": [],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"lib": [],
|
||||||
|
"types": ["node", "jsdom"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,15 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import { lezer } from "@lezer/generator/rollup";
|
import { lezer } from "@lezer/generator/rollup";
|
||||||
import path from "path";
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue(), lezer()],
|
plugins: [vue(), lezer()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
"@data": path.resolve(__dirname, "./data"),
|
"@data": fileURLToPath(new URL("./data", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
Reference in New Issue
Block a user