Compare commits

...

48 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
moonrailgun
c70e69879f fix: fix isUser middleware will call twice problem 2024-11-04 00:54:57 +08:00
moonrailgun
6a4bdd324c feat: add api key fe and usage counter 2024-11-03 19:39:59 +08:00
moonrailgun
f7b1d33c5d feat: add user api key backend support 2024-11-03 17:56:47 +08:00
moonrailgun
7aec9e7237 chore: release v1.16.5 2024-11-03 00:02:15 +08:00
moonrailgun
f637ade70f refactor: refactor status header and add typescript and translation support 2024-11-01 07:22:52 +08:00
Tommy Johnston
5207338ac1 change: percent > 95 to percent >= 95 2024-11-01 06:58:22 +08:00
Tommy Johnston
cb476f7361 fix styling hiccup 2024-11-01 06:58:22 +08:00
tommy
f77acf9eac consider 95% or higher to be green 2024-11-01 06:58:22 +08:00
tommy
325ab38fbb use client local time for date display 2024-11-01 06:58:22 +08:00
tommy
9949b973bd fix: add key to Fragment in map for monitor items 2024-11-01 06:58:22 +08:00
tommy
6312ec6eed Add Status Header & Modify Styles 2024-11-01 06:58:22 +08:00
moonrailgun
59b874644f chore: fix ci problem 2024-11-01 00:45:19 +08:00
moonrailgun
266b08f2da refactor: update webhooks signature api guide 2024-10-31 22:20:01 +08:00
tommy
a8a47ed94d fix: retrieve date as string
Apparently prisma can automatically cast dates to UTC, formatting the date to a string in the query can prevent this.
2024-10-31 16:07:07 +08:00
moonrailgun
272505669e refactor: update amount in stripe 2024-10-30 02:57:24 +08:00
moonrailgun
6b3631eae1 feat: add webhookSignature in feed channel 2024-10-29 03:41:45 +08:00
moonrailgun
f592466d62 chore: release v1.16.4 2024-10-27 17:29:50 +08:00
moonrailgun
98298c4367 refactor: update currency symbols in feed 2024-10-27 03:16:06 +08:00
moonrailgun
09d0f02d84 feat: add stripe feed integration 2024-10-26 00:55:51 +08:00
moonrailgun
59d32e0119 chore: release v1.16.3 2024-10-25 00:22:32 +08:00
moonrailgun
1c5737e588 chore: fix ci problem and upgrade version 2024-10-24 02:46:57 +08:00
moonrailgun
ba580dd70b chore: release v1.16.2 2024-10-24 02:36:36 +08:00
moonrailgun
e402ee1688 chore: update openapi document 2024-10-23 23:53:32 +08:00
moonrailgun
1df32dc257 docs: update README 2024-10-22 23:39:56 +08:00
moonrailgun
79667a9644 fix: fix a bug which will match incorrect path #115 2024-10-22 02:26:20 +08:00
moonrailgun
554f902584 chore: fix ci problem 2024-10-22 01:16:23 +08:00
moonrailgun
fcb8f22116 feat: add prometheus report support 2024-10-22 00:37:46 +08:00
moonrailgun
f080830407 chore: release v1.16.1 2024-10-21 01:26:02 +08:00
moonrailgun
c7e20df516 feat: add timezone support #114 2024-10-20 22:47:22 +08:00
moonrailgun
83850f2981 refactor: update cronjob clear time 2024-10-20 22:29:07 +08:00
moonrailgun
3dca8fc27c feat: add workspace settings manage 2024-10-20 22:27:31 +08:00
moonrailgun
4e3fd9db64 feat: add test notify 2024-10-20 16:58:23 +08:00
116 changed files with 5617 additions and 476 deletions

View File

@ -1,5 +1,82 @@
## [1.16.5](https://github.com/msgbyte/tianji/compare/v1.16.4...v1.16.5) (2024-11-02)
### Features
* add webhookSignature in feed channel ([6b3631e](https://github.com/msgbyte/tianji/commit/6b3631eae186b9cacf64d0ddcfbb66378e041281))
### Bug Fixes
* add key to Fragment in map for monitor items ([9949b97](https://github.com/msgbyte/tianji/commit/9949b973bd63b4ad6b5e71f7b819442f505c09a6))
* retrieve date as string ([a8a47ed](https://github.com/msgbyte/tianji/commit/a8a47ed94dda87c3fe4cdecc0acb9a31f53f00a5))
### Others
* fix ci problem ([59b8746](https://github.com/msgbyte/tianji/commit/59b874644fd3427bc86cd2a7e948054e827de080))
* refactor status header and add typescript and translation support ([f637ade](https://github.com/msgbyte/tianji/commit/f637ade70f230fbf472bdee84105c9b284d6b8d4))
* update amount in stripe ([2725056](https://github.com/msgbyte/tianji/commit/272505669e450d882930cbf594dac39a879b2072))
* update webhooks signature api guide ([266b08f](https://github.com/msgbyte/tianji/commit/266b08f2da16d0457a5a44b4a7a251d28502abc9))
## [1.16.4](https://github.com/msgbyte/tianji/compare/v1.16.3...v1.16.4) (2024-10-27)
### Features
* add stripe feed integration ([09d0f02](https://github.com/msgbyte/tianji/commit/09d0f02d844159565e97bb64f076e0bbe218ce98))
### Others
* update currency symbols in feed ([98298c4](https://github.com/msgbyte/tianji/commit/98298c43670326b4e2300a6bbdeee3daa53f0eb3))
## [1.16.3](https://github.com/msgbyte/tianji/compare/v1.16.2...v1.16.3) (2024-10-24)
### Others
* fix ci problem and upgrade version ([1c5737e](https://github.com/msgbyte/tianji/commit/1c5737e588d19e0657be6437792cf4484b6fdddb))
## [1.16.2](https://github.com/msgbyte/tianji/compare/v1.16.1...v1.16.2) (2024-10-23)
### Features
* add prometheus report support ([fcb8f22](https://github.com/msgbyte/tianji/commit/fcb8f221168281ab710d3d3f12064a99d17b39e7))
### Bug Fixes
* fix a bug which will match incorrect path [#115](https://github.com/msgbyte/tianji/issues/115) ([79667a9](https://github.com/msgbyte/tianji/commit/79667a9644b78451400acb6a6bbf07b6ca61e6e0))
### Document
* update README ([1df32dc](https://github.com/msgbyte/tianji/commit/1df32dc2579f32649afd6c008512c1190a45fd9e))
### Others
* fix ci problem ([554f902](https://github.com/msgbyte/tianji/commit/554f9025847defe0b05492cf07a5dc8acc6c3685))
* update openapi document ([e402ee1](https://github.com/msgbyte/tianji/commit/e402ee1688bb77d83463ce70c5e730c97f68a695))
## [1.16.1](https://github.com/msgbyte/tianji/compare/v1.16.0...v1.16.1) (2024-10-20)
### Features
* add test notify ([4e3fd9d](https://github.com/msgbyte/tianji/commit/4e3fd9db64629f7721e6092b86b06144c47f521d))
* add timezone support [#114](https://github.com/msgbyte/tianji/issues/114) ([c7e20df](https://github.com/msgbyte/tianji/commit/c7e20df516bf3a991ce46c937223948bcdb6b8f0))
* add workspace settings manage ([3dca8fc](https://github.com/msgbyte/tianji/commit/3dca8fc27c82bd96dbab423b111e4de57f3b4bd8))
### Others
* update cronjob clear time ([83850f2](https://github.com/msgbyte/tianji/commit/83850f2981ded0b6624556ee3430f684752b8ea3))
## [1.16.0](https://github.com/msgbyte/tianji/compare/v1.15.8...v1.16.0) (2024-10-19)

View File

@ -37,9 +37,8 @@ It's good to specialize in one thing, if we are experts in related abilities we
- [x] waitlist
- [x] survey
- [ ] survey page
- [ ] lighthouse report
- [x] lighthouse report
- [x] hooks
- [ ] links
- [x] helm install support
- [x] allow install from public
- [ ] improve monitor reporter usage

View File

@ -1,7 +1,7 @@
{
"name": "tianji",
"private": true,
"version": "1.16.0",
"version": "1.16.5",
"type": "module",
"scripts": {
"dev": "concurrently --kill-others npm:dev:server npm:dev:web",

View File

@ -1,6 +1,6 @@
{
"name": "tianji-client-sdk",
"version": "1.1.0",
"version": "1.1.1",
"description": "",
"main": "lib/index.js",
"scripts": {

View File

@ -47,7 +47,7 @@ export const OpenAPI: OpenAPIConfig = {
PASSWORD: undefined,
TOKEN: undefined,
USERNAME: undefined,
VERSION: '1.15.7',
VERSION: '1.16.1',
WITH_CREDENTIALS: false,
interceptors: {
request: new Interceptors(),

View File

@ -120,10 +120,10 @@ export class WorkspaceService {
* @returns unknown Successful response
* @throws ApiError
*/
public static workspaceDelete(data: $OpenApiTs['/workspace//{workspaceId}']['delete']['req']): CancelablePromise<$OpenApiTs['/workspace//{workspaceId}']['delete']['res'][200]> {
public static workspaceDelete(data: $OpenApiTs['/workspace//{workspaceId}/del']['delete']['req']): CancelablePromise<$OpenApiTs['/workspace//{workspaceId}/del']['delete']['res'][200]> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/workspace//{workspaceId}',
url: '/workspace//{workspaceId}/del',
path: {
workspaceId: data.workspaceId
}
@ -146,6 +146,25 @@ export class WorkspaceService {
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
* @param data.requestBody
* @returns unknown Successful response
* @throws ApiError
*/
public static workspaceUpdateSettings(data: $OpenApiTs['/workspace//{workspaceId}/updateSettings']['post']['req']): CancelablePromise<$OpenApiTs['/workspace//{workspaceId}/updateSettings']['post']['res'][200]> {
return __request(OpenAPI, {
method: 'POST',
url: '/workspace//{workspaceId}/updateSettings',
path: {
workspaceId: data.workspaceId
},
body: data.requestBody,
mediaType: 'application/json'
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
@ -585,28 +604,10 @@ export class MonitorService {
* @returns unknown Successful response
* @throws ApiError
*/
public static monitorGet(data: $OpenApiTs['/workspace/{workspaceId}/monitor/{monitorId}']['get']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/monitor/{monitorId}']['get']['res'][200]> {
public static monitorGet(data: $OpenApiTs['/workspace/{workspaceId}/monitor/{monitorId}/get']['get']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/monitor/{monitorId}/get']['get']['res'][200]> {
return __request(OpenAPI, {
method: 'GET',
url: '/workspace/{workspaceId}/monitor/{monitorId}',
path: {
workspaceId: data.workspaceId,
monitorId: data.monitorId
}
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
* @param data.monitorId
* @returns unknown Successful response
* @throws ApiError
*/
public static monitorDelete(data: $OpenApiTs['/workspace/{workspaceId}/monitor/{monitorId}']['delete']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/monitor/{monitorId}']['delete']['res'][200]> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/workspace/{workspaceId}/monitor/{monitorId}',
url: '/workspace/{workspaceId}/monitor/{monitorId}/get',
path: {
workspaceId: data.workspaceId,
monitorId: data.monitorId
@ -648,6 +649,24 @@ export class MonitorService {
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
* @param data.monitorId
* @returns unknown Successful response
* @throws ApiError
*/
public static monitorDelete(data: $OpenApiTs['/workspace/{workspaceId}/monitor/{monitorId}/del']['delete']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/monitor/{monitorId}/del']['delete']['res'][200]> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/workspace/{workspaceId}/monitor/{monitorId}/del',
path: {
workspaceId: data.workspaceId,
monitorId: data.monitorId
}
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
@ -715,6 +734,42 @@ export class MonitorService {
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
* @param data.monitorId
* @returns unknown Successful response
* @throws ApiError
*/
public static monitorPublicSummary(data: $OpenApiTs['/workspace/{workspaceId}/monitor/{monitorId}/publicSummary']['get']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/monitor/{monitorId}/publicSummary']['get']['res'][200]> {
return __request(OpenAPI, {
method: 'GET',
url: '/workspace/{workspaceId}/monitor/{monitorId}/publicSummary',
path: {
workspaceId: data.workspaceId,
monitorId: data.monitorId
}
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
* @param data.monitorId
* @returns unknown Successful response
* @throws ApiError
*/
public static monitorPublicData(data: $OpenApiTs['/workspace/{workspaceId}/monitor/{monitorId}/publicData']['get']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/monitor/{monitorId}/publicData']['get']['res'][200]> {
return __request(OpenAPI, {
method: 'GET',
url: '/workspace/{workspaceId}/monitor/{monitorId}/publicData',
path: {
workspaceId: data.workspaceId,
monitorId: data.monitorId
}
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
@ -1158,10 +1213,10 @@ export class SurveyService {
* @returns unknown Successful response
* @throws ApiError
*/
public static surveyGet(data: $OpenApiTs['/workspace/{workspaceId}/survey/{surveyId}']['get']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/survey/{surveyId}']['get']['res'][200]> {
public static surveyGet(data: $OpenApiTs['/workspace/{workspaceId}/survey/{surveyId}/get']['get']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/survey/{surveyId}/get']['get']['res'][200]> {
return __request(OpenAPI, {
method: 'GET',
url: '/workspace/{workspaceId}/survey/{surveyId}',
url: '/workspace/{workspaceId}/survey/{surveyId}/get',
path: {
workspaceId: data.workspaceId,
surveyId: data.surveyId
@ -1472,10 +1527,10 @@ export class FeedService {
* @returns unknown Successful response
* @throws ApiError
*/
public static feedDeleteChannel(data: $OpenApiTs['/workspace/{workspaceId}/feed/{channelId}']['delete']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/feed/{channelId}']['delete']['res'][200]> {
public static feedDeleteChannel(data: $OpenApiTs['/workspace/{workspaceId}/feed/{channelId}/del']['delete']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/feed/{channelId}/del']['delete']['res'][200]> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/workspace/{workspaceId}/feed/{channelId}',
url: '/workspace/{workspaceId}/feed/{channelId}/del',
path: {
workspaceId: data.workspaceId,
channelId: data.channelId

View File

@ -49,6 +49,9 @@ export type $OpenApiTs = {
workspace: {
id: string;
name: string;
settings: {
[key: string]: unknown;
};
};
}>;
};
@ -85,6 +88,9 @@ export type $OpenApiTs = {
workspace: {
id: string;
name: string;
settings: {
[key: string]: unknown;
};
};
}>;
};
@ -122,6 +128,9 @@ export type $OpenApiTs = {
workspace: {
id: string;
name: string;
settings: {
[key: string]: unknown;
};
};
}>;
};
@ -157,6 +166,9 @@ export type $OpenApiTs = {
workspace: {
id: string;
name: string;
settings: {
[key: string]: unknown;
};
};
}>;
};
@ -190,6 +202,9 @@ export type $OpenApiTs = {
workspace: {
id: string;
name: string;
settings: {
[key: string]: unknown;
};
};
}>;
};
@ -211,11 +226,14 @@ export type $OpenApiTs = {
200: {
id: string;
name: string;
settings: {
[key: string]: unknown;
};
};
};
};
};
'/workspace//{workspaceId}': {
'/workspace//{workspaceId}/del': {
delete: {
req: {
workspaceId: string;
@ -253,6 +271,30 @@ export type $OpenApiTs = {
};
};
};
'/workspace//{workspaceId}/updateSettings': {
post: {
req: {
requestBody: {
settings: {
[key: string]: unknown;
};
};
workspaceId: string;
};
res: {
/**
* Successful response
*/
200: {
id: string;
name: string;
settings: {
[key: string]: unknown;
};
};
};
};
};
'/workspace//{workspaceId}/invite': {
post: {
req: {
@ -692,7 +734,7 @@ export type $OpenApiTs = {
};
};
};
'/workspace/{workspaceId}/monitor/{monitorId}': {
'/workspace/{workspaceId}/monitor/{monitorId}/get': {
get: {
req: {
monitorId: string;
@ -722,9 +764,43 @@ export type $OpenApiTs = {
} | null;
};
};
delete: {
};
'/monitor/getPublicInfo': {
post: {
req: {
monitorId: string;
requestBody: {
monitorIds: Array<(string)>;
};
};
res: {
/**
* Successful response
*/
200: Array<{
id: string;
name: string;
type: string;
trendingMode: boolean;
}>;
};
};
};
'/workspace/{workspaceId}/monitor/upsert': {
post: {
req: {
requestBody: {
id?: string;
name: string;
type: string;
active?: boolean;
interval?: number;
maxRetries?: number;
trendingMode?: boolean;
notificationIds?: Array<(string)>;
payload: {
[key: string]: unknown;
};
};
workspaceId: string;
};
res: {
@ -749,41 +825,10 @@ export type $OpenApiTs = {
};
};
};
'/monitor/getPublicInfo': {
post: {
'/workspace/{workspaceId}/monitor/{monitorId}/del': {
delete: {
req: {
requestBody: {
monitorIds: Array<(string)>;
};
};
res: {
/**
* Successful response
*/
200: Array<{
id: string;
name: string;
type: string;
}>;
};
};
};
'/workspace/{workspaceId}/monitor/upsert': {
post: {
req: {
requestBody: {
id?: string;
name: string;
type: string;
active?: boolean;
interval?: number;
maxRetries?: number;
trendingMode?: boolean;
notificationIds?: Array<(string)>;
payload: {
[key: string]: unknown;
};
};
monitorId: string;
workspaceId: string;
};
res: {
@ -876,6 +921,42 @@ export type $OpenApiTs = {
};
};
};
'/workspace/{workspaceId}/monitor/{monitorId}/publicSummary': {
get: {
req: {
monitorId: string;
workspaceId: string;
};
res: {
/**
* Successful response
*/
200: Array<{
day: string;
totalCount: number;
upCount: number;
upRate: number;
}>;
};
};
};
'/workspace/{workspaceId}/monitor/{monitorId}/publicData': {
get: {
req: {
monitorId: string;
workspaceId: string;
};
res: {
/**
* Successful response
*/
200: Array<{
value: number;
createdAt: string;
}>;
};
};
};
'/workspace/{workspaceId}/monitor/{monitorId}/dataMetrics': {
get: {
req: {
@ -1384,13 +1465,14 @@ export type $OpenApiTs = {
};
feedChannelIds: Array<(string)>;
feedTemplate: string;
webhookUrl: string;
createdAt: string;
updatedAt: string;
}>;
};
};
};
'/workspace/{workspaceId}/survey/{surveyId}': {
'/workspace/{workspaceId}/survey/{surveyId}/get': {
get: {
req: {
surveyId: string;
@ -1414,6 +1496,7 @@ export type $OpenApiTs = {
};
feedChannelIds: Array<(string)>;
feedTemplate: string;
webhookUrl: string;
createdAt: string;
updatedAt: string;
} | null;
@ -1499,6 +1582,7 @@ export type $OpenApiTs = {
};
feedChannelIds: Array<(string)>;
feedTemplate: string;
webhookUrl: string;
};
workspaceId: string;
};
@ -1520,6 +1604,7 @@ export type $OpenApiTs = {
};
feedChannelIds: Array<(string)>;
feedTemplate: string;
webhookUrl: string;
createdAt: string;
updatedAt: string;
};
@ -1541,6 +1626,7 @@ export type $OpenApiTs = {
};
feedChannelIds?: Array<(string)>;
feedTemplate?: string;
webhookUrl?: string;
};
surveyId: string;
workspaceId: string;
@ -1563,6 +1649,7 @@ export type $OpenApiTs = {
};
feedChannelIds: Array<(string)>;
feedTemplate: string;
webhookUrl: string;
createdAt: string;
updatedAt: string;
};
@ -1593,6 +1680,7 @@ export type $OpenApiTs = {
};
feedChannelIds: Array<(string)>;
feedTemplate: string;
webhookUrl: string;
createdAt: string;
updatedAt: string;
};
@ -1820,7 +1908,7 @@ export type $OpenApiTs = {
};
};
};
'/workspace/{workspaceId}/feed/{channelId}': {
'/workspace/{workspaceId}/feed/{channelId}/del': {
delete: {
req: {
channelId: string;

View File

@ -1,6 +1,6 @@
{
"name": "tianji-client-react",
"version": "1.0.0",
"version": "1.0.1",
"description": "",
"main": "lib/index.js",
"scripts": {

View File

@ -6,7 +6,7 @@ import {
} from 'tianji-client-sdk';
type SurveyInfo =
openApiClient.$OpenApiTs['/workspace/{workspaceId}/survey/{surveyId}']['get']['res']['200'];
openApiClient.$OpenApiTs['/workspace/{workspaceId}/survey/{surveyId}/get']['get']['res']['200'];
interface UseTianjiSurveyOptions {
baseUrl?: string;

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)
@ -275,6 +275,9 @@ importers:
lucide-react:
specifier: ^0.358.0
version: 0.358.0(react@18.2.0)
md5:
specifier: ^2.3.0
version: 2.3.0
millify:
specifier: ^6.1.0
version: 6.1.0
@ -378,6 +381,9 @@ importers:
'@types/lodash-es':
specifier: ^4.17.12
version: 4.17.12
'@types/md5':
specifier: ^2.3.5
version: 2.3.5
'@types/react':
specifier: ^18.2.22
version: 18.2.78
@ -435,6 +441,9 @@ importers:
'@auth/express':
specifier: ^0.5.5
version: 0.5.6(express@4.18.2)(nodemailer@6.9.8)
'@lemonsqueezy/lemonsqueezy.js':
specifier: ^3.3.1
version: 3.3.1
'@paralleldrive/cuid2':
specifier: ^2.2.2
version: 2.2.2
@ -486,6 +495,9 @@ importers:
dotenv:
specifier: ^16.3.1
version: 16.3.1
easy-currency-symbol:
specifier: ^1.0.1
version: 1.0.1
express:
specifier: ^4.18.2
version: 4.18.2
@ -531,15 +543,15 @@ 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
prom-client:
specifier: ^15.1.3
version: 15.1.3
puppeteer:
specifier: 23.4.1
version: 23.4.1(typescript@5.5.4)
@ -619,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
@ -646,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
@ -2248,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
@ -2322,6 +2331,10 @@ packages:
'@leichtgewicht/ip-codec@2.0.5':
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
'@lemonsqueezy/lemonsqueezy.js@3.3.1':
resolution: {integrity: sha512-gM/FdNsK3BlrD6JRrhmiyqBXQsCpzSUdKSoZwJMQfXqfqcK321og+uMssc6HYcygUMrGvPnNJyJ1RqZPFDrgtg==}
engines: {node: '>=20'}
'@ljharb/through@2.3.11':
resolution: {integrity: sha512-ccfcIDlogiXNq5KcbAwbaO7lMh3Tm1i3khMPYpxlK8hH/W53zN81KM9coerRLOnTGu3nfXIniAmQbRI9OxbC0w==}
engines: {node: '>= 0.4'}
@ -2426,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==}
@ -3328,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==}
@ -3338,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==}
@ -4255,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==}
@ -4911,6 +4933,9 @@ packages:
bing-translate-api@4.0.2:
resolution: {integrity: sha512-JJ8XUehnxzOhHU91oy86xEtp8OOMjVEjCZJX042fKxoO19NNvxJ5omeCcxQNFoPbDqVpBJwqiGVquL0oPdQm1Q==}
bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@ -6198,6 +6223,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
easy-currency-symbol@1.0.1:
resolution: {integrity: sha512-M8ugWXSnV5utVTJd4uLOsXqg/sv8Ca7yrDvbSy01mQ52u2XxNnoJ0+tenh3gYCvcceRRLkG/AzIVP/rdjKGuAg==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
@ -7266,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==}
@ -9287,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==}
@ -9378,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
@ -9926,6 +9943,10 @@ packages:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
prom-client@15.1.3:
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
engines: {node: ^16 || ^18 || >=20}
promise-coalesce@1.1.2:
resolution: {integrity: sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==}
engines: {node: '>=16'}
@ -10311,6 +10332,7 @@ packages:
react-beautiful-dnd@13.1.1:
resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==}
deprecated: 'react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672'
peerDependencies:
react: ^16.8.5 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0
@ -11582,6 +11604,9 @@ packages:
tcp-ping@0.1.1:
resolution: {integrity: sha512-7Ed10Ds0hYnF+O1lfiZ2iSZ1bCAj+96Madctebmq7Y1ALPWlBY4YI8C6pCL+UTlshFY5YogixKLpgDP/4BlHrw==}
tdigest@0.1.2:
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
teex@1.0.1:
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
@ -14173,7 +14198,7 @@ snapshots:
'@babel/helper-split-export-declaration': 7.22.6
'@babel/parser': 7.24.0
'@babel/types': 7.24.0
debug: 4.3.6
debug: 4.3.7
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@ -14188,7 +14213,7 @@ snapshots:
'@babel/helper-split-export-declaration': 7.22.6
'@babel/parser': 7.24.0
'@babel/types': 7.24.0
debug: 4.3.6
debug: 4.3.7
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@ -15118,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
@ -15259,6 +15295,8 @@ snapshots:
'@leichtgewicht/ip-codec@2.0.5': {}
'@lemonsqueezy/lemonsqueezy.js@3.3.1': {}
'@ljharb/through@2.3.11':
dependencies:
call-bind: 1.0.7
@ -17911,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': {}
@ -18273,13 +18296,13 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.3.6
debug: 4.3.7
transitivePeerDependencies:
- supports-color
agent-base@7.1.0:
dependencies:
debug: 4.3.6
debug: 4.3.7
transitivePeerDependencies:
- supports-color
@ -18790,6 +18813,8 @@ snapshots:
dependencies:
got: 11.8.6
bintrees@1.0.2: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
@ -20310,6 +20335,8 @@ snapshots:
eastasianwidth@0.2.0: {}
easy-currency-symbol@1.0.1: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
@ -20714,7 +20741,7 @@ snapshots:
extract-zip@2.0.1:
dependencies:
debug: 4.3.6
debug: 4.3.7
get-stream: 5.2.0
yauzl: 2.10.0
optionalDependencies:
@ -21065,7 +21092,7 @@ snapshots:
dependencies:
basic-ftp: 5.0.3
data-uri-to-buffer: 6.0.1
debug: 4.3.6
debug: 4.3.7
fs-extra: 8.1.0
transitivePeerDependencies:
- supports-color
@ -21659,14 +21686,14 @@ snapshots:
http-proxy-agent@7.0.0:
dependencies:
agent-base: 7.1.0
debug: 4.3.6
debug: 4.3.7
transitivePeerDependencies:
- supports-color
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.0
debug: 4.3.6
debug: 4.3.7
transitivePeerDependencies:
- supports-color
@ -21707,7 +21734,7 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.3.6
debug: 4.3.7
transitivePeerDependencies:
- supports-color
@ -21721,21 +21748,21 @@ snapshots:
https-proxy-agent@7.0.0:
dependencies:
agent-base: 7.1.0
debug: 4.3.6
debug: 4.3.7
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.0
debug: 4.3.6
debug: 4.3.7
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.5:
dependencies:
agent-base: 7.1.0
debug: 4.3.6
debug: 4.3.7
transitivePeerDependencies:
- supports-color
@ -21749,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)
@ -23475,7 +23506,7 @@ snapshots:
micromark@3.2.0:
dependencies:
'@types/debug': 4.1.12
debug: 4.3.6
debug: 4.3.7
decode-named-character-reference: 1.0.2
micromark-core-commonmark: 1.1.0
micromark-factory-space: 1.1.0
@ -24200,7 +24231,7 @@ snapshots:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
agent-base: 7.1.0
debug: 4.3.6
debug: 4.3.7
get-uri: 6.0.2
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.5
@ -24295,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: {}
@ -24369,8 +24387,6 @@ snapshots:
pathval@1.1.1: {}
pause@0.0.1: {}
pbf@3.2.1:
dependencies:
ieee754: 1.2.1
@ -24908,6 +24924,11 @@ snapshots:
progress@2.0.3: {}
prom-client@15.1.3:
dependencies:
'@opentelemetry/api': 1.4.1
tdigest: 0.1.2
promise-coalesce@1.1.2: {}
promise-retry@2.0.1:
@ -24966,7 +24987,7 @@ snapshots:
proxy-agent@6.4.0:
dependencies:
agent-base: 7.1.0
debug: 4.3.6
debug: 4.3.7
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.5
lru-cache: 7.18.3
@ -26954,7 +26975,7 @@ snapshots:
socks-proxy-agent@8.0.2:
dependencies:
agent-base: 7.1.0
debug: 4.3.6
debug: 4.3.7
socks: 2.7.1
transitivePeerDependencies:
- supports-color
@ -27459,6 +27480,10 @@ snapshots:
tcp-ping@0.1.1: {}
tdigest@0.1.2:
dependencies:
bintrees: 1.0.2
teex@1.0.1:
dependencies:
streamx: 2.20.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,32 @@
import { cn } from '@/utils/style';
import React, { PropsWithChildren } from 'react';
import copy from 'copy-to-clipboard';
import { useEvent } from '@/hooks/useEvent';
import { toast } from 'sonner';
import { useTranslation } from '@i18next-toolkit/react';
interface CopyableTextProps extends PropsWithChildren {
className?: string;
text: string;
}
export const CopyableText: React.FC<CopyableTextProps> = React.memo((props) => {
const { t } = useTranslation();
const handleClick = useEvent(() => {
copy(props.text);
toast.success(t('Copied'));
});
return (
<span
className={cn(
'cursor-pointer select-none rounded bg-white bg-opacity-10 px-2',
'hover:bg-white hover:bg-opacity-20',
props.className
)}
onClick={handleClick}
>
{props.children ?? props.text}
</span>
);
});
CopyableText.displayName = 'CopyableText';

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

@ -3,41 +3,56 @@ import React from 'react';
import { useTranslation } from '@i18next-toolkit/react';
import { CodeExample } from '../CodeExample';
export const FeedApiGuide: React.FC<{ channelId: string }> = React.memo(
(props) => {
const { t } = useTranslation();
interface FeedApiGuideProps {
channelId: string;
webhookSignature?: string;
}
export const FeedApiGuide: React.FC<FeedApiGuideProps> = React.memo((props) => {
const { t } = useTranslation();
return (
<Card className="w-full overflow-hidden">
<CardHeader>
<div>{t('You can send a message to this channel with:')}</div>
</CardHeader>
<CardContent className="flex w-full flex-col gap-5 overflow-hidden">
<CodeExample
example={{
curl: {
label: 'curl',
code: generateCurlCode(props.channelId),
},
fetch: {
label: 'fetch',
code: generateFetchCode(props.channelId),
},
}}
/>
return (
<Card className="w-full overflow-hidden">
<CardHeader>
<div>{t('You can send a message to this channel with:')}</div>
</CardHeader>
<CardContent className="flex w-full flex-col gap-5 overflow-hidden">
<CodeExample
example={{
curl: {
label: 'curl',
code: generateCurlCode(props.channelId, props.webhookSignature),
},
fetch: {
label: 'fetch',
code: generateFetchCode(props.channelId, props.webhookSignature),
},
}}
/>
<div className="pl-2 font-bold">{t('OR')}</div>
<div className="pl-2 font-bold">{t('OR')}</div>
<div>{t('Integrate with third party with webhook')}</div>
</CardContent>
</Card>
);
}
);
<div>{t('Integrate with third party with webhook')}</div>
</CardContent>
</Card>
);
});
FeedApiGuide.displayName = 'FeedApiGuide';
function generateCurlCode(channelId: string) {
const code = `curl -X POST ${window.location.origin}/open/feed/${channelId}/send \\
function generateCurlCode(channelId: string, webhookSignature?: string) {
if (webhookSignature) {
return `curl -X POST ${window.location.origin}/open/feed/${channelId}/send \\
-H "Content-Type: application/json" \\
-H "X-Webhook-Signature: ${webhookSignature}" \\
-d '{
"eventName": "test name",
"eventContent": "test content",
"tags": ["test"],
"source": "custom",
"important": false
}'`;
}
return `curl -X POST ${window.location.origin}/open/feed/${channelId}/send \\
-H "Content-Type: application/json" \\
-d '{
"eventName": "test name",
@ -46,12 +61,27 @@ function generateCurlCode(channelId: string) {
"source": "custom",
"important": false
}'`;
return code;
}
function generateFetchCode(channelId: string) {
const code = `fetch('${window.location.origin}/open/feed/${channelId}/send', {
function generateFetchCode(channelId: string, webhookSignature?: string) {
if (webhookSignature) {
return `fetch('${window.location.origin}/open/feed/${channelId}/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': '${webhookSignature}'
},
body: JSON.stringify({
eventName: 'test name',
eventContent: 'test content',
tags: ['test'],
source: 'custom',
important: false,
})
})`;
}
return `fetch('${window.location.origin}/open/feed/${channelId}/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@ -64,6 +94,4 @@ function generateFetchCode(channelId: string) {
important: false,
})
})`;
return code;
}

View File

@ -24,9 +24,13 @@ import {
SelectValue,
} from '../ui/select';
import { NotificationPicker } from '../notification/NotificationPicker';
import { LuRefreshCcw } from 'react-icons/lu';
import md5 from 'md5';
import dayjs from 'dayjs';
const addFormSchema = z.object({
name: z.string(),
webhookSignature: z.string().default(''),
notificationIds: z.array(z.string()).default([]),
notifyFrequency: z.enum(['none', 'event', 'day', 'week', 'month']),
});
@ -45,6 +49,7 @@ export const FeedChannelEditForm: React.FC<FeedChannelEditFormProps> =
resolver: zodResolver(addFormSchema),
defaultValues: props.defaultValues ?? {
name: 'New Channel',
webhookSignature: '',
notificationIds: [],
notifyFrequency: 'none',
},
@ -79,6 +84,38 @@ export const FeedChannelEditForm: React.FC<FeedChannelEditFormProps> =
)}
/>
<FormField
control={form.control}
name="webhookSignature"
render={({ field }) => (
<FormItem>
<FormLabel optional={true}>
{t('Webhook Signature')}
</FormLabel>
<FormControl>
<div className="flex">
<Input className="rounded-r-none" {...field} />
<Button
className="rounded-l-none"
type="button"
Icon={LuRefreshCcw}
onClick={() => {
form.setValue(
'webhookSignature',
md5(dayjs().valueOf().toString())
);
}}
/>
</div>
</FormControl>
<FormDescription>
{t('Optional, Webhook Signature for Incoming Webhook')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notificationIds"

View File

@ -4,9 +4,11 @@ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { CodeBlock } from '../CodeBlock';
import { useTranslation } from '@i18next-toolkit/react';
import { SiSentry } from 'react-icons/si';
import { FaStripe } from 'react-icons/fa6';
export const FeedIntegration: React.FC<{
feedId: string;
webhookSignature: string;
}> = React.memo((props) => {
const { t } = useTranslation();
@ -57,6 +59,22 @@ export const FeedIntegration: React.FC<{
}
/>
<FeedIntegrationItem
icon={<FaStripe size={32} />}
label="Stripe"
content={
<div>
<div className="text-lg font-bold">{t('Receive Webhooks')}</div>
<div>{t('Add sentry webhook with url')}:</div>
<CodeBlock
code={`${window.location.origin}/open/feed/${props.feedId}/stripe`}
/>
</div>
}
/>
<div onClick={() => window.open('/feed/playground', '_blank')}>
<FeedIntegrationItemTrigger
icon={<LuTestTube2 size={32} />}
@ -75,7 +93,7 @@ export const FeedIntegration: React.FC<{
<CodeBlock
code={`POST ${window.location.origin}/open/feed/${props.feedId}/send
${props.webhookSignature ? `\nHeader:\nX-Webhook-Signature: ${props.webhookSignature}\n` : ''}
Body
{
eventName: "",

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

@ -61,6 +61,10 @@ export const MonitorInfo: React.FC<MonitorInfoProps> = React.memo((props) => {
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const testNotifyScriptMutation = trpc.monitor.testNotifyScript.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const trpcUtils = trpc.useContext();
@ -229,6 +233,15 @@ export const MonitorInfo: React.FC<MonitorInfoProps> = React.memo((props) => {
label: t('Show Badge'),
onClick: () => setShowBadge(true),
},
{
key: 'testNotify',
label: t('Test Notify'),
onClick: () =>
testNotifyScriptMutation.mutateAsync({
workspaceId,
monitorId,
}),
},
{
type: 'divider',
},

View File

@ -2,6 +2,7 @@ import { AppRouterOutput, trpc } from '@/api/trpc';
import React, { useMemo, useReducer } from 'react';
import { bodySchema } from './schema';
import { Empty } from 'antd';
import { Separator } from '@/components/ui/separator';
import { useTranslation } from '@i18next-toolkit/react';
import { cn } from '@/utils/style';
import {
@ -36,12 +37,13 @@ export const StatusPageBody: React.FC<StatusPageBodyProps> = React.memo(
}, [info.body]);
return (
<div>
<div className="rounded-lg border border-gray-200/80 dark:border-gray-700/25">
{body.groups.map((group) => (
<div key={group.key} className="mb-6">
<div className="mb-2 text-lg font-semibold">{group.title}</div>
<div className="flex flex-col gap-4 rounded-md border border-gray-200 p-2.5 dark:border-gray-700">
<div key={group.key} className="m-4 rounded-lg bg-neutral-500/15">
<div className="ml-4 pl-2.5 pt-2.5 text-lg font-semibold">
{group.title}
</div>
<div className="flex flex-col gap-2 rounded-md p-2.5">
{group.children.length === 0 && (
<Empty description={t('No any monitor has been set')} />
)}
@ -49,12 +51,14 @@ export const StatusPageBody: React.FC<StatusPageBodyProps> = React.memo(
{group.children.map((item) => {
if (item.type === 'monitor') {
return (
<StatusItemMonitor
key={item.key}
workspaceId={props.workspaceId}
monitorId={item.id}
showCurrent={item.showCurrent ?? false}
/>
<React.Fragment key={item.key}>
<Separator />
<StatusItemMonitor
workspaceId={props.workspaceId}
monitorId={item.id}
showCurrent={item.showCurrent ?? false}
/>
</React.Fragment>
);
}
@ -121,7 +125,7 @@ export const StatusItemMonitor: React.FC<{
<div>
<span
className={cn(
'inline-block min-w-[62px] rounded-full p-0.5 text-center text-white',
'text-white inline-block min-w-[62px] rounded-lg p-0.5 text-center font-semibold',
getStatusBgColorClassName(summaryStatus)
)}
>

View File

@ -0,0 +1,199 @@
import React, { useMemo } from 'react';
import { cn } from '@/utils/style';
import { bodySchema } from './schema';
import { LuCheckCircle2, LuCircleSlash, LuAlertCircle } from 'react-icons/lu';
import { AppRouterOutput, trpc } from '../../../api/trpc';
import { getMonitorProvider, getProviderDisplay } from '../provider';
import { takeRight, last } from 'lodash-es';
import dayjs from 'dayjs';
import { IconType } from 'react-icons';
import { useTranslation } from '@i18next-toolkit/react';
interface StatusPageHeaderProps {
info: NonNullable<AppRouterOutput['monitor']['getPageInfo']>;
workspaceId: string;
}
interface ContextItem {
id: string;
groupId: string;
groupName: string;
}
type StatusType = 'operational' | 'degraded' | 'offline' | 'unknown';
export const StatusPageHeader: React.FC<StatusPageHeaderProps> = React.memo(
({ info, workspaceId }) => {
const { t } = useTranslation();
const body = useMemo(() => {
const res = bodySchema.safeParse(info.body);
return res.success ? res.data : { groups: [] };
}, [info.body]);
const monitorContexts = useMemo(() => {
const contexts: ContextItem[] = [];
body.groups.forEach((group) => {
group.children.forEach((item) => {
if (item.type === 'monitor') {
contexts.push({
id: item.id,
groupId: group.key,
groupName: group.title,
});
}
});
});
if (Array.isArray(info.monitorList)) {
info.monitorList.forEach((monitor) => {
contexts.push({
id: monitor.id,
groupId: 'deprecated',
groupName: 'Legacy Monitors',
});
});
}
return contexts;
}, [body, info.monitorList]);
const recentDataQueries = monitorContexts.map((context) => {
const { data: recentData = [] } = trpc.monitor.recentData.useQuery({
workspaceId,
monitorId: context.id,
take: 1,
});
const items = useMemo(() => {
return takeRight(
[...Array.from({ length: 1 }).map(() => null), ...recentData],
1
);
}, [recentData]);
const provider = useMemo(
() => getMonitorProvider(context.id),
[context.id]
);
const latestStatus = useMemo(() => {
const latestItem = last(items);
if (!latestItem) {
return 'none';
}
const { value, createdAt } = latestItem;
const { text } = getProviderDisplay(value, provider);
const title = `${dayjs(createdAt).format('YYYY-MM-DD HH:mm')} | ${text}`;
return value < 0
? { status: 'error', title }
: { status: 'health', title };
}, [items, provider]);
return {
id: context.id,
status: latestStatus === 'none' ? undefined : latestStatus.status,
timestamp: dayjs(last(items)?.createdAt).valueOf(),
};
});
const { overallStatus, lastChecked } = useMemo(() => {
let totalCount = 0;
let errorCount = 0;
let latestTimestamp = 0;
recentDataQueries.forEach((query) => {
if (!query) return;
totalCount += 1;
if (query.status != 'health') {
errorCount += 1;
}
if (
!latestTimestamp ||
(query.timestamp && query.timestamp > latestTimestamp)
) {
latestTimestamp = query.timestamp;
}
});
let status: string = 'unknown';
let uprate = ((totalCount - errorCount) / totalCount) * 100;
if (uprate > 90) {
status = 'operational';
} else if (uprate > 50) {
status = 'degraded';
} else if (uprate > 0) {
status = 'offline';
}
return {
overallStatus: status as StatusType,
servicesCount: totalCount,
lastChecked: latestTimestamp,
};
}, [recentDataQueries]);
const statusConfig: Record<
StatusType,
{ text: string; icon: IconType; iconColor: string }
> = {
operational: {
text: t('All Systems Operational'),
icon: LuCheckCircle2,
iconColor: 'text-green-500',
},
degraded: {
text: t('Partial System Outage'),
icon: LuAlertCircle,
iconColor: 'text-yellow-500',
},
offline: {
text: t('Major System Outage'),
icon: LuCircleSlash,
iconColor: 'text-red-500',
},
unknown: {
text: t('Status Unknown'),
icon: LuAlertCircle,
iconColor: 'text-gray-500',
},
};
const config = statusConfig[overallStatus];
const StatusIcon = config.icon;
const formatDate = (date: number) => {
const options: Intl.DateTimeFormatOptions = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
};
const formatted = new Date(date).toLocaleString('en-US', options);
return `${t('Last updated')} ${formatted}`;
};
return (
<div className="flex flex-col items-center space-y-2">
<StatusIcon
className={cn('h-12 w-12', config.iconColor)}
aria-hidden="true"
/>
<h1 className="pb-2 pt-4 text-4xl font-bold">{config.text}</h1>
{lastChecked && (
<p className="text-md text-gray-600 dark:text-gray-400">
{formatDate(lastChecked)}
</p>
)}
</div>
);
}
);
export default StatusPageHeader;

View File

@ -9,6 +9,7 @@ import clsx from 'clsx';
import { useRequest } from '../../../hooks/useRequest';
import { ColorSchemeSwitcher } from '../../ColorSchemeSwitcher';
import { StatusPageServices } from './Services';
import { StatusPageHeader } from './StatusHeader';
import { useTranslation } from '@i18next-toolkit/react';
import { Link, useNavigate } from '@tanstack/react-router';
import { Helmet } from 'react-helmet';
@ -146,8 +147,14 @@ export const MonitorStatusPage: React.FC<MonitorStatusPageProps> = React.memo(
</div>
)}
{info && (
<div className="my-6">
<StatusPageHeader info={info} workspaceId={info.workspaceId} />
</div>
)}
{/* Desc */}
<div className="mb-4">
<div className="mb-6 text-center">
<MarkdownViewer value={info?.description ?? ''} />
</div>

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",
@ -70,6 +70,7 @@
"leaflet": "^1.9.4",
"lodash-es": "^4.17.21",
"lucide-react": "^0.358.0",
"md5": "^2.3.0",
"millify": "^6.1.0",
"next-themes": "^0.2.1",
"pretty-ms": "^9.0.0",
@ -106,6 +107,7 @@
"@types/leaflet": "^1.9.8",
"@types/loadable__component": "^5.13.8",
"@types/lodash-es": "^4.17.12",
"@types/md5": "^2.3.5",
"@types/react": "^18.2.22",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "^18.2.7",

View File

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 269 B

View File

@ -19,11 +19,14 @@
"k17058821": "Website Lighthouse Berichte",
"k172a09c3": "Vorschläge",
"k1777bbf2": "Manuell",
"k1940fd6": "Allgemein",
"k1964b988": "Stopp",
"k1bd89236": "Reporter mit ausführen",
"k1c33c293": "Einstellungen",
"k1d8f92b4": "Tablet",
"k1da4ecc2": "Sie können eine Nachricht an diesen Kanal senden mit:",
"k1eb5b3ed": "Übersicht",
"k1ee0c2ca": "Setzen Sie die Webhook-URL auf <1></1> und halten Sie dieses Fenster aktiv. Sobald Sie fertig sind, beginnen Sie, Webhook-Anfragen hier zu empfangen.",
"k1f6dea0": "Kanalname",
"k2099f2e0": "Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihren Benutzernamen und Ihr Passwort",
"k20edf271": "24 Stunden",
@ -53,12 +56,15 @@
"k2c84fe32": "Feed-Ereigniszähler",
"k2cecf817": "Typ",
"k2dad13e3": "Sprache",
"k2db2c0c5": "Testbenachrichtigung",
"k2e6dbf02": "An E-Mail",
"k2ea8a019": "Überwachen",
"k30b5f01b": "Arbeitsbereiche",
"k30d33d71": "Webhook-Signatur",
"k310fee": "Letzte 30 Tage",
"k32344f64": "Daten löschen",
"k3260f019": "Abmelden",
"k3404b72f": "Neuer Arbeitsbereichsname",
"k340547f0": "Entschuldigung, aber etwas ist schief gelaufen",
"k3471e956": "Neues Passwort wiederholen",
"k34981fea": "Docker treibt auf See und findet seinen Weg nicht. Bitte starten Sie Docker, um wieder auf Kurs zu kommen.",
@ -85,6 +91,7 @@
"k3e8b13f8": "Discord beitreten",
"k3eaab921": "ÜberwachungsListe",
"k3f36e17e": "Twitter folgen",
"k406089a4": "Aktion",
"k406e9ad8": "Bestätigen",
"k41d3ce6c": "Ereignis wiederhergestellt",
"k42347b91": "Website-Ereigniszählung",
@ -93,7 +100,8 @@
"k44186b66": "Zählung",
"k44cad477": "(Aktuell)",
"k45f80a27": "Erweitert",
"k4738284": "Sie können jede Nachricht in diesen Kanal mit folgendem senden:",
"k4727e4db": "Ablaufdatum",
"k477b7ee4": "Teilweise Systemausfälle",
"k47fe1f95": "Fügen Sie diesen Beispielcode zu Ihrem Projekt hinzu",
"k48186ce": "Zurück zur Startseite",
"k4905ed7b": "KEINE",
@ -107,6 +115,7 @@
"k4de48e75": "Maximale Wiederholungen",
"k4e08cf58": "Detailnummer anzeigen",
"k4eea9393": "Profil",
"k4f182a7c": "Wichtige Systemausfälle",
"k4fc2b5b": "Bild",
"k4fe1b4de": "Telemetrie",
"k505c2733": "Bericht erstellen",
@ -123,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",
@ -135,6 +147,7 @@
"k62e19375": "Letzte Aktualisierung: {{date}}",
"k6488f302": "Optional",
"k659b065": "Zum Beispiel: https://open.feishu.cn/open-apis/bot/v2/hook/00000000-0000-0000-0000-000000000000",
"k678e2f90": "Anforderungsinhalt",
"k67c5a895": "Gestern",
"k683be220": "Ausführen",
"k691b7170": "Gestoppt",
@ -147,9 +160,11 @@
"k6e96fc3": "Formularinfo",
"k6ea11aff": "Holen!",
"k6f15bcc3": "Host",
"k71067412": "Optional, Webhook-Signatur für eingehenden Webhook",
"k721589c1": "Heute",
"k7247683c": "Arbeitsbereich löschen",
"k7350bd93": "Gleichzeitig können wir es auch in einigen Client-Seiten-Anwendungsszenarien verwenden, wie z.B. das Sammeln der Häufigkeit der CLI-Nutzung, das Sammeln der Installation von selbst gehosteten Apps und so weiter.",
"k736f3e4c": "Kopieren als",
"k75581e13": "Kreditkarte",
"k75bfaaa6": "Fügen Sie diesen Code in das Kopf-Skript Ihrer Website ein",
"k763816ac": "Vorschau",
@ -157,16 +172,17 @@
"k78b1ef6a": "Eingeben",
"k7927b824": "Sind Sie sicher, alle Offline-Knoten zu löschen?",
"k7a132ce8": "Entschuldigung, aber diese Seite wurde nicht gefunden",
"k7a15497a": "Echtzeit",
"k7ac44a6e": "Sitzungsschlüssel",
"k7b74a43f": "Besucher",
"k7b75e24c": "Integration",
"k7b9aa48c": "Inhalt",
"k7cac602a": "Status",
"k7d8cd81c": "URL kopieren",
"k7e0360fd": "Es wurde keine Gruppe erstellt, klicken Sie auf die Schaltfläche, um eine zu erstellen",
"k7e61b1af": "Arbeitsbereich auswählen",
"k7f01b47c": "Prüfprotokoll",
"k7f03a704": "Denken Sie daran, keine Daten mit application/json zu senden",
"k7f29bae5": "Seitenaufrufe",
"k8037cc6b": "Server",
"k816ce026": "Herunterladen",
"k819633bc": "Zur Speicherung verwenden",
@ -176,6 +192,7 @@
"k84ce1618": "(24 Stunden)",
"k84e82947": "{{num}} Ereignisse gelöscht",
"k85344b23": "Laden",
"k85a116ee": "Webhook-URL",
"k85c5fd4c": "Noch kein Monitor eingerichtet",
"k85db19da": "Noch kein Feed-Kanal vorhanden. Verwenden Sie die Feed-Funktion, um alle Ereignisse aus dem Netzwerk oder Ihrem eigenen Dienst zu empfangen.",
"k873c90e6": "Anzeigelabel",
@ -188,6 +205,7 @@
"k88d2647b": "Webseite",
"k89056082": "(30 Tage)",
"k892f84b6": "Aktuelle Benutzerinformationen können nicht abgerufen werden",
"k895cafe1": "Optional, Webhook-URL zum Senden der Umfrage-Payload",
"k899fd0cd": "Ports",
"k89d54f7a": "Überwachung der Ausführungszählung",
"k8a1deb63": "Mitglieder",
@ -207,7 +225,10 @@
"k90b603b8": "Duplizieren",
"k90b668e5": "Letzte 24 Stunden",
"k93374bc9": "Website löschen",
"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?",
@ -233,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",
@ -254,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",
@ -261,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",
@ -296,13 +320,16 @@
"kcc9c1bff": "Jede Woche",
"kccaa732a": "Keine aufeinanderfolgenden Bindestriche",
"kccb42483": "Passwort",
"kcd56f27b": "Zuletzt aktualisiert",
"kcd643ef3": "Lade...",
"kce77d0c1": "Zeitzone",
"kcff78587": "Zuletzt verwendet am",
"kd005f7a8": "Alle Feeds werden entfernt",
"kd031b383": "Ansichten",
"kd044d5d4": "Session",
"kd092de58": "Aktueller Arbeitsbereich:",
"kd1f7e695": "Abmelden bestätigen",
"kd211e2d4": "Versionsseite",
"kd25f123a": "Status unbekannt",
"kd2a7ad83": "Feed-Vorlage",
"kd3262a4a": "Konfig",
"kd3396544": "Allgemein werden wir ein ein Pixel großes leeres Bild verwenden, sodass es die normale Nutzung des Benutzers nicht beeinträchtigt.",
@ -311,14 +338,18 @@
"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",
"kdc51b5db": "Webseiten",
"kdd44ac01": "Anzuzeigender Telemetrie-Name",
"kdd55936a": "Resolver-Port",
"kde315178": "Umbenennen",
"kde37bc27": "Zurück zum Admin",
"kdeba7706": "Geräte",
"kdeecbfea": "Resolver-Server",
@ -359,6 +390,7 @@
"kf246dd2e": "Es wurde kein Arbeitsbereich gefunden, bitte zuerst erstellen",
"kf3b749ef": "Unterstützt Direktchat / Gruppe / Kanal-Chat-ID",
"kf55495e0": "Speichern",
"kf5c3b616": "Anforderungsheader",
"kf5c9520e": "Noch keine Statusseite vorhanden, Sie können eine neue erstellen, um den Status Ihres Dienstes der Öffentlichkeit anzuzeigen.",
"kf6339d4f": "Verifiziert",
"kf6582ba": "Arbeitsbereich",
@ -374,6 +406,7 @@
"kf97b6f71": "Führen Sie diesen Befehl auf Ihrer Linux-Maschine aus",
"kf9877f28": "Details anzeigen",
"kf9965c19": "Alle Inhalte in diesem Arbeitsbereich werden zerstört und können nicht wiederhergestellt werden.",
"kf9a498c7": "Lighthouse-Bericht abgeschlossen!",
"kfc98929b": "{{num}} Tage",
"kfd33c459": "Kopieren erfolgreich!",
"kfdaf0bb3": "Zuletzt online: {{time}}",

View File

@ -19,11 +19,14 @@
"k17058821": "Website Lighthouse Reports",
"k172a09c3": "Suggestions",
"k1777bbf2": "Manual",
"k1940fd6": "General",
"k1964b988": "Stop",
"k1bd89236": "run reporter with",
"k1c33c293": "Settings",
"k1d8f92b4": "Tablet",
"k1da4ecc2": "You can send a message to this channel with:",
"k1eb5b3ed": "Overview",
"k1ee0c2ca": "Set the webhook URL to <1></1>, and keep this window active. Once done, you will start receiving webhook requests here.",
"k1f6dea0": "Channel Name",
"k2099f2e0": "Login failed, please check your username and password",
"k20edf271": "24h",
@ -53,12 +56,15 @@
"k2c84fe32": "Feed Event Count",
"k2cecf817": "Type",
"k2dad13e3": "Language",
"k2db2c0c5": "Test Notify",
"k2e6dbf02": "To Email",
"k2ea8a019": "Monitor",
"k30b5f01b": "Workspaces",
"k30d33d71": "Webhook Signature",
"k310fee": "Last 30 days",
"k32344f64": "Clear Data",
"k3260f019": "Logout",
"k3404b72f": "New Workspace Name",
"k340547f0": "Sorry, but something went wrong",
"k3471e956": "Repaet New Password",
"k34981fea": "Docker is adrift at sea, unable to find its way. Please start Docker to get back on course.",
@ -85,6 +91,7 @@
"k3e8b13f8": "Join Discord",
"k3eaab921": "Monitor List",
"k3f36e17e": "Follow Twitter",
"k406089a4": "Action",
"k406e9ad8": "Confirm",
"k41d3ce6c": "Event unarchived",
"k42347b91": "Website Event Count",
@ -93,7 +100,8 @@
"k44186b66": "Count",
"k44cad477": "(Current)",
"k45f80a27": "Advanced",
"k4738284": "You can send a message to this channel with:",
"k4727e4db": "Expired At",
"k477b7ee4": "Partial System Outage",
"k47fe1f95": "Add this example code into your project",
"k48186ce": "Back to Homepage",
"k4905ed7b": "NONE",
@ -107,6 +115,7 @@
"k4de48e75": "Max Retries",
"k4e08cf58": "Show Detail Number",
"k4eea9393": "Profile",
"k4f182a7c": "Major System Outage",
"k4fc2b5b": "Image",
"k4fe1b4de": "Telemetry",
"k505c2733": "Create Report",
@ -123,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",
@ -135,6 +147,7 @@
"k62e19375": "Last updated at: {{date}}",
"k6488f302": "Optional",
"k659b065": "For example: https://open.feishu.cn/open-apis/bot/v2/hook/00000000-0000-0000-0000-000000000000",
"k678e2f90": "Request Body",
"k67c5a895": "Yesterday",
"k683be220": "Run",
"k691b7170": "Stopped",
@ -147,9 +160,11 @@
"k6e96fc3": "Form Info",
"k6ea11aff": "Get!",
"k6f15bcc3": "Host",
"k71067412": "Optional, Webhook Signature for Incoming Webhook",
"k721589c1": "Today",
"k7247683c": "Delete Workspace",
"k7350bd93": "At the same time, we can also use it in some client-side application scenarios, such as collecting the frequency of cli usage or installation of selfhosted apps, and so on.",
"k736f3e4c": "Copy as",
"k75581e13": "CC",
"k75bfaaa6": "Add this code into your website head script",
"k763816ac": "Preview",
@ -157,16 +172,17 @@
"k78b1ef6a": "Enter",
"k7927b824": "Are you sure to clear all offline nodes?",
"k7a132ce8": "Sorry, but this page is not found",
"k7a15497a": "Realtime",
"k7ac44a6e": "Session Key",
"k7b74a43f": "visitors",
"k7b75e24c": "Integration",
"k7b9aa48c": "Body",
"k7cac602a": "Status",
"k7d8cd81c": "Copy URL",
"k7e0360fd": "No group has been created yet, click button to create one",
"k7e61b1af": "Select Workspace",
"k7f01b47c": "Audit Log",
"k7f03a704": "Dont remember send data with application/json",
"k7f29bae5": "pageview",
"k8037cc6b": "Servers",
"k816ce026": "Download",
"k819633bc": "Use for storage",
@ -176,6 +192,7 @@
"k84ce1618": "(24 hour)",
"k84e82947": "{{num}} events cleared",
"k85344b23": "Load",
"k85a116ee": "Webhook Url",
"k85c5fd4c": "No monitor has been set",
"k85db19da": "No feed channel yet. Use feed feature to receive any event from your network or service.",
"k873c90e6": "Display Label",
@ -188,6 +205,7 @@
"k88d2647b": "Website",
"k89056082": "(30 days)",
"k892f84b6": "Can not get current user info",
"k895cafe1": "Optional, webhook url to send survey payload",
"k899fd0cd": "ports",
"k89d54f7a": "Monitor Execution Count",
"k8a1deb63": "Members",
@ -207,7 +225,10 @@
"k90b603b8": "Duplicate",
"k90b668e5": "Last 24 Hours",
"k93374bc9": "Delete Website",
"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?",
@ -233,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",
@ -254,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",
@ -261,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",
@ -296,13 +320,16 @@
"kcc9c1bff": "Every Week",
"kccaa732a": "No consecutive dashes",
"kccb42483": "Password",
"kcd56f27b": "Last updated",
"kcd643ef3": "Loading...",
"kce77d0c1": "Timezone",
"kcff78587": "Last Use At",
"kd005f7a8": "All feed will be remove",
"kd031b383": "Views",
"kd044d5d4": "session",
"kd092de58": "Current Workspace:",
"kd1f7e695": "Confirm to logout",
"kd211e2d4": "Releases Page",
"kd25f123a": "Status Unknown",
"kd2a7ad83": "Feed Template",
"kd3262a4a": "Config",
"kd3396544": "Generally, we will use a one-pixel blank image so that it will not affect the user's normal use.",
@ -311,14 +338,18 @@
"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",
"kdc51b5db": "Websites",
"kdd44ac01": "Telemetry Name to Display",
"kdd55936a": "Resolver Port",
"kde315178": "Rename",
"kde37bc27": "Back to Admin",
"kdeba7706": "Devices",
"kdeecbfea": "Resolver Server",
@ -359,6 +390,7 @@
"kf246dd2e": "Not any workspace has been found, please create first",
"kf3b749ef": "Support Direct Chat / Group / Channel's Chat ID",
"kf55495e0": "Save",
"kf5c3b616": "Request Header",
"kf5c9520e": "No any status page yet, you can create a new one to show your service status to public.",
"kf6339d4f": "Verified",
"kf6582ba": "Workspace",
@ -374,6 +406,7 @@
"kf97b6f71": "Run this command in your linux machine",
"kf9877f28": "View Details",
"kf9965c19": "All content in this workspace will be destory and can not recover.",
"kf9a498c7": "Lighthouse report completed!",
"kfc98929b": "{{num}} days",
"kfd33c459": "Copy success!",
"kfdaf0bb3": "Last online: {{time}}",

View File

Before

Width:  |  Height:  |  Size: 267 B

After

Width:  |  Height:  |  Size: 267 B

View File

@ -19,11 +19,14 @@
"k17058821": "Rapports Lighthouse du site Web",
"k172a09c3": "Suggestions",
"k1777bbf2": "Manuel",
"k1940fd6": "Général",
"k1964b988": "Arrêter",
"k1bd89236": "exécuter le rapporteur avec",
"k1c33c293": "Paramètres",
"k1d8f92b4": "Tablette",
"k1da4ecc2": "Vous pouvez envoyer un message à ce canal avec :",
"k1eb5b3ed": "Aperçu",
"k1ee0c2ca": "Définissez l'URL du webhook sur <1></1>, et gardez cette fenêtre active. Une fois terminé, vous commencerez à recevoir des requêtes webhook ici.",
"k1f6dea0": "Nom du canal",
"k2099f2e0": "Échec de la connexion, veuillez vérifier votre nom d'utilisateur et votre mot de passe",
"k20edf271": "24h",
@ -53,12 +56,15 @@
"k2c84fe32": "Nombre d'événements de flux",
"k2cecf817": "Type",
"k2dad13e3": "Langue",
"k2db2c0c5": "Test Notify",
"k2e6dbf02": "À l'email",
"k2ea8a019": "Moniteur",
"k30b5f01b": "Espaces de travail",
"k30d33d71": "Signature du Webhook",
"k310fee": "30 derniers jours",
"k32344f64": "Effacer les données",
"k3260f019": "Déconnexion",
"k3404b72f": "Nouveau nom d'espace de travail",
"k340547f0": "Désolé, mais quelque chose s'est mal passé",
"k3471e956": "Répéter le nouveau mot de passe",
"k34981fea": "Docker est à la dérive en mer, incapable de trouver son chemin. Veuillez démarrer Docker pour revenir sur la bonne voie.",
@ -85,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",
@ -93,7 +100,8 @@
"k44186b66": "Compte",
"k44cad477": "(Actuel)",
"k45f80a27": "Avancé",
"k4738284": "Vous pouvez envoyer n'importe quel message dans ce canal avec :",
"k4727e4db": "Expiré À",
"k477b7ee4": "Panne partielle du système",
"k47fe1f95": "Ajoutez ce code d'exemple à votre projet",
"k48186ce": "Retour à la page d'accueil",
"k4905ed7b": "AUCUN",
@ -107,6 +115,7 @@
"k4de48e75": "Nombre maximum de tentatives",
"k4e08cf58": "Afficher le numéro de détail",
"k4eea9393": "Profil",
"k4f182a7c": "Panne majeure du système",
"k4fc2b5b": "Image",
"k4fe1b4de": "Télémétrie",
"k505c2733": "Créer un rapport",
@ -123,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",
@ -135,6 +147,7 @@
"k62e19375": "Dernière mise à jour : {{date}}",
"k6488f302": "Optionnel",
"k659b065": "Par exemple : https://open.feishu.cn/open-apis/bot/v2/hook/00000000-0000-0000-0000-000000000000",
"k678e2f90": "Corps de la requête",
"k67c5a895": "Hier",
"k683be220": "Exécuter",
"k691b7170": "Arrêté",
@ -147,9 +160,11 @@
"k6e96fc3": "Informations sur le formulaire",
"k6ea11aff": "Obtenir !",
"k6f15bcc3": "Hôte",
"k71067412": "Optionnel, signature du webhook pour le webhook entrant",
"k721589c1": "Aujourd'hui",
"k7247683c": "Supprimer l'espace de travail",
"k7350bd93": "En même temps, nous pouvons également l'utiliser dans certains scénarios d'application côté client, comme la collecte de la fréquence d'utilisation du CLI, la collecte de l'installation d'applications auto-hébergées, etc.",
"k736f3e4c": "Copier en tant que",
"k75581e13": "Sous-titres",
"k75bfaaa6": "Ajoutez ce code dans le script de tête de votre site web",
"k763816ac": "Aperçu",
@ -157,16 +172,17 @@
"k78b1ef6a": "Entrer",
"k7927b824": "Êtes-vous sûr de vouloir effacer tous les nœuds hors ligne ?",
"k7a132ce8": "Désolé, mais cette page est introuvable",
"k7a15497a": "Temps réel",
"k7ac44a6e": "Clé de session",
"k7b74a43f": "visiteurs",
"k7b75e24c": "Intégration",
"k7b9aa48c": "Corps",
"k7cac602a": "Statut",
"k7d8cd81c": "Copier l'URL",
"k7e0360fd": "Aucun groupe n'a été créé, cliquez sur le bouton pour en créer un",
"k7e61b1af": "Sélectionner l'espace de travail",
"k7f01b47c": "Journal d'audit",
"k7f03a704": "N'oubliez pas de ne pas envoyer de données avec application/json",
"k7f29bae5": "Vue de la page",
"k8037cc6b": "Serveurs",
"k816ce026": "Télécharger",
"k819633bc": "Utiliser pour le stockage",
@ -176,6 +192,7 @@
"k84ce1618": "(24 heures)",
"k84e82947": "{{num}} événements effacés",
"k85344b23": "Charge",
"k85a116ee": "URL du Webhook",
"k85c5fd4c": "Aucun moniteur n'a été défini",
"k85db19da": "Pas encore de canal de flux. Utilisez la fonctionnalité de flux pour recevoir tous les événements du réseau ou de votre propre service.",
"k873c90e6": "Étiquette d'affichage",
@ -188,6 +205,7 @@
"k88d2647b": "Site Web",
"k89056082": "(30 jours)",
"k892f84b6": "Impossible d'obtenir les informations de l'utilisateur actuel",
"k895cafe1": "Optionnel, URL du webhook pour envoyer la charge utile de l'enquête",
"k899fd0cd": "Ports",
"k89d54f7a": "Compte des exécutions surveillées",
"k8a1deb63": "Membres",
@ -207,7 +225,10 @@
"k90b603b8": "Dupliquer",
"k90b668e5": "24 dernières heures",
"k93374bc9": "Supprimer le site Web",
"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 ?",
@ -233,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",
@ -254,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",
@ -261,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",
@ -296,13 +320,16 @@
"kcc9c1bff": "Toutes les semaines",
"kccaa732a": "Pas de tirets consécutifs",
"kccb42483": "Mot de passe",
"kcd56f27b": "Dernière mise à jour",
"kcd643ef3": "Chargement...",
"kce77d0c1": "Fuseau horaire",
"kcff78587": "Dernière Utilisation À",
"kd005f7a8": "Tous les flux seront supprimés",
"kd031b383": "Vues",
"kd044d5d4": "Session",
"kd092de58": "Espace de travail actuel :",
"kd1f7e695": "Confirmer la déconnexion",
"kd211e2d4": "Page des versions",
"kd25f123a": "Statut inconnu",
"kd2a7ad83": "Modèle de flux",
"kd3262a4a": "Configuration",
"kd3396544": "Généralement, nous utiliserons une image vide d'un pixel de sorte qu'elle n'affecte pas l'utilisation normale de l'utilisateur.",
@ -311,14 +338,18 @@
"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",
"kdc51b5db": "Sites Web",
"kdd44ac01": "Nom de la télémétrie à afficher",
"kdd55936a": "Port de résolveur",
"kde315178": "Renommer",
"kde37bc27": "Retour à l'administrateur",
"kdeba7706": "Appareils",
"kdeecbfea": "Serveur de résolveur",
@ -359,6 +390,7 @@
"kf246dd2e": "Aucun espace de travail n'a été trouvé, veuillez d'abord en créer un",
"kf3b749ef": "Prend en charge le chat direct / groupe / ID de chat de canal",
"kf55495e0": "Sauvegarder",
"kf5c3b616": "En-tête de la requête",
"kf5c9520e": "Pas encore de page de statut, vous pouvez en créer une nouvelle pour afficher l'état de votre service au public.",
"kf6339d4f": "Vérifié",
"kf6582ba": "Espace de travail",
@ -374,6 +406,7 @@
"kf97b6f71": "Exécutez cette commande sur votre machine Linux",
"kf9877f28": "Voir les détails",
"kf9965c19": "Tout le contenu de cet espace de travail sera détruit et ne pourra pas être récupéré.",
"kf9a498c7": "Rapport Lighthouse terminé !",
"kfc98929b": "{{num}} jours",
"kfd33c459": "Copie réussie !",
"kfdaf0bb3": "Dernière connexion : {{time}}",

View File

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 450 B

View File

@ -19,11 +19,14 @@
"k17058821": "ウェブサイト ライトハウス レポート",
"k172a09c3": "提案",
"k1777bbf2": "マニュアル",
"k1940fd6": "一般",
"k1964b988": "停止",
"k1bd89236": "レポーターを実行する",
"k1c33c293": "設定",
"k1d8f92b4": "タブレット",
"k1da4ecc2": "このチャンネルにメッセージを送信できます:",
"k1eb5b3ed": "概要",
"k1ee0c2ca": "Webhook URLを<1></1>に設定し、このウィンドウをアクティブに保ってください。完了すると、ここでWebhookリクエストを受信し始めます。",
"k1f6dea0": "チャンネル名",
"k2099f2e0": "ログインに失敗しました。ユーザー名とパスワードを確認してください",
"k20edf271": "24時間",
@ -53,12 +56,15 @@
"k2c84fe32": "フィードイベント数",
"k2cecf817": "タイプ",
"k2dad13e3": "言語",
"k2db2c0c5": "テスト通知",
"k2e6dbf02": "メールアドレスへ",
"k2ea8a019": "モニター",
"k30b5f01b": "ワークスペース",
"k30d33d71": "Webhook署名",
"k310fee": "過去30日間",
"k32344f64": "データクリア",
"k3260f019": "ログアウト",
"k3404b72f": "新しいワークスペース名",
"k340547f0": "申し訳ありませんが、何か問題が発生しました",
"k3471e956": "新しいパスワードの再入力",
"k34981fea": "Dockerは海上で漂流しており、方向を見失っています。Dockerを起動して進路を修正してください。",
@ -85,6 +91,7 @@
"k3e8b13f8": "Discordに参加",
"k3eaab921": "モニターリスト",
"k3f36e17e": "Twitterをフォロー",
"k406089a4": "アクション",
"k406e9ad8": "確認",
"k41d3ce6c": "イベントがアーカイブ解除されました",
"k42347b91": "ウェブサイトイベント数",
@ -93,7 +100,8 @@
"k44186b66": "カウント",
"k44cad477": "(現在)",
"k45f80a27": "詳細",
"k4738284": "次の方法でこのチャンネルにメッセージを送信できます:",
"k4727e4db": "期限切れ",
"k477b7ee4": "部分的なシステム障害",
"k47fe1f95": "このサンプルコードをプロジェクトに追加してください",
"k48186ce": "ホームページに戻る",
"k4905ed7b": "なし",
@ -107,6 +115,7 @@
"k4de48e75": "最大リトライ回数",
"k4e08cf58": "詳細番号を表示",
"k4eea9393": "プロファイル",
"k4f182a7c": "重大なシステム障害",
"k4fc2b5b": "画像",
"k4fe1b4de": "テレメトリー",
"k505c2733": "レポートを作成",
@ -123,9 +132,12 @@
"k58267a45": "ソース",
"k58f90514": "ボットトークン",
"k593cf342": "このモニターを削除してもよろしいですか?",
"k5a782f4b": "ウェブサイト数",
"k5a839f71": "アップタイム",
"k5b5be0d4": "現在の役割",
"k5c18db28": "ステータスページ情報を変更",
"k5d00536d": "コピー済み",
"k5d49d751": "新しいAPIキーがクリップボードにコピーされました",
"k5eb87a8b": "開始",
"k5ec0de4": "HTTPSモニタリングの場合、通知方法が割り当てられている場合、有効期限の1、3、7、14日前に通知が送信されます。",
"k5ecf04b0": "ビュー",
@ -135,6 +147,7 @@
"k62e19375": "最終更新:{{date}}",
"k6488f302": "オプション",
"k659b065": "例https://open.feishu.cn/open-apis/bot/v2/hook/00000000-0000-0000-0000-000000000000",
"k678e2f90": "リクエストボディ",
"k67c5a895": "昨日",
"k683be220": "実行",
"k691b7170": "停止済み",
@ -147,9 +160,11 @@
"k6e96fc3": "フォーム情報",
"k6ea11aff": "取得!",
"k6f15bcc3": "ホスト",
"k71067412": "オプション、受信WebhookのWebhook署名",
"k721589c1": "今日",
"k7247683c": "ワークスペースを削除",
"k7350bd93": "同時に、CLIの使用頻度の収集、自己ホスト型アプリのインストールの収集など、クライアントサイドのアプリケーションシナリオでも使用することができます。",
"k736f3e4c": "コピーとして",
"k75581e13": "CC",
"k75bfaaa6": "このコードをウェブサイトのヘッドスクリプトに追加してください",
"k763816ac": "プレビュー",
@ -157,16 +172,17 @@
"k78b1ef6a": "入力",
"k7927b824": "すべてのオフラインノードをクリアしてもよろしいですか?",
"k7a132ce8": "申し訳ありませんが、このページは見つかりません",
"k7a15497a": "リアルタイム",
"k7ac44a6e": "セッションキー",
"k7b74a43f": "訪問者",
"k7b75e24c": "統合",
"k7b9aa48c": "ボディ",
"k7cac602a": "ステータス",
"k7d8cd81c": "URLをコピー",
"k7e0360fd": "グループが作成されていません。ボタンをクリックして作成してください",
"k7e61b1af": "ワークスペースを選択",
"k7f01b47c": "監査ログ",
"k7f03a704": "application/json でデータを送信しないことを忘れないでください",
"k7f29bae5": "ページビュー",
"k8037cc6b": "サーバー",
"k816ce026": "ダウンロード",
"k819633bc": "ストレージ用",
@ -176,6 +192,7 @@
"k84ce1618": "24時間",
"k84e82947": "{{num}} イベントがクリアされました",
"k85344b23": "ロード",
"k85a116ee": "Webhook URL",
"k85c5fd4c": "まだモニターが設定されていません",
"k85db19da": "まだフィードチャンネルがありません。ネットワークや自分のサービスからのすべてのイベントを受信するには、フィード機能を使用してください。",
"k873c90e6": "表示ラベル",
@ -188,6 +205,7 @@
"k88d2647b": "ウェブサイト",
"k89056082": "30日間",
"k892f84b6": "現在のユーザー情報を取得できません",
"k895cafe1": "オプション、調査ペイロードを送信するためのWebhook URL",
"k899fd0cd": "ポート",
"k89d54f7a": "実行カウントの監視",
"k8a1deb63": "メンバー",
@ -207,7 +225,10 @@
"k90b603b8": "重複",
"k90b668e5": "過去24時間",
"k93374bc9": "ウェブサイトを削除",
"k93458b98": "プレイグラウンド",
"k951a939a": "ウェブサイト承認カウント",
"k95f932a": "現在、リモートサーバーからの新しいリクエストを待機中です",
"k97b02874": "ページ数",
"k98f433ee": "からレポーターをダウンロード",
"k9991c290": "コミュニティ",
"k9a272ecf": "これはあなたのサーバーですか?",
@ -233,6 +254,7 @@
"ka6ee7455": "ウェブサイトID",
"ka71c12e1": "2つのパスワードが一致しません",
"ka765ad32": "通知",
"ka7d8617e": "フィードチャンネル数",
"ka7fe5937": "ディスク読み取り/書き込み",
"ka8e41156": "検索して素早く移動",
"ka90bc019": "アンインストール",
@ -254,6 +276,7 @@
"kb0e351e0": "更新されました",
"kb114a2e8": "非推奨",
"kb15a6374": "自分のドメインでステータスページを設定できます。たとえば、status.example.com",
"kb2dded49": "キー",
"kb320aac4": "{{dayNum}}日間監視",
"kb35cde91": "検索",
"kb35d71ed": "または",
@ -261,6 +284,7 @@
"kb5673707": "過去7日間",
"kb659c1bc": "証明書の有効期限",
"kb6d350b6": "フィードチャンネル",
"kb7bf8869": "APIキー",
"kb7fa344a": "送信するフィードチャンネルを選択",
"kb8de8c50": "BCC",
"kbb31d3db": "統計日",
@ -296,13 +320,16 @@
"kcc9c1bff": "毎週",
"kccaa732a": "連続ダッシュなし",
"kccb42483": "パスワード",
"kcd56f27b": "最終更新",
"kcd643ef3": "読み込み中...",
"kce77d0c1": "タイムゾーン",
"kcff78587": "最終使用日時",
"kd005f7a8": "すべてのフィードが削除されます",
"kd031b383": "ビュー",
"kd044d5d4": "セッション",
"kd092de58": "現在のワークスペース:",
"kd1f7e695": "ログアウトを確認",
"kd211e2d4": "リリースページ",
"kd25f123a": "ステータス不明",
"kd2a7ad83": "フィードテンプレート",
"kd3262a4a": "設定",
"kd3396544": "一般的に、ユーザーの通常の使用に影響を与えないように、1ピクセルの空白画像を使用します。",
@ -311,14 +338,18 @@
"kd7279fa6": "コード",
"kd7985726": "{{num}}人のユーザー",
"kd92fa3e7": "ホスト名",
"kdaa6ae2b": "モニター数",
"kdaff25a6": "最新値を表示",
"kdb61adbb": "オフラインを隠す",
"kdbadcf43": "すべてのシステムが稼働中",
"kdbe222b": "APIキー",
"kdc10ee1a": "チームメンバーと協力するために新しいワークスペースを作成します。",
"kdc15c5d": "データ",
"kdc1bf80e": "URLは必須です",
"kdc51b5db": "ウェブサイト",
"kdd44ac01": "表示するテレメトリー名",
"kdd55936a": "リゾルバーポート",
"kde315178": "名前を変更",
"kde37bc27": "管理者に戻る",
"kdeba7706": "デバイス",
"kdeecbfea": "リゾルバーサーバー",
@ -359,6 +390,7 @@
"kf246dd2e": "ワークスペースが見つかりません。最初に作成してください。",
"kf3b749ef": "ダイレクトチャット/グループ/チャネルのチャットIDをサポート",
"kf55495e0": "保存",
"kf5c3b616": "リクエストヘッダー",
"kf5c9520e": "まだステータスページがありません。新しいステータスページを作成して、サービスのステータスを公開することができます。",
"kf6339d4f": "確認済み",
"kf6582ba": "ワークスペース",
@ -374,6 +406,7 @@
"kf97b6f71": "Linuxマシンでこのコマンドを実行してください",
"kf9877f28": "詳細を見る",
"kf9965c19": "このワークスペース内のすべてのコンテンツは破壊され、復元できません。",
"kf9a498c7": "Lighthouseレポートが完了しました",
"kfc98929b": "{{num}}日",
"kfd33c459": "コピーに成功しました!",
"kfdaf0bb3": "最後のオンライン:{{time}}",

View File

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 259 B

View File

@ -19,11 +19,14 @@
"k17058821": "Raporty Lighthouse Strony",
"k172a09c3": "Sugestie",
"k1777bbf2": "Instrukcja obsługi",
"k1940fd6": "Ogólne",
"k1964b988": "Zatrzymaj",
"k1bd89236": "uruchom raportera z",
"k1c33c293": "Ustawienia",
"k1d8f92b4": "Tablet",
"k1da4ecc2": "Możesz wysłać wiadomość do tego kanału za pomocą:",
"k1eb5b3ed": "Przegląd",
"k1ee0c2ca": "Ustaw adres URL webhooka na <1></1> i utrzymuj to okno aktywne. Po zakończeniu zaczniesz otrzymywać żądania webhooka tutaj.",
"k1f6dea0": "Nazwa kanału",
"k2099f2e0": "Logowanie nie powiodło się, sprawdź swoją nazwę użytkownika i hasło",
"k20edf271": "24h",
@ -53,12 +56,15 @@
"k2c84fe32": "Liczba zdarzeń w kanale",
"k2cecf817": "Typ",
"k2dad13e3": "Język",
"k2db2c0c5": "Test Powiadomienie",
"k2e6dbf02": "Do e-maila",
"k2ea8a019": "Monitorować",
"k30b5f01b": "Obszary robocze",
"k30d33d71": "Podpis Webhooka",
"k310fee": "Ostatnie 30 dni",
"k32344f64": "Wyczyść dane",
"k3260f019": "Wyloguj",
"k3404b72f": "Nowa nazwa przestrzeni roboczej",
"k340547f0": "Przepraszamy, ale coś poszło nie tak",
"k3471e956": "Powtórz nowe hasło",
"k34981fea": "Docker dryfuje po morzu, nie mogąc znaleźć drogi. Uruchom Docker, aby wrócić na właściwy kurs.",
@ -85,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",
@ -93,7 +100,8 @@
"k44186b66": "Liczba",
"k44cad477": "(Obecny)",
"k45f80a27": "Zaawansowane",
"k4738284": "Możesz wysłać dowolną wiadomość do tego kanału za pomocą:",
"k4727e4db": "Wygasło",
"k477b7ee4": "Częściowa awaria systemu",
"k47fe1f95": "Dodaj ten przykładowy kod do swojego projektu",
"k48186ce": "Powrót do strony głównej",
"k4905ed7b": "BRAK",
@ -107,6 +115,7 @@
"k4de48e75": "Maksymalna liczba prób",
"k4e08cf58": "Pokaż liczbę szczegółów",
"k4eea9393": "Profil",
"k4f182a7c": "Poważna awaria systemu",
"k4fc2b5b": "Obraz",
"k4fe1b4de": "Telemetria",
"k505c2733": "Utwórz Raport",
@ -123,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",
@ -135,6 +147,7 @@
"k62e19375": "Ostatnia aktualizacja: {{date}}",
"k6488f302": "Opcjonalne",
"k659b065": "Na przykład: https://open.feishu.cn/open-apis/bot/v2/hook/00000000-0000-0000-0000-000000000000",
"k678e2f90": "Treść żądania",
"k67c5a895": "Wczoraj",
"k683be220": "Uruchom",
"k691b7170": "Zatrzymany",
@ -147,9 +160,11 @@
"k6e96fc3": "Informacje o formularzu",
"k6ea11aff": "OK!",
"k6f15bcc3": "Host",
"k71067412": "Opcjonalne, podpis webhooka dla przychodzącego webhooka",
"k721589c1": "Dziś",
"k7247683c": "Usuń przestrzeń roboczą",
"k7350bd93": "W tym samym czasie możemy go również użyć w niektórych scenariuszach aplikacji po stronie klienta, takich jak zbieranie częstotliwości używania wiersza poleceń, takie jak zbieranie instalacji aplikacji selfhosted itp.",
"k736f3e4c": "Kopiuj jako",
"k75581e13": "DW",
"k75bfaaa6": "Dodaj ten kod do sekcji head na swojej stronie internetowej",
"k763816ac": "Podgląd",
@ -157,16 +172,17 @@
"k78b1ef6a": "Wprowadź",
"k7927b824": "Czy na pewno chcesz wyczyścić wszystkie wyłączone węzły?",
"k7a132ce8": "Przepraszamy, ale ta strona nie została znaleziona",
"k7a15497a": "Na żywo",
"k7ac44a6e": "Klucz sesji",
"k7b74a43f": "odwiedzający",
"k7b75e24c": "Integracja",
"k7b9aa48c": "Treść",
"k7cac602a": "Status",
"k7d8cd81c": "Kopiuj URL",
"k7e0360fd": "Nie utworzono żadnej grupy, kliknij przycisk, aby utworzyć jedną",
"k7e61b1af": "Wybierz przestrzeń roboczą",
"k7f01b47c": "Dziennik audytu",
"k7f03a704": "Pamiętaj, aby nie wysyłać danych za pomocą application/json",
"k7f29bae5": "wyświetlenia strony",
"k8037cc6b": "Serwery",
"k816ce026": "Pobierz",
"k819633bc": "Użyj do przechowywania",
@ -176,6 +192,7 @@
"k84ce1618": "(24 godziny)",
"k84e82947": "{{num}} zdarzeń usuniętych",
"k85344b23": "Obciążenie",
"k85a116ee": "Adres URL Webhooka",
"k85c5fd4c": "Nie ustawiono żadnego monitora",
"k85db19da": "Brak kanałów informacyjnych. Użyj funkcji kanałów, aby otrzymywać wszystkie zdarzenia z sieci lub własnej usługi.",
"k873c90e6": "Etykieta wyświetlania",
@ -188,6 +205,7 @@
"k88d2647b": "Strona internetowa",
"k89056082": "(30 dni)",
"k892f84b6": "Nie można uzyskać informacji o bieżącym użytkowniku",
"k895cafe1": "Opcjonalne, adres url webhooka do wysyłania ładunku ankiety",
"k899fd0cd": "Porty",
"k89d54f7a": "Liczba wykonań monitora",
"k8a1deb63": "Członkowie",
@ -207,7 +225,10 @@
"k90b603b8": "Duplikat",
"k90b668e5": "Ostatnie 24 godziny",
"k93374bc9": "Usuń stronę internetową",
"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?",
@ -233,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",
@ -254,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",
@ -261,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",
@ -284,7 +308,7 @@
"kc5f82d53": "Na przykład: pushdeer://pushKey",
"kc6888ac4": "Automatyczny",
"kc6cac621": "(Brak)",
"kc6dc3c38": "Desktop",
"kc6dc3c38": "Komputer stacjonarny",
"kc70d69ad": "Odpowiedź",
"kc9b446d1": "Zakończono uruchamianie",
"kcacbfde1": "Utwórz teraz",
@ -296,13 +320,16 @@
"kcc9c1bff": "Każdy tydzień",
"kccaa732a": "Brak kolejnych myślników",
"kccb42483": "Hasło",
"kcd56f27b": "Ostatnia aktualizacja",
"kcd643ef3": "Ładowanie...",
"kce77d0c1": "Strefa czasowa",
"kcff78587": "Ostatnie użycie",
"kd005f7a8": "Wszystkie kanały informacyjne zostaną usunięte",
"kd031b383": "Odsłony",
"kd044d5d4": "sesja",
"kd092de58": "Aktualna przestrzeń robocza:",
"kd1f7e695": "Potwierdź wylogowanie",
"kd211e2d4": "Strona wydań",
"kd25f123a": "Status nieznany",
"kd2a7ad83": "Szablon feedu",
"kd3262a4a": "Konfiguracja",
"kd3396544": "Zazwyczaj użyjemy pustego obrazu o rozmiarze jednego piksela, aby nie wpływał na normalne użytkowanie użytkownika.",
@ -311,14 +338,18 @@
"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",
"kdc51b5db": "Strony internetowe",
"kdd44ac01": "Nazwa telemetrii do wyświetlenia",
"kdd55936a": "Port resolvera",
"kde315178": "Zmień nazwę",
"kde37bc27": "Powrót do panelu administratora",
"kdeba7706": "Urządzenia",
"kdeecbfea": "Serwer resolvera",
@ -359,6 +390,7 @@
"kf246dd2e": "Nie znaleziono żadnej przestrzeni roboczej, proszę najpierw utworzyć",
"kf3b749ef": "Wsparcie dla czatu bezpośredniego / grupy / czatu kanału",
"kf55495e0": "Zapisz",
"kf5c3b616": "Nagłówek żądania",
"kf5c9520e": "Brak stron statusu, możesz utworzyć nową, aby pokazać stan swojej usługi publicznie.",
"kf6339d4f": "Zweryfikowane",
"kf6582ba": "Przestrzeń robocza",
@ -374,6 +406,7 @@
"kf97b6f71": "Uruchom to polecenie na swojej maszynie z systemem Linux",
"kf9877f28": "Pokaż szczegóły",
"kf9965c19": "Cała zawartość w tej przestrzeni roboczej zostanie zniszczona i nie można jej odzyskać.",
"kf9a498c7": "Raport Lighthouse zakończony!",
"kfc98929b": "{{num}} dni",
"kfd33c459": "Kopiowanie powiodło się!",
"kfdaf0bb3": "O na pewno chcesz usunąć wszystkie zdarzenia dla tego monitora?",

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -19,11 +19,14 @@
"k17058821": "Relatórios do Website Lighthouse",
"k172a09c3": "Sugestões",
"k1777bbf2": "Manual",
"k1940fd6": "Geral",
"k1964b988": "Parar",
"k1bd89236": "correr repórter com",
"k1c33c293": "Definições",
"k1d8f92b4": "Tablet",
"k1da4ecc2": "Você pode enviar uma mensagem para este canal com:",
"k1eb5b3ed": "Visão geral",
"k1ee0c2ca": "Defina a URL do webhook para <1></1> e mantenha esta janela ativa. Uma vez feito, você começará a receber solicitações de webhook aqui.",
"k1f6dea0": "Nome do Canal",
"k2099f2e0": "Falha no login, verifique seu nome de usuário e senha",
"k20edf271": "24 horas",
@ -53,12 +56,15 @@
"k2c84fe32": "Contagem de eventos de feed",
"k2cecf817": "Tipo",
"k2dad13e3": "Idioma",
"k2db2c0c5": "Notificação de Teste",
"k2e6dbf02": "Para o e-mail",
"k2ea8a019": "Monitorar",
"k30b5f01b": "Áreas de trabalho",
"k30d33d71": "Assinatura do Webhook",
"k310fee": "Últimos 30 dias",
"k32344f64": "Limpar dados",
"k3260f019": "Terminar sessão",
"k3404b72f": "Novo Nome do Espaço de Trabalho",
"k340547f0": "Desculpe, mas algo correu mal",
"k3471e956": "Repetir nova palavra-passe",
"k34981fea": "O Docker está à deriva no mar, incapaz de encontrar seu caminho. Por favor, inicie o Docker para voltar ao curso.",
@ -85,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",
@ -93,7 +100,8 @@
"k44186b66": "Contar",
"k44cad477": "(Atual)",
"k45f80a27": "Avançado",
"k4738284": "Você pode enviar qualquer mensagem para este canal com:",
"k4727e4db": "Expirado Em",
"k477b7ee4": "Interrupção Parcial do Sistema",
"k47fe1f95": "Adicione este código de exemplo ao seu projeto",
"k48186ce": "Voltar à página inicial",
"k4905ed7b": "NENHUM",
@ -107,6 +115,7 @@
"k4de48e75": "Máximo de tentativas",
"k4e08cf58": "Mostrar número de pormenor",
"k4eea9393": "Perfil",
"k4f182a7c": "Interrupção Maior do Sistema",
"k4fc2b5b": "Imagem",
"k4fe1b4de": "Telemetria",
"k505c2733": "Criar Relatório",
@ -123,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",
@ -135,6 +147,7 @@
"k62e19375": "Última atualização em: {{date}}",
"k6488f302": "Opcional",
"k659b065": "Por exemplo: https://open.feishu.cn/open-apis/bot/v2/hook/00000000-0000-0000-0000-000000000000",
"k678e2f90": "Corpo da Solicitação",
"k67c5a895": "Ontem",
"k683be220": "Correr",
"k691b7170": "Parado",
@ -147,9 +160,11 @@
"k6e96fc3": "Informações do formulário",
"k6ea11aff": "Obter!",
"k6f15bcc3": "Anfitrião",
"k71067412": "Opcional, Assinatura do Webhook para Webhook de Entrada",
"k721589c1": "Hoje",
"k7247683c": "Excluir Espaço de Trabalho",
"k7350bd93": "Ao mesmo tempo, também podemos utilizá-lo em alguns cenários de aplicações do lado do cliente, como a recolha da frequência de utilização do cli, como a recolha da instalação de aplicações auto-hospedadas, etc.",
"k736f3e4c": "Copiar como",
"k75581e13": "CC",
"k75bfaaa6": "Adicionar este código ao script principal do seu sítio Web",
"k763816ac": "Pré-visualização",
@ -157,16 +172,17 @@
"k78b1ef6a": "Entrar",
"k7927b824": "Tem a certeza de que pretende limpar todos os nós offline?",
"k7a132ce8": "Desculpe, mas esta página não foi encontrada",
"k7a15497a": "Em Tempo Real",
"k7ac44a6e": "Chave de sessão",
"k7b74a43f": "visitantes",
"k7b75e24c": "Integração",
"k7b9aa48c": "Corpo",
"k7cac602a": "Estado",
"k7d8cd81c": "Copiar URL",
"k7e0360fd": "Nenhum grupo foi criado, clique no botão para criar um",
"k7e61b1af": "Selecionar Espaço de Trabalho",
"k7f01b47c": "Registo de auditoria",
"k7f03a704": "Não se esqueça de não enviar dados com application/json",
"k7f29bae5": "visualização de página",
"k8037cc6b": "Servidores",
"k816ce026": "Baixar",
"k819633bc": "Usar para armazenamento",
@ -176,6 +192,7 @@
"k84ce1618": "(24 horas)",
"k84e82947": "{{num}} eventos limpos",
"k85344b23": "Carregar",
"k85a116ee": "URL do Webhook",
"k85c5fd4c": "Não foi definido qualquer monitor",
"k85db19da": "Ainda não há nenhum canal de feed. Use o recurso de feed para receber todos os eventos da rede ou do seu próprio serviço.",
"k873c90e6": "Etiqueta de Exibição",
@ -188,6 +205,7 @@
"k88d2647b": "Sítio Web",
"k89056082": "(30 dias)",
"k892f84b6": "Não é possível obter as informações do usuário atual",
"k895cafe1": "Opcional, url do webhook para enviar carga de pesquisa",
"k899fd0cd": "Portos",
"k89d54f7a": "Contagem de Execução do Monitor",
"k8a1deb63": "Membros",
@ -207,7 +225,10 @@
"k90b603b8": "Duplicar",
"k90b668e5": "Últimas 24 horas",
"k93374bc9": "Eliminar sítio Web",
"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?",
@ -233,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",
@ -254,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",
@ -261,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",
@ -296,13 +320,16 @@
"kcc9c1bff": "Toda semana",
"kccaa732a": "Sem traços consecutivos",
"kccb42483": "Palavra-passe",
"kcd56f27b": "Última atualização",
"kcd643ef3": "Carregando...",
"kce77d0c1": "Fuso Horário",
"kcff78587": "Último Uso Em",
"kd005f7a8": "Todos os feeds serão removidos",
"kd031b383": "Vistas",
"kd044d5d4": "sessão",
"kd092de58": "Espaço de Trabalho Atual:",
"kd1f7e695": "Confirmar para terminar a sessão",
"kd211e2d4": "Página de lançamentos",
"kd25f123a": "Status Desconhecido",
"kd2a7ad83": "Modelo de Feed",
"kd3262a4a": "Configuração",
"kd3396544": "Geralmente, utilizamos uma imagem em branco de um pixel para que não afecte a utilização normal do utilizador.",
@ -311,14 +338,18 @@
"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",
"kdc51b5db": "Sites",
"kdd44ac01": "Nome de telemetria a apresentar",
"kdd55936a": "Porta do resolvedor",
"kde315178": "Renomear",
"kde37bc27": "Voltar ao Administrador",
"kdeba7706": "Dispositivos",
"kdeecbfea": "Servidor de resolução",
@ -359,6 +390,7 @@
"kf246dd2e": "Nenhum espaço de trabalho foi encontrado, por favor crie primeiro",
"kf3b749ef": "ID de Chat Direto do Suporte / Grupo / Canal",
"kf55495e0": "Guardar",
"kf5c3b616": "Cabeçalho da Solicitação",
"kf5c9520e": "Ainda não há nenhuma página de status, você pode criar uma nova para mostrar o status do seu serviço ao público.",
"kf6339d4f": "Verificado",
"kf6582ba": "Espaço de Trabalho",
@ -374,6 +406,7 @@
"kf97b6f71": "Executar este comando na sua máquina linux",
"kf9877f28": "Ver detalhes",
"kf9965c19": "Todo o conteúdo neste espaço de trabalho será destruído e não poderá ser recuperado.",
"kf9a498c7": "Relatório do Lighthouse concluído!",
"kfc98929b": "{{num}} dias",
"kfd33c459": "Cópia bem sucedida!",
"kfdaf0bb3": "Última vez online: {{tempo}}",

View File

Before

Width:  |  Height:  |  Size: 268 B

After

Width:  |  Height:  |  Size: 268 B

View File

@ -19,11 +19,14 @@
"k17058821": "Отчеты Lighthouse для веб-сайтов",
"k172a09c3": "Предложения",
"k1777bbf2": "Вручную",
"k1940fd6": "Общее",
"k1964b988": "Остановить",
"k1bd89236": "запустить репортер с",
"k1c33c293": "Настройки",
"k1d8f92b4": "Планшет",
"k1da4ecc2": "Вы можете отправить сообщение в этот канал с помощью:",
"k1eb5b3ed": "Обзор",
"k1ee0c2ca": "Установите URL вебхука на <1></1> и оставьте это окно активным. После завершения вы начнете получать запросы вебхука здесь.",
"k1f6dea0": "Название канала",
"k2099f2e0": "Ошибка входа, проверьте имя пользователя и пароль",
"k20edf271": "24ч",
@ -53,12 +56,15 @@
"k2c84fe32": "Количество событий ленты",
"k2cecf817": "Тип",
"k2dad13e3": "Язык",
"k2db2c0c5": "Тестовое уведомление",
"k2e6dbf02": "На Email",
"k2ea8a019": "Монитор",
"k30b5f01b": "Рабочие области",
"k30d33d71": "Подпись вебхука",
"k310fee": "Последние 30 дней",
"k32344f64": "Очистить данные",
"k3260f019": "Выйти",
"k3404b72f": "Новое имя рабочего пространства",
"k340547f0": "Извините, но что-то пошло не так",
"k3471e956": "Повтор нового пароля",
"k34981fea": "Docker дрейфует в море, не может найти свой путь. Пожалуйста, запустите Docker, чтобы вернуться на правильный курс.",
@ -85,6 +91,7 @@
"k3e8b13f8": "Присоединяйтесь к Discord",
"k3eaab921": "Список мониторинга",
"k3f36e17e": "Подписаться на Twitter",
"k406089a4": "Действие",
"k406e9ad8": "Подтвердить",
"k41d3ce6c": "Событие восстановлено",
"k42347b91": "Количество событий на сайте",
@ -93,7 +100,8 @@
"k44186b66": "Количество",
"k44cad477": "(Текущий)",
"k45f80a27": "Расширенный",
"k4738284": "Вы можете отправить любое сообщение в этот канал с помощью:",
"k4727e4db": "Истекает",
"k477b7ee4": "Частичный сбой системы",
"k47fe1f95": "Добавьте этот пример кода в ваш проект",
"k48186ce": "Вернуться на главную страницу",
"k4905ed7b": "НИКАКОЙ",
@ -107,6 +115,7 @@
"k4de48e75": "Макс. попыток",
"k4e08cf58": "Показать подробное количество",
"k4eea9393": "Профиль",
"k4f182a7c": "Крупный сбой системы",
"k4fc2b5b": "Изображение",
"k4fe1b4de": "Телеметрия",
"k505c2733": "Создать отчет",
@ -123,9 +132,12 @@
"k58267a45": "Источник",
"k58f90514": "Токен бота",
"k593cf342": "Вы уверены, что хотите удалить этот монитор?",
"k5a782f4b": "Количество сайтов",
"k5a839f71": "Время работы",
"k5b5be0d4": "Текущая роль",
"k5c18db28": "Изменить информацию на странице статуса",
"k5d00536d": "Скопировано",
"k5d49d751": "Новый API-ключ скопирован в буфер обмена!",
"k5eb87a8b": "Старт",
"k5ec0de4": "Для мониторинга HTTPS, если назначен любой метод уведомления, уведомления будут отправлены за 1, 3, 7 и 14 дней до истечения срока действия.",
"k5ecf04b0": "Просмотр",
@ -135,6 +147,7 @@
"k62e19375": "Последнее обновление: {{date}}",
"k6488f302": "Необязательно",
"k659b065": "Например: https://open.feishu.cn/open-apis/bot/v2/hook/00000000-0000-0000-0000-000000000000",
"k678e2f90": "Тело запроса",
"k67c5a895": "Вчера",
"k683be220": "Запустить",
"k691b7170": "Остановлено",
@ -147,9 +160,11 @@
"k6e96fc3": "Информация формы",
"k6ea11aff": "Получить!",
"k6f15bcc3": "Хост",
"k71067412": "Необязательно, подпись вебхука для входящего вебхука",
"k721589c1": "Сегодня",
"k7247683c": "Удалить рабочее пространство",
"k7350bd93": "В то же время, мы также можем использовать это в некоторых сценариях клиентского приложения, таких как сбор частоты использования cli, сбор установок самостоятельно размещенных приложений и так далее.",
"k736f3e4c": "Копировать как",
"k75581e13": "Копия",
"k75bfaaa6": "Добавьте этот код в скрипт заголовка вашего веб-сайта",
"k763816ac": "Предварительный просмотр",
@ -157,16 +172,17 @@
"k78b1ef6a": "Ввод",
"k7927b824": "Вы уверены, что хотите очистить все офлайн узлы?",
"k7a132ce8": "Извините, но эта страница не найдена",
"k7a15497a": "В реальном времени",
"k7ac44a6e": "Ключ сессии",
"k7b74a43f": "посетители",
"k7b75e24c": "Интеграция",
"k7b9aa48c": "Тело",
"k7cac602a": "Статус",
"k7d8cd81c": "Копировать URL",
"k7e0360fd": "Не создано ни одной группы, нажмите кнопку, чтобы создать одну",
"k7e61b1af": "Выбрать рабочее пространство",
"k7f01b47c": "Журнал аудита",
"k7f03a704": "Не забудьте не отправлять данные с application/json",
"k7f29bae5": "Просмотр страницы",
"k8037cc6b": "Серверы",
"k816ce026": "Скачать",
"k819633bc": "Использовать для хранения",
@ -176,6 +192,7 @@
"k84ce1618": "(24 часа)",
"k84e82947": "{{num}} события очищены",
"k85344b23": "Нагрузка",
"k85a116ee": "URL вебхука",
"k85c5fd4c": "Мониторы еще не настроены",
"k85db19da": "Пока нет ни одного канала. Используйте функцию канала для получения всех событий из сети или вашей собственной службы.",
"k873c90e6": "Метка отображения",
@ -188,6 +205,7 @@
"k88d2647b": "Веб-сайт",
"k89056082": "(30 дней)",
"k892f84b6": "Не удается получить информацию о текущем пользователе",
"k895cafe1": "Необязательно, URL вебхука для отправки полезной нагрузки опроса",
"k899fd0cd": "Порты",
"k89d54f7a": "Количество выполнений мониторинга",
"k8a1deb63": "Участники",
@ -207,7 +225,10 @@
"k90b603b8": "Дублировать",
"k90b668e5": "Последние 24 часа",
"k93374bc9": "Удалить веб-сайт",
"k93458b98": "Площадка",
"k951a939a": "Количество принятых сайтом",
"k95f932a": "В настоящее время ожидает нового запроса от удаленного сервера",
"k97b02874": "Количество страниц",
"k98f433ee": "Скачать репортер с",
"k9991c290": "Сообщество",
"k9a272ecf": "Это ваши серверы?",
@ -233,6 +254,7 @@
"ka6ee7455": "ID веб-сайта",
"ka71c12e1": "Два пароля не совпадают",
"ka765ad32": "Уведомления",
"ka7d8617e": "Количество каналов ленты",
"ka7fe5937": "Чтение/запись на диск",
"ka8e41156": "Поиск и быстрый переход",
"ka90bc019": "Удалить",
@ -254,6 +276,7 @@
"kb0e351e0": "Обновлено",
"kb114a2e8": "Устаревший",
"kb15a6374": "Вы можете настроить свою страницу статуса на своем собственном домене, например: status.example.com",
"kb2dded49": "Ключ",
"kb320aac4": "Мониторинг в течение {{dayNum}} дней",
"kb35cde91": "Поиск",
"kb35d71ed": "ИЛИ",
@ -261,6 +284,7 @@
"kb5673707": "Последние 7 дней",
"kb659c1bc": "Истечение серт.",
"kb6d350b6": "Каналы обратной связи",
"kb7bf8869": "API-ключи",
"kb7fa344a": "Выберите канал обратной связи для отправки",
"kb8de8c50": "Скрытая копия",
"kbb31d3db": "Дата статистики",
@ -296,13 +320,16 @@
"kcc9c1bff": "Каждую неделю",
"kccaa732a": "Без последовательных тире",
"kccb42483": "Пароль",
"kcd56f27b": "Последнее обновление",
"kcd643ef3": "Загрузка...",
"kce77d0c1": "Часовой пояс",
"kcff78587": "Последнее использование",
"kd005f7a8": "Все ленты будут удалены",
"kd031b383": "Просмотры",
"kd044d5d4": "Сессия",
"kd092de58": "Текущее рабочее пространство:",
"kd1f7e695": "Подтвердить выход",
"kd211e2d4": "Страница релизов",
"kd25f123a": "Статус неизвестен",
"kd2a7ad83": "Шаблон обратной связи",
"kd3262a4a": "Настройка",
"kd3396544": "Обычно мы будем использовать однопиксельное пустое изображение, так что это не повлияет на нормальное использование пользователя.",
@ -311,14 +338,18 @@
"kd7279fa6": "Код",
"kd7985726": "{{num}} пользователей",
"kd92fa3e7": "Имя хоста",
"kdaa6ae2b": "Количество мониторов",
"kdaff25a6": "Показать последнее значение",
"kdb61adbb": "Скрыть офлайн",
"kdbadcf43": "Все системы работают",
"kdbe222b": "API-ключ",
"kdc10ee1a": "Создайте новое рабочее пространство для сотрудничества с членами команды.",
"kdc15c5d": "Данные",
"kdc1bf80e": "URL обязателен",
"kdc51b5db": "Веб-сайты",
"kdd44ac01": "Отображаемое имя телеметрии",
"kdd55936a": "Порт разрешителя",
"kde315178": "Переименовать",
"kde37bc27": "Вернуться к администратору",
"kdeba7706": "Устройства",
"kdeecbfea": "Сервер разрешителя",
@ -359,6 +390,7 @@
"kf246dd2e": "Рабочее пространство не найдено, пожалуйста, создайте его сначала",
"kf3b749ef": "Поддержка прямого чата / группы / ID чата канала",
"kf55495e0": "Сохранить",
"kf5c3b616": "Заголовок запроса",
"kf5c9520e": "Пока нет страницы состояния, вы можете создать новую, чтобы показать статус вашего сервиса общественности.",
"kf6339d4f": "Подтверждено",
"kf6582ba": "Рабочее пространство",
@ -374,6 +406,7 @@
"kf97b6f71": "Запустите эту команду на вашем Linux-машине",
"kf9877f28": "Посмотреть детали",
"kf9965c19": "Всё содержимое в этом рабочем пространстве будет уничтожено и не может быть восстановлено.",
"kf9a498c7": "Отчет Lighthouse завершен!",
"kfc98929b": "{{num}} дней",
"kfd33c459": "Копирование успешно!",
"kfdaf0bb3": "Последний онлайн: {{time}}",

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -19,11 +19,14 @@
"k17058821": "网站灯塔报告",
"k172a09c3": "建议",
"k1777bbf2": "手动",
"k1940fd6": "常规",
"k1964b988": "停止",
"k1bd89236": "运行报告器",
"k1c33c293": "设置",
"k1d8f92b4": "平板电脑",
"k1da4ecc2": "您可以通过以下方式向此频道发送消息:",
"k1eb5b3ed": "概览",
"k1ee0c2ca": "将 webhook URL 设置为 <1></1>,并保持此窗口处于活动状态。完成后,您将开始在此接收 webhook 请求。",
"k1f6dea0": "频道名称",
"k2099f2e0": "登录失败,请检查您的用户名和密码",
"k20edf271": "24小时",
@ -53,12 +56,15 @@
"k2c84fe32": "事件计数",
"k2cecf817": "类型",
"k2dad13e3": "语言",
"k2db2c0c5": "测试通知",
"k2e6dbf02": "发邮件到",
"k2ea8a019": "监控器",
"k30b5f01b": "工作区",
"k30d33d71": "Webhook 签名",
"k310fee": "最近30天",
"k32344f64": "清除数据",
"k3260f019": "登出",
"k3404b72f": "新工作区名称",
"k340547f0": "抱歉,出了点问题",
"k3471e956": "重复新密码",
"k34981fea": "Docker在海上漂流无法找到方向。请启动Docker以重新导航。",
@ -85,6 +91,7 @@
"k3e8b13f8": "加入 Discord",
"k3eaab921": "监控列表",
"k3f36e17e": "关注 Twitter",
"k406089a4": "操作",
"k406e9ad8": "确认",
"k41d3ce6c": "事件已取消归档",
"k42347b91": "网站事件计数",
@ -93,7 +100,8 @@
"k44186b66": "计数",
"k44cad477": "(当前)",
"k45f80a27": "高级",
"k4738284": "你可以通过以下方式向此频道发送任何消息:",
"k4727e4db": "到期时间",
"k477b7ee4": "部分系统故障",
"k47fe1f95": "将此示例代码添加到您的项目中",
"k48186ce": "返回首页",
"k4905ed7b": "无",
@ -107,6 +115,7 @@
"k4de48e75": "最大重试次数",
"k4e08cf58": "显示详细数字",
"k4eea9393": "个人资料",
"k4f182a7c": "重大系统故障",
"k4fc2b5b": "图片",
"k4fe1b4de": "遥测",
"k505c2733": "创建报告",
@ -123,9 +132,12 @@
"k58267a45": "源",
"k58f90514": "机器人令牌",
"k593cf342": "您确定要删除这个监控器吗?",
"k5a782f4b": "网站数量",
"k5a839f71": "正常运行时间",
"k5b5be0d4": "当前角色",
"k5c18db28": "修改状态页面信息",
"k5d00536d": "已复制",
"k5d49d751": "新的 API 密钥已复制到您的剪贴板!",
"k5eb87a8b": "开始",
"k5ec0de4": "对于 HTTPS 监控,如果分配了任何通知方法,则将在到期前 1、3、7 和 14 天发送通知。",
"k5ecf04b0": "查看",
@ -135,6 +147,7 @@
"k62e19375": "最后更新时间:{{date}}",
"k6488f302": "可选",
"k659b065": "示例https://open.feishu.cn/open-apis/bot/v2/hook/00000000-0000-0000-0000-000000000000",
"k678e2f90": "请求体",
"k67c5a895": "昨天",
"k683be220": "运行",
"k691b7170": "已停止",
@ -147,9 +160,11 @@
"k6e96fc3": "表单信息",
"k6ea11aff": "获取!",
"k6f15bcc3": "主机",
"k71067412": "可选,传入 webhook 的 webhook 签名",
"k721589c1": "今天",
"k7247683c": "删除工作区",
"k7350bd93": "同时我们也可以在一些客户端应用场景中使用它比如收集cli使用频率比如收集自托管应用的安装情况等。",
"k736f3e4c": "复制为",
"k75581e13": "抄送",
"k75bfaaa6": "将此代码添加到您的网站头部脚本中",
"k763816ac": "预览",
@ -157,16 +172,17 @@
"k78b1ef6a": "输入",
"k7927b824": "您确定要清除所有离线节点吗?",
"k7a132ce8": "抱歉,找不到此页面",
"k7a15497a": "实时",
"k7ac44a6e": "会话密钥",
"k7b74a43f": "访客",
"k7b75e24c": "集成",
"k7b9aa48c": "正文",
"k7cac602a": "状态",
"k7d8cd81c": "复制 URL",
"k7e0360fd": "尚未创建任何组,点击按钮创建一个",
"k7e61b1af": "选择工作区",
"k7f01b47c": "审计日志",
"k7f03a704": "记得不要使用 application/json 发送数据",
"k7f29bae5": "页面查看",
"k8037cc6b": "服务器",
"k816ce026": "下载",
"k819633bc": "用于存储",
@ -176,6 +192,7 @@
"k84ce1618": "24小时",
"k84e82947": "{{num}} 事件已清除",
"k85344b23": "负载",
"k85a116ee": "Webhook Url",
"k85c5fd4c": "还没有设置任何监控器",
"k85db19da": "还没有任何订阅频道。使用订阅功能接收来自网络或您自己服务的所有事件。",
"k873c90e6": "显示标签",
@ -188,6 +205,7 @@
"k88d2647b": "网站",
"k89056082": "30天",
"k892f84b6": "无法获取当前用户信息",
"k895cafe1": "可选,发送调查有效负载的 webhook url",
"k899fd0cd": "端口",
"k89d54f7a": "监控执行计数",
"k8a1deb63": "成员",
@ -207,7 +225,10 @@
"k90b603b8": "重复",
"k90b668e5": "最近24小时",
"k93374bc9": "删除网站",
"k93458b98": "游乐场",
"k951a939a": "网站接受计数",
"k95f932a": "当前正在等待来自远程服务器的新请求",
"k97b02874": "页面数量",
"k98f433ee": "从这里下载报告器",
"k9991c290": "社区",
"k9a272ecf": "这是您的服务器吗?",
@ -233,6 +254,7 @@
"ka6ee7455": "网站ID",
"ka71c12e1": "两次密码不一致",
"ka765ad32": "通知",
"ka7d8617e": "Feed 渠道数量",
"ka7fe5937": "磁盘读/写",
"ka8e41156": "搜索和快速跳转",
"ka90bc019": "卸载",
@ -254,6 +276,7 @@
"kb0e351e0": "已刷新",
"kb114a2e8": "已弃用",
"kb15a6374": "您可以在自己的域名中配置您的状态页面例如status.example.com",
"kb2dded49": "密钥",
"kb320aac4": "已监控{{dayNum}}天",
"kb35cde91": "搜索",
"kb35d71ed": "或",
@ -261,6 +284,7 @@
"kb5673707": "最近7天",
"kb659c1bc": "证书到期",
"kb6d350b6": "馈送频道",
"kb7bf8869": "API 密钥",
"kb7fa344a": "选择要发送的馈送频道",
"kb8de8c50": "密送",
"kbb31d3db": "统计日期",
@ -296,13 +320,16 @@
"kcc9c1bff": "每周",
"kccaa732a": "无连续破折号",
"kccb42483": "密码",
"kcd56f27b": "最后更新",
"kcd643ef3": "加载中...",
"kce77d0c1": "时区",
"kcff78587": "最后使用时间",
"kd005f7a8": "所有订阅将被删除",
"kd031b383": "视图",
"kd044d5d4": "会话",
"kd092de58": "当前工作区:",
"kd1f7e695": "确认注销",
"kd211e2d4": "发布页面",
"kd25f123a": "状态未知",
"kd2a7ad83": "馈送模板",
"kd3262a4a": "配置",
"kd3396544": "通常,我们会使用一个 1x1 像素的空白图片,这样不会影响用户的正常使用。",
@ -311,14 +338,18 @@
"kd7279fa6": "代码",
"kd7985726": "{{num}}个用户",
"kd92fa3e7": "主机名",
"kdaa6ae2b": "监控数量",
"kdaff25a6": "显示最新值",
"kdb61adbb": "隐藏离线",
"kdbadcf43": "所有系统正常运行",
"kdbe222b": "API 密钥",
"kdc10ee1a": "创建一个新的工作区以与团队成员合作。",
"kdc15c5d": "数据",
"kdc1bf80e": "网址是必需的",
"kdc51b5db": "网站",
"kdd44ac01": "显示的遥测名称",
"kdd55936a": "解析器端口",
"kde315178": "重命名",
"kde37bc27": "返回管理员",
"kdeba7706": "设备",
"kdeecbfea": "解析器服务器",
@ -359,6 +390,7 @@
"kf246dd2e": "未找到任何工作区,请先创建",
"kf3b749ef": "支持直接聊天/群组/频道的聊天ID",
"kf55495e0": "保存",
"kf5c3b616": "请求头",
"kf5c9520e": "还没有任何状态页面,您可以创建一个新的状态页面向公众展示您的服务状态。",
"kf6339d4f": "已验证",
"kf6582ba": "工作区",
@ -374,6 +406,7 @@
"kf97b6f71": "在您的Linux机器上运行此命令",
"kf9877f28": "查看详情",
"kf9965c19": "此工作区中的所有内容将被销毁,无法恢复。",
"kf9a498c7": "灯塔报告已完成!",
"kfc98929b": "{{num}}天",
"kfd33c459": "复制成功!",
"kfdaf0bb3": "最后在线时间:{{time}}",

View File

@ -34,7 +34,9 @@ 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'
import { Route as PageSlugImport } from './routes/page/$slug'
import { Route as MonitorAddImport } from './routes/monitor/add'
@ -166,11 +168,21 @@ 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,
} as any)
const SettingsApiKeyRoute = SettingsApiKeyImport.update({
path: '/apiKey',
getParentRoute: () => SettingsRoute,
} as any)
const PageAddRoute = PageAddImport.update({
path: '/add',
getParentRoute: () => PageRoute,
@ -312,10 +324,18 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PageAddImport
parentRoute: typeof PageImport
}
'/settings/apiKey': {
preLoaderRoute: typeof SettingsApiKeyImport
parentRoute: typeof SettingsImport
}
'/settings/auditLog': {
preLoaderRoute: typeof SettingsAuditLogImport
parentRoute: typeof SettingsImport
}
'/settings/billing': {
preLoaderRoute: typeof SettingsBillingImport
parentRoute: typeof SettingsImport
}
'/settings/notifications': {
preLoaderRoute: typeof SettingsNotificationsImport
parentRoute: typeof SettingsImport
@ -411,7 +431,9 @@ export const routeTree = rootRoute.addChildren([
RegisterRoute,
ServerRoute,
SettingsRoute.addChildren([
SettingsApiKeyRoute,
SettingsAuditLogRoute,
SettingsBillingRoute,
SettingsNotificationsRoute,
SettingsProfileRoute,
SettingsUsageRoute,

View File

@ -114,7 +114,12 @@ function PageComponent() {
{info?.id && (
<DialogWrapper
title={t('Integration')}
content={<FeedIntegration feedId={info.id} />}
content={
<FeedIntegration
feedId={info.id}
webhookSignature={info.webhookSignature}
/>
}
>
<Button variant="default" size="icon" Icon={LuWebhook} />
</DialogWrapper>
@ -194,7 +199,12 @@ function PageComponent() {
)}
renderEmpty={() => (
<div className="w-full overflow-hidden p-4">
{!isInitialLoading && <FeedApiGuide channelId={channelId} />}
{!isInitialLoading && (
<FeedApiGuide
channelId={channelId}
webhookSignature={info?.webhookSignature}
/>
)}
</div>
)}
/>

View File

@ -8,6 +8,12 @@ import { useState } from 'react';
import { EditableText } from '@/components/EditableText';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { WebhookPlayground } from '@/components/WebhookPlayground';
import React from 'react';
import { defaultErrorHandler, defaultSuccessHandler, trpc } from '@/api/trpc';
import { Button } from '@/components/ui/button';
import { useEvent } from '@/hooks/useEvent';
import { useCurrentWorkspaceId } from '@/store/user';
import { Separator } from '@/components/ui/separator';
export const Route = createFileRoute('/playground')({
beforeLoad: () => {
@ -48,18 +54,22 @@ function PageComponent() {
return (
<div className="h-full w-full p-4">
<Tabs defaultValue="current" className="flex h-full flex-col">
<Tabs defaultValue="billing" className="flex h-full flex-col">
<div>
<TabsList>
<TabsTrigger value="current">Current</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
<TabsTrigger value="webhook">Webhook</TabsTrigger>
<TabsTrigger value="misc">Misc</TabsTrigger>
</TabsList>
</div>
<TabsContent value="current" className="flex-1 overflow-hidden">
<TabsContent value="billing">
<BillingPlayground />
</TabsContent>
<TabsContent value="webhook" className="flex-1 overflow-hidden">
<WebhookPlayground />
</TabsContent>
<TabsContent value="history">
<TabsContent value="misc">
<div>
<EditableText
defaultValue="fooooooooo"
@ -73,3 +83,114 @@ function PageComponent() {
</div>
);
}
export const BillingPlayground: React.FC = React.memo(() => {
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 workspaceId = useCurrentWorkspaceId();
const { data, refetch, isInitialLoading, isLoading } =
trpc.billing.currentSubscription.useQuery({
workspaceId,
});
const handleCheckoutSubscribe = useEvent(async (tier: 'pro' | 'team') => {
const { url } = await checkoutMutation.mutateAsync({
workspaceId,
tier,
redirectUrl: location.href,
});
location.href = url;
});
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">
<Button
loading={checkoutMutation.isLoading}
onClick={() => handleCheckoutSubscribe('pro')}
>
Upgrade to Pro
</Button>
<Button
loading={checkoutMutation.isLoading}
onClick={() => handleCheckoutSubscribe('team')}
>
Upgrade to Team
</Button>
</div>
);
return (
<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>
);
});
BillingPlayground.displayName = 'BillingPlayground';

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'),
@ -39,6 +42,11 @@ function PageComponent() {
title: t('Workspace'),
href: '/settings/workspace',
},
{
id: 'apiKey',
title: t('Api Key'),
href: '/settings/apiKey',
},
{
id: 'auditLog',
title: t('Audit Log'),
@ -49,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

@ -0,0 +1,157 @@
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 { CommonHeader } from '@/components/CommonHeader';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { AppRouterOutput, defaultErrorHandler, trpc } from '@/api/trpc';
import { createColumnHelper, DataTable } from '@/components/DataTable';
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { useEvent } from '@/hooks/useEvent';
import dayjs from 'dayjs';
import { LuPlus, LuTrash } from 'react-icons/lu';
import copy from 'copy-to-clipboard';
import { toast } from 'sonner';
import { CopyableText } from '@/components/CopyableText';
import { AlertConfirm } from '@/components/AlertConfirm';
import { formatNumber } from '@/utils/common';
export const Route = createFileRoute('/settings/apiKey')({
beforeLoad: routeAuthBeforeLoad,
component: PageComponent,
});
type ApiKeyInfo = AppRouterOutput['user']['allApiKeys'][number];
const columnHelper = createColumnHelper<ApiKeyInfo>();
function PageComponent() {
const { t } = useTranslation();
const { data: apiKeys = [], refetch: refetchApiKeys } =
trpc.user.allApiKeys.useQuery();
const generateApiKeyMutation = trpc.user.generateApiKey.useMutation({
onError: defaultErrorHandler,
});
const deleteApiKeyMutation = trpc.user.deleteApiKey.useMutation({
onError: defaultErrorHandler,
});
const columns = useMemo(() => {
return [
columnHelper.accessor('apiKey', {
header: t('Key'),
size: 300,
cell: (props) => {
return (
<CopyableText text={props.getValue()}>
{props.getValue().slice(0, 20)}...
</CopyableText>
);
},
}),
columnHelper.accessor('usage', {
header: t('Usage'),
size: 80,
cell: (props) => {
return (
<div className="text-right">
{formatNumber(Number(props.getValue()))}
</div>
);
},
}),
columnHelper.accessor('createdAt', {
header: t('Created At'),
size: 130,
cell: (props) => {
const date = props.getValue();
return (
<span>
{date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'}
</span>
);
},
}),
columnHelper.accessor('updatedAt', {
header: t('Last Use At'),
size: 130,
cell: (props) => {
const date = props.getValue();
return (
<span>
{date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'}
</span>
);
},
}),
columnHelper.accessor('expiredAt', {
header: t('Expired At'),
size: 130,
cell: (props) => {
const date = props.getValue();
return (
<span>
{date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'}
</span>
);
},
}),
columnHelper.display({
id: 'action',
header: t('Action'),
size: 130,
cell: (props) => {
return (
<div>
<AlertConfirm
onConfirm={async () => {
await deleteApiKeyMutation.mutateAsync({
apiKey: props.row.original.apiKey,
});
refetchApiKeys();
}}
>
<Button variant="outline" size="icon" Icon={LuTrash} />
</AlertConfirm>
</div>
);
},
}),
];
}, [t]);
const handleGenerateApiKey = useEvent(async () => {
const apiKey = await generateApiKeyMutation.mutateAsync();
copy(apiKey);
toast.success(t('New api key has been copied into your clipboard!'));
refetchApiKeys();
});
return (
<CommonWrapper header={<CommonHeader title={t('Api Keys')} />}>
<ScrollArea className="h-full overflow-hidden p-4">
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="text-lg font-bold">
<div className="flex items-center justify-between gap-2">
<div>{t('Api Keys')}</div>
<Button
Icon={LuPlus}
size="icon"
variant="outline"
onClick={handleGenerateApiKey}
/>
</div>
</CardHeader>
<CardContent>
<DataTable columns={columns} data={apiKeys} />
</CardContent>
</Card>
</div>
</ScrollArea>
</CommonWrapper>
);
}

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

@ -38,13 +38,22 @@ import {
} from '@/components/ui/form';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { useEventWithLoading } from '@/hooks/useEvent';
import { useEvent, useEventWithLoading } from '@/hooks/useEvent';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { AlertConfirm } from '@/components/AlertConfirm';
import { ROLES } from '@tianji/shared';
import { cn } from '@/utils/style';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import dayjs from 'dayjs';
import { getTimezoneList } from '@/utils/date';
export const Route = createFileRoute('/settings/workspace')({
beforeLoad: routeAuthBeforeLoad,
@ -62,7 +71,7 @@ const columnHelper = createColumnHelper<MemberInfo>();
function PageComponent() {
const { t } = useTranslation();
const { id: workspaceId, name, role } = useCurrentWorkspace();
const { id: workspaceId, name, role, settings } = useCurrentWorkspace();
const hasAdminPermission = useHasAdminPermission();
const { data: members = [], refetch: refetchMembers } =
trpc.workspace.members.useQuery({
@ -71,6 +80,9 @@ function PageComponent() {
const updateCurrentWorkspaceName = useUserStore(
(state) => state.updateCurrentWorkspaceName
);
const updateCurrentWorkspaceSettings = useUserStore(
(state) => state.updateCurrentWorkspaceSettings
);
const form = useForm<InviteFormValues>({
resolver: zodResolver(inviteFormSchema),
defaultValues: {
@ -89,6 +101,10 @@ function PageComponent() {
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const updateSettings = trpc.workspace.updateSettings.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const [renameWorkspaceName, setRenameWorkspaceName] = useState('');
const [handleRename, isRenameLoading] = useEventWithLoading(async () => {
@ -112,6 +128,19 @@ function PageComponent() {
}
);
const handleUpdateSettings = useEvent(async (key: string, value: string) => {
const { settings } = await updateSettings.mutateAsync({
workspaceId,
settings: {
[key]: value,
},
});
updateCurrentWorkspaceSettings(settings);
});
const timezoneList = useMemo(() => getTimezoneList(), []);
const columns = useMemo(() => {
return [
columnHelper.accessor(
@ -167,6 +196,36 @@ function PageComponent() {
</CardContent>
</Card>
<Card>
<CardHeader className="text-lg font-bold">
{t('General')}
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="flex-1">{t('Timezone')}</div>
<div>
<Select
value={settings['timezone'] ?? dayjs.tz.guess()}
onValueChange={(value) =>
handleUpdateSettings('timezone', value)
}
>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{timezoneList.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleInvite)}

View File

@ -10,6 +10,7 @@ export type UserLoginInfo = NonNullable<AppRouterOutput['user']['info']>;
interface UserState {
info: UserLoginInfo | null;
updateCurrentWorkspaceName: (name: string) => void;
updateCurrentWorkspaceSettings: (settings: Record<string, any>) => void;
}
export const useUserStore = createWithEqualityFn<UserState>()(
@ -27,6 +28,21 @@ export const useUserStore = createWithEqualityFn<UserState>()(
}
});
},
updateCurrentWorkspaceSettings: (settings) => {
set((state) => {
const currentUserInfo = useUserStore.getState().info;
if (!currentUserInfo) {
return;
}
for (const workspace of state.info?.workspaces ?? []) {
workspace.workspace.settings = {
...workspace.workspace.settings,
...settings,
};
}
});
},
})),
shallow
);
@ -88,6 +104,7 @@ export function useCurrentWorkspaceSafe() {
id: currentWorkspace.workspace.id,
name: currentWorkspace.workspace.name,
role: currentWorkspace.role,
settings: currentWorkspace.workspace.settings,
};
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
import { describe, expect, test } from 'vitest';
import { getTimezoneList } from './date';
describe('getTimezoneList', () => {
test('should return timezone list with correct labels and values', () => {
const result = getTimezoneList();
expect(result).toMatchSnapshot();
});
});

View File

@ -68,3 +68,25 @@ export function formatDateWithUnit(val: dayjs.ConfigType, unit: DateUnit) {
return formatDate(val);
}
function formatOffset(offset: number) {
const sign = offset >= 0 ? '+' : '-';
const absOffset = Math.abs(offset);
const hours = String(Math.floor(absOffset / 60)).padStart(2, '0');
const minutes = String(absOffset % 60).padStart(2, '0');
return `${sign}${hours}:${minutes}`;
}
export function getTimezoneList() {
const timezones = Intl.supportedValuesOf('timeZone');
return timezones.map((timezone) => {
const offset = dayjs().tz(timezone).utcOffset();
return {
label: `${timezone} (${formatOffset(offset)})`,
value: timezone,
};
});
}

View File

@ -10,7 +10,7 @@ export function parseHealthStatusByPercent(
percent: number,
count: number
): HealthStatus {
if (percent === 100) {
if (percent >= 95) {
return 'health';
} else if (percent === 0 && count === 0) {
return 'none';

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';
@ -22,17 +21,22 @@ import path from 'path';
import { monitorPageManager } from './model/monitor/page/manager.js';
import { ExpressAuth } from '@auth/express';
import { authConfig } from './model/auth.js';
import { prometheusApiVersion } from './middleware/prometheus/index.js';
import { billingRouter } from './router/billing.js';
const app = express();
app.set('trust proxy', true);
app.use(prometheusApiVersion());
app.use(compression());
app.use(
express.json({
limit: '10mb',
verify: (req, res, buf) => {
(req as any).rawBody = buf;
},
})
);
app.use(passport.initialize());
app.use(morgan('tiny'));
app.use(cors());
@ -49,6 +53,7 @@ app.use(
app.use('/health', healthRouter);
app.use('/api/auth/*', ExpressAuth(authConfig));
app.use('/api/website', websiteRouter);
app.use('/api/billing', billingRouter);
app.use('/monitor', monitorRouter);
app.use('/telemetry', telemetryRouter);
app.use('/serverStatus', serverStatusRouter);

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);
@ -242,14 +247,14 @@ async function statDailyUsage() {
}
/**
* Clear over 2 week data
* Clear over 1 month data
*/
async function clearMonitorDataDaily() {
if (env.disableAutoClear) {
return;
}
const date = dayjs().subtract(2, 'weeks').toDate();
const date = dayjs().subtract(1, 'months').toDate();
logger.info(
'[clearMonitorDataDaily] Start clear monitor data before:',
date.toISOString()
@ -269,14 +274,14 @@ async function clearMonitorDataDaily() {
}
/**
* Clear over 2 week data
* Clear over 1 month data
*/
async function clearMonitorEventDaily() {
if (env.disableAutoClear) {
return;
}
const date = dayjs().subtract(2, 'weeks').toDate();
const date = dayjs().subtract(1, 'month').toDate();
logger.info(
'[clearMonitorEventDaily] Start clear monitor data before:',
date.toISOString()
@ -386,6 +391,9 @@ async function dailyHTTPCertCheckNotify() {
);
}
/**
* Check feed events notify
*/
async function checkFeedEventsNotify(
notifyFrequency: FeedChannelNotifyFrequency
) {

View File

@ -1,8 +1,14 @@
import { version } from '@tianji/shared';
import axios from 'axios';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import timezone from 'dayjs/plugin/timezone.js';
axios.defaults.headers.common['User-Agent'] = `tianji/${version}`;
dayjs.extend(utc);
dayjs.extend(timezone);
(BigInt.prototype as any).toJSON = function () {
const int = Number.parseInt(this.toString());
return int ?? this.toString();

View File

@ -9,6 +9,7 @@ import { initCronjob } from './cronjob/index.js';
import { logger } from './utils/logger.js';
import { app } from './app.js';
import { runMQWorker } from './mq/worker.js';
import { initCounter } from './utils/prometheus/index.js';
const port = env.port;
@ -20,6 +21,8 @@ initSocketio(httpServer);
initCronjob();
initCounter();
runMQWorker();
monitorManager.startAll();

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 @@
This folder is fork from https://github.com/PayU/prometheus-api-metrics

View File

@ -0,0 +1,132 @@
import Prometheus from 'prom-client';
import * as utils from './utils.js';
import { SetupOptions, ApiMetricsOpts } from './types.js';
import { ExpressMiddleware } from './middleware.js';
export function prometheusApiVersion(options: ApiMetricsOpts = {}) {
const appVersion = '1.0.0';
const projectName = 'tianji';
const {
metricsPath,
defaultMetricsInterval = 10000,
durationBuckets,
requestSizeBuckets,
responseSizeBuckets,
useUniqueHistogramName,
metricsPrefix,
excludeRoutes,
includeQueryParams,
additionalLabels = [],
extractAdditionalLabelValuesFn,
} = options;
const setupOptions: SetupOptions = {};
setupOptions.metricsRoute = utils.validateInput({
input: metricsPath,
isValidInputFn: utils.isString,
defaultValue: '/_prom/metrics',
errorMessage: 'metricsPath should be an string',
});
setupOptions.excludeRoutes = utils.validateInput({
input: excludeRoutes,
isValidInputFn: utils.isArray,
defaultValue: [],
errorMessage: 'excludeRoutes should be an array',
});
setupOptions.includeQueryParams = includeQueryParams;
setupOptions.defaultMetricsInterval = defaultMetricsInterval;
setupOptions.additionalLabels = utils.validateInput({
input: additionalLabels,
isValidInputFn: utils.isArray,
defaultValue: [],
errorMessage: 'additionalLabels should be an array',
});
setupOptions.extractAdditionalLabelValuesFn = utils.validateInput({
input: extractAdditionalLabelValuesFn,
isValidInputFn: utils.isFunction,
defaultValue: () => ({}),
errorMessage: 'extractAdditionalLabelValuesFn should be a function',
});
const metricNames = utils.getMetricNames(
{
http_request_duration_seconds: 'http_request_duration_seconds',
app_version: 'app_version',
http_request_size_bytes: 'http_request_size_bytes',
http_response_size_bytes: 'http_response_size_bytes',
defaultMetricsPrefix: '',
},
useUniqueHistogramName ?? false,
metricsPrefix ?? '',
projectName
);
Prometheus.collectDefaultMetrics({
eventLoopMonitoringPrecision: defaultMetricsInterval,
prefix: `${metricNames.defaultMetricsPrefix}`,
});
PrometheusRegisterAppVersion(appVersion, metricNames.app_version);
const metricLabels = ['method', 'route', 'code', ...additionalLabels].filter(
Boolean
);
// Buckets for response time from 1ms to 500ms
const defaultDurationSecondsBuckets = [
0.001, 0.005, 0.015, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5,
];
// Buckets for request size from 5 bytes to 10000 bytes
const defaultSizeBytesBuckets = [
5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000,
];
setupOptions.responseTimeHistogram =
Prometheus.register.getSingleMetric(
metricNames.http_request_duration_seconds
) ||
new Prometheus.Histogram({
name: metricNames.http_request_duration_seconds,
help: 'Duration of HTTP requests in seconds',
labelNames: metricLabels,
buckets: durationBuckets || defaultDurationSecondsBuckets,
});
setupOptions.requestSizeHistogram =
Prometheus.register.getSingleMetric(metricNames.http_request_size_bytes) ||
new Prometheus.Histogram({
name: metricNames.http_request_size_bytes,
help: 'Size of HTTP requests in bytes',
labelNames: metricLabels,
buckets: requestSizeBuckets || defaultSizeBytesBuckets,
});
setupOptions.responseSizeHistogram =
Prometheus.register.getSingleMetric(metricNames.http_response_size_bytes) ||
new Prometheus.Histogram({
name: metricNames.http_response_size_bytes,
help: 'Size of HTTP response in bytes',
labelNames: metricLabels,
buckets: responseSizeBuckets || defaultSizeBytesBuckets,
});
const middleware = new ExpressMiddleware(setupOptions);
return middleware.middleware.bind(middleware);
}
function PrometheusRegisterAppVersion(appVersion: string, metricName: string) {
const version = new Prometheus.Gauge({
name: metricName,
help: 'The service version by package.json',
labelNames: ['version', 'major', 'minor', 'patch'],
});
const [major, minor, patch] = appVersion.split('.');
version.labels(appVersion, major, minor, patch).set(1);
}

View File

@ -0,0 +1,135 @@
import Prometheus from 'prom-client';
import { SetupOptions } from './types.js';
import { Request, Response, NextFunction } from 'express';
import * as utils from './utils.js';
export class ExpressMiddleware {
constructor(public setupOptions: SetupOptions) {}
_collectDefaultServerMetrics(timeout: number) {
const NUMBER_OF_CONNECTIONS_METRICS_NAME =
'expressjs_number_of_open_connections';
this.setupOptions.numberOfConnectionsGauge =
Prometheus.register.getSingleMetric(NUMBER_OF_CONNECTIONS_METRICS_NAME) ||
new Prometheus.Gauge({
name: NUMBER_OF_CONNECTIONS_METRICS_NAME,
help: 'Number of open connections to the Express.js server',
});
if (this.setupOptions.server) {
setInterval(this._getConnections.bind(this), timeout).unref();
}
}
_getConnections() {
if (this.setupOptions && this.setupOptions.server) {
this.setupOptions.server.getConnections((error: any, count: any) => {
if (error) {
// debug('Error while collection number of open connections', error);
} else {
this.setupOptions.numberOfConnectionsGauge.set(count);
}
});
}
}
_handleResponse(req: Request, res: Response) {
const responseLength = parseInt(res.get('Content-Length')!) || 0;
const route = this._getRoute(req);
if (
route &&
utils.shouldLogMetrics(this.setupOptions.excludeRoutes!, route)
) {
const labels = {
method: req.method,
route,
code: res.statusCode,
...this.setupOptions.extractAdditionalLabelValuesFn!(req, res),
};
this.setupOptions.requestSizeHistogram.observe(
labels,
(req as any).metrics.contentLength
);
(req as any).metrics.timer(labels);
this.setupOptions.responseSizeHistogram.observe(labels, responseLength);
}
}
_getRoute(req: Request) {
let route = req.baseUrl;
if (req.route) {
if (req.route.path !== '/') {
route = route ? route + req.route.path : req.route.path;
}
if (!route || route === '' || typeof route !== 'string') {
route = req.originalUrl.split('?')[0];
} else {
const splittedRoute = route.split('/');
const splittedUrl = req.originalUrl.split('?')[0].split('/');
const routeIndex = splittedUrl.length - splittedRoute.length + 1;
const baseUrl = splittedUrl.slice(0, routeIndex).join('/');
route = baseUrl + route;
}
if (
this.setupOptions.includeQueryParams === true &&
Object.keys(req.query).length > 0
) {
route = `${route}?${Object.keys(req.query)
.sort()
.map((queryParam) => `${queryParam}=<?>`)
.join('&')}`;
}
}
// nest.js - build request url pattern if exists
if (typeof req.params === 'object') {
Object.keys(req.params).forEach((paramName) => {
route = route.replace(req.params[paramName], ':' + paramName);
});
}
// this condition will evaluate to true only in
// express framework and no route was found for the request. if we log this metrics
// we'll risk in a memory leak since the route is not a pattern but a hardcoded string.
if (!route || route === '') {
// if (!req.route && res && res.statusCode === 404) {
route = 'N/A';
}
return route;
}
async middleware(req: Request, res: Response, next: NextFunction) {
if (!this.setupOptions.server && req.socket) {
this.setupOptions.server = (req.socket as any).server;
this._collectDefaultServerMetrics(
this.setupOptions.defaultMetricsInterval as any
);
}
const routeUrl = req.originalUrl || req.url;
if (routeUrl === this.setupOptions.metricsRoute) {
res.set('Content-Type', Prometheus.register.contentType);
return res.end(await Prometheus.register.metrics());
}
if (routeUrl === `${this.setupOptions.metricsRoute}.json`) {
return res.json(await Prometheus.register.getMetricsAsJSON());
}
(req as any).metrics = {
timer: (this.setupOptions as any).responseTimeHistogram.startTimer(),
contentLength: parseInt(req.get('content-length')!) || 0,
} as any;
res.once('finish', () => {
this._handleResponse(req, res);
});
return next();
}
}

View File

@ -0,0 +1,40 @@
import { Request, Response } from 'express';
import Prometheus from 'prom-client';
export interface ApiMetricsOpts {
metricsPath?: string;
defaultMetricsInterval?: number;
durationBuckets?: number[];
requestSizeBuckets?: number[];
responseSizeBuckets?: number[];
useUniqueHistogramName?: boolean;
metricsPrefix?: string;
excludeRoutes?: string[];
includeQueryParams?: boolean;
additionalLabels?: string[];
extractAdditionalLabelValuesFn?: (
req: Request,
res: Response
) => Record<string, unknown>;
}
export interface CollectorOpts {
durationBuckets?: number[];
countClientErrors?: boolean;
useUniqueHistogramName?: boolean;
prefix?: string;
}
export interface SetupOptions {
metricsRoute?: string;
excludeRoutes?: string[];
includeQueryParams?: boolean;
defaultMetricsInterval?: number;
additionalLabels?: string[];
extractAdditionalLabelValuesFn?: (
req: Request,
res: Response
) => Record<string, unknown>;
responseTimeHistogram?: Prometheus.Metric<string> | undefined;
[other: string]: any;
}

View File

@ -0,0 +1,63 @@
'use strict';
type MetricNames = { [key: string]: string };
const getMetricNames = (
metricNames: MetricNames,
useUniqueHistogramName: boolean,
metricsPrefix: string,
projectName: string
): MetricNames => {
const prefix = useUniqueHistogramName === true ? projectName : metricsPrefix;
if (prefix) {
Object.keys(metricNames).forEach((key) => {
metricNames[key] = `${prefix}_${metricNames[key]}`;
});
}
return metricNames;
};
const isArray = (input: unknown): input is any[] => Array.isArray(input);
const isFunction = (input: unknown): input is Function =>
typeof input === 'function';
const isString = (input: unknown): input is string => typeof input === 'string';
const shouldLogMetrics = (excludeRoutes: string[], route: string): boolean =>
excludeRoutes.every((path) => !route.includes(path));
interface ValidateInputParams<T> {
input: T | undefined;
isValidInputFn: (input: T) => boolean;
defaultValue: T;
errorMessage: string;
}
const validateInput = <T>({
input,
isValidInputFn,
defaultValue,
errorMessage,
}: ValidateInputParams<T>): T => {
if (typeof input !== 'undefined') {
if (isValidInputFn(input)) {
return input;
} else {
throw new Error(errorMessage);
}
}
return defaultValue;
};
export {
getMetricNames,
isArray,
isFunction,
isString,
shouldLogMetrics,
validateInput,
};

View File

@ -19,6 +19,7 @@ export const workspaceDashboardLayoutSchema = z.object({
export const workspaceSchema = z.object({
id: z.string(),
name: z.string(),
settings: z.record(z.string(), z.any()),
});
export const userInfoSchema = z.object({

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

@ -0,0 +1,194 @@
import {
lemonSqueezySetup,
createCheckout,
updateSubscription,
cancelSubscription as lsCancelSubscription,
} from '@lemonsqueezy/lemonsqueezy.js';
import { env } from '../../utils/env.js';
import { prisma } from '../_client.js';
import { WorkspaceSubscriptionTier } from '@prisma/client';
export const billingAvailable = Boolean(env.billing.lemonSqueezy.apiKey);
if (billingAvailable) {
lemonSqueezySetup({
apiKey: env.billing.lemonSqueezy.apiKey,
onError: (error) => console.error('Error!', error),
});
}
export type SubscriptionTierType =
keyof typeof env.billing.lemonSqueezy.tierVariantId;
export function getTierNameByvariantId(variantId: string) {
const tierName = Object.keys(env.billing.lemonSqueezy.tierVariantId).find(
(key) =>
env.billing.lemonSqueezy.tierVariantId[key as SubscriptionTierType] ===
variantId
);
if (!tierName) {
throw new Error('Unknown Tier Name');
}
return tierName;
}
export function getTierEnumByVariantId(
variantId: string
): WorkspaceSubscriptionTier {
const name = getTierNameByvariantId(variantId);
if (name === 'free') {
return WorkspaceSubscriptionTier.FREE;
} else if (name === 'pro') {
return WorkspaceSubscriptionTier.PRO;
} else if (name === 'team') {
return WorkspaceSubscriptionTier.TEAM;
}
return WorkspaceSubscriptionTier.FREE; // not cool, fallback to free
}
export function checkIsValidProduct(storeId: string, variantId: string) {
if (String(storeId) !== env.billing.lemonSqueezy.storeId) {
return false;
}
if (
!Object.values(env.billing.lemonSqueezy.tierVariantId).includes(variantId)
) {
return false;
}
return true;
}
export async function createCheckoutBilling(
workspaceId: string,
userId: string,
subscriptionTier: SubscriptionTierType,
redirectUrl?: string
) {
const variantId = env.billing.lemonSqueezy.tierVariantId[subscriptionTier];
if (!variantId) {
throw new Error('Unknown subscription tier');
}
const userInfo = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!userInfo) {
throw new Error('User not found');
}
const subscription = await prisma.lemonSqueezySubscription.findUnique({
where: {
workspaceId,
},
});
if (subscription) {
throw new Error('This workspace already has a subscription');
}
// not existed subscription
const checkout = await createCheckout(
env.billing.lemonSqueezy.storeId,
variantId,
{
checkoutData: {
name: userInfo.nickname ?? undefined,
email: userInfo.email ?? undefined,
custom: {
userId,
workspaceId,
},
},
productOptions: {
redirectUrl,
},
}
);
if (checkout.error) {
throw checkout.error;
}
const checkoutData = checkout.data.data;
return checkoutData;
}
export async function updateWorkspaceSubscription(
workspaceId: string,
subscriptionTier: WorkspaceSubscriptionTier
) {
const res = await prisma.workspaceSubscription.upsert({
where: {
workspaceId,
},
create: {
workspaceId,
tier: subscriptionTier,
},
update: {
tier: subscriptionTier,
},
});
return res;
}
export async function changeSubscription(
workspaceId: string,
subscriptionTier: SubscriptionTierType
) {
const variantId = env.billing.lemonSqueezy.tierVariantId[subscriptionTier];
if (!variantId) {
throw new Error('Unknown subscription tier');
}
const subscription = await prisma.lemonSqueezySubscription.findUnique({
where: {
workspaceId,
},
});
if (!subscription) {
throw new Error('Can not found existed subscription');
}
const res = await updateSubscription(subscription.subscriptionId, {
variantId: Number(variantId),
});
if (res.error) {
throw res.error;
}
return res.data.data;
}
export async function cancelSubscription(workspaceId: string) {
const subscription = await prisma.lemonSqueezySubscription.findUnique({
where: {
workspaceId,
},
});
if (!subscription) {
throw new Error('Can not found existed subscription');
}
const res = await lsCancelSubscription(subscription.subscriptionId);
if (res.error) {
throw res.error;
}
return res.data.data;
}

View File

@ -0,0 +1,61 @@
import { WorkspaceSubscriptionTier } from '@prisma/client';
import { z } from 'zod';
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: WorkspaceSubscriptionTier): TierLimit {
if (tier === WorkspaceSubscriptionTier.FREE) {
return {
maxWebsiteCount: 3,
maxWebsiteEventCount: 100_000,
maxMonitorExecutionCount: 100_000,
maxSurveyCount: 3,
maxFeedChannelCount: 3,
maxFeedEventCount: 10_000,
};
}
if (tier === WorkspaceSubscriptionTier.PRO) {
return {
maxWebsiteCount: 10,
maxWebsiteEventCount: 1_000_000,
maxMonitorExecutionCount: 1_000_000,
maxSurveyCount: 20,
maxFeedChannelCount: 20,
maxFeedEventCount: 100_000,
};
}
if (tier === WorkspaceSubscriptionTier.TEAM) {
return {
maxWebsiteCount: -1,
maxWebsiteEventCount: 20_000_000,
maxMonitorExecutionCount: 20_000_000,
maxSurveyCount: -1,
maxFeedChannelCount: -1,
maxFeedEventCount: 1_000_000,
};
}
// Unlimited
return {
maxWebsiteCount: -1,
maxWebsiteEventCount: -1,
maxMonitorExecutionCount: -1,
maxSurveyCount: -1,
maxFeedChannelCount: -1,
maxFeedEventCount: -1,
};
}

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

@ -82,7 +82,7 @@ export async function getMonitorSummaryWithDay(
const list = await prisma.$queryRaw<MonitorSummaryItem[]>`
SELECT
DATE("createdAt") AS day,
TO_CHAR(DATE("createdAt"), 'YYYY-MM-DD') AS day,
COUNT(1) AS total_count,
SUM(CASE WHEN "value" >= 0 THEN 1 ELSE 0 END) AS up_count,
(SUM(CASE WHEN "value" >= 0 THEN 1 ELSE 0 END) * 100.0 / COUNT(1)) AS up_rate
@ -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

@ -1,7 +1,9 @@
import { Monitor, Notification } from '@prisma/client';
import { Monitor } from '@prisma/client';
import { prisma } from '../_client.js';
import { MonitorRunner } from './runner.js';
import { logger } from '../../utils/logger.js';
import { MonitorWithNotification } from './types.js';
import { promMonitorRunnerCounter } from '../../utils/prometheus/client.js';
export type MonitorUpsertData = Pick<
Monitor,
@ -13,8 +15,6 @@ export type MonitorUpsertData = Pick<
payload: Record<string, any>;
};
type MonitorWithNotification = Monitor & { notifications: Notification[] };
export class MonitorManager {
private monitorRunner: Record<string, MonitorRunner> = {};
private isStarted = false;
@ -64,9 +64,7 @@ export class MonitorManager {
delete this.monitorRunner[monitor.id];
}
const runner = (this.monitorRunner[monitor.id] = new MonitorRunner(
monitor
));
const runner = await this.createRunner(monitor);
runner.startMonitor();
return monitor;
@ -112,7 +110,7 @@ export class MonitorManager {
Promise.all(
monitors.map(async (m) => {
try {
const runner = new MonitorRunner(m);
const runner = await this.createRunner(m);
this.monitorRunner[m.id] = runner;
await runner.startMonitor();
} catch (err) {
@ -128,11 +126,37 @@ export class MonitorManager {
return this.monitorRunner[monitorId];
}
createRunner(monitor: MonitorWithNotification) {
/**
* Restart all runner basic on workspace id
*/
restartWithWorkspaceId(workspaceId: string) {
Object.values(this.monitorRunner).map((runner) => {
if (runner.workspace.id === workspaceId) {
this.createRunner(runner.monitor);
}
});
}
/**
* create runner
*/
async createRunner(monitor: MonitorWithNotification) {
if (this.monitorRunner[monitor.id]) {
this.monitorRunner[monitor.id].stopMonitor();
}
const workspace = await prisma.workspace.findUniqueOrThrow({
where: {
id: monitor.workspaceId,
},
});
const runner = (this.monitorRunner[monitor.id] = new MonitorRunner(
workspace,
monitor
));
promMonitorRunnerCounter.set(Object.keys(this.monitorRunner).length);
return runner;
}
}

View File

@ -1,4 +1,4 @@
import { Monitor, Notification } from '@prisma/client';
import { Notification, Workspace } from '@prisma/client';
import { subscribeEventBus } from '../../ws/shared.js';
import { prisma } from '../_client.js';
import { monitorProviders } from './provider/index.js';
@ -8,6 +8,8 @@ import { logger } from '../../utils/logger.js';
import { token } from '../notification/token/index.js';
import { ContentToken } from '../notification/token/type.js';
import { createAuditLog } from '../auditLog.js';
import { MonitorWithNotification } from './types.js';
import { get } from 'lodash-es';
/**
* Class which actually run monitor data collect
@ -17,7 +19,14 @@ export class MonitorRunner {
timer: NodeJS.Timeout | null = null;
retriedNum = 0;
constructor(public monitor: Monitor & { notifications: Notification[] }) {}
constructor(
public workspace: Workspace,
public monitor: MonitorWithNotification
) {}
getTimezone(): string {
return get(this.workspace, ['settings', 'timezone']) || 'utc';
}
/**
* Start single monitor
@ -74,9 +83,9 @@ export class MonitorRunner {
);
await this.notify(`[${monitor.name}] 🔴 Down`, [
token.text(
`[${monitor.name}] 🔴 Down\nTime: ${dayjs().format(
'YYYY-MM-DD HH:mm:ss (z)'
)}`
`[${monitor.name}] 🔴 Down\nTime: ${dayjs()
.tz(this.getTimezone())
.format('YYYY-MM-DD HH:mm:ss (z)')}`
),
]);
currentStatus = 'DOWN';
@ -88,9 +97,9 @@ export class MonitorRunner {
);
await this.notify(`[${monitor.name}] ✅ Up`, [
token.text(
`[${monitor.name}] ✅ Up\nTime: ${dayjs().format(
'YYYY-MM-DD HH:mm:ss (z)'
)}`
`[${monitor.name}] ✅ Up\nTime: ${dayjs()
.tz(this.getTimezone())
.format('YYYY-MM-DD HH:mm:ss (z)')}`
),
]);
currentStatus = 'UP';

View File

@ -0,0 +1,5 @@
import { Monitor, Notification } from '@prisma/client';
export type MonitorWithNotification = Monitor & {
notifications: Notification[];
};

View File

@ -1,4 +1,5 @@
import { ServerStatusInfo } from '../../types/index.js';
import { promServerCounter } from '../utils/prometheus/client.js';
import { createSubscribeInitializer, subscribeEventBus } from '../ws/shared.js';
import { isServerOnline } from '@tianji/shared';
@ -42,6 +43,13 @@ export function recordServerStatus(info: ServerStatusInfo) {
payload,
};
promServerCounter.set(
{
workspaceId,
},
Object.keys(serverMap[workspaceId]).length
);
subscribeEventBus.emit(
'onServerStatusUpdate',
workspaceId,

View File

@ -5,8 +5,9 @@ import { jwtVerify } from '../middleware/auth.js';
import { TRPCError } from '@trpc/server';
import { Prisma } from '@prisma/client';
import { AdapterUser } from '@auth/core/adapters';
import { md5 } from '../utils/common.js';
import { md5, sha256 } from '../utils/common.js';
import { logger } from '../utils/logger.js';
import { promUserCounter } from '../utils/prometheus/client.js';
async function hashPassword(password: string) {
return await bcryptjs.hash(password, 10);
@ -41,6 +42,7 @@ export const createUserSelect = {
select: {
id: true,
name: true,
settings: true,
},
},
},
@ -87,6 +89,8 @@ export async function createAdminUser(username: string, password: string) {
return user;
});
promUserCounter.inc();
return user;
}
@ -129,6 +133,8 @@ export async function createUser(username: string, password: string) {
return user;
});
promUserCounter.inc();
return user;
}
@ -335,3 +341,56 @@ export async function leaveWorkspace(userId: string, workspaceId: string) {
throw new Error('Leave Workspace Failed.');
}
}
/**
* Generate User Api Key, for user to call api
*/
export async function generateUserApiKey(userId: string, expiredAt?: Date) {
const apiKey = `sk_${sha256(`${userId}.${Date.now()}`)}`;
const result = await prisma.userApiKey.create({
data: {
apiKey,
userId,
expiredAt,
},
});
return result.apiKey;
}
/**
* Verify User Api Key
*/
export async function verifyUserApiKey(apiKey: string) {
const result = await prisma.userApiKey.findUnique({
where: {
apiKey,
},
select: {
user: true,
expiredAt: true,
},
});
if (result?.expiredAt && result.expiredAt.valueOf() < Date.now()) {
throw new Error('Api Key has been expired.');
}
if (!result) {
throw new Error('Api Key not found');
}
prisma.userApiKey.update({
where: {
apiKey,
},
data: {
usage: {
increment: 1,
},
},
});
return result.user;
}

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

@ -8,6 +8,7 @@
},
"scripts": {
"dev": "tsx watch --env-file=.env ./main.ts",
"dev:debug": "tsx --inspect-brk --env-file=.env ./main.ts",
"build": "tsc",
"postinstall": "pnpm db:generate",
"check:type": "tsc --noEmit --skipLibCheck",
@ -25,6 +26,7 @@
"dependencies": {
"@auth/core": "^0.34.1",
"@auth/express": "^0.5.5",
"@lemonsqueezy/lemonsqueezy.js": "^3.3.1",
"@paralleldrive/cuid2": "^2.2.2",
"@prisma/client": "5.14.0",
"@tianji/shared": "workspace:^",
@ -42,6 +44,7 @@
"dayjs": "^1.11.9",
"detect-browser": "^5.3.0",
"dotenv": "^16.3.1",
"easy-currency-symbol": "^1.0.1",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-validator": "^7.0.1",
@ -57,9 +60,9 @@
"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",
"request-ip": "^3.3.0",
"socket.io": "^4.7.4",
@ -88,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",
@ -97,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,35 @@
-- CreateTable
CREATE TABLE "LemonSqueezySubscription" (
"subscriptionId" TEXT NOT NULL,
"workspaceId" VARCHAR(30) NOT NULL,
"storeId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"variantId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"cardBrand" TEXT NOT NULL,
"cardLastFour" TEXT NOT NULL,
"renewsAt" TIMESTAMPTZ(6) NOT NULL,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "LemonSqueezySubscription_pkey" PRIMARY KEY ("subscriptionId")
);
-- CreateTable
CREATE TABLE "LemonSqueezyWebhookEvent" (
"id" VARCHAR(30) NOT NULL,
"eventName" TEXT NOT NULL,
"payload" JSON NOT NULL,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LemonSqueezyWebhookEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "LemonSqueezySubscription_subscriptionId_key" ON "LemonSqueezySubscription"("subscriptionId");
-- CreateIndex
CREATE UNIQUE INDEX "LemonSqueezySubscription_workspaceId_key" ON "LemonSqueezySubscription"("workspaceId");
-- CreateIndex
CREATE UNIQUE INDEX "LemonSqueezyWebhookEvent_id_key" ON "LemonSqueezyWebhookEvent"("id");

View File

@ -0,0 +1,23 @@
-- CreateEnum
CREATE TYPE "WorkspaceSubscriptionTier" AS ENUM ('FREE', 'PRO', 'TEAM', 'UNLIMITED');
-- CreateTable
CREATE TABLE "WorkspaceSubscription" (
"id" VARCHAR(30) NOT NULL,
"workspaceId" VARCHAR(30) NOT NULL,
"tier" "WorkspaceSubscriptionTier" NOT NULL DEFAULT 'FREE',
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "WorkspaceSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WorkspaceSubscription_workspaceId_key" ON "WorkspaceSubscription"("workspaceId");
-- AddForeignKey
ALTER TABLE "WorkspaceSubscription" ADD CONSTRAINT "WorkspaceSubscription_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Set admin workspace to UNLIMITED
INSERT INTO "WorkspaceSubscription" ("id", "workspaceId", "tier", "createdAt", "updatedAt") VALUES ('cm1yqv4xd002154qnfhzg9i5d', 'clnzoxcy10001vy2ohi4obbi0', 'UNLIMITED', '2024-10-07 08:22:45.169+00', '2024-10-07 08:22:45.169+00');

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "FeedChannel" ADD COLUMN "webhookSignature" VARCHAR(100) NOT NULL DEFAULT '';

View File

@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "UserApiKey" (
"apiKey" VARCHAR(128) NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(6) NOT NULL,
"expiredAt" TIMESTAMP(3),
CONSTRAINT "UserApiKey_pkey" PRIMARY KEY ("apiKey")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserApiKey_apiKey_key" ON "UserApiKey"("apiKey");
-- AddForeignKey
ALTER TABLE "UserApiKey" ADD CONSTRAINT "UserApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserApiKey" ADD COLUMN "usage" INTEGER NOT NULL DEFAULT 0;

View File

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

View File

@ -34,6 +34,18 @@ model User {
accounts Account[]
sessions Session[]
workspaces WorkspacesOnUsers[]
apiKeys UserApiKey[]
}
model UserApiKey {
apiKey String @id @unique @db.VarChar(128)
userId String
usage Int @default(0)
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
expiredAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Account {
@ -84,9 +96,12 @@ 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)
subscription WorkspaceSubscription?
users WorkspacesOnUsers[]
websites Website[]
notifications Notification[]
@ -116,6 +131,49 @@ model WorkspacesOnUsers {
@@index([workspaceId])
}
enum WorkspaceSubscriptionTier {
FREE
PRO
TEAM
UNLIMITED // This type should only use for special people or admin workspace
}
model WorkspaceSubscription {
id String @id() @default(cuid()) @db.VarChar(30)
workspaceId String @unique @db.VarChar(30)
tier WorkspaceSubscriptionTier @default(FREE) // free, pro, team
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onUpdate: Cascade, onDelete: Cascade)
}
model LemonSqueezySubscription {
subscriptionId String @id @unique
workspaceId String @unique @db.VarChar(30)
storeId String
productId String
variantId String
status String
cardBrand String
cardLastFour String
renewsAt DateTime @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
}
model LemonSqueezyWebhookEvent {
id String @id @unique @default(cuid()) @db.VarChar(30)
eventName String
/// [CommonPayload]
/// @zod.custom(imports.CommonPayloadSchema)
payload Json @db.Json // Other payload info get from query params, should be a object
createdAt DateTime @default(now()) @db.Timestamptz(6)
}
model Website {
id String @id @unique @default(cuid()) @db.VarChar(30)
workspaceId String @db.VarChar(30)
@ -540,12 +598,13 @@ enum FeedChannelNotifyFrequency {
}
model FeedChannel {
id String @id @default(cuid()) @db.VarChar(30)
workspaceId String @db.VarChar(30)
name String
notifyFrequency FeedChannelNotifyFrequency @default(day)
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
id String @id @default(cuid()) @db.VarChar(30)
workspaceId String @db.VarChar(30)
name String
webhookSignature String @default("") @db.VarChar(100)
notifyFrequency FeedChannelNotifyFrequency @default(day)
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onUpdate: Cascade, onDelete: Cascade)
events FeedEvent[]

View File

@ -7,6 +7,7 @@ export const FeedChannelModelSchema = z.object({
id: z.string(),
workspaceId: z.string(),
name: z.string(),
webhookSignature: z.string(),
notifyFrequency: z.nativeEnum(FeedChannelNotifyFrequency),
createdAt: z.date(),
updatedAt: z.date(),

View File

@ -1,9 +1,13 @@
export * from "./user.js"
export * from "./userapikey.js"
export * from "./account.js"
export * from "./session.js"
export * from "./verificationtoken.js"
export * from "./workspace.js"
export * from "./workspacesonusers.js"
export * from "./workspacesubscription.js"
export * from "./lemonsqueezysubscription.js"
export * from "./lemonsqueezywebhookevent.js"
export * from "./website.js"
export * from "./websitesession.js"
export * from "./websiteevent.js"

View File

@ -0,0 +1,16 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
export const LemonSqueezySubscriptionModelSchema = z.object({
subscriptionId: z.string(),
workspaceId: z.string(),
storeId: z.string(),
productId: z.string(),
variantId: z.string(),
status: z.string(),
cardBrand: z.string(),
cardLastFour: z.string(),
renewsAt: z.date(),
createdAt: z.date(),
updatedAt: z.date(),
})

View File

@ -0,0 +1,18 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
// Helper schema for JSON fields
type Literal = boolean | number | string
type Json = Literal | { [key: string]: Json } | Json[]
const literalSchema = z.union([z.string(), z.number(), z.boolean()])
const jsonSchema: z.ZodSchema<Json> = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]))
export const LemonSqueezyWebhookEventModelSchema = z.object({
id: z.string(),
eventName: z.string(),
/**
* [CommonPayload]
*/
payload: imports.CommonPayloadSchema,
createdAt: z.date(),
})

View File

@ -1,6 +1,6 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
import { CompleteAccount, RelatedAccountModelSchema, CompleteSession, RelatedSessionModelSchema, CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema } from "./index.js"
import { CompleteAccount, RelatedAccountModelSchema, CompleteSession, RelatedSessionModelSchema, CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteUserApiKey, RelatedUserApiKeyModelSchema } from "./index.js"
export const UserModelSchema = z.object({
id: z.string(),
@ -21,6 +21,7 @@ export interface CompleteUser extends z.infer<typeof UserModelSchema> {
accounts: CompleteAccount[]
sessions: CompleteSession[]
workspaces: CompleteWorkspacesOnUsers[]
apiKeys: CompleteUserApiKey[]
}
/**
@ -32,4 +33,5 @@ export const RelatedUserModelSchema: z.ZodSchema<CompleteUser> = z.lazy(() => Us
accounts: RelatedAccountModelSchema.array(),
sessions: RelatedSessionModelSchema.array(),
workspaces: RelatedWorkspacesOnUsersModelSchema.array(),
apiKeys: RelatedUserApiKeyModelSchema.array(),
}))

View File

@ -0,0 +1,25 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
import { CompleteUser, RelatedUserModelSchema } from "./index.js"
export const UserApiKeyModelSchema = z.object({
apiKey: z.string(),
userId: z.string(),
usage: z.number().int(),
createdAt: z.date(),
updatedAt: z.date(),
expiredAt: z.date().nullish(),
})
export interface CompleteUserApiKey extends z.infer<typeof UserApiKeyModelSchema> {
user: CompleteUser
}
/**
* RelatedUserApiKeyModelSchema contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const RelatedUserApiKeyModelSchema: z.ZodSchema<CompleteUserApiKey> = z.lazy(() => UserApiKeyModelSchema.extend({
user: RelatedUserModelSchema,
}))

View File

@ -1,6 +1,6 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
import { CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteWebsite, RelatedWebsiteModelSchema, CompleteNotification, RelatedNotificationModelSchema, CompleteMonitor, RelatedMonitorModelSchema, CompleteMonitorStatusPage, RelatedMonitorStatusPageModelSchema, CompleteTelemetry, RelatedTelemetryModelSchema, CompleteWorkspaceDailyUsage, RelatedWorkspaceDailyUsageModelSchema, CompleteWorkspaceAuditLog, RelatedWorkspaceAuditLogModelSchema, CompleteSurvey, RelatedSurveyModelSchema, CompleteFeedChannel, RelatedFeedChannelModelSchema } from "./index.js"
import { CompleteWorkspaceSubscription, RelatedWorkspaceSubscriptionModelSchema, CompleteWorkspacesOnUsers, RelatedWorkspacesOnUsersModelSchema, CompleteWebsite, RelatedWebsiteModelSchema, CompleteNotification, RelatedNotificationModelSchema, CompleteMonitor, RelatedMonitorModelSchema, CompleteMonitorStatusPage, RelatedMonitorStatusPageModelSchema, CompleteTelemetry, RelatedTelemetryModelSchema, CompleteWorkspaceDailyUsage, RelatedWorkspaceDailyUsageModelSchema, CompleteWorkspaceAuditLog, RelatedWorkspaceAuditLogModelSchema, CompleteSurvey, RelatedSurveyModelSchema, CompleteFeedChannel, RelatedFeedChannelModelSchema } from "./index.js"
// Helper schema for JSON fields
type Literal = boolean | number | string
@ -20,11 +20,13 @@ export const WorkspaceModelSchema = z.object({
* [CommonPayload]
*/
settings: imports.CommonPayloadSchema,
paused: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
})
export interface CompleteWorkspace extends z.infer<typeof WorkspaceModelSchema> {
subscription?: CompleteWorkspaceSubscription | null
users: CompleteWorkspacesOnUsers[]
websites: CompleteWebsite[]
notifications: CompleteNotification[]
@ -43,6 +45,7 @@ export interface CompleteWorkspace extends z.infer<typeof WorkspaceModelSchema>
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const RelatedWorkspaceModelSchema: z.ZodSchema<CompleteWorkspace> = z.lazy(() => WorkspaceModelSchema.extend({
subscription: RelatedWorkspaceSubscriptionModelSchema.nullish(),
users: RelatedWorkspacesOnUsersModelSchema.array(),
websites: RelatedWebsiteModelSchema.array(),
notifications: RelatedNotificationModelSchema.array(),

View File

@ -0,0 +1,25 @@
import * as z from "zod"
import * as imports from "./schemas/index.js"
import { WorkspaceSubscriptionTier } from "@prisma/client"
import { CompleteWorkspace, RelatedWorkspaceModelSchema } from "./index.js"
export const WorkspaceSubscriptionModelSchema = z.object({
id: z.string(),
workspaceId: z.string(),
tier: z.nativeEnum(WorkspaceSubscriptionTier),
createdAt: z.date(),
updatedAt: z.date(),
})
export interface CompleteWorkspaceSubscription extends z.infer<typeof WorkspaceSubscriptionModelSchema> {
workspace: CompleteWorkspace
}
/**
* RelatedWorkspaceSubscriptionModelSchema contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const RelatedWorkspaceSubscriptionModelSchema: z.ZodSchema<CompleteWorkspaceSubscription> = z.lazy(() => WorkspaceSubscriptionModelSchema.extend({
workspace: RelatedWorkspaceModelSchema,
}))

View File

@ -0,0 +1,101 @@
import { Router, raw } from 'express';
import crypto from 'crypto';
import { env } from '../utils/env.js';
import { get } from 'lodash-es';
import {
checkIsValidProduct,
getTierEnumByVariantId,
updateWorkspaceSubscription,
} from '../model/billing/index.js';
import { prisma } from '../model/_client.js';
import dayjs from 'dayjs';
export const billingRouter = Router();
billingRouter.post(
'/lemonsqueezy/webhook',
raw({
type: () => true,
}),
async (req, res) => {
const rawBody = String(req.rawBody);
const body = req.body;
signatureIsValid(rawBody, req.get('X-Signature') ?? '');
const eventName = get(body, 'meta.event_name');
const workspaceId = get(body, 'meta.custom_data.workspace_id');
await prisma.lemonSqueezyWebhookEvent.create({
data: {
eventName,
payload: body,
},
});
if (!workspaceId) {
res.status(500).send('No workspace id');
return;
}
if (eventName === 'subscription_updated') {
// update user subscription
const subscriptionId = String(get(body, 'data.id'));
const storeId = String(get(body, 'data.attributes.store_id'));
const productId = String(get(body, 'data.attributes.product_id'));
const variantId = String(get(body, 'data.attributes.variant_id'));
const status = get(body, 'data.attributes.status');
const cardBrand = get(body, 'data.attributes.card_brand');
const cardLastFour = get(body, 'data.attributes.card_last_four');
const renewsAt = dayjs(get(body, 'data.attributes.renews_at')).toDate();
if (!checkIsValidProduct(storeId, variantId)) {
throw new Error(`Invalid product: ${storeId}, ${variantId}`);
}
await prisma.lemonSqueezySubscription.upsert({
create: {
subscriptionId,
workspaceId,
storeId,
productId,
variantId,
status,
cardBrand,
cardLastFour,
renewsAt,
},
update: {
storeId,
productId,
variantId,
status,
cardBrand,
cardLastFour,
renewsAt,
},
where: {
workspaceId,
subscriptionId,
},
});
await updateWorkspaceSubscription(
workspaceId,
getTierEnumByVariantId(variantId)
);
}
res.status(200).send('OK');
}
);
function signatureIsValid(rawBody: string, requestSignature: string) {
const secret = env.billing.lemonSqueezy.signatureSecret;
const hmac = crypto.createHmac('sha256', secret);
const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8');
const signature = Buffer.from(requestSignature, 'utf8');
if (!crypto.timingSafeEqual(digest, signature)) {
throw new Error('Invalid signature.');
}
}

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

@ -1,8 +1,27 @@
import { z } from 'zod';
import { OpenApiMetaInfo, router, workspaceProcedure } from '../trpc.js';
import {
OpenApiMetaInfo,
router,
workspaceOwnerProcedure,
workspaceProcedure,
} from '../trpc.js';
import { OPENAPI_TAG } from '../../utils/const.js';
import { prisma } from '../../model/_client.js';
import { OpenApiMeta } from 'trpc-openapi';
import {
cancelSubscription,
changeSubscription,
createCheckoutBilling,
getTierNameByvariantId,
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
@ -31,30 +50,117 @@ export const billingRouter = router({
.query(async ({ input }) => {
const { workspaceId, startAt, endAt } = input;
const res = await prisma.workspaceDailyUsage.aggregate({
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 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(
buildBillingOpenapi({
method: 'GET',
path: '/currentSubscription',
description: 'get workspace current subscription',
})
)
.output(
LemonSqueezySubscriptionModelSchema.merge(
z.object({
tier: z.string(),
})
).nullable()
)
.query(async ({ input }) => {
const { workspaceId } = input;
const res = await prisma.lemonSqueezySubscription.findUnique({
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,
};
if (!res) {
return null;
}
return { ...res, tier: getTierNameByvariantId(res.variantId) };
}),
checkout: workspaceOwnerProcedure
.input(
z.object({
tier: z.enum(['free', 'pro', 'team']),
redirectUrl: z.string().optional(),
})
)
.output(
z.object({
url: z.string(),
})
)
.mutation(async ({ input, ctx }) => {
const { workspaceId, redirectUrl } = input;
const userId = ctx.user.id;
const checkout = await createCheckoutBilling(
workspaceId,
userId,
input.tier,
redirectUrl
);
const url = checkout.attributes.url;
return { url };
}),
changePlan: workspaceOwnerProcedure
.input(
z.object({
tier: z.string(),
})
)
.output(z.string())
.mutation(async ({ input }) => {
const { workspaceId } = input;
const subscription = await changeSubscription(
workspaceId,
input.tier as SubscriptionTierType
);
return subscription.id;
}),
cancelSubscription: workspaceOwnerProcedure
.output(z.string())
.mutation(async ({ input }) => {
const { workspaceId } = input;
const subscription = await cancelSubscription(workspaceId);
return subscription.id;
}),
});

View File

@ -124,6 +124,7 @@ export const feedRouter = router({
.merge(
FeedChannelModelSchema.pick({
name: true,
webhookSignature: true,
notifyFrequency: true,
})
)
@ -137,8 +138,14 @@ export const feedRouter = router({
.nullable()
)
.mutation(async ({ input }) => {
const { channelId, workspaceId, name, notifyFrequency, notificationIds } =
input;
const {
channelId,
workspaceId,
name,
webhookSignature,
notifyFrequency,
notificationIds,
} = input;
const channel = await prisma.feedChannel.update({
where: {
@ -147,6 +154,7 @@ export const feedRouter = router({
},
data: {
name,
webhookSignature,
notifyFrequency,
notifications: {
set: notificationIds.map((id) => ({
@ -272,7 +280,7 @@ export const feedRouter = router({
.meta(
buildFeedOpenapi({
method: 'DELETE',
path: '/{channelId}',
path: '/{channelId}/del',
})
)
.input(
@ -317,9 +325,27 @@ export const feedRouter = router({
)
)
.output(FeedEventModelSchema)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const { channelId, ...data } = input;
const channel = await prisma.feedChannel.findUnique({
where: {
id: channelId,
},
});
if (channel?.webhookSignature) {
const signature = ctx.req.headers['x-webhook-signature'];
if (!signature) {
throw new Error(
'This channel configured with webhook signature, but no signature found'
);
}
if (channel.webhookSignature !== signature) {
throw new Error('Invalid webhook signature');
}
}
const event = await prisma.feedEvent.create({
data: {
...data,

Some files were not shown because too many files have changed in this diff Show More