mirror of
https://github.com/grafana/grafana.git
synced 2025-09-18 21:22:53 +08:00
Loki: Decouple data source plugin (#107242)
* WIP * Update yarn.lock * Align uuid dependency * Add e2e test and update * Update cue version * Fix lint * Update snapshot test * Fix test that was importing from coupled module * Fix lint * Update public/app/plugins/datasource/loki/package.json Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com> --------- Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>
This commit is contained in:
@ -101,6 +101,8 @@ linters:
|
||||
- '**/pkg/tsdb/tempo/**/*'
|
||||
- '**/pkg/tsdb/cloudwatch/*'
|
||||
- '**/pkg/tsdb/cloudwatch/**/*'
|
||||
- '**/pkg/tsdb/loki/*'
|
||||
- '**/pkg/tsdb/loki/**/*'
|
||||
deny:
|
||||
- pkg: github.com/grafana/grafana/pkg/api
|
||||
desc: Core plugins are not allowed to depend on Grafana core packages
|
||||
|
8
e2e/plugin-e2e/loki/loki.spec.ts
Normal file
8
e2e/plugin-e2e/loki/loki.spec.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test('Smoke test: decoupled frontend plugin loads', async ({ createDataSourceConfigPage, page }) => {
|
||||
await createDataSourceConfigPage({ type: 'loki' });
|
||||
|
||||
await expect(await page.getByText('Type: Loki', { exact: true })).toBeVisible();
|
||||
await expect(await page.getByRole('heading', { name: 'Connection', exact: true })).toBeVisible();
|
||||
});
|
@ -10,7 +10,7 @@
|
||||
|
||||
import * as common from '@grafana/schema';
|
||||
|
||||
export const pluginVersion = "12.1.0-pre";
|
||||
export const pluginVersion = "%VERSION%";
|
||||
|
||||
export enum QueryEditorMode {
|
||||
Builder = 'builder',
|
||||
|
@ -209,7 +209,7 @@
|
||||
"path": "public/app/plugins/datasource/azuremonitor/img/azure_monitor_cpu.png"
|
||||
}
|
||||
],
|
||||
"version": "11.6.0-pre",
|
||||
"version": "12.1.0-pre",
|
||||
"updated": "",
|
||||
"keywords": [
|
||||
"azure",
|
||||
@ -880,7 +880,7 @@
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "11.6.0-pre",
|
||||
"version": "12.1.0-pre",
|
||||
"updated": "",
|
||||
"keywords": null
|
||||
},
|
||||
@ -934,7 +934,7 @@
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "11.6.0-pre",
|
||||
"version": "12.1.0-pre",
|
||||
"updated": "",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@ -1217,7 +1217,7 @@
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "11.6.0-pre",
|
||||
"version": "12.1.0-pre",
|
||||
"updated": "",
|
||||
"keywords": null
|
||||
},
|
||||
@ -1325,12 +1325,12 @@
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "",
|
||||
"version": "12.1.0-pre",
|
||||
"updated": "",
|
||||
"keywords": null
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": "",
|
||||
"grafanaDependency": "\u003e=10.4.0",
|
||||
"grafanaVersion": "*",
|
||||
"plugins": [],
|
||||
"extensions": {
|
||||
@ -1375,7 +1375,7 @@
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "11.6.0-pre",
|
||||
"version": "12.1.0-pre",
|
||||
"updated": "",
|
||||
"keywords": null
|
||||
},
|
||||
@ -1425,7 +1425,7 @@
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "11.6.0-pre",
|
||||
"version": "12.1.0-pre",
|
||||
"updated": "",
|
||||
"keywords": null
|
||||
},
|
||||
@ -1629,7 +1629,7 @@
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "11.6.0-pre",
|
||||
"version": "12.1.0-pre",
|
||||
"updated": "",
|
||||
"keywords": [
|
||||
"grafana",
|
||||
@ -1734,7 +1734,7 @@
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "11.6.0-pre",
|
||||
"version": "12.1.0-pre",
|
||||
"updated": "",
|
||||
"keywords": null
|
||||
},
|
||||
@ -2042,7 +2042,7 @@
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "11.6.0-pre",
|
||||
"version": "12.1.0-pre",
|
||||
"updated": "",
|
||||
"keywords": null
|
||||
},
|
||||
@ -2092,7 +2092,7 @@
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "11.6.0-pre",
|
||||
"version": "12.1.0-pre",
|
||||
"updated": "",
|
||||
"keywords": null
|
||||
},
|
||||
@ -2445,7 +2445,7 @@
|
||||
},
|
||||
"build": {},
|
||||
"screenshots": null,
|
||||
"version": "11.6.0-pre",
|
||||
"version": "12.1.0-pre",
|
||||
"updated": "",
|
||||
"keywords": null
|
||||
},
|
||||
|
40
pkg/tsdb/loki/standalone/datasource.go
Normal file
40
pkg/tsdb/loki/standalone/datasource.go
Normal file
@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
|
||||
|
||||
loki "github.com/grafana/grafana/pkg/tsdb/loki"
|
||||
)
|
||||
|
||||
var (
|
||||
_ backend.QueryDataHandler = (*Datasource)(nil)
|
||||
_ backend.CheckHealthHandler = (*Datasource)(nil)
|
||||
_ backend.CallResourceHandler = (*Datasource)(nil)
|
||||
)
|
||||
|
||||
func NewDatasource(context.Context, backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
|
||||
return &Datasource{
|
||||
Service: loki.ProvideService(httpclient.NewProvider(), tracing.DefaultTracer()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Datasource struct {
|
||||
Service *loki.Service
|
||||
}
|
||||
|
||||
func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
|
||||
return d.Service.QueryData(ctx, req)
|
||||
}
|
||||
|
||||
func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
|
||||
return d.Service.CallResource(ctx, req, sender)
|
||||
}
|
||||
|
||||
func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
|
||||
return d.Service.CheckHealth(ctx, req)
|
||||
}
|
23
pkg/tsdb/loki/standalone/main.go
Normal file
23
pkg/tsdb/loki/standalone/main.go
Normal file
@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Start listening to requests sent from Grafana. This call is blocking so
|
||||
// it won't finish until Grafana shuts down the process or the plugin choose
|
||||
// to exit by itself using os.Exit. Manage automatically manages life cycle
|
||||
// of datasource instances. It accepts datasource instance factory as first
|
||||
// argument. This factory will be automatically called on incoming request
|
||||
// from Grafana to create different instances of SampleDatasource (per datasource
|
||||
// ID). When datasource configuration changed Dispose method will be called and
|
||||
// new datasource instance created using NewSampleDatasource factory.
|
||||
if err := datasource.Manage("loki", NewDatasource, datasource.ManageOpts{}); err != nil {
|
||||
log.DefaultLogger.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
@ -187,5 +187,14 @@ export default defineConfig<PluginOptions>({
|
||||
},
|
||||
dependencies: ['authenticate'],
|
||||
},
|
||||
{
|
||||
name: 'loki',
|
||||
testDir: path.join(testDirRoot, '/loki'),
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'playwright/.auth/admin.json',
|
||||
},
|
||||
dependencies: ['authenticate'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@ -4,14 +4,21 @@ import { merge, uniqueId } from 'lodash';
|
||||
import { openMenu } from 'react-select-event';
|
||||
import { Observable } from 'rxjs';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
import { MockDataSourceApi } from 'test/mocks/datasource_srv';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
|
||||
import { DataSourceInstanceSettings, SupportedTransformationType } from '@grafana/data';
|
||||
import { SupportedTransformationType } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { BackendSrv, BackendSrvRequest, reportInteraction, setBackendSrv, setAppEvents } from '@grafana/runtime';
|
||||
import {
|
||||
BackendSrv,
|
||||
BackendSrvRequest,
|
||||
DataSourceSrv,
|
||||
reportInteraction,
|
||||
setAppEvents,
|
||||
setDataSourceSrv,
|
||||
} from '@grafana/runtime';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { mockDataSource } from '../alerting/unified/mocks';
|
||||
@ -23,6 +30,7 @@ import {
|
||||
createFetchCorrelationsResponse,
|
||||
createRemoveCorrelationResponse,
|
||||
createUpdateCorrelationResponse,
|
||||
MockDataSourceSrv,
|
||||
} from './__mocks__/useCorrelations.mocks';
|
||||
import { Correlation, CreateCorrelationParams, OmitUnion } from './types';
|
||||
|
||||
@ -30,7 +38,7 @@ import { Correlation, CreateCorrelationParams, OmitUnion } from './types';
|
||||
setAppEvents(appEvents);
|
||||
|
||||
const renderWithContext = async (
|
||||
datasources: Record<string, DataSourceInstanceSettings>,
|
||||
datasources: ConstructorParameters<typeof MockDataSourceSrv>[0] = {},
|
||||
correlations: Correlation[] = []
|
||||
) => {
|
||||
const backend = {
|
||||
@ -90,8 +98,17 @@ const renderWithContext = async (
|
||||
},
|
||||
} as unknown as BackendSrv;
|
||||
const grafanaContext = getGrafanaContextMock({ backend });
|
||||
setBackendSrv(backend);
|
||||
setupDataSources(...Object.values(datasources));
|
||||
const dsServer = new MockDataSourceSrv(datasources) as unknown as DataSourceSrv;
|
||||
dsServer.get = (name: string) => {
|
||||
const dsApi = new MockDataSourceApi(name);
|
||||
// Mock the QueryEditor component
|
||||
dsApi.components = {
|
||||
QueryEditor: () => <>{name} query editor</>,
|
||||
};
|
||||
return Promise.resolve(dsApi);
|
||||
};
|
||||
|
||||
setDataSourceSrv(dsServer);
|
||||
|
||||
const renderResult = render(
|
||||
<TestProvider store={configureStore({})} grafanaContext={grafanaContext}>
|
||||
@ -207,7 +224,7 @@ describe('CorrelationsPage', () => {
|
||||
jsonData: {},
|
||||
type: 'datasource',
|
||||
},
|
||||
{ logs: true, module: 'core:plugin/loki' }
|
||||
{ logs: true }
|
||||
),
|
||||
prometheus: mockDataSource(
|
||||
{
|
||||
@ -316,7 +333,6 @@ describe('CorrelationsPage', () => {
|
||||
},
|
||||
{
|
||||
logs: true,
|
||||
module: 'core:plugin/loki',
|
||||
}
|
||||
),
|
||||
prometheus: mockDataSource(
|
||||
@ -582,7 +598,6 @@ describe('CorrelationsPage', () => {
|
||||
},
|
||||
{
|
||||
logs: true,
|
||||
module: 'core:plugin/loki',
|
||||
}
|
||||
),
|
||||
},
|
||||
@ -678,7 +693,7 @@ describe('CorrelationsPage', () => {
|
||||
access: 'direct',
|
||||
type: 'datasource',
|
||||
},
|
||||
{ logs: true, module: 'core:plugin/loki' }
|
||||
{ logs: true }
|
||||
),
|
||||
},
|
||||
correlations
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { merge } from 'lodash';
|
||||
import { DeepPartial } from 'react-hook-form';
|
||||
import { DatasourceSrvMock } from 'test/mocks/datasource_srv';
|
||||
|
||||
import { DataSourceApi, DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { FetchError, FetchResponse } from '@grafana/runtime';
|
||||
|
||||
import { Correlation, CreateCorrelationResponse, RemoveCorrelationResponse, UpdateCorrelationResponse } from '../types';
|
||||
@ -56,3 +58,17 @@ export function createRemoveCorrelationResponse(): RemoveCorrelationResponse {
|
||||
message: 'Correlation removed',
|
||||
};
|
||||
}
|
||||
|
||||
export class MockDataSourceSrv extends DatasourceSrvMock {
|
||||
private ds: DataSourceInstanceSettings[];
|
||||
constructor(datasources: Record<string, DataSourceInstanceSettings>) {
|
||||
super({} as DataSourceApi, {});
|
||||
this.ds = Object.values(datasources);
|
||||
}
|
||||
getList(): DataSourceInstanceSettings[] {
|
||||
return this.ds;
|
||||
}
|
||||
getInstanceSettings(name?: string): DataSourceInstanceSettings | undefined {
|
||||
return name ? this.ds.find((ds) => ds.name === name) : undefined;
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,6 @@ const grafanaPlugin = async () =>
|
||||
await import(/* webpackChunkName: "grafanaPlugin" */ 'app/plugins/datasource/grafana/module');
|
||||
const influxdbPlugin = async () =>
|
||||
await import(/* webpackChunkName: "influxdbPlugin" */ 'app/plugins/datasource/influxdb/module');
|
||||
const lokiPlugin = async () => await import(/* webpackChunkName: "lokiPlugin" */ 'app/plugins/datasource/loki/module');
|
||||
const mixedPlugin = async () =>
|
||||
await import(/* webpackChunkName: "mixedPlugin" */ 'app/plugins/datasource/mixed/module');
|
||||
const prometheusPlugin = async () =>
|
||||
@ -90,7 +89,6 @@ const builtInPlugins: Record<string, System.Module | (() => Promise<System.Modul
|
||||
'core:plugin/opentsdb': opentsdbPlugin,
|
||||
'core:plugin/grafana': grafanaPlugin,
|
||||
'core:plugin/influxdb': influxdbPlugin,
|
||||
'core:plugin/loki': lokiPlugin,
|
||||
'core:plugin/mixed': mixedPlugin,
|
||||
'core:plugin/prometheus': prometheusPlugin,
|
||||
'core:plugin/alertmanager': alertmanagerPlugin,
|
||||
|
1
public/app/plugins/datasource/loki/CHANGELOG.md
Normal file
1
public/app/plugins/datasource/loki/CHANGELOG.md
Normal file
@ -0,0 +1 @@
|
||||
# Changelog
|
52
public/app/plugins/datasource/loki/package.json
Normal file
52
public/app/plugins/datasource/loki/package.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@grafana-plugins/loki",
|
||||
"description": "Loki data source plugin for Grafana",
|
||||
"private": true,
|
||||
"version": "12.1.0-pre",
|
||||
"dependencies": {
|
||||
"@emotion/css": "11.13.5",
|
||||
"@grafana/data": "12.1.0-pre",
|
||||
"@grafana/lezer-logql": "0.2.7",
|
||||
"@grafana/llm": "0.22.1",
|
||||
"@grafana/monaco-logql": "^0.0.8",
|
||||
"@grafana/runtime": "12.1.0-pre",
|
||||
"@grafana/schema": "12.1.0-pre",
|
||||
"@grafana/ui": "12.1.0-pre",
|
||||
"d3-random": "^3.0.1",
|
||||
"lodash": "4.17.21",
|
||||
"micro-memoize": "^4.1.2",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-select": "5.10.1",
|
||||
"react-use": "17.6.0",
|
||||
"rxjs": "7.8.2",
|
||||
"tslib": "2.8.1",
|
||||
"uuid": "11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@grafana/e2e-selectors": "12.1.0-pre",
|
||||
"@grafana/plugin-configs": "12.1.0-pre",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/react": "16.2.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/d3-random": "^3.0.2",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.15",
|
||||
"@types/node": "22.15.0",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/uuid": "10.0.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.7.3",
|
||||
"webpack": "5.97.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@grafana/runtime": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "webpack -c ./webpack.config.ts --env production",
|
||||
"build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)",
|
||||
"dev": "webpack -w -c ./webpack.config.ts --env development"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2"
|
||||
}
|
@ -14,7 +14,7 @@
|
||||
"queryOptions": {
|
||||
"maxDataPoints": true
|
||||
},
|
||||
|
||||
"executable": "gpx_loki",
|
||||
"info": {
|
||||
"description": "Like Prometheus but for logs. OSS logging solution from Grafana Labs",
|
||||
"author": {
|
||||
@ -42,6 +42,11 @@
|
||||
"name": "Documentation",
|
||||
"url": "https://grafana.com/docs/grafana/latest/datasources/loki/"
|
||||
}
|
||||
]
|
||||
],
|
||||
"version": "%VERSION%"
|
||||
},
|
||||
"dependencies": {
|
||||
"grafanaDependency": ">=10.4.0",
|
||||
"plugins": []
|
||||
}
|
||||
}
|
||||
|
9
public/app/plugins/datasource/loki/project.json
Normal file
9
public/app/plugins/datasource/loki/project.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:plugin", "type:datasource"],
|
||||
"targets": {
|
||||
"build": {},
|
||||
"dev": {}
|
||||
}
|
||||
}
|
7
public/app/plugins/datasource/loki/tsconfig.json
Normal file
7
public/app/plugins/datasource/loki/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"extends": "@grafana/plugin-configs/tsconfig.json",
|
||||
"include": ["."]
|
||||
}
|
4
public/app/plugins/datasource/loki/webpack.config.ts
Normal file
4
public/app/plugins/datasource/loki/webpack.config.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import config from '@grafana/plugin-configs/webpack.config';
|
||||
|
||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
||||
export default config;
|
42
yarn.lock
42
yarn.lock
@ -2713,6 +2713,48 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@grafana-plugins/loki@workspace:public/app/plugins/datasource/loki":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@grafana-plugins/loki@workspace:public/app/plugins/datasource/loki"
|
||||
dependencies:
|
||||
"@emotion/css": "npm:11.13.5"
|
||||
"@grafana/data": "npm:12.1.0-pre"
|
||||
"@grafana/e2e-selectors": "npm:12.1.0-pre"
|
||||
"@grafana/lezer-logql": "npm:0.2.7"
|
||||
"@grafana/llm": "npm:0.22.1"
|
||||
"@grafana/monaco-logql": "npm:^0.0.8"
|
||||
"@grafana/plugin-configs": "npm:12.1.0-pre"
|
||||
"@grafana/runtime": "npm:12.1.0-pre"
|
||||
"@grafana/schema": "npm:12.1.0-pre"
|
||||
"@grafana/ui": "npm:12.1.0-pre"
|
||||
"@testing-library/dom": "npm:10.4.0"
|
||||
"@testing-library/react": "npm:16.2.0"
|
||||
"@testing-library/user-event": "npm:14.6.1"
|
||||
"@types/d3-random": "npm:^3.0.2"
|
||||
"@types/jest": "npm:29.5.14"
|
||||
"@types/lodash": "npm:4.17.15"
|
||||
"@types/node": "npm:22.15.0"
|
||||
"@types/react": "npm:18.3.18"
|
||||
"@types/react-dom": "npm:18.3.5"
|
||||
"@types/uuid": "npm:10.0.0"
|
||||
d3-random: "npm:^3.0.1"
|
||||
lodash: "npm:4.17.21"
|
||||
micro-memoize: "npm:^4.1.2"
|
||||
react: "npm:18.3.1"
|
||||
react-dom: "npm:18.3.1"
|
||||
react-select: "npm:5.10.1"
|
||||
react-use: "npm:17.6.0"
|
||||
rxjs: "npm:7.8.2"
|
||||
ts-node: "npm:10.9.2"
|
||||
tslib: "npm:2.8.1"
|
||||
typescript: "npm:5.7.3"
|
||||
uuid: "npm:11.1.0"
|
||||
webpack: "npm:5.97.1"
|
||||
peerDependencies:
|
||||
"@grafana/runtime": "*"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@grafana-plugins/mssql@workspace:public/app/plugins/datasource/mssql":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@grafana-plugins/mssql@workspace:public/app/plugins/datasource/mssql"
|
||||
|
Reference in New Issue
Block a user