Compare commits

...

6 commits

Author SHA1 Message Date
Mike Cao
33e927ed1f Bump version 3.0.2.
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-12-03 23:01:22 -08:00
Mike Cao
2993db14f0 Updated README. 2025-12-03 19:37:51 -08:00
Mike Cao
1483241494 Merge branch 'dev' of https://github.com/umami-software/umami into dev
Some checks are pending
Create docker images (cloud) / Build, push, and deploy (push) Waiting to run
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-12-03 18:39:45 -08:00
Mike Cao
c427c6f547 Fixed replica logic. 2025-12-03 17:05:14 -08:00
Mike Cao
3379cc6e89 Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	pnpm-lock.yaml
2025-11-28 00:34:12 -08:00
Mike Cao
d7fd22645c Fixed nav menus. 2025-11-28 00:33:53 -08:00
12 changed files with 72 additions and 52 deletions

View file

@ -27,10 +27,10 @@ A detailed getting started guide can be found at [umami.is/docs](https://umami.i
### Requirements ### Requirements
- A server with Node.js version 18.18 or newer - A server with Node.js version 18.18+.
- A database. Umami supports [PostgreSQL](https://www.postgresql.org/) (minimum v12.14) databases. - A PostgreSQL database version v12.14+.
### Get the Source Code and Install Packages ### Get the source code and install packages
```bash ```bash
git clone https://github.com/umami-software/umami.git git clone https://github.com/umami-software/umami.git
@ -58,7 +58,7 @@ postgresql://username:mypassword@localhost:5432/mydb
pnpm run build pnpm run build
``` ```
_The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**._ The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**.
### Start the Application ### Start the Application
@ -66,37 +66,36 @@ _The build step will create tables in your database if you are installing for th
pnpm run start pnpm run start
``` ```
_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._ 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.
--- ---
## 🐳 Installing with Docker ## 🐳 Installing with Docker
To build the Umami container and start up a Postgres database, run: Umami provides Docker images as well as a Docker compose file for easy deployment.
```bash Docker image:
docker compose up -d
```
Alternatively, to pull just the Umami Docker image with PostgreSQL support:
```bash ```bash
docker pull docker.umami.is/umami-software/umami:latest docker pull docker.umami.is/umami-software/umami:latest
``` ```
Docker compose to run Umami with a Postgres database, run:
```bash
docker compose up -d
```
--- ---
## 🔄 Getting Updates ## 🔄 Getting Updates
> [!WARNING]
> If you are updating from Umami V2, image "postgresql-latest" is deprecated. You must change it to "latest".
> e.g., rename `docker.umami.is/umami-software/umami:postgresql-latest` to `docker.umami.is/umami-software/umami:latest`.
To get the latest features, simply do a pull, install any new dependencies, and rebuild: To get the latest features, simply do a pull, install any new dependencies, and rebuild:
```bash ```bash
git pull git pull
pnpm install pnpm install
pnpm run build pnpm build
``` ```
To update the Docker image, simply pull the new images and rebuild: To update the Docker image, simply pull the new images and rebuild:

View file

@ -46,6 +46,14 @@
"arrowParentheses": "asNeeded" "arrowParentheses": "asNeeded"
} }
}, },
"css": {
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf"
}
},
"assist": { "assist": {
"enabled": true, "enabled": true,
"actions": { "actions": {

View file

@ -1,6 +1,6 @@
{ {
"name": "umami", "name": "umami",
"version": "3.0.1", "version": "3.0.2",
"description": "A modern, privacy-focused alternative to Google Analytics.", "description": "A modern, privacy-focused alternative to Google Analytics.",
"author": "Umami Software, Inc. <hello@umami.is>", "author": "Umami Software, Inc. <hello@umami.is>",
"license": "MIT", "license": "MIT",

View file

@ -21,6 +21,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
border="right" border="right"
backgroundColor backgroundColor
marginRight="2" marginRight="2"
padding="3"
> >
<AdminNav /> <AdminNav />
</Column> </Column>

View file

@ -50,7 +50,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
label={formatMessage(labels.role)} label={formatMessage(labels.role)}
rules={{ required: formatMessage(labels.required) }} rules={{ required: formatMessage(labels.required) }}
> >
<Select defaultSelectedKey={user.role}> <Select defaultValue={user.role}>
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly"> <ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
{formatMessage(labels.viewOnly)} {formatMessage(labels.viewOnly)}
</ListItem> </ListItem>

View file

@ -14,6 +14,7 @@ export function SettingsLayout({ children }: { children: ReactNode }) {
border="right" border="right"
backgroundColor backgroundColor
marginRight="2" marginRight="2"
padding="3"
> >
<SettingsNav /> <SettingsNav />
</Column> </Column>

View file

@ -3,7 +3,7 @@ import { SettingsLayout } from './SettingsLayout';
export default function ({ children }) { export default function ({ children }) {
if (process.env.cloudMode) { if (process.env.cloudMode) {
//return null; return null;
} }
return <SettingsLayout>{children}</SettingsLayout>; return <SettingsLayout>{children}</SettingsLayout>;

View file

@ -51,7 +51,7 @@ export function SideMenu({
}; };
return ( return (
<Column gap overflowY="auto" justifyContent="space-between"> <Column gap overflowY="auto" justifyContent="space-between" position="sticky" top="20px">
{title && ( {title && (
<Row padding> <Row padding>
<Heading size="1">{title}</Heading> <Heading size="1">{title}</Heading>

View file

@ -1,10 +1,11 @@
import { useToast } from '@umami/react-zen'; import { useToast } from '@umami/react-zen';
import type { ApiError } from '@/lib/types';
import { useApi } from '../useApi'; import { useApi } from '../useApi';
import { useModified } from '../useModified'; import { useModified } from '../useModified';
export function useUpdateQuery(path: string, params?: Record<string, any>) { export function useUpdateQuery(path: string, params?: Record<string, any>) {
const { post, useMutation } = useApi(); const { post, useMutation } = useApi();
const query = useMutation({ const query = useMutation<any, ApiError, Record<string, any>>({
mutationFn: (data: Record<string, any>) => post(path, { ...data, ...params }), mutationFn: (data: Record<string, any>) => post(path, { ...data, ...params }),
}); });
const { touch } = useModified(); const { touch } = useModified();

View file

@ -1,5 +1,6 @@
import { FormattedMessage, type MessageDescriptor, useIntl } from 'react-intl'; import { FormattedMessage, type MessageDescriptor, useIntl } from 'react-intl';
import { labels, messages } from '@/components/messages'; import { labels, messages } from '@/components/messages';
import type { ApiError } from '@/lib/types';
type FormatMessage = ( type FormatMessage = (
descriptor: MessageDescriptor, descriptor: MessageDescriptor,
@ -12,7 +13,7 @@ interface UseMessages {
messages: typeof messages; messages: typeof messages;
labels: typeof labels; labels: typeof labels;
getMessage: (id: string) => string; getMessage: (id: string) => string;
getErrorMessage: (error: unknown) => string | undefined; getErrorMessage: (error: ApiError) => string | undefined;
FormattedMessage: typeof FormattedMessage; FormattedMessage: typeof FormattedMessage;
} }
@ -25,7 +26,7 @@ export function useMessages(): UseMessages {
return message ? formatMessage(message) : id; return message ? formatMessage(message) : id;
}; };
const getErrorMessage = (error: unknown) => { const getErrorMessage = (error: ApiError) => {
if (!error) { if (!error) {
return undefined; return undefined;
} }

View file

@ -206,6 +206,10 @@ async function rawQuery(sql: string, data: Record<string, any>, name?: string):
return `$${params.length}${type ?? ''}`; return `$${params.length}${type ?? ''}`;
}); });
if (process.env.DATABASE_REPLICA_URL && '$replica' in client) {
return client.$replica().$queryRawUnsafe(query, ...params);
}
return client.$queryRawUnsafe(query, ...params); return client.$queryRawUnsafe(query, ...params);
} }
@ -296,54 +300,54 @@ function getSchema() {
} }
function getClient() { function getClient() {
if (!process.env.DATABASE_URL) {
return null;
}
const url = process.env.DATABASE_URL; const url = process.env.DATABASE_URL;
const replicaUrl = process.env.DATABASE_REPLICA_URL; const replicaUrl = process.env.DATABASE_REPLICA_URL;
const logQuery = process.env.LOG_QUERY; const logQuery = process.env.LOG_QUERY;
const schema = getSchema();
const connectionUrl = new URL(url); const baseAdapter = new PrismaPg({ connectionString: url }, { schema });
const schema = connectionUrl.searchParams.get('schema') ?? undefined;
const adapter = new PrismaPg({ connectionString: url.toString() }, { schema }); const baseClient = new PrismaClient({
adapter: baseAdapter,
const prisma = new PrismaClient({
adapter,
errorFormat: 'pretty', errorFormat: 'pretty',
...(logQuery ? PRISMA_LOG_OPTIONS : {}), ...(logQuery ? PRISMA_LOG_OPTIONS : {}),
}); });
if (replicaUrl) { if (logQuery) {
const replicaAdapter = new PrismaPg({ connectionString: replicaUrl.toString() }, { schema }); baseClient.$on('query', log);
const replicaClient = new PrismaClient({
adapter: replicaAdapter,
...(logQuery ? PRISMA_LOG_OPTIONS : {}),
});
prisma.$extends(
readReplicas({
replicas: [replicaClient],
}),
);
} }
if (!replicaUrl) {
log('Prisma initialized');
globalThis[PRISMA] ??= baseClient;
return baseClient;
}
const replicaAdapter = new PrismaPg({ connectionString: replicaUrl }, { schema });
const replicaClient = new PrismaClient({
adapter: replicaAdapter,
errorFormat: 'pretty',
...(logQuery ? PRISMA_LOG_OPTIONS : {}),
});
if (logQuery) { if (logQuery) {
prisma.$on('query' as never, log); replicaClient.$on('query', log);
} }
log('Prisma initialized'); const extended = baseClient.$extends(
readReplicas({
replicas: [replicaClient],
}),
);
if (!globalThis[PRISMA]) { log('Prisma initialized (with replica)');
globalThis[PRISMA] = prisma; globalThis[PRISMA] ??= extended;
}
return prisma; return extended;
} }
const client: PrismaClient = globalThis[PRISMA] || getClient(); const client = (globalThis[PRISMA] || getClient()) as ReturnType<typeof getClient>;
export default { export default {
client, client,

View file

@ -136,3 +136,8 @@ export interface RealtimeData {
urls: Record<string, number>; urls: Record<string, number>;
visitors: any[]; visitors: any[];
} }
export interface ApiError extends Error {
code?: string;
message: string;
}