Compare commits

..

16 Commits

Author SHA1 Message Date
moonrailgun
162954606a feat: add auto language detect for browser 2024-11-11 01:33:06 +08:00
moonrailgun
3bf86b3e6e feat: add audit log clear feature 2024-11-10 06:08:22 +08:00
moonrailgun
843a581d42 feat: add subscription selection page 2024-11-09 20:28:27 +08:00
moonrailgun
fffc989336 chore: remove unused script 2024-11-09 20:27:35 +08:00
moonrailgun
ea75ed7f88 chore: add alert 2024-11-09 20:27:17 +08:00
moonrailgun
34f9fe6957 refactor: add usage limit and update card style 2024-11-08 01:57:35 +08:00
moonrailgun
71f75c27dd feat: add api key and usage to command panel 2024-11-08 01:56:07 +08:00
moonrailgun
a12fa3e6fe feat: add <UsageCard /> component which can render usage data and progress 2024-11-08 01:47:49 +08:00
moonrailgun
ae5f5a97d9 chore: remove passport package 2024-11-08 00:28:04 +08:00
moonrailgun
31ad64cd95 feat: add cronjob to check workspace limit which will pause workspace 2024-11-07 00:06:04 +08:00
tommy141x
1096e9ca9a Fix number casting issue 2024-11-06 12:11:08 +08:00
moonrailgun
b71bf6542e feat: add more usage stats 2024-11-06 01:19:57 +08:00
moonrailgun
e4b98b1c36 feat: add workspace subscription 2024-11-06 01:11:03 +08:00
moonrailgun
fa1ff3b5f6 refactor: move billing mode inside folder 2024-11-06 01:11:03 +08:00
moonrailgun
f0ddf6c5dd refactor: add apikey check before setup 2024-11-06 01:11:03 +08:00
moonrailgun
74d391afc1 feat: add lemonsqueezy subscription 2024-11-06 01:11:03 +08:00
51 changed files with 983 additions and 271 deletions

View File

@ -135,8 +135,8 @@ importers:
specifier: ^3.3.4
version: 3.3.4(react-hook-form@7.51.1(react@18.2.0))
'@i18next-toolkit/react':
specifier: ^1.1.0
version: 1.1.0(@types/react@18.2.78)(buffer@6.0.3)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
specifier: 2.0.0-rc.5
version: 2.0.0-rc.5(@types/react@18.2.78)(buffer@6.0.3)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@loadable/component':
specifier: ^5.16.3
version: 5.16.3(react@18.2.0)
@ -543,12 +543,9 @@ importers:
nodemailer:
specifier: ^6.9.8
version: 6.9.8
passport:
specifier: ^0.7.0
version: 0.7.0
passport-jwt:
specifier: ^4.0.1
version: 4.0.1
p-map:
specifier: 4.0.0
version: 4.0.0
ping:
specifier: ^0.4.4
version: 0.4.4
@ -634,12 +631,6 @@ importers:
'@types/nodemailer':
specifier: ^6.4.11
version: 6.4.11
'@types/passport':
specifier: ^1.0.12
version: 1.0.12
'@types/passport-jwt':
specifier: ^3.0.9
version: 3.0.9
'@types/ping':
specifier: ^0.4.2
version: 0.4.2
@ -661,9 +652,6 @@ importers:
execa:
specifier: ^5.1.1
version: 5.1.1
p-map:
specifier: 4.0.0
version: 4.0.0
prisma:
specifier: 5.14.0
version: 5.14.0
@ -2263,8 +2251,14 @@ packages:
'@i18next-toolkit/extractor@1.1.0':
resolution: {integrity: sha512-USq83a1XKKCRGqlaKBoNRuCImD1IDFCHMgDHs9686v3IpZ2wQdj/e11+cPaGX1UIjndZKULdQq4b0aZJyMrBfg==}
'@i18next-toolkit/react@1.1.0':
resolution: {integrity: sha512-S9HFkBwCukCwRR18P4yhskzoBJwIJ2W182GQ9u5H69Guj3Sg4Lm0ghGk7VVALS4z3XmQNcJBzA+LWmLB2X5hIQ==}
'@i18next-toolkit/react-core@1.1.0':
resolution: {integrity: sha512-PkuBaIY8jLS0QKy1sjj0g0XAC7zLIDM8ckI5VZGY3feiCPjV6ZdFsuC3cJJwRH+LgOCTj49LurhBii5UXGBlVQ==}
peerDependencies:
'@types/react': ^18.2.55
react: ^18.2.0
'@i18next-toolkit/react@2.0.0-rc.5':
resolution: {integrity: sha512-ZiQaLwS3jnYgFrotDTPbcF74Wbs0JNw0DDouuY+qNegUt5QxLBf/sCymQsglt9C9u2KOU+2CjS3ZP5NWlRknOg==}
peerDependencies:
'@types/react': ^18.2.55
react: ^18.2.0
@ -2341,10 +2335,6 @@ packages:
resolution: {integrity: sha512-gM/FdNsK3BlrD6JRrhmiyqBXQsCpzSUdKSoZwJMQfXqfqcK321og+uMssc6HYcygUMrGvPnNJyJ1RqZPFDrgtg==}
engines: {node: '>=20'}
'@ljharb/resumer@0.0.1':
resolution: {integrity: sha512-skQiAOrCfO7vRTq53cxznMpks7wS1va95UCidALlOVWqvBAzwPVErwizDwoMqNVMEn1mDq0utxZd02eIrvF1lw==}
engines: {node: '>= 0.4'}
'@ljharb/through@2.3.11':
resolution: {integrity: sha512-ccfcIDlogiXNq5KcbAwbaO7lMh3Tm1i3khMPYpxlK8hH/W53zN81KM9coerRLOnTGu3nfXIniAmQbRI9OxbC0w==}
engines: {node: '>= 0.4'}
@ -2449,24 +2439,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@14.1.3':
resolution: {integrity: sha512-esk1RkRBLSIEp1qaQXv1+s6ZdYzuVCnDAZySpa62iFTMGTisCyNQmqyCTL9P+cLJ4N9FKCI3ojtSfsyPHJDQNw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@14.1.3':
resolution: {integrity: sha512-8uOgRlYEYiKo0L8YGeS+3TudHVDWDjPVDUcST+z+dUzgBbTEwSSIaSgF/vkcC1T/iwl4QX9iuUyUdQEl0Kxalg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@14.1.3':
resolution: {integrity: sha512-DX2zqz05ziElLoxskgHasaJBREC5Y9TJcbR2LYqu4r7naff25B4iXkfXWfcp69uD75/0URmmoSgT8JclJtrBoQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@14.1.3':
resolution: {integrity: sha512-HjssFsCdsD4GHstXSQxsi2l70F/5FsRTRQp8xNgmQs15SxUfUJRvSI9qKny/jLkY3gLgiCR3+6A7wzzK0DBlfA==}
@ -3351,6 +3345,7 @@ packages:
resolution: {integrity: sha512-MXg1xp+e5GhZ3Vit1gGEyoC+dyQUBy2JgVQ+3hUrD9wZMkUw/ywgkpK7oZgnB6kPpGrxJ41clkPPnsknuD6M2Q==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-gnueabihf@4.9.5':
resolution: {integrity: sha512-Q0LcU61v92tQB6ae+udZvOyZ0wfpGojtAKrrpAaIqmJ7+psq4cMIhT/9lfV6UQIpeItnq/2QDROhNLo00lOD1g==}
@ -3361,66 +3356,79 @@ packages:
resolution: {integrity: sha512-DZNLwIY4ftPSRVkJEaxYkq7u2zel7aah57HESuNkUnz+3bZHxwkCUkrfS2IWC1sxK6F2QNIR0Qr/YXw7nkF3Pw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.19.1':
resolution: {integrity: sha512-C7evongnjyxdngSDRRSQv5GvyfISizgtk9RM+z2biV5kY6S/NF/wta7K+DanmktC5DkuaJQgoKGf7KUDmA7RUw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-gnu@4.9.5':
resolution: {integrity: sha512-dkRscpM+RrR2Ee3eOQmRWFjmV/payHEOrjyq1VZegRUa5OrZJ2MAxBNs05bZuY0YCtpqETDy1Ix4i/hRqX98cA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.19.1':
resolution: {integrity: sha512-89tFWqxfxLLHkAthAcrTs9etAoBFRduNfWdl2xUs/yLV+7XDrJ5yuXMHptNqf1Zw0UCA3cAutkAiAokYCkaPtw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-musl@4.9.5':
resolution: {integrity: sha512-QaKFVOzzST2xzY4MAmiDmURagWLFh+zZtttuEnuNn19AiZ0T3fhPyjPPGwLNdiDT82ZE91hnfJsUiDwF9DClIQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-powerpc64le-gnu@4.19.1':
resolution: {integrity: sha512-PromGeV50sq+YfaisG8W3fd+Cl6mnOOiNv2qKKqKCpiiEke2KiKVyDqG/Mb9GWKbYMHj5a01fq/qlUR28PFhCQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.19.1':
resolution: {integrity: sha512-/1BmHYh+iz0cNCP0oHCuF8CSiNj0JOGf0jRlSo3L/FAyZyG2rGBuKpkZVH9YF+x58r1jgWxvm1aRg3DHrLDt6A==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.9.5':
resolution: {integrity: sha512-HeGqmRJuyVg6/X6MpE2ur7GbymBPS8Np0S/vQFHDmocfORT+Zt76qu+69NUoxXzGqVP1pzaY6QIi0FJWLC3OPA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.19.1':
resolution: {integrity: sha512-0cYP5rGkQWRZKy9/HtsWVStLXzCF3cCBTRI+qRL8Z+wkYlqN7zrSYm6FuY5Kd5ysS5aH0q5lVgb/WbG4jqXN1Q==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.19.1':
resolution: {integrity: sha512-XUXeI9eM8rMP8aGvii/aOOiMvTs7xlCosq9xCjcqI9+5hBxtjDpD+7Abm1ZhVIFE1J2h2VIg0t2DX/gjespC2Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.9.5':
resolution: {integrity: sha512-Dq1bqBdLaZ1Gb/l2e5/+o3B18+8TI9ANlA1SkejZqDgdU/jK/ThYaMPMJpVMMXy2uRHvGKbkz9vheVGdq3cJfA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.19.1':
resolution: {integrity: sha512-V7cBw/cKXMfEVhpSvVZhC+iGifD6U1zJ4tbibjjN+Xi3blSXaj/rJynAkCFFQfoG6VZrAiP7uGVzL440Q6Me2Q==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-x64-musl@4.9.5':
resolution: {integrity: sha512-ezyFUOwldYpj7AbkwyW9AJ203peub81CaAIVvckdkyH8EvhEIoKzaMFJj0G4qYJ5sw3BpqhFrsCc30t54HV8vg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.19.1':
resolution: {integrity: sha512-88brja2vldW/76jWATlBqHEoGjJLRnP0WOEKAUbMcXaAZnemNhlAHSyj4jIwMoP2T750LE9lblvD4e2jXleZsA==}
@ -4278,15 +4286,6 @@ packages:
'@types/parse5@6.0.3':
resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==}
'@types/passport-jwt@3.0.9':
resolution: {integrity: sha512-5XJt+79emfgpuBvBQusUPylFIVtW1QVAAkTRwCbRJAmxUjmLtIqUU6V1ovpnHPu6Qut3mR5Juc+s7kd06roNTg==}
'@types/passport-strategy@0.2.35':
resolution: {integrity: sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==}
'@types/passport@1.0.12':
resolution: {integrity: sha512-QFdJ2TiAEoXfEQSNDISJR1Tm51I78CymqcBa8imbjo6dNNu+l2huDxxbDEIoFIwOSKMkOfHEikyDuZ38WwWsmw==}
'@types/pbf@3.0.5':
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
@ -7295,6 +7294,9 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
i18next-browser-languagedetector@8.0.0:
resolution: {integrity: sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==}
i18next-http-backend@2.4.3:
resolution: {integrity: sha512-jo2M03O6n1/DNb51WSQ8PsQ0xEELzLZRdYUTbf17mLw3rVwnJF9hwNgMXvEFSxxb+N8dT+o0vtigA6s5mGWyPA==}
@ -9316,17 +9318,6 @@ packages:
pascal-case@3.1.2:
resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==}
passport-jwt@4.0.1:
resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==}
passport-strategy@1.0.0:
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
engines: {node: '>= 0.4.0'}
passport@0.7.0:
resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==}
engines: {node: '>= 0.4.0'}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
@ -9407,9 +9398,6 @@ packages:
pathval@1.1.1:
resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==}
pause@0.0.1:
resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==}
pbf@3.2.1:
resolution: {integrity: sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==}
hasBin: true
@ -15155,14 +15143,25 @@ snapshots:
transitivePeerDependencies:
- buffer
'@i18next-toolkit/react@1.1.0(@types/react@18.2.78)(buffer@6.0.3)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
'@i18next-toolkit/react-core@1.1.0(@types/react@18.2.78)(buffer@6.0.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@types/react': 18.2.78
crc: 4.3.2(buffer@6.0.3)
i18next: 23.10.0
i18next-http-backend: 2.4.3(encoding@0.1.13)
react: 18.2.0
react-i18next: 14.0.5(i18next@23.10.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
transitivePeerDependencies:
- buffer
- react-dom
- react-native
'@i18next-toolkit/react@2.0.0-rc.5(@types/react@18.2.78)(buffer@6.0.3)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@i18next-toolkit/react-core': 1.1.0(@types/react@18.2.78)(buffer@6.0.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@types/react': 18.2.78
i18next-browser-languagedetector: 8.0.0
i18next-http-backend: 2.4.3(encoding@0.1.13)
react: 18.2.0
transitivePeerDependencies:
- buffer
- encoding
@ -15298,10 +15297,6 @@ snapshots:
'@lemonsqueezy/lemonsqueezy.js@3.3.1': {}
'@ljharb/resumer@0.0.1':
dependencies:
'@ljharb/through': 2.3.11
'@ljharb/through@2.3.11':
dependencies:
call-bind: 1.0.7
@ -17954,21 +17949,6 @@ snapshots:
'@types/parse5@6.0.3': {}
'@types/passport-jwt@3.0.9':
dependencies:
'@types/express': 4.17.17
'@types/jsonwebtoken': 9.0.5
'@types/passport-strategy': 0.2.35
'@types/passport-strategy@0.2.35':
dependencies:
'@types/express': 4.17.17
'@types/passport': 1.0.12
'@types/passport@1.0.12':
dependencies:
'@types/express': 4.17.17
'@types/pbf@3.0.5': {}
'@types/ping@0.4.2': {}
@ -21796,6 +21776,10 @@ snapshots:
dependencies:
ms: 2.1.3
i18next-browser-languagedetector@8.0.0:
dependencies:
'@babel/runtime': 7.24.0
i18next-http-backend@2.4.3(encoding@0.1.13):
dependencies:
cross-fetch: 4.0.0(encoding@0.1.13)
@ -24342,19 +24326,6 @@ snapshots:
no-case: 3.0.4
tslib: 2.6.2
passport-jwt@4.0.1:
dependencies:
jsonwebtoken: 9.0.2
passport-strategy: 1.0.0
passport-strategy@1.0.0: {}
passport@0.7.0:
dependencies:
passport-strategy: 1.0.0
pause: 0.0.1
utils-merge: 1.0.1
path-browserify@1.0.1: {}
path-dirname@1.0.2: {}
@ -24416,8 +24387,6 @@ snapshots:
pathval@1.1.1: {}
pause@0.0.1: {}
pbf@3.2.1:
dependencies:
ieee754: 1.2.1

View File

@ -14,6 +14,8 @@ import {
LuAreaChart,
LuBellDot,
LuFilePieChart,
LuKanbanSquare,
LuKeyRound,
LuMonitorDot,
LuSearch,
LuServer,
@ -171,6 +173,22 @@ export const CommandPanel: React.FC<CommandPanelProps> = React.memo((props) => {
<LuBellDot className="mr-2 h-4 w-4" />
<span>{t('Notifications')}</span>
</CommandItem>
<CommandItem
onSelect={handleJump({
to: '/settings/apiKey',
})}
>
<LuKeyRound className="mr-2 h-4 w-4" />
<span>{t('Api Key')}</span>
</CommandItem>
<CommandItem
onSelect={handleJump({
to: '/settings/usage',
})}
>
<LuKanbanSquare className="mr-2 h-4 w-4" />
<span>{t('Usage')}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>

View File

@ -0,0 +1,61 @@
import React from 'react';
import { Card, CardContent, CardHeader } from './ui/card';
import { formatNumber } from '@/utils/common';
import { LuAlertCircle } from 'react-icons/lu';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { useTranslation } from '@i18next-toolkit/react';
import colors from 'tailwindcss/colors';
interface UsageCardProps {
title: string;
current: number;
limit?: number;
}
export const UsageCard: React.FC<UsageCardProps> = React.memo((props) => {
const { title, current, limit } = props;
const { t } = useTranslation();
return (
<Card className="relative h-full w-full overflow-hidden">
{limit && (
<div
className="absolute h-full bg-black bg-opacity-5 dark:bg-white dark:bg-opacity-10"
style={{ width: `${(current / limit) * 100}%` }}
/>
)}
{limit && current > limit && (
<div className="absolute right-2 top-2">
<Tooltip>
<TooltipTrigger>
<LuAlertCircle stroke={colors.red['500']} />
</TooltipTrigger>
<TooltipContent>
<div>
{t(
'Exceeded the limit, please upgrade your plan or your workspace will be paused soon.'
)}
</div>
</TooltipContent>
</Tooltip>
</div>
)}
<CardHeader className="text-muted-foreground">{title}</CardHeader>
<CardContent>
{limit && limit >= 0 ? (
<div>
<span className="text-2xl font-bold">{formatNumber(current)}</span>{' '}
/ <span>{formatNumber(limit)}</span>
</div>
) : (
<div>
<span className="text-2xl font-bold">{formatNumber(current)}</span>{' '}
/ <span></span>
</div>
)}
</CardContent>
</Card>
);
});
UsageCard.displayName = 'UsageCard';

View File

@ -0,0 +1,166 @@
import { Check } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import React from 'react';
import { useTranslation } from '@i18next-toolkit/react';
import { useEvent } from '@/hooks/useEvent';
import { defaultErrorHandler, trpc } from '@/api/trpc';
import { useCurrentWorkspaceId } from '@/store/user';
import { cn } from '@/utils/style';
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
import { LuInfo } from 'react-icons/lu';
interface SubscriptionSelectionProps {
tier: 'FREE' | 'PRO' | 'TEAM' | 'UNLIMITED' | undefined;
}
export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> =
React.memo((props) => {
const { tier } = props;
const workspaceId = useCurrentWorkspaceId();
const { t } = useTranslation();
const checkoutMutation = trpc.billing.checkout.useMutation({
onError: defaultErrorHandler,
});
const handleCheckoutSubscribe = useEvent(
async (tier: 'free' | 'pro' | 'team') => {
const { url } = await checkoutMutation.mutateAsync({
workspaceId,
tier,
redirectUrl: location.href,
});
location.href = url;
}
);
const plans = [
{
id: 'FREE',
name: t('Free'),
price: 0,
features: [
t('Basic trial'),
t('Basic Usage'),
t('Up to 3 websites'),
t('Up to 3 surveys'),
t('Up to 3 feed channels'),
t('100K website events per month'),
t('100K monitor execution per month'),
t('10K feed event per month'),
t('Discord Community Support'),
],
onClick: () => handleCheckoutSubscribe('free'),
},
{
id: 'PRO',
name: 'Pro',
price: 19.99,
features: [
t('Sufficient for most situations'),
t('Priority access to advanced features'),
t('Up to 10 websites'),
t('Up to 20 surveys'),
t('Up to 20 feed channels'),
t('1M website events per month'),
t('1M monitor execution per month'),
t('100K feed events per month'),
t('Discord Community Support'),
],
onClick: () => handleCheckoutSubscribe('pro'),
},
{
id: 'TEAM',
name: 'Team',
price: 99.99,
features: [
t('Fully sufficient'),
t('Priority access to advanced features'),
t('Unlimited websites'),
t('Unlimited surveys'),
t('Unlimited feed channels'),
t('20M website events per month'),
t('20M monitor execution per month'),
t('1M feed events per month'),
t('Priority email support'),
],
onClick: () => handleCheckoutSubscribe('team'),
},
];
return (
<div className="container mx-auto px-4 py-8">
<h1 className="mb-8 text-center text-3xl font-bold">
{t('Subscription Plan')}
</h1>
<Alert className="mb-4">
<LuInfo className="h-4 w-4" />
<AlertTitle>{t('Current Plan')}</AlertTitle>
<AlertDescription>
{t('Your Current Plan is:')}{' '}
<span className="font-bold">{tier}</span>
</AlertDescription>
</Alert>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{plans.map((plan) => {
const isCurrent = plan.id === tier;
return (
<Card
key={plan.name}
className={cn('flex flex-col', isCurrent && 'border-primary')}
>
<CardHeader>
<CardTitle>{plan.name}</CardTitle>
<CardDescription>${plan.price} per month</CardDescription>
</CardHeader>
<CardContent className="flex-grow">
<ul className="space-y-2">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center">
<Check className="mr-2 h-4 w-4 text-green-500" />
{feature}
</li>
))}
</ul>
</CardContent>
<CardFooter>
{isCurrent ? (
<Button className="w-full" disabled variant="outline">
{t('Current')}
</Button>
) : (
<Button
className="w-full"
disabled={checkoutMutation.isLoading}
onClick={plan.onClick}
>
{t('{{action}} to {{plan}}', {
action:
plans.indexOf(plan) <
plans.findIndex((p) => p.id === tier)
? t('Downgrade')
: t('Upgrade'),
plan: plan.name,
})}
</Button>
)}
</CardFooter>
</Card>
);
})}
</div>
</div>
);
});
SubscriptionSelection.displayName = 'SubscriptionSelection';

View File

@ -23,7 +23,7 @@ import {
useUserInfo,
useUserStore,
} from '@/store/user';
import { languages } from '@/utils/constants';
import { languages } from '@/utils/i18n';
import { useTranslation, setLanguage } from '@i18next-toolkit/react';
import { useNavigate } from '@tanstack/react-router';
import { version } from '@/utils/env';

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils/style"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -22,6 +22,9 @@ export function useGlobalConfig(): AppRouterOutput['global']['config'] {
{
staleTime: 1000 * 60 * 60 * 1, // 1 hour
onSuccess(data) {
/**
* Call anonymous telemetry if not disabled
*/
if (data.disableAnonymousTelemetry !== true) {
callAnonymousTelemetry();
}

View File

@ -1,6 +1,6 @@
/** @type {import('@i18next-toolkit/cli').I18nextToolkitConfig} */
const config = {
locales: ['en', 'zh', 'jp', 'fr', 'de', 'pl', 'pt', 'ru'],
locales: ['en', 'zh-CN', 'ja-JP', 'fr-FR', 'de-DE', 'pl-PL', 'pt-PT', 'ru-RU'],
verbose: true,
namespaces: ['translation'],
translator: {

3
src/client/init.ts Normal file
View File

@ -0,0 +1,3 @@
import { initI18N } from './utils/i18n';
initI18N();

View File

@ -1,5 +1,6 @@
import './index.css';
import './styles/global.less';
import './init';
import React from 'react';
import ReactDOM from 'react-dom/client';

View File

@ -23,7 +23,7 @@
"@bytemd/plugin-gfm": "^1.21.0",
"@bytemd/react": "^1.21.0",
"@hookform/resolvers": "^3.3.4",
"@i18next-toolkit/react": "^1.1.0",
"@i18next-toolkit/react": "2.0.0-rc.5",
"@loadable/component": "^5.16.3",
"@monaco-editor/react": "^4.6.0",
"@radix-ui/react-alert-dialog": "^1.0.5",

View File

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 269 B

View File

@ -91,6 +91,7 @@
"k3e8b13f8": "Discord beitreten",
"k3eaab921": "ÜberwachungsListe",
"k3f36e17e": "Twitter folgen",
"k406089a4": "Aktion",
"k406e9ad8": "Bestätigen",
"k41d3ce6c": "Ereignis wiederhergestellt",
"k42347b91": "Website-Ereigniszählung",
@ -99,6 +100,7 @@
"k44186b66": "Zählung",
"k44cad477": "(Aktuell)",
"k45f80a27": "Erweitert",
"k4727e4db": "Ablaufdatum",
"k477b7ee4": "Teilweise Systemausfälle",
"k47fe1f95": "Fügen Sie diesen Beispielcode zu Ihrem Projekt hinzu",
"k48186ce": "Zurück zur Startseite",
@ -130,9 +132,12 @@
"k58267a45": "Quelle",
"k58f90514": "Bot-Token",
"k593cf342": "Sind Sie sicher, diesen Monitor zu löschen?",
"k5a782f4b": "Website-Anzahl",
"k5a839f71": "Betriebszeit",
"k5b5be0d4": "Aktuelle Rolle",
"k5c18db28": "Statusseiteninformationen ändern",
"k5d00536d": "Kopiert",
"k5d49d751": "Neuer API-Schlüssel wurde in Ihre Zwischenablage kopiert!",
"k5eb87a8b": "Start",
"k5ec0de4": "Für die HTTPS-Überwachung werden bei Zuweisung einer Benachrichtigungsmethode Benachrichtigungen 1, 3, 7 und 14 Tage vor Ablauf gesendet.",
"k5ecf04b0": "Ansicht",
@ -223,6 +228,7 @@
"k93458b98": "Spielplatz",
"k951a939a": "Akzeptierte Zählung der Website",
"k95f932a": "Warten derzeit auf eine neue Anfrage vom Remote-Server",
"k97b02874": "Seitenanzahl",
"k98f433ee": "Reporter herunterladen von",
"k9991c290": "Gemeinschaft",
"k9a272ecf": "Sind das Ihre Server?",
@ -248,6 +254,7 @@
"ka6ee7455": "Website-ID",
"ka71c12e1": "Die beiden Passwörter stimmen nicht überein",
"ka765ad32": "Benachrichtigung",
"ka7d8617e": "Feed-Kanalanzahl",
"ka7fe5937": "Festplattenlesen/-schreiben",
"ka8e41156": "Suche und schneller Sprung",
"ka90bc019": "Deinstallieren",
@ -269,6 +276,7 @@
"kb0e351e0": "Aktualisiert",
"kb114a2e8": "Veraltet",
"kb15a6374": "Sie können Ihre Statusseite in Ihrer eigenen Domain konfigurieren, zum Beispiel: status.beispiel.com",
"kb2dded49": "Schlüssel",
"kb320aac4": "Überwacht seit {{dayNum}} Tagen",
"kb35cde91": "Suche",
"kb35d71ed": "ODER",
@ -276,6 +284,7 @@
"kb5673707": "Letzte 7 Tage",
"kb659c1bc": "Zert. Ablauf",
"kb6d350b6": "Feed-Kanäle",
"kb7bf8869": "API-Schlüssel",
"kb7fa344a": "Wählen Sie einen Feed-Kanal zum Senden aus",
"kb8de8c50": "BCC",
"kbb31d3db": "Statistikdatum",
@ -314,6 +323,7 @@
"kcd56f27b": "Zuletzt aktualisiert",
"kcd643ef3": "Lade...",
"kce77d0c1": "Zeitzone",
"kcff78587": "Zuletzt verwendet am",
"kd005f7a8": "Alle Feeds werden entfernt",
"kd031b383": "Ansichten",
"kd092de58": "Aktueller Arbeitsbereich:",
@ -328,9 +338,11 @@
"kd7279fa6": "Code",
"kd7985726": "{{num}} Benutzer",
"kd92fa3e7": "Host-Name",
"kdaa6ae2b": "Überwachungsanzahl",
"kdaff25a6": "Zeige den neuesten Wert",
"kdb61adbb": "Offline verbergen",
"kdbadcf43": "Alle Systeme betriebsbereit",
"kdbe222b": "API-Schlüssel",
"kdc10ee1a": "Erstellen Sie einen neuen Arbeitsbereich, um mit Teammitgliedern zusammenzuarbeiten.",
"kdc15c5d": "Daten",
"kdc1bf80e": "Url ist erforderlich",

View File

@ -91,6 +91,7 @@
"k3e8b13f8": "Join Discord",
"k3eaab921": "Monitor List",
"k3f36e17e": "Follow Twitter",
"k406089a4": "Action",
"k406e9ad8": "Confirm",
"k41d3ce6c": "Event unarchived",
"k42347b91": "Website Event Count",
@ -99,6 +100,7 @@
"k44186b66": "Count",
"k44cad477": "(Current)",
"k45f80a27": "Advanced",
"k4727e4db": "Expired At",
"k477b7ee4": "Partial System Outage",
"k47fe1f95": "Add this example code into your project",
"k48186ce": "Back to Homepage",
@ -130,9 +132,12 @@
"k58267a45": "Source",
"k58f90514": "Bot Token",
"k593cf342": "Are you sure you want to delete this monitor?",
"k5a782f4b": "Website Count",
"k5a839f71": "Uptime",
"k5b5be0d4": "Current Role",
"k5c18db28": "Modify Status Page Info",
"k5d00536d": "Copied",
"k5d49d751": "New api key has been copied into your clipboard!",
"k5eb87a8b": "Start",
"k5ec0de4": "For HTTPS monitoring, if any notification method is assigned, notifications will be sent at 1, 3, 7 and 14 days before expiration.",
"k5ecf04b0": "View",
@ -223,6 +228,7 @@
"k93458b98": "Playground",
"k951a939a": "Website Accepted Count",
"k95f932a": "Currently waiting for a new request from the remote server",
"k97b02874": "Page Count",
"k98f433ee": "Download reporter from",
"k9991c290": "Community",
"k9a272ecf": "Is this your servers?",
@ -248,6 +254,7 @@
"ka6ee7455": "Website ID",
"ka71c12e1": "The two passwords are not consistent",
"ka765ad32": "Notification",
"ka7d8617e": "Feed Channel Count",
"ka7fe5937": "Disk read/write",
"ka8e41156": "Search and quick jump",
"ka90bc019": "Uninstall",
@ -269,6 +276,7 @@
"kb0e351e0": "Refreshed",
"kb114a2e8": "Deprecated",
"kb15a6374": "You can config your status page in your own domain, for example: status.example.com",
"kb2dded49": "Key",
"kb320aac4": "Monitored for {{dayNum}} days",
"kb35cde91": "Search",
"kb35d71ed": "OR",
@ -276,6 +284,7 @@
"kb5673707": "Last 7 days",
"kb659c1bc": "Cert Exp.",
"kb6d350b6": "Feed Channels",
"kb7bf8869": "Api Keys",
"kb7fa344a": "Select Feed Channel for send",
"kb8de8c50": "BCC",
"kbb31d3db": "Statistic Date",
@ -314,6 +323,7 @@
"kcd56f27b": "Last updated",
"kcd643ef3": "Loading...",
"kce77d0c1": "Timezone",
"kcff78587": "Last Use At",
"kd005f7a8": "All feed will be remove",
"kd031b383": "Views",
"kd092de58": "Current Workspace:",
@ -328,9 +338,11 @@
"kd7279fa6": "Code",
"kd7985726": "{{num}} users",
"kd92fa3e7": "Host Name",
"kdaa6ae2b": "Monitor Count",
"kdaff25a6": "Show Latest Value",
"kdb61adbb": "Hide Offline",
"kdbadcf43": "All Systems Operational",
"kdbe222b": "Api Key",
"kdc10ee1a": "Create a new workspace to cooperate with team members.",
"kdc15c5d": "Data",
"kdc1bf80e": "Url is required",

View File

Before

Width:  |  Height:  |  Size: 267 B

After

Width:  |  Height:  |  Size: 267 B

View File

@ -91,6 +91,7 @@
"k3e8b13f8": "Rejoindre Discord",
"k3eaab921": "Liste de surveillance",
"k3f36e17e": "Suivre Twitter",
"k406089a4": "Action",
"k406e9ad8": "Confirmer",
"k41d3ce6c": "Événement désarchivé",
"k42347b91": "Nombre d'événements sur le site Web",
@ -99,6 +100,7 @@
"k44186b66": "Compte",
"k44cad477": "(Actuel)",
"k45f80a27": "Avancé",
"k4727e4db": "Expiré À",
"k477b7ee4": "Panne partielle du système",
"k47fe1f95": "Ajoutez ce code d'exemple à votre projet",
"k48186ce": "Retour à la page d'accueil",
@ -130,9 +132,12 @@
"k58267a45": "Source",
"k58f90514": "Jeton de bot",
"k593cf342": "Êtes-vous sûr de vouloir supprimer ce moniteur ?",
"k5a782f4b": "Nombre de Sites Web",
"k5a839f71": "Disponibilité",
"k5b5be0d4": "Rôle actuel",
"k5c18db28": "Modifier les informations de la page d'état",
"k5d00536d": "Copié",
"k5d49d751": "La nouvelle clé API a été copiée dans votre presse-papiers !",
"k5eb87a8b": "Démarrer",
"k5ec0de4": "Pour la surveillance HTTPS, si une méthode de notification est assignée, des notifications seront envoyées à 1, 3, 7 et 14 jours avant l'expiration.",
"k5ecf04b0": "Vue",
@ -223,6 +228,7 @@
"k93458b98": "Terrain de jeu",
"k951a939a": "Compte accepté par le site Web",
"k95f932a": "En attente d'une nouvelle requête du serveur distant",
"k97b02874": "Nombre de Pages",
"k98f433ee": "Télécharger le rapporteur de",
"k9991c290": "Communauté",
"k9a272ecf": "S'agit-il de vos serveurs ?",
@ -248,6 +254,7 @@
"ka6ee7455": "ID du site Web",
"ka71c12e1": "Les deux mots de passe ne sont pas cohérents",
"ka765ad32": "Notification",
"ka7d8617e": "Nombre de Canaux de Flux",
"ka7fe5937": "Lecture/écriture de disque",
"ka8e41156": "Rechercher et sauter rapidement",
"ka90bc019": "Désinstaller",
@ -269,6 +276,7 @@
"kb0e351e0": "Rafraîchi",
"kb114a2e8": "Obsolète",
"kb15a6374": "Vous pouvez configurer votre page de statut sur votre propre domaine, par exemple : status.example.com",
"kb2dded49": "Clé",
"kb320aac4": "Surveillé pendant {{dayNum}} jours",
"kb35cde91": "Recherche",
"kb35d71ed": "OU",
@ -276,6 +284,7 @@
"kb5673707": "7 derniers jours",
"kb659c1bc": "Expiration du cert.",
"kb6d350b6": "Canaux de flux",
"kb7bf8869": "Clés API",
"kb7fa344a": "Sélectionner le canal de flux à envoyer",
"kb8de8c50": "CCI",
"kbb31d3db": "Date de statistique",
@ -314,6 +323,7 @@
"kcd56f27b": "Dernière mise à jour",
"kcd643ef3": "Chargement...",
"kce77d0c1": "Fuseau horaire",
"kcff78587": "Dernière Utilisation À",
"kd005f7a8": "Tous les flux seront supprimés",
"kd031b383": "Vues",
"kd092de58": "Espace de travail actuel :",
@ -328,9 +338,11 @@
"kd7279fa6": "Code",
"kd7985726": "{{num}} utilisateurs",
"kd92fa3e7": "Nom de l'hôte",
"kdaa6ae2b": "Nombre de Moniteurs",
"kdaff25a6": "Afficher la dernière valeur",
"kdb61adbb": "Masquer hors ligne",
"kdbadcf43": "Tous les systèmes opérationnels",
"kdbe222b": "Clé API",
"kdc10ee1a": "Créer un nouvel espace de travail pour coopérer avec les membres de l'équipe.",
"kdc15c5d": "Données",
"kdc1bf80e": "L'URL est requise",

View File

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 450 B

View File

@ -91,6 +91,7 @@
"k3e8b13f8": "Discordに参加",
"k3eaab921": "モニターリスト",
"k3f36e17e": "Twitterをフォロー",
"k406089a4": "アクション",
"k406e9ad8": "確認",
"k41d3ce6c": "イベントがアーカイブ解除されました",
"k42347b91": "ウェブサイトイベント数",
@ -99,6 +100,7 @@
"k44186b66": "カウント",
"k44cad477": "(現在)",
"k45f80a27": "詳細",
"k4727e4db": "期限切れ",
"k477b7ee4": "部分的なシステム障害",
"k47fe1f95": "このサンプルコードをプロジェクトに追加してください",
"k48186ce": "ホームページに戻る",
@ -130,9 +132,12 @@
"k58267a45": "ソース",
"k58f90514": "ボットトークン",
"k593cf342": "このモニターを削除してもよろしいですか?",
"k5a782f4b": "ウェブサイト数",
"k5a839f71": "アップタイム",
"k5b5be0d4": "現在の役割",
"k5c18db28": "ステータスページ情報を変更",
"k5d00536d": "コピー済み",
"k5d49d751": "新しいAPIキーがクリップボードにコピーされました",
"k5eb87a8b": "開始",
"k5ec0de4": "HTTPSモニタリングの場合、通知方法が割り当てられている場合、有効期限の1、3、7、14日前に通知が送信されます。",
"k5ecf04b0": "ビュー",
@ -223,6 +228,7 @@
"k93458b98": "プレイグラウンド",
"k951a939a": "ウェブサイト承認カウント",
"k95f932a": "現在、リモートサーバーからの新しいリクエストを待機中です",
"k97b02874": "ページ数",
"k98f433ee": "からレポーターをダウンロード",
"k9991c290": "コミュニティ",
"k9a272ecf": "これはあなたのサーバーですか?",
@ -248,6 +254,7 @@
"ka6ee7455": "ウェブサイトID",
"ka71c12e1": "2つのパスワードが一致しません",
"ka765ad32": "通知",
"ka7d8617e": "フィードチャンネル数",
"ka7fe5937": "ディスク読み取り/書き込み",
"ka8e41156": "検索して素早く移動",
"ka90bc019": "アンインストール",
@ -269,6 +276,7 @@
"kb0e351e0": "更新されました",
"kb114a2e8": "非推奨",
"kb15a6374": "自分のドメインでステータスページを設定できます。たとえば、status.example.com",
"kb2dded49": "キー",
"kb320aac4": "{{dayNum}}日間監視",
"kb35cde91": "検索",
"kb35d71ed": "または",
@ -276,6 +284,7 @@
"kb5673707": "過去7日間",
"kb659c1bc": "証明書の有効期限",
"kb6d350b6": "フィードチャンネル",
"kb7bf8869": "APIキー",
"kb7fa344a": "送信するフィードチャンネルを選択",
"kb8de8c50": "BCC",
"kbb31d3db": "統計日",
@ -314,6 +323,7 @@
"kcd56f27b": "最終更新",
"kcd643ef3": "読み込み中...",
"kce77d0c1": "タイムゾーン",
"kcff78587": "最終使用日時",
"kd005f7a8": "すべてのフィードが削除されます",
"kd031b383": "ビュー",
"kd092de58": "現在のワークスペース:",
@ -328,9 +338,11 @@
"kd7279fa6": "コード",
"kd7985726": "{{num}}人のユーザー",
"kd92fa3e7": "ホスト名",
"kdaa6ae2b": "モニター数",
"kdaff25a6": "最新値を表示",
"kdb61adbb": "オフラインを隠す",
"kdbadcf43": "すべてのシステムが稼働中",
"kdbe222b": "APIキー",
"kdc10ee1a": "チームメンバーと協力するために新しいワークスペースを作成します。",
"kdc15c5d": "データ",
"kdc1bf80e": "URLは必須です",

View File

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 259 B

View File

@ -91,6 +91,7 @@
"k3e8b13f8": "Dołącz do Discorda",
"k3eaab921": "Lista monitorów",
"k3f36e17e": "Śledź na Twitterze",
"k406089a4": "Akcja",
"k406e9ad8": "Potwierdź",
"k41d3ce6c": "Wydarzenie odarchiwizowane",
"k42347b91": "Liczba zdarzeń na stronie internetowej",
@ -99,6 +100,7 @@
"k44186b66": "Liczba",
"k44cad477": "(Obecny)",
"k45f80a27": "Zaawansowane",
"k4727e4db": "Wygasło",
"k477b7ee4": "Częściowa awaria systemu",
"k47fe1f95": "Dodaj ten przykładowy kod do swojego projektu",
"k48186ce": "Powrót do strony głównej",
@ -130,9 +132,12 @@
"k58267a45": "Źródło",
"k58f90514": "Token Bota",
"k593cf342": "Czy na pewno chcesz usunąć ten monitor?",
"k5a782f4b": "Liczba stron internetowych",
"k5a839f71": "Czas działania",
"k5b5be0d4": "Aktualna Rola",
"k5c18db28": "Zmień informacje na stronie statusu",
"k5d00536d": "Skopiowane",
"k5d49d751": "Nowy klucz API został skopiowany do schowka!",
"k5eb87a8b": "Wznów",
"k5ec0de4": "Dla monitorowania HTTPS, jeśli przypisana jest jakakolwiek metoda powiadamiania, powiadomienia zostaną wysłane 1, 3, 7 i 14 dni przed wygaśnięciem.",
"k5ecf04b0": "Widok",
@ -223,6 +228,7 @@
"k93458b98": "Plac zabaw",
"k951a939a": "Liczba zaakceptowanych stron internetowych",
"k95f932a": "Obecnie czekam na nowe żądanie z zdalnego serwera",
"k97b02874": "Liczba stron",
"k98f433ee": "Pobierz reporter z",
"k9991c290": "Społeczność",
"k9a272ecf": "Czy to twoje serwery?",
@ -248,6 +254,7 @@
"ka6ee7455": "ID strony internetowej",
"ka71c12e1": "Dwa hasła nie są zgodne",
"ka765ad32": "Powiadomienie",
"ka7d8617e": "Liczba kanałów feed",
"ka7fe5937": "Odczyt/zapis dysku",
"ka8e41156": "Wyszukiwanie i szybkie przeskakiwanie",
"ka90bc019": "Odinstaluj",
@ -269,6 +276,7 @@
"kb0e351e0": "Odświeżone",
"kb114a2e8": "Przestarzałe",
"kb15a6374": "Możesz skonfigurować swoją stronę statusu pod własną domeną, na przykład: status.example.com",
"kb2dded49": "Klucz",
"kb320aac4": "Monitorowane przez {{dayNum}} dni",
"kb35cde91": "Szukaj",
"kb35d71ed": "LUB",
@ -276,6 +284,7 @@
"kb5673707": "Ostatnie 7 dni",
"kb659c1bc": "Wygaśnięcie certyfikatu",
"kb6d350b6": "Kanały feedu",
"kb7bf8869": "Klucze API",
"kb7fa344a": "Wybierz kanał feedu do wysłania",
"kb8de8c50": "DWU",
"kbb31d3db": "Data statystyk",
@ -314,6 +323,7 @@
"kcd56f27b": "Ostatnia aktualizacja",
"kcd643ef3": "Ładowanie...",
"kce77d0c1": "Strefa czasowa",
"kcff78587": "Ostatnie użycie",
"kd005f7a8": "Wszystkie kanały informacyjne zostaną usunięte",
"kd031b383": "Odsłony",
"kd092de58": "Aktualna przestrzeń robocza:",
@ -328,9 +338,11 @@
"kd7279fa6": "Kod",
"kd7985726": "{{num}} użytkowników",
"kd92fa3e7": "Nazwa hosta",
"kdaa6ae2b": "Liczba monitorów",
"kdaff25a6": "Pokaż najnowszą wartość",
"kdb61adbb": "Ukryj wyłączone",
"kdbadcf43": "Wszystkie systemy działają",
"kdbe222b": "Klucz API",
"kdc10ee1a": "Utwórz nową przestrzeń roboczą, aby współpracować z członkami zespołu.",
"kdc15c5d": "Dane",
"kdc1bf80e": "Url jest wymagany",

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -91,6 +91,7 @@
"k3e8b13f8": "Aderir ao Discord",
"k3eaab921": "Lista de Monitoramento",
"k3f36e17e": "Seguir o Twitter",
"k406089a4": "Ação",
"k406e9ad8": "Confirmar",
"k41d3ce6c": "Evento desarquivado",
"k42347b91": "Contagem de eventos do sítio Web",
@ -99,6 +100,7 @@
"k44186b66": "Contar",
"k44cad477": "(Atual)",
"k45f80a27": "Avançado",
"k4727e4db": "Expirado Em",
"k477b7ee4": "Interrupção Parcial do Sistema",
"k47fe1f95": "Adicione este código de exemplo ao seu projeto",
"k48186ce": "Voltar à página inicial",
@ -130,9 +132,12 @@
"k58267a45": "Tipo de Letra",
"k58f90514": "Token de Bot",
"k593cf342": "De certeza que eliminou este monitor?",
"k5a782f4b": "Contagem de Sites",
"k5a839f71": "Tempo de atividade",
"k5b5be0d4": "Função Atual",
"k5c18db28": "Modificar Informações da Página de Status",
"k5d00536d": "Copiado",
"k5d49d751": "Nova chave de API foi copiada para sua área de transferência!",
"k5eb87a8b": "Início",
"k5ec0de4": "Para monitoramento HTTPS, se algum método de notificação estiver atribuído, notificações serão enviadas com 1, 3, 7 e 14 dias antes do vencimento.",
"k5ecf04b0": "Ver",
@ -223,6 +228,7 @@
"k93458b98": "Playground",
"k951a939a": "Contagem de sites aceites",
"k95f932a": "Aguardando atualmente uma nova solicitação do servidor remoto",
"k97b02874": "Contagem de Páginas",
"k98f433ee": "Descarregar repórter de",
"k9991c290": "Comunidade",
"k9a272ecf": "Estes são os vossos servidores?",
@ -248,6 +254,7 @@
"ka6ee7455": "ID do sítio Web",
"ka71c12e1": "As duas palavras-passe não são consistentes",
"ka765ad32": "Notificação",
"ka7d8617e": "Contagem de Canais de Feed",
"ka7fe5937": "Leitura/escrita de disco",
"ka8e41156": "Pesquisa e salto rápido",
"ka90bc019": "Desinstalar",
@ -269,6 +276,7 @@
"kb0e351e0": "Atualizado",
"kb114a2e8": "Obsoleto",
"kb15a6374": "Você pode configurar sua página de status em seu próprio domínio, por exemplo: status.example.com",
"kb2dded49": "Chave",
"kb320aac4": "Monitorizado durante {{dayNum}} dias",
"kb35cde91": "Pesquisar",
"kb35d71ed": "OU",
@ -276,6 +284,7 @@
"kb5673707": "Últimos 7 dias",
"kb659c1bc": "Exp. do certificado",
"kb6d350b6": "Canais de Feed",
"kb7bf8869": "Chaves de API",
"kb7fa344a": "Selecione o Canal de Feed para enviar",
"kb8de8c50": "CCO",
"kbb31d3db": "Data da estatística",
@ -314,6 +323,7 @@
"kcd56f27b": "Última atualização",
"kcd643ef3": "Carregando...",
"kce77d0c1": "Fuso Horário",
"kcff78587": "Último Uso Em",
"kd005f7a8": "Todos os feeds serão removidos",
"kd031b383": "Vistas",
"kd092de58": "Espaço de Trabalho Atual:",
@ -328,9 +338,11 @@
"kd7279fa6": "Código",
"kd7985726": "{{num}} utilizadores",
"kd92fa3e7": "Nome do anfitrião",
"kdaa6ae2b": "Contagem de Monitores",
"kdaff25a6": "Mostrar valor mais recente",
"kdb61adbb": "Ocultar offline",
"kdbadcf43": "Todos os Sistemas Operacionais",
"kdbe222b": "Chave de API",
"kdc10ee1a": "Crie um novo espaço de trabalho para cooperar com os membros da equipe.",
"kdc15c5d": "Dados",
"kdc1bf80e": "Url é obrigatório",

View File

Before

Width:  |  Height:  |  Size: 268 B

After

Width:  |  Height:  |  Size: 268 B

View File

@ -91,6 +91,7 @@
"k3e8b13f8": "Присоединяйтесь к Discord",
"k3eaab921": "Список мониторинга",
"k3f36e17e": "Подписаться на Twitter",
"k406089a4": "Действие",
"k406e9ad8": "Подтвердить",
"k41d3ce6c": "Событие восстановлено",
"k42347b91": "Количество событий на сайте",
@ -99,6 +100,7 @@
"k44186b66": "Количество",
"k44cad477": "(Текущий)",
"k45f80a27": "Расширенный",
"k4727e4db": "Истекает",
"k477b7ee4": "Частичный сбой системы",
"k47fe1f95": "Добавьте этот пример кода в ваш проект",
"k48186ce": "Вернуться на главную страницу",
@ -130,9 +132,12 @@
"k58267a45": "Источник",
"k58f90514": "Токен бота",
"k593cf342": "Вы уверены, что хотите удалить этот монитор?",
"k5a782f4b": "Количество сайтов",
"k5a839f71": "Время работы",
"k5b5be0d4": "Текущая роль",
"k5c18db28": "Изменить информацию на странице статуса",
"k5d00536d": "Скопировано",
"k5d49d751": "Новый API-ключ скопирован в буфер обмена!",
"k5eb87a8b": "Старт",
"k5ec0de4": "Для мониторинга HTTPS, если назначен любой метод уведомления, уведомления будут отправлены за 1, 3, 7 и 14 дней до истечения срока действия.",
"k5ecf04b0": "Просмотр",
@ -223,6 +228,7 @@
"k93458b98": "Площадка",
"k951a939a": "Количество принятых сайтом",
"k95f932a": "В настоящее время ожидает нового запроса от удаленного сервера",
"k97b02874": "Количество страниц",
"k98f433ee": "Скачать репортер с",
"k9991c290": "Сообщество",
"k9a272ecf": "Это ваши серверы?",
@ -248,6 +254,7 @@
"ka6ee7455": "ID веб-сайта",
"ka71c12e1": "Два пароля не совпадают",
"ka765ad32": "Уведомления",
"ka7d8617e": "Количество каналов ленты",
"ka7fe5937": "Чтение/запись на диск",
"ka8e41156": "Поиск и быстрый переход",
"ka90bc019": "Удалить",
@ -269,6 +276,7 @@
"kb0e351e0": "Обновлено",
"kb114a2e8": "Устаревший",
"kb15a6374": "Вы можете настроить свою страницу статуса на своем собственном домене, например: status.example.com",
"kb2dded49": "Ключ",
"kb320aac4": "Мониторинг в течение {{dayNum}} дней",
"kb35cde91": "Поиск",
"kb35d71ed": "ИЛИ",
@ -276,6 +284,7 @@
"kb5673707": "Последние 7 дней",
"kb659c1bc": "Истечение серт.",
"kb6d350b6": "Каналы обратной связи",
"kb7bf8869": "API-ключи",
"kb7fa344a": "Выберите канал обратной связи для отправки",
"kb8de8c50": "Скрытая копия",
"kbb31d3db": "Дата статистики",
@ -314,6 +323,7 @@
"kcd56f27b": "Последнее обновление",
"kcd643ef3": "Загрузка...",
"kce77d0c1": "Часовой пояс",
"kcff78587": "Последнее использование",
"kd005f7a8": "Все ленты будут удалены",
"kd031b383": "Просмотры",
"kd092de58": "Текущее рабочее пространство:",
@ -328,9 +338,11 @@
"kd7279fa6": "Код",
"kd7985726": "{{num}} пользователей",
"kd92fa3e7": "Имя хоста",
"kdaa6ae2b": "Количество мониторов",
"kdaff25a6": "Показать последнее значение",
"kdb61adbb": "Скрыть офлайн",
"kdbadcf43": "Все системы работают",
"kdbe222b": "API-ключ",
"kdc10ee1a": "Создайте новое рабочее пространство для сотрудничества с членами команды.",
"kdc15c5d": "Данные",
"kdc1bf80e": "URL обязателен",

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -91,6 +91,7 @@
"k3e8b13f8": "加入 Discord",
"k3eaab921": "监控列表",
"k3f36e17e": "关注 Twitter",
"k406089a4": "操作",
"k406e9ad8": "确认",
"k41d3ce6c": "事件已取消归档",
"k42347b91": "网站事件计数",
@ -99,6 +100,7 @@
"k44186b66": "计数",
"k44cad477": "(当前)",
"k45f80a27": "高级",
"k4727e4db": "到期时间",
"k477b7ee4": "部分系统故障",
"k47fe1f95": "将此示例代码添加到您的项目中",
"k48186ce": "返回首页",
@ -130,9 +132,12 @@
"k58267a45": "源",
"k58f90514": "机器人令牌",
"k593cf342": "您确定要删除这个监控器吗?",
"k5a782f4b": "网站数量",
"k5a839f71": "正常运行时间",
"k5b5be0d4": "当前角色",
"k5c18db28": "修改状态页面信息",
"k5d00536d": "已复制",
"k5d49d751": "新的 API 密钥已复制到您的剪贴板!",
"k5eb87a8b": "开始",
"k5ec0de4": "对于 HTTPS 监控,如果分配了任何通知方法,则将在到期前 1、3、7 和 14 天发送通知。",
"k5ecf04b0": "查看",
@ -223,6 +228,7 @@
"k93458b98": "游乐场",
"k951a939a": "网站接受计数",
"k95f932a": "当前正在等待来自远程服务器的新请求",
"k97b02874": "页面数量",
"k98f433ee": "从这里下载报告器",
"k9991c290": "社区",
"k9a272ecf": "这是您的服务器吗?",
@ -248,6 +254,7 @@
"ka6ee7455": "网站ID",
"ka71c12e1": "两次密码不一致",
"ka765ad32": "通知",
"ka7d8617e": "Feed 渠道数量",
"ka7fe5937": "磁盘读/写",
"ka8e41156": "搜索和快速跳转",
"ka90bc019": "卸载",
@ -269,6 +276,7 @@
"kb0e351e0": "已刷新",
"kb114a2e8": "已弃用",
"kb15a6374": "您可以在自己的域名中配置您的状态页面例如status.example.com",
"kb2dded49": "密钥",
"kb320aac4": "已监控{{dayNum}}天",
"kb35cde91": "搜索",
"kb35d71ed": "或",
@ -276,6 +284,7 @@
"kb5673707": "最近7天",
"kb659c1bc": "证书到期",
"kb6d350b6": "馈送频道",
"kb7bf8869": "API 密钥",
"kb7fa344a": "选择要发送的馈送频道",
"kb8de8c50": "密送",
"kbb31d3db": "统计日期",
@ -314,6 +323,7 @@
"kcd56f27b": "最后更新",
"kcd643ef3": "加载中...",
"kce77d0c1": "时区",
"kcff78587": "最后使用时间",
"kd005f7a8": "所有订阅将被删除",
"kd031b383": "视图",
"kd092de58": "当前工作区:",
@ -328,9 +338,11 @@
"kd7279fa6": "代码",
"kd7985726": "{{num}}个用户",
"kd92fa3e7": "主机名",
"kdaa6ae2b": "监控数量",
"kdaff25a6": "显示最新值",
"kdb61adbb": "隐藏离线",
"kdbadcf43": "所有系统正常运行",
"kdbe222b": "API 密钥",
"kdc10ee1a": "创建一个新的工作区以与团队成员合作。",
"kdc15c5d": "数据",
"kdc1bf80e": "网址是必需的",

View File

@ -34,6 +34,7 @@ import { Route as SettingsWorkspaceImport } from './routes/settings/workspace'
import { Route as SettingsUsageImport } from './routes/settings/usage'
import { Route as SettingsProfileImport } from './routes/settings/profile'
import { Route as SettingsNotificationsImport } from './routes/settings/notifications'
import { Route as SettingsBillingImport } from './routes/settings/billing'
import { Route as SettingsAuditLogImport } from './routes/settings/auditLog'
import { Route as SettingsApiKeyImport } from './routes/settings/apiKey'
import { Route as PageAddImport } from './routes/page/add'
@ -167,6 +168,11 @@ const SettingsNotificationsRoute = SettingsNotificationsImport.update({
getParentRoute: () => SettingsRoute,
} as any)
const SettingsBillingRoute = SettingsBillingImport.update({
path: '/billing',
getParentRoute: () => SettingsRoute,
} as any)
const SettingsAuditLogRoute = SettingsAuditLogImport.update({
path: '/auditLog',
getParentRoute: () => SettingsRoute,
@ -326,6 +332,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsAuditLogImport
parentRoute: typeof SettingsImport
}
'/settings/billing': {
preLoaderRoute: typeof SettingsBillingImport
parentRoute: typeof SettingsImport
}
'/settings/notifications': {
preLoaderRoute: typeof SettingsNotificationsImport
parentRoute: typeof SettingsImport
@ -423,6 +433,7 @@ export const routeTree = rootRoute.addChildren([
SettingsRoute.addChildren([
SettingsApiKeyRoute,
SettingsAuditLogRoute,
SettingsBillingRoute,
SettingsNotificationsRoute,
SettingsProfileRoute,
SettingsUsageRoute,

View File

@ -2,6 +2,7 @@ import { CommonHeader } from '@/components/CommonHeader';
import { CommonList } from '@/components/CommonList';
import { CommonWrapper } from '@/components/CommonWrapper';
import { Layout } from '@/components/layout';
import { useGlobalConfig } from '@/hooks/useConfig';
import { routeAuthBeforeLoad } from '@/utils/route';
import { useTranslation } from '@i18next-toolkit/react';
import {
@ -9,6 +10,7 @@ import {
useNavigate,
useRouterState,
} from '@tanstack/react-router';
import { compact } from 'lodash-es';
import { useEffect } from 'react';
export const Route = createFileRoute('/settings')({
@ -22,8 +24,9 @@ function PageComponent() {
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
const { enableBilling } = useGlobalConfig();
const items = [
const items = compact([
{
id: 'profile',
title: t('Profile'),
@ -54,7 +57,12 @@ function PageComponent() {
title: t('Usage'),
href: '/settings/usage',
},
];
enableBilling && {
id: 'billing',
title: t('Billing'),
href: '/settings/billing',
},
]);
useEffect(() => {
if (pathname === Route.fullPath) {

View File

@ -2,11 +2,13 @@ import { routeAuthBeforeLoad } from '@/utils/route';
import { createFileRoute } from '@tanstack/react-router';
import { useTranslation } from '@i18next-toolkit/react';
import { CommonWrapper } from '@/components/CommonWrapper';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Empty, List } from 'antd';
import { useMemo, useRef } from 'react';
import { trpc } from '../../api/trpc';
import { useCurrentWorkspaceId } from '../../store/user';
import {
defaultErrorHandler,
defaultSuccessHandler,
trpc,
} from '../../api/trpc';
import { useCurrentWorkspaceId, useHasAdminPermission } from '../../store/user';
import { CommonHeader } from '@/components/CommonHeader';
import { last } from 'lodash-es';
import { useVirtualizer } from '@tanstack/react-virtual';
@ -14,6 +16,9 @@ import { useWatch } from '@/hooks/useWatch';
import dayjs from 'dayjs';
import { ColorTag } from '@/components/ColorTag';
import { SimpleVirtualList } from '@/components/SimpleVirtualList';
import { Button } from '@/components/ui/button';
import { LuTrash2 } from 'react-icons/lu';
import { AlertConfirm } from '@/components/AlertConfirm';
export const Route = createFileRoute('/settings/auditLog')({
beforeLoad: routeAuthBeforeLoad,
@ -24,8 +29,9 @@ function PageComponent() {
const { t } = useTranslation();
const workspaceId = useCurrentWorkspaceId();
const parentRef = useRef<HTMLDivElement>(null);
const hasAdminPermission = useHasAdminPermission();
const { data, hasNextPage, fetchNextPage, isFetchingNextPage } =
const { data, hasNextPage, fetchNextPage, isFetchingNextPage, refetch } =
trpc.auditLog.fetchByCursor.useInfiniteQuery(
{
workspaceId,
@ -35,6 +41,11 @@ function PageComponent() {
}
);
const clearMutation = trpc.auditLog.clear.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const allData = useMemo(() => {
if (!data) {
return [];
@ -69,7 +80,27 @@ function PageComponent() {
});
return (
<CommonWrapper header={<CommonHeader title={t('Audit Log')} />}>
<CommonWrapper
header={
<CommonHeader
title={t('Audit Log')}
actions={
<>
{hasAdminPermission && (
<AlertConfirm
onConfirm={() => {
clearMutation.mutateAsync({ workspaceId });
refetch();
}}
>
<Button variant="outline" size="icon" Icon={LuTrash2} />
</AlertConfirm>
)}
</>
}
/>
}
>
<div className="h-full overflow-hidden p-4">
<SimpleVirtualList
allData={allData}

View File

@ -0,0 +1,124 @@
import { routeAuthBeforeLoad } from '@/utils/route';
import { createFileRoute } from '@tanstack/react-router';
import { useTranslation } from '@i18next-toolkit/react';
import { CommonWrapper } from '@/components/CommonWrapper';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useMemo } from 'react';
import {
defaultErrorHandler,
defaultSuccessHandler,
trpc,
} from '../../api/trpc';
import { useCurrentWorkspace, useCurrentWorkspaceId } from '../../store/user';
import { CommonHeader } from '@/components/CommonHeader';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import dayjs from 'dayjs';
import { formatNumber } from '@/utils/common';
import { UsageCard } from '@/components/UsageCard';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useEvent } from '@/hooks/useEvent';
import { SubscriptionSelection } from '@/components/billing/SubscriptionSelection';
export const Route = createFileRoute('/settings/billing')({
beforeLoad: routeAuthBeforeLoad,
component: PageComponent,
});
function PageComponent() {
const workspaceId = useCurrentWorkspaceId();
const { t } = useTranslation();
const { data: currentTier } = trpc.billing.currentTier.useQuery({
workspaceId,
});
const checkoutMutation = trpc.billing.checkout.useMutation({
onError: defaultErrorHandler,
});
const changePlanMutation = trpc.billing.changePlan.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const cancelSubscriptionMutation =
trpc.billing.cancelSubscription.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const { data, refetch, isInitialLoading, isLoading } =
trpc.billing.currentSubscription.useQuery({
workspaceId,
});
const handleChangeSubscribe = useEvent(
async (tier: 'free' | 'pro' | 'team') => {
await changePlanMutation.mutateAsync({
workspaceId,
tier,
});
refetch();
}
);
const plan = data ? (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button
loading={changePlanMutation.isLoading}
onClick={() => handleChangeSubscribe('free')}
>
Change plan to Free
</Button>
<Button
loading={changePlanMutation.isLoading}
onClick={() => handleChangeSubscribe('pro')}
>
Change plan to Pro
</Button>
<Button
loading={changePlanMutation.isLoading}
onClick={() => handleChangeSubscribe('team')}
>
Change plan to Team
</Button>
</div>
<div>
<Button
loading={cancelSubscriptionMutation.isLoading}
onClick={() =>
cancelSubscriptionMutation.mutateAsync({
workspaceId,
})
}
>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex gap-2">
<SubscriptionSelection tier={currentTier} />
</div>
);
return (
<CommonWrapper header={<CommonHeader title={t('Billing')} />}>
<ScrollArea className="h-full overflow-hidden p-4">
<div className="flex flex-col gap-2">
<div>
<div>Current: {JSON.stringify(data)}</div>
<Button loading={isLoading} onClick={() => refetch()}>
Refresh
</Button>
</div>
<Separator className="my-2" />
{isInitialLoading === false && plan}
</div>
</ScrollArea>
</CommonWrapper>
);
}

View File

@ -10,6 +10,7 @@ import { CommonHeader } from '@/components/CommonHeader';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import dayjs from 'dayjs';
import { formatNumber } from '@/utils/common';
import { UsageCard } from '@/components/UsageCard';
export const Route = createFileRoute('/settings/usage')({
beforeLoad: routeAuthBeforeLoad,
@ -24,12 +25,20 @@ function PageComponent() {
[]
);
const { data } = trpc.billing.usage.useQuery({
const { data: serviceCountData } = trpc.workspace.getServiceCount.useQuery({
workspaceId,
});
const { data: billingUsageData } = trpc.billing.usage.useQuery({
workspaceId,
startAt: startDate.valueOf(),
endAt: endDate.valueOf(),
});
const { data: limit } = trpc.billing.limit.useQuery({
workspaceId,
});
return (
<CommonWrapper header={<CommonHeader title={t('Usage')} />}>
<ScrollArea className="h-full overflow-hidden p-4">
@ -45,50 +54,61 @@ function PageComponent() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<Card className="flex-1">
<CardHeader className="text-muted-foreground">
{t('Website Accepted Count')}
</CardHeader>
<CardContent>
{formatNumber(data?.websiteAcceptedCount ?? 0)}
</CardContent>
</Card>
<UsageCard
title={t('Website Count')}
current={serviceCountData?.website ?? 0}
limit={limit?.maxWebsiteCount}
/>
<Card className="flex-1">
<CardHeader className="text-muted-foreground">
{t('Website Event Count')}
</CardHeader>
<CardContent>
{formatNumber(data?.websiteEventCount ?? 0)}
</CardContent>
</Card>
<UsageCard
title={t('Monitor Count')}
current={serviceCountData?.monitor ?? 0}
/>
<Card className="flex-1">
<CardHeader className="text-muted-foreground">
{t('Monitor Execution Count')}
</CardHeader>
<CardContent>
{formatNumber(data?.monitorExecutionCount ?? 0)}
</CardContent>
</Card>
<UsageCard
title={t('Survey Count')}
current={serviceCountData?.survey ?? 0}
/>
<Card className="flex-1">
<CardHeader className="text-muted-foreground">
{t('Survey Count')}
</CardHeader>
<CardContent>
{formatNumber(data?.surveyCount ?? 0)}
</CardContent>
</Card>
<UsageCard
title={t('Page Count')}
current={serviceCountData?.page ?? 0}
/>
<Card className="flex-1">
<CardHeader className="text-muted-foreground">
{t('Feed Event Count')}
</CardHeader>
<CardContent>
{formatNumber(data?.feedEventCount ?? 0)}
</CardContent>
</Card>
<UsageCard
title={t('Feed Channel Count')}
current={serviceCountData?.feed ?? 0}
limit={limit?.maxFeedChannelCount}
/>
<UsageCard
title={t('Website Accepted Count')}
current={billingUsageData?.websiteAcceptedCount ?? 0}
/>
<UsageCard
title={t('Website Event Count')}
current={billingUsageData?.websiteEventCount ?? 0}
limit={limit?.maxWebsiteEventCount}
/>
<UsageCard
title={t('Monitor Execution Count')}
current={billingUsageData?.monitorExecutionCount ?? 0}
limit={limit?.maxMonitorExecutionCount}
/>
<UsageCard
title={t('Survey Count')}
current={billingUsageData?.surveyCount ?? 0}
limit={limit?.maxSurveyCount}
/>
<UsageCard
title={t('Feed Event Count')}
current={billingUsageData?.feedEventCount ?? 0}
limit={limit?.maxFeedEventCount}
/>
</div>
</CardContent>
</Card>

View File

@ -1,3 +1,5 @@
import { setupI18nInstance } from '@i18next-toolkit/react';
export const languages = [
{
label: 'English',
@ -5,31 +7,36 @@ export const languages = [
},
{
label: 'Deutsch',
key: 'de',
key: 'de-DE',
},
{
label: 'Français',
key: 'fr',
key: 'fr-FR',
},
{
label: '日本語',
key: 'jp',
key: 'ja-JP',
},
{
label: 'Polski',
key: 'pl',
key: 'pl-PL',
},
{
label: 'Português',
key: 'pt',
key: 'pt-PT',
},
{
label: 'Русский',
key: 'ru',
key: 'ru-RU',
},
{
label: '简体中文',
key: 'zh',
key: 'zh-CN',
},
];
export function initI18N() {
setupI18nInstance({
supportedLngs: languages.map((l) => l.key),
});
}

View File

@ -2,7 +2,6 @@ import express from 'express';
import 'express-async-errors';
import compression from 'compression';
import swaggerUI from 'swagger-ui-express';
import passport from 'passport';
import morgan from 'morgan';
import { websiteRouter } from './router/website.js';
import { telemetryRouter } from './router/telemetry.js';
@ -38,7 +37,6 @@ app.use(
},
})
);
app.use(passport.initialize());
app.use(morgan('tiny'));
app.use(cors());

View File

@ -9,6 +9,7 @@ import { token } from '../model/notification/token/index.js';
import pMap from 'p-map';
import { sendFeedEventsNotify } from '../model/feed/event.js';
import { get } from 'lodash-es';
import { checkWorkspaceUsage } from '../model/billing/cronjob.js';
type WebsiteEventCountSqlReturn = {
workspace_id: string;
@ -29,6 +30,10 @@ export function initCronjob() {
checkFeedEventsNotify(FeedChannelNotifyFrequency.day),
]);
if (env.billing.enable) {
await checkWorkspaceUsage();
}
logger.info('Daily cronjob completed');
} catch (err) {
logger.error('Daily cronjob error:', err);
@ -386,6 +391,9 @@ async function dailyHTTPCertCheckNotify() {
);
}
/**
* Check feed events notify
*/
async function checkFeedEventsNotify(
notifyFrequency: FeedChannelNotifyFrequency
) {

View File

@ -1,7 +1,3 @@
import { findUser } from '../model/user.js';
import passport from 'passport';
import { Handler } from 'express';
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
import jwt from 'jsonwebtoken';
import { jwtSecret } from '../utils/common.js';
@ -14,38 +10,6 @@ export interface JWTPayload {
role: string;
}
passport.use(
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: jwtSecret,
issuer: jwtIssuer,
audience: jwtAudience,
},
function (jwt_payload, done) {
findUser(jwt_payload.id)
.then((user) => {
if (user) {
done(null, user);
} else {
done(null, false);
}
})
.catch((err) => {
done(err);
});
}
)
);
passport.serializeUser(function (user: any, cb) {
cb(null, { id: user.id, username: user.username });
});
passport.deserializeUser(function (user: any, cb) {
cb(null, user);
});
export function jwtSign(payload: JWTPayload): string {
const token = jwt.sign(
{
@ -72,9 +36,3 @@ export function jwtVerify(token: string): JWTPayload {
return payload as JWTPayload;
}
export function auth(): Handler {
return passport.authenticate('jwt', {
session: false,
});
}

View File

@ -0,0 +1,65 @@
import pMap from 'p-map';
import { prisma } from '../_client.js';
import { WorkspaceSubscriptionTier } from '@prisma/client';
import { logger } from '../../utils/logger.js';
import { getTierLimit } from './limit.js';
import { getWorkspaceUsage, pauseWorkspace } from './workspace.js';
import dayjs from 'dayjs';
import { getWorkspaceServiceCount } from '../workspace.js';
/**
* Check workspace usage
* if over limit, pause workspace
*/
export async function checkWorkspaceUsage() {
logger.info('[checkWorkspaceUsage] Start run checkWorkspaceUsage');
const workspaces = await prisma.workspace.findMany({
where: {
paused: false,
},
include: {
subscription: true,
},
});
await pMap(
workspaces,
async (workspace) => {
const tier =
workspace.subscription?.tier ?? WorkspaceSubscriptionTier.FREE;
if (tier === WorkspaceSubscriptionTier.UNLIMITED) {
return;
}
const [usage, serviceCount] = await Promise.all([
getWorkspaceUsage(
workspace.id,
dayjs().startOf('month').valueOf(),
dayjs().valueOf()
),
getWorkspaceServiceCount(workspace.id),
]);
const limit = getTierLimit(tier);
const overUsage =
serviceCount.website > limit.maxWebsiteCount ||
usage.websiteEventCount > limit.maxWebsiteEventCount ||
usage.monitorExecutionCount > limit.maxMonitorExecutionCount ||
usage.websiteEventCount > limit.maxWebsiteEventCount ||
usage.surveyCount > limit.maxSurveyCount ||
serviceCount.feed > limit.maxFeedChannelCount ||
usage.feedEventCount > limit.maxFeedEventCount;
if (overUsage) {
// pause workspace
await pauseWorkspace(workspace.id);
}
},
{
concurrency: 5,
}
);
}

View File

@ -1,19 +1,22 @@
import { TierType } from './types.js';
import { WorkspaceSubscriptionTier } from '@prisma/client';
import { z } from 'zod';
interface TierLimit {
maxWebsiteCount: number;
maxWebsiteEventCount: number;
maxMonitorExecutionCount: number;
maxSurveyCount: number;
maxFeedChannelCount: number;
maxFeedEventCount: number;
}
export const TierLimitSchema = z.object({
maxWebsiteCount: z.number(),
maxWebsiteEventCount: z.number(),
maxMonitorExecutionCount: z.number(),
maxSurveyCount: z.number(),
maxFeedChannelCount: z.number(),
maxFeedEventCount: z.number(),
});
type TierLimit = z.infer<typeof TierLimitSchema>;
/**
* Limit, Every month
*/
export function getTierLimit(tier: TierType): TierLimit {
if (tier === 'free') {
export function getTierLimit(tier: WorkspaceSubscriptionTier): TierLimit {
if (tier === WorkspaceSubscriptionTier.FREE) {
return {
maxWebsiteCount: 3,
maxWebsiteEventCount: 100_000,
@ -24,7 +27,7 @@ export function getTierLimit(tier: TierType): TierLimit {
};
}
if (tier === 'pro') {
if (tier === WorkspaceSubscriptionTier.PRO) {
return {
maxWebsiteCount: 10,
maxWebsiteEventCount: 1_000_000,
@ -35,7 +38,7 @@ export function getTierLimit(tier: TierType): TierLimit {
};
}
if (tier === 'team') {
if (tier === WorkspaceSubscriptionTier.TEAM) {
return {
maxWebsiteCount: -1,
maxWebsiteEventCount: 20_000_000,
@ -46,6 +49,7 @@ export function getTierLimit(tier: TierType): TierLimit {
};
}
// Unlimited
return {
maxWebsiteCount: -1,
maxWebsiteEventCount: -1,

View File

@ -1 +0,0 @@
export type TierType = 'free' | 'pro' | 'team' | 'unlimited';

View File

@ -0,0 +1,56 @@
import { WorkspaceSubscriptionTier } from '@prisma/client';
import { prisma } from '../_client.js';
export async function getWorkspaceUsage(
workspaceId: string,
startAt: number,
endAt: number
) {
const res = await prisma.workspaceDailyUsage.aggregate({
where: {
workspaceId,
date: {
gte: new Date(startAt),
lte: new Date(endAt),
},
},
_sum: {
websiteAcceptedCount: true,
websiteEventCount: true,
monitorExecutionCount: true,
surveyCount: true,
feedEventCount: true,
},
});
return {
websiteAcceptedCount: res._sum.websiteAcceptedCount ?? 0,
websiteEventCount: res._sum.websiteEventCount ?? 0,
monitorExecutionCount: res._sum.monitorExecutionCount ?? 0,
surveyCount: res._sum.surveyCount ?? 0,
feedEventCount: res._sum.feedEventCount ?? 0,
};
}
export async function getWorkspaceSubscription(
workspaceId: string
): Promise<WorkspaceSubscriptionTier> {
const subscription = await prisma.workspaceSubscription.findFirst({
where: {
workspaceId,
},
});
return subscription?.tier ?? WorkspaceSubscriptionTier.FREE;
}
export async function pauseWorkspace(workspaceId: string) {
await prisma.workspace.update({
where: {
id: workspaceId,
},
data: {
paused: true,
},
});
}

View File

@ -90,7 +90,7 @@ export async function getMonitorSummaryWithDay(
"MonitorData"
WHERE
"monitorId" = ${monitorId} AND
"createdAt" >= CURRENT_DATE - INTERVAL '${beforeDay} days'
"createdAt" >= CURRENT_DATE - INTERVAL '1 day' * ${beforeDay}
GROUP BY
DATE("createdAt")
ORDER BY

View File

@ -72,3 +72,47 @@ export async function getWorkspaceWebsiteDateRange(websiteId: string) {
min: res._min.createdAt,
};
}
export async function getWorkspaceServiceCount(workspaceId: string) {
const [website, monitor, telemetry, page, survey, feed] = await Promise.all([
prisma.website.count({
where: {
workspaceId,
},
}),
prisma.monitor.count({
where: {
workspaceId,
},
}),
prisma.telemetry.count({
where: {
workspaceId,
},
}),
prisma.monitorStatusPage.count({
where: {
workspaceId,
},
}),
prisma.survey.count({
where: {
workspaceId,
},
}),
prisma.feedChannel.count({
where: {
workspaceId,
},
}),
]);
return {
website,
monitor,
telemetry,
page,
survey,
feed,
};
}

View File

@ -60,8 +60,7 @@
"morgan": "^1.10.0",
"nanoid": "^5.0.4",
"nodemailer": "^6.9.8",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"p-map": "4.0.0",
"ping": "^0.4.4",
"prom-client": "^15.1.3",
"puppeteer": "23.4.1",
@ -92,8 +91,6 @@
"@types/morgan": "^1.9.5",
"@types/node": "^18.17.12",
"@types/nodemailer": "^6.4.11",
"@types/passport": "^1.0.12",
"@types/passport-jwt": "^3.0.9",
"@types/ping": "^0.4.2",
"@types/request-ip": "^0.0.38",
"@types/supertest": "^6.0.2",
@ -101,7 +98,6 @@
"@types/tcp-ping": "^0.1.5",
"@types/uuid": "^9.0.7",
"execa": "^5.1.1",
"p-map": "4.0.0",
"prisma": "5.14.0",
"prisma-json-types-generator": "3.0.3",
"prisma-zod-generator": "0.8.13",

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Workspace" ADD COLUMN "paused" BOOLEAN NOT NULL DEFAULT false;

View File

@ -96,6 +96,7 @@ model Workspace {
/// [CommonPayload]
/// @zod.custom(imports.CommonPayloadSchema)
settings Json @default("{}")
paused Boolean @default(false) // if workspace over billing, its will marked as pause and not receive and input.
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)

View File

@ -20,6 +20,7 @@ export const WorkspaceModelSchema = z.object({
* [CommonPayload]
*/
settings: imports.CommonPayloadSchema,
paused: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
})

View File

@ -1,5 +1,10 @@
import { z } from 'zod';
import { OpenApiMetaInfo, router, workspaceProcedure } from '../trpc.js';
import {
OpenApiMetaInfo,
router,
workspaceAdminProcedure,
workspaceProcedure,
} from '../trpc.js';
import { OPENAPI_TAG } from '../../utils/const.js';
import { WorkspaceAuditLogModelSchema } from '../../prisma/zod/index.js';
import { prisma } from '../../model/_client.js';
@ -46,6 +51,24 @@ export const auditLogRouter = router({
nextCursor,
};
}),
clear: workspaceAdminProcedure
.meta(
buildAuditLogOpenapi({
method: 'DELETE',
path: '/clear',
description: 'clear all workspace audit log',
})
)
.output(z.void())
.mutation(async ({ input }) => {
const { workspaceId } = input;
await prisma.workspaceAuditLog.deleteMany({
where: {
workspaceId,
},
});
}),
});
function buildAuditLogOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {

View File

@ -16,6 +16,12 @@ import {
SubscriptionTierType,
} from '../../model/billing/index.js';
import { LemonSqueezySubscriptionModelSchema } from '../../prisma/zod/lemonsqueezysubscription.js';
import {
getWorkspaceSubscription,
getWorkspaceUsage,
} from '../../model/billing/workspace.js';
import { getTierLimit, TierLimitSchema } from '../../model/billing/limit.js';
import { WorkspaceSubscriptionTier } from '@prisma/client';
export const billingRouter = router({
usage: workspaceProcedure
@ -44,30 +50,36 @@ export const billingRouter = router({
.query(async ({ input }) => {
const { workspaceId, startAt, endAt } = input;
const res = await prisma.workspaceDailyUsage.aggregate({
where: {
workspaceId,
date: {
gte: new Date(startAt),
lte: new Date(endAt),
},
},
_sum: {
websiteAcceptedCount: true,
websiteEventCount: true,
monitorExecutionCount: true,
surveyCount: true,
feedEventCount: true,
},
});
return getWorkspaceUsage(workspaceId, startAt, endAt);
}),
limit: workspaceProcedure
.meta(
buildBillingOpenapi({
method: 'GET',
path: '/limit',
description: 'get workspace subscription limit',
})
)
.output(TierLimitSchema)
.query(async ({ input }) => {
const { workspaceId } = input;
const tier = await getWorkspaceSubscription(workspaceId);
return {
websiteAcceptedCount: res._sum.websiteAcceptedCount ?? 0,
websiteEventCount: res._sum.websiteEventCount ?? 0,
monitorExecutionCount: res._sum.monitorExecutionCount ?? 0,
surveyCount: res._sum.surveyCount ?? 0,
feedEventCount: res._sum.feedEventCount ?? 0,
};
return getTierLimit(tier);
}),
currentTier: workspaceProcedure
.meta(
buildBillingOpenapi({
method: 'GET',
path: '/currentTier',
description: 'get workspace current tier',
})
)
.output(z.nativeEnum(WorkspaceSubscriptionTier))
.query(({ input }) => {
const { workspaceId } = input;
return getWorkspaceSubscription(workspaceId);
}),
currentSubscription: workspaceProcedure
.meta(
@ -102,7 +114,7 @@ export const billingRouter = router({
checkout: workspaceOwnerProcedure
.input(
z.object({
tier: z.string(),
tier: z.enum(['free', 'pro', 'team']),
redirectUrl: z.string().optional(),
})
)
@ -117,7 +129,7 @@ export const billingRouter = router({
const checkout = await createCheckoutBilling(
workspaceId,
userId,
input.tier as SubscriptionTierType,
input.tier,
redirectUrl
);

View File

@ -24,9 +24,10 @@ export const globalRouter = router({
disableAnonymousTelemetry: z.boolean(),
customTrackerScriptName: z.string().optional(),
authProvider: z.array(z.string()),
enableBilling: z.boolean(),
})
)
.query(async ({ input }) => {
.query(async () => {
return {
allowRegister: env.allowRegister,
websiteId: env.websiteId,
@ -36,6 +37,7 @@ export const globalRouter = router({
disableAnonymousTelemetry: env.disableAnonymousTelemetry,
customTrackerScriptName: env.customTrackerScriptName,
authProvider: env.auth.provider,
enableBilling: env.billing.enable,
};
}),
});

View File

@ -28,6 +28,7 @@ import { WorkspacesOnUsersModelSchema } from '../../prisma/zod/workspacesonusers
import { monitorManager } from '../../model/monitor/index.js';
import { get, merge } from 'lodash-es';
import { promWorkspaceCounter } from '../../utils/prometheus/client.js';
import { getWorkspaceServiceCount } from '../../model/workspace.js';
export const workspaceRouter = router({
create: protectProedure
@ -382,39 +383,8 @@ export const workspaceRouter = router({
.query(async ({ input }) => {
const { workspaceId } = input;
const [website, monitor, telemetry, page, survey, feed] =
await Promise.all([
prisma.website.count({
where: {
workspaceId,
},
}),
prisma.monitor.count({
where: {
workspaceId,
},
}),
prisma.telemetry.count({
where: {
workspaceId,
},
}),
prisma.monitorStatusPage.count({
where: {
workspaceId,
},
}),
prisma.survey.count({
where: {
workspaceId,
},
}),
prisma.feedChannel.count({
where: {
workspaceId,
},
}),
]);
const { website, monitor, telemetry, page, survey, feed } =
await getWorkspaceServiceCount(workspaceId);
const server = getServerCount(workspaceId);

View File

@ -47,6 +47,7 @@ export const env = {
},
},
billing: {
enable: checkEnvTrusty(process.env.ENABLE_BILLING),
lemonSqueezy: {
signatureSecret: process.env.LEMON_SQUEEZY_SIGNATURE_SECRET ?? '',
apiKey: process.env.LEMON_SQUEEZY_API_KEY ?? '',

View File

@ -205,11 +205,6 @@ const config: Config = {
defer: true,
'data-website-id': 'clopxgjr6050tqn5dzxo7pjac',
},
{
src: 'https://plausible.io/js/script.outbound-links.js',
defer: true,
'data-domain': 'msgbyte.com',
},
],
};