Async config file (#682)
* initial * only donwload config once * formatting * update sample config * sentry * refactor load state * fix build yaml * Upper case enums * change how defaults work. review fixes * abstract initialization * copyright * gitignore styleing * refactor initialization * use dafualt as fallback * internalInstance rename * review * remove acidentally added posthog file * DSN rename * update Copyright * remove olm from the initializer Co-authored-by: Timo K <timok@element.io>
This commit is contained in:
parent
806a9032e1
commit
78a313c373
10 changed files with 293 additions and 88 deletions
5
.github/workflows/build.yaml
vendored
5
.github/workflows/build.yaml
vendored
|
@ -4,9 +4,6 @@ on:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
env:
|
env:
|
||||||
VITE_DEFAULT_HOMESERVER: "https://call.ems.host"
|
VITE_DEFAULT_HOMESERVER: "https://call.ems.host"
|
||||||
VITE_SENTRY_DSN: https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41
|
|
||||||
VITE_SENTRY_ENVIRONMENT: main-branch-cd
|
|
||||||
VITE_RAGESHAKE_SUBMIT_URL: https://element.io/bugreports/submit
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
|
@ -17,7 +14,7 @@ jobs:
|
||||||
- name: Yarn cache
|
- name: Yarn cache
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
cache: 'yarn'
|
cache: "yarn"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: "yarn install"
|
run: "yarn install"
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -5,3 +5,4 @@ dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
.idea/
|
.idea/
|
||||||
|
config.json
|
||||||
|
|
13
sample.config.json
Normal file
13
sample.config.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"posthog": {
|
||||||
|
"api_key": "examplePosthogKey",
|
||||||
|
"api_host": "https://posthog.com"
|
||||||
|
},
|
||||||
|
"sentry": {
|
||||||
|
"environment": "main-branch",
|
||||||
|
"DSN": "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||||
|
},
|
||||||
|
"rageshake": {
|
||||||
|
"submit_url": "http://localhost:9110/api/submit"
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,8 +3,6 @@
|
||||||
set -ex
|
set -ex
|
||||||
|
|
||||||
export VITE_DEFAULT_HOMESERVER=https://call.ems.host
|
export VITE_DEFAULT_HOMESERVER=https://call.ems.host
|
||||||
export VITE_SENTRY_DSN=https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41
|
|
||||||
export VITE_RAGESHAKE_SUBMIT_URL=https://element.io/bugreports/submit
|
|
||||||
export VITE_PRODUCT_NAME="Element Call"
|
export VITE_PRODUCT_NAME="Element Call"
|
||||||
|
|
||||||
git clone https://github.com/matrix-org/matrix-js-sdk.git
|
git clone https://github.com/matrix-org/matrix-js-sdk.git
|
||||||
|
|
77
src/App.tsx
77
src/App.tsx
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { Suspense } from "react";
|
import React, { Suspense, useEffect, useState } from "react";
|
||||||
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
|
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
|
||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
import { OverlayProvider } from "@react-aria/overlays";
|
import { OverlayProvider } from "@react-aria/overlays";
|
||||||
|
@ -28,7 +28,8 @@ import { ClientProvider } from "./ClientContext";
|
||||||
import { usePageFocusStyle } from "./usePageFocusStyle";
|
import { usePageFocusStyle } from "./usePageFocusStyle";
|
||||||
import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
|
import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
|
||||||
import { InspectorContextProvider } from "./room/GroupCallInspector";
|
import { InspectorContextProvider } from "./room/GroupCallInspector";
|
||||||
import { CrashView } from "./FullScreenView";
|
import { CrashView, LoadingView } from "./FullScreenView";
|
||||||
|
import { Initializer } from "./initializer";
|
||||||
|
|
||||||
const SentryRoute = Sentry.withSentryRouting(Route);
|
const SentryRoute = Sentry.withSentryRouting(Route);
|
||||||
|
|
||||||
|
@ -37,42 +38,54 @@ interface AppProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App({ history }: AppProps) {
|
export default function App({ history }: AppProps) {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Initializer.init()?.then(() => {
|
||||||
|
setLoaded(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
usePageFocusStyle();
|
usePageFocusStyle();
|
||||||
|
|
||||||
const errorPage = <CrashView />;
|
const errorPage = <CrashView />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router history={history}>
|
<Router history={history}>
|
||||||
<Suspense fallback={null}>
|
{loaded ? (
|
||||||
<ClientProvider>
|
<Suspense fallback={null}>
|
||||||
<InspectorContextProvider>
|
<ClientProvider>
|
||||||
<Sentry.ErrorBoundary fallback={errorPage}>
|
<InspectorContextProvider>
|
||||||
<OverlayProvider>
|
<Sentry.ErrorBoundary fallback={errorPage}>
|
||||||
<Switch>
|
<OverlayProvider>
|
||||||
<SentryRoute exact path="/">
|
<Switch>
|
||||||
<HomePage />
|
<SentryRoute exact path="/">
|
||||||
</SentryRoute>
|
<HomePage />
|
||||||
<SentryRoute exact path="/login">
|
</SentryRoute>
|
||||||
<LoginPage />
|
<SentryRoute exact path="/login">
|
||||||
</SentryRoute>
|
<LoginPage />
|
||||||
<SentryRoute exact path="/register">
|
</SentryRoute>
|
||||||
<RegisterPage />
|
<SentryRoute exact path="/register">
|
||||||
</SentryRoute>
|
<RegisterPage />
|
||||||
<SentryRoute path="/room/:roomId?">
|
</SentryRoute>
|
||||||
<RoomPage />
|
<SentryRoute path="/room/:roomId?">
|
||||||
</SentryRoute>
|
<RoomPage />
|
||||||
<SentryRoute path="/inspector">
|
</SentryRoute>
|
||||||
<SequenceDiagramViewerPage />
|
<SentryRoute path="/inspector">
|
||||||
</SentryRoute>
|
<SequenceDiagramViewerPage />
|
||||||
<SentryRoute path="*">
|
</SentryRoute>
|
||||||
<RoomRedirect />
|
<SentryRoute path="*">
|
||||||
</SentryRoute>
|
<RoomRedirect />
|
||||||
</Switch>
|
</SentryRoute>
|
||||||
</OverlayProvider>
|
</Switch>
|
||||||
</Sentry.ErrorBoundary>
|
</OverlayProvider>
|
||||||
</InspectorContextProvider>
|
</Sentry.ErrorBoundary>
|
||||||
</ClientProvider>
|
</InspectorContextProvider>
|
||||||
</Suspense>
|
</ClientProvider>
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<LoadingView />
|
||||||
|
)}
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
64
src/config/Config.ts
Normal file
64
src/config/Config.ts
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021-2022 New Vector Ltd
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_CONFIG, IConfigOptions } from "./ConfigOptions";
|
||||||
|
|
||||||
|
export class Config {
|
||||||
|
private static internalInstance: Config;
|
||||||
|
public static get instance(): Config {
|
||||||
|
if (!this.internalInstance)
|
||||||
|
throw new Error("Config instance read before config got initialized");
|
||||||
|
return this.internalInstance;
|
||||||
|
}
|
||||||
|
public static init(): Promise<void> {
|
||||||
|
if (Config?.internalInstance?.initPromise) {
|
||||||
|
return Config.internalInstance.initPromise;
|
||||||
|
}
|
||||||
|
Config.internalInstance = new Config();
|
||||||
|
Config.internalInstance.initPromise = new Promise<void>((resolve) => {
|
||||||
|
downloadConfig("../config.json").then((config) => {
|
||||||
|
Config.internalInstance.config = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Config.internalInstance.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
public config: IConfigOptions;
|
||||||
|
private initPromise: Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadConfig(
|
||||||
|
configJsonFilename: string
|
||||||
|
): Promise<IConfigOptions> {
|
||||||
|
const url = new URL(configJsonFilename, window.location.href);
|
||||||
|
url.searchParams.set("cachebuster", Date.now().toString());
|
||||||
|
const res = await fetch(url, {
|
||||||
|
cache: "no-cache",
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 404 || res.status === 0) {
|
||||||
|
// Lack of a config isn't an error, we should just use the defaults.
|
||||||
|
// Also treat a blank config as no config, assuming the status code is 0, because we don't get 404s from file:
|
||||||
|
// URIs so this is the only way we can not fail if the file doesn't exist when loading from a file:// URI.
|
||||||
|
return {} as IConfigOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
}
|
19
src/config/ConfigOptions.ts
Normal file
19
src/config/ConfigOptions.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
export interface IConfigOptions {
|
||||||
|
posthog?: {
|
||||||
|
api_key: string;
|
||||||
|
};
|
||||||
|
sentry?: {
|
||||||
|
DSN: string;
|
||||||
|
environment: string;
|
||||||
|
};
|
||||||
|
rageshake?: {
|
||||||
|
submit_url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: IConfigOptions = {
|
||||||
|
sentry: { DSN: "", environment: "production" },
|
||||||
|
rageshake: {
|
||||||
|
submit_url: "https://element.io/bugreports/submit",
|
||||||
|
},
|
||||||
|
};
|
146
src/initializer.tsx
Normal file
146
src/initializer.tsx
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Integrations } from "@sentry/tracing";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
|
import Backend from "i18next-http-backend";
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
|
|
||||||
|
import { getUrlParams } from "./UrlParams";
|
||||||
|
import { Config } from "./config/Config";
|
||||||
|
import { DEFAULT_CONFIG } from "./config/ConfigOptions";
|
||||||
|
|
||||||
|
enum LoadState {
|
||||||
|
None,
|
||||||
|
Loading,
|
||||||
|
Loaded,
|
||||||
|
}
|
||||||
|
|
||||||
|
class DependencyLoadStates {
|
||||||
|
// TODO: decide where olm should be initialized (see TODO comment below)
|
||||||
|
// olm: LoadState = LoadState.None;
|
||||||
|
config: LoadState = LoadState.None;
|
||||||
|
sentry: LoadState = LoadState.None;
|
||||||
|
i18n: LoadState = LoadState.None;
|
||||||
|
|
||||||
|
allDepsAreLoaded() {
|
||||||
|
return !Object.values(this).some((s) => s !== LoadState.Loaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Initializer {
|
||||||
|
private static internalInstance: Initializer;
|
||||||
|
|
||||||
|
public static init(): Promise<void> | null {
|
||||||
|
if (Initializer?.internalInstance?.initPromise) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Initializer.internalInstance = new Initializer();
|
||||||
|
Initializer.internalInstance.initPromise = new Promise<void>((resolve) => {
|
||||||
|
// initStep calls itself recursivly until everything is initialized in the correct order.
|
||||||
|
// Then the promise gets resolved.
|
||||||
|
Initializer.internalInstance.initStep(resolve);
|
||||||
|
});
|
||||||
|
return Initializer.internalInstance.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStates = new DependencyLoadStates();
|
||||||
|
|
||||||
|
initStep(resolve: (value: void | PromiseLike<void>) => void) {
|
||||||
|
// TODO: Olm is initialized with the client currently (see `initClient()` and `olm.ts`)
|
||||||
|
// we need to decide if we want to init it here or keep it in initClient
|
||||||
|
// if (this.loadStates.olm === LoadState.None) {
|
||||||
|
// this.loadStates.olm = LoadState.Loading;
|
||||||
|
// // TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
||||||
|
// window.OLM_OPTIONS = {};
|
||||||
|
// Olm.init({ locateFile: () => olmWasmPath }).then(() => {
|
||||||
|
// this.loadStates.olm = LoadState.Loaded;
|
||||||
|
// this.initStep(resolve);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// config
|
||||||
|
if (this.loadStates.config === LoadState.None) {
|
||||||
|
this.loadStates.config = LoadState.Loading;
|
||||||
|
Config.init().then(() => {
|
||||||
|
this.loadStates.config = LoadState.Loaded;
|
||||||
|
this.initStep(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//sentry (only initialize after the config is ready)
|
||||||
|
if (
|
||||||
|
this.loadStates.sentry === LoadState.None &&
|
||||||
|
this.loadStates.config === LoadState.Loaded
|
||||||
|
) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: Config.instance.config.sentry?.DSN ?? DEFAULT_CONFIG.sentry.DSN,
|
||||||
|
environment:
|
||||||
|
Config.instance.config.sentry.environment ??
|
||||||
|
DEFAULT_CONFIG.sentry.environment,
|
||||||
|
integrations: [
|
||||||
|
new Integrations.BrowserTracing({
|
||||||
|
routingInstrumentation:
|
||||||
|
Sentry.reactRouterV5Instrumentation(history),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
});
|
||||||
|
this.loadStates.sentry = LoadState.Loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
//i18n
|
||||||
|
if (this.loadStates.i18n === LoadState.None) {
|
||||||
|
const languageDetector = new LanguageDetector();
|
||||||
|
languageDetector.addDetector({
|
||||||
|
name: "urlFragment",
|
||||||
|
// Look for a language code in the URL's fragment
|
||||||
|
lookup: () => getUrlParams().lang ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(Backend)
|
||||||
|
.use(languageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
fallbackLng: "en-GB",
|
||||||
|
defaultNS: "app",
|
||||||
|
keySeparator: false,
|
||||||
|
nsSeparator: false,
|
||||||
|
pluralSeparator: "|",
|
||||||
|
contextSeparator: "|",
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false, // React has built-in XSS protections
|
||||||
|
},
|
||||||
|
detection: {
|
||||||
|
// No localStorage detectors or caching here, since we don't have any way
|
||||||
|
// of letting the user manually select a language
|
||||||
|
order: ["urlFragment", "navigator"],
|
||||||
|
caches: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.loadStates.i18n = LoadState.Loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.loadStates.allDepsAreLoaded()) {
|
||||||
|
// resolve if there is no dependency that is not loaded
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private initPromise: Promise<void>;
|
||||||
|
}
|
48
src/main.tsx
48
src/main.tsx
|
@ -23,17 +23,10 @@ import "matrix-js-sdk/src/browser-index";
|
||||||
import React, { StrictMode } from "react";
|
import React, { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { createBrowserHistory } from "history";
|
import { createBrowserHistory } from "history";
|
||||||
import * as Sentry from "@sentry/react";
|
|
||||||
import { Integrations } from "@sentry/tracing";
|
|
||||||
import i18n from "i18next";
|
|
||||||
import { initReactI18next } from "react-i18next";
|
|
||||||
import Backend from "i18next-http-backend";
|
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
|
||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { init as initRageshake } from "./settings/rageshake";
|
import { init as initRageshake } from "./settings/rageshake";
|
||||||
import { getUrlParams } from "./UrlParams";
|
|
||||||
|
|
||||||
initRageshake();
|
initRageshake();
|
||||||
|
|
||||||
|
@ -108,47 +101,6 @@ if (import.meta.env.VITE_CUSTOM_THEME) {
|
||||||
|
|
||||||
const history = createBrowserHistory();
|
const history = createBrowserHistory();
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn: import.meta.env.VITE_SENTRY_DSN as string,
|
|
||||||
environment:
|
|
||||||
(import.meta.env.VITE_SENTRY_ENVIRONMENT as string) ?? "production",
|
|
||||||
integrations: [
|
|
||||||
new Integrations.BrowserTracing({
|
|
||||||
routingInstrumentation: Sentry.reactRouterV5Instrumentation(history),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
tracesSampleRate: 1.0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const languageDetector = new LanguageDetector();
|
|
||||||
languageDetector.addDetector({
|
|
||||||
name: "urlFragment",
|
|
||||||
// Look for a language code in the URL's fragment
|
|
||||||
lookup: () => getUrlParams().lang ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
i18n
|
|
||||||
.use(Backend)
|
|
||||||
.use(languageDetector)
|
|
||||||
.use(initReactI18next)
|
|
||||||
.init({
|
|
||||||
fallbackLng: "en-GB",
|
|
||||||
defaultNS: "app",
|
|
||||||
keySeparator: false,
|
|
||||||
nsSeparator: false,
|
|
||||||
pluralSeparator: "|",
|
|
||||||
contextSeparator: "|",
|
|
||||||
interpolation: {
|
|
||||||
escapeValue: false, // React has built-in XSS protections
|
|
||||||
},
|
|
||||||
detection: {
|
|
||||||
// No localStorage detectors or caching here, since we don't have any way
|
|
||||||
// of letting the user manually select a language
|
|
||||||
order: ["urlFragment", "navigator"],
|
|
||||||
caches: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App history={history} />
|
<App history={history} />
|
||||||
|
|
|
@ -24,6 +24,8 @@ import { getLogsForReport } from "./rageshake";
|
||||||
import { useClient } from "../ClientContext";
|
import { useClient } from "../ClientContext";
|
||||||
import { InspectorContext } from "../room/GroupCallInspector";
|
import { InspectorContext } from "../room/GroupCallInspector";
|
||||||
import { useModalTriggerState } from "../Modal";
|
import { useModalTriggerState } from "../Modal";
|
||||||
|
import { Config } from "../config/Config";
|
||||||
|
import { DEFAULT_CONFIG } from "../config/ConfigOptions";
|
||||||
|
|
||||||
interface RageShakeSubmitOptions {
|
interface RageShakeSubmitOptions {
|
||||||
sendLogs: boolean;
|
sendLogs: boolean;
|
||||||
|
@ -252,8 +254,8 @@ export function useSubmitRageshake(): {
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetch(
|
await fetch(
|
||||||
(import.meta.env.VITE_RAGESHAKE_SUBMIT_URL as string) ||
|
Config.instance.config.rageshake?.submit_url ??
|
||||||
"https://element.io/bugreports/submit",
|
DEFAULT_CONFIG.rageshake.submit_url,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
|
|
Loading…
Add table
Reference in a new issue