mirror of
https://github.com/umami-software/umami.git
synced 2025-12-06 01:18:00 +01:00
Updated realtime page.
This commit is contained in:
parent
8aa4192576
commit
ac9edb8b5f
11 changed files with 131 additions and 277 deletions
|
|
@ -1,5 +0,0 @@
|
|||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import { useCallback } from 'react';
|
||||
import { IconLabel } from '@umami/react-zen';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { useLocale, useCountryNames, useMessages } from '@/components/hooks';
|
||||
import classNames from 'classnames';
|
||||
import styles from './RealtimeCountries.module.css';
|
||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||
|
||||
export function RealtimeCountries({ data }) {
|
||||
|
|
@ -12,10 +11,7 @@ export function RealtimeCountries({ data }) {
|
|||
|
||||
const renderCountryName = useCallback(
|
||||
({ label: code }) => (
|
||||
<span className={classNames(styles.row)}>
|
||||
<TypeIcon type="country" value={code} />
|
||||
{countryNames[code]}
|
||||
</span>
|
||||
<IconLabel icon={<TypeIcon type="country" value={code} />} label={countryNames[code]} />
|
||||
),
|
||||
[countryNames, locale],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.card {
|
||||
flex-basis: calc(50% - 20px);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { RealtimeData } from '@/lib/types';
|
||||
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||
|
||||
export function RealtimeHeader({ data }: { data: RealtimeData }) {
|
||||
export function RealtimeHeader({ data }: { data: any }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { totals }: any = data || {};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
.table {
|
||||
font-size: var(--font-size-sm);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 40px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 50px;
|
||||
border-bottom: 1px solid var(--base300);
|
||||
}
|
||||
|
||||
.body {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
.time {
|
||||
min-width: 60px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.row .link {
|
||||
color: var(--base900);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.row .link:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.search {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { SearchField, Text, Column, Row, IconLabel, Heading } from '@umami/react-zen';
|
||||
import Link from 'next/link';
|
||||
import { useFormat } from '@/components//hooks/useFormat';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||
|
|
@ -5,17 +9,15 @@ import {
|
|||
useCountryNames,
|
||||
useLocale,
|
||||
useMessages,
|
||||
useNavigation,
|
||||
useTimezone,
|
||||
useWebsite,
|
||||
} from '@/components/hooks';
|
||||
import { Eye, User } from '@/components/icons';
|
||||
import { Lightning } from '@/components/svg';
|
||||
import { BROWSERS, OS_NAMES } from '@/lib/constants';
|
||||
import { stringToColor } from '@/lib/format';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Icon, SearchField, StatusLight, Text } from '@umami/react-zen';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import styles from './RealtimeLog.module.css';
|
||||
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
|
||||
import { Avatar } from '@/components/common/Avatar';
|
||||
|
||||
const TYPE_ALL = 'all';
|
||||
const TYPE_PAGEVIEW = 'pageview';
|
||||
|
|
@ -37,6 +39,7 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
const { formatTimezoneDate } = useTimezone();
|
||||
const { countryNames } = useCountryNames(locale);
|
||||
const [filter, setFilter] = useState(TYPE_ALL);
|
||||
const { updateParams } = useNavigation();
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
|
|
@ -59,8 +62,6 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
|
||||
const getTime = ({ createdAt, firstAt }) => formatTimezoneDate(firstAt || createdAt, 'pp');
|
||||
|
||||
const getColor = ({ id, sessionId }) => stringToColor(sessionId || id);
|
||||
|
||||
const getIcon = ({ __type }) => icons[__type];
|
||||
|
||||
const getDetail = (log: {
|
||||
|
|
@ -84,7 +85,6 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
<a
|
||||
key="a"
|
||||
href={`//${website?.domain}${urlPath}`}
|
||||
className={styles.link}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
|
|
@ -98,12 +98,7 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
|
||||
if (__type === TYPE_PAGEVIEW) {
|
||||
return (
|
||||
<a
|
||||
href={`//${website?.domain}${urlPath}`}
|
||||
className={styles.link}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener">
|
||||
{urlPath}
|
||||
</a>
|
||||
);
|
||||
|
|
@ -124,19 +119,18 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
}
|
||||
};
|
||||
|
||||
const Row = ({ index, style }) => {
|
||||
const TableRow = ({ index, style }) => {
|
||||
const row = logs[index];
|
||||
return (
|
||||
<div className={styles.row} style={style}>
|
||||
<div>
|
||||
<StatusLight color={getColor(row)} />
|
||||
</div>
|
||||
<div className={styles.time}>{getTime(row)}</div>
|
||||
<div className={styles.detail}>
|
||||
<Icon className={styles.icon}>{getIcon(row)}</Icon>
|
||||
<Row alignItems="center" style={style} gap>
|
||||
<Link href={updateParams({ session: row.sessionId })}>
|
||||
<Avatar seed={row.sessionId} size={32} />
|
||||
</Link>
|
||||
<Row width="100px">{getTime(row)}</Row>
|
||||
<IconLabel icon={getIcon(row)}>
|
||||
<Text>{getDetail(row)}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</IconLabel>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -172,20 +166,21 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
}, [data, filter, formatValue, search]);
|
||||
|
||||
return (
|
||||
<div className={styles.table}>
|
||||
<div className={styles.actions}>
|
||||
<SearchField className={styles.search} value={search} onSearch={setSearch} />
|
||||
<Column gap>
|
||||
<Heading size="2">{formatMessage(labels.activity)}</Heading>
|
||||
<Row alignItems="center" justifyContent="space-between">
|
||||
<SearchField value={search} onSearch={setSearch} />
|
||||
<FilterButtons items={buttons} value={filter} onChange={setFilter} />
|
||||
</div>
|
||||
<div className={styles.header}>{formatMessage(labels.activity)}</div>
|
||||
<div className={styles.body}>
|
||||
</Row>
|
||||
<Column>
|
||||
{logs?.length === 0 && <Empty />}
|
||||
{logs?.length > 0 && (
|
||||
<FixedSizeList width="100%" height={500} itemCount={logs.length} itemSize={50}>
|
||||
{Row}
|
||||
{TableRow}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Column>
|
||||
<SessionModal websiteId={website.id} />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import { WorldMap } from '@/components/metrics/WorldMap';
|
|||
import { useRealtimeQuery } from '@/components/hooks';
|
||||
import { RealtimeLog } from './RealtimeLog';
|
||||
import { RealtimeHeader } from './RealtimeHeader';
|
||||
import { RealtimeUrls } from './RealtimeUrls';
|
||||
import { RealtimePaths } from './RealtimePaths';
|
||||
import { RealtimeReferrers } from './RealtimeReferrers';
|
||||
import { RealtimeCountries } from './RealtimeCountries';
|
||||
import { percentFilter } from '@/lib/filters';
|
||||
|
||||
|
|
@ -32,12 +33,15 @@ export function RealtimePage({ websiteId }: { websiteId: string }) {
|
|||
<Panel>
|
||||
<RealtimeChart data={data} unit="minute" />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<RealtimeLog data={data} />
|
||||
</Panel>
|
||||
<GridRow layout="two">
|
||||
<Panel>
|
||||
<RealtimeUrls data={data} />
|
||||
<RealtimePaths data={data} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<RealtimeLog data={data} />
|
||||
<RealtimeReferrers data={data} />
|
||||
</Panel>
|
||||
</GridRow>
|
||||
<GridRow layout="one-two">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import thenby from 'thenby';
|
||||
import { percentFilter } from '@/lib/filters';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { useMessages, useWebsite } from '@/components/hooks';
|
||||
|
||||
export function RealtimePaths({ data }: { data: any }) {
|
||||
const website = useWebsite();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { urls } = data || {};
|
||||
const limit = 15;
|
||||
|
||||
const renderLink = ({ label: x }) => {
|
||||
const domain = x.startsWith('/') ? website?.domain : '';
|
||||
return (
|
||||
<a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
|
||||
{x}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const pages = percentFilter(
|
||||
Object.keys(urls)
|
||||
.map(key => {
|
||||
return {
|
||||
x: key,
|
||||
y: urls[key],
|
||||
};
|
||||
})
|
||||
.sort(thenby.firstBy('y', -1))
|
||||
.slice(0, limit),
|
||||
);
|
||||
|
||||
return (
|
||||
<ListTable
|
||||
title={formatMessage(labels.pages)}
|
||||
metric={formatMessage(labels.views)}
|
||||
renderLabel={renderLink}
|
||||
data={pages.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
|
||||
label: x,
|
||||
count: y,
|
||||
percent: z,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import thenby from 'thenby';
|
||||
import { percentFilter } from '@/lib/filters';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { useMessages, useWebsite } from '@/components/hooks';
|
||||
|
||||
export function RealtimeReferrers({ data }: { data: any }) {
|
||||
const website = useWebsite();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { referrers } = data || {};
|
||||
const limit = 15;
|
||||
|
||||
const renderLink = ({ label: x }) => {
|
||||
const domain = x.startsWith('/') ? website?.domain : '';
|
||||
return (
|
||||
<a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
|
||||
{x}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const domains = percentFilter(
|
||||
Object.keys(referrers)
|
||||
.map(key => {
|
||||
return {
|
||||
x: key,
|
||||
y: referrers[key],
|
||||
};
|
||||
})
|
||||
.sort(thenby.firstBy('y', -1))
|
||||
.slice(0, limit),
|
||||
);
|
||||
|
||||
return (
|
||||
<ListTable
|
||||
title={formatMessage(labels.referrers)}
|
||||
metric={formatMessage(labels.views)}
|
||||
renderLabel={renderLink}
|
||||
data={domains.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
|
||||
label: x,
|
||||
count: y,
|
||||
percent: z,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Row } from '@umami/react-zen';
|
||||
import thenby from 'thenby';
|
||||
import { percentFilter } from '@/lib/filters';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { useMessages, useWebsite } from '@/components/hooks';
|
||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||
|
||||
const FILTER_REFERRERS = 'filter-referrers';
|
||||
const FILTER_PAGES = 'filter-pages';
|
||||
|
||||
export function RealtimeUrls({ data }: { data: any }) {
|
||||
const website = useWebsite();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { referrers, urls } = data || {};
|
||||
const [filter, setFilter] = useState(FILTER_REFERRERS);
|
||||
const limit = 15;
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
id: FILTER_REFERRERS,
|
||||
label: formatMessage(labels.referrers),
|
||||
},
|
||||
{
|
||||
id: FILTER_PAGES,
|
||||
label: formatMessage(labels.pages),
|
||||
},
|
||||
];
|
||||
|
||||
const renderLink = ({ label: x }) => {
|
||||
const domain = x.startsWith('/') ? website?.domain : '';
|
||||
return (
|
||||
<a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
|
||||
{x}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const domains = percentFilter(
|
||||
Object.keys(referrers)
|
||||
.map(key => {
|
||||
return {
|
||||
x: key,
|
||||
y: referrers[key],
|
||||
};
|
||||
})
|
||||
.sort(thenby.firstBy('y', -1))
|
||||
.slice(0, limit),
|
||||
);
|
||||
|
||||
const pages = percentFilter(
|
||||
Object.keys(urls)
|
||||
.map(key => {
|
||||
return {
|
||||
x: key,
|
||||
y: urls[key],
|
||||
};
|
||||
})
|
||||
.sort(thenby.firstBy('y', -1))
|
||||
.slice(0, limit),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row justifyContent="center">
|
||||
<FilterButtons items={buttons} value={filter} onChange={setFilter} />
|
||||
</Row>
|
||||
{filter === FILTER_REFERRERS && (
|
||||
<ListTable
|
||||
title={formatMessage(labels.referrers)}
|
||||
metric={formatMessage(labels.views)}
|
||||
renderLabel={renderLink}
|
||||
data={domains.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
|
||||
label: x,
|
||||
count: y,
|
||||
percent: z,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{filter === FILTER_PAGES && (
|
||||
<ListTable
|
||||
title={formatMessage(labels.pages)}
|
||||
metric={formatMessage(labels.views)}
|
||||
renderLabel={renderLink}
|
||||
data={pages.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
|
||||
label: x,
|
||||
count: y,
|
||||
percent: z,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
'use client';
|
||||
import { useMemo } from 'react';
|
||||
import { createAvatar } from '@dicebear/core';
|
||||
import { lorelei } from '@dicebear/collection';
|
||||
|
|
@ -6,41 +5,17 @@ import { getColor, getPastel } from '@/lib/colors';
|
|||
|
||||
const lib = lorelei;
|
||||
|
||||
// ✅ Modern UTF-8 safe base64 encoder (no deprecated APIs)
|
||||
function toBase64(str: string): string {
|
||||
if (typeof window === 'undefined') {
|
||||
// Server (Node.js)
|
||||
return Buffer.from(str, 'utf-8').toString('base64');
|
||||
} else {
|
||||
// Browser (UTF-8 safe)
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(str);
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
}
|
||||
|
||||
export function Avatar({ seed, size = 128, ...props }: { seed: string; size?: number }) {
|
||||
const backgroundColor = getPastel(getColor(seed), 4);
|
||||
|
||||
const avatar = useMemo(() => {
|
||||
const svg = createAvatar(lib, {
|
||||
return createAvatar(lib, {
|
||||
...props,
|
||||
seed,
|
||||
size,
|
||||
backgroundColor: [backgroundColor],
|
||||
}).toString();
|
||||
|
||||
const base64 = toBase64(svg);
|
||||
return `data:image/svg+xml;base64,${base64}`;
|
||||
}, [seed, size, backgroundColor, props]);
|
||||
}).toDataUri();
|
||||
}, []);
|
||||
|
||||
return <img src={avatar} alt="Avatar" style={{ borderRadius: '100%', width: size }} />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue