Merge pull request #3109 from umami-software/analytics

v2.15.0
This commit is contained in:
Mike Cao 2024-12-12 19:36:02 -08:00 committed by GitHub
commit 35068d34a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 1275 additions and 946 deletions

View file

@ -86,12 +86,10 @@ export function NavBar() {
if (!cloudMode) {
const teamIdLocal = getItem('umami.team')?.id;
if (teamIdLocal && pathname !== '/' && pathname !== '/dashboard') {
const url = '/';
router.push(url);
} else if (teamIdLocal) {
const url = `/teams/${teamIdLocal}/dashboard`;
router.push(url);
if (teamIdLocal && teamIdLocal !== teamId) {
router.push(
pathname !== '/' && pathname !== '/dashboard' ? '/' : `/teams/${teamIdLocal}/dashboard`,
);
}
}
}, [cloudMode]);

View file

@ -5,7 +5,9 @@ async function getEnabled() {
return !!process.env.ENABLE_TEST_CONSOLE;
}
export default async function ({ params: { websiteId } }) {
export default async function ({ params }: { params: { websiteId: string } }) {
const { websiteId } = await params;
const enabled = await getEnabled();
if (!enabled) {

View file

@ -1,11 +1,10 @@
import { useState } from 'react';
import { Dropdown, Item, Button, Flexbox } from 'react-basics';
import moment from 'moment-timezone';
import { useTimezone, useMessages } from 'components/hooks';
import { getTimezone } from 'lib/date';
import styles from './TimezoneSetting.module.css';
const timezones = moment.tz.names();
const timezones = Intl.supportedValuesOf('timeZone');
export function TimezoneSetting() {
const [search, setSearch] = useState('');

View file

@ -1,7 +1,9 @@
import { Metadata } from 'next';
import ReportPage from './ReportPage';
export default function ({ params: { reportId } }) {
export default async function ({ params }: { params: { reportId: string } }) {
const { reportId } = await params;
return <ReportPage reportId={reportId} />;
}

View file

@ -1,7 +1,9 @@
import UserPage from './UserPage';
import { Metadata } from 'next';
export default function ({ params: { userId } }) {
export default async function ({ params }: { params: { userId: string } }) {
const { userId } = await params;
return <UserPage userId={userId} />;
}

View file

@ -1,7 +1,9 @@
import WebsiteSettingsPage from './WebsiteSettingsPage';
import { Metadata } from 'next';
export default async function ({ params: { websiteId } }) {
export default async function ({ params }: { params: { websiteId: string } }) {
const { websiteId } = await params;
return <WebsiteSettingsPage websiteId={websiteId} />;
}

View file

@ -1,7 +1,9 @@
import { Metadata } from 'next';
import WebsitesSettingsPage from './WebsitesSettingsPage';
export default function ({ params: { teamId } }: { params: { teamId: string } }) {
export default async function ({ params }: { params: { teamId: string } }) {
const { teamId } = await params;
return <WebsitesSettingsPage teamId={teamId} />;
}

View file

@ -2,7 +2,15 @@ import TeamProvider from './TeamProvider';
import { Metadata } from 'next';
import TeamSettingsLayout from './settings/TeamSettingsLayout';
export default function ({ children, params: { teamId } }) {
export default async function ({
children,
params,
}: {
children: any;
params: { teamId: string };
}) {
const { teamId } = await params;
return (
<TeamProvider teamId={teamId}>
<TeamSettingsLayout>{children}</TeamSettingsLayout>

View file

@ -1,7 +1,9 @@
import TeamMembersPage from './TeamMembersPage';
import { Metadata } from 'next';
import TeamMembersPage from './TeamMembersPage';
export default async function ({ params }: { params: { teamId: string } }) {
const { teamId } = await params;
export default function ({ params: { teamId } }) {
return <TeamMembersPage teamId={teamId} />;
}

View file

@ -1,7 +1,9 @@
import { Metadata } from 'next';
import TeamPage from './TeamPage';
export default function ({ params: { teamId } }) {
export default async function ({ params }: { params: { teamId: string } }) {
const { teamId } = await params;
return <TeamPage teamId={teamId} />;
}

View file

@ -1,7 +1,9 @@
import TeamWebsitesPage from './TeamWebsitesPage';
import { Metadata } from 'next';
export default function ({ params: { teamId } }) {
export default async function ({ params }: { params: { teamId: string } }) {
const { teamId } = await params;
return <TeamWebsitesPage teamId={teamId} />;
}

View file

@ -11,7 +11,7 @@ export function WebsiteChart({
compareMode?: boolean;
}) {
const { dateRange, dateCompare } = useDateRange(websiteId);
const { startDate, endDate, unit } = dateRange;
const { startDate, endDate, unit, value } = dateRange;
const { data, isLoading } = useWebsitePageviews(websiteId, compareMode ? dateCompare : undefined);
const { pageviews, sessions, compare } = (data || {}) as any;
@ -49,6 +49,7 @@ export function WebsiteChart({
maxDate={endDate.toISOString()}
unit={unit}
isLoading={isLoading}
isAllTime={value === 'all'}
/>
);
}

View file

@ -1,7 +1,9 @@
import WebsiteComparePage from './WebsiteComparePage';
import { Metadata } from 'next';
export default function ({ params: { websiteId } }) {
export default async function ({ params }: { params: { websiteId: string } }) {
const { websiteId } = await params;
return <WebsiteComparePage websiteId={websiteId} />;
}

View file

@ -1,7 +1,9 @@
import { Metadata } from 'next';
import EventsPage from './EventsPage';
export default async function ({ params: { websiteId } }) {
export default async function ({ params }: { params: { websiteId: string } }) {
const { websiteId } = await params;
return <EventsPage websiteId={websiteId} />;
}

View file

@ -1,7 +1,15 @@
import { Metadata } from 'next';
import WebsiteProvider from './WebsiteProvider';
export default function ({ children, params: { websiteId } }) {
export default async function ({
children,
params,
}: {
children: any;
params: { websiteId: string };
}) {
const { websiteId } = await params;
return <WebsiteProvider websiteId={websiteId}>{children}</WebsiteProvider>;
}

View file

@ -1,7 +1,9 @@
import WebsiteDetailsPage from './WebsiteDetailsPage';
import { Metadata } from 'next';
export default function WebsitePage({ params: { websiteId } }) {
export default async function WebsitePage({ params }: { params: { websiteId: string } }) {
const { websiteId } = await params;
return <WebsiteDetailsPage websiteId={websiteId} />;
}

View file

@ -1,7 +1,9 @@
import WebsiteRealtimePage from './WebsiteRealtimePage';
import { Metadata } from 'next';
export default function ({ params: { websiteId } }) {
export default async function ({ params }: { params: { websiteId: string } }) {
const { websiteId } = await params;
return <WebsiteRealtimePage websiteId={websiteId} />;
}

View file

@ -1,7 +1,9 @@
import WebsiteReportsPage from './WebsiteReportsPage';
import { Metadata } from 'next';
export default function ({ params: { websiteId } }) {
export default async function ({ params }: { params: { websiteId: string } }) {
const { websiteId } = await params;
return <WebsiteReportsPage websiteId={websiteId} />;
}

View file

@ -5,7 +5,8 @@
}
.row {
display: flex;
display: grid;
grid-template-columns: max-content max-content 1fr;
align-items: center;
gap: 20px;
}
@ -15,10 +16,6 @@
width: 150px;
}
.value {
white-space: nowrap;
}
.header {
font-weight: bold;
}

View file

@ -42,7 +42,7 @@ export function SessionActivity({
</StatusLight>
</div>
<Icon>{eventName ? <Icons.Bolt /> : <Icons.Eye />}</Icon>
<div className={styles.value}>{eventName || urlPath}</div>
<div>{eventName || urlPath}</div>
</div>
</>
);

View file

@ -1,7 +1,13 @@
import SessionDetailsPage from './SessionDetailsPage';
import { Metadata } from 'next';
export default function WebsitePage({ params: { websiteId, sessionId } }) {
export default async function WebsitePage({
params,
}: {
params: { websiteId: string; sessionId: string };
}) {
const { websiteId, sessionId } = await params;
return <SessionDetailsPage websiteId={websiteId} sessionId={sessionId} />;
}

View file

@ -1,7 +1,9 @@
import SessionsPage from './SessionsPage';
import { Metadata } from 'next';
export default function ({ params: { websiteId } }) {
export default async function ({ params }: { params: { websiteId: string } }) {
const { websiteId } = await params;
return <SessionsPage websiteId={websiteId} />;
}

View file

@ -1,8 +1,9 @@
import { Metadata } from 'next';
import Providers from './Providers';
import '@fontsource/inter/300.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/700.css';
import '@fontsource/inter/800.css';
import 'react-basics/dist/styles.css';
import 'styles/index.css';
import 'styles/variables.css';

View file

@ -1,5 +1,7 @@
import SharePage from './SharePage';
export default function ({ params: { shareId } }) {
export default async function ({ params }: { params: { shareId: string } }) {
const { shareId } = await params;
return <SharePage shareId={shareId[0]} />;
}

View file

@ -14,6 +14,7 @@ export interface BarChartProps extends ChartProps {
YAxisType?: string;
minDate?: number | string;
maxDate?: number | string;
isAllTime?: boolean;
}
export function BarChart(props: BarChartProps) {
@ -29,6 +30,7 @@ export function BarChart(props: BarChartProps) {
minDate,
maxDate,
currency,
isAllTime,
} = props;
const options: any = useMemo(() => {
@ -37,7 +39,7 @@ export function BarChart(props: BarChartProps) {
x: {
type: XAxisType,
stacked: true,
min: minDate && new Date(minDate).getSeconds() === 0 ? minDate : '',
min: isAllTime ? '' : minDate,
max: maxDate,
time: {
unit,

View file

@ -1,5 +1,5 @@
function getHostName(url: string) {
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?=]+)/im);
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im);
return match && match.length > 1 ? match[1] : null;
}

View file

@ -12,9 +12,12 @@ export function TypeIcon({
return (
<>
<img
src={`${process.env.basePath || ''}/images/${type}/${
value?.replaceAll(' ', '-').toLowerCase() || 'unknown'
}.png`}
src={`${process.env.basePath || ''}/images/${type}/${value
?.replaceAll(' ', '-')
.toLowerCase()}.png`}
onError={e => {
e.currentTarget.src = `${process.env.basePath || ''}/images/${type}/unknown.png`;
}}
alt={value}
width={type === 'country' ? undefined : 16}
height={type === 'country' ? undefined : 16}

View file

@ -1,4 +1,6 @@
import { useApi } from '../useApi';
import { useCountryNames, useRegionNames } from 'components/hooks';
import useLocale from '../useLocale';
export function useWebsiteValues({
websiteId,
@ -14,6 +16,36 @@ export function useWebsiteValues({
search?: string;
}) {
const { get, useQuery } = useApi();
const { locale } = useLocale();
const { countryNames } = useCountryNames(locale);
const { regionNames } = useRegionNames(locale);
const names = {
country: countryNames,
region: regionNames,
};
const getSearch = (type: string, value: string) => {
if (value) {
const values = names[type];
if (values) {
return (
Object.keys(values)
.reduce((arr: string[], key: string) => {
if (values[key].toLowerCase().includes(value.toLowerCase())) {
return arr.concat(key);
}
return arr;
}, [])
.slice(0, 5)
.join(',') || value
);
}
return value;
}
};
return useQuery({
queryKey: ['websites:values', { websiteId, type, startDate, endDate, search }],
@ -22,7 +54,7 @@ export function useWebsiteValues({
type,
startAt: +startDate,
endAt: +endDate,
search,
search: getSearch(type, search),
}),
enabled: !!(websiteId && type && startDate && endDate),
});

View file

@ -2,12 +2,14 @@ import useMessages from './useMessages';
import { BROWSERS, OS_NAMES } from 'lib/constants';
import useLocale from './useLocale';
import useCountryNames from './useCountryNames';
import useLanguageNames from './useLanguageNames';
import regions from '../../../public/iso-3166-2.json';
export function useFormat() {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const { countryNames } = useCountryNames(locale);
const { languageNames } = useLanguageNames(locale);
const formatOS = (value: string): string => {
return OS_NAMES[value] || value;
@ -34,6 +36,10 @@ export function useFormat() {
return countryNames[country] ? `${value}, ${countryNames[country]}` : value;
};
const formatLanguage = (value: string): string => {
return languageNames[value?.split('-')[0]] || value;
};
const formatValue = (value: string, type: string, data?: { [key: string]: any }): string => {
switch (type) {
case 'os':
@ -48,12 +54,23 @@ export function useFormat() {
return formatRegion(value);
case 'city':
return formatCity(value, data?.country);
case 'language':
return formatLanguage(value);
default:
return value;
}
};
return { formatOS, formatBrowser, formatDevice, formatCountry, formatRegion, formatValue };
return {
formatOS,
formatBrowser,
formatDevice,
formatCountry,
formatRegion,
formatCity,
formatLanguage,
formatValue,
};
}
export default useFormat;

View file

@ -28,7 +28,7 @@ export function useLanguageNames(locale) {
}
}, [locale]);
return list;
return { languageNames: list };
}
export default useLanguageNames;

View file

@ -1,25 +1,24 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import { emptyFilter } from 'lib/filters';
import FilterLink from 'components/common/FilterLink';
import TypeIcon from 'components/common/TypeIcon';
import { useLocale } from 'components/hooks';
import { useMessages } from 'components/hooks';
import { useCountryNames } from 'components/hooks';
import { useFormat } from 'components/hooks';
export function CitiesTable(props: MetricsTableProps) {
const { locale } = useLocale();
const { formatMessage, labels } = useMessages();
const { countryNames } = useCountryNames(locale);
const renderLabel = (city: string, country: string) => {
const countryName = countryNames[country];
return countryName ? `${city}, ${countryName}` : city;
};
const { formatCity } = useFormat();
const renderLink = ({ x: city, country }) => {
return (
<FilterLink id="city" value={city} label={renderLabel(city, country)}>
{country && <TypeIcon type="country" value={country} />}
<FilterLink id="city" value={city} label={formatCity(city, country)}>
{country && (
<img
src={`${process.env.basePath || ''}/images/country/${
country?.toLowerCase() || 'xx'
}.png`}
alt={country}
/>
)}
</FilterLink>
);
};
@ -32,6 +31,7 @@ export function CitiesTable(props: MetricsTableProps) {
metric={formatMessage(labels.visitors)}
dataFilter={emptyFilter}
renderLabel={renderLink}
searchFormattedValues={true}
/>
);
}

View file

@ -12,7 +12,11 @@ export function CountriesTable({ ...props }: MetricsTableProps) {
const renderLink = ({ x: code }) => {
return (
<FilterLink id="country" value={countryNames[code] && code} label={formatCountry(code)}>
<FilterLink
id="country"
value={(countryNames[code] && code) || code}
label={formatCountry(code)}
>
<TypeIcon type="country" value={code?.toLowerCase()} />
</FilterLink>
);
@ -25,6 +29,7 @@ export function CountriesTable({ ...props }: MetricsTableProps) {
type="country"
metric={formatMessage(labels.visitors)}
renderLabel={renderLink}
searchFormattedValues={true}
/>
);
}

View file

@ -23,6 +23,7 @@ export function DevicesTable(props: MetricsTableProps) {
type="device"
metric={formatMessage(labels.visitors)}
renderLabel={renderLink}
searchFormattedValues={true}
/>
);
}

View file

@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { colord } from 'colord';
import BarChart from 'components/charts/BarChart';
import { useLocale, useDateRange, useWebsiteEventsSeries } from 'components/hooks';
import { CHART_COLORS } from 'lib/constants';
import { useDateRange, useLocale, useWebsiteEventsSeries } from 'components/hooks';
import { renderDateLabels } from 'lib/charts';
import { CHART_COLORS } from 'lib/constants';
import { useMemo } from 'react';
export interface EventsChartProps {
websiteId: string;
@ -12,7 +12,7 @@ export interface EventsChartProps {
export function EventsChart({ websiteId, className }: EventsChartProps) {
const {
dateRange: { startDate, endDate, unit },
dateRange: { startDate, endDate, unit, value },
} = useDateRange(websiteId);
const { locale } = useLocale();
const { data, isLoading } = useWebsiteEventsSeries(websiteId);
@ -55,6 +55,7 @@ export function EventsChart({ websiteId, className }: EventsChartProps) {
stacked={true}
renderXLabel={renderDateLabels(unit, locale)}
isLoading={isLoading}
isAllTime={value === 'all'}
/>
);
}

View file

@ -1,8 +1,8 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import { percentFilter } from 'lib/filters';
import { useLanguageNames } from 'components/hooks';
import { useLocale } from 'components/hooks';
import { useMessages } from 'components/hooks';
import { useFormat } from 'components/hooks';
export function LanguagesTable({
onDataLoad,
@ -10,10 +10,10 @@ export function LanguagesTable({
}: { onDataLoad: (data: any) => void } & MetricsTableProps) {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const languageNames = useLanguageNames(locale);
const { formatLanguage } = useFormat();
const renderLabel = ({ x }) => {
return languageNames[x?.split('-')[0]] ?? x;
return <div className={locale}>{formatLanguage(x)}</div>;
};
return (
@ -24,6 +24,7 @@ export function LanguagesTable({
metric={formatMessage(labels.visitors)}
onDataLoad={data => onDataLoad?.(percentFilter(data))}
renderLabel={renderLabel}
searchFormattedValues={true}
/>
);
}

View file

@ -26,6 +26,7 @@ export interface MetricsTableProps extends ListTableProps {
onDataLoad?: (data: any) => void;
onSearch?: (search: string) => void;
allowSearch?: boolean;
searchFormattedValues?: boolean;
showMore?: boolean;
params?: { [key: string]: any };
children?: ReactNode;
@ -40,6 +41,7 @@ export function MetricsTable({
onDataLoad,
delay = null,
allowSearch = false,
searchFormattedValues = false,
showMore = true,
params,
children,
@ -53,7 +55,7 @@ export function MetricsTable({
const { data, isLoading, isFetched, error } = useWebsiteMetrics(
websiteId,
{ type, limit, search, ...params },
{ type, limit, search: searchFormattedValues ? undefined : search, ...params },
{
retryDelay: delay || DEFAULT_ANIMATION_DURATION,
onDataLoad,
@ -74,6 +76,14 @@ export function MetricsTable({
}
}
if (searchFormattedValues && search) {
items = items.filter(({ x, ...data }) => {
const value = formatValue(x, type, data);
return value?.toLowerCase().includes(search.toLowerCase());
});
}
items = percentFilter(items);
return items;

View file

@ -14,9 +14,16 @@ export interface PagepageviewsChartProps extends BarChartProps {
};
unit: string;
isLoading?: boolean;
isAllTime?: boolean;
}
export function PagepageviewsChart({ data, unit, isLoading, ...props }: PagepageviewsChartProps) {
export function PagepageviewsChart({
data,
unit,
isLoading,
isAllTime,
...props
}: PagepageviewsChartProps) {
const { formatMessage, labels } = useMessages();
const { colors } = useTheme();
const { locale } = useLocale();
@ -74,6 +81,7 @@ export function PagepageviewsChart({ data, unit, isLoading, ...props }: Pagepage
data={chartData}
unit={unit}
isLoading={isLoading}
isAllTime={isAllTime}
renderXLabel={renderDateLabels(unit, locale)}
/>
);

View file

@ -25,6 +25,7 @@ export function RegionsTable(props: MetricsTableProps) {
metric={formatMessage(labels.visitors)}
dataFilter={emptyFilter}
renderLabel={renderLink}
searchFormattedValues={true}
/>
);
}

View file

@ -229,7 +229,7 @@
"label.views-per-visit": "訪問あたりの閲覧数",
"label.visit-duration": "平均滞在時間",
"label.visitors": "訪問者",
"label.visits": "訪問数",
"label.visits": "訪問数",
"label.website": "Webサイト",
"label.website-id": "WebサイトID",
"label.websites": "Webサイト",

View file

@ -1,51 +1,51 @@
{
"label.access-code": "Access code",
"label.access-code": "Tilgangskode",
"label.actions": "Handlinger",
"label.activity": "Activity log",
"label.add": "Add",
"label.add-description": "Add description",
"label.add-member": "Add member",
"label.add-step": "Add step",
"label.activity": "Aktivitetslogg",
"label.add": "Legg til",
"label.add-description": "Legg til beskrivelse",
"label.add-member": "Legg til bruker",
"label.add-step": "Legg til steg",
"label.add-website": "Legg til nettsted",
"label.admin": "Administrator",
"label.after": "After",
"label.after": "Etter",
"label.all": "Alle",
"label.all-time": "Noensinne",
"label.analytics": "Analytics",
"label.average": "Average",
"label.average": "Gjennomsnnitt",
"label.back": "Tilbake",
"label.before": "Before",
"label.before": "Før",
"label.bounce-rate": "Avvisningsfrekvens",
"label.breakdown": "Breakdown",
"label.browser": "Browser",
"label.breakdown": "Nedbrytning",
"label.browser": "Nettleser",
"label.browsers": "Nettlesere",
"label.cancel": "Avvis",
"label.change-password": "Bytt passord",
"label.cities": "Cities",
"label.city": "City",
"label.clear-all": "Clear all",
"label.compare": "Compare",
"label.confirm": "Confirm",
"label.cities": "Byer",
"label.city": "By",
"label.clear-all": "Tøm alle",
"label.compare": "Sammenlign",
"label.confirm": "Bekreft",
"label.confirm-password": "Godkjenn passord",
"label.contains": "Contains",
"label.continue": "Continue",
"label.count": "Count",
"label.contains": "Inneholder",
"label.continue": "Fortsett",
"label.count": "Antall",
"label.countries": "Land",
"label.country": "Country",
"label.create": "Create",
"label.create-report": "Create report",
"label.create-team": "Create team",
"label.create-user": "Create user",
"label.created": "Created",
"label.created-by": "Created By",
"label.current": "Current",
"label.country": "Land",
"label.create": "Opprett",
"label.create-report": "Opprett rapport",
"label.create-team": "Opprett team",
"label.create-user": "Opprett bruker",
"label.created": "Opprettet",
"label.created-by": "Opprettet av",
"label.current": "Nåværende",
"label.current-password": "Nåværende passord",
"label.custom-range": "Egendefinert utvalg",
"label.dashboard": "Dashbord",
"label.data": "Data",
"label.date": "Date",
"label.date": "Dato",
"label.date-range": "Datointervall",
"label.day": "Day",
"label.day": "Dag",
"label.default-date-range": "Standard datoperiode",
"label.delete": "Slett",
"label.delete-report": "Delete report",
@ -54,226 +54,227 @@
"label.delete-website": "Slett nettstedet",
"label.description": "Description",
"label.desktop": "Stasjonær",
"label.details": "Details",
"label.device": "Device",
"label.details": "Detaljer",
"label.device": "Enhet",
"label.devices": "Enheter",
"label.dismiss": "Avbryt",
"label.does-not-contain": "Does not contain",
"label.does-not-contain": "Innholder ikke",
"label.domain": "Domene",
"label.dropoff": "Dropoff",
"label.edit": "Rediger",
"label.edit-dashboard": "Edit dashboard",
"label.edit-member": "Edit member",
"label.edit-dashboard": "Rediger dashboard",
"label.edit-member": "Rediger bruker",
"label.enable-share-url": "Aktiver delings-URL",
"label.end-step": "End Step",
"label.entry": "Entry URL",
"label.event": "Event",
"label.event-data": "Event data",
"label.events": "Arrangementer",
"label.exit": "Exit URL",
"label.false": "False",
"label.field": "Field",
"label.fields": "Fields",
"label.end-step": "Avslutt steg",
"label.entry": "Inngangs-URL",
"label.event": "Hendelse",
"label.event-data": "Hendelsesdata",
"label.events": "Hendelser",
"label.exit": "Utgangs-URL",
"label.false": "Usant",
"label.field": "Felt",
"label.fields": "Felt",
"label.filter": "Filter",
"label.filter-combined": "Kombinert",
"label.filter-raw": "Rå",
"label.filters": "Filters",
"label.first-seen": "First seen",
"label.funnel": "Funnel",
"label.funnel-description": "Understand the conversion and drop-off rate of users.",
"label.goal": "Goal",
"label.goals": "Goals",
"label.goals-description": "Track your goals for pageviews and events.",
"label.greater-than": "Greater than",
"label.greater-than-equals": "Greater than or equals",
"label.host": "Host",
"label.hosts": "Hosts",
"label.insights": "Insights",
"label.insights-description": "Dive deeper into your data by using segments and filters.",
"label.is": "Is",
"label.is-not": "Is not",
"label.is-not-set": "Is not set",
"label.is-set": "Is set",
"label.join": "Join",
"label.join-team": "Join team",
"label.journey": "Journey",
"label.journey-description": "Understand how users navigate through your website.",
"label.filters": "Filter",
"label.first-seen": "Først sett",
"label.funnel": "Trakt",
"label.funnel-description": "Forstå konverteringen og drop-off frafallsfrekvens av brukere.",
"label.goal": "l",
"label.goals": "Mål",
"label.goals-description": "Spor dine mål for sidevisninger og hendelser.",
"label.greater-than": "Mer enn",
"label.greater-than-equals": "Mer enn eller lik",
"label.host": "Vert",
"label.hosts": "Verter",
"label.insights": "Innsikt",
"label.insights-description": "Dykk dypere i din data ved bruk av segmentering og filtre.",
"label.is": "Er",
"label.is-not": "Er ikke",
"label.is-not-set": "Er ikke satt",
"label.is-set": "Er satt",
"label.join": "Bli med",
"label.join-team": "Bli med i teamet",
"label.journey": "Reise",
"label.journey-description": "Forstå hvordan brukerene navigerer gjennom din side.",
"label.language": "Språk",
"label.languages": "Språk",
"label.laptop": "Bærbar",
"label.last-days": "Siste {x} dager",
"label.last-hours": "Siste {x} timer",
"label.last-months": "Last {x} months",
"label.last-seen": "Last seen",
"label.leave": "Leave",
"label.leave-team": "Leave team",
"label.less-than": "Less than",
"label.less-than-equals": "Less than or equals",
"label.last-seen": "Sist sett",
"label.leave": "Forlat",
"label.leave-team": "Forlat team",
"label.less-than": "Mindre enn",
"label.less-than-equals": "Mindre enn eller lik",
"label.login": "Logg inn",
"label.logout": "Logg ut",
"label.manage": "Manage",
"label.manager": "Manager",
"label.max": "Max",
"label.member": "Member",
"label.members": "Members",
"label.manage": "Administrer",
"label.manager": "Administrator",
"label.max": "Maks",
"label.member": "Bruker",
"label.members": "Brukere",
"label.min": "Min",
"label.mobile": "Mobiltelefon",
"label.more": "Mer",
"label.my-account": "My account",
"label.my-websites": "My websites",
"label.my-account": "Min konto",
"label.my-websites": "Mine nettsider",
"label.name": "Navn",
"label.new-password": "Nytt passord",
"label.none": "None",
"label.none": "Ingen",
"label.number-of-records": "{x} {x, plural, one {record} other {records}}",
"label.ok": "OK",
"label.os": "OS",
"label.overview": "Overview",
"label.overview": "Oversikt",
"label.owner": "Eier",
"label.page-of": "Page {current} of {total}",
"label.page-of": "Side {current} av {total}",
"label.page-views": "Sidevisninger",
"label.pageTitle": "Page title",
"label.pageTitle": "Sidetittel",
"label.pages": "Sider",
"label.password": "Passord",
"label.path": "Path",
"label.paths": "Paths",
"label.path": "Sti",
"label.paths": "Stier",
"label.powered-by": "Drevet av {name}",
"label.previous": "Previous",
"label.previous-period": "Previous period",
"label.previous-year": "Previous year",
"label.previous": "Forrige",
"label.previous-period": "Forrige periode",
"label.previous-year": "Forrige år",
"label.profile": "Profil",
"label.properties": "Properties",
"label.property": "Property",
"label.queries": "Queries",
"label.query": "Query",
"label.query-parameters": "Query parameters",
"label.properties": "Egenskaper",
"label.property": "Egenskap",
"label.queries": "Forspørsler",
"label.query": "Forespørsel",
"label.query-parameters": "Forespørsel parametere",
"label.realtime": "Sanntid",
"label.referrer": "Referrer",
"label.referrers": "Referanser",
"label.referrer": "Henviser",
"label.referrers": "Henvisere",
"label.refresh": "Oppdater",
"label.regenerate": "Regenerate",
"label.regenerate": "Regenerer",
"label.region": "Region",
"label.regions": "Regions",
"label.remove": "Remove",
"label.remove-member": "Remove member",
"label.reports": "Reports",
"label.regions": "Regioner",
"label.remove": "Fjern",
"label.remove-member": "Fjern bruker",
"label.reports": "Rapporter",
"label.required": "Påkrevd",
"label.reset": "Nullstill",
"label.reset-website": "Nullstill statistikk",
"label.retention": "Retention",
"label.retention-description": "Measure your website stickiness by tracking how often users return.",
"label.revenue": "Revenue",
"label.revenue-description": "Look into your revenue across time.",
"label.revenue-property": "Revenue Property",
"label.role": "Role",
"label.run-query": "Run query",
"label.retention": "Retensjon",
"label.retention-description": "Mål nettstedets klebrighet ved å spore hvor ofte brukere kommer tilbake.",
"label.revenue": "Inntenker",
"label.revenue-description": "Se på inntektene dine over tid.",
"label.revenue-property": "Inntektegenskaper",
"label.role": "Rolle",
"label.run-query": "Kjør spørring",
"label.save": "Lagre",
"label.screens": "Screens",
"label.search": "Search",
"label.select": "Select",
"label.select-date": "Select date",
"label.select-role": "Select role",
"label.select-website": "Select website",
"label.session": "Session",
"label.sessions": "Sessions",
"label.screens": "Skjermer",
"label.search": "Søk",
"label.select": "Velg",
"label.select-date": "Velg dato",
"label.select-role": "Velg rolle",
"label.select-website": "Velg nettsted",
"label.session": "Økt",
"label.sessions": "Økter",
"label.settings": "Innstillinger",
"label.share-url": "Del URL",
"label.single-day": "Enkelt dag",
"label.start-step": "Start Step",
"label.steps": "Steps",
"label.single-day": "Enkeltdag",
"label.start-step": "Starttrinn",
"label.steps": "Trinn",
"label.sum": "Sum",
"label.tablet": "Nettbrett",
"label.team": "Team",
"label.team-id": "Team ID",
"label.team-manager": "Team manager",
"label.team-member": "Team member",
"label.team-name": "Team name",
"label.team-owner": "Team owner",
"label.team-view-only": "Team view only",
"label.team-websites": "Team websites",
"label.teams": "Teams",
"label.theme": "Theme",
"label.team-id": "Team-ID",
"label.team-manager": "Teamadministrator",
"label.team-member": "Teammedlem",
"label.team-name": "Teamnavn",
"label.team-owner": "Teameier",
"label.team-view-only": "Team (kun visning)",
"label.team-websites": "Team-nettsteder",
"label.teams": "Team",
"label.theme": "Tema",
"label.this-month": "Denne måneden",
"label.this-week": "Denne uka",
"label.this-year": "I år",
"label.timezone": "Tidssone",
"label.title": "Title",
"label.title": "Tittel",
"label.today": "I dag",
"label.toggle-charts": "Veksle grafer",
"label.total": "Total",
"label.total-records": "Total records",
"label.total": "Totalt",
"label.total-records": "Totalt antall oppføringer",
"label.tracking-code": "Sporingskode",
"label.transactions": "Transactions",
"label.transfer": "Transfer",
"label.transfer-website": "Transfer website",
"label.true": "True",
"label.transactions": "Transaksjoner",
"label.transfer": "Overfør",
"label.transfer-website": "Overfør nettsted",
"label.true": "Sant",
"label.type": "Type",
"label.unique": "Unique",
"label.unique": "Unike",
"label.unique-visitors": "Unike besøkende",
"label.uniqueCustomers": "Unique Customers",
"label.uniqueCustomers": "Unike kunder",
"label.unknown": "Ukjent",
"label.untitled": "Untitled",
"label.update": "Update",
"label.untitled": "Uten tittel",
"label.update": "Oppdater",
"label.url": "URL",
"label.urls": "URLs",
"label.user": "User",
"label.user-property": "User Property",
"label.urls": "URL-er",
"label.user": "Bruker",
"label.user-property": "Brukeregenskap",
"label.username": "Brukernavn",
"label.users": "Users",
"label.users": "Brukere",
"label.utm": "UTM",
"label.utm-description": "Track your campaigns through UTM parameters.",
"label.value": "Value",
"label.view": "View",
"label.utm-description": "Spor kampanjene dine via UTM-parametre.",
"label.value": "Verdi",
"label.view": "Vis",
"label.view-details": "Vis detaljer",
"label.view-only": "View only",
"label.view-only": "Kun visning",
"label.views": "Visninger",
"label.views-per-visit": "Views per visit",
"label.views-per-visit": "Visninger per besøk",
"label.visit-duration": "Gjennomsnittlig besøkstid",
"label.visitors": "Besøkende",
"label.visits": "Visits",
"label.website": "Website",
"label.website-id": "Website ID",
"label.visits": "Besøk",
"label.website": "Nettsted",
"label.website-id": "Nettsted-ID",
"label.websites": "Nettsteder",
"label.window": "Window",
"label.yesterday": "Yesterday",
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
"label.window": "Vindu",
"label.yesterday": "I går",
"message.action-confirmation": "Skriv {confirmation} i feltet nedenfor for å bekrefte.",
"message.active-users": "{x} {x, plural, one {besøkende} other {besøkende}} nå",
"message.collected-data": "Collected data",
"message.collected-data": "Innsamlede data",
"message.confirm-delete": "Er du sikker på at du vil slette {target}?",
"message.confirm-leave": "Are you sure you want to leave {target}?",
"message.confirm-remove": "Are you sure you want to remove {target}?",
"message.confirm-reset": "Er du sikker på at du vil nullstille {target}'s statistikk?",
"message.delete-team-warning": "Deleting a team will also delete all team websites.",
"message.delete-website-warning": "Alle tilknyttede data slettes også.",
"message.confirm-leave": "Er du sikker på at du vil forlate {target}?",
"message.confirm-remove": "Er du sikker på at du vil fjerne {target}?",
"message.confirm-reset": "Er du sikker på at du vil nullstille statistikken til {target}?",
"message.delete-team-warning": "Å slette et team vil også slette alle teamets nettsteder.",
"message.delete-website-warning": "Alle tilknyttede data vil også bli slettet.",
"message.error": "Noe gikk galt.",
"message.event-log": "{event} on {url}",
"message.event-log": "{event} {url}",
"message.go-to-settings": "Gå til innstillinger",
"message.incorrect-username-password": "Ugyldig brukernavn/passord.",
"message.invalid-domain": "Ugyldig domene",
"message.min-password-length": "Minimum length of {n} characters",
"message.new-version-available": "A new version of Umami {version} is available!",
"message.min-password-length": "Minimumslengde på {n} tegn",
"message.new-version-available": "En ny versjon av Umami {version} er tilgjengelig!",
"message.no-data-available": "Ingen data tilgjengelig.",
"message.no-event-data": "No event data is available.",
"message.no-event-data": "Ingen hendelsesdata er tilgjengelig.",
"message.no-match-password": "Passordene er ikke like",
"message.no-results-found": "No results were found.",
"message.no-team-websites": "This team does not have any websites.",
"message.no-teams": "You have not created any teams.",
"message.no-users": "There are no users.",
"message.no-results-found": "Ingen resultater funnet.",
"message.no-team-websites": "Dette teamet har ingen nettsteder.",
"message.no-teams": "Du har ikke opprettet noen team.",
"message.no-users": "Ingen brukere.",
"message.no-websites-configured": "Du har ikke satt opp noen nettsteder.",
"message.page-not-found": "Side ikke funnet.",
"message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
"message.reset-website-warning": "All statistikk for denne nettsiden vil bli slettet, men sporingskoden din vil forbli uberørt.",
"message.page-not-found": "Siden ble ikke funnet.",
"message.reset-website": "For å nullstille dette nettstedet, skriv {confirmation} i feltet nedenfor for å bekrefte.",
"message.reset-website-warning": "All statistikk for dette nettstedet vil bli slettet, men sporingskoden forblir uberørt.",
"message.saved": "Lagret!",
"message.share-url": "Dette er den offentlige delings-URL-en for {target}.",
"message.team-already-member": "You are already a member of the team.",
"message.team-not-found": "Team not found.",
"message.team-websites-info": "Websites can be viewed by anyone on the team.",
"message.team-already-member": "Du er allerede medlem av teamet.",
"message.team-not-found": "Teamet ble ikke funnet.",
"message.team-websites-info": "Nettsteder kan vises av alle på teamet.",
"message.tracking-code": "Sporingskode",
"message.transfer-team-website-to-user": "Transfer this website to your account?",
"message.transfer-user-website-to-team": "Select the team to transfer this website to.",
"message.transfer-website": "Transfer website ownership to your account or another team.",
"message.triggered-event": "Triggered event",
"message.user-deleted": "User deleted.",
"message.viewed-page": "Viewed page",
"message.transfer-team-website-to-user": "Overfør dette nettstedet til kontoen din?",
"message.transfer-user-website-to-team": "Velg teamet du vil overføre dette nettstedet til.",
"message.transfer-website": "Overfør eierskapet til nettstedet til din konto eller et annet team.",
"message.triggered-event": "Utløst hendelse",
"message.user-deleted": "Bruker slettet.",
"message.viewed-page": "Vist side",
"message.visitor-log": "Besøkende fra {country} med {browser} på {os} {device}",
"message.visitors-dropped-off": "Visitors dropped off"
"message.visitors-dropped-off": "Besøkende falt fra"
}

View file

@ -78,7 +78,7 @@
"label.filter-combined": "合并",
"label.filter-raw": "原始",
"label.filters": "筛选",
"label.first-seen": "First seen",
"label.first-seen": "首次出现",
"label.funnel": "分析",
"label.funnel-description": "了解用户的转换率和退出率。",
"label.goal": "目标",
@ -104,7 +104,7 @@
"label.last-days": "最近 {x} 天",
"label.last-hours": "最近 {x} 小时",
"label.last-months": "最近 {x} 个月",
"label.last-seen": "Last seen",
"label.last-seen": "最后出现",
"label.leave": "离开",
"label.leave-team": "离开团队",
"label.less-than": "少于",
@ -161,9 +161,9 @@
"label.reset-website": "重置统计数据",
"label.retention": "保留",
"label.retention-description": "通过跟踪用户返回的频率来衡量网站的用户粘性。",
"label.revenue": "Revenue",
"label.revenue-description": "Look into your revenue across time.",
"label.revenue-property": "Revenue Property",
"label.revenue": "收入",
"label.revenue-description": "查看您的收入随时间的变化。",
"label.revenue-property": "收入值",
"label.role": "角色",
"label.run-query": "查询",
"label.save": "保存",
@ -202,21 +202,21 @@
"label.total": "总数",
"label.total-records": "总记录数",
"label.tracking-code": "跟踪代码",
"label.transactions": "Transactions",
"label.transactions": "交易",
"label.transfer": "转移",
"label.transfer-website": "转移网站",
"label.true": "是",
"label.type": "类型",
"label.unique": "独立",
"label.unique-visitors": "独立访客",
"label.uniqueCustomers": "Unique Customers",
"label.uniqueCustomers": "独特客户",
"label.unknown": "未知",
"label.untitled": "未命名",
"label.update": "更新",
"label.url": "网址",
"label.urls": "网址",
"label.user": "用户",
"label.user-property": "User Property",
"label.user-property": "用户属性",
"label.username": "用户名",
"label.users": "用户",
"label.utm": "UTM",

View file

@ -1,11 +1,11 @@
{
"label.access-code": "存取碼",
"label.actions": "行",
"label.activity": "活動日誌",
"label.actions": "行",
"label.activity": "活動紀錄",
"label.add": "新增",
"label.add-description": "新增描述",
"label.add-member": "Add member",
"label.add-step": "Add step",
"label.add-member": "新增成員",
"label.add-step": "新增步驟",
"label.add-website": "新增網站",
"label.admin": "管理員",
"label.after": "之後",
@ -16,7 +16,7 @@
"label.back": "返回",
"label.before": "之前",
"label.bounce-rate": "跳出率",
"label.breakdown": "分解",
"label.breakdown": "細項分析",
"label.browser": "瀏覽器",
"label.browsers": "瀏覽器",
"label.cancel": "取消",
@ -24,21 +24,21 @@
"label.cities": "城市",
"label.city": "城市",
"label.clear-all": "全部清除",
"label.compare": "Compare",
"label.compare": "比較",
"label.confirm": "確認",
"label.confirm-password": "確認密碼",
"label.contains": "包含",
"label.continue": "繼續",
"label.count": "Count",
"label.count": "數量",
"label.countries": "國家",
"label.country": "國家",
"label.create": "建立",
"label.create-report": "建立報",
"label.create-report": "建立報",
"label.create-team": "建立團隊",
"label.create-user": "建立使用者",
"label.created": "已建立",
"label.created-by": "Created By",
"label.current": "Current",
"label.created-by": "建立者",
"label.current": "目前",
"label.current-password": "目前密碼",
"label.custom-range": "自訂範圍",
"label.dashboard": "儀表板",
@ -48,7 +48,7 @@
"label.day": "日",
"label.default-date-range": "預設日期範圍",
"label.delete": "刪除",
"label.delete-report": "Delete report",
"label.delete-report": "刪除報表",
"label.delete-team": "刪除團隊",
"label.delete-user": "刪除使用者",
"label.delete-website": "刪除網站",
@ -60,89 +60,89 @@
"label.dismiss": "關閉",
"label.does-not-contain": "不包含",
"label.domain": "網域",
"label.dropoff": "退出",
"label.dropoff": "離開",
"label.edit": "編輯",
"label.edit-dashboard": "編輯儀表板",
"label.edit-member": "Edit member",
"label.enable-share-url": "啟用分享網址",
"label.end-step": "End Step",
"label.entry": "Entry URL",
"label.edit-member": "編輯成員",
"label.enable-share-url": "啟用分享連結",
"label.end-step": "結束步驟",
"label.entry": "進入網址",
"label.event": "事件",
"label.event-data": "事件資料",
"label.events": "事件",
"label.exit": "Exit URL",
"label.exit": "離開網址",
"label.false": "否",
"label.field": "欄位",
"label.fields": "欄位",
"label.filter": "篩選器",
"label.filter-combined": "組合",
"label.filter-raw": "原始",
"label.filters": "篩選",
"label.first-seen": "First seen",
"label.funnel": "漏斗",
"label.funnel-description": "瞭解使用者的轉換率和退出率",
"label.goal": "Goal",
"label.goals": "Goals",
"label.goals-description": "Track your goals for pageviews and events.",
"label.filters": "篩選條件",
"label.first-seen": "首次造訪",
"label.funnel": "漏斗分析",
"label.funnel-description": "瞭解使用者的轉換率與流失率。",
"label.goal": "目標",
"label.goals": "目標",
"label.goals-description": "追蹤網頁瀏覽和事件的目標。",
"label.greater-than": "大於",
"label.greater-than-equals": "大於或等於",
"label.host": "Host",
"label.hosts": "Hosts",
"label.host": "主機名稱",
"label.hosts": "主機名稱",
"label.insights": "洞察",
"label.insights-description": "透過使用區段和篩選器來深入探索你的數據",
"label.insights-description": "使用區段和篩選器來深入分析您的資料。",
"label.is": "是",
"label.is-not": "不是",
"label.is-not-set": "未設定",
"label.is-set": "已設定",
"label.join": "加入",
"label.join-team": "加入團隊",
"label.journey": "Journey",
"label.journey-description": "Understand how users navigate through your website.",
"label.journey": "使用者旅程",
"label.journey-description": "瞭解使用者如何瀏覽您的網站。",
"label.language": "語言",
"label.languages": "語言",
"label.laptop": "筆記型電腦",
"label.last-days": "最近 {x} 天",
"label.last-hours": "最近 {x} 小時",
"label.last-months": "Last {x} months",
"label.last-seen": "Last seen",
"label.last-months": "最近 {x} 個月",
"label.last-seen": "最後造訪",
"label.leave": "離開",
"label.leave-team": "離開團隊",
"label.less-than": "小於",
"label.less-than-equals": "小於或等於",
"label.login": "登入",
"label.logout": "登出",
"label.manage": "Manage",
"label.manager": "Manager",
"label.max": "最大",
"label.member": "Member",
"label.manage": "管理",
"label.manager": "管理者",
"label.max": "最大",
"label.member": "成員",
"label.members": "成員",
"label.min": "最小",
"label.min": "最小",
"label.mobile": "行動裝置",
"label.more": "更多",
"label.my-account": "My account",
"label.my-account": "我的帳號",
"label.my-websites": "我的網站",
"label.name": "名稱",
"label.new-password": "新密碼",
"label.none": "無",
"label.number-of-records": "{x} {x, plural, one {record} other {records}}",
"label.number-of-records": "{x} 筆紀錄",
"label.ok": "OK",
"label.os": "作業系統",
"label.overview": "覽",
"label.overview": "覽",
"label.owner": "擁有者",
"label.page-of": "頁面 {current} / {total}",
"label.page-views": "頁面瀏覽",
"label.pageTitle": "標題",
"label.pages": "",
"label.page-of": "第 {current} 頁,共 {total} 頁",
"label.page-views": "網頁瀏覽次數",
"label.pageTitle": "頁標題",
"label.pages": "頁",
"label.password": "密碼",
"label.path": "Path",
"label.paths": "Paths",
"label.powered-by": "由 {name} 提供",
"label.previous": "Previous",
"label.previous-period": "Previous period",
"label.previous-year": "Previous year",
"label.profile": "個人資料",
"label.properties": "Properties",
"label.property": "Property",
"label.path": "路徑",
"label.paths": "路徑",
"label.powered-by": "由 {name} 提供技術支援",
"label.previous": "上一個",
"label.previous-period": "上一期間",
"label.previous-year": "去年",
"label.profile": "個人檔案",
"label.properties": "屬性",
"label.property": "屬性",
"label.queries": "查詢",
"label.query": "查詢",
"label.query-parameters": "查詢參數",
@ -151,44 +151,44 @@
"label.referrers": "參照來源",
"label.refresh": "重新整理",
"label.regenerate": "重新產生",
"label.region": "",
"label.regions": "",
"label.region": "區",
"label.regions": "區",
"label.remove": "移除",
"label.remove-member": "Remove member",
"label.reports": "報",
"label.remove-member": "移除成員",
"label.reports": "報",
"label.required": "必填",
"label.reset": "重設",
"label.reset-website": "重設網站",
"label.retention": "留",
"label.reset-website": "重設網站統計資料",
"label.retention": "存率",
"label.retention-description": "透過追蹤使用者回訪的頻率來衡量您的網站黏著度。",
"label.revenue": "Revenue",
"label.revenue-description": "Look into your revenue across time.",
"label.revenue-property": "Revenue Property",
"label.revenue": "營收",
"label.revenue-description": "查看您的營收趨勢。",
"label.revenue-property": "營收屬性",
"label.role": "角色",
"label.run-query": "執行查詢",
"label.save": "儲存",
"label.screens": "螢幕",
"label.search": "Search",
"label.select": "Select",
"label.select-date": "選日期",
"label.select-role": "Select role",
"label.select-website": "選網站",
"label.session": "Session",
"label.search": "搜尋",
"label.select": "選取",
"label.select-date": "選日期",
"label.select-role": "選取角色",
"label.select-website": "選網站",
"label.session": "工作階段",
"label.sessions": "工作階段",
"label.settings": "設定",
"label.share-url": "分享網址",
"label.share-url": "分享連結",
"label.single-day": "單日",
"label.start-step": "Start Step",
"label.steps": "Steps",
"label.start-step": "起始步驟",
"label.steps": "步驟",
"label.sum": "總和",
"label.tablet": "平板",
"label.team": "團隊",
"label.team-id": "團隊 ID",
"label.team-manager": "Team manager",
"label.team-manager": "團隊管理者",
"label.team-member": "團隊成員",
"label.team-name": "團隊名稱",
"label.team-owner": "團隊擁有者",
"label.team-view-only": "Team view only",
"label.team-view-only": "團隊僅供檢視",
"label.team-websites": "團隊網站",
"label.teams": "團隊",
"label.theme": "主題",
@ -200,80 +200,80 @@
"label.today": "今天",
"label.toggle-charts": "切換圖表",
"label.total": "總計",
"label.total-records": "總記錄",
"label.total-records": "紀錄總數",
"label.tracking-code": "追蹤代碼",
"label.transactions": "Transactions",
"label.transfer": "Transfer",
"label.transfer-website": "Transfer website",
"label.transactions": "交易",
"label.transfer": "轉移",
"label.transfer-website": "轉移網站",
"label.true": "是",
"label.type": "類型",
"label.unique": "獨立",
"label.unique-visitors": "獨立訪客",
"label.uniqueCustomers": "Unique Customers",
"label.unique": "不重複",
"label.unique-visitors": "不重複訪客",
"label.uniqueCustomers": "不重複客戶",
"label.unknown": "未知",
"label.untitled": "無標題",
"label.update": "Update",
"label.untitled": "未命名",
"label.update": "更新",
"label.url": "網址",
"label.urls": "網址",
"label.user": "使用者",
"label.user-property": "User Property",
"label.user-property": "使用者屬性",
"label.username": "使用者名稱",
"label.users": "使用者",
"label.utm": "UTM",
"label.utm-description": "Track your campaigns through UTM parameters.",
"label.utm-description": "透過 UTM 參數追蹤您的行銷活動。",
"label.value": "值",
"label.view": "檢視",
"label.view-details": "檢視詳細資訊",
"label.view-only": "僅供檢視",
"label.views": "檢視",
"label.views-per-visit": "Views per visit",
"label.visit-duration": "平均造訪時間",
"label.views": "瀏覽次數",
"label.views-per-visit": "每次造訪的瀏覽次數",
"label.visit-duration": "造訪時間",
"label.visitors": "訪客",
"label.visits": "Visits",
"label.visits": "造訪次數",
"label.website": "網站",
"label.website-id": "網站 ID",
"label.websites": "網站",
"label.window": "視窗",
"label.yesterday": "昨天",
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
"message.active-users": "目前有 {x} 個活躍的訪客",
"message.collected-data": "Collected data",
"message.action-confirmation": "請在下方欄位輸入 {confirmation} 以確認。",
"message.active-users": "目前有 {x} 訪客",
"message.collected-data": "已蒐集的資料",
"message.confirm-delete": "您確定要刪除 {target} 嗎?",
"message.confirm-leave": "您確定要離開 {target} 嗎?",
"message.confirm-remove": "Are you sure you want to remove {target}?",
"message.confirm-reset": "您確定要重設 {target} 嗎?",
"message.delete-team-warning": "Deleting a team will also delete all team websites.",
"message.delete-website-warning": "所有網站資料將被刪除。",
"message.confirm-remove": "您確定要移除 {target} 嗎?",
"message.confirm-reset": "您確定要重設 {target} 的統計資料嗎?",
"message.delete-team-warning": "刪除團隊的同時也會刪除所有團隊的網站。",
"message.delete-website-warning": "所有網站資料將被刪除。",
"message.error": "發生錯誤。",
"message.event-log": "{event} 在 {url}",
"message.event-log": "在 {url} 上的 {event}",
"message.go-to-settings": "前往設定",
"message.incorrect-username-password": "使用者名稱和/或密碼不正確。",
"message.invalid-domain": "無效的網域。請不要包含 http/https。",
"message.min-password-length": "最少需要 {n} 個字元",
"message.new-version-available": "Umami {version} 的新版本已經可以使用",
"message.incorrect-username-password": "使用者名稱或密碼不正確。",
"message.invalid-domain": "無效的網域。請包含 http/https。",
"message.min-password-length": "密碼長度至少需 {n} 個字元",
"message.new-version-available": "Umami {version} 的新版本已推出",
"message.no-data-available": "沒有可用的資料。",
"message.no-event-data": "沒有可用的事件資料。",
"message.no-match-password": "密碼不一致。",
"message.no-results-found": "找不到結果。",
"message.no-team-websites": "此團隊沒有任何網站。",
"message.no-teams": "您尚未建立任何團隊。",
"message.no-users": "沒有使用者。",
"message.no-users": "沒有任何使用者。",
"message.no-websites-configured": "您尚未設定任何網站。",
"message.page-not-found": "找不到",
"message.reset-website": "要重設此網站,請在下方的方框中輸入 {confirmation} 以確認。",
"message.reset-website-warning": "此網站的所有統計將被刪除,但您的設定將保持不變。",
"message.page-not-found": "找不到頁",
"message.reset-website": "要重設此網站的統計資料,請在下方欄位輸入 {confirmation} 以確認。",
"message.reset-website-warning": "此網站的所有統計資料都將被刪除,但您的設定將保持不變。",
"message.saved": "已儲存。",
"message.share-url": "您的網站統計資料可在以下網址公開檢視:",
"message.team-already-member": "您已是團隊的成員。",
"message.share-url": "您的網站統計資料可在以下網址公開檢視:",
"message.team-already-member": "您已團隊的成員。",
"message.team-not-found": "找不到團隊。",
"message.team-websites-info": "團隊的任何成員都可以檢視網站。",
"message.tracking-code": "要追蹤此網站的統計,請將以下代碼放在您的 HTML 的 <head>...</head> 區段中。",
"message.transfer-team-website-to-user": "Transfer this website to your account?",
"message.transfer-user-website-to-team": "Select the team to transfer this website to.",
"message.transfer-website": "Transfer website ownership to your account or another team.",
"message.triggered-event": "Triggered event",
"message.team-websites-info": "團隊中的所有成員都可以檢視網站。",
"message.tracking-code": "要追蹤此網站的統計資料,請將以下程式碼放在您 HTML 的 <head>...</head> 區段中。",
"message.transfer-team-website-to-user": "要將此網站轉移至您的帳號嗎?",
"message.transfer-user-website-to-team": "請選擇要轉移此網站的團隊。",
"message.transfer-website": "將網站所有權轉移至您的帳號或其他團隊。",
"message.triggered-event": "已觸發的事件",
"message.user-deleted": "使用者已刪除。",
"message.viewed-page": "Viewed page",
"message.viewed-page": "已瀏覽的網頁",
"message.visitor-log": "來自 {country} 的訪客在 {device} 上的 {os} 使用 {browser} 瀏覽。",
"message.visitors-dropped-off": "Visitors dropped off"
"message.visitors-dropped-off": "訪客已離開"
}

View file

@ -1,7 +1,7 @@
import { Report } from '@prisma/client';
import redis from '@umami/redis-client';
import { getClient } from '@umami/redis-client';
import debug from 'debug';
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER, ROLES } from 'lib/constants';
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants';
import { secret } from 'lib/crypto';
import { NextApiRequest } from 'next';
import { createSecureToken, ensureArray, getRandomChars, parseToken } from 'next-basics';
@ -14,10 +14,12 @@ const cloudMode = process.env.CLOUD_MODE;
export async function saveAuth(data: any, expire = 0) {
const authKey = `auth:${getRandomChars(32)}`;
await redis.client.set(authKey, data);
const redis = getClient();
await redis.set(authKey, data);
if (expire) {
await redis.client.expire(authKey, expire);
await redis.expire(authKey, expire);
}
return createSecureToken({ authKey }, secret());

View file

@ -68,6 +68,10 @@ function getDateSQL(field: string, unit: string, timezone?: string) {
return `toDateTime(date_trunc('${unit}', ${field}))`;
}
function getSearchSQL(column: string, param: string = 'search'): string {
return `and positionCaseInsensitive(${column}, {${param}:String}) > 0`;
}
function mapFilter(column: string, operator: string, name: string, type: string = 'String') {
const value = `{${name}:${type}}`;
@ -229,6 +233,7 @@ export default {
connect,
getDateStringSQL,
getDateSQL,
getSearchSQL,
getFilterQuery,
getUTCString,
parseFilters,

View file

@ -1,4 +1,3 @@
import moment from 'moment-timezone';
import {
addMinutes,
addHours,
@ -105,8 +104,17 @@ const DATE_FUNCTIONS = {
},
};
export function isValidTimezone(timezone: string) {
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
return true;
} catch (error) {
return false;
}
}
export function getTimezone() {
return moment.tz.guess();
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
export function parseDateValue(value: string) {

View file

@ -67,6 +67,14 @@ function getRegionCode(country: string, region: string) {
return region.includes('-') ? region : `${country}-${region}`;
}
function safeDecodeCfHeader(s: string | undefined | null): string | undefined | null {
if (s === undefined || s === null) {
return s;
}
return Buffer.from(s, 'latin1').toString('utf-8');
}
export async function getLocation(ip: string, req: NextApiRequestCollect) {
// Ignore local ips
if (await isLocalhost(ip)) {
@ -75,9 +83,9 @@ export async function getLocation(ip: string, req: NextApiRequestCollect) {
// Cloudflare headers
if (req.headers['cf-ipcountry']) {
const country = safeDecodeURIComponent(req.headers['cf-ipcountry']);
const subdivision1 = safeDecodeURIComponent(req.headers['cf-region-code']);
const city = safeDecodeURIComponent(req.headers['cf-ipcity']);
const country = safeDecodeCfHeader(req.headers['cf-ipcountry']);
const subdivision1 = safeDecodeCfHeader(req.headers['cf-region-code']);
const city = safeDecodeCfHeader(req.headers['cf-ipcity']);
return {
country,

View file

@ -1,12 +1,14 @@
import { getWebsiteSession, getWebsite } from 'queries';
import { Website, Session } from '@prisma/client';
import redis from '@umami/redis-client';
import { getClient, redisEnabled } from '@umami/redis-client';
export async function fetchWebsite(websiteId: string): Promise<Website> {
let website = null;
if (redis.enabled) {
website = await redis.client.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400);
if (redisEnabled) {
const redis = getClient();
website = await redis.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400);
} else {
website = await getWebsite(websiteId);
}
@ -21,8 +23,10 @@ export async function fetchWebsite(websiteId: string): Promise<Website> {
export async function fetchSession(websiteId: string, sessionId: string): Promise<Session> {
let session = null;
if (redis.enabled) {
session = await redis.client.fetch(
if (redisEnabled) {
const redis = getClient();
session = await redis.fetch(
`session:${sessionId}`,
() => getWebsiteSession(websiteId, sessionId),
86400,

View file

@ -1,6 +1,6 @@
import cors from 'cors';
import debug from 'debug';
import redis from '@umami/redis-client';
import { getClient, redisEnabled } from '@umami/redis-client';
import { getAuthToken, parseShareToken } from 'lib/auth';
import { ROLES } from 'lib/constants';
import { secret } from 'lib/crypto';
@ -54,8 +54,10 @@ export const useAuth = createMiddleware(async (req, res, next) => {
if (userId) {
user = await getUser(userId);
} else if (redis.enabled && authKey) {
const key = await redis.client.get(authKey);
} else if (redisEnabled && authKey) {
const redis = getClient();
const key = await redis.get(authKey);
if (key?.userId) {
user = await getUser(key.userId);

View file

@ -1,7 +1,7 @@
import debug from 'debug';
import { Prisma } from '@prisma/client';
import prisma from '@umami/prisma-client';
import moment from 'moment-timezone';
import { formatInTimeZone } from 'date-fns-tz';
import { MYSQL, POSTGRESQL, getDatabaseType } from 'lib/db';
import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants';
import { fetchWebsite } from './load';
@ -75,7 +75,7 @@ function getDateSQL(field: string, unit: string, timezone?: string): string {
if (db === MYSQL) {
if (timezone) {
const tz = moment.tz(timezone).format('Z');
const tz = formatInTimeZone(new Date(), timezone, 'yyyy-MM-dd HH:mm:ss');
return `date_format(convert_tz(${field},'+00:00','${tz}'), '${MYSQL_DATE_FORMATS[unit]}')`;
}
return `date_format(${field}, '${MYSQL_DATE_FORMATS[unit]}')`;
@ -90,7 +90,7 @@ function getDateWeeklySQL(field: string, timezone?: string) {
}
if (db === MYSQL) {
const tz = moment.tz(timezone).format('Z');
const tz = formatInTimeZone(new Date(), timezone, 'yyyy-MM-dd HH:mm:ss');
return `date_format(convert_tz(${field},'+00:00','${tz}'), '%w:%H')`;
}
}
@ -119,11 +119,11 @@ function getTimestampDiffSQL(field1: string, field2: string): string {
}
}
function getSearchSQL(column: string): string {
function getSearchSQL(column: string, param: string = 'search'): string {
const db = getDatabaseType();
const like = db === POSTGRESQL ? 'ilike' : 'like';
return `and ${column} ${like} {{search}}`;
return `and ${column} ${like} {{${param}}`;
}
function mapFilter(column: string, operator: string, name: string, type: string = '') {

View file

@ -88,5 +88,5 @@ export async function getSession(req: NextApiRequestCollect): Promise<SessionDat
}
}
return { ...session, visitId: visitId };
return { ...session, visitId };
}

View file

@ -1,5 +1,5 @@
import moment from 'moment-timezone';
import * as yup from 'yup';
import { isValidTimezone } from 'lib/date';
import { UNIT_TYPES } from './constants';
export const TimezoneTest = yup
@ -8,7 +8,7 @@ export const TimezoneTest = yup
.test(
'timezone',
() => `Invalid timezone`,
value => moment.tz.zone(value) !== null,
value => isValidTimezone(value),
);
export const UnitTypeTest = yup.string().test(

View file

@ -1,4 +1,4 @@
import redis from '@umami/redis-client';
import { redisEnabled } from '@umami/redis-client';
import { saveAuth } from 'lib/auth';
import { secret } from 'lib/crypto';
import { useValidate } from 'lib/middleware';
@ -49,7 +49,7 @@ export default async (
const user = await getUserByUsername(username, { includePassword: true });
if (user && checkPassword(password, user.password)) {
if (redis.enabled) {
if (redisEnabled) {
const token = await saveAuth({ userId: user.id });
return ok(res, { token, user });

View file

@ -1,5 +1,5 @@
import { methodNotAllowed, ok } from 'next-basics';
import redis from '@umami/redis-client';
import { getClient, redisEnabled } from '@umami/redis-client';
import { useAuth } from 'lib/middleware';
import { getAuthToken } from 'lib/auth';
import { NextApiRequest, NextApiResponse } from 'next';
@ -8,8 +8,10 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
await useAuth(req, res);
if (req.method === 'POST') {
if (redis.enabled) {
await redis.client.del(getAuthToken(req));
if (redisEnabled) {
const redis = getClient();
await redis.del(getAuthToken(req));
}
return ok(res);

View file

@ -2,13 +2,13 @@ import { NextApiRequestAuth } from 'lib/types';
import { useAuth } from 'lib/middleware';
import { NextApiResponse } from 'next';
import { badRequest, ok } from 'next-basics';
import redis from '@umami/redis-client';
import { redisEnabled } from '@umami/redis-client';
import { saveAuth } from 'lib/auth';
export default async (req: NextApiRequestAuth, res: NextApiResponse) => {
await useAuth(req, res);
if (redis.enabled && req.auth.user) {
if (redisEnabled && req.auth.user) {
const token = await saveAuth({ userId: req.auth.user.id }, 86400);
return ok(res, { user: req.auth.user, token });

View file

@ -31,7 +31,10 @@ const schema = {
.of(
yup.object().shape({
type: yup.string().required(),
value: yup.string().required(),
value: yup
.string()
.matches(/^[a-zA-Z0-9/*-_]+$/, 'Invalid URL pattern')
.required(),
}),
)
.min(2)

View file

@ -102,6 +102,11 @@ export default async (req: NextApiRequestCollect, res: NextApiResponse) => {
await useSession(req, res);
const session = req.session;
if (!session?.id) {
return;
}
const iat = Math.floor(new Date().getTime() / 1000);
// expire visitId after 30 minutes

View file

@ -49,13 +49,7 @@ export default async (req: NextApiRequestQueryBody<ValuesRequestQuery>, res: Nex
return unauthorized(res);
}
const values = await getValues(
websiteId,
FILTER_COLUMNS[type as string],
startDate,
endDate,
search,
);
const values = await getValues(websiteId, FILTER_COLUMNS[type], startDate, endDate, search);
return ok(
res,

View file

@ -19,10 +19,25 @@ async function relationalQuery(
search: string,
) {
const { rawQuery, getSearchSQL } = prisma;
const params = {};
let searchQuery = '';
if (search) {
searchQuery = getSearchSQL(column);
if (decodeURIComponent(search).includes(',')) {
searchQuery = `AND (${decodeURIComponent(search)
.split(',')
.slice(0, 5)
.map((value: string, index: number) => {
const key = `search${index}`;
params[key] = value;
return getSearchSQL(column, key).replace('and ', '');
})
.join(' OR ')})`;
} else {
searchQuery = getSearchSQL(column);
}
}
return rawQuery(
@ -43,6 +58,7 @@ async function relationalQuery(
startDate,
endDate,
search: `%${search}%`,
...params,
},
);
}
@ -54,13 +70,32 @@ async function clickhouseQuery(
endDate: Date,
search: string,
) {
const { rawQuery } = clickhouse;
const { rawQuery, getSearchSQL } = clickhouse;
const params = {};
let searchQuery = '';
if (search) {
searchQuery = `and positionCaseInsensitive(${column}, {search:String}) > 0`;
}
if (search) {
if (decodeURIComponent(search).includes(',')) {
searchQuery = `AND (${decodeURIComponent(search)
.split(',')
.slice(0, 5)
.map((value: string, index: number) => {
const key = `search${index}`;
params[key] = value;
return getSearchSQL(column, key).replace('and ', '');
})
.join(' OR ')})`;
} else {
searchQuery = getSearchSQL(column);
}
}
return rawQuery(
`
select ${column} as value, count(*)
@ -77,6 +112,7 @@ async function clickhouseQuery(
startDate,
endDate,
search,
...params,
},
);
}

View file

@ -38,7 +38,7 @@ async function clickhouseQuery(websiteId: string) {
select
min(created_at) as mindate,
max(created_at) as maxdate
from website_event
from website_event_stats_hourly
where website_id = {websiteId:UUID}
and created_at >= {startDate:DateTime64}
`,

View file

@ -70,9 +70,16 @@ async function relationalQuery(
(pv, cv, i) => {
const levelNumber = i + 1;
const startSum = i > 0 ? 'union ' : '';
const operator = cv.type === 'url' && cv.value.endsWith('*') ? 'like' : '=';
const column = cv.type === 'url' ? 'url_path' : 'event_name';
const paramValue = cv.value.endsWith('*') ? cv.value.replace('*', '%') : cv.value;
const isURL = cv.type === 'url';
const column = isURL ? 'url_path' : 'event_name';
let operator = '=';
let paramValue = cv.value;
if (cv.value.startsWith('*') || cv.value.endsWith('*')) {
operator = 'like';
paramValue = cv.value.replace(/^\*|\*$/g, '%');
}
if (levelNumber === 1) {
pv.levelOneQuery = `
@ -167,9 +174,16 @@ async function clickhouseQuery(
const levelNumber = i + 1;
const startSum = i > 0 ? 'union all ' : '';
const startFilter = i > 0 ? 'or' : '';
const operator = cv.type === 'url' && cv.value.endsWith('*') ? 'like' : '=';
const column = cv.type === 'url' ? 'url_path' : 'event_name';
const paramValue = cv.value.endsWith('*') ? cv.value.replace('*', '%') : cv.value;
const isURL = cv.type === 'url';
const column = isURL ? 'url_path' : 'event_name';
let operator = '=';
let paramValue = cv.value;
if (cv.value.startsWith('*') || cv.value.endsWith('*')) {
operator = 'like';
paramValue = cv.value.replace(/^\*|\*$/g, '%');
}
if (levelNumber === 1) {
pv.levelOneQuery = `\n

View file

@ -1,5 +1,5 @@
import { Prisma, Website } from '@prisma/client';
import redis from '@umami/redis-client';
import { getClient } from '@umami/redis-client';
import prisma from 'lib/prisma';
import { PageResult, PageParams } from 'lib/types';
import WebsiteFindManyArgs = Prisma.WebsiteFindManyArgs;
@ -21,6 +21,7 @@ export async function getSharedWebsite(shareId: string) {
return findWebsite({
where: {
shareId,
deletedAt: null,
},
});
}
@ -181,7 +182,9 @@ export async function resetWebsite(
}),
]).then(async data => {
if (cloudMode) {
await redis.client.set(`website:${websiteId}`, data[3]);
const redis = getClient();
await redis.set(`website:${websiteId}`, data[3]);
}
return data;
@ -224,7 +227,9 @@ export async function deleteWebsite(
}),
]).then(async data => {
if (cloudMode) {
await redis.client.del(`website:${websiteId}`);
const redis = getClient();
await redis.del(`website:${websiteId}`);
}
return data;

View file

@ -54,7 +54,7 @@
const parseURL = url => {
try {
// use location.origin as the base to handle cases where the url is a relative path
const { pathname, search, hash } = new URL(url, origin);
const { pathname, search, hash } = new URL(url, location.href);
url = pathname + search + hash;
} catch (e) {
/* empty */