Merge branch 'mikecao:master' into master

This commit is contained in:
Didier Krux 2022-06-27 11:37:46 +01:00 committed by GitHub
commit d7144ee485
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
73 changed files with 2206 additions and 1086 deletions

36
.github/workflows/cd-manual.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: Create docker images
on:
workflow_dispatch:
inputs:
version:
type: string
description: Version
required: true
add-latest:
type: boolean
description: Add latest tag
required: false
jobs:
build:
name: Build, push, and deploy
runs-on: ubuntu-latest
strategy:
matrix:
db-type: [postgresql, mysql]
steps:
- uses: actions/checkout@v2
- uses: mr-smithers-excellent/docker-build-push@v5
name: Build & push Docker image for ${{ matrix.db-type }}
with:
image: umami
tags: ${{ matrix.db-type }}-${{ inputs.version }}
addLatest: ${{ inputs.add-latest }}
buildArgs: DATABASE_TYPE=${{ matrix.db-type }}
registry: ghcr.io/${{ github.actor }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View file

@ -14,17 +14,17 @@ jobs:
db-type: [postgresql, mysql]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v2
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- uses: mr-smithers-excellent/docker-build-push@v5
name: Build & push Docker image for ${{ matrix.db-type }}
with:
image: umami
tags: ${{ matrix.db-type }}-${{ env.RELEASE_VERSION }}, ${{ matrix.db-type }}-latest
buildArgs: DATABASE_TYPE=${{ matrix.db-type }}
registry: ghcr.io/${{ github.actor }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: mr-smithers-excellent/docker-build-push@v5
name: Build & push Docker image for ${{ matrix.db-type }}
with:
image: umami
tags: ${{ matrix.db-type }}-${{ env.RELEASE_VERSION }}, ${{ matrix.db-type }}-latest
buildArgs: DATABASE_TYPE=${{ matrix.db-type }}
registry: ghcr.io/${{ github.actor }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored
View file

@ -11,7 +11,7 @@
# next.js
/.next/
/out/
/prisma/schema.prisma
/prisma/
# production
/build

View file

@ -1,45 +1,59 @@
# Build image
FROM node:12.22-alpine AS build
ARG BASE_PATH
ARG DATABASE_TYPE
ENV BASE_PATH=$BASE_PATH
ENV DATABASE_URL "postgresql://umami:umami@db:5432/umami"
ENV DATABASE_TYPE=$DATABASE_TYPE
WORKDIR /build
RUN yarn config set --home enableTelemetry 0
COPY package.json yarn.lock /build/
# Install only the production dependencies
RUN yarn install --production --frozen-lockfile
# Cache these modules for production
RUN cp -R node_modules/ prod_node_modules/
# Install development dependencies
# Install dependencies only when needed
FROM node:16-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . /build
RUN yarn next telemetry disable
# Rebuild the source code only when needed
FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG DATABASE_URL
ARG DATABASE_TYPE
ARG BASE_PATH
ARG DISABLE_LOGIN
ENV DATABASE_URL $DATABASE_URL
ENV DATABASE_TYPE $DATABASE_TYPE
ENV BASE_PATH $BASE_PATH
ENV DISABLE_LOGIN $DISABLE_LOGIN
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
# Production image
FROM node:12.22-alpine AS production
# Production image, copy all the files and run next
FROM node:16-alpine AS runner
WORKDIR /app
# Copy cached dependencies
COPY --from=build /build/prod_node_modules ./node_modules
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Copy generated Prisma client
COPY --from=build /build/node_modules/.prisma/ ./node_modules/.prisma/
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=build /build/yarn.lock /build/package.json ./
COPY --from=build /build/.next ./.next
COPY --from=build /build/public ./public
RUN yarn global add prisma
USER node
# You only need to copy next.config.js if you are NOT using the default configuration
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prisma/schema.prisma ./prisma/schema.prisma
COPY --from=builder /app/prisma/migrations ./prisma/migrations
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["yarn", "start"]
ENV PORT 3000
CMD ["yarn", "start-docker"]

View file

@ -10,43 +10,29 @@ A detailed getting started guide can be found at [https://umami.is/docs/](https:
### Requirements
- A server with Node.js 12 or newer
- A database (MySQL or Postgresql)
- A server with Node.js version 12 or newer
- A database. Umami supports [MySQL](https://www.mysql.com/) and [Postgresql](https://www.postgresql.org/) databases.
### Install Yarn
```
npm install -g yarn
```
### Get the source code and install packages
```
git clone https://github.com/mikecao/umami.git
cd umami
npm install
yarn install
```
### Create database tables
Umami supports [MySQL](https://www.mysql.com/) and [Postgresql](https://www.postgresql.org/).
Create a database for your Umami installation and install the tables with the included scripts.
For MySQL:
```
mysql -u username -p databasename < sql/schema.mysql.sql
```
For Postgresql:
```
psql -h hostname -U username -d databasename -f sql/schema.postgresql.sql
```
This will also create a login account with username **admin** and password **umami**.
### Configure umami
Create an `.env` file with the following
```
DATABASE_URL=(connection url)
HASH_SALT=(any random string)
```
The connection url is in the following format:
@ -56,21 +42,27 @@ postgresql://username:mypassword@localhost:5432/mydb
mysql://username:mypassword@localhost:3306/mydb
```
The `HASH_SALT` is used to generate unique values for your installation.
### Build the application
```bash
npm run build
yarn build
```
### Create database tables
```bash
yarn update-db
```
This will also create a login account with username **admin** and password **umami**.
### Start the application
```bash
npm start
yarn start
```
By default this will launch the application on `http://localhost:3000`. You will need to either
By default this will launch the application on `http://localhost:3000`. You will need to either
[proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server
or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly.
@ -98,8 +90,9 @@ To get the latest features, simply do a pull, install any new dependencies, and
```bash
git pull
npm install
npm run build
yarn install
yarn build
yarn update-db
```
To update the Docker image, simply pull the new images and rebuild:

View file

@ -1,26 +1,16 @@
{
"name": "Umami",
"description": "Umami is a simple, fast, website analytics alternative to Google Analytics.",
"keywords": [
"analytics",
"charts",
"statistics",
"web-analytics"
],
"website": "https://umami.is",
"repository": "https://github.com/mikecao/umami",
"addons": [
"heroku-postgresql"
],
"env": {
"HASH_SALT": {
"description": "Used to generate unique values for your installation",
"required": true,
"generator": "secret"
}
},
"scripts": {
"postdeploy": "psql $DATABASE_URL -f sql/schema.postgresql.sql"
},
"success_url": "/"
"name": "Umami",
"description": "Umami is a simple, fast, website analytics alternative to Google Analytics.",
"keywords": ["analytics", "charts", "statistics", "web-analytics"],
"website": "https://umami.is",
"repository": "https://github.com/mikecao/umami",
"addons": ["heroku-postgresql"],
"env": {
"HASH_SALT": {
"description": "Used to generate unique values for your installation",
"required": true,
"generator": "secret"
}
},
"success_url": "/"
}

View file

@ -1,27 +1,38 @@
import React from 'react';
import { useState, useEffect, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import useVersion from 'hooks/useVersion';
import styles from './UpdateNotice.module.css';
import ButtonLayout from '../layout/ButtonLayout';
import ButtonLayout from 'components/layout/ButtonLayout';
import useStore, { checkVersion } from 'store/version';
import { setItem } from 'lib/web';
import { VERSION_CHECK, VERSION_URL } from 'lib/constants';
import Button from './Button';
import useForceUpdate from '../../hooks/useForceUpdate';
import styles from './UpdateNotice.module.css';
export default function UpdateNotice() {
const forceUpdate = useForceUpdate();
const { hasUpdate, checked, latest, updateCheck } = useVersion(true);
const { latest, checked, hasUpdate } = useStore();
const [dismissed, setDismissed] = useState(false);
const updateCheck = useCallback(() => {
setItem(VERSION_CHECK, { version: latest, time: Date.now() });
}, [latest]);
function handleViewClick() {
location.href = 'https://github.com/mikecao/umami/releases';
updateCheck();
forceUpdate();
setDismissed(true);
location.href = VERSION_URL;
}
function handleDismissClick() {
updateCheck();
forceUpdate();
setDismissed(true);
}
if (!hasUpdate || checked) {
useEffect(() => {
if (!checked) {
checkVersion();
}
}, []);
if (!hasUpdate || dismissed) {
return null;
}

View file

@ -3,11 +3,11 @@ import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
import Link from 'components/common/Link';
import styles from './Footer.module.css';
import useVersion from 'hooks/useVersion';
import useStore from 'store/version';
import { HOMEPAGE_URL, VERSION_URL } from 'lib/constants';
export default function Footer() {
const { current } = useVersion();
const { current } = useStore();
return (
<footer className={classNames(styles.footer, 'row')}>

View file

@ -19,7 +19,7 @@ export default function Header() {
return (
<>
{user?.is_admin && <UpdateNotice />}
{user?.is_admin && !process.env.updatesDisabled && <UpdateNotice />}
<header className={classNames(styles.header, 'row')}>
<div className={styles.title}>
<Icon icon={<Logo />} size="large" className={styles.logo} />

View file

@ -3,20 +3,16 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import useFetch from 'hooks/useFetch';
import Dot from 'components/common/Dot';
import { TOKEN_HEADER } from 'lib/constants';
import useShareToken from 'hooks/useShareToken';
import styles from './ActiveUsers.module.css';
export default function ActiveUsers({ websiteId, className, value, interval = 60000 }) {
const shareToken = useShareToken();
const url = websiteId ? `/website/${websiteId}/active` : null;
const { data } = useFetch(url, {
interval,
headers: { [TOKEN_HEADER]: shareToken?.token },
});
const count = useMemo(() => {
if (websiteId) {
return data?.[0]?.x || 0
return data?.[0]?.x || 0;
}
return value !== undefined ? value : 0;

View file

@ -1,14 +1,21 @@
import React from 'react';
import MetricsTable from './MetricsTable';
import { percentFilter } from 'lib/filters';
import { FormattedMessage } from 'react-intl';
import { useIntl, defineMessages } from 'react-intl';
import FilterLink from 'components/common/FilterLink';
import useCountryNames from 'hooks/useCountryNames';
import useLocale from 'hooks/useLocale';
const messages = defineMessages({
unknown: { id: 'label.unknown', defaultMessage: 'Unknown' },
countries: { id: 'metrics.countries', defaultMessage: 'Countries' },
visitors: { id: 'metrics.visitors', defaultMessage: 'Visitors' },
});
export default function CountriesTable({ websiteId, onDataLoad, ...props }) {
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const { formatMessage } = useIntl();
function renderLink({ x: code }) {
return (
@ -16,9 +23,7 @@ export default function CountriesTable({ websiteId, onDataLoad, ...props }) {
<FilterLink
id="country"
value={code}
label={
countryNames[code] ?? <FormattedMessage id="label.unknown" defaultMessage="Unknown" />
}
label={countryNames[code] ?? formatMessage(messages.unknown)}
/>
</div>
);
@ -27,9 +32,9 @@ export default function CountriesTable({ websiteId, onDataLoad, ...props }) {
return (
<MetricsTable
{...props}
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
title={formatMessage(messages.countries)}
type="country"
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
metric={formatMessage(messages.visitors)}
websiteId={websiteId}
onDataLoad={data => onDataLoad?.(percentFilter(data))}
renderLabel={renderLink}

View file

@ -6,8 +6,7 @@ import useFetch from 'hooks/useFetch';
import useDateRange from 'hooks/useDateRange';
import useTimezone from 'hooks/useTimezone';
import usePageQuery from 'hooks/usePageQuery';
import useShareToken from 'hooks/useShareToken';
import { EVENT_COLORS, TOKEN_HEADER } from 'lib/constants';
import { EVENT_COLORS } from 'lib/constants';
export default function EventsChart({ websiteId, className, token }) {
const [{ startDate, endDate, unit, modified }] = useDateRange(websiteId);
@ -15,7 +14,6 @@ export default function EventsChart({ websiteId, className, token }) {
const {
query: { url, eventType },
} = usePageQuery();
const shareToken = useShareToken();
const { data, loading } = useFetch(
`/website/${websiteId}/events`,
@ -29,7 +27,6 @@ export default function EventsChart({ websiteId, className, token }) {
event_type: eventType,
token,
},
headers: { [TOKEN_HEADER]: shareToken?.token },
},
[modified, eventType],
);

View file

@ -6,14 +6,11 @@ import ErrorMessage from 'components/common/ErrorMessage';
import useFetch from 'hooks/useFetch';
import useDateRange from 'hooks/useDateRange';
import usePageQuery from 'hooks/usePageQuery';
import useShareToken from 'hooks/useShareToken';
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
import { TOKEN_HEADER } from 'lib/constants';
import MetricCard from './MetricCard';
import styles from './MetricsBar.module.css';
export default function MetricsBar({ websiteId, className }) {
const shareToken = useShareToken();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
const [format, setFormat] = useState(true);
@ -34,7 +31,6 @@ export default function MetricsBar({ websiteId, className }) {
device,
country,
},
headers: { [TOKEN_HEADER]: shareToken?.token },
},
[modified, url, referrer, os, browser, device, country],
);

View file

@ -9,10 +9,9 @@ import Arrow from 'assets/arrow-right.svg';
import { percentFilter } from 'lib/filters';
import useDateRange from 'hooks/useDateRange';
import usePageQuery from 'hooks/usePageQuery';
import useShareToken from 'hooks/useShareToken';
import ErrorMessage from 'components/common/ErrorMessage';
import DataTable from './DataTable';
import { DEFAULT_ANIMATION_DURATION, TOKEN_HEADER } from 'lib/constants';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import styles from './MetricsTable.module.css';
export default function MetricsTable({
@ -25,7 +24,6 @@ export default function MetricsTable({
onDataLoad,
...props
}) {
const shareToken = useShareToken();
const [{ startDate, endDate, modified }] = useDateRange(websiteId);
const {
resolve,
@ -49,7 +47,6 @@ export default function MetricsTable({
},
onDataLoad,
delay: DEFAULT_ANIMATION_DURATION,
headers: { [TOKEN_HEADER]: shareToken?.token },
},
[modified, url, referrer, os, browser, device, country],
);

View file

@ -12,9 +12,7 @@ import useDateRange from 'hooks/useDateRange';
import useTimezone from 'hooks/useTimezone';
import usePageQuery from 'hooks/usePageQuery';
import { getDateArray, getDateLength, getDateRangeValues } from 'lib/date';
import useShareToken from 'hooks/useShareToken';
import useApi from 'hooks/useApi';
import { TOKEN_HEADER } from 'lib/constants';
import styles from './WebsiteChart.module.css';
export default function WebsiteChart({
@ -26,7 +24,6 @@ export default function WebsiteChart({
showChart = true,
onDataLoad = () => {},
}) {
const shareToken = useShareToken();
const [dateRange, setDateRange] = useDateRange(websiteId);
const { startDate, endDate, unit, value, modified } = dateRange;
const [timezone] = useTimezone();
@ -53,7 +50,6 @@ export default function WebsiteChart({
country,
},
onDataLoad,
headers: { [TOKEN_HEADER]: shareToken?.token },
},
[modified, url, referrer, os, browser, device, country],
);

View file

@ -32,7 +32,9 @@ export default function Dashboard() {
return (
<Page>
<PageHeader>
<div>Dashboard</div>
<div>
<FormattedMessage id="label.dashboard" defaultMessage="Dashboard" />
</div>
<DashboardSettingsButton />
</PageHeader>
<WebsiteList websites={data} showCharts={showCharts} limit={max} />

View file

@ -14,7 +14,7 @@ import useFetch from 'hooks/useFetch';
import useLocale from 'hooks/useLocale';
import useCountryNames from 'hooks/useCountryNames';
import { percentFilter } from 'lib/filters';
import { TOKEN_HEADER, REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
import { SHARE_TOKEN_HEADER, REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
import styles from './RealtimeDashboard.module.css';
function mergeData(state, data, time) {
@ -38,7 +38,7 @@ export default function RealtimeDashboard() {
params: { start_at: data?.timestamp },
disabled: !init?.websites?.length || !data,
interval: REALTIME_INTERVAL,
headers: { [TOKEN_HEADER]: init?.token },
headers: { [SHARE_TOKEN_HEADER]: init?.token },
});
const renderCountryName = useCallback(

View file

@ -20,8 +20,7 @@ import EventsTable from 'components/metrics/EventsTable';
import EventsChart from 'components/metrics/EventsChart';
import useFetch from 'hooks/useFetch';
import usePageQuery from 'hooks/usePageQuery';
import useShareToken from 'hooks/useShareToken';
import { DEFAULT_ANIMATION_DURATION, TOKEN_HEADER } from 'lib/constants';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import styles from './WebsiteDetails.module.css';
const views = {
@ -36,10 +35,7 @@ const views = {
};
export default function WebsiteDetails({ websiteId }) {
const shareToken = useShareToken();
const { data } = useFetch(`/website/${websiteId}`, {
headers: { [TOKEN_HEADER]: shareToken?.token },
});
const { data } = useFetch(`/website/${websiteId}`);
const [chartLoaded, setChartLoaded] = useState(false);
const [countryData, setCountryData] = useState();
const [eventsData, setEventsData] = useState();

View file

@ -97,3 +97,6 @@ ALTER TABLE `session` ADD FOREIGN KEY (`website_id`) REFERENCES `website`(`websi
-- AddForeignKey
ALTER TABLE `website` ADD FOREIGN KEY (`user_id`) REFERENCES `account`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateAdminUser
INSERT INTO account (username, password, is_admin) values ('admin', '$2b$10$BUli0c.muyCW1ErNJc3jL.vFRFtFJWrT8/GcR4A.sUdCznaXiqFXa', true);

View file

@ -127,3 +127,6 @@ ALTER TABLE "session" ADD FOREIGN KEY ("website_id") REFERENCES "website"("websi
-- AddForeignKey
ALTER TABLE "website" ADD FOREIGN KEY ("user_id") REFERENCES "account"("user_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateAdminUser
INSERT INTO account (username, password, is_admin) values ('admin', '$2b$10$BUli0c.muyCW1ErNJc3jL.vFRFtFJWrT8/GcR4A.sUdCznaXiqFXa', true);

View file

@ -1,13 +1,18 @@
import { useCallback } from 'react';
import { useRouter } from 'next/router';
import { get, post, put, del, getItem } from 'lib/web';
import { AUTH_TOKEN } from 'lib/constants';
import { AUTH_TOKEN, SHARE_TOKEN_HEADER } from 'lib/constants';
import useStore from 'store/app';
function includeAuthToken(headers = {}) {
const authToken = getItem(AUTH_TOKEN);
const selector = state => state.shareToken;
function parseHeaders(headers = {}, { authToken, shareToken }) {
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
headers.authorization = `Bearer ${authToken}`;
}
if (shareToken) {
headers[SHARE_TOKEN_HEADER] = shareToken.token;
}
return headers;
@ -15,32 +20,50 @@ function includeAuthToken(headers = {}) {
export default function useApi() {
const { basePath } = useRouter();
const authToken = getItem(AUTH_TOKEN);
const shareToken = useStore(selector);
return {
get: useCallback(
async (url, params, headers) => {
return get(`${basePath}/api${url}`, params, includeAuthToken(headers));
return get(
`${basePath}/api${url}`,
params,
parseHeaders(headers, { authToken, shareToken }),
);
},
[get],
),
post: useCallback(
async (url, params, headers) => {
return post(`${basePath}/api${url}`, params, includeAuthToken(headers));
return post(
`${basePath}/api${url}`,
params,
parseHeaders(headers, { authToken, shareToken }),
);
},
[post],
),
put: useCallback(
async (url, params, headers) => {
return put(`${basePath}/api${url}`, params, includeAuthToken(headers));
return put(
`${basePath}/api${url}`,
params,
parseHeaders(headers, { authToken, shareToken }),
);
},
[put],
),
del: useCallback(
async (url, params, headers) => {
return del(`${basePath}/api${url}`, params, includeAuthToken(headers));
return del(
`${basePath}/api${url}`,
params,
parseHeaders(headers, { authToken, shareToken }),
);
},
[del],
),

View file

@ -5,14 +5,14 @@ import useApi from './useApi';
export default function useFetch(url, options = {}, update = []) {
const [response, setResponse] = useState();
const [error, setError] = useState();
const [loading, setLoadiing] = useState(false);
const [loading, setLoading] = useState(false);
const [count, setCount] = useState(0);
const { get } = useApi();
const { params = {}, headers = {}, disabled, delay = 0, interval, onDataLoad } = options;
async function loadData(params) {
try {
setLoadiing(true);
setLoading(true);
setError(null);
const time = performance.now();
@ -32,7 +32,7 @@ export default function useFetch(url, options = {}, update = []) {
console.error(e);
setError(e);
} finally {
setLoadiing(false);
setLoading(false);
}
}

View file

@ -1,21 +0,0 @@
import { useEffect, useCallback } from 'react';
import useStore, { checkVersion } from 'store/version';
import { VERSION_CHECK } from 'lib/constants';
import { getItem, setItem } from 'lib/web';
export default function useVersion(check) {
const versions = useStore();
const checked = versions.latest === getItem(VERSION_CHECK)?.version;
const updateCheck = useCallback(() => {
setItem(VERSION_CHECK, { version: versions.latest, time: Date.now() });
}, [versions]);
useEffect(() => {
if (check && !versions.latest) {
checkVersion();
}
}, [versions, check]);
return { ...versions, checked, updateCheck };
}

View file

@ -4,6 +4,7 @@
"label.administrator",
"label.name",
"label.domain",
"label.theme",
"metrics.device.desktop",
"metrics.device.laptop",
"metrics.device.tablet",

View file

@ -28,7 +28,7 @@
"label.enable-share-url": "Freigabe-URL aktivieren",
"label.invalid": "Ungültig",
"label.invalid-domain": "Ungültige Domain",
"label.language": "Language",
"label.language": "Sprache",
"label.last-days": "Letzten {x} Tage",
"label.last-hours": "Letzten {x} Stunden",
"label.logged-in-as": "Angemeldet als {username}",
@ -51,7 +51,7 @@
"label.settings": "Einstellungen",
"label.share-url": "Freigabe-URL",
"label.single-day": "Ein Tag",
"label.theme": "Theme",
"label.theme": "Thema",
"label.this-month": "Diesen Monat",
"label.this-week": "Diese Woche",
"label.this-year": "Dieses Jahr",
@ -92,7 +92,7 @@
"metrics.countries": "Länder",
"metrics.device.desktop": "Desktop",
"metrics.device.laptop": "Laptop",
"metrics.device.mobile": "Mobiltelefon",
"metrics.device.mobile": "Handy",
"metrics.device.tablet": "Tablet",
"metrics.devices": "Geräte",
"metrics.events": "Ereignisse",

View file

@ -5,7 +5,7 @@
"label.administrator": "مدیر",
"label.all": "همه",
"label.all-events": "همه‌ی رویدادها",
"label.all-time": "All time",
"label.all-time": "همه زمان",
"label.all-websites": "همه‌ی وب‌سایت‌ها",
"label.back": "برگشت",
"label.cancel": "انصراف",
@ -28,16 +28,16 @@
"label.enable-share-url": "فعال کردن اشتراک گذاری URL",
"label.invalid": "نامعتبر",
"label.invalid-domain": "دامنه‌ی نامعتبر",
"label.language": "Language",
"label.last-days": "لیست {x} روز",
"label.last-hours": "لیست {x} ساعت",
"label.language": "زبان",
"label.last-days": "لیست {x} روز گذشته",
"label.last-hours": "لیست {x} ساعت گذشته",
"label.logged-in-as": "وارد شده به عنوان {username}",
"label.login": "ورود",
"label.logout": "خروج",
"label.more": "بیشتر",
"label.name": "نام",
"label.new-password": "رمز جدید",
"label.owner": "Owner",
"label.owner": "ایجاد شده توسط",
"label.password": "رمز",
"label.passwords-dont-match": "رمزها یکسان نیستند",
"label.profile": "پروفایل",
@ -46,12 +46,12 @@
"label.refresh": "به‌روزرسانی",
"label.required": "ضروری",
"label.reset": "بازنشانی",
"label.reset-website": "Reset statistics",
"label.reset-website": "بازنشانی آمار",
"label.save": "ذخیره",
"label.settings": "تنظیمات",
"label.share-url": "به اشتراک گذاری URL",
"label.single-day": "یک روز",
"label.theme": "Theme",
"label.theme": "تم",
"label.this-month": "این ماه",
"label.this-week": "این هفته",
"label.this-year": "امسال",
@ -64,7 +64,7 @@
"label.websites": "وب‌سایت‌ها",
"message.active-users": "{x} هم اکنون {x, plural, one {یک} other {از میان}}",
"message.confirm-delete": "آیا مطمئن هستید می‌خواهید {target} را حذف کنید?",
"message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
"message.confirm-reset": "آیا از بازنشانی آمار {target} مطمئن هستید?",
"message.copied": "کپی شد!",
"message.delete-warning": "همه‌ی داده‌های مرتبط هم حذف خواهد شد.",
"message.failure": "مشکلی پیش آمده است.",
@ -78,7 +78,7 @@
"message.no-websites-configured": "شما هیچ وب‌سایتی را پیکربندی نکرده‌اید.",
"message.page-not-found": "صفحه یافت نشد.",
"message.powered-by": "قدرت گرفته توسط {name}",
"message.reset-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
"message.reset-warning": "تمامی آمارهای این وب‌سایت حذف خواهد شد اما tracking code بدون تغییر باقی می‌ماند.",
"message.save-success": "با موفقیت ذخیره شد.",
"message.share-url": "این URL به اشتراک گذاشته شده عمومی برای {target} است.",
"message.toggle-charts": "Toggle charts",
@ -99,7 +99,7 @@
"metrics.filter.combined": "ترکیب شده",
"metrics.filter.domain-only": "فقط دامنه",
"metrics.filter.raw": "خام",
"metrics.languages": "Languages",
"metrics.languages": "زبان‌ها",
"metrics.operating-systems": "سیستم‌عامل‌ها",
"metrics.page-views": "بازدید صفحه",
"metrics.pages": "صفحه‌ها",

View file

@ -5,7 +5,7 @@
"label.administrator": "Administrateur",
"label.all": "Tout",
"label.all-events": "Tous les événements",
"label.all-time": "Toutes périodes",
"label.all-time": "Toutes les données",
"label.all-websites": "Tous les sites web",
"label.back": "Retour",
"label.cancel": "Annuler",
@ -13,10 +13,10 @@
"label.confirm-password": "Confirmation du mot de passe",
"label.copy-to-clipboard": "Copier dans le presse papier",
"label.current-password": "Mot de passe actuel",
"label.custom-range": "Intervalle personnalisé",
"label.custom-range": "Période personnalisée",
"label.dashboard": "Tableau de bord",
"label.date-range": "Intervalle",
"label.default-date-range": "Intervalle par défaut",
"label.date-range": "Période",
"label.default-date-range": "Période par défaut",
"label.delete": "Supprimer",
"label.delete-account": "Supprimer le compte",
"label.delete-website": "Supprimer le site",
@ -25,7 +25,7 @@
"label.edit": "Modifier",
"label.edit-account": "Modifier le compte",
"label.edit-website": "Modifier le site",
"label.enable-share-url": "Activer le partage d'URL",
"label.enable-share-url": "Activer l'URL de partage",
"label.invalid": "Invalide",
"label.invalid-domain": "Domaine invalide",
"label.language": "Langage",
@ -68,10 +68,10 @@
"message.copied": "Copié !",
"message.delete-warning": "Toutes les données associées seront également supprimées.",
"message.failure": "Un problème est survenu.",
"message.get-share-url": "Obtenez l'URL de partage",
"message.get-tracking-code": "Obtenez le code de suivi",
"message.get-share-url": "Obtenir l'URL de partage",
"message.get-tracking-code": "Obtenir le code de suivi",
"message.go-to-settings": "Aller aux paramètres",
"message.incorrect-username-password": "nom d'utilisateurs/mot de passe incorrect.",
"message.incorrect-username-password": "Nom d'utilisateur/Mot de passe incorrect.",
"message.log.visitor": "Visiteur de {country} utilisant {browser} sur {os} {device}",
"message.new-version-available": "Une nouvelle version de umami {version} est disponible !",
"message.no-data-available": "Pas de données disponibles.",
@ -81,7 +81,7 @@
"message.reset-warning": "Toutes les statistiques pour ce site seront supprimés, mais votre code de suivi restera intact.",
"message.save-success": "Enregistré avec succès.",
"message.share-url": "Ceci est l'URL partagée pour {target}.",
"message.toggle-charts": "Changer les graphiques",
"message.toggle-charts": "Afficher/Masquer les graphiques",
"message.track-stats": "Pour suivre les statistiques de {target}, placez le code suivant dans la section {head} de votre site Web.",
"message.type-delete": "Tapez {delete} dans la case ci-dessous pour confirmer.",
"message.type-reset": "Tapez {reset} dans la case ci-dessous pour confirmer.",
@ -98,12 +98,12 @@
"metrics.events": "Événements",
"metrics.filter.combined": "Combiné",
"metrics.filter.domain-only": "Domaine uniquement",
"metrics.filter.raw": "Brute",
"metrics.filter.raw": "Brut",
"metrics.languages": "Langages",
"metrics.operating-systems": "Systèmes d'exploitation",
"metrics.page-views": "Pages vues",
"metrics.pages": "Pages",
"metrics.referrers": "URL Référentes",
"metrics.referrers": "Sources",
"metrics.unique-visitors": "Visiteurs uniques",
"metrics.views": "Vues",
"metrics.visitors": "Visiteurs"

110
lang/ga-ES.json Normal file
View file

@ -0,0 +1,110 @@
{
"label.accounts": "Contas",
"label.add-account": "Engadir conta",
"label.add-website": "Engadir sitio web",
"label.administrator": "Administradora",
"label.all": "Todo",
"label.all-events": "Tódolos eventos",
"label.all-time": "Sempre",
"label.all-websites": "Tódolos sitios web",
"label.back": "Atrás",
"label.cancel": "Cancelar",
"label.change-password": "Mudar contrasinal",
"label.confirm-password": "Confirmar contrasinal",
"label.copy-to-clipboard": "Copiar ao portapapeis",
"label.current-password": "Contrasinal actual",
"label.custom-range": "Rango personalizado",
"label.dashboard": "Taboleiro",
"label.date-range": "Rango temporal",
"label.default-date-range": "Rango temporal por defecto",
"label.delete": "Eliminar",
"label.delete-account": "Eliminar conta",
"label.delete-website": "Eliminar sitio web",
"label.dismiss": "Desbotar",
"label.domain": "Dominio",
"label.edit": "Editar",
"label.edit-account": "Editar conta",
"label.edit-website": "Editar sitio web",
"label.enable-share-url": "Activar URL de compartición",
"label.invalid": "Non válido",
"label.invalid-domain": "Dominio non válido",
"label.language": "Idioma",
"label.last-days": "Últimos {x} días",
"label.last-hours": "Últimas {x} horas",
"label.logged-in-as": "Sesión de {username}",
"label.login": "Acceder",
"label.logout": "Pechar sesión",
"label.more": "Máis",
"label.name": "Nome",
"label.new-password": "Novo contrasinal",
"label.owner": "Dona",
"label.password": "Contrasinal",
"label.passwords-dont-match": "Non concordan os contrasinais",
"label.profile": "Perfil",
"label.realtime": "Agora mesmo",
"label.realtime-logs": "Rexistro neste intre",
"label.refresh": "Actualizar",
"label.required": "Requerido",
"label.reset": "Restablecer",
"label.reset-website": "Restablecer estatísticas",
"label.save": "Gardar",
"label.settings": "Axustes",
"label.share-url": "Compartir URL",
"label.single-day": "Un só día",
"label.theme": "Decorado",
"label.this-month": "Este mes",
"label.this-week": "Esta semana",
"label.this-year": "Este ano",
"label.timezone": "Zona horaria",
"label.today": "Hoxe",
"label.tracking-code": "Código de seguimento",
"label.unknown": "Descoñecido",
"label.username": "Identificador",
"label.view-details": "Ver detalles",
"label.websites": "Sitios web",
"message.active-users": "{x} actual {x, plural, one {visitante} other {visitantes}}",
"message.confirm-delete": "Tes a certeza de querer eliminar {target}?",
"message.confirm-reset": "Tes a certeza de querer restablecer as estatísticas de {target}?",
"message.copied": "Copiado!",
"message.delete-warning": "Tamén serán borrados tódolos datos asociados.",
"message.failure": "Houbo un fallo.",
"message.get-share-url": "Obter URL de compartición",
"message.get-tracking-code": "Obter código de seguimento",
"message.go-to-settings": "Ir aos axustes",
"message.incorrect-username-password": "Credenciais incorrectas.",
"message.log.visitor": "Visitante desde {country} usando {browser} en {os} {device}",
"message.new-version-available": "A nova versión {version} de umami está dispoñible!",
"message.no-data-available": "Sen datos dispoñibles.",
"message.no-websites-configured": "Non tes sitios web configurados.",
"message.page-not-found": "Páxina non atopada.",
"message.powered-by": "Funciona grazas a {name}",
"message.reset-warning": "Vanse eliminar tódalas estatísticas deste sitio web, pero o código de seguimento permanecerá sen cambios.",
"message.save-success": "Gardouse correctamente.",
"message.share-url": "Este é o URL da compartición pública de {target}.",
"message.toggle-charts": "Activación das gráficas",
"message.track-stats": "Para crear estatísticas de {target}, pon este código na sección {head} do teu sitio web.",
"message.type-delete": "Escribe {delete} na caixa inferior para confirmar.",
"message.type-reset": "Escribe {reset} na caixa inferior para confirmar.",
"metrics.actions": "Accións",
"metrics.average-visit-time": "Tempo medio de visita",
"metrics.bounce-rate": "Proporción de rebote",
"metrics.browsers": "Navegadores",
"metrics.countries": "Países",
"metrics.device.desktop": "Escritorio",
"metrics.device.laptop": "Portátil",
"metrics.device.mobile": "Móbil",
"metrics.device.tablet": "Tableta",
"metrics.devices": "Dispositivos",
"metrics.events": "Eventos",
"metrics.filter.combined": "Combinado",
"metrics.filter.domain-only": "Só dominio",
"metrics.filter.raw": "Raw",
"metrics.languages": "Idiomas",
"metrics.operating-systems": "Sistemas operativos",
"metrics.page-views": "Vistas de páxinas",
"metrics.pages": "Páxinas",
"metrics.referrers": "Orixes",
"metrics.unique-visitors": "Visitas únicas",
"metrics.views": "Visualizacións",
"metrics.visitors": "Visitantes"
}

View file

@ -12,7 +12,7 @@
"label.change-password": "Modifica password",
"label.confirm-password": "Conferma password",
"label.copy-to-clipboard": "Copia",
"label.current-password": "Password corrente",
"label.current-password": "Password attuale",
"label.custom-range": "Personalizzato",
"label.dashboard": "Pannello di Controllo",
"label.date-range": "Periodo",
@ -28,7 +28,7 @@
"label.enable-share-url": "Abilita URL di condivisione",
"label.invalid": "Non valido",
"label.invalid-domain": "Dominio non valido",
"label.language": "Language",
"label.language": "Lingua",
"label.last-days": "Ultimi {x} giorni",
"label.last-hours": "Ultime {x} ore",
"label.logged-in-as": "Ciao {username}",
@ -51,7 +51,7 @@
"label.settings": "Impostazioni",
"label.share-url": "Condividi link",
"label.single-day": "Singolo giorno",
"label.theme": "Theme",
"label.theme": "Tema",
"label.this-month": "Questo mese",
"label.this-week": "Questa settimana",
"label.this-year": "Quest'anno",
@ -64,7 +64,7 @@
"label.websites": "Siti web",
"message.active-users": "{x} {x, plural, one {visitatore} other {visitatori}} online",
"message.confirm-delete": "Sei sicuro di voler eliminare {target}?",
"message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
"message.confirm-reset": "Sei sicuro di voler azzerare le statistiche di {target}?",
"message.copied": "Copiato!",
"message.delete-warning": "Saranno eliminati anche tutti i dati associati.",
"message.failure": "Si è verificato un errore.",
@ -73,7 +73,7 @@
"message.go-to-settings": "Vai alle impostazioni",
"message.incorrect-username-password": "Username o password non corretti.",
"message.log.visitor": "Utenti da {country} tramite {browser} su {os} {device}",
"message.new-version-available": "Una nuova versione umami {version} è disponibile!",
"message.new-version-available": "Una nuova versione {version} di umami è disponibile!",
"message.no-data-available": "Nessun dato disponibile.",
"message.no-websites-configured": "Non hai ancora configurato alcun sito.",
"message.page-not-found": "Pagina non trovata",
@ -103,7 +103,7 @@
"metrics.operating-systems": "Sistemi operativi",
"metrics.page-views": "Visualizzazioni di pagina",
"metrics.pages": "Pagine",
"metrics.referrers": "Referr",
"metrics.referrers": "Referrers",
"metrics.unique-visitors": "Visitatori unici",
"metrics.views": "Visualizzazioni",
"metrics.visitors": "Visitatori"

View file

@ -5,7 +5,7 @@
"label.administrator": "Админ",
"label.all": "Бүх",
"label.all-events": "Бүх үйл явдал",
"label.all-time": "All time",
"label.all-time": "Бүх цаг үеийн",
"label.all-websites": "Бүх вебүүд",
"label.back": "Буцах",
"label.cancel": "Цуцлах",
@ -28,7 +28,7 @@
"label.enable-share-url": "Хуваалцах холбоос идэвхжүүлэх",
"label.invalid": "Буруу",
"label.invalid-domain": "Буруу домэйн",
"label.language": "Language",
"label.language": "Хэл",
"label.last-days": "Сүүлийн {x} хоног",
"label.last-hours": "Сүүлийн {x} цаг",
"label.logged-in-as": "{username}-р нэвтэрсэн",
@ -37,7 +37,7 @@
"label.more": "Цааш",
"label.name": "Нэр",
"label.new-password": "Шинэ нууц үг",
"label.owner": "Owner",
"label.owner": "Эзэмшигч",
"label.password": "Нууц үг",
"label.passwords-dont-match": "Нууц үг тохирохгүй байна",
"label.profile": "Бүртгэл",
@ -46,12 +46,12 @@
"label.refresh": "Сэргээх",
"label.required": "Шаардлагатай",
"label.reset": "Хуучин хэвд нь оруулах",
"label.reset-website": "Reset statistics",
"label.reset-website": "Тоон үзүүлэлтийг дахин эхлүүлэх",
"label.save": "Хадгалах",
"label.settings": "Тохиргоо",
"label.share-url": "Хуваалцах холбоос",
"label.single-day": "Нэг өдөр",
"label.theme": "Theme",
"label.theme": "Загвар",
"label.this-month": "Энэ сар",
"label.this-week": "Энэ долоо хоног",
"label.this-year": "Энэ жил",
@ -78,10 +78,10 @@
"message.no-websites-configured": "Та ямар нэгэн веб тохируулаагүй байна.",
"message.page-not-found": "Хуудас олдсонгүй.",
"message.powered-by": "{name} дээр суурилсан",
"message.reset-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
"message.reset-warning": "Энэ вебийн бүх тоон үзүүлэлтүүдийг устгах болно. Гэхдээ мөрдөх код хэвэндээ үлдэнэ.",
"message.save-success": "Амжилттай хадгаллаа.",
"message.share-url": "{target}-г нийтэд хуваалцах холбоос.",
"message.toggle-charts": "Toggle charts",
"message.toggle-charts": "Графикийг харуулах/нуух",
"message.track-stats": "{target} вебийн статистикийг бүртгэхийн тулд доорх кодыг вебийнхээ {head} хэсэгт байрлуулна уу.",
"message.type-delete": "Доорх хэсэгт {delete} гэж бичиж баталгаажуулна уу.",
"message.type-reset": "Доорх хэсэгт {reset} гэж бичиж баталгаажуулна уу.",
@ -99,7 +99,7 @@
"metrics.filter.combined": "Нэгтгэсэн",
"metrics.filter.domain-only": "Зөвхөн домэйн",
"metrics.filter.raw": "Түүхий",
"metrics.languages": "Languages",
"metrics.languages": "Хэл",
"metrics.operating-systems": "Үйлдлийн систем",
"metrics.page-views": "Хуудас үзсэн",
"metrics.pages": "Хуудас",

View file

@ -4,8 +4,8 @@
"label.add-website": "Adicionar site",
"label.administrator": "Administrador",
"label.all": "Todos",
"label.all-events": "All events",
"label.all-time": "All time",
"label.all-events": "Todos os eventos",
"label.all-time": "Todo o período",
"label.all-websites": "Todos os sites",
"label.back": "Voltar",
"label.cancel": "Cancelar",
@ -28,7 +28,7 @@
"label.enable-share-url": "Ativar link de compartilhamento",
"label.invalid": "Inválido",
"label.invalid-domain": "Domínio inválido",
"label.language": "Language",
"label.language": "Idioma",
"label.last-days": "Últimos {x} dias",
"label.last-hours": "Últimas {x} horas",
"label.logged-in-as": "Sessão iniciada como {username}",
@ -37,7 +37,7 @@
"label.more": "Mais",
"label.name": "Nome",
"label.new-password": "Nova senha",
"label.owner": "Owner",
"label.owner": "Proprietário",
"label.password": "Senha",
"label.passwords-dont-match": "As senhas não correspondem",
"label.profile": "Perfil",
@ -46,12 +46,12 @@
"label.refresh": "Atualizar",
"label.required": "Obrigatório",
"label.reset": "Redefinir",
"label.reset-website": "Reset statistics",
"label.reset-website": "Redefinir estatísticas",
"label.save": "Salvar",
"label.settings": "Configurações",
"label.share-url": "Link de compartilhamento",
"label.single-day": "Dia específico",
"label.theme": "Theme",
"label.theme": "Tema",
"label.this-month": "Este mês",
"label.this-week": "Esta semana",
"label.this-year": "Este ano",
@ -64,7 +64,7 @@
"label.websites": "Sites",
"message.active-users": "{x} {x, plural, one {visitante} other {visitantes}} neste momento",
"message.confirm-delete": "Deseja realmente remover {target}?",
"message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
"message.confirm-reset": "Você tem certeza que deseja redefinir as estatísticas de {target}?",
"message.copied": "Copiado!",
"message.delete-warning": "Todos os dados associados também serão eliminados.",
"message.failure": "Ocorreu um erro.",
@ -78,10 +78,10 @@
"message.no-websites-configured": "Nenhum site foi configurado ainda.",
"message.page-not-found": "Página não encontrada.",
"message.powered-by": "Distribuído por {name}",
"message.reset-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
"message.reset-warning": "Todas as estatísticas deste site serão removidas, mas seu código de rastreamento permanecerá intacto.",
"message.save-success": "Salvo com sucesso.",
"message.share-url": "Este é o link público de compartilhamento para {target}.",
"message.toggle-charts": "Toggle charts",
"message.toggle-charts": "Mostrar/Esconder gráficos",
"message.track-stats": "Para gerar estatística para {target}, coloque o seguinte código no {head} do html do seu site.",
"message.type-delete": "Escreva {delete} abaixo para continuar.",
"message.type-reset": "Escreva {reset} abaixo para continuar.",
@ -99,7 +99,7 @@
"metrics.filter.combined": "Combinado",
"metrics.filter.domain-only": "Apenas domínio",
"metrics.filter.raw": "Dados brutos",
"metrics.languages": "Languages",
"metrics.languages": "Idiomas",
"metrics.operating-systems": "Sistemas operacionais",
"metrics.page-views": "Visualizações de página",
"metrics.pages": "Páginas",

View file

@ -28,7 +28,7 @@
"label.enable-share-url": "Разрешить делиться ссылкой",
"label.invalid": "Некорректный",
"label.invalid-domain": "Некорректный домен",
"label.language": "Language",
"label.language": "Язык",
"label.last-days": "Последние {x} дней",
"label.last-hours": "Последние {x} часа",
"label.logged-in-as": "Вы вошли как {username}",
@ -51,7 +51,7 @@
"label.settings": "Настройки",
"label.share-url": "Поделиться ссылкой",
"label.single-day": "Один день",
"label.theme": "Theme",
"label.theme": "Тема",
"label.this-month": "Этот месяц",
"label.this-week": "Эта неделя",
"label.this-year": "Этот год",

View file

@ -2,11 +2,11 @@
"label.accounts": "Tài khoản",
"label.add-account": "Thêm tài khoản",
"label.add-website": "Thêm website",
"label.administrator": "Quản Trị",
"label.administrator": "Quản trị",
"label.all": "Tất cả",
"label.all-events": "Tất cả events",
"label.all-time": "All time",
"label.all-websites": "Tất cả websites",
"label.all-events": "Tất cả sự kiện",
"label.all-time": "Toàn thời gian",
"label.all-websites": "Tất cả website",
"label.back": "Quay về",
"label.cancel": "Huỷ bỏ",
"label.change-password": "Đổi mật khẩu",
@ -16,7 +16,7 @@
"label.custom-range": "Phạm vi ngày tuỳ chọn",
"label.dashboard": "Bảng điều khiển",
"label.date-range": "Phạm vi ngày",
"label.default-date-range": "Phạm vi ngày mặc định",
"label.default-date-range": "Khoảng thời gian mặc định",
"label.delete": "Xoá",
"label.delete-account": "Xoá tài khoản",
"label.delete-website": "Xáo website",
@ -37,7 +37,7 @@
"label.more": "Thêm",
"label.name": "Tên",
"label.new-password": "Mật khẩu mới",
"label.owner": "Owner",
"label.owner": "Chủ nhân",
"label.password": "Mật khẩu",
"label.passwords-dont-match": "Mật khẩu không đồng nhất",
"label.profile": "Hồ sơ",
@ -81,7 +81,7 @@
"message.reset-warning": "Tất cả số liệu thống kê của website này sẽ bị xoá, nhưng mã theo dõi sẽ vẫn giữ nguyên.",
"message.save-success": "Đã lưu thành công.",
"message.share-url": "Đây là đường dẫn URL cho {target}.",
"message.toggle-charts": "Toggle charts",
"message.toggle-charts": "Bật/tắt biểu đồ",
"message.track-stats": "Để theo dõi {target}, dán mã theo dõi vào {head} của website bạn.",
"message.type-delete": "Nhập {delete} bên dưới để xác nhận.",
"message.type-reset": "Nhập {reset} bên dưới để xác nhận.",
@ -99,12 +99,12 @@
"metrics.filter.combined": "Kết hợp",
"metrics.filter.domain-only": "Chỉ tên miền",
"metrics.filter.raw": "Gốc",
"metrics.languages": "Languages",
"metrics.languages": "Ngôn ngũ",
"metrics.operating-systems": "Hệ điều hành",
"metrics.page-views": "Lượt xem",
"metrics.pages": "Trang",
"metrics.referrers": "Liên kết giới thiệu",
"metrics.unique-visitors": "Khách truy cập duy nhất",
"metrics.unique-visitors": "Khách truy cập một lần",
"metrics.views": "Xem",
"metrics.visitors": "Khách"
}

View file

@ -1,5 +1,5 @@
import { parseSecureToken, parseToken } from './crypto';
import { TOKEN_HEADER } from './constants';
import { SHARE_TOKEN_HEADER } from './constants';
import { getWebsiteById } from './queries';
export async function getAuthToken(req) {
@ -30,7 +30,7 @@ export async function isValidToken(token, validation) {
export async function allowQuery(req, skipToken) {
const { id } = req.query;
const token = req.headers[TOKEN_HEADER];
const token = req.headers[SHARE_TOKEN_HEADER];
const websiteId = +id;
const website = await getWebsiteById(websiteId);

View file

@ -5,7 +5,7 @@ export const DATE_RANGE_CONFIG = 'umami.date-range';
export const THEME_CONFIG = 'umami.theme';
export const DASHBOARD_CONFIG = 'umami.dashboard';
export const VERSION_CHECK = 'umami.version-check';
export const TOKEN_HEADER = 'x-umami-token';
export const SHARE_TOKEN_HEADER = 'x-umami-share-token';
export const HOMEPAGE_URL = 'https://umami.is';
export const VERSION_URL = 'https://github.com/mikecao/umami/releases';

View file

@ -14,11 +14,11 @@ export function hash(...args) {
}
export function secret() {
return hash(process.env.HASH_SALT);
return hash(process.env.HASH_SALT || process.env.DATABASE_URL);
}
export function salt() {
return v5([secret(), ROTATING_SALT].join(''), v5.DNS);
return v5(hash(secret(), ROTATING_SALT), v5.DNS);
}
export function uuid(...args) {

View file

@ -11,23 +11,23 @@ const options = {
};
function logQuery(e) {
if (process.env.LOG_QUERY) {
console.log(chalk.yellow(e.params), '->', e.query, chalk.greenBright(`${e.duration}ms`));
}
console.log(chalk.yellow(e.params), '->', e.query, chalk.greenBright(`${e.duration}ms`));
}
let prisma;
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient(options);
prisma.$on('query', logQuery);
} else {
if (!global.prisma) {
global.prisma = new PrismaClient(options);
global.prisma.$on('query', logQuery);
}
prisma = global.prisma;
}
if (process.env.LOG_QUERY) {
prisma.$on('query', logQuery);
}
export default prisma;

View file

@ -53,6 +53,7 @@ export const languages = {
'fa-IR': { label: 'فارسی', dateLocale: faIR, dir: 'rtl' },
'fo-FO': { label: 'Føroyskt' },
'fr-FR': { label: 'Français', dateLocale: fr },
'ga-ES': { label: 'Galacian (Spain)', dateLocale: es },
'el-GR': { label: 'Ελληνικά', dateLocale: el },
'he-IL': { label: 'עברית', dateLocale: he },
'hi-IN': { label: 'हिन्दी', dateLocale: hi },

View file

@ -64,7 +64,7 @@ export function getFilterQuery(table, filters = {}, params = []) {
switch (key) {
case 'url':
if (table === 'session' || table === 'pageview') {
if (table === 'pageview' || table === 'event') {
arr.push(`and ${table}.${key}=$${params.length + 1}`);
params.push(decodeURIComponent(value));
}
@ -110,11 +110,11 @@ export function getFilterQuery(table, filters = {}, params = []) {
}
export function parseFilters(table, filters = {}, params = []) {
const { domain, url, referrer, os, browser, device, country, event_type } = filters;
const { domain, url, event_url, referrer, os, browser, device, country, event_type } = filters;
const pageviewFilters = { domain, url, referrer };
const sessionFilters = { os, browser, device, country };
const eventFilters = { event_type };
const eventFilters = { url: event_url, event_type };
return {
pageviewFilters,
@ -315,6 +315,13 @@ export async function getAccounts() {
username: 'asc',
},
],
select: {
user_id: true,
username: true,
is_admin: true,
created_at: true,
updated_at: true,
},
}),
);
}
@ -502,8 +509,11 @@ export function getSessionMetrics(website_id, start_at, end_at, field, filters =
export function getPageviewMetrics(website_id, start_at, end_at, field, table, filters = {}) {
const params = [website_id, start_at, end_at];
console.log({ table, filters });
const { pageviewQuery, sessionQuery, joinSession } = parseFilters(table, filters, params);
const { pageviewQuery, sessionQuery, eventQuery, joinSession } = parseFilters(
table,
filters,
params,
);
return rawQuery(
`
@ -514,6 +524,7 @@ export function getPageviewMetrics(website_id, start_at, end_at, field, table, f
and ${table}.created_at between $2 and $3
${pageviewQuery}
${joinSession && sessionQuery}
${eventQuery}
group by 1
order by 2 desc
`,

View file

@ -3,9 +3,14 @@ const pkg = require('./package.json');
module.exports = {
env: {
VERSION: pkg.version,
currentVersion: pkg.version,
loginDisabled: process.env.DISABLE_LOGIN,
updatesDisabled: process.env.DISABLE_UPDATES,
},
basePath: process.env.BASE_PATH,
experimental: {
outputStandalone: true,
},
eslint: {
ignoreDuringBuilds: true,
},

View file

@ -1,6 +1,6 @@
{
"name": "umami",
"version": "1.30.0",
"version": "1.33.0",
"description": "A simple, fast, privacy-focused alternative to Google Analytics.",
"author": "Mike Cao <mike@mikecao.com>",
"license": "MIT",
@ -12,20 +12,21 @@
"scripts": {
"dev": "next dev",
"build": "npm-run-all build-tracker build-geo build-db build-app",
"start": "next start",
"start": "npm-run-all check-db start-next",
"start-docker": "npm-run-all check-db start-server",
"start-env": "node -r dotenv/config scripts/start-env.js",
"start-server": "node server.js",
"start-next": "next start",
"build-app": "next build",
"build-tracker": "rollup -c rollup.tracker.config.js",
"build-db": "npm-run-all copy-db-schema build-db-client",
"build-db": "npm-run-all copy-db-files build-db-client",
"build-lang": "npm-run-all format-lang compile-lang",
"build-geo": "node scripts/build-geo.js",
"build-db-schema": "dotenv prisma introspect",
"build-db-client": "dotenv prisma generate",
"build-mysql-schema": "dotenv prisma db pull -- --schema=./prisma/schema.mysql.prisma",
"build-mysql-client": "dotenv prisma generate -- --schema=./prisma/schema.mysql.prisma",
"build-postgresql-schema": "dotenv prisma db pull -- --schema=./prisma/schema.postgresql.prisma",
"build-postgresql-client": "dotenv prisma generate -- --schema=./prisma/schema.postgresql.prisma",
"copy-db-schema": "node scripts/copy-db-schema.js",
"build-db-schema": "prisma db pull",
"build-db-client": "prisma generate",
"update-db": "prisma migrate deploy",
"check-db": "node scripts/check-db.js",
"copy-db-files": "node scripts/copy-db-files.js",
"generate-lang": "npm-run-all extract-lang merge-lang",
"extract-lang": "formatjs extract \"{pages,components}/**/*.js\" --out-file build/messages.json",
"merge-lang": "node scripts/merge-lang.js",
@ -36,7 +37,7 @@
"download-language-names": "node scripts/download-language-names.js",
"change-password": "node scripts/change-password.js",
"lint": "next lint --quiet",
"prepare": "husky install",
"prepare": "node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky install",
"postbuild": "node scripts/postbuild.js"
},
"lint-staged": {
@ -54,13 +55,14 @@
},
"dependencies": {
"@fontsource/inter": "4.5.7",
"@prisma/client": "3.12.0",
"@prisma/client": "3.15.2",
"bcryptjs": "^2.4.3",
"chalk": "^4.1.1",
"chart.js": "^2.9.4",
"classnames": "^2.3.1",
"colord": "^2.9.2",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"date-fns": "^2.23.0",
"date-fns-tz": "^1.1.4",
"del": "^6.0.0",
@ -78,7 +80,7 @@
"jose": "2.0.5",
"maxmind": "^4.3.6",
"moment-timezone": "^0.5.33",
"next": "12.1.4",
"next": "12.1.0",
"node-fetch": "^3.2.3",
"npm-run-all": "^4.1.5",
"prop-types": "^15.7.2",
@ -112,10 +114,10 @@
"postcss": "^8.4.12",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-import": "^14.0.2",
"postcss-preset-env": "^7.4.2",
"postcss-preset-env": "7.4.3",
"postcss-rtlcss": "^3.6.1",
"prettier": "^2.6.2",
"prisma": "3.12.0",
"prisma": "3.15.2",
"prompts": "2.4.2",
"rollup": "^2.70.1",
"rollup-plugin-terser": "^7.0.2",

View file

@ -24,7 +24,6 @@ const Intl = ({ children }) => {
export default function App({ Component, pageProps }) {
const { basePath } = useRouter();
const { dir } = useLocale();
const version = process.env.VERSION;
return (
<Intl>
@ -35,12 +34,6 @@ export default function App({ Component, pageProps }) {
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
<link rel="manifest" href={`${basePath}/site.webmanifest`} />
<link rel="mask-icon" href={`${basePath}/safari-pinned-tab.svg`} color="#5bbad5" />
<link
rel="preload"
href={`https://i.umami.is/icon.png?v=${version}`}
as="image"
type="image/png"
/>
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#2f2f2f" media="(prefers-color-scheme: dark)" />

View file

@ -6,7 +6,7 @@ function customScriptName(req) {
if (scriptName) {
const url = req.nextUrl.clone();
const { pathname } = url;
const names = scriptName.split(',').map(name => (name + '.js').trim());
const names = scriptName.split(',').map(name => name.trim() + '.js');
if (names.find(name => pathname.endsWith(name))) {
url.pathname = '/umami.js';
@ -15,12 +15,6 @@ function customScriptName(req) {
}
}
function disableLogin(req) {
if (process.env.DISABLE_LOGIN && req.nextUrl.pathname.endsWith('/login')) {
return new Response('403 Forbidden', { status: 403 });
}
}
function forceSSL(req, res) {
if (process.env.FORCE_SSL && req.nextUrl.protocol === 'http:') {
res.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
@ -30,7 +24,7 @@ function forceSSL(req, res) {
}
export function middleware(req) {
const fns = [customScriptName, disableLogin];
const fns = [customScriptName];
for (const fn of fns) {
const res = fn(req);

View file

@ -1,9 +1,10 @@
const { Resolver } = require('dns').promises;
import isbot from 'isbot';
import ipaddr from 'ipaddr.js';
import { savePageView, saveEvent } from 'lib/queries';
import { useCors, useSession } from 'lib/middleware';
import { getJsonBody, getIpAddress } from 'lib/request';
import { ok, send, badRequest } from 'lib/response';
import { ok, send, badRequest, forbidden } from 'lib/response';
import { createToken } from 'lib/crypto';
import { removeTrailingSlash } from 'lib/url';
@ -15,16 +16,35 @@ export default async (req, res) => {
}
const ignoreIps = process.env.IGNORE_IP;
if (ignoreIps) {
const ips = ignoreIps.split(',').map(n => n.trim());
const ip = getIpAddress(req);
const blocked = ips.find(i => {
if (i === ip) return true;
const ignoreHostnames = process.env.IGNORE_HOSTNAME;
if (ignoreIps || ignoreHostnames) {
const ips = [];
if (ignoreIps) {
ips.push(...ignoreIps.split(',').map(n => n.trim()));
}
if (ignoreHostnames) {
const resolver = new Resolver();
const promises = ignoreHostnames
.split(',')
.map(n => resolver.resolve4(n.trim()).catch(() => {}));
await Promise.all(promises).then(resolvedIps => {
ips.push(...resolvedIps.filter(n => n).flatMap(n => n));
});
}
const clientIp = getIpAddress(req);
const blocked = ips.find(ip => {
if (ip === clientIp) return true;
// CIDR notation
if (i.indexOf('/') > 0) {
const addr = ipaddr.parse(ip);
const range = ipaddr.parseCIDR(i);
if (ip.indexOf('/') > 0) {
const addr = ipaddr.parse(clientIp);
const range = ipaddr.parseCIDR(ip);
if (addr.kind() === range[0].kind() && addr.match(range)) return true;
}
@ -33,7 +53,7 @@ export default async (req, res) => {
});
if (blocked) {
return ok(res);
return forbidden(res);
}
}

View file

@ -2,7 +2,7 @@ import { useAuth } from 'lib/middleware';
import { ok, methodNotAllowed, badRequest } from 'lib/response';
import { getRealtimeData } from 'lib/queries';
import { parseToken } from 'lib/crypto';
import { TOKEN_HEADER } from 'lib/constants';
import { SHARE_TOKEN_HEADER } from 'lib/constants';
export default async (req, res) => {
await useAuth(req, res);
@ -10,7 +10,7 @@ export default async (req, res) => {
if (req.method === 'GET') {
const { start_at } = req.query;
const token = req.headers[TOKEN_HEADER];
const token = req.headers[SHARE_TOKEN_HEADER];
if (!token) {
return badRequest(res);

View file

@ -83,12 +83,13 @@ export default async (req, res) => {
const data = await getPageviewMetrics(websiteId, startDate, endDate, column, table, {
domain,
url: type !== 'url' ? url : undefined,
url: type !== 'url' && table !== 'event' ? url : undefined,
referrer: type !== 'referrer' ? referrer : undefined,
os: type !== 'os' ? os : undefined,
browser: type !== 'browser' ? browser : undefined,
device: type !== 'device' ? device : undefined,
country: type !== 'country' ? country : undefined,
event_url: type !== 'url' && table === 'event' ? url : undefined,
});
return ok(res, data);

View file

@ -3,7 +3,7 @@ import Layout from 'components/layout/Layout';
import LoginForm from 'components/forms/LoginForm';
export default function LoginPage() {
if (process.env.DISABLE_LOGIN) {
if (process.env.loginDisabled) {
return null;
}

View file

@ -1 +0,0 @@
../schema.mysql.prisma

View file

@ -1 +0,0 @@
../schema.postgresql.prisma

View file

@ -1,29 +0,0 @@
const bcrypt = require('bcryptjs');
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const SALT_ROUNDS = 10;
const hashPassword = password => {
return bcrypt.hashSync(password, SALT_ROUNDS);
};
async function main() {
await prisma.account.upsert({
where: { username: 'admin' },
update: {},
create: {
username: 'admin',
password: hashPassword(process.env.ADMIN_PASSWORD || 'umami'),
is_admin: true,
},
});
}
main()
.catch(e => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View file

@ -0,0 +1 @@
404: Not Found

View file

@ -0,0 +1 @@
404: Not Found

View file

@ -176,7 +176,7 @@
"label.language": [
{
"type": 0,
"value": "Language"
"value": "Sprache"
}
],
"label.last-days": [
@ -334,7 +334,7 @@
"label.theme": [
{
"type": 0,
"value": "Theme"
"value": "Thema"
}
],
"label.this-month": [
@ -704,7 +704,7 @@
"metrics.device.mobile": [
{
"type": 0,
"value": "Mobiltelefon"
"value": "Handy"
}
],
"metrics.device.tablet": [

View file

@ -38,7 +38,7 @@
"label.all-time": [
{
"type": 0,
"value": "All time"
"value": "همه زمان"
}
],
"label.all-websites": [
@ -176,7 +176,7 @@
"label.language": [
{
"type": 0,
"value": "Language"
"value": "زبان"
}
],
"label.last-days": [
@ -190,7 +190,7 @@
},
{
"type": 0,
"value": " روز"
"value": " روز گذشته"
}
],
"label.last-hours": [
@ -204,7 +204,7 @@
},
{
"type": 0,
"value": " ساعت"
"value": " ساعت گذشته"
}
],
"label.logged-in-as": [
@ -250,7 +250,7 @@
"label.owner": [
{
"type": 0,
"value": "Owner"
"value": "ایجاد شده توسط"
}
],
"label.password": [
@ -304,7 +304,7 @@
"label.reset-website": [
{
"type": 0,
"value": "Reset statistics"
"value": "بازنشانی آمار"
}
],
"label.save": [
@ -334,7 +334,7 @@
"label.theme": [
{
"type": 0,
"value": "Theme"
"value": "تم"
}
],
"label.this-month": [
@ -448,7 +448,7 @@
"message.confirm-reset": [
{
"type": 0,
"value": "Are your sure you want to reset "
"value": "آیا از بازنشانی آمار "
},
{
"type": 1,
@ -456,7 +456,7 @@
},
{
"type": 0,
"value": "'s statistics?"
"value": " مطمئن هستید?"
}
],
"message.copied": [
@ -580,7 +580,7 @@
"message.reset-warning": [
{
"type": 0,
"value": "All statistics for this website will be deleted, but your tracking code will remain intact."
"value": "تمامی آمارهای این وب‌سایت حذف خواهد شد اما tracking code بدون تغییر باقی می‌ماند."
}
],
"message.save-success": [
@ -730,7 +730,7 @@
"metrics.languages": [
{
"type": 0,
"value": "Languages"
"value": "زبان‌ها"
}
],
"metrics.operating-systems": [

View file

@ -38,7 +38,7 @@
"label.all-time": [
{
"type": 0,
"value": "Toutes périodes"
"value": "Toutes les données"
}
],
"label.all-websites": [
@ -86,7 +86,7 @@
"label.custom-range": [
{
"type": 0,
"value": "Intervalle personnalisé"
"value": "Période personnalisée"
}
],
"label.dashboard": [
@ -98,13 +98,13 @@
"label.date-range": [
{
"type": 0,
"value": "Intervalle"
"value": "Période"
}
],
"label.default-date-range": [
{
"type": 0,
"value": "Intervalle par défaut"
"value": "Période par défaut"
}
],
"label.delete": [
@ -158,7 +158,7 @@
"label.enable-share-url": [
{
"type": 0,
"value": "Activer le partage d'URL"
"value": "Activer l'URL de partage"
}
],
"label.invalid": [
@ -476,13 +476,13 @@
"message.get-share-url": [
{
"type": 0,
"value": "Obtenez l'URL de partage"
"value": "Obtenir l'URL de partage"
}
],
"message.get-tracking-code": [
{
"type": 0,
"value": "Obtenez le code de suivi"
"value": "Obtenir le code de suivi"
}
],
"message.go-to-settings": [
@ -494,7 +494,7 @@
"message.incorrect-username-password": [
{
"type": 0,
"value": "nom d'utilisateurs/mot de passe incorrect."
"value": "Nom d'utilisateur/Mot de passe incorrect."
}
],
"message.log.visitor": [
@ -602,7 +602,7 @@
"message.toggle-charts": [
{
"type": 0,
"value": "Changer les graphiques"
"value": "Afficher/Masquer les graphiques"
}
],
"message.track-stats": [
@ -736,7 +736,7 @@
"metrics.filter.raw": [
{
"type": 0,
"value": "Brute"
"value": "Brut"
}
],
"metrics.languages": [
@ -766,7 +766,7 @@
"metrics.referrers": [
{
"type": 0,
"value": "URL Référentes"
"value": "Sources"
}
],
"metrics.unique-visitors": [

View file

@ -0,0 +1,794 @@
{
"label.accounts": [
{
"type": 0,
"value": "Contas"
}
],
"label.add-account": [
{
"type": 0,
"value": "Engadir conta"
}
],
"label.add-website": [
{
"type": 0,
"value": "Engadir sitio web"
}
],
"label.administrator": [
{
"type": 0,
"value": "Administradora"
}
],
"label.all": [
{
"type": 0,
"value": "Todo"
}
],
"label.all-events": [
{
"type": 0,
"value": "Tódolos eventos"
}
],
"label.all-time": [
{
"type": 0,
"value": "Sempre"
}
],
"label.all-websites": [
{
"type": 0,
"value": "Tódolos sitios web"
}
],
"label.back": [
{
"type": 0,
"value": "Atrás"
}
],
"label.cancel": [
{
"type": 0,
"value": "Cancelar"
}
],
"label.change-password": [
{
"type": 0,
"value": "Mudar contrasinal"
}
],
"label.confirm-password": [
{
"type": 0,
"value": "Confirmar contrasinal"
}
],
"label.copy-to-clipboard": [
{
"type": 0,
"value": "Copiar ao portapapeis"
}
],
"label.current-password": [
{
"type": 0,
"value": "Contrasinal actual"
}
],
"label.custom-range": [
{
"type": 0,
"value": "Rango personalizado"
}
],
"label.dashboard": [
{
"type": 0,
"value": "Taboleiro"
}
],
"label.date-range": [
{
"type": 0,
"value": "Rango temporal"
}
],
"label.default-date-range": [
{
"type": 0,
"value": "Rango temporal por defecto"
}
],
"label.delete": [
{
"type": 0,
"value": "Eliminar"
}
],
"label.delete-account": [
{
"type": 0,
"value": "Eliminar conta"
}
],
"label.delete-website": [
{
"type": 0,
"value": "Eliminar sitio web"
}
],
"label.dismiss": [
{
"type": 0,
"value": "Desbotar"
}
],
"label.domain": [
{
"type": 0,
"value": "Dominio"
}
],
"label.edit": [
{
"type": 0,
"value": "Editar"
}
],
"label.edit-account": [
{
"type": 0,
"value": "Editar conta"
}
],
"label.edit-website": [
{
"type": 0,
"value": "Editar sitio web"
}
],
"label.enable-share-url": [
{
"type": 0,
"value": "Activar URL de compartición"
}
],
"label.invalid": [
{
"type": 0,
"value": "Non válido"
}
],
"label.invalid-domain": [
{
"type": 0,
"value": "Dominio non válido"
}
],
"label.language": [
{
"type": 0,
"value": "Idioma"
}
],
"label.last-days": [
{
"type": 0,
"value": "Últimos "
},
{
"type": 1,
"value": "x"
},
{
"type": 0,
"value": " días"
}
],
"label.last-hours": [
{
"type": 0,
"value": "Últimas "
},
{
"type": 1,
"value": "x"
},
{
"type": 0,
"value": " horas"
}
],
"label.logged-in-as": [
{
"type": 0,
"value": "Sesión de "
},
{
"type": 1,
"value": "username"
}
],
"label.login": [
{
"type": 0,
"value": "Acceder"
}
],
"label.logout": [
{
"type": 0,
"value": "Pechar sesión"
}
],
"label.more": [
{
"type": 0,
"value": "Máis"
}
],
"label.name": [
{
"type": 0,
"value": "Nome"
}
],
"label.new-password": [
{
"type": 0,
"value": "Novo contrasinal"
}
],
"label.owner": [
{
"type": 0,
"value": "Dona"
}
],
"label.password": [
{
"type": 0,
"value": "Contrasinal"
}
],
"label.passwords-dont-match": [
{
"type": 0,
"value": "Non concordan os contrasinais"
}
],
"label.profile": [
{
"type": 0,
"value": "Perfil"
}
],
"label.realtime": [
{
"type": 0,
"value": "Agora mesmo"
}
],
"label.realtime-logs": [
{
"type": 0,
"value": "Rexistro neste intre"
}
],
"label.refresh": [
{
"type": 0,
"value": "Actualizar"
}
],
"label.required": [
{
"type": 0,
"value": "Requerido"
}
],
"label.reset": [
{
"type": 0,
"value": "Restablecer"
}
],
"label.reset-website": [
{
"type": 0,
"value": "Restablecer estatísticas"
}
],
"label.save": [
{
"type": 0,
"value": "Gardar"
}
],
"label.settings": [
{
"type": 0,
"value": "Axustes"
}
],
"label.share-url": [
{
"type": 0,
"value": "Compartir URL"
}
],
"label.single-day": [
{
"type": 0,
"value": "Un só día"
}
],
"label.theme": [
{
"type": 0,
"value": "Decorado"
}
],
"label.this-month": [
{
"type": 0,
"value": "Este mes"
}
],
"label.this-week": [
{
"type": 0,
"value": "Esta semana"
}
],
"label.this-year": [
{
"type": 0,
"value": "Este ano"
}
],
"label.timezone": [
{
"type": 0,
"value": "Zona horaria"
}
],
"label.today": [
{
"type": 0,
"value": "Hoxe"
}
],
"label.tracking-code": [
{
"type": 0,
"value": "Código de seguimento"
}
],
"label.unknown": [
{
"type": 0,
"value": "Descoñecido"
}
],
"label.username": [
{
"type": 0,
"value": "Identificador"
}
],
"label.view-details": [
{
"type": 0,
"value": "Ver detalles"
}
],
"label.websites": [
{
"type": 0,
"value": "Sitios web"
}
],
"message.active-users": [
{
"type": 1,
"value": "x"
},
{
"type": 0,
"value": " actual "
},
{
"offset": 0,
"options": {
"one": {
"value": [
{
"type": 0,
"value": "visitante"
}
]
},
"other": {
"value": [
{
"type": 0,
"value": "visitantes"
}
]
}
},
"pluralType": "cardinal",
"type": 6,
"value": "x"
}
],
"message.confirm-delete": [
{
"type": 0,
"value": "Tes a certeza de querer eliminar "
},
{
"type": 1,
"value": "target"
},
{
"type": 0,
"value": "?"
}
],
"message.confirm-reset": [
{
"type": 0,
"value": "Tes a certeza de querer restablecer as estatísticas de "
},
{
"type": 1,
"value": "target"
},
{
"type": 0,
"value": "?"
}
],
"message.copied": [
{
"type": 0,
"value": "Copiado!"
}
],
"message.delete-warning": [
{
"type": 0,
"value": "Tamén serán borrados tódolos datos asociados."
}
],
"message.failure": [
{
"type": 0,
"value": "Houbo un fallo."
}
],
"message.get-share-url": [
{
"type": 0,
"value": "Obter URL de compartición"
}
],
"message.get-tracking-code": [
{
"type": 0,
"value": "Obter código de seguimento"
}
],
"message.go-to-settings": [
{
"type": 0,
"value": "Ir aos axustes"
}
],
"message.incorrect-username-password": [
{
"type": 0,
"value": "Credenciais incorrectas."
}
],
"message.log.visitor": [
{
"type": 0,
"value": "Visitante desde "
},
{
"type": 1,
"value": "country"
},
{
"type": 0,
"value": " usando "
},
{
"type": 1,
"value": "browser"
},
{
"type": 0,
"value": " en "
},
{
"type": 1,
"value": "os"
},
{
"type": 0,
"value": " "
},
{
"type": 1,
"value": "device"
}
],
"message.new-version-available": [
{
"type": 0,
"value": "A nova versión "
},
{
"type": 1,
"value": "version"
},
{
"type": 0,
"value": " de umami está dispoñible!"
}
],
"message.no-data-available": [
{
"type": 0,
"value": "Sen datos dispoñibles."
}
],
"message.no-websites-configured": [
{
"type": 0,
"value": "Non tes sitios web configurados."
}
],
"message.page-not-found": [
{
"type": 0,
"value": "Páxina non atopada."
}
],
"message.powered-by": [
{
"type": 0,
"value": "Funciona grazas a "
},
{
"type": 1,
"value": "name"
}
],
"message.reset-warning": [
{
"type": 0,
"value": "Vanse eliminar tódalas estatísticas deste sitio web, pero o código de seguimento permanecerá sen cambios."
}
],
"message.save-success": [
{
"type": 0,
"value": "Gardouse correctamente."
}
],
"message.share-url": [
{
"type": 0,
"value": "Este é o URL da compartición pública de "
},
{
"type": 1,
"value": "target"
},
{
"type": 0,
"value": "."
}
],
"message.toggle-charts": [
{
"type": 0,
"value": "Activación das gráficas"
}
],
"message.track-stats": [
{
"type": 0,
"value": "Para crear estatísticas de "
},
{
"type": 1,
"value": "target"
},
{
"type": 0,
"value": ", pon este código na sección "
},
{
"type": 1,
"value": "head"
},
{
"type": 0,
"value": " do teu sitio web."
}
],
"message.type-delete": [
{
"type": 0,
"value": "Escribe "
},
{
"type": 1,
"value": "delete"
},
{
"type": 0,
"value": " na caixa inferior para confirmar."
}
],
"message.type-reset": [
{
"type": 0,
"value": "Escribe "
},
{
"type": 1,
"value": "reset"
},
{
"type": 0,
"value": " na caixa inferior para confirmar."
}
],
"metrics.actions": [
{
"type": 0,
"value": "Accións"
}
],
"metrics.average-visit-time": [
{
"type": 0,
"value": "Tempo medio de visita"
}
],
"metrics.bounce-rate": [
{
"type": 0,
"value": "Proporción de rebote"
}
],
"metrics.browsers": [
{
"type": 0,
"value": "Navegadores"
}
],
"metrics.countries": [
{
"type": 0,
"value": "Países"
}
],
"metrics.device.desktop": [
{
"type": 0,
"value": "Escritorio"
}
],
"metrics.device.laptop": [
{
"type": 0,
"value": "Portátil"
}
],
"metrics.device.mobile": [
{
"type": 0,
"value": "Móbil"
}
],
"metrics.device.tablet": [
{
"type": 0,
"value": "Tableta"
}
],
"metrics.devices": [
{
"type": 0,
"value": "Dispositivos"
}
],
"metrics.events": [
{
"type": 0,
"value": "Eventos"
}
],
"metrics.filter.combined": [
{
"type": 0,
"value": "Combinado"
}
],
"metrics.filter.domain-only": [
{
"type": 0,
"value": "Só dominio"
}
],
"metrics.filter.raw": [
{
"type": 0,
"value": "Raw"
}
],
"metrics.languages": [
{
"type": 0,
"value": "Idiomas"
}
],
"metrics.operating-systems": [
{
"type": 0,
"value": "Sistemas operativos"
}
],
"metrics.page-views": [
{
"type": 0,
"value": "Vistas de páxinas"
}
],
"metrics.pages": [
{
"type": 0,
"value": "Páxinas"
}
],
"metrics.referrers": [
{
"type": 0,
"value": "Orixes"
}
],
"metrics.unique-visitors": [
{
"type": 0,
"value": "Visitas únicas"
}
],
"metrics.views": [
{
"type": 0,
"value": "Visualizacións"
}
],
"metrics.visitors": [
{
"type": 0,
"value": "Visitantes"
}
]
}

View file

@ -80,7 +80,7 @@
"label.current-password": [
{
"type": 0,
"value": "Password corrente"
"value": "Password attuale"
}
],
"label.custom-range": [
@ -176,7 +176,7 @@
"label.language": [
{
"type": 0,
"value": "Language"
"value": "Lingua"
}
],
"label.last-days": [
@ -334,7 +334,7 @@
"label.theme": [
{
"type": 0,
"value": "Theme"
"value": "Tema"
}
],
"label.this-month": [
@ -452,7 +452,7 @@
"message.confirm-reset": [
{
"type": 0,
"value": "Are your sure you want to reset "
"value": "Sei sicuro di voler azzerare le statistiche di "
},
{
"type": 1,
@ -460,7 +460,7 @@
},
{
"type": 0,
"value": "'s statistics?"
"value": "?"
}
],
"message.copied": [
@ -542,7 +542,7 @@
"message.new-version-available": [
{
"type": 0,
"value": "Una nuova versione umami "
"value": "Una nuova versione "
},
{
"type": 1,
@ -550,7 +550,7 @@
},
{
"type": 0,
"value": " è disponibile!"
"value": " di umami è disponibile!"
}
],
"message.no-data-available": [
@ -774,7 +774,7 @@
"metrics.referrers": [
{
"type": 0,
"value": "Referr"
"value": "Referrers"
}
],
"metrics.unique-visitors": [

View file

@ -38,7 +38,7 @@
"label.all-time": [
{
"type": 0,
"value": "All time"
"value": "Бүх цаг үеийн"
}
],
"label.all-websites": [
@ -176,7 +176,7 @@
"label.language": [
{
"type": 0,
"value": "Language"
"value": "Хэл"
}
],
"label.last-days": [
@ -250,7 +250,7 @@
"label.owner": [
{
"type": 0,
"value": "Owner"
"value": "Эзэмшигч"
}
],
"label.password": [
@ -304,7 +304,7 @@
"label.reset-website": [
{
"type": 0,
"value": "Reset statistics"
"value": "Тоон үзүүлэлтийг дахин эхлүүлэх"
}
],
"label.save": [
@ -334,7 +334,7 @@
"label.theme": [
{
"type": 0,
"value": "Theme"
"value": "Загвар"
}
],
"label.this-month": [
@ -588,7 +588,7 @@
"message.reset-warning": [
{
"type": 0,
"value": "All statistics for this website will be deleted, but your tracking code will remain intact."
"value": "Энэ вебийн бүх тоон үзүүлэлтүүдийг устгах болно. Гэхдээ мөрдөх код хэвэндээ үлдэнэ."
}
],
"message.save-success": [
@ -610,7 +610,7 @@
"message.toggle-charts": [
{
"type": 0,
"value": "Toggle charts"
"value": "Графикийг харуулах/нуух"
}
],
"message.track-stats": [
@ -746,7 +746,7 @@
"metrics.languages": [
{
"type": 0,
"value": "Languages"
"value": "Хэл"
}
],
"metrics.operating-systems": [

View file

@ -32,13 +32,13 @@
"label.all-events": [
{
"type": 0,
"value": "All events"
"value": "Todos os eventos"
}
],
"label.all-time": [
{
"type": 0,
"value": "All time"
"value": "Todo o período"
}
],
"label.all-websites": [
@ -176,7 +176,7 @@
"label.language": [
{
"type": 0,
"value": "Language"
"value": "Idioma"
}
],
"label.last-days": [
@ -250,7 +250,7 @@
"label.owner": [
{
"type": 0,
"value": "Owner"
"value": "Proprietário"
}
],
"label.password": [
@ -304,7 +304,7 @@
"label.reset-website": [
{
"type": 0,
"value": "Reset statistics"
"value": "Redefinir estatísticas"
}
],
"label.save": [
@ -334,7 +334,7 @@
"label.theme": [
{
"type": 0,
"value": "Theme"
"value": "Tema"
}
],
"label.this-month": [
@ -452,7 +452,7 @@
"message.confirm-reset": [
{
"type": 0,
"value": "Are your sure you want to reset "
"value": "Você tem certeza que deseja redefinir as estatísticas de "
},
{
"type": 1,
@ -460,7 +460,7 @@
},
{
"type": 0,
"value": "'s statistics?"
"value": "?"
}
],
"message.copied": [
@ -584,7 +584,7 @@
"message.reset-warning": [
{
"type": 0,
"value": "All statistics for this website will be deleted, but your tracking code will remain intact."
"value": "Todas as estatísticas deste site serão removidas, mas seu código de rastreamento permanecerá intacto."
}
],
"message.save-success": [
@ -610,7 +610,7 @@
"message.toggle-charts": [
{
"type": 0,
"value": "Toggle charts"
"value": "Mostrar/Esconder gráficos"
}
],
"message.track-stats": [
@ -750,7 +750,7 @@
"metrics.languages": [
{
"type": 0,
"value": "Languages"
"value": "Idiomas"
}
],
"metrics.operating-systems": [

View file

@ -176,7 +176,7 @@
"label.language": [
{
"type": 0,
"value": "Language"
"value": "Язык"
}
],
"label.last-days": [
@ -334,7 +334,7 @@
"label.theme": [
{
"type": 0,
"value": "Theme"
"value": "Тема"
}
],
"label.this-month": [

View file

@ -20,7 +20,7 @@
"label.administrator": [
{
"type": 0,
"value": "Quản Trị"
"value": "Quản trị"
}
],
"label.all": [
@ -32,19 +32,19 @@
"label.all-events": [
{
"type": 0,
"value": "Tất cả events"
"value": "Tất cả sự kiện"
}
],
"label.all-time": [
{
"type": 0,
"value": "All time"
"value": "Toàn thời gian"
}
],
"label.all-websites": [
{
"type": 0,
"value": "Tất cả websites"
"value": "Tất cả website"
}
],
"label.back": [
@ -104,7 +104,7 @@
"label.default-date-range": [
{
"type": 0,
"value": "Phạm vi ngày mặc định"
"value": "Khoảng thời gian mặc định"
}
],
"label.delete": [
@ -242,7 +242,7 @@
"label.owner": [
{
"type": 0,
"value": "Owner"
"value": "Chủ nhân"
}
],
"label.password": [
@ -590,7 +590,7 @@
"message.toggle-charts": [
{
"type": 0,
"value": "Toggle charts"
"value": "Bật/tắt biểu đồ"
}
],
"message.track-stats": [
@ -730,7 +730,7 @@
"metrics.languages": [
{
"type": 0,
"value": "Languages"
"value": "Ngôn ngũ"
}
],
"metrics.operating-systems": [
@ -760,7 +760,7 @@
"metrics.unique-visitors": [
{
"type": 0,
"value": "Khách truy cập duy nhất"
"value": "Khách truy cập một lần"
}
],
"metrics.views": [

100
scripts/check-db.js Normal file
View file

@ -0,0 +1,100 @@
require('dotenv').config();
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const chalk = require('chalk');
const spawn = require('cross-spawn');
let message = '';
const updateMessage = `To update your database, you need to run:\n${chalk.bold.whiteBright(
'yarn update-db',
)}`;
const baselineMessage = cmd =>
`You need to update your database by running:\n${chalk.bold.whiteBright(cmd)}`;
function success(msg) {
console.log(chalk.greenBright(`${msg}`));
}
async function checkEnv() {
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is not defined.');
} else {
success('DATABASE_URL is defined.');
}
}
async function checkConnection() {
try {
await prisma.$connect();
success('Database connection successful.');
} catch (e) {
throw new Error('Unable to connect to the database.');
}
}
async function checkTables() {
try {
await prisma.account.findFirst();
success('Database tables found.');
} catch (e) {
message = updateMessage;
throw new Error('Database tables not found.');
}
}
async function run(cmd, args) {
const buffer = [];
const proc = spawn(cmd, args);
return new Promise((resolve, reject) => {
proc.stdout.on('data', data => buffer.push(data));
proc.on('error', () => {
reject(new Error('Failed to run Prisma.'));
});
proc.on('exit', () => resolve(buffer.join('')));
});
}
async function checkMigrations() {
const output = await run('prisma', ['migrate', 'status']);
const missingMigrations = output.includes('Following migration have not yet been applied');
const notManaged = output.includes('The current database is not managed');
if (notManaged) {
const cmd = output.match(/yarn prisma migrate resolve --applied ".*"/g);
message = baselineMessage(cmd[0]);
throw new Error('Database is out of date.');
} else if (missingMigrations) {
message = updateMessage;
throw new Error('Database is out of date.');
}
success('Database is up to date.');
}
(async () => {
let err = false;
for (let fn of [checkEnv, checkConnection, checkTables, checkMigrations]) {
try {
await fn();
} catch (e) {
console.log(chalk.red(`${e.message}`));
err = true;
} finally {
await prisma.$disconnect();
if (err) {
console.log(message);
process.exit(1);
}
}
}
})();

View file

@ -1,8 +1,9 @@
require('dotenv').config();
const fs = require('fs');
const fse = require('fs-extra');
const path = require('path');
const del = require('del');
function getDatabase() {
function getDatabaseType() {
const type =
process.env.DATABASE_TYPE ||
(process.env.DATABASE_URL && process.env.DATABASE_URL.split(':')[0]);
@ -14,7 +15,7 @@ function getDatabase() {
return type;
}
const databaseType = getDatabase();
const databaseType = getDatabaseType();
if (!databaseType || !['mysql', 'postgresql'].includes(databaseType)) {
throw new Error('Missing or invalid database');
@ -22,9 +23,11 @@ if (!databaseType || !['mysql', 'postgresql'].includes(databaseType)) {
console.log(`Database type detected: ${databaseType}`);
const src = path.resolve(__dirname, `../prisma/schema.${databaseType}.prisma`);
const dest = path.resolve(__dirname, '../prisma/schema.prisma');
const src = path.resolve(__dirname, `../db/${databaseType}`);
const dest = path.resolve(__dirname, '../prisma');
fs.copyFileSync(src, dest);
del.sync(dest);
fse.copySync(src, dest);
console.log(`Copied ${src} to ${dest}`);

View file

@ -4,7 +4,7 @@ const https = require('https');
const chalk = require('chalk');
const src = path.resolve(__dirname, '../lang');
const dest = path.resolve(__dirname, '../public/country');
const dest = path.resolve(__dirname, '../public/intl/country');
const files = fs.readdirSync(src);
const getUrl = locale =>

View file

@ -4,7 +4,7 @@ const https = require('https');
const chalk = require('chalk');
const src = path.resolve(__dirname, '../lang');
const dest = path.resolve(__dirname, '../public/language');
const dest = path.resolve(__dirname, '../public/intl/language');
const files = fs.readdirSync(src);
const getUrl = locale =>

View file

@ -71,4 +71,4 @@ create index event_created_at_idx on event(created_at);
create index event_website_id_idx on event(website_id);
create index event_session_id_idx on event(session_id);
insert into account (username, password, is_admin) values ('admin', '$2b$10$BUli0c.muyCW1ErNJc3jL.vFRFtFJWrT8/GcR4A.sUdCznaXiqFXa', true);
insert into account (username, password, is_admin) values ('admin', '$2b$10$BUli0c.muyCW1ErNJc3jL.vFRFtFJWrT8/GcR4A.sUdCznaXiqFXa', true);

View file

@ -4,12 +4,13 @@ import semver from 'semver';
import { VERSION_CHECK } from 'lib/constants';
import { getItem } from 'lib/web';
const REPO_URL = 'https://api.github.com/repos/mikecao/umami/releases/latest';
const UPDATES_URL = 'https://api.umami.is/v1/updates';
const initialState = {
current: process.env.VERSION,
current: process.env.currentVersion,
latest: null,
hasUpdate: false,
checked: false,
};
const store = create(() => ({ ...initialState }));
@ -17,10 +18,10 @@ const store = create(() => ({ ...initialState }));
export async function checkVersion() {
const { current } = store.getState();
const data = await fetch(REPO_URL, {
method: 'get',
const data = await fetch(`${UPDATES_URL}?v=${current}`, {
method: 'GET',
headers: {
Accept: 'application/vnd.github.v3+json',
Accept: 'application/json',
},
}).then(res => {
if (res.ok) {
@ -36,15 +37,15 @@ export async function checkVersion() {
store.setState(
produce(state => {
const { tag_name } = data;
const latest = tag_name.startsWith('v') ? tag_name.slice(1) : tag_name;
const { latest } = data;
const lastCheck = getItem(VERSION_CHECK);
const hasUpdate = latest && semver.gt(latest, current) && lastCheck?.version !== latest;
const hasUpdate = !!(latest && lastCheck?.version !== latest && semver.gt(latest, current));
state.current = current;
state.latest = latest;
state.hasUpdate = hasUpdate;
state.checked = true;
return state;
}),

View file

@ -122,7 +122,11 @@ import { removeTrailingSlash } from '../lib/url';
payload,
});
navigator.sendBeacon(`${root}/api/collect`, data);
fetch(`${root}/api/collect`, {
method: 'POST',
body: data,
keepalive: true,
});
};
const addEvents = node => {

1414
yarn.lock

File diff suppressed because it is too large Load diff