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:
Vincent Bernat
2022-11-15 20:34:08 +01:00
parent 1737234588
commit 52816e7fe1
65 changed files with 6477 additions and 1010 deletions

View File

@@ -173,7 +173,7 @@ lint: .lint-go~ .lint-js~ ## Run linting
.lint-go~: $(shell $(LSFILES) '*.go' 2> /dev/null) | $(REVIVE) ; $(info $(M) running golint)
$Q $(REVIVE) -formatter friendly -set_exit_status ./...
$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)
$Q cd console/frontend && npm run --silent lint
$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)
$Q $(GOIMPORTS) -local $(MODULE) -w $? < /dev/null
$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)
$Q cd console/frontend && npm run --silent format
$Q touch $@

View File

@@ -25,7 +25,8 @@ details.
- 🩹 *console*: use configured dimensions limit for “Visualize” tab
- 🌱 *inlet*: optimize BMP collector (see above)
- 🌱 *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

View File

@@ -9,7 +9,12 @@ module.exports = {
parserOptions: {
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: {
"vue/no-unused-vars": "error",
"vue/no-v-html": "off",

15
console/frontend/env.d.ts vendored Normal file
View 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;
}>;
}

View File

@@ -10,6 +10,6 @@
class="h-full bg-white text-gray-900 dark:bg-gray-800 dark:text-gray-100"
>
<div id="app" class="h-full"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,3 +0,0 @@
{
"include": ["src/**/*.js", "src/**/*.vue"]
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,14 @@
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "run-p type-check build-only",
"build-only": "vite build",
"preview": "vite preview",
"lint": "eslint '{src/**/,}*.{js,vue}'",
"format": "prettier --loglevel warn --write '{src/**/,}*.{js,vue,html}'",
"test": "vitest run"
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint '{src/**/,}*.{js,ts,vue}'",
"format": "prettier --loglevel warn --write '{src/**/,}*.{ts,js,vue,html}'",
"test": "vitest run",
"postinstall": "patch-package"
},
"dependencies": {
"@codemirror/autocomplete": "^6.0.1",
@@ -35,18 +38,31 @@
"devDependencies": {
"@headlessui/tailwindcss": "^0.1.1",
"@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",
"@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",
"eslint": "^8.19.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-vue": "^9.2.0",
"license-compliance": "^1.2.3",
"npm-run-all": "^4.1.5",
"patch-package": "^6.5.0",
"postcss": "^8.4.14",
"prettier": "^2.7.0",
"prettier-plugin-tailwindcss": "^0.1.11",
"tailwindcss": "^3.1.2",
"typescript": "~4.9.3",
"typescript-language-server": "^2.1.0",
"vite": "^3.0.0",
"vitest": "^0.25.1"
"vitest": "^0.25.1",
"vue-tsc": "^1.0.8"
}
}

View 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;

View 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;

View File

@@ -20,7 +20,7 @@
</ServerConfigProvider>
</template>
<script setup>
<script lang="ts" setup>
import "./tailwind.css";
import NavigationBar from "@/components/NavigationBar.vue";

View File

@@ -1,23 +1,27 @@
// SPDX-FileCopyrightText: 2022 Free Mobile
// SPDX-License-Identifier: AGPL-3.0-only
import { EditorState } from "@codemirror/state";
import { CompletionContext, autocompletion } from "@codemirror/autocomplete";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { filterLanguage, filterCompletion } from ".";
import type { complete } from "./complete";
async function get(doc) {
let cur = doc.indexOf("|");
async function get(doc: string) {
const cur = doc.indexOf("|");
doc = doc.slice(0, cur) + doc.slice(cur + 1);
let state = EditorState.create({
const state = EditorState.create({
doc,
selection: { anchor: cur },
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)
);
}
describe("filter completion", () => {
let fetchOptions = {};
let fetchOptions: RequestInit = {};
afterEach(() => {
vi.restoreAllMocks();
fetchOptions = {};
@@ -25,9 +29,13 @@ describe("filter completion", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn((url, options) => {
vi.fn((_: string, options: RequestInit) => {
fetchOptions = options;
const body = JSON.parse(options.body);
const body: {
what: "column" | "operator" | "value";
column?: string;
prefix?: string;
} = JSON.parse(options.body!.toString());
return {
ok: true,
async json() {
@@ -119,9 +127,9 @@ describe("filter completion", () => {
});
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(JSON.parse(fetchOptions.body)).toEqual({
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
what: "column",
prefix: "S",
});
@@ -137,9 +145,9 @@ describe("filter completion", () => {
});
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(JSON.parse(fetchOptions.body)).toEqual({
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
what: "column",
prefix: "Src",
});
@@ -155,8 +163,8 @@ describe("filter completion", () => {
});
it("completes operator names", async () => {
let { from, to, options } = await get("SrcAS |");
expect(JSON.parse(fetchOptions.body)).toEqual({
const { from, to, options } = await get("SrcAS |");
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
what: "operator",
column: "SrcAS",
});
@@ -172,8 +180,8 @@ describe("filter completion", () => {
});
it("completes values", async () => {
let { from, to, options } = await get("SrcAS = fac|");
expect(JSON.parse(fetchOptions.body)).toEqual({
const { from, to, options } = await get("SrcAS = fac|");
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
what: "value",
column: "SrcAS",
prefix: "fac",
@@ -190,8 +198,8 @@ describe("filter completion", () => {
});
it("completes quoted values", async () => {
let { from, to, options } = await get('DstNetName = "so|');
expect(JSON.parse(fetchOptions.body)).toEqual({
const { from, to, options } = await get('DstNetName = "so|');
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
what: "value",
column: "DstNetName",
prefix: "so",
@@ -206,8 +214,8 @@ describe("filter completion", () => {
});
it("completes quoted values even when not quoted", async () => {
let { from, to, options } = await get("DstNetName = so|");
expect(JSON.parse(fetchOptions.body)).toEqual({
const { from, to, options } = await get("DstNetName = so|");
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
what: "value",
column: "DstNetName",
prefix: "so",
@@ -222,7 +230,7 @@ describe("filter completion", () => {
});
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({ from, to, options }).toEqual({
from: 13,
@@ -237,7 +245,7 @@ describe("filter completion", () => {
});
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({ from, to, options }).toEqual({
from: 17,
@@ -247,8 +255,8 @@ describe("filter completion", () => {
});
it("completes inside operator", async () => {
let { from, to, options } = await get("SrcAS I|");
expect(JSON.parse(fetchOptions.body)).toEqual({
const { from, to, options } = await get("SrcAS I|");
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
what: "operator",
prefix: "I",
column: "SrcAS",
@@ -261,15 +269,13 @@ describe("filter completion", () => {
});
it("completes empty list of values", async () => {
let { from, to, options } = await get("SrcAS IN (|");
expect(JSON.parse(fetchOptions.body)).toEqual({
const { from, to, options } = await get("SrcAS IN (|");
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
what: "value",
column: "SrcAS",
prefix: null,
});
expect({ from, to, options }).toEqual({
from: 10,
to: null,
options: [
{ apply: "AS65403, ", detail: "AS number", label: "AS65403" },
{ apply: "AS65404, ", detail: "AS number", label: "AS65404" },
@@ -279,15 +285,13 @@ describe("filter completion", () => {
});
it("completes non-empty list of values", async () => {
let { from, to, options } = await get("SrcAS IN (100,|");
expect(JSON.parse(fetchOptions.body)).toEqual({
const { from, to, options } = await get("SrcAS IN (100,|");
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
what: "value",
column: "SrcAS",
prefix: null,
});
expect({ from, to, options }).toEqual({
from: 14,
to: null,
options: [
{ apply: " AS65403, ", detail: "AS number", label: "AS65403" },
{ apply: " AS65404, ", detail: "AS number", label: "AS65404" },
@@ -297,8 +301,8 @@ describe("filter completion", () => {
});
it("completes NOT", async () => {
let { from, to, options } = await get("SrcAS = 100 AND |");
expect(JSON.parse(fetchOptions.body)).toEqual({
const { from, to, options } = await get("SrcAS = 100 AND |");
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
what: "column",
});
expect({ from, to, options }).toEqual({
@@ -317,8 +321,8 @@ describe("filter completion", () => {
});
it("completes column after logic operator", async () => {
let { from, to, options } = await get("SrcAS = 100 AND S|");
expect(JSON.parse(fetchOptions.body)).toEqual({
const { from, to, options } = await get("SrcAS = 100 AND S|");
expect(JSON.parse(fetchOptions.body!.toString())).toEqual({
what: "column",
prefix: "S",
});

View File

@@ -3,24 +3,36 @@
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);
let completion = {
const completion: CompletionResult = {
from: ctx.pos,
filter: false,
options: [],
};
// 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", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) return;
const data = await response.json();
const data: apiCompleteResult = await response.json();
completion.options = [
...completion.options,
...(data.completions ?? []).map(({ label, detail, quoted }) =>
@@ -33,7 +45,7 @@ export const complete = async (ctx) => {
};
// Some helpers to match nodes.
const nodeAncestor = (node, names) => {
const nodeAncestor = (node: SyntaxNode | null, names: string[]) => {
for (let n = node; n; n = n.parent) {
if (names.includes(n.name)) {
return n;
@@ -41,7 +53,7 @@ export const complete = async (ctx) => {
}
return null;
};
const nodePrevSibling = (node) => {
const nodePrevSibling = (node: SyntaxNode | null) => {
for (let n = node?.prevSibling; n; n = n.prevSibling) {
if (!["LineComment", "BlockComment"].includes(n.name)) {
return n;
@@ -49,10 +61,11 @@ export const complete = async (ctx) => {
}
return null;
};
const nodeRightMostChildBefore = (node, pos) => {
const nodeRightMostChildBefore = (node: SyntaxNode | null, pos: number) => {
// Go to the right most child
let n = node;
for (;;) {
if (!n) break;
if (!n.lastChild) {
return n;
}
@@ -60,13 +73,12 @@ export const complete = async (ctx) => {
while (n && n.to > pos) {
n = n.prevSibling;
}
if (!n) break;
}
return null;
};
let nodeBefore = tree.resolve(ctx.pos, -1);
let n = null;
let nodeBefore: SyntaxNode | null = tree.resolve(ctx.pos, -1);
let n: SyntaxNode | null = null;
if (["LineComment", "BlockComment"].includes(nodeBefore.name)) {
// Do not complete !
} else if ((n = nodeAncestor(nodeBefore, ["Column"]))) {
@@ -93,16 +105,19 @@ export const complete = async (ctx) => {
) {
const c = nodePrevSibling(nodePrevSibling(n));
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.to = nodeBefore.to;
if (
["ValueLParen", "ValueComma", "ListOfValues"].includes(nodeBefore.name)
) {
// Empty term
prefix = null;
prefix = undefined;
completion.from = ctx.pos;
completion.to = null;
completion.to = undefined;
} else if (nodeBefore.name === "String") {
prefix = prefix.replace(/^["']/, "").replace(/["']$/, "");
}
@@ -149,10 +164,13 @@ export const complete = async (ctx) => {
];
} else if ((n = nodeAncestor(nodeBefore, ["Or", "And", "Not"]))) {
if (n.name !== "Not") {
completion.options.push({
label: "NOT",
detail: "logic operator",
});
completion.options = [
...completion.options,
{
label: "NOT",
detail: "logic operator",
},
];
}
await remote({ what: "column" });
}
@@ -160,7 +178,7 @@ export const complete = async (ctx) => {
completion.options.forEach((option) => {
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 "("
if (
completion.from > 0 &&

View File

@@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2022 Free Mobile
// SPDX-License-Identifier: AGPL-3.0-only
import { parser } from "./syntax.grammar";
import { fileTests } from "@lezer/generator/dist/test";
import { describe, it } from "vitest";
@@ -11,7 +14,7 @@ const caseFile = path.join(
);
describe("filter parsing", () => {
for (let { name, run } of fileTests(
for (const { name, run } of fileTests(
fs.readFileSync(caseFile, "utf8"),
"grammar.test.txt"
))

View File

@@ -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;
};

View 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;
};

View File

@@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2022 Free Mobile
// SPDX-License-Identifier: AGPL-3.0-only
@top Filter {
expression
}

View 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;

View File

@@ -12,8 +12,9 @@
</button>
</template>
<script setup>
<script lang="ts" setup>
import { inject } from "vue";
import { SunIcon, MoonIcon } from "@heroicons/vue/solid";
const { isDark, toggleDark } = inject("theme");
import { ThemeKey } from "@/components/ThemeProvider.vue";
const { isDark, toggleDark } = inject(ThemeKey)!;
</script>

View File

@@ -8,31 +8,31 @@
</div>
</template>
<script setup>
const props = defineProps({
kind: {
type: String,
default: "error",
validator(value) {
return ["info", "danger", "success", "warning"].includes(value);
},
},
});
<script lang="ts" setup>
import { computed } from "vue";
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) {
case "info":
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";
case "success":
return "text-green-700 bg-green-100 dark:bg-green-200 dark:text-green-800";
case "warning":
return "text-yellow-700 bg-yellow-100 dark:bg-yellow-200 dark:text-yellow-800";
default:
return "";
}
return "";
});
</script>

View File

@@ -33,18 +33,19 @@
</div>
</template>
<script setup>
defineProps({
label: {
type: String,
default: null,
},
error: {
type: String,
default: "",
},
});
<script lang="ts" setup>
import { v4 as uuidv4 } from "uuid";
withDefaults(
defineProps<{
label?: string;
error?: string;
}>(),
{
label: "",
error: "",
}
);
const id = uuidv4();
</script>

View File

@@ -38,38 +38,23 @@
</button>
</template>
<script 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,
},
});
<script lang="ts" setup>
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>

View File

@@ -1,3 +1,6 @@
<!-- SPDX-FileCopyrightText: 2022 Free Mobile -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
<template>
<div>
<label :for="id" class="flex items-center">
@@ -6,7 +9,12 @@
type="checkbox"
: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"
@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">
{{ label }}
@@ -15,19 +23,16 @@
</div>
</template>
<script setup>
defineProps({
label: {
type: String,
required: true,
},
modelValue: {
type: Boolean,
required: true,
},
});
defineEmits(["update:modelValue"]);
<script lang="ts" setup>
import { v4 as uuidv4 } from "uuid";
defineProps<{
label: string;
modelValue: boolean;
}>();
defineEmits<{
(e: "update:modelValue", value: boolean): void;
}>();
const id = uuidv4();
</script>

View File

@@ -9,17 +9,20 @@
role="group"
>
<label
v-for="({ name, label: blabel }, idx) in choices"
:key="name"
:for="id(name)"
v-for="(choice, idx) in choices"
:key="choice.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"
>
<input
:id="id(name)"
:id="id(choice.name)"
type="radio"
:checked="modelValue === name"
:checked="modelValue === choice.name"
class="peer sr-only"
@change="$event.target.checked && $emit('update:modelValue', name)"
@change="
($event.target as HTMLInputElement).checked &&
$emit('update:modelValue', choice.name)
"
/>
<div
: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"
>
{{ blabel }}
{{ choice.label }}
</div>
</label>
</div>
</div>
</template>
<script setup>
defineProps({
label: {
type: String,
required: true,
},
choices: {
type: Array,
required: true,
},
modelValue: {
type: String,
required: true,
},
});
defineEmits(["update:modelValue"]);
<script lang="ts" setup>
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 id = (name) => `${baseID}-${name}`;
const id = (name: string) => `${baseID}-${name}`;
</script>

View File

@@ -50,33 +50,32 @@
</div>
</template>
<script 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"]);
<script lang="ts" setup>
import { ref, watch, computed, inject } from "vue";
import draggable from "vuedraggable";
import { XIcon, SelectorIcon } from "@heroicons/vue/solid";
import { dataColor } from "@/utils";
import InputString from "@/components/InputString.vue";
import InputListBox from "@/components/InputListBox.vue";
import { ServerConfigKey } from "@/components/ServerConfigProvider.vue";
import fields from "@data/fields.json";
import { isEqual } from "lodash-es";
const serverConfiguration = inject("server-configuration");
const selectedDimensions = ref([]);
const props = withDefaults(
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(() => {
if (selectedDimensions.value.length < props.minDimensions) {
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(
(d) => d !== dimension
);
@@ -119,19 +118,22 @@ const removeDimension = (dimension) => {
watch(
() => props.modelValue,
(value) => {
limit.value = value.limit.toString();
selectedDimensions.value = value.selected
.map((name) => dimensions.find((d) => d.name === name))
.filter((d) => d !== undefined);
if (value) {
limit.value = value.limit.toString();
}
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 }
);
watch(
[selectedDimensions, limit, hasErrors],
[selectedDimensions, limit, hasErrors] as const,
([selected, limit, hasErrors]) => {
const updated = {
selected: selected.map((d) => d.name),
limit: parseInt(limit) || limit,
limit: parseInt(limit) || 10,
errors: hasErrors,
};
if (!isEqual(updated, props.modelValue)) {
@@ -140,3 +142,11 @@ watch(
}
);
</script>
<script lang="ts">
export type ModelType = {
selected: string[];
limit: number;
errors?: boolean;
} | null;
</script>

View File

@@ -63,65 +63,22 @@
</InputListBox>
</template>
<script>
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"]);
<script lang="ts" setup>
import { ref, inject, watch, computed, onMounted, onBeforeUnmount } from "vue";
import { useFetch } from "@vueuse/core";
import { TrashIcon, EyeIcon, EyeOffIcon } from "@heroicons/vue/solid";
import InputBase from "@/components/InputBase.vue";
const { isDark } = inject("theme");
const { user: currentUser } = inject("user");
// # Saved filters
import InputListBox from "@/components/InputListBox.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({});
const { data: rawSavedFilters, execute: refreshSavedFilters } = useFetch(
`/api/v0/console/filter/saved`
).json();
const savedFilters = computed(() => rawSavedFilters.value?.filters ?? []);
watch(selectedSavedFilter, (filter) => {
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 {
EditorState,
StateEffect,
Compartment,
type Extension,
} from "@codemirror/state";
import { EditorView, keymap, placeholder } from "@codemirror/view";
import { syntaxHighlighting, HighlightStyle } from "@codemirror/language";
import { standardKeymap, history } from "@codemirror/commands";
@@ -135,16 +92,75 @@ import {
} from "@/codemirror/lang-filter";
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 error = ref(""); // Keep in sync with modelValue.errors
const component = {
let component:
| { view: EditorView; state: EditorState }
| { view: null; state: null } = {
view: null,
state: null,
};
watch(
() => props.modelValue,
(model) => (expression.value = model.expression),
(model) => {
if (model) expression.value = model.expression;
},
{ immediate: true }
);
watch(
@@ -160,7 +176,7 @@ watch(
// https://github.com/surmon-china/vue-codemirror/blob/59598ff72327ab6c5ee70a640edc9e2eb2518775/src/codemirror.ts#L52
const rerunExtension = () => {
const compartment = new Compartment();
const run = (view, extension) => {
const run = (view: EditorView, extension: Extension) => {
if (compartment.get(view.state)) {
// reconfigure
view.dispatch({ effects: compartment.reconfigure(extension) });
@@ -188,14 +204,15 @@ const filterTheme = computed(() => [
EditorView.theme({}, { dark: isDark.value }),
]);
const submitFilter = () => {
const submitFilter = (_: EditorView): boolean => {
emit("submit");
return true;
};
onMounted(() => {
// Create Code mirror instance
component.state = EditorState.create({
doc: props.modelValue.expression,
const state = EditorState.create({
doc: props.modelValue?.expression ?? "",
extensions: [
filterLanguage(),
filterCompletion(),
@@ -235,16 +252,20 @@ onMounted(() => {
}),
],
});
component.view = new EditorView({
state: component.state,
parent: elEditor.value,
const view = new EditorView({
state: state,
parent: elEditor.value!,
});
component = {
state,
view,
};
watch(
expression,
(expression) => {
if (expression !== component.view.state.doc.toString()) {
component.view.dispatch({
if (expression !== component.view?.state.doc.toString()) {
component.view?.dispatch({
changes: {
from: 0,
to: component.view.state.doc.length,
@@ -263,10 +284,20 @@ onMounted(() => {
extensions,
(extensions) => {
const exts = extensions.filter((e) => !!e);
dynamicExtensions(component.view, exts);
if (component.view !== null) dynamicExtensions(component.view, exts);
},
{ immediate: true }
);
});
onBeforeUnmount(() => component.view?.destroy());
</script>
<script lang="ts">
export default {
inheritAttrs: false,
};
export type ModelType = {
expression: string;
errors?: boolean;
} | null;
</script>

View File

@@ -7,7 +7,7 @@
:class="$attrs['class']"
:multiple="multiple"
:model-value="modelValue"
@update:model-value="(item) => $emit('update:modelValue', item)"
@update:model-value="(selected: any) => $emit('update:modelValue', selected)"
>
<div class="relative">
<InputBase v-slot="{ id, childClass }" v-bind="otherAttrs" :error="error">
@@ -84,40 +84,13 @@
</component>
</template>
<script>
<script lang="ts">
export default {
inheritAttrs: false,
};
</script>
<script 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"]);
<script lang="ts" setup>
import { ref, computed, useAttrs } from "vue";
import {
Listbox,
@@ -133,6 +106,24 @@ import {
import { CheckIcon, SelectorIcon } from "@heroicons/vue/solid";
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 query = ref("");
const component = computed(() =>
@@ -153,16 +144,15 @@ const component = computed(() =>
Input: ComboboxInput,
}
);
const filteredItems = computed(() =>
props.filter === null
? props.items
: props.items.filter((it) =>
query.value
.toLowerCase()
.split(/\W+/)
.every((w) => it[props.filter].toLowerCase().includes(w))
)
);
const filteredItems = computed(() => {
if (props.filter === null) return props.items;
return props.items.filter((it) =>
query.value
.toLowerCase()
.split(/\W+/)
.every((w) => `${it[props.filter!]}`.toLowerCase().includes(w))
);
});
const otherAttrs = computed(() => {
// eslint-disable-next-line no-unused-vars
const { class: _, ...others } = attrs;

View File

@@ -9,19 +9,20 @@
type="text"
placeholder=" "
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
@input="
$emit('update:modelValue', ($event.target as HTMLInputElement).value)
"
/>
</InputBase>
</template>
<script setup>
defineProps({
modelValue: {
type: String,
required: true,
},
});
defineEmits(["update:modelValue"]);
<script lang="ts" setup>
import InputBase from "@/components/InputBase.vue";
defineProps<{
modelValue: string;
}>();
defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
</script>

View File

@@ -18,35 +18,31 @@
</div>
</template>
<script 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"]);
<script lang="ts" setup>
import { ref, computed, watch } from "vue";
import { Date as SugarDate } from "sugar-date";
import InputString from "@/components/InputString.vue";
import InputListBox from "@/components/InputListBox.vue";
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 endTime = ref("");
const parsedStartTime = computed(() => SugarDate.create(startTime.value));
const parsedEndTime = computed(() => SugarDate.create(endTime.value));
const startTimeError = computed(() =>
isNaN(parsedStartTime.value) ? "Invalid date" : ""
isNaN(parsedStartTime.value.valueOf()) ? "Invalid date" : ""
);
const endTimeError = computed(
() =>
(isNaN(parsedEndTime.value) ? "Invalid date" : "") ||
(!isNaN(parsedStartTime.value) &&
(isNaN(parsedEndTime.value.valueOf()) ? "Invalid date" : "") ||
(!isNaN(parsedStartTime.value.valueOf()) &&
parsedStartTime.value > parsedEndTime.value &&
"End date should be before start date") ||
""
@@ -124,13 +120,15 @@ watch(selectedPreset, (preset) => {
watch(
() => props.modelValue,
(m) => {
startTime.value = m.start;
endTime.value = m.end;
if (m) {
startTime.value = m.start;
endTime.value = m.end;
}
},
{ immediate: true, deep: true }
);
watch(
[startTime, endTime, hasErrors],
[startTime, endTime, hasErrors] as const,
([start, end, errors]) => {
// Find the right preset
const newPreset =
@@ -152,3 +150,11 @@ watch(
{ immediate: true }
);
</script>
<script lang="ts">
export type ModelType = {
start: string;
end: string;
errors?: boolean;
} | null;
</script>

View File

@@ -18,13 +18,10 @@
</div>
</template>
<script setup>
defineProps({
loading: {
type: Boolean,
required: true,
},
});
<script lang="ts" setup>
import LoadingSpinner from "@/components/LoadingSpinner.vue";
defineProps<{
loading: boolean;
}>();
</script>

View File

@@ -65,7 +65,7 @@
</Disclosure>
</template>
<script setup>
<script lang="ts" setup>
import { computed, inject } from "vue";
import { useRoute } from "vue-router";
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/vue";
@@ -78,8 +78,9 @@ import {
} from "@heroicons/vue/solid";
import DarkModeSwitcher from "@/components/DarkModeSwitcher.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 navigation = computed(() => [
{ name: "Home", icon: HomeIcon, link: "/", current: route.path == "/" },

View File

@@ -5,12 +5,32 @@
<slot></slot>
</template>
<script setup>
<script lang="ts" setup>
import { provide, readonly } from "vue";
import { useFetch } from "@vueuse/core";
// TODO: handle error
const { data } = useFetch("/api/v0/console/configuration").get().json();
const { data } = useFetch("/api/v0/console/configuration")
.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>

View File

@@ -5,15 +5,23 @@
<slot></slot>
</template>
<script setup>
<script lang="ts" setup>
import { provide, readonly } from "vue";
import { useDark, useToggle } from "@vueuse/core";
const isDark = useDark();
const toggleDark = useToggle(isDark);
provide("theme", {
provide(ThemeKey, {
isDark: readonly(isDark),
toggleDark,
});
</script>
<script lang="ts">
import type { InjectionKey, Ref } from "vue";
export const ThemeKey: InjectionKey<{
isDark: Readonly<Ref<boolean>>;
toggleDark: () => void;
}> = Symbol();
</script>

View File

@@ -5,7 +5,7 @@
<slot></slot>
</template>
<script setup>
<script lang="ts" setup>
import { provide, computed, ref } from "vue";
import { useTitle } from "@vueuse/core";
import { useRouter, useRoute } from "vue-router";
@@ -17,7 +17,7 @@ import { useRouter, useRoute } from "vue-router";
const route = useRoute();
const applicationName = "Akvorado";
const viewName = computed(() => route.meta?.title);
const documentTitle = ref(null);
const documentTitle = ref<string | null>(null);
const title = computed(() =>
[applicationName, viewName.value, documentTitle.value]
.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>

View File

@@ -47,10 +47,11 @@
</Popover>
</template>
<script setup>
<script lang="ts" setup>
import { inject } from "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";
</script>

View File

@@ -5,7 +5,7 @@
<slot></slot>
</template>
<script setup>
<script lang="ts" setup>
import { provide, readonly, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useFetch } from "@vueuse/core";
@@ -13,7 +13,7 @@ import { useFetch } from "@vueuse/core";
const { data, execute } = useFetch("/api/v0/console/user/info", {
immediate: false,
onFetchError(ctx) {
if (ctx.response.status === 401) {
if (ctx.response?.status === 401) {
// TODO: avoid component flash.
router.replace({ name: "401", query: { redirect: route.path } });
}
@@ -21,7 +21,7 @@ const { data, execute } = useFetch("/api/v0/console/user/info", {
},
})
.get()
.json();
.json<UserInfo>();
// Handle verification on route change.
const route = useRoute();
@@ -36,7 +36,21 @@ watch(
{ immediate: true }
);
provide("user", {
provide(UserKey, {
user: readonly(data),
});
</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>

View File

@@ -7,6 +7,13 @@ import VisualizePage from "@/views/VisualizePage.vue";
import DocumentationPage from "@/views/DocumentationPage.vue";
import ErrorPage from "@/views/ErrorPage.vue";
declare module "vue-router" {
interface RouteMeta {
title: string;
notAuthenticated?: boolean;
}
}
const router = createRouter({
history: createWebHistory(),
routes: [

View File

@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: 2022 Free Mobile
// SPDX-License-Identifier: AGPL-3.0-only
export function formatXps(value) {
export function formatXps(value: number) {
value = Math.abs(value);
const suffixes = ["", "K", "M", "G", "T"];
let idx = 0;
@@ -9,13 +9,12 @@ export function formatXps(value) {
value /= 1000;
idx++;
}
value = value.toFixed(2);
return `${value}${suffixes[idx]}`;
return `${value.toFixed(2)}${suffixes[idx]}`;
}
// Order function for field names
export function compareFields(f1, f2) {
const metric = {
export function compareFields(f1: string, f2: string) {
const metric: { [prefix: string]: number } = {
Dat: 1,
Tim: 2,
Byt: 3,

View File

@@ -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]
.map((idx) => orderedColors.map((colorName) => colors[colorName][idx]))
.map((idx) =>
orderedColors.map(
(colorName: keyof typeof colors) => colors[colorName][idx]
)
)
.flat();
const lightPalette = [5, 4, 3, 2, 1, 0]
.map((idx) => orderedColors.map((colorName) => colors[colorName][idx]))
.flat();
const lightenColor = (color, amount) =>
const lightenColor = (color: string, amount: number) =>
"#" +
color
.replace(/^#/, "")
@@ -86,10 +90,14 @@ const lightenColor = (color, amount) =>
(
"0" +
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 correctedIndex = index % 2 === 0 ? index : index + orderedColors.length;
const computed = palette[correctedIndex % palette.length];
@@ -99,7 +107,11 @@ export function dataColor(index, alternate = false, theme = "light") {
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 =
theme === "light"
? ["#aaaaaa", "#bbbbbb", "#999999", "#cccccc", "#888888"]

View File

@@ -59,7 +59,7 @@
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">
<InfoBox v-if="errorMessage" kind="danger">
<InfoBox v-if="errorMessage" kind="error">
<strong>Unable to fetch documentation page!</strong>
{{ errorMessage }}
</InfoBox>
@@ -72,47 +72,70 @@
</div>
</template>
<script setup>
const props = defineProps({
id: {
type: String,
required: true,
},
});
<script lang="ts" setup>
import { ref, computed, watch, inject, nextTick } from "vue";
import { useFetch } from "@vueuse/core";
import { useRouteHash } from "@vueuse/router";
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
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(
() =>
(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 toc = computed(() => (!error.value && data.value?.toc) || []);
const markdown = computed(
() =>
(!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 activeSlug = useRouteHash();
// Scroll to the right anchor after loading markdown
const contentEl = ref(null);
watch([markdown, activeSlug], async () => {
const contentEl = ref<HTMLElement | null>(null);
watch([markdown, activeSlug] as const, async () => {
await nextTick();
if (contentEl.value === null) return;
let scrollEl = contentEl.value;
while (window.getComputedStyle(scrollEl).position === "static") {
while (
window.getComputedStyle(scrollEl).position === "static" &&
scrollEl.parentNode instanceof HTMLElement
) {
scrollEl = scrollEl.parentNode;
}
const top =
(activeSlug.value &&
document.querySelector(`#${CSS.escape(activeSlug.value.slice(1))}`)
?.offsetTop) ||
(
document.querySelector(
`#${CSS.escape(activeSlug.value.slice(1))}`
) as HTMLElement | null
)?.offsetTop) ||
0;
scrollEl.scrollTo(0, top);
});
@@ -120,6 +143,7 @@ watch([markdown, activeSlug], async () => {
// Update title
watch(markdown, async () => {
await nextTick();
title.set(contentEl.value?.querySelector("h1")?.textContent);
const t = contentEl.value?.querySelector("h1")?.textContent;
if (t) title.set(t);
});
</script>

View File

@@ -11,11 +11,8 @@
</div>
</template>
<script setup>
defineProps({
error: {
type: String,
required: true,
},
});
<script lang="ts" setup>
defineProps<{
error: string;
}>();
</script>

View File

@@ -46,7 +46,7 @@
</div>
</template>
<script setup>
<script lang="ts" setup>
import { inject, computed } from "vue";
import { useInterval } from "@vueuse/core";
import WidgetLastFlow from "./HomePage/WidgetLastFlow.vue";
@@ -54,12 +54,13 @@ import WidgetFlowRate from "./HomePage/WidgetFlowRate.vue";
import WidgetExporters from "./HomePage/WidgetExporters.vue";
import WidgetTop from "./HomePage/WidgetTop.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(
() => serverConfiguration.value?.homepageTopWidgets ?? []
);
const widgetTitle = (name) =>
const widgetTitle = (name: string) =>
({
"src-as": "Top source AS",
"dst-as": "Top destination AS",

View File

@@ -12,7 +12,10 @@
</div>
</template>
<script setup>
<script lang="ts" setup>
import { computed } from "vue";
import { useFetch } from "@vueuse/core";
const props = defineProps({
refresh: {
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 { data } = useFetch(url, { refetch: true }).get().json();
const exporters = computed(() => data?.value?.exporters?.length || "???");
const { data } = useFetch(url, { refetch: true })
.get()
.json<{ exporters: string[] }>();
const exporters = computed(() => data.value?.exporters.length || "???");
</script>

View File

@@ -12,29 +12,34 @@
</div>
</template>
<script setup>
const props = defineProps({
refresh: {
type: Number,
default: 0,
},
});
<script lang="ts" setup>
import { computed } from "vue";
import { useFetch } from "@vueuse/core";
const url = computed(() => "/api/v0/console/widget/flow-rate?" + props.refresh);
const { data } = useFetch(url, { refetch: true }).get().json();
const props = withDefaults(
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(() => {
if (!data.value?.rate) {
return "???";
}
if (data.value?.rate > 1_500_000) {
return (data.value.rate / 1_000_000).toFixed(1) + "M";
}
if (data.value?.rate > 1_500) {
return (data.value.rate / 1_000).toFixed(1) + "K";
}
if (data.value?.rate >= 0) {
return data.value.rate.toFixed(0);
}
return "???";
return data.value.rate.toFixed(0);
});
</script>

View File

@@ -4,77 +4,96 @@
<template>
<div>
<div class="h-[300px]">
<v-chart :option="options" :theme="isDark ? 'dark' : null" autoresize />
<v-chart
:option="option"
:theme="isDark ? 'dark' : undefined"
autoresize
/>
</div>
</div>
</template>
<script setup>
const props = defineProps({
refresh: {
type: Number,
required: true,
},
});
<script lang="ts" setup>
import { computed, inject } from "vue";
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 { LineChart } from "echarts/charts";
import { TooltipComponent, GridComponent } from "echarts/components";
import { LineChart, type LineSeriesOption } from "echarts/charts";
import {
TooltipComponent,
GridComponent,
type TooltipComponentOption,
type GridComponentOption,
} from "echarts/components";
import VChart from "vue-echarts";
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]);
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 { data } = useFetch(url, { refetch: true }).get().json();
const options = computed(() => ({
darkMode: isDark.value,
backgroundColor: "transparent",
xAxis: { type: "time" },
yAxis: {
type: "value",
min: 0,
axisLabel: { formatter: formatGbps },
},
tooltip: {
confine: true,
trigger: "axis",
axisPointer: {
type: "cross",
label: { backgroundColor: "#6a7985" },
const { data } = useFetch(url, { refetch: true })
.get()
.json<{ data: Array<{ t: string; gbps: number }> }>();
const option = computed(
(): ECOption => ({
darkMode: isDark.value,
backgroundColor: "transparent",
xAxis: { type: "time" },
yAxis: {
type: "value",
min: 0,
axisLabel: { formatter: formatGbps },
},
valueFormatter: formatGbps,
},
series: [
{
type: "line",
symbol: "none",
lineStyle: {
width: 0,
tooltip: {
confine: true,
trigger: "axis",
axisPointer: {
type: "cross",
label: { backgroundColor: "#6a7985" },
},
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),
valueFormatter: (value) => formatGbps(value.valueOf() as number),
},
],
}));
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>

View File

@@ -19,21 +19,23 @@
</div>
</template>
<script setup>
const props = defineProps({
refresh: {
type: Number,
default: 0,
},
});
<script lang="ts" setup>
import { computed } from "vue";
import { useFetch } from "@vueuse/core";
import { compareFields } from "../../utils";
const url = computed(() => "/api/v0/console/widget/flow-last?" + props.refresh);
const { data } = useFetch(url, { refetch: true }).get().json();
const lastFlow = computed(() => ({
const props = withDefaults(
defineProps<{
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 || {}),
...Object.entries(data.value || {}).sort(([f1], [f2]) =>
compareFields(f1, f2)

View File

@@ -5,98 +5,107 @@
<div>
<h1 class="font-semibold leading-relaxed">{{ title }}</h1>
<div class="h-[200px]">
<v-chart :option="options" :theme="isDark ? 'dark' : null" autoresize />
<v-chart
:option="options"
:theme="isDark ? 'dark' : undefined"
autoresize
/>
</div>
</div>
</template>
<script setup>
const props = defineProps({
refresh: {
type: Number,
required: true,
},
what: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
});
<script lang="ts" setup>
import { computed, inject } from "vue";
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 { PieChart } from "echarts/charts";
import { TooltipComponent, LegendComponent } from "echarts/components";
import { PieChart, type PieSeriesOption } from "echarts/charts";
import {
TooltipComponent,
LegendComponent,
type TooltipComponentOption,
type LegendComponentOption,
} from "echarts/components";
import VChart from "vue-echarts";
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]);
type ECOption = ComposeOption<
PieSeriesOption | TooltipComponentOption | LegendComponentOption
>;
const url = computed(
() => `/api/v0/console/widget/top/${props.what}?${props.refresh}`
);
const { data } = useFetch(url, { refetch: true }).get().json();
const options = computed(() => ({
darkMode: isDark.value,
backgroundColor: "transparent",
tooltip: {
trigger: "item",
confine: true,
valueFormatter(value) {
return value.toFixed(2) + "%";
},
},
legend: {
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);
},
const { data } = useFetch(url, { refetch: true })
.get()
.json<{ top: Array<{ name: string; percent: number }> }>();
const options = computed(
(): ECOption => ({
darkMode: isDark.value,
backgroundColor: "transparent",
tooltip: {
trigger: "item",
confine: true,
valueFormatter(value) {
return (value.valueOf() as number).toFixed(2) + "%";
},
},
],
}));
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>

View File

@@ -13,7 +13,7 @@
<LoadingOverlay :loading="isFetching">
<RequestSummary :request="request" />
<div class="mx-4 my-2">
<InfoBox v-if="errorMessage" kind="danger">
<InfoBox v-if="errorMessage" kind="error">
<strong>Unable to fetch data!&nbsp;</strong>{{ errorMessage }}
</InfoBox>
<ResizeRow
@@ -41,56 +41,69 @@
</div>
</template>
<script setup>
const props = defineProps({
routeState: {
type: String,
default: "",
},
});
<script lang="ts" setup>
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 { Date as SugarDate } from "sugar-date";
import { ResizeRow } from "vue-resizer";
import LZString from "lz-string";
import InfoBox from "@/components/InfoBox.vue";
import LoadingOverlay from "@/components/LoadingOverlay.vue";
import RequestSummary from "./VisualizePage/RequestSummary.vue";
import DataTable from "./VisualizePage/DataTable.vue";
import DataGraph from "./VisualizePage/DataGraph.vue";
import OptionsPanel from "./VisualizePage/OptionsPanel.vue";
import RequestSummary from "./VisualizePage/RequestSummary.vue";
import { graphTypes } from "./VisualizePage/constants";
import { isEqual, omit } from "lodash-es";
import {
default as OptionsPanel,
type ModelType,
} 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 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.end = end.toISOString();
};
// Main state
const state = ref({});
const state = ref<ModelType>(null);
// Load data from URL
const route = useRoute();
const router = useRouter();
const decodeState = (serialized) => {
const decodeState = (serialized: string | undefined): ModelType => {
try {
if (!serialized) {
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) {
console.error("cannot decode state:", error);
return {};
return null;
}
};
const encodeState = (state) => {
const encodeState = (state: ModelType) => {
if (state === null) return "";
return LZString.compressToBase64(
JSON.stringify(state, Object.keys(state).sort())
);
@@ -108,50 +121,88 @@ watch(
const encodedState = computed(() => encodeState(state.value));
// Fetch data
const fetchedData = ref({});
const finalState = computed(() => ({
...state.value,
start: SugarDate.create(state.value.start),
end: SugarDate.create(state.value.end),
}));
const jsonPayload = computed(() => ({
...omit(finalState.value, ["previousPeriod", "graphType"]),
"previous-period": finalState.value.previousPeriod,
}));
const request = ref({}); // Same as finalState, but once request is successful
const fetchedData = ref<GraphHandlerResult | SankeyHandlerResult | null>(null);
const finalState = computed((): ModelType => {
return state.value === null
? null
: {
...state.value,
start: SugarDate.create(state.value.start).toISOString(),
end: SugarDate.create(state.value.end).toISOString(),
};
});
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("", {
beforeFetch(ctx) {
// Add the URL. Not a computed value as if we change both payload
// and URL, the query will be triggered twice.
const { cancel } = ctx;
const endpoint = {
[graphTypes.stacked]: "graph",
[graphTypes.lines]: "graph",
[graphTypes.grid]: "graph",
[graphTypes.sankey]: "sankey",
};
const url = endpoint[state.value.graphType];
if (url === undefined) {
if (finalState.value === null) {
cancel();
return ctx;
}
const endpoint: Record<GraphType, string> = {
stacked: "graph",
lines: "graph",
grid: "graph",
sankey: "sankey",
};
const url = endpoint[finalState.value.graphType];
return {
...ctx,
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
// previous data in case of errors.
const { data, response } = ctx;
if (data === null || !finalState.value) return ctx;
console.groupCollapsed("SQL query");
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();
fetchedData.value = {
...data,
...omit(finalState.value, ["limit", "filter", "points"]),
};
if (finalState.value.graphType === "sankey") {
fetchedData.value = {
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.
const routeTarget = {
@@ -171,13 +222,14 @@ const { data, isFetching, aborted, abort, canAbort, error } = useFetch("", {
},
refetch: true,
})
.post(jsonPayload)
.json();
.post(jsonPayload, "json") // this will trigger a refetch
.json<GraphHandlerOutput | SankeyHandlerOutput | { message: string }>();
const errorMessage = computed(
() =>
(error.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>

View File

@@ -4,33 +4,32 @@
<template>
<component
:is="component"
:theme="isDark ? 'dark' : null"
:theme="isDark ? 'dark' : undefined"
:data="data"
autoresize
/>
</template>
<script setup>
const props = defineProps({
data: {
type: Object,
default: null,
},
});
<script lang="ts" setup>
import { computed, inject } from "vue";
import { graphTypes } from "./constants";
import DataGraphTimeSeries from "./DataGraphTimeSeries.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 { stacked, lines, grid, sankey } = graphTypes;
if ([stacked, lines, grid].includes(props.data.graphType)) {
return DataGraphTimeSeries;
}
if ([sankey].includes(props.data.graphType)) {
return DataGraphSankey;
switch (props.data?.graphType) {
case "stacked":
case "lines":
case "grid":
return DataGraphTimeSeries;
case "sankey":
return DataGraphSankey;
}
return "div";
});

View File

@@ -2,30 +2,37 @@
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
<template>
<v-chart :option="graph" />
<v-chart :option="option" />
</template>
<script setup>
const props = defineProps({
data: {
type: Object,
default: null,
},
});
<script lang="ts" setup>
import { inject, computed } from "vue";
import { formatXps, dataColor, dataColorGrey } from "@/utils";
const { isDark } = inject("theme");
import { use } from "echarts/core";
import { ThemeKey } from "@/components/ThemeProvider.vue";
import type { SankeyHandlerResult } from ".";
import { use, type ComposeOption } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { SankeyChart } from "echarts/charts";
import { TooltipComponent } from "echarts/components";
import { SankeyChart, type SankeySeriesOption } from "echarts/charts";
import {
TooltipComponent,
type TooltipComponentOption,
type GridComponentOption,
} from "echarts/components";
import type { TooltipCallbackDataParams } from "echarts/types/src/component/tooltip/TooltipView";
import VChart from "vue-echarts";
use([CanvasRenderer, SankeyChart, TooltipComponent]);
type ECOption = ComposeOption<
SankeySeriesOption | TooltipComponentOption | GridComponentOption
>;
const props = defineProps<{
data: SankeyHandlerResult;
}>();
const { isDark } = inject(ThemeKey)!;
// Graph component
const graph = computed(() => {
const option = computed((): ECOption => {
const theme = isDark.value ? "dark" : "light";
const data = props.data || {};
if (!data.xps) return {};
@@ -37,27 +44,39 @@ const graph = computed(() => {
confine: true,
trigger: "item",
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") {
const nodeData = data as NonNullable<SankeySeriesOption["nodes"]>[0];
return [
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(
value
value.valueOf() as number
)}`,
].join("");
} else if (dataType === "edge") {
const source = data.source.split(": ").slice(1).join(": ");
const target = data.target.split(": ").slice(1).join(": ");
return [
`${source}${target}`,
`<span style="display:inline-block;margin-left:2em;font-weight:bold;">${formatXps(
data.value
)}`,
].join("");
const edgeData = data as NonNullable<SankeySeriesOption["edges"]>[0];
const source =
edgeData.source?.toString().split(": ").slice(1).join(": ") ??
"???";
const target =
edgeData.target?.toString().split(": ").slice(1).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: [
{

View File

@@ -4,43 +4,38 @@
<template>
<v-chart
ref="chartComponent"
:option="echartsOptions"
:option="option"
:update-options="{ notMerge: true }"
@brush-end="updateTimeRange"
/>
</template>
<script setup>
const props = defineProps({
data: {
type: Object,
default: () => {},
},
highlight: {
type: Number,
default: null,
},
});
const emit = defineEmits(["update:timeRange"]);
<script lang="ts" setup>
import { ref, watch, inject, computed, onMounted, nextTick } from "vue";
import { useMediaQuery } from "@vueuse/core";
import { formatXps, dataColor, dataColorGrey } from "@/utils";
import { graphTypes } from "./constants";
const { isDark } = inject("theme");
import { ThemeKey } from "@/components/ThemeProvider.vue";
import type { GraphHandlerResult } from ".";
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 { LineChart } from "echarts/charts";
import { LineChart, type LineSeriesOption } from "echarts/charts";
import {
TooltipComponent,
type TooltipComponentOption,
GridComponent,
type GridComponentOption,
BrushComponent,
type BrushComponentOption,
ToolboxComponent,
type ToolboxComponentOption,
DatasetComponent,
type DatasetComponentOption,
TitleComponent,
type TitleComponentOption,
} 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";
use([
CanvasRenderer,
@@ -52,10 +47,29 @@ use([
DatasetComponent,
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
const chartComponent = ref(null);
const commonGraph = {
const chartComponent = ref<typeof VChart | null>(null);
const commonGraph: ECOption = {
backgroundColor: "transparent",
animationDuration: 500,
toolbox: {
@@ -65,42 +79,48 @@ const commonGraph = {
xAxisIndex: "all",
},
};
const graph = computed(() => {
const graph = computed((): ECOption => {
const theme = isDark.value ? "dark" : "light";
const data = props.data || {};
if (!data.t) return {};
const rowName = (row) => row.join(" — ") || "Total",
dataset = {
const data = props.data;
if (!data) return {};
const rowName = (row: string[]) => row.join(" — ") || "Total";
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,
dimensions: ["time", ...data.rows.map(rowName)],
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),
],
source,
},
xAxis = {
xAxis: ECOption["xAxis"] = {
type: "time",
min: data.start,
max: data.end,
},
yAxis = {
yAxis: ECOption["yAxis"] = {
type: "value",
min: data.bidirectional ? undefined : 0,
axisLabel: { formatter: formatXps },
axisPointer: {
label: { formatter: ({ value }) => formatXps(value) },
label: {
formatter: ({ value }) => formatXps(value.valueOf() as number),
},
},
},
tooltip = {
tooltip: ECOption["tooltip"] = {
confine: true,
trigger: "axis",
axisPointer: {
@@ -111,14 +131,22 @@ const graph = computed(() => {
textStyle: isDark.value ? { color: "#ddd" } : { color: "#222" },
formatter: (params) => {
// 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 = [];
params.forEach((param) => {
let table: {
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 seriesName = [1, 2].includes(axis)
? param.seriesName
: data["axis-names"][axis];
if (!seriesName) return;
const key = `${Math.floor((axis - 1) / 2)}-${seriesName}`;
let idx = findIndex(table, (r) => r.key === key);
if (idx === -1) {
@@ -131,7 +159,7 @@ const graph = computed(() => {
});
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;
else table[idx].down = val;
});
@@ -141,23 +169,26 @@ const graph = computed(() => {
`<tr>`,
`<td>${row.marker} ${row.seriesName}</td>`,
`<td class="pl-2">${data.bidirectional ? "↑" : ""}<b>${formatXps(
row.up || 0
row.up
)}</b></td>`,
data.bidirectional
? `<td class="pl-2">↓<b>${formatXps(row.down || 0)}</b></td>`
? `<td class="pl-2">↓<b>${formatXps(row.down)}</b></td>`
: "",
`</tr>`,
].join("")
)
.join("");
return `${params[0].axisValueLabel}<table>${rows}</table>`;
return `${
(params as TooltipCallbackDataParams[])[0].axisValueLabel
}<table>${rows}</table>`;
},
};
// 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),
uniqRowIndex = (row) => findIndex(uniqRows, (orow) => isEqual(row, orow));
uniqRowIndex = (row: string[]) =>
findIndex(uniqRows, (orow) => isEqual(row, orow));
return {
grid: {
@@ -174,10 +205,10 @@ const graph = computed(() => {
.map((row, idx) => {
const isOther = row.some((name) => name === "Other"),
color = isOther ? dataColorGrey : dataColor;
if (data.graphType === graphTypes.lines && isOther) {
if (data.graphType === "lines" && isOther) {
return undefined;
}
let serie = {
let serie: LineSeriesOption = {
type: "line",
symbol: "none",
itemStyle: {
@@ -214,13 +245,10 @@ const graph = computed(() => {
},
};
}
if (
data.graphType === graphTypes.stacked &&
[1, 2].includes(data.axis[idx])
) {
if (data.graphType === "stacked" && [1, 2].includes(data.axis[idx])) {
serie = {
...serie,
stack: data.axis[idx],
stack: data.axis[idx].toString(),
lineStyle:
idx == data.rows.length - 1 ||
data.axis[idx] != data.axis[idx + 1]
@@ -243,26 +271,28 @@ const graph = computed(() => {
}
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) =>
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
.map((row, idx) => (row.some((name) => name === "Other") ? idx : -1))
.filter((idx) => idx >= 0),
somethingY = (fn) =>
fn(
...dataset.source.map((row) =>
fn(
...row
.slice(1)
.filter((_, idx) => !otherIndexes.includes(idx + 1))
)
)
somethingY = (fn: (...n: number[]) => number) =>
fn.apply(
null,
dataset.source.map((row) => {
const [, ...cdr] = row;
return fn.apply(
null,
cdr.filter((_, idx) => !otherIndexes.includes(idx + 1))
);
})
),
maxY = somethingY(Math.max),
minY = somethingY(Math.min);
@@ -313,7 +343,7 @@ const graph = computed(() => {
dataset,
series: data.rows
.map((row, idx) => {
let serie = {
let serie: LineSeriesOption = {
type: "line",
symbol: "none",
xAxisIndex: uniqRowIndex(row),
@@ -346,12 +376,12 @@ const graph = computed(() => {
};
return serie;
})
.filter((s) => s.xAxisIndex >= 0),
.filter((s) => s.xAxisIndex! >= 0),
};
}
return {};
});
const echartsOptions = computed(() => ({ ...commonGraph, ...graph.value }));
const option = computed((): ECOption => ({ ...commonGraph, ...graph.value }));
// Enable and handle brush
const isTouchScreen = useMediaQuery("(pointer: coarse");
@@ -367,22 +397,28 @@ const enableBrush = () => {
});
};
onMounted(enableBrush);
const updateTimeRange = (evt) => {
if (evt.areas.length === 0) {
const updateTimeRange = (evt: BrushModel) => {
if (
!chartComponent.value ||
evt.areas.length === 0 ||
!evt.areas[0].coordRange
) {
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({
type: "brush",
areas: [],
});
emit("update:timeRange", [start, end]);
};
watch([graph, isTouchScreen], enableBrush);
watch([graph, isTouchScreen] as const, enableBrush);
// Highlight selected indexes
watch(
() => [props.highlight, props.data],
() => [props.highlight, props.data] as const,
([index]) => {
chartComponent.value?.dispatchAction({
type: "highlight",

View File

@@ -5,7 +5,7 @@
<div>
<!-- Axis selection -->
<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"
>
<ul class="flex flex-wrap">
@@ -16,7 +16,7 @@
'active border-blue-600 text-blue-600 dark:border-blue-500 dark:text-blue-500':
displayedAxis === axis,
}"
:aria-current="displayedAxis === axis ? 'page' : null"
:aria-current="displayedAxis === axis ? 'page' : undefined"
@click="selectedAxis = axis"
>
{{ name }}
@@ -29,6 +29,7 @@
class="relative overflow-x-auto shadow-md dark:shadow-white/10 sm:rounded-lg"
>
<table
v-if="table"
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">
@@ -84,123 +85,136 @@
</div>
</template>
<script setup>
const props = defineProps({
data: {
type: Object,
default: null,
},
});
const emit = defineEmits(["highlighted"]);
<script lang="ts" setup>
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 { formatXps, dataColor, dataColorGrey } from "@/utils";
import { ThemeKey } from "@/components/ThemeProvider.vue";
import type { GraphHandlerResult, SankeyHandlerResult } from ".";
const { isDark } = inject(ThemeKey)!;
const highlight = (index) => {
if (index === null) {
const props = defineProps<{
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);
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.
const axis = props.data.axis;
const originalIndex = takeWhile(
props.data.rows,
(() => {
let count = 0;
return (_, idx) =>
props.data.axis[idx] != displayedAxis.value || count++ < index;
return (_, idx) => axis[idx] != displayedAxis.value || count++ < index;
})()
).length;
emit("highlighted", originalIndex);
};
const axes = computed(() =>
toPairs(props.data["axis-names"])
const axes = computed(() => {
if (!props.data || props.data.graphType === "sankey") return null;
return toPairs(props.data["axis-names"])
.map(([k, v]) => ({ id: Number(k), name: v }))
.filter(({ id }) => [1, 2].includes(id))
.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: [],
};
.sort(({ id: id1 }, { id: id2 }) => id1 - id2);
});
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>

View File

@@ -52,19 +52,14 @@
</svg>
</template>
<script>
<script lang="ts">
export default {
inheritAttrs: false,
};
</script>
<script setup>
defineProps({
name: {
type: String,
required: true,
},
});
<script lang="ts" setup>
import { graphTypes } from "./constants.js";
defineProps<{ name: string }>();
</script>

View File

@@ -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"
>
<InputCheckbox
v-if="[stacked, lines, grid].includes(graphType.name)"
v-if="
graphType.type === 'stacked' ||
graphType.type === 'lines' ||
graphType.type === 'grid'
"
v-model="bidirectional"
label="Bidirectional"
/>
<InputCheckbox
v-if="[stacked].includes(graphType.name)"
v-if="graphType.type === 'stacked'"
v-model="previousPeriod"
label="Previous period"
/>
@@ -106,45 +110,57 @@
</aside>
</template>
<script setup>
const props = defineProps({
modelValue: {
type: Object,
required: true,
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "cancel"]);
import { ref, watch, computed, inject } from "vue";
<script lang="ts" setup>
import { ref, watch, computed, inject, toRaw } from "vue";
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/vue/solid";
import InputTimeRange from "@/components/InputTimeRange.vue";
import InputDimensions from "@/components/InputDimensions.vue";
import {
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 InputButton from "@/components/InputButton.vue";
import InputCheckbox from "@/components/InputCheckbox.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 GraphIcon from "./GraphIcon.vue";
import { graphTypes } from "./constants";
import type { Units } from ".";
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,
type: k as keyof typeof graphTypes, // why isn't it infered?
name: v,
}));
const { stacked, lines, grid } = graphTypes;
const open = ref(false);
const graphType = ref(graphTypeList[0]);
const timeRange = ref({});
const dimensions = ref([]);
const filter = ref({});
const units = ref("l3bps");
const timeRange = ref<InputTimeRangeModelType>(null);
const dimensions = ref<InputDimensionsModelType>(null);
const filter = ref<InputFilterModelType>(null);
const units = ref<Units>("l3bps");
const bidirectional = ref(false);
const previousPeriod = ref(false);
@@ -156,71 +172,84 @@ const submitOptions = () => {
}
};
const options = computed(() => ({
// Common to all graph types
graphType: graphType.value.name,
start: timeRange.value.start,
end: timeRange.value.end,
dimensions: dimensions.value.selected,
limit: dimensions.value.limit,
filter: filter.value.expression,
units: units.value,
// Depending on the graph type...
...([stacked, lines].includes(graphType.value.name) && {
bidirectional: bidirectional.value,
previousPeriod:
graphType.value.name === stacked ? previousPeriod.value : false,
points: 200,
}),
...(graphType.value.name === grid && {
bidirectional: bidirectional.value,
const options = computed((): ModelType => {
if (!timeRange.value || !dimensions.value || !filter.value) {
return options.value;
}
return {
graphType: graphType.value.type,
start: timeRange.value?.start,
end: timeRange.value?.end,
dimensions: dimensions.value?.selected,
limit: dimensions.value?.limit,
filter: filter.value?.expression,
units: units.value,
bidirectional: 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(() =>
isEqual(options.value, props.modelValue) ? "Refresh" : "Apply"
);
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(
() => [props.modelValue, serverConfiguration.value?.defaultVisualizeOptions],
() =>
[
props.modelValue,
serverConfiguration.value?.defaultVisualizeOptions,
] as const,
([modelValue, defaultOptions]) => {
if (defaultOptions === undefined) return;
const {
graphType: _graphType = graphTypes.stacked,
start = defaultOptions?.start,
end = defaultOptions?.end,
dimensions: _dimensions = defaultOptions?.dimensions,
limit = 10,
points /* eslint-disable-line no-unused-vars */,
filter: _filter = defaultOptions?.filter,
units: _units = "l3bps",
bidirectional: _bidirectional = false,
previousPeriod: _previousPeriod = false,
} = modelValue;
if (!defaultOptions) return;
const currentValue: NonNullable<ModelType> = modelValue ?? {
graphType: "stacked",
start: defaultOptions.start,
end: defaultOptions.end,
dimensions: toRaw(defaultOptions.dimensions),
limit: 10,
filter: defaultOptions.filter,
units: "l3bps",
bidirectional: false,
previousPeriod: false,
};
// Dispatch values in refs
const t = currentValue.graphType;
graphType.value =
graphTypeList.find(({ name }) => name === _graphType) || graphTypeList[0];
timeRange.value = { start, end };
graphTypeList.find(({ type }) => type === t) || graphTypeList[0];
timeRange.value = { start: currentValue.start, end: currentValue.end };
dimensions.value = {
selected: [..._dimensions],
limit,
selected: [...currentValue.dimensions],
limit: currentValue.limit,
};
filter.value = { expression: _filter };
units.value = _units;
bidirectional.value = _bidirectional;
previousPeriod.value = _previousPeriod;
filter.value = { expression: currentValue.filter };
units.value = currentValue.units;
bidirectional.value = currentValue.bidirectional;
previousPeriod.value = currentValue.previousPeriod;
// A bit risky, but it seems to work.
if (!isEqual(modelValue, options.value)) {
open.value = true;
if (!hasErrors.value && start) {
if (!hasErrors.value) {
emit("update:modelValue", options.value);
}
}
@@ -228,3 +257,19 @@ watch(
{ immediate: true, deep: true }
);
</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>

View File

@@ -3,29 +3,29 @@
<template>
<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"
>
<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" />
<span class="align-middle">{{ start }} {{ end }}</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" />
<span class="align-middle">{{ request.graphType }}</span>
<span class="align-middle">{{ graphTypes[request.graphType] }}</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" />
<span class="align-middle">{{ request.limit }}</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" />
<span class="align-middle">{{
{ l3bps: "L3ᵇₛ", l2bps: "L2ᵇₛ", pps: "ᵖ⁄ₛ" }[request.units] ||
requests.units
{ l3bps: "L3ᵇₛ", l2bps: "L2ᵇₛ", pps: "ᵖ⁄ₛ" }[request.units]
}}</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"
:title="request.dimensions.join(', ')"
>
@@ -43,14 +43,7 @@
</div>
</template>
<script setup>
const props = defineProps({
request: {
type: Object,
required: true,
},
});
<script lang="ts" setup>
import { computed, watch, inject } from "vue";
import {
ChartPieIcon,
@@ -61,23 +54,33 @@ import {
HashtagIcon,
} from "@heroicons/vue/solid";
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(() =>
SugarDate(props.request.end).format(
props.request.start?.toDateString() === props.request.end?.toDateString()
? "%X"
: "{long}"
)
props.request
? SugarDate(props.request.end).format(
SugarDate(props.request.start).toDateString() ===
SugarDate(props.request.end).toDateString()
? "%X"
: "{long}"
)
: null
);
// Also set title
const title = inject("title");
const title = inject(TitleKey)!;
const computedTitle = computed(() =>
[
props.request.graphType,
props.request ? graphTypes[props.request?.graphType] : null,
props.request?.dimensions?.join(","),
props.request.filter,
props.request?.filter,
start.value,
end.value,
]

View File

@@ -6,4 +6,5 @@ export const graphTypes = {
lines: "Lines",
grid: "Grid",
sankey: "Sankey",
};
} as const;
export type GraphType = keyof typeof graphTypes;

View 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"
>;

View File

@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: 2022 Free Mobile
// SPDX-License-Identifier: AGPL-3.0-only
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
darkMode: "class",

View 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/*"]
}
}
}

View 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"]
}
}

View File

@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.config.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"lib": [],
"types": ["node", "jsdom"]
}
}

View File

@@ -4,15 +4,15 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { lezer } from "@lezer/generator/rollup";
import path from "path";
import { fileURLToPath, URL } from "node:url";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), lezer()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@data": path.resolve(__dirname, "./data"),
"@": fileURLToPath(new URL("./src", import.meta.url)),
"@data": fileURLToPath(new URL("./data", import.meta.url)),
},
},
build: {