mirror of
https://github.com/umami-software/umami.git
synced 2026-02-22 05:25:36 +01:00
Compare commits
No commits in common. "ec81cd665fcc53514c68f4814f5f0dbae93af816" and "8e059212276e18e22095c72302d257a5780b7723" have entirely different histories.
ec81cd665f
...
8e05921227
16 changed files with 67 additions and 52 deletions
|
|
@ -14,14 +14,14 @@ const frameAncestors = process.env.ALLOWED_FRAME_URLS || '';
|
|||
const trackerScriptName = process.env.TRACKER_SCRIPT_NAME || '';
|
||||
const trackerScriptURL = process.env.TRACKER_SCRIPT_URL || '';
|
||||
|
||||
const contentSecurityPolicy = `
|
||||
default-src 'self';
|
||||
img-src 'self' https: data:;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
connect-src *;
|
||||
frame-ancestors 'self' ${frameAncestors};
|
||||
`;
|
||||
const contentSecurityPolicy = [
|
||||
`default-src 'self'`,
|
||||
`img-src * data:`,
|
||||
`script-src 'self' 'unsafe-eval' 'unsafe-inline'`,
|
||||
`style-src 'self' 'unsafe-inline'`,
|
||||
`connect-src 'self' api.umami.is cloud.umami.is`,
|
||||
`frame-ancestors 'self' ${frameAncestors}`,
|
||||
];
|
||||
|
||||
const defaultHeaders = [
|
||||
{
|
||||
|
|
@ -30,7 +30,10 @@ const defaultHeaders = [
|
|||
},
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: contentSecurityPolicy.replace(/\s{2,}/g, ' ').trim(),
|
||||
value: contentSecurityPolicy
|
||||
.join(';')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim(),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -23,5 +23,12 @@ if (!process.env.SKIP_DB_CHECK && !process.env.DATABASE_TYPE) {
|
|||
}
|
||||
|
||||
if (process.env.CLOUD_URL) {
|
||||
checkMissing(['CLOUD_URL', 'CLICKHOUSE_URL', 'REDIS_URL']);
|
||||
checkMissing([
|
||||
'CLOUD_URL',
|
||||
'CLICKHOUSE_URL',
|
||||
'REDIS_URL',
|
||||
'KAFKA_BROKER',
|
||||
'KAFKA_URL',
|
||||
'KAFKA_SASL_MECHANISM',
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { PixelPage } from './PixelPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default async function ({ params }: { params: { pixelId: string } }) {
|
||||
const { pixelId } = await params;
|
||||
export default function ({ params }: { params: { pixelId: string } }) {
|
||||
const { pixelId } = params;
|
||||
|
||||
return <PixelPage pixelId={pixelId} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { EVENT_COLUMNS, EVENT_TYPE, SESSION_COLUMNS } from '@/lib/constants';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { EVENT_COLUMNS, EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, unauthorized } from '@/lib/response';
|
||||
import { dateRangeParams, filterParams, searchParams } from '@/lib/schema';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import {
|
||||
getChannelExpandedMetrics,
|
||||
getEventExpandedMetrics,
|
||||
|
|
@ -50,8 +50,13 @@ export async function GET(
|
|||
}
|
||||
|
||||
if (EVENT_COLUMNS.includes(type)) {
|
||||
if (type === 'event') {
|
||||
const column = FILTER_COLUMNS[type] || type;
|
||||
|
||||
if (column === 'event_name') {
|
||||
filters.eventType = EVENT_TYPE.customEvent;
|
||||
}
|
||||
|
||||
if (type === 'event') {
|
||||
return json(await getEventExpandedMetrics(websiteId, { type, limit, offset }, filters));
|
||||
} else {
|
||||
return json(await getPageviewExpandedMetrics(websiteId, { type, limit, offset }, filters));
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { EVENT_COLUMNS, EVENT_TYPE, SESSION_COLUMNS } from '@/lib/constants';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { EVENT_COLUMNS, EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, unauthorized } from '@/lib/response';
|
||||
import { dateRangeParams, filterParams, searchParams } from '@/lib/schema';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import {
|
||||
getChannelMetrics,
|
||||
getEventMetrics,
|
||||
|
|
@ -10,6 +9,7 @@ import {
|
|||
getSessionMetrics,
|
||||
} from '@/queries/sql';
|
||||
import { z } from 'zod';
|
||||
import { dateRangeParams, filterParams, searchParams } from '@/lib/schema';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
|
|
@ -50,8 +50,13 @@ export async function GET(
|
|||
}
|
||||
|
||||
if (EVENT_COLUMNS.includes(type)) {
|
||||
if (type === 'event') {
|
||||
const column = FILTER_COLUMNS[type] || type;
|
||||
|
||||
if (column === 'event_name') {
|
||||
filters.eventType = EVENT_TYPE.customEvent;
|
||||
}
|
||||
|
||||
if (type === 'event') {
|
||||
return json(await getEventMetrics(websiteId, { type, limit, offset }, filters));
|
||||
} else {
|
||||
return json(await getPageviewMetrics(websiteId, { type, limit, offset }, filters));
|
||||
|
|
|
|||
|
|
@ -122,8 +122,9 @@ export function parseDateValue(value: string) {
|
|||
if (!match) return null;
|
||||
|
||||
const { num, unit } = match.groups;
|
||||
const formattedNum = +num > 0 ? +num - 1 : +num;
|
||||
|
||||
return { num: +num, unit };
|
||||
return { num: formattedNum, unit };
|
||||
}
|
||||
|
||||
export function parseDateRange(value: string, locale = 'en-US'): DateRange {
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ async function clickhouseQuery(
|
|||
const { filterQuery, cohortQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.customEvent,
|
||||
});
|
||||
|
||||
return rawQuery(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import clickhouse from '@/lib/clickhouse';
|
||||
import {
|
||||
EMAIL_DOMAINS,
|
||||
EVENT_TYPE,
|
||||
PAID_AD_PARAMS,
|
||||
SEARCH_DOMAINS,
|
||||
SHOPPING_DOMAINS,
|
||||
|
|
@ -44,6 +45,7 @@ async function relationalQuery(
|
|||
const { queryParams, filterQuery, joinSessionQuery, cohortQuery, dateQuery } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.pageView,
|
||||
});
|
||||
|
||||
return rawQuery(
|
||||
|
|
@ -66,8 +68,7 @@ async function relationalQuery(
|
|||
from website_event
|
||||
${cohortQuery}
|
||||
${joinSessionQuery}
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.event_type != 2
|
||||
where website_id = {{websiteId::uuid}}
|
||||
${dateQuery}
|
||||
${filterQuery}
|
||||
group by 1, 2
|
||||
|
|
@ -92,6 +93,7 @@ async function clickhouseQuery(
|
|||
const { queryParams, filterQuery, cohortQuery } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.pageView,
|
||||
});
|
||||
|
||||
return rawQuery(
|
||||
|
|
@ -138,7 +140,6 @@ async function clickhouseQuery(
|
|||
${cohortQuery}
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
and name != ''
|
||||
${filterQuery}
|
||||
group by prefix, name, session_id, visit_id
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import clickhouse from '@/lib/clickhouse';
|
||||
import {
|
||||
EMAIL_DOMAINS,
|
||||
EVENT_TYPE,
|
||||
PAID_AD_PARAMS,
|
||||
SEARCH_DOMAINS,
|
||||
SHOPPING_DOMAINS,
|
||||
|
|
@ -25,6 +26,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
|||
const { queryParams, filterQuery, joinSessionQuery, cohortQuery, dateQuery } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.pageView,
|
||||
});
|
||||
|
||||
return rawQuery(
|
||||
|
|
@ -47,8 +49,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
|||
from website_event
|
||||
${cohortQuery}
|
||||
${joinSessionQuery}
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.event_type != 2
|
||||
where website_id = {{websiteId::uuid}}
|
||||
${dateQuery}
|
||||
${filterQuery}
|
||||
group by 1, 2
|
||||
|
|
@ -73,6 +74,7 @@ async function clickhouseQuery(
|
|||
const { queryParams, filterQuery, cohortQuery, dateQuery } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.pageView,
|
||||
});
|
||||
|
||||
const sql = `
|
||||
|
|
@ -106,7 +108,6 @@ async function clickhouseQuery(
|
|||
from website_event
|
||||
${cohortQuery}
|
||||
where website_id = {websiteId:UUID}
|
||||
and event_type != 2
|
||||
${dateQuery}
|
||||
${filterQuery}
|
||||
group by 1, 2
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import clickhouse from '@/lib/clickhouse';
|
||||
import { EVENT_COLUMNS } from '@/lib/constants';
|
||||
import { EVENT_TYPE } from '@/lib/constants';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { QueryFilters } from '@/lib/types';
|
||||
import { EVENT_COLUMNS } from '@/lib/constants';
|
||||
|
||||
export interface WebsiteStatsData {
|
||||
pageviews: number;
|
||||
|
|
@ -29,6 +30,7 @@ async function relationalQuery(
|
|||
const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.pageView,
|
||||
});
|
||||
|
||||
return rawQuery(
|
||||
|
|
@ -51,7 +53,6 @@ async function relationalQuery(
|
|||
${joinSessionQuery}
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_type != 2
|
||||
${filterQuery}
|
||||
group by 1, 2
|
||||
) as t
|
||||
|
|
@ -68,6 +69,7 @@ async function clickhouseQuery(
|
|||
const { filterQuery, cohortQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.pageView,
|
||||
});
|
||||
|
||||
let sql = '';
|
||||
|
|
@ -91,7 +93,6 @@ async function clickhouseQuery(
|
|||
${cohortQuery}
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
${filterQuery}
|
||||
group by session_id, visit_id
|
||||
) as t;
|
||||
|
|
@ -114,7 +115,6 @@ async function clickhouseQuery(
|
|||
${cohortQuery}
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
${filterQuery}
|
||||
group by session_id, visit_id
|
||||
) as t;
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ async function relationalQuery(
|
|||
from website_event
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_type != 2
|
||||
and event_type = {{eventType}}
|
||||
group by visit_id
|
||||
) x
|
||||
on x.visit_id = website_event.visit_id
|
||||
|
|
@ -82,7 +82,6 @@ async function relationalQuery(
|
|||
${entryExitQuery}
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_type != 2
|
||||
${excludeDomain}
|
||||
${filterQuery}
|
||||
group by 1
|
||||
|
|
@ -128,7 +127,7 @@ async function clickhouseQuery(
|
|||
from website_event
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
and event_type = {eventType:UInt32}
|
||||
group by visit_id) x
|
||||
ON x.visit_id = website_event.visit_id`;
|
||||
}
|
||||
|
|
@ -155,7 +154,6 @@ async function clickhouseQuery(
|
|||
${entryExitQuery}
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
and name != ''
|
||||
${excludeDomain}
|
||||
${filterQuery}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ async function relationalQuery(
|
|||
from website_event
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_type != 2
|
||||
and event_type = {{eventType}}
|
||||
order by visit_id, created_at ${order}
|
||||
) x
|
||||
on x.visit_id = website_event.visit_id
|
||||
|
|
@ -79,7 +79,6 @@ async function relationalQuery(
|
|||
${entryExitQuery}
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_type != 2
|
||||
${excludeDomain}
|
||||
${filterQuery}
|
||||
group by 1
|
||||
|
|
@ -125,7 +124,7 @@ async function clickhouseQuery(
|
|||
from website_event
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
and event_type = {eventType:UInt32}
|
||||
group by visit_id) x
|
||||
ON x.visit_id = website_event.visit_id`;
|
||||
}
|
||||
|
|
@ -138,7 +137,6 @@ async function clickhouseQuery(
|
|||
${entryExitQuery}
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
${excludeDomain}
|
||||
${filterQuery}
|
||||
group by x
|
||||
|
|
@ -176,7 +174,6 @@ async function clickhouseQuery(
|
|||
${cohortQuery}
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
${excludeDomain}
|
||||
${filterQuery}
|
||||
${groupByQuery}) as g
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import clickhouse from '@/lib/clickhouse';
|
||||
import { EVENT_COLUMNS } from '@/lib/constants';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { EVENT_COLUMNS, EVENT_TYPE } from '@/lib/constants';
|
||||
import { QueryFilters } from '@/lib/types';
|
||||
|
||||
export async function getPageviewStats(...args: [websiteId: string, filters: QueryFilters]) {
|
||||
|
|
@ -17,6 +17,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
|||
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.pageView,
|
||||
});
|
||||
|
||||
return rawQuery(
|
||||
|
|
@ -29,7 +30,6 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
|||
${joinSessionQuery}
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_type != 2
|
||||
${filterQuery}
|
||||
group by 1
|
||||
order by 1
|
||||
|
|
@ -47,6 +47,7 @@ async function clickhouseQuery(
|
|||
const { filterQuery, cohortQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.pageView,
|
||||
});
|
||||
|
||||
let sql = '';
|
||||
|
|
@ -64,7 +65,6 @@ async function clickhouseQuery(
|
|||
${cohortQuery}
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
${filterQuery}
|
||||
group by t
|
||||
) as g
|
||||
|
|
@ -83,7 +83,6 @@ async function clickhouseQuery(
|
|||
${cohortQuery}
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
${filterQuery}
|
||||
group by t
|
||||
) as g
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import clickhouse from '@/lib/clickhouse';
|
||||
import { FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
|
||||
import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { QueryFilters } from '@/lib/types';
|
||||
|
|
@ -40,6 +40,7 @@ async function relationalQuery(
|
|||
{
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.pageView,
|
||||
},
|
||||
{
|
||||
joinSession: SESSION_COLUMNS.includes(type),
|
||||
|
|
@ -62,7 +63,6 @@ async function relationalQuery(
|
|||
${joinSessionQuery}
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_type != 2
|
||||
${filterQuery}
|
||||
group by 1
|
||||
${includeCountry ? ', 3' : ''}
|
||||
|
|
@ -85,6 +85,7 @@ async function clickhouseQuery(
|
|||
const { filterQuery, cohortQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.pageView,
|
||||
});
|
||||
const includeCountry = column === 'city' || column === 'region';
|
||||
|
||||
|
|
@ -115,7 +116,6 @@ async function clickhouseQuery(
|
|||
${cohortQuery}
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
and name != ''
|
||||
${filterQuery}
|
||||
group by name, session_id, visit_id
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ async function relationalQuery(
|
|||
${joinSessionQuery}
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_type != 2
|
||||
and event_type != 2
|
||||
${filterQuery}
|
||||
group by 1
|
||||
${includeCountry ? ', 3' : ''}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import clickhouse from '@/lib/clickhouse';
|
||||
import { EVENT_COLUMNS } from '@/lib/constants';
|
||||
import { EVENT_COLUMNS, EVENT_TYPE } from '@/lib/constants';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { QueryFilters } from '@/lib/types';
|
||||
|
|
@ -17,6 +17,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
|||
const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.pageView,
|
||||
});
|
||||
|
||||
return rawQuery(
|
||||
|
|
@ -29,7 +30,6 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
|
|||
${joinSessionQuery}
|
||||
where website_event.website_id = {{websiteId::uuid}}
|
||||
and website_event.created_at between {{startDate}} and {{endDate}}
|
||||
and website_event.event_type != 2
|
||||
${filterQuery}
|
||||
group by 1
|
||||
order by 1
|
||||
|
|
@ -47,6 +47,7 @@ async function clickhouseQuery(
|
|||
const { filterQuery, cohortQuery, queryParams } = parseFilters({
|
||||
...filters,
|
||||
websiteId,
|
||||
eventType: EVENT_TYPE.pageView,
|
||||
});
|
||||
|
||||
let sql = '';
|
||||
|
|
@ -64,7 +65,6 @@ async function clickhouseQuery(
|
|||
${cohortQuery}
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
${filterQuery}
|
||||
group by t
|
||||
) as g
|
||||
|
|
@ -83,7 +83,6 @@ async function clickhouseQuery(
|
|||
${cohortQuery}
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and event_type != 2
|
||||
${filterQuery}
|
||||
group by t
|
||||
) as g
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue