Compare commits

...

75 Commits

Author SHA1 Message Date
Nawaz Dhandala
8a3feab3d0 feat: enhance number formatting in CompareCriteria class 2025-11-13 15:10:17 +00:00
Nawaz Dhandala
7864bbb87b feat: simplify telemetry monitor type check in SummaryInfo component 2025-11-13 14:55:44 +00:00
Nawaz Dhandala
d112d87b80 feat: add ObjectID type for projectId in ExceptionInstanceTable component 2025-11-13 14:35:58 +00:00
Nawaz Dhandala
2f8fcabce4 refactor: clean up code formatting and improve readability in Telemetry and ExceptionInstanceTable components 2025-11-13 14:35:36 +00:00
Nawaz Dhandala
0023560588 feat: enhance error handling with auth refresh logic in API class 2025-11-13 14:35:00 +00:00
Nawaz Dhandala
0bc14acde9 feat: migrate exception monitors to ExceptionInstance (analytics)
- Replace TelemetryException with ExceptionInstance across types and telemetry query
- Rename MonitorStepExceptionMonitorUtil.toQuery -> toAnalyticsQuery and map fields (telemetryServiceId->serviceId, lastSeenAt->time)
- Use ExceptionInstanceService and Query<ExceptionInstance> in MonitorTelemetryMonitor
- Minor MonitorResource variable cleanup for ExceptionMonitorResponse
- Add ExceptionInstanceTable component and switch UI (forms, Alert/Incident views) to use ExceptionInstance
2025-11-13 14:33:01 +00:00
Nawaz Dhandala
3f3956edd6 feat: implement SDK-side aggregation selector for modern metrics API 2025-11-13 13:32:35 +00:00
Nawaz Dhandala
93755da2e8 Merge branch 'master' of https://github.com/OneUptime/oneuptime 2025-11-13 12:06:46 +00:00
Nawaz Dhandala
0657222ea7 Add support for Exception monitor type in MonitorCriteriaInstance 2025-11-13 12:06:42 +00:00
Nawaz Dhandala
ca352826ca Add Exception Monitor functionality with related types and components 2025-11-13 12:02:17 +00:00
Simon Larsen
3cbd99042b Merge pull request #2107 from OneUptime/chore/npm-audit-fix
chore: npm audit fix
2025-11-13 09:38:52 +00:00
simlarsen
2f102acdc2 chore: npm audit fix 2025-11-13 01:48:58 +00:00
Nawaz Dhandala
b76811d152 Fix indentation for headers in API requests across multiple components 2025-11-12 17:55:54 +00:00
Nawaz Dhandala
2335935a3e Add conditional rendering for app.enabled in app.yaml 2025-11-12 17:53:01 +00:00
Nawaz Dhandala
c324fe03d3 Update API header retrieval to remove dependency on StatusPageUtil for all subscription and announcement pages 2025-11-12 17:52:44 +00:00
Nawaz Dhandala
d5bc83a5a1 Remove deprecated @opentelemetry/sdk-trace-node dependency from package.json and package-lock.json 2025-11-12 17:44:28 +00:00
Nawaz Dhandala
e2baa449f5 Refactor Telemetry initialization to explicitly type webTraceExporter as WebSpanExporter 2025-11-12 16:18:39 +00:00
Nawaz Dhandala
51b88eb065 Refactor Telemetry class to streamline span processor registration 2025-11-12 16:16:34 +00:00
Nawaz Dhandala
b0d95bb7df Add 'enabled' property to various components in values schema and YAML templates 2025-11-12 16:15:29 +00:00
Nawaz Dhandala
8bf8c891ab Refactor telemetry exports to improve type handling and streamline initialization 2025-11-12 13:32:27 +00:00
Nawaz Dhandala
fcf919c70b Refactor session handling and cookie management in StatusPage authentication 2025-11-12 12:52:19 +00:00
Nawaz Dhandala
f0f3d32d31 Merge remote-tracking branch 'origin/snyk-upgrade-b70b2734abb0e16d5d110c8cd2735c35' 2025-11-12 11:49:20 +00:00
Nawaz Dhandala
444e8f17b6 Implement feature X to enhance user experience and fix bug Y in module Z 2025-11-12 11:46:44 +00:00
Nawaz Dhandala
3aabf44b4e Merge branch 'snyk-upgrade-f017994c6dac770941ee664640830ac7' 2025-11-12 11:45:47 +00:00
Simon Larsen
c11fcc3c8e Merge pull request #2106 from OneUptime/user-refresh-token
User refresh token
2025-11-12 11:42:29 +00:00
Simon Larsen
52519c9af8 Merge pull request #2104 from OneUptime/snyk-upgrade-d659682fbd62c498810b328f5aaca524
[Snyk] Upgrade @opentelemetry/exporter-trace-otlp-http from 0.52.1 to 0.207.0
2025-11-12 11:42:08 +00:00
Simon Larsen
2483cf9499 Merge pull request #2102 from OneUptime/snyk-upgrade-289d4467acd89e4854c2a2dc61916341
[Snyk] Upgrade @opentelemetry/exporter-logs-otlp-http from 0.52.1 to 0.207.0
2025-11-12 11:41:44 +00:00
Simon Larsen
634e21b13c Merge pull request #2101 from OneUptime/snyk-upgrade-8678075385220dcc2c31b1b4a3900956
[Snyk] Upgrade @opentelemetry/sdk-logs from 0.52.1 to 0.207.0
2025-11-12 11:41:35 +00:00
Nawaz Dhandala
aad933b9eb feat(Authentication, Session Management): implement finalizeStatusPageLogin and refresh-token endpoints for enhanced session handling 2025-11-12 11:40:52 +00:00
Nawaz Dhandala
9356f2964e feat(Authentication): integrate UserSession model and enhance finalizeUserLogin type definition
feat(Express): define HeaderValue type and improve type annotations for headerValueToString and extractDeviceInfo functions
2025-11-11 21:41:20 +00:00
Nawaz Dhandala
aae70ead3b refactor: streamline code formatting and improve readability across multiple files 2025-11-11 21:36:12 +00:00
Nawaz Dhandala
8a482dce10 feat(UserSession): enhance session management with comprehensive session handling methods and metadata 2025-11-11 21:34:11 +00:00
Nawaz Dhandala
9fdf46889c feat(Text): add truncate method for string length limitation 2025-11-11 21:32:57 +00:00
Nawaz Dhandala
40ca9dc04c feat(Authentication, SSO): enhance session management with user session creation and refresh token handling 2025-11-11 21:22:21 +00:00
Nawaz Dhandala
74937f2208 feat(DeviceInfo): add extractDeviceInfo function and RequestDeviceInfo type for enhanced device data retrieval 2025-11-11 21:20:07 +00:00
Nawaz Dhandala
c02ab56477 feat(Cookie): enhance cookie management with refresh token support and default access token expiry 2025-11-11 21:11:34 +00:00
Nawaz Dhandala
3f99b9680f feat(Migration): add UserSession and StatusPagePrivateUserSession migrations with constraints and indexes 2025-11-11 19:51:54 +00:00
Nawaz Dhandala
b08c39037d feat(Index): add StatusPagePrivateUserSessionService and UserSessionService to services 2025-11-11 19:49:32 +00:00
Nawaz Dhandala
f7cc3c00da feat(Migration): add migration for StatusPagePrivateUserSession and UserSession tables 2025-11-11 19:48:42 +00:00
Nawaz Dhandala
ac4286935a refactor(StatusPagePrivateUserSession): remove unnecessary blank line for cleaner code
refactor(UserSession): format description for additional info column for improved readability
2025-11-11 19:47:08 +00:00
Nawaz Dhandala
90a0b2e4a8 refactor(StatusPagePrivateUserSession): simplify access control by removing specific permissions 2025-11-11 19:46:19 +00:00
Nawaz Dhandala
9b22c48d27 feat(UserSession): add UserSession model for managing active user sessions and security tokens 2025-11-11 19:41:41 +00:00
Nawaz Dhandala
9c9dad5da0 feat(UserSettings): add user settings page and integrate into side menu 2025-11-11 19:18:20 +00:00
Nawaz Dhandala
e986f74025 fix(TeamMemberService): skip one-member guard when SCIM manages project membership 2025-11-11 19:07:11 +00:00
snyk-bot
deb2e81b21 fix: upgrade @opentelemetry/exporter-trace-otlp-proto from 0.52.1 to 0.207.0
Snyk has created this PR to upgrade @opentelemetry/exporter-trace-otlp-proto from 0.52.1 to 0.207.0.

See this package in npm:
@opentelemetry/exporter-trace-otlp-proto

See this project in Snyk:
https://app.snyk.io/org/oneuptime-RsC2nshvQ2Vnr35jHvMnMP/project/f6446ec8-d441-487e-b58f-38373430e213?utm_source=github&utm_medium=referral&page=upgrade-pr
2025-11-11 17:46:09 +00:00
snyk-bot
0f8b322892 fix: upgrade @opentelemetry/exporter-trace-otlp-http from 0.52.1 to 0.207.0
Snyk has created this PR to upgrade @opentelemetry/exporter-trace-otlp-http from 0.52.1 to 0.207.0.

See this package in npm:
@opentelemetry/exporter-trace-otlp-http

See this project in Snyk:
https://app.snyk.io/org/oneuptime-RsC2nshvQ2Vnr35jHvMnMP/project/f6446ec8-d441-487e-b58f-38373430e213?utm_source=github&utm_medium=referral&page=upgrade-pr
2025-11-11 17:46:03 +00:00
snyk-bot
23c7de3ecd fix: upgrade @opentelemetry/exporter-metrics-otlp-proto from 0.52.1 to 0.207.0
Snyk has created this PR to upgrade @opentelemetry/exporter-metrics-otlp-proto from 0.52.1 to 0.207.0.

See this package in npm:
@opentelemetry/exporter-metrics-otlp-proto

See this project in Snyk:
https://app.snyk.io/org/oneuptime-RsC2nshvQ2Vnr35jHvMnMP/project/f6446ec8-d441-487e-b58f-38373430e213?utm_source=github&utm_medium=referral&page=upgrade-pr
2025-11-11 17:45:57 +00:00
snyk-bot
ad144a6240 fix: upgrade @opentelemetry/exporter-logs-otlp-http from 0.52.1 to 0.207.0
Snyk has created this PR to upgrade @opentelemetry/exporter-logs-otlp-http from 0.52.1 to 0.207.0.

See this package in npm:
@opentelemetry/exporter-logs-otlp-http

See this project in Snyk:
https://app.snyk.io/org/oneuptime-RsC2nshvQ2Vnr35jHvMnMP/project/f6446ec8-d441-487e-b58f-38373430e213?utm_source=github&utm_medium=referral&page=upgrade-pr
2025-11-11 17:45:52 +00:00
snyk-bot
debfef0388 fix: upgrade @opentelemetry/sdk-logs from 0.52.1 to 0.207.0
Snyk has created this PR to upgrade @opentelemetry/sdk-logs from 0.52.1 to 0.207.0.

See this package in npm:
@opentelemetry/sdk-logs

See this project in Snyk:
https://app.snyk.io/org/oneuptime-RsC2nshvQ2Vnr35jHvMnMP/project/f6446ec8-d441-487e-b58f-38373430e213?utm_source=github&utm_medium=referral&page=upgrade-pr
2025-11-11 17:45:46 +00:00
Nawaz Dhandala
bb85c9f8c8 refactor(BaseModelTable): enhance filter function for dropdown labels to improve type safety 2025-11-11 17:23:14 +00:00
Nawaz Dhandala
25ab1cdbf9 refactor(BaseModelTable): improve code formatting and simplify access control value handling 2025-11-11 17:21:51 +00:00
Nawaz Dhandala
44b8a9ddc9 feat(BaseModelTable): integrate access control column handling and update dropdown labels with color support 2025-11-11 17:16:52 +00:00
Nawaz Dhandala
c388ff9550 fix(IncidentCreate): remove unnecessary whitespace in color mapping function 2025-11-11 15:59:04 +00:00
Nawaz Dhandala
321d1680e6 feat(IncidentCreate): enhance incident state options with color attribute 2025-11-11 15:58:13 +00:00
Nawaz Dhandala
6c0e9f0fed refactor: remove unnecessary whitespace in model classes and improve code formatting 2025-11-11 15:49:20 +00:00
Nawaz Dhandala
99349ecb30 fix: restore @ColorField decorator in multiple model classes for color handling 2025-11-11 15:47:57 +00:00
Nawaz Dhandala
258bbbd9cf feat(BaseModelTable): enhance dropdown options with color handling based on the first color column 2025-11-11 15:37:56 +00:00
Nawaz Dhandala
1094a07fc6 fix(DropdownUtil): ensure color variable is explicitly typed as Color | null
refactor(ColorField): add return type annotation for ColorField function
2025-11-11 14:30:33 +00:00
Nawaz Dhandala
14a5671645 feat(Color, Dropdown, ModelForm): enhance color handling in Color class and Dropdown options 2025-11-11 14:05:30 +00:00
Nawaz Dhandala
5a41c66953 refactor(DatabaseBaseModel, ColorField): improve formatting and readability of type definitions and method implementations 2025-11-11 13:26:02 +00:00
Nawaz Dhandala
af605fce4c feat(DatabaseBaseModel): add getFirstColorColumn method to retrieve the first color field column 2025-11-11 13:23:55 +00:00
Nawaz Dhandala
f8ef6c69fe feat(ColorField): add ColorField decorator and related utility functions for color field management in database models 2025-11-11 13:20:42 +00:00
Simon Larsen
e1848f44f7 Merge pull request #2100 from OneUptime/dropdown-lbl
Dropdown lbl
2025-11-11 12:49:54 +00:00
Nawaz Dhandala
825bd39dda feat(Dropdown): enhance type definitions for styling functions and improve label rendering 2025-11-11 12:49:25 +00:00
Nawaz Dhandala
b99905dfe8 fix(Telemetry): add npm install step for Common directory in workflow 2025-11-11 12:45:06 +00:00
Nawaz Dhandala
a4bf40a2c1 fix(Label): correct indentation for icon property in LabelElement 2025-11-11 12:44:05 +00:00
Nawaz Dhandala
711998b048 feat(Icon): add strokeWidth to EmptyCircle icon rendering 2025-11-11 12:42:36 +00:00
Nawaz Dhandala
132e044c07 feat(Icon): add EmptyCircle icon and update Label to use it 2025-11-11 12:16:01 +00:00
Nawaz Dhandala
8ecc307451 feat(Dropdown): implement label rendering and color resolution for selected labels 2025-11-11 12:02:49 +00:00
Nawaz Dhandala
c85c29989f feat(Dropdown): update styling for focused and selected states, enhance multi-value appearance 2025-11-10 23:29:16 +00:00
Nawaz Dhandala
95726e0f21 feat(Dropdown): enhance dropdown options with label support and improve styling 2025-11-10 23:13:10 +00:00
Nawaz Dhandala
adc15992e9 refactor(Label): simplify import statements for Pill component 2025-11-10 22:49:36 +00:00
Nawaz Dhandala
58d83a2a80 feat(Pill): add thickness prop to Icon component in Pill 2025-11-10 22:48:57 +00:00
Nawaz Dhandala
5461cd4502 feat(Pill): add icon support to Pill component and update tests 2025-11-10 22:44:07 +00:00
Nawaz Dhandala
478465a65b refactor: update label imports and restructure label components for consistency 2025-11-10 22:34:33 +00:00
170 changed files with 22746 additions and 18125 deletions

View File

@@ -17,5 +17,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: latest
- run: cd Common && npm install
- run: cd Telemetry && npm install && npm run test

View File

@@ -33,19 +33,18 @@
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.206.0",
"@opentelemetry/context-zone": "^1.25.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.52.1",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.207.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.207.0",
"@opentelemetry/id-generator-aws-xray": "^1.2.2",
"@opentelemetry/instrumentation": "^0.52.1",
"@opentelemetry/instrumentation-fetch": "^0.52.1",
"@opentelemetry/instrumentation-xml-http-request": "^0.52.1",
"@opentelemetry/instrumentation": "^0.207.0",
"@opentelemetry/instrumentation-fetch": "^0.207.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.207.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-logs": "^0.52.1",
"@opentelemetry/sdk-logs": "^0.207.0",
"@opentelemetry/sdk-metrics": "^1.25.1",
"@opentelemetry/sdk-node": "^0.52.1",
"@opentelemetry/sdk-trace-node": "^1.25.1",
"@opentelemetry/sdk-node": "^0.207.0",
"@opentelemetry/sdk-trace-web": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.26.0",
"@remixicon/react": "^4.2.0",

View File

@@ -37,19 +37,18 @@
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.206.0",
"@opentelemetry/context-zone": "^1.25.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.52.1",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.207.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.207.0",
"@opentelemetry/id-generator-aws-xray": "^1.2.2",
"@opentelemetry/instrumentation": "^0.52.1",
"@opentelemetry/instrumentation-fetch": "^0.52.1",
"@opentelemetry/instrumentation-xml-http-request": "^0.52.1",
"@opentelemetry/instrumentation": "^0.207.0",
"@opentelemetry/instrumentation-fetch": "^0.207.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.207.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-logs": "^0.52.1",
"@opentelemetry/sdk-logs": "^0.207.0",
"@opentelemetry/sdk-metrics": "^1.25.1",
"@opentelemetry/sdk-node": "^0.52.1",
"@opentelemetry/sdk-trace-node": "^1.25.1",
"@opentelemetry/sdk-node": "^0.207.0",
"@opentelemetry/sdk-trace-web": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.26.0",
"@remixicon/react": "^4.2.0",

View File

@@ -36,19 +36,18 @@
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.206.0",
"@opentelemetry/context-zone": "^1.25.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.52.1",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.207.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.207.0",
"@opentelemetry/id-generator-aws-xray": "^1.2.2",
"@opentelemetry/instrumentation": "^0.52.1",
"@opentelemetry/instrumentation-fetch": "^0.52.1",
"@opentelemetry/instrumentation-xml-http-request": "^0.52.1",
"@opentelemetry/instrumentation": "^0.207.0",
"@opentelemetry/instrumentation-fetch": "^0.207.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.207.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-logs": "^0.52.1",
"@opentelemetry/sdk-logs": "^0.207.0",
"@opentelemetry/sdk-metrics": "^1.25.1",
"@opentelemetry/sdk-node": "^0.52.1",
"@opentelemetry/sdk-trace-node": "^1.25.1",
"@opentelemetry/sdk-node": "^0.207.0",
"@opentelemetry/sdk-trace-web": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.26.0",
"@remixicon/react": "^4.2.0",

View File

@@ -26,6 +26,7 @@ import {
} from "react-router-dom";
import UserView from "./Pages/Users/View/Index";
import UserDelete from "./Pages/Users/View/Delete";
import UserSettings from "./Pages/Users/View/Settings";
import ProjectView from "./Pages/Projects/View/Index";
import ProjectDelete from "./Pages/Projects/View/Delete";
@@ -71,6 +72,11 @@ const App: () => JSX.Element = () => {
element={<UserView />}
/>
<PageRoute
path={RouteMap[PageMap.USER_SETTINGS]?.toString() || ""}
element={<UserSettings />}
/>
<PageRoute
path={RouteMap[PageMap.USER_DELETE]?.toString() || ""}
element={<UserDelete />}

View File

@@ -0,0 +1,94 @@
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import React, { FunctionComponent, ReactElement } from "react";
import SideMenuComponent from "./SideMenu";
import User from "Common/Models/DatabaseModels/User";
import ModelPage from "Common/UI/Components/Page/ModelPage";
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import FieldType from "Common/UI/Components/Types/FieldType";
const UserSettings: FunctionComponent = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
return (
<ModelPage<User>
modelId={modelId}
modelNameField="email"
modelType={User}
title={"User"}
breadcrumbLinks={[
{
title: "Admin Dashboard",
to: RouteUtil.populateRouteParams(RouteMap[PageMap.HOME] as Route),
},
{
title: "Users",
to: RouteUtil.populateRouteParams(RouteMap[PageMap.USERS] as Route),
},
{
title: "User",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.USER_VIEW] as Route,
{
modelId: modelId,
},
),
},
{
title: "Settings",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.USER_SETTINGS] as Route,
{
modelId: modelId,
},
),
},
]}
sideMenu={<SideMenuComponent modelId={modelId} />}
>
<CardModelDetail<User>
name="user-master-admin-settings"
cardProps={{
title: "Master Admin Access",
description:
"Grant or revoke master admin access for this user. Master admins can manage every project and workspace.",
}}
isEditable={true}
editButtonText="Update Access"
formFields={[
{
field: {
isMasterAdmin: true,
},
title: "Master Admin",
description:
"Enable to give this user full access to the entire platform.",
fieldType: FormFieldSchemaType.Toggle,
required: true,
},
]}
modelDetailProps={{
modelType: User,
id: "user-master-admin-settings-detail",
fields: [
{
field: {
isMasterAdmin: true,
},
title: "Master Admin",
fieldType: FieldType.Boolean,
placeholder: "No",
},
],
modelId: modelId,
}}
/>
</ModelPage>
);
};
export default UserSettings;

View File

@@ -30,6 +30,18 @@ const SideMenuComponent: FunctionComponent<SideMenuProps> = (
}}
icon={IconProp.Info}
/>
<SideMenuItem
link={{
title: "Settings",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.USER_SETTINGS] as Route,
{
modelId: props.modelId,
},
),
}}
icon={IconProp.Settings}
/>
</SideMenuSection>
<SideMenuSection title="Advanced">

View File

@@ -6,6 +6,7 @@ enum PageMap {
USERS = "USERS",
USER_VIEW = "USER_VIEW",
USER_SETTINGS = "USER_SETTINGS",
USER_DELETE = "USER_DELETE",
PROJECTS = "PROJECTS",

View File

@@ -18,6 +18,9 @@ const RouteMap: Dictionary<Route> = {
[PageMap.USERS]: new Route(`/admin/users`),
[PageMap.USER_VIEW]: new Route(`/admin/users/${RouteParams.ModelID}`),
[PageMap.USER_SETTINGS]: new Route(
`/admin/users/${RouteParams.ModelID}/settings`,
),
[PageMap.USER_DELETE]: new Route(
`/admin/users/${RouteParams.ModelID}/delete`,
),

View File

@@ -25,24 +25,70 @@ import EmailVerificationTokenService from "Common/Server/Services/EmailVerificat
import MailService from "Common/Server/Services/MailService";
import UserService from "Common/Server/Services/UserService";
import UserTotpAuthService from "Common/Server/Services/UserTotpAuthService";
import UserSessionService, {
SessionMetadata,
} from "Common/Server/Services/UserSessionService";
import CookieUtil from "Common/Server/Utils/Cookie";
import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
extractDeviceInfo,
getClientIp,
headerValueToString,
} from "Common/Server/Utils/Express";
import logger from "Common/Server/Utils/Logger";
import Response from "Common/Server/Utils/Response";
import TotpAuth from "Common/Server/Utils/TotpAuth";
import EmailVerificationToken from "Common/Models/DatabaseModels/EmailVerificationToken";
import User from "Common/Models/DatabaseModels/User";
import UserSession from "Common/Models/DatabaseModels/UserSession";
import UserTotpAuth from "Common/Models/DatabaseModels/UserTotpAuth";
import UserWebAuthn from "Common/Models/DatabaseModels/UserWebAuthn";
import UserWebAuthnService from "Common/Server/Services/UserWebAuthnService";
import NotAuthenticatedException from "Common/Types/Exception/NotAuthenticatedException";
const router: ExpressRouter = Express.getRouter();
const ACCESS_TOKEN_EXPIRY_SECONDS: number = 15 * 60;
type FinalizeUserLoginInput = {
req: ExpressRequest;
res: ExpressResponse;
user: User;
isGlobalLogin: boolean;
};
const finalizeUserLogin: (
data: FinalizeUserLoginInput,
) => Promise<SessionMetadata> = async (
data: FinalizeUserLoginInput,
): Promise<SessionMetadata> => {
const { req, res, user, isGlobalLogin } = data;
const sessionMetadata: SessionMetadata =
await UserSessionService.createSession({
userId: user.id!,
isGlobalLogin,
ipAddress: getClientIp(req),
userAgent: headerValueToString(req.headers["user-agent"]),
...extractDeviceInfo(req),
});
CookieUtil.setUserCookie({
expressResponse: res,
user,
isGlobalLogin,
sessionId: sessionMetadata.session.id!,
refreshToken: sessionMetadata.refreshToken,
refreshTokenExpiresAt: sessionMetadata.refreshTokenExpiresAt,
accessTokenExpiresInSeconds: ACCESS_TOKEN_EXPIRY_SECONDS,
});
return sessionMetadata;
};
router.post(
"/signup",
async (
@@ -185,9 +231,9 @@ router.post(
if (savedUser) {
// Refresh Permissions for this user here.
await AccessTokenService.refreshUserAllPermissions(savedUser.id!);
CookieUtil.setUserCookie({
expressResponse: res,
await finalizeUserLogin({
req,
res,
user: savedUser,
isGlobalLogin: true,
});
@@ -487,6 +533,127 @@ router.post(
},
);
router.post(
"/refresh-token",
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
const refreshToken: string | undefined =
CookieUtil.getRefreshTokenFromExpressRequest(req);
if (!refreshToken) {
CookieUtil.removeAllCookies(req, res);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException(
"Refresh token missing. Please login again.",
),
);
}
const session: UserSession | null =
await UserSessionService.findActiveSessionByRefreshToken(refreshToken);
if (!session || !session.id) {
CookieUtil.removeAllCookies(req, res);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Session expired. Please login again."),
);
}
if (
session.refreshTokenExpiresAt &&
OneUptimeDate.hasExpired(session.refreshTokenExpiresAt)
) {
await UserSessionService.revokeSessionById(session.id, {
reason: "Refresh token expired",
});
CookieUtil.removeAllCookies(req, res);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Session expired. Please login again."),
);
}
if (!session.userId) {
await UserSessionService.revokeSessionById(session.id, {
reason: "Session missing user",
});
CookieUtil.removeAllCookies(req, res);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Session expired. Please login again."),
);
}
const user: User | null = await UserService.findOneById({
id: session.userId,
props: {
isRoot: true,
},
select: {
_id: true,
email: true,
name: true,
isMasterAdmin: true,
profilePictureId: true,
timezone: true,
enableTwoFactorAuth: true,
},
});
if (!user) {
await UserSessionService.revokeSessionById(session.id, {
reason: "User not found",
});
CookieUtil.removeAllCookies(req, res);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Account no longer exists."),
);
}
const additionalInfo: JSONObject = (session.additionalInfo ||
{}) as JSONObject;
const isGlobalLogin: boolean =
typeof additionalInfo["isGlobalLogin"] === "boolean"
? (additionalInfo["isGlobalLogin"] as boolean)
: true;
const renewedSession: SessionMetadata =
await UserSessionService.renewSessionWithNewRefreshToken({
session,
ipAddress: getClientIp(req),
userAgent: headerValueToString(req.headers["user-agent"]),
...extractDeviceInfo(req),
});
CookieUtil.setUserCookie({
expressResponse: res,
user,
isGlobalLogin,
sessionId: renewedSession.session.id!,
refreshToken: renewedSession.refreshToken,
refreshTokenExpiresAt: renewedSession.refreshTokenExpiresAt,
accessTokenExpiresInSeconds: ACCESS_TOKEN_EXPIRY_SECONDS,
});
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
},
);
router.post(
"/logout",
async (
@@ -495,6 +662,15 @@ router.post(
next: NextFunction,
): Promise<void> => {
try {
const refreshToken: string | undefined =
CookieUtil.getRefreshTokenFromExpressRequest(req);
if (refreshToken) {
await UserSessionService.revokeSessionByRefreshToken(refreshToken, {
reason: "User logout",
});
}
CookieUtil.removeAllCookies(req, res);
return Response.sendEmptySuccessResponse(req, res);
@@ -788,8 +964,9 @@ const login: LoginFunction = async (options: {
if (alreadySavedUser.password.toString() === user.password!.toString()) {
logger.info("User logged in: " + alreadySavedUser.email?.toString());
CookieUtil.setUserCookie({
expressResponse: res,
await finalizeUserLogin({
req,
res,
user: alreadySavedUser,
isGlobalLogin: true,
});

View File

@@ -20,6 +20,9 @@ import AccessTokenService from "Common/Server/Services/AccessTokenService";
import ProjectSSOService from "Common/Server/Services/ProjectSsoService";
import TeamMemberService from "Common/Server/Services/TeamMemberService";
import UserService from "Common/Server/Services/UserService";
import UserSessionService, {
SessionMetadata,
} from "Common/Server/Services/UserSessionService";
import QueryHelper from "Common/Server/Types/Database/QueryHelper";
import Select from "Common/Server/Types/Database/Select";
import CookieUtil from "Common/Server/Utils/Cookie";
@@ -28,6 +31,9 @@ import Express, {
ExpressResponse,
ExpressRouter,
NextFunction,
extractDeviceInfo,
getClientIp,
headerValueToString,
} from "Common/Server/Utils/Express";
import logger from "Common/Server/Utils/Logger";
import Response from "Common/Server/Utils/Response";
@@ -40,6 +46,8 @@ import Name from "Common/Types/Name";
const router: ExpressRouter = Express.getRouter();
const ACCESS_TOKEN_EXPIRY_SECONDS: number = 15 * 60;
/*
* This route is used to get the SSO config for the user.
* when the user logs in from OneUptime and not from the IDP.
@@ -539,15 +547,31 @@ const loginUserWithSso: LoginUserWithSsoFunction = async (
expressResponse: res,
});
// Refresh Permissions for this user here.
await AccessTokenService.refreshUserAllPermissions(alreadySavedUser.id!);
const sessionMetadata: SessionMetadata =
await UserSessionService.createSession({
userId: alreadySavedUser.id!,
isGlobalLogin: false,
ipAddress: getClientIp(req),
userAgent: headerValueToString(req.headers["user-agent"]),
...extractDeviceInfo(req),
additionalInfo: {
projectId: projectId.toString(),
},
});
CookieUtil.setUserCookie({
expressResponse: res,
user: alreadySavedUser,
isGlobalLogin: false,
sessionId: sessionMetadata.session.id!,
refreshToken: sessionMetadata.refreshToken,
refreshTokenExpiresAt: sessionMetadata.refreshTokenExpiresAt,
accessTokenExpiresInSeconds: ACCESS_TOKEN_EXPIRY_SECONDS,
});
// Refresh Permissions for this user here.
await AccessTokenService.refreshUserAllPermissions(alreadySavedUser.id!);
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();

View File

@@ -6,30 +6,93 @@ import URL from "Common/Types/API/URL";
import OneUptimeDate from "Common/Types/Date";
import EmailTemplateType from "Common/Types/Email/EmailTemplateType";
import BadDataException from "Common/Types/Exception/BadDataException";
import NotAuthenticatedException from "Common/Types/Exception/NotAuthenticatedException";
import { JSONObject } from "Common/Types/JSON";
import JSONFunctions from "Common/Types/JSONFunctions";
import ObjectID from "Common/Types/ObjectID";
import PositiveNumber from "Common/Types/PositiveNumber";
import DatabaseConfig from "Common/Server/DatabaseConfig";
import { EncryptionSecret } from "Common/Server/EnvironmentConfig";
import MailService from "Common/Server/Services/MailService";
import StatusPagePrivateUserService from "Common/Server/Services/StatusPagePrivateUserService";
import StatusPageService from "Common/Server/Services/StatusPageService";
import StatusPagePrivateUserSessionService, {
SessionMetadata as StatusPageSessionMetadata,
} from "Common/Server/Services/StatusPagePrivateUserSessionService";
import CookieUtil from "Common/Server/Utils/Cookie";
import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
extractDeviceInfo,
getClientIp,
headerValueToString,
} from "Common/Server/Utils/Express";
import JSONWebToken from "Common/Server/Utils/JsonWebToken";
import logger from "Common/Server/Utils/Logger";
import Response from "Common/Server/Utils/Response";
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
import StatusPagePrivateUser from "Common/Models/DatabaseModels/StatusPagePrivateUser";
import StatusPagePrivateUserSession from "Common/Models/DatabaseModels/StatusPagePrivateUserSession";
const router: ExpressRouter = Express.getRouter();
const ACCESS_TOKEN_EXPIRY_SECONDS: number = 15 * 60;
type FinalizeStatusPageLoginInput = {
req: ExpressRequest;
res: ExpressResponse;
user: StatusPagePrivateUser;
};
const finalizeStatusPageLogin: (data: FinalizeStatusPageLoginInput) => Promise<{
sessionMetadata: StatusPageSessionMetadata;
accessToken: string;
}> = async (
data: FinalizeStatusPageLoginInput,
): Promise<{
sessionMetadata: StatusPageSessionMetadata;
accessToken: string;
}> => {
const { req, res, user } = data;
if (!user.projectId) {
throw new BadDataException(
"Status page user is missing associated projectId.",
);
}
if (!user.statusPageId) {
throw new BadDataException(
"Status page user is missing associated statusPageId.",
);
}
const sessionMetadata: StatusPageSessionMetadata =
await StatusPagePrivateUserSessionService.createSession({
projectId: user.projectId,
statusPageId: user.statusPageId,
statusPagePrivateUserId: user.id!,
ipAddress: getClientIp(req),
userAgent: headerValueToString(req.headers["user-agent"]),
...extractDeviceInfo(req),
});
const accessToken: string = CookieUtil.setStatusPagePrivateUserCookie({
expressResponse: res,
user,
statusPageId: user.statusPageId,
sessionId: sessionMetadata.session.id!,
refreshToken: sessionMetadata.refreshToken,
refreshTokenExpiresAt: sessionMetadata.refreshTokenExpiresAt,
accessTokenExpiresInSeconds: ACCESS_TOKEN_EXPIRY_SECONDS,
});
return {
sessionMetadata,
accessToken,
};
};
router.post(
"/logout/:statuspageid",
async (
@@ -46,7 +109,20 @@ router.post(
req.params["statuspageid"].toString(),
);
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey(statusPageId)); // remove the cookie.
const refreshToken: string | undefined =
CookieUtil.getRefreshTokenFromExpressRequest(req, statusPageId);
if (refreshToken) {
await StatusPagePrivateUserSessionService.revokeSessionByRefreshToken(
refreshToken,
{
reason: "User logged out",
},
);
}
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey(statusPageId));
CookieUtil.removeCookie(res, CookieUtil.getRefreshTokenKey(statusPageId));
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
@@ -55,6 +131,198 @@ router.post(
},
);
router.post(
"/refresh-token/:statuspageid",
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
const statusPageIdParam: string | undefined = req.params["statuspageid"];
if (!statusPageIdParam) {
throw new BadDataException("Status Page ID is required.");
}
const statusPageId: ObjectID = new ObjectID(statusPageIdParam.toString());
const refreshToken: string | undefined =
CookieUtil.getRefreshTokenFromExpressRequest(req, statusPageId);
if (!refreshToken) {
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey(statusPageId));
CookieUtil.removeCookie(
res,
CookieUtil.getRefreshTokenKey(statusPageId),
);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException(
"Refresh token missing. Please login again.",
),
);
}
const session: StatusPagePrivateUserSession | null =
await StatusPagePrivateUserSessionService.findActiveSessionByRefreshToken(
refreshToken,
);
if (!session || !session.id || !session.statusPageId) {
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey(statusPageId));
CookieUtil.removeCookie(
res,
CookieUtil.getRefreshTokenKey(statusPageId),
);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Session expired. Please login again."),
);
}
if (session.statusPageId.toString() !== statusPageId.toString()) {
await StatusPagePrivateUserSessionService.revokeSessionById(
session.id,
{
reason: "Status page mismatch",
},
);
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey(statusPageId));
CookieUtil.removeCookie(
res,
CookieUtil.getRefreshTokenKey(statusPageId),
);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Session expired. Please login again."),
);
}
if (
session.refreshTokenExpiresAt &&
OneUptimeDate.hasExpired(session.refreshTokenExpiresAt)
) {
await StatusPagePrivateUserSessionService.revokeSessionById(
session.id,
{
reason: "Refresh token expired",
},
);
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey(statusPageId));
CookieUtil.removeCookie(
res,
CookieUtil.getRefreshTokenKey(statusPageId),
);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Session expired. Please login again."),
);
}
if (!session.statusPagePrivateUserId) {
await StatusPagePrivateUserSessionService.revokeSessionById(
session.id,
{
reason: "Session missing user",
},
);
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey(statusPageId));
CookieUtil.removeCookie(
res,
CookieUtil.getRefreshTokenKey(statusPageId),
);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Session expired. Please login again."),
);
}
const user: StatusPagePrivateUser | null =
await StatusPagePrivateUserService.findOneById({
id: session.statusPagePrivateUserId,
props: {
isRoot: true,
},
select: {
_id: true,
email: true,
statusPageId: true,
projectId: true,
},
});
if (!user) {
await StatusPagePrivateUserSessionService.revokeSessionById(
session.id,
{
reason: "User not found",
},
);
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey(statusPageId));
CookieUtil.removeCookie(
res,
CookieUtil.getRefreshTokenKey(statusPageId),
);
return Response.sendErrorResponse(
req,
res,
new NotAuthenticatedException("Account no longer exists."),
);
}
const renewedSession: StatusPageSessionMetadata =
await StatusPagePrivateUserSessionService.renewSessionWithNewRefreshToken(
{
session,
ipAddress: getClientIp(req),
userAgent: headerValueToString(req.headers["user-agent"]),
...extractDeviceInfo(req),
},
);
const accessToken: string = CookieUtil.setStatusPagePrivateUserCookie({
expressResponse: res,
user,
statusPageId: user.statusPageId!,
sessionId: renewedSession.session.id!,
refreshToken: renewedSession.refreshToken,
refreshTokenExpiresAt: renewedSession.refreshTokenExpiresAt,
accessTokenExpiresInSeconds: ACCESS_TOKEN_EXPIRY_SECONDS,
});
return Response.sendEntityResponse(
req,
res,
user,
StatusPagePrivateUser,
{
miscData: {
token: accessToken,
},
},
);
} catch (err) {
return next(err);
}
},
);
router.post(
"/forgot-password",
async (
@@ -376,6 +644,7 @@ router.post(
password: true,
email: true,
statusPageId: true,
projectId: true,
},
props: {
isRoot: true,
@@ -383,31 +652,38 @@ router.post(
});
if (alreadySavedUser) {
const token: string = JSONWebToken.sign({
data: alreadySavedUser,
expiresInSeconds: OneUptimeDate.getSecondsInDays(
new PositiveNumber(30),
),
const { accessToken } = await finalizeStatusPageLogin({
req,
res,
user: alreadySavedUser,
});
CookieUtil.setCookie(
res,
CookieUtil.getUserTokenKey(alreadySavedUser.statusPageId!),
token,
{
httpOnly: true,
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
},
);
const sanitizedUser: StatusPagePrivateUser | null =
await StatusPagePrivateUserService.findOneById({
id: alreadySavedUser.id!,
props: {
isRoot: true,
},
select: {
_id: true,
email: true,
statusPageId: true,
projectId: true,
},
});
if (!sanitizedUser && (alreadySavedUser as any).password) {
delete (alreadySavedUser as any).password;
}
return Response.sendEntityResponse(
req,
res,
alreadySavedUser,
sanitizedUser || alreadySavedUser,
StatusPagePrivateUser,
{
miscData: {
token: token,
token: accessToken,
},
},
);

View File

@@ -1,6 +1,5 @@
import SSOUtil from "../Utils/SSO";
import URL from "Common/Types/API/URL";
import OneUptimeDate from "Common/Types/Date";
import Email from "Common/Types/Email";
import BadRequestException from "Common/Types/Exception/BadRequestException";
import Exception from "Common/Types/Exception/Exception";
@@ -8,9 +7,11 @@ import ServerException from "Common/Types/Exception/ServerException";
import HashedString from "Common/Types/HashedString";
import { JSONObject } from "Common/Types/JSON";
import ObjectID from "Common/Types/ObjectID";
import PositiveNumber from "Common/Types/PositiveNumber";
import { Host, HttpProtocol } from "Common/Server/EnvironmentConfig";
import StatusPagePrivateUserService from "Common/Server/Services/StatusPagePrivateUserService";
import StatusPagePrivateUserSessionService, {
SessionMetadata as StatusPageSessionMetadata,
} from "Common/Server/Services/StatusPagePrivateUserSessionService";
import StatusPageService from "Common/Server/Services/StatusPageService";
import StatusPageSsoService from "Common/Server/Services/StatusPageSsoService";
import CookieUtil from "Common/Server/Utils/Cookie";
@@ -19,8 +20,10 @@ import Express, {
ExpressResponse,
ExpressRouter,
NextFunction,
extractDeviceInfo,
getClientIp,
headerValueToString,
} from "Common/Server/Utils/Express";
import JSONWebToken from "Common/Server/Utils/JsonWebToken";
import logger from "Common/Server/Utils/Logger";
import Response from "Common/Server/Utils/Response";
import StatusPagePrivateUser from "Common/Models/DatabaseModels/StatusPagePrivateUser";
@@ -30,6 +33,8 @@ import xml2js from "xml2js";
// Initialize Express router.
const router: ExpressRouter = Express.getRouter();
const ACCESS_TOKEN_EXPIRY_SECONDS: number = 15 * 60;
// Define a GET route for SSO in a status page context.
router.get(
"/status-page-sso/:statusPageId/:statusPageSsoId",
@@ -285,24 +290,30 @@ router.post(
});
}
const token: string = JSONWebToken.sign({
data: alreadySavedUser,
expiresInSeconds: OneUptimeDate.getSecondsInDays(
new PositiveNumber(30),
),
if (!alreadySavedUser.projectId) {
alreadySavedUser.projectId = projectId;
}
const sessionMetadata: StatusPageSessionMetadata =
await StatusPagePrivateUserSessionService.createSession({
projectId: alreadySavedUser.projectId!,
statusPageId: statusPageId,
statusPagePrivateUserId: alreadySavedUser.id!,
ipAddress: getClientIp(req),
userAgent: headerValueToString(req.headers["user-agent"]),
...extractDeviceInfo(req),
});
const token: string = CookieUtil.setStatusPagePrivateUserCookie({
expressResponse: res,
user: alreadySavedUser,
statusPageId: statusPageId,
sessionId: sessionMetadata.session.id!,
refreshToken: sessionMetadata.refreshToken,
refreshTokenExpiresAt: sessionMetadata.refreshTokenExpiresAt,
accessTokenExpiresInSeconds: ACCESS_TOKEN_EXPIRY_SECONDS,
});
CookieUtil.setCookie(
res,
CookieUtil.getUserTokenKey(alreadySavedUser.statusPageId!),
token,
{
httpOnly: true,
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
},
);
// get status page URL.
const statusPageURL: string =
await StatusPageService.getStatusPageFirstURL(statusPageId);

19
App/package-lock.json generated
View File

@@ -43,19 +43,18 @@
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.206.0",
"@opentelemetry/context-zone": "^1.25.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.52.1",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.207.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.207.0",
"@opentelemetry/id-generator-aws-xray": "^1.2.2",
"@opentelemetry/instrumentation": "^0.52.1",
"@opentelemetry/instrumentation-fetch": "^0.52.1",
"@opentelemetry/instrumentation-xml-http-request": "^0.52.1",
"@opentelemetry/instrumentation": "^0.207.0",
"@opentelemetry/instrumentation-fetch": "^0.207.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.207.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-logs": "^0.52.1",
"@opentelemetry/sdk-logs": "^0.207.0",
"@opentelemetry/sdk-metrics": "^1.25.1",
"@opentelemetry/sdk-node": "^0.52.1",
"@opentelemetry/sdk-trace-node": "^1.25.1",
"@opentelemetry/sdk-node": "^0.207.0",
"@opentelemetry/sdk-trace-web": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.26.0",
"@remixicon/react": "^4.2.0",

View File

@@ -10,6 +10,7 @@ import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import ColorField from "../../Types/Database/ColorField";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
import TableMetadata from "../../Types/Database/TableMetadata";
@@ -418,6 +419,7 @@ export default class AlertFeed extends BaseModel {
],
update: [],
})
@ColorField()
@TableColumn({
type: TableColumnType.Color,
required: true,

View File

@@ -12,6 +12,7 @@ import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -378,6 +379,7 @@ export default class AlertSeverity extends BaseModel {
Permission.EditAlertSeverity,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -12,6 +12,7 @@ import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -356,6 +357,7 @@ export default class AlertState extends BaseModel {
Permission.EditAlertState,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -15,6 +15,7 @@ import TableColumn, {
getTableColumns,
} from "../../../Types/Database/TableColumn";
import TableColumnType from "../../../Types/Database/TableColumnType";
import { getFirstColorFieldColumn } from "../../../Types/Database/ColorField";
import Dictionary from "../../../Types/Dictionary";
import Email from "../../../Types/Email";
import BadDataException from "../../../Types/Exception/BadDataException";
@@ -203,6 +204,10 @@ export default class DatabaseBaseModel extends BaseEntity {
return new Columns(Object.keys(getTableColumns(this)));
}
public getFirstColorColumn(): string | null {
return getFirstColorFieldColumn(this);
}
public canQueryMultiTenant(): boolean {
return Boolean(this.isMultiTenantRequestAllowed);
}

View File

@@ -12,6 +12,7 @@ import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -378,6 +379,7 @@ export default class IncidentSeverity extends BaseModel {
Permission.EditIncidentSeverity,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -12,6 +12,7 @@ import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -380,6 +381,7 @@ export default class IncidentState extends BaseModel {
Permission.EditIncidentState,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -118,6 +118,7 @@ import StatusPageHistoryChartBarColorRule from "./StatusPageHistoryChartBarColor
import StatusPageOwnerTeam from "./StatusPageOwnerTeam";
import StatusPageOwnerUser from "./StatusPageOwnerUser";
import StatusPagePrivateUser from "./StatusPagePrivateUser";
import StatusPagePrivateUserSession from "./StatusPagePrivateUserSession";
import StatusPageResource from "./StatusPageResource";
import StatusPageSCIM from "./StatusPageSCIM";
import StatusPageSSO from "./StatusPageSso";
@@ -130,6 +131,7 @@ import TeamComplianceSetting from "./TeamComplianceSetting";
import TelemetryService from "./TelemetryService";
import UsageBilling from "./TelemetryUsageBilling";
import User from "./User";
import UserSession from "./UserSession";
import UserCall from "./UserCall";
// Notification Methods
import UserEmail from "./UserEmail";
@@ -266,6 +268,7 @@ const AllModelTypes: Array<{
StatusPageFooterLink,
StatusPageHeaderLink,
StatusPagePrivateUser,
StatusPagePrivateUserSession,
StatusPageHistoryChartBarColorRule,
ScheduledMaintenanceState,
@@ -375,6 +378,7 @@ const AllModelTypes: Array<{
ProbeOwnerTeam,
ProbeOwnerUser,
UserSession,
UserTotpAuth,
UserWebAuthn,

View File

@@ -12,6 +12,7 @@ import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -365,6 +366,7 @@ export default class Label extends AccessControlModel {
Permission.EditProjectLabel,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -12,6 +12,7 @@ import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -379,6 +380,7 @@ export default class MonitorStatus extends BaseModel {
Permission.EditProjectMonitorStatus,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -10,6 +10,7 @@ import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import ColorField from "../../Types/Database/ColorField";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
import TableMetadata from "../../Types/Database/TableMetadata";
@@ -422,6 +423,7 @@ export default class ScheduledMaintenanceFeed extends BaseModel {
],
update: [],
})
@ColorField()
@TableColumn({
type: TableColumnType.Color,
required: true,

View File

@@ -12,6 +12,7 @@ import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -380,6 +381,7 @@ export default class ScheduledMaintenanceState extends BaseModel {
Permission.EditScheduledMaintenanceState,
],
})
@ColorField()
@TableColumn({
title: "Color",
required: true,

View File

@@ -14,6 +14,7 @@ import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -448,6 +449,7 @@ export default class ServiceCatalog extends BaseModel {
Permission.EditServiceCatalog,
],
})
@ColorField()
@TableColumn({
type: TableColumnType.Color,
title: "Service Color",

View File

@@ -0,0 +1,413 @@
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import Project from "./Project";
import StatusPage from "./StatusPage";
import StatusPagePrivateUser from "./StatusPagePrivateUser";
import Route from "../../Types/API/Route";
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
import AllowAccessIfSubscriptionIsUnpaid from "../../Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
import CanAccessIfCanReadOn from "../../Types/Database/CanAccessIfCanReadOn";
import ColumnLength from "../../Types/Database/ColumnLength";
import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
import TableMetadata from "../../Types/Database/TableMetadata";
import TenantColumn from "../../Types/Database/TenantColumn";
import HashedString from "../../Types/HashedString";
import IconProp from "../../Types/Icon/IconProp";
import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@AllowAccessIfSubscriptionIsUnpaid()
@TableBillingAccessControl({
create: PlanType.Growth,
read: PlanType.Growth,
update: PlanType.Growth,
delete: PlanType.Growth,
})
@CanAccessIfCanReadOn("statusPage")
@TenantColumn("projectId")
@TableAccessControl({
create: [],
read: [],
delete: [],
update: [],
})
@CrudApiEndpoint(new Route("/status-page-private-user-session"))
@Entity({
name: "StatusPagePrivateUserSession",
})
@TableMetadata({
tableName: "StatusPagePrivateUserSession",
singularName: "Status Page Private User Session",
pluralName: "Status Page Private User Sessions",
icon: IconProp.Lock,
tableDescription:
"Stores status page private user sessions, refresh tokens, and device metadata for secure access control.",
})
export default class StatusPagePrivateUserSession extends BaseModel {
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "projectId",
type: TableColumnType.Entity,
modelType: Project,
title: "Project",
description: "Project that owns this private status page session.",
})
@ManyToOne(
() => {
return Project;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "projectId" })
public project?: Project = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
title: "Project ID",
description: "Project identifier for this session.",
required: true,
canReadOnRelationQuery: true,
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "statusPageId",
type: TableColumnType.Entity,
modelType: StatusPage,
title: "Status Page",
description: "Status page associated with this session.",
})
@ManyToOne(
() => {
return StatusPage;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "statusPageId" })
public statusPage?: StatusPage = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
title: "Status Page ID",
description: "Identifier for the status page.",
required: true,
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public statusPageId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "statusPagePrivateUserId",
type: TableColumnType.Entity,
modelType: StatusPagePrivateUser,
title: "Status Page Private User",
description: "Private user record associated with this session.",
})
@ManyToOne(
() => {
return StatusPagePrivateUser;
},
{
eager: false,
nullable: false,
onDelete: "CASCADE",
orphanedRowAction: "delete",
},
)
@JoinColumn({ name: "statusPagePrivateUserId" })
public statusPagePrivateUser?: StatusPagePrivateUser = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
title: "Status Page Private User ID",
description: "Identifier for the status page private user.",
required: true,
canReadOnRelationQuery: true,
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public statusPagePrivateUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@Index({ unique: true })
@TableColumn({
type: TableColumnType.HashedString,
title: "Refresh Token",
description: "Hashed refresh token for the private user session.",
required: true,
hideColumnInDocumentation: true,
})
@Column({
type: ColumnType.HashedString,
length: ColumnLength.HashedString,
nullable: false,
unique: true,
transformer: HashedString.getDatabaseTransformer(),
})
public refreshToken?: HashedString = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Refresh Token Expires At",
description: "Expiration timestamp for the refresh token.",
required: true,
})
@Column({
type: ColumnType.Date,
nullable: false,
})
public refreshTokenExpiresAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Last Active At",
description: "Last time this session was active.",
})
@Column({
type: ColumnType.Date,
nullable: true,
})
public lastActiveAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Device Name",
description: "Friendly name for the device used to access the status page.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceName?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Device Type",
description: "Type of device (desktop, mobile, etc).",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceType?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Device OS",
description: "Operating system reported for this session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceOS?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Browser",
description: "Browser or client application used for the session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceBrowser?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "IP Address",
description: "IP address recorded for this session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public ipAddress?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "User Agent",
description: "User agent string supplied by the client.",
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public userAgent?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.Boolean,
title: "Is Revoked",
description: "Indicates if the session has been revoked.",
isDefaultValueColumn: true,
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
default: false,
})
public isRevoked?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Revoked At",
description: "Timestamp when the session was revoked, if applicable.",
})
@Column({
type: ColumnType.Date,
nullable: true,
})
public revokedAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Revoked Reason",
description: "Reason provided for revoking this session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public revokedReason?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.JSON,
title: "Additional Info",
description: "Flexible JSON payload for storing structured metadata.",
})
@Column({
type: ColumnType.JSON,
nullable: true,
})
public additionalInfo?: JSONObject = undefined;
}

View File

@@ -12,6 +12,7 @@ import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import ColorField from "../../Types/Database/ColorField";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
@@ -505,6 +506,7 @@ export default class TelemetryService extends BaseModel {
Permission.EditTelemetryService,
],
})
@ColorField()
@TableColumn({
type: TableColumnType.Color,
title: "Service Color",

View File

@@ -0,0 +1,318 @@
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import User from "./User";
import Route from "../../Types/API/Route";
import AllowAccessIfSubscriptionIsUnpaid from "../../Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
import ColumnLength from "../../Types/Database/ColumnLength";
import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import CurrentUserCanAccessRecordBy from "../../Types/Database/CurrentUserCanAccessRecordBy";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
import TableMetadata from "../../Types/Database/TableMetadata";
import HashedString from "../../Types/HashedString";
import IconProp from "../../Types/Icon/IconProp";
import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation({
isMasterAdminApiDocs: true,
})
@AllowAccessIfSubscriptionIsUnpaid()
@TableAccessControl({
create: [Permission.CurrentUser],
read: [Permission.CurrentUser],
delete: [Permission.CurrentUser],
update: [Permission.CurrentUser],
})
@CrudApiEndpoint(new Route("/user-session"))
@Entity({
name: "UserSession",
})
@TableMetadata({
tableName: "UserSession",
singularName: "User Session",
pluralName: "User Sessions",
icon: IconProp.Lock,
tableDescription:
"Active user sessions with refresh tokens and device metadata for enhanced authentication security.",
})
@CurrentUserCanAccessRecordBy("userId")
class UserSession extends BaseModel {
@ColumnAccessControl({
create: [Permission.CurrentUser],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "userId",
type: TableColumnType.Entity,
modelType: User,
title: "User",
description: "User account this session belongs to.",
})
@ManyToOne(
() => {
return User;
},
{
eager: false,
nullable: false,
onDelete: "CASCADE",
orphanedRowAction: "delete",
},
)
@JoinColumn({ name: "userId" })
public user?: User = undefined;
@ColumnAccessControl({
create: [Permission.CurrentUser],
read: [Permission.CurrentUser],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
title: "User ID",
description: "Identifier for the user that owns this session.",
canReadOnRelationQuery: true,
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public userId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@Index({ unique: true })
@TableColumn({
type: TableColumnType.HashedString,
title: "Refresh Token",
description: "Hashed refresh token for this session.",
required: true,
hideColumnInDocumentation: true,
})
@Column({
type: ColumnType.HashedString,
length: ColumnLength.HashedString,
nullable: false,
unique: true,
transformer: HashedString.getDatabaseTransformer(),
})
public refreshToken?: HashedString = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Refresh Token Expires At",
description: "Expiration timestamp for the refresh token.",
required: true,
})
@Column({
type: ColumnType.Date,
nullable: false,
})
public refreshTokenExpiresAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Last Active At",
description: "Last time this session was used.",
})
@Column({
type: ColumnType.Date,
nullable: true,
})
public lastActiveAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Device Name",
description: "Friendly name for the device used to sign in.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceName?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Device Type",
description: "Type of device (e.g., desktop, mobile).",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceType?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Device OS",
description: "Operating system reported for this session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceOS?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Browser",
description: "Browser or client application used for this session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public deviceBrowser?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "IP Address",
description: "IP address observed for this session.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public ipAddress?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "User Agent",
description: "Complete user agent string supplied by the client.",
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public userAgent?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.Boolean,
title: "Is Revoked",
description: "Marks whether the session has been explicitly revoked.",
isDefaultValueColumn: true,
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
default: false,
})
public isRevoked?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Revoked At",
description: "Timestamp when the session was revoked, if applicable.",
})
@Column({
type: ColumnType.Date,
nullable: true,
})
public revokedAt?: Date = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Revoked Reason",
description: "Optional reason describing why the session was revoked.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
})
public revokedReason?: string = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
type: TableColumnType.JSON,
title: "Additional Info",
description:
"Flexible JSON payload for storing structured session metadata.",
})
@Column({
type: ColumnType.JSON,
nullable: true,
})
public additionalInfo?: JSONObject = undefined;
}
export default UserSession;

View File

@@ -0,0 +1,91 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1762890441920 implements MigrationInterface {
public name = "MigrationName1762890441920";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "StatusPagePrivateUserSession" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "statusPageId" uuid NOT NULL, "statusPagePrivateUserId" uuid NOT NULL, "refreshToken" character varying(64) NOT NULL, "refreshTokenExpiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "lastActiveAt" TIMESTAMP WITH TIME ZONE, "deviceName" character varying(100), "deviceType" character varying(100), "deviceOS" character varying(100), "deviceBrowser" character varying(100), "ipAddress" character varying(100), "userAgent" text, "isRevoked" boolean NOT NULL DEFAULT false, "revokedAt" TIMESTAMP WITH TIME ZONE, "revokedReason" character varying(100), "additionalInfo" jsonb, CONSTRAINT "UQ_12ce827a16d121bf6719260b8a9" UNIQUE ("refreshToken"), CONSTRAINT "PK_cbace84fe4c9712b94e571dc133" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_ac5f4c13d6bc9696cbfb8e5a79" ON "StatusPagePrivateUserSession" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_7b8d9b6e068c045d56b47a484b" ON "StatusPagePrivateUserSession" ("statusPageId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_365d602943505272f8f651ff4e" ON "StatusPagePrivateUserSession" ("statusPagePrivateUserId") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_12ce827a16d121bf6719260b8a" ON "StatusPagePrivateUserSession" ("refreshToken") `,
);
await queryRunner.query(
`CREATE TABLE "UserSession" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "userId" uuid NOT NULL, "refreshToken" character varying(64) NOT NULL, "refreshTokenExpiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "lastActiveAt" TIMESTAMP WITH TIME ZONE, "deviceName" character varying(100), "deviceType" character varying(100), "deviceOS" character varying(100), "deviceBrowser" character varying(100), "ipAddress" character varying(100), "userAgent" text, "isRevoked" boolean NOT NULL DEFAULT false, "revokedAt" TIMESTAMP WITH TIME ZONE, "revokedReason" character varying(100), "additionalInfo" jsonb, CONSTRAINT "UQ_d66bd8342b0005c7192bdb17efc" UNIQUE ("refreshToken"), CONSTRAINT "PK_9dcd180f25755bab5fcebcbeb14" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_7353eaf92987aeaf38c2590e94" ON "UserSession" ("userId") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_d66bd8342b0005c7192bdb17ef" ON "UserSession" ("refreshToken") `,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
);
await queryRunner.query(
`ALTER TABLE "StatusPagePrivateUserSession" ADD CONSTRAINT "FK_ac5f4c13d6bc9696cbfb8e5a794" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "StatusPagePrivateUserSession" ADD CONSTRAINT "FK_7b8d9b6e068c045d56b47a484be" FOREIGN KEY ("statusPageId") REFERENCES "StatusPage"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "StatusPagePrivateUserSession" ADD CONSTRAINT "FK_365d602943505272f8f651ff4e8" FOREIGN KEY ("statusPagePrivateUserId") REFERENCES "StatusPagePrivateUser"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "UserSession" ADD CONSTRAINT "FK_7353eaf92987aeaf38c2590e943" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "UserSession" DROP CONSTRAINT "FK_7353eaf92987aeaf38c2590e943"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPagePrivateUserSession" DROP CONSTRAINT "FK_365d602943505272f8f651ff4e8"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPagePrivateUserSession" DROP CONSTRAINT "FK_7b8d9b6e068c045d56b47a484be"`,
);
await queryRunner.query(
`ALTER TABLE "StatusPagePrivateUserSession" DROP CONSTRAINT "FK_ac5f4c13d6bc9696cbfb8e5a794"`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_d66bd8342b0005c7192bdb17ef"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7353eaf92987aeaf38c2590e94"`,
);
await queryRunner.query(`DROP TABLE "UserSession"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_12ce827a16d121bf6719260b8a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_365d602943505272f8f651ff4e"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7b8d9b6e068c045d56b47a484b"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_ac5f4c13d6bc9696cbfb8e5a79"`,
);
await queryRunner.query(`DROP TABLE "StatusPagePrivateUserSession"`);
}
}

View File

@@ -181,6 +181,7 @@ import { MigrationName1761232578396 } from "./1761232578396-MigrationName";
import { MigrationName1761834523183 } from "./1761834523183-MigrationName";
import { MigrationName1762181014879 } from "./1762181014879-MigrationName";
import { MigrationName1762554602716 } from "./1762554602716-MigrationName";
import { MigrationName1762890441920 } from "./1762890441920-MigrationName";
export default [
InitialMigration,
@@ -366,4 +367,5 @@ export default [
MigrationName1761834523183,
MigrationName1762181014879,
MigrationName1762554602716,
MigrationName1762890441920,
];

View File

@@ -106,6 +106,7 @@ import StatusPageHistoryChartBarColorRuleService from "./StatusPageHistoryChartB
import StatusPageOwnerTeamService from "./StatusPageOwnerTeamService";
import StatusPageOwnerUserService from "./StatusPageOwnerUserService";
import StatusPagePrivateUserService from "./StatusPagePrivateUserService";
import StatusPagePrivateUserSessionService from "./StatusPagePrivateUserSessionService";
import StatusPageResourceService from "./StatusPageResourceService";
// Status Page
import StatusPageService from "./StatusPageService";
@@ -125,6 +126,7 @@ import UserNotificationSettingService from "./UserNotificationSettingService";
import UserOnCallLogService from "./UserOnCallLogService";
import UserOnCallLogTimelineService from "./UserOnCallLogTimelineService";
import UserService from "./UserService";
import UserSessionService from "./UserSessionService";
import UserTotpAuthService from "./UserTotpAuthService";
import UserWebAuthnService from "./UserWebAuthnService";
import UserSmsService from "./UserSmsService";
@@ -266,6 +268,7 @@ const services: Array<BaseService> = [
StatusPageOwnerTeamService,
StatusPageOwnerUserService,
StatusPagePrivateUserService,
StatusPagePrivateUserSessionService,
StatusPageResourceService,
StatusPageService,
StatusPageSsoService,
@@ -278,6 +281,7 @@ const services: Array<BaseService> = [
TeamService,
UserService,
UserSessionService,
UserCallService,
UserEmailService,
UserNotificationRuleService,

View File

@@ -0,0 +1,368 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/StatusPagePrivateUserSession";
import ObjectID from "../../Types/ObjectID";
import { JSONObject } from "../../Types/JSON";
import HashedString from "../../Types/HashedString";
import { EncryptionSecret } from "../EnvironmentConfig";
import OneUptimeDate from "../../Types/Date";
import Text from "../../Types/Text";
import logger from "../Utils/Logger";
import Exception from "../../Types/Exception/Exception";
import BadDataException from "../../Types/Exception/BadDataException";
export interface SessionMetadata {
session: Model;
refreshToken: string;
refreshTokenExpiresAt: Date;
}
export interface CreateSessionOptions {
projectId: ObjectID;
statusPageId: ObjectID;
statusPagePrivateUserId: ObjectID;
refreshToken?: string | undefined;
refreshTokenExpiresAt?: Date | undefined;
ipAddress?: string | undefined;
userAgent?: string | undefined;
deviceName?: string | undefined;
deviceType?: string | undefined;
deviceOS?: string | undefined;
deviceBrowser?: string | undefined;
additionalInfo?: JSONObject | undefined;
}
export interface RenewSessionOptions {
session: Model;
refreshTokenExpiresAt?: Date | undefined;
ipAddress?: string | undefined;
userAgent?: string | undefined;
deviceName?: string | undefined;
deviceType?: string | undefined;
deviceOS?: string | undefined;
deviceBrowser?: string | undefined;
additionalInfo?: JSONObject | undefined;
}
export interface TouchSessionOptions {
ipAddress?: string | undefined;
userAgent?: string | undefined;
}
export interface RevokeSessionOptions {
reason?: string | undefined;
}
export class Service extends DatabaseService<Model> {
private static readonly DEFAULT_REFRESH_TOKEN_TTL_DAYS: number = 30;
private static readonly SHORT_TEXT_LIMIT: number = 100;
public constructor() {
super(Model);
}
public async createSession(
options: CreateSessionOptions,
): Promise<SessionMetadata> {
const refreshToken: string =
options.refreshToken || Service.generateRefreshToken();
const refreshTokenExpiresAt: Date =
options.refreshTokenExpiresAt || Service.getRefreshTokenExpiry();
const session: Model = this.buildSessionModel(options, {
refreshToken,
refreshTokenExpiresAt,
});
try {
const createdSession: Model = await this.create({
data: session,
props: {
isRoot: true,
},
});
return {
session: createdSession,
refreshToken,
refreshTokenExpiresAt,
};
} catch (error) {
throw error as Exception;
}
}
public async findActiveSessionByRefreshToken(
refreshToken: string,
): Promise<Model | null> {
const hashedValue: string = await HashedString.hashValue(
refreshToken,
EncryptionSecret,
);
const session: Model | null = await this.findOneBy({
query: {
refreshToken: new HashedString(hashedValue, true),
isRevoked: false,
},
select: {
_id: true,
projectId: true,
statusPageId: true,
statusPagePrivateUserId: true,
refreshTokenExpiresAt: true,
lastActiveAt: true,
additionalInfo: true,
deviceName: true,
deviceType: true,
deviceOS: true,
deviceBrowser: true,
ipAddress: true,
userAgent: true,
isRevoked: true,
},
props: {
isRoot: true,
},
});
if (!session) {
return null;
}
if (
!session.refreshTokenExpiresAt ||
OneUptimeDate.hasExpired(session.refreshTokenExpiresAt)
) {
return null;
}
return session;
}
public async renewSessionWithNewRefreshToken(
options: RenewSessionOptions,
): Promise<SessionMetadata> {
const refreshToken: string = Service.generateRefreshToken();
const refreshTokenExpiresAt: Date =
options.refreshTokenExpiresAt || Service.getRefreshTokenExpiry();
const updatePayload: Partial<Model> = {
refreshToken: HashedString.fromString(refreshToken),
refreshTokenExpiresAt: refreshTokenExpiresAt,
lastActiveAt: OneUptimeDate.getCurrentDate(),
isRevoked: false,
};
const ipAddress: string | undefined = Text.truncate(
options.ipAddress,
Service.SHORT_TEXT_LIMIT,
);
if (ipAddress) {
updatePayload.ipAddress = ipAddress;
}
if (options.userAgent) {
updatePayload.userAgent = options.userAgent;
}
const deviceName: string | undefined = Text.truncate(
options.deviceName,
Service.SHORT_TEXT_LIMIT,
);
if (deviceName) {
updatePayload.deviceName = deviceName;
}
const deviceType: string | undefined = Text.truncate(
options.deviceType,
Service.SHORT_TEXT_LIMIT,
);
if (deviceType) {
updatePayload.deviceType = deviceType;
}
const deviceOS: string | undefined = Text.truncate(
options.deviceOS,
Service.SHORT_TEXT_LIMIT,
);
if (deviceOS) {
updatePayload.deviceOS = deviceOS;
}
const deviceBrowser: string | undefined = Text.truncate(
options.deviceBrowser,
Service.SHORT_TEXT_LIMIT,
);
if (deviceBrowser) {
updatePayload.deviceBrowser = deviceBrowser;
}
if (options.additionalInfo || options.session.additionalInfo) {
updatePayload.additionalInfo = {
...(options.session.additionalInfo || {}),
...(options.additionalInfo || {}),
} as JSONObject;
}
const updatedSession: Model | null = await this.updateOneByIdAndFetch({
id: options.session.id!,
data: updatePayload as any,
props: {
isRoot: true,
},
});
if (!updatedSession) {
throw new BadDataException("Unable to renew status page user session");
}
return {
session: updatedSession,
refreshToken,
refreshTokenExpiresAt,
};
}
public async touchSession(
sessionId: ObjectID,
options: TouchSessionOptions,
): Promise<void> {
const updatePayload: Partial<Model> = {
lastActiveAt: OneUptimeDate.getCurrentDate(),
};
const ipAddress: string | undefined = Text.truncate(
options.ipAddress,
Service.SHORT_TEXT_LIMIT,
);
if (ipAddress) {
updatePayload.ipAddress = ipAddress;
}
if (options.userAgent) {
updatePayload.userAgent = options.userAgent;
}
try {
await this.updateOneById({
id: sessionId,
data: updatePayload as any,
props: {
isRoot: true,
},
});
} catch (err) {
logger.warn(
`Failed to update status page session activity for session ${sessionId.toString()}: ${(err as Error).message}`,
);
}
}
public async revokeSessionById(
sessionId: ObjectID,
options?: RevokeSessionOptions,
): Promise<void> {
await this.updateOneById({
id: sessionId,
data: {
isRevoked: true,
revokedAt: OneUptimeDate.getCurrentDate(),
revokedReason: options?.reason ?? null,
},
props: {
isRoot: true,
},
});
}
public async revokeSessionByRefreshToken(
refreshToken: string,
options?: RevokeSessionOptions,
): Promise<void> {
const session: Model | null =
await this.findActiveSessionByRefreshToken(refreshToken);
if (!session || !session.id) {
return;
}
await this.revokeSessionById(session.id, options);
}
private buildSessionModel(
options: CreateSessionOptions,
tokenMeta: { refreshToken: string; refreshTokenExpiresAt: Date },
): Model {
const session: Model = new Model();
session.projectId = options.projectId;
session.statusPageId = options.statusPageId;
session.statusPagePrivateUserId = options.statusPagePrivateUserId;
session.refreshToken = HashedString.fromString(tokenMeta.refreshToken);
session.refreshTokenExpiresAt = tokenMeta.refreshTokenExpiresAt;
session.lastActiveAt = OneUptimeDate.getCurrentDate();
if (options.userAgent) {
session.userAgent = options.userAgent;
}
const deviceName: string | undefined = Text.truncate(
options.deviceName,
Service.SHORT_TEXT_LIMIT,
);
if (deviceName) {
session.deviceName = deviceName;
}
const deviceType: string | undefined = Text.truncate(
options.deviceType,
Service.SHORT_TEXT_LIMIT,
);
if (deviceType) {
session.deviceType = deviceType;
}
const deviceOS: string | undefined = Text.truncate(
options.deviceOS,
Service.SHORT_TEXT_LIMIT,
);
if (deviceOS) {
session.deviceOS = deviceOS;
}
const deviceBrowser: string | undefined = Text.truncate(
options.deviceBrowser,
Service.SHORT_TEXT_LIMIT,
);
if (deviceBrowser) {
session.deviceBrowser = deviceBrowser;
}
const ipAddress: string | undefined = Text.truncate(
options.ipAddress,
Service.SHORT_TEXT_LIMIT,
);
if (ipAddress) {
session.ipAddress = ipAddress;
}
session.additionalInfo = {
...(options.additionalInfo || {}),
} as JSONObject;
return session;
}
private static generateRefreshToken(): string {
return ObjectID.generate().toString();
}
private static getRefreshTokenExpiry(): Date {
return OneUptimeDate.getSomeDaysAfter(
Service.DEFAULT_REFRESH_TOKEN_TTL_DAYS,
);
}
}
export default new Service();

View File

@@ -345,7 +345,12 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
},
});
if (membersInTeam.toNumber() <= 1) {
// Skip the one-member guard when SCIM manages membership for the project.
const isSCIMEnabled: boolean = await this.isSCIMEnabled(
member.projectId!,
);
if (!isSCIMEnabled && membersInTeam.toNumber() <= 1) {
throw new BadDataException(
Errors.TeamMemberService.ONE_MEMBER_REQUIRED,
);

View File

@@ -0,0 +1,350 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/UserSession";
import ObjectID from "../../Types/ObjectID";
import { JSONObject } from "../../Types/JSON";
import HashedString from "../../Types/HashedString";
import { EncryptionSecret } from "../EnvironmentConfig";
import OneUptimeDate from "../../Types/Date";
import Text from "../../Types/Text";
import logger from "../Utils/Logger";
import Exception from "../../Types/Exception/Exception";
import BadDataException from "../../Types/Exception/BadDataException";
export interface SessionMetadata {
session: Model;
refreshToken: string;
refreshTokenExpiresAt: Date;
}
export interface CreateSessionOptions {
userId: ObjectID;
isGlobalLogin: boolean;
refreshToken?: string | undefined;
refreshTokenExpiresAt?: Date | undefined;
ipAddress?: string | undefined;
userAgent?: string | undefined;
deviceName?: string | undefined;
deviceType?: string | undefined;
deviceOS?: string | undefined;
deviceBrowser?: string | undefined;
additionalInfo?: JSONObject | undefined;
}
export interface RenewSessionOptions {
session: Model;
refreshTokenExpiresAt?: Date | undefined;
ipAddress?: string | undefined;
userAgent?: string | undefined;
deviceName?: string | undefined;
deviceType?: string | undefined;
deviceOS?: string | undefined;
deviceBrowser?: string | undefined;
additionalInfo?: JSONObject | undefined;
}
export interface TouchSessionOptions {
ipAddress?: string | undefined;
userAgent?: string | undefined;
}
export interface RevokeSessionOptions {
reason?: string | undefined;
}
export class Service extends DatabaseService<Model> {
private static readonly DEFAULT_REFRESH_TOKEN_TTL_DAYS: number = 30;
private static readonly SHORT_TEXT_LIMIT: number = 100;
public constructor() {
super(Model);
}
public async createSession(
options: CreateSessionOptions,
): Promise<SessionMetadata> {
const refreshToken: string =
options.refreshToken || Service.generateRefreshToken();
const refreshTokenExpiresAt: Date =
options.refreshTokenExpiresAt || Service.getRefreshTokenExpiry();
const session: Model = this.buildSessionModel(options, {
refreshToken,
refreshTokenExpiresAt,
});
try {
const createdSession: Model = await this.create({
data: session,
props: {
isRoot: true,
},
});
return {
session: createdSession,
refreshToken: refreshToken,
refreshTokenExpiresAt: refreshTokenExpiresAt,
};
} catch (error) {
throw error as Exception;
}
}
public async findActiveSessionByRefreshToken(
refreshToken: string,
): Promise<Model | null> {
const hashedValue: string = await HashedString.hashValue(
refreshToken,
EncryptionSecret,
);
return await this.findOneBy({
query: {
refreshToken: new HashedString(hashedValue, true),
isRevoked: false,
},
select: {
_id: true,
userId: true,
refreshTokenExpiresAt: true,
lastActiveAt: true,
isRevoked: true,
additionalInfo: true,
deviceName: true,
deviceType: true,
deviceOS: true,
deviceBrowser: true,
ipAddress: true,
userAgent: true,
},
props: {
isRoot: true,
},
});
}
public async renewSessionWithNewRefreshToken(
options: RenewSessionOptions,
): Promise<SessionMetadata> {
const refreshToken: string = Service.generateRefreshToken();
const refreshTokenExpiresAt: Date =
options.refreshTokenExpiresAt || Service.getRefreshTokenExpiry();
const updatePayload: Partial<Model> = {
refreshToken: HashedString.fromString(refreshToken),
refreshTokenExpiresAt: refreshTokenExpiresAt,
lastActiveAt: OneUptimeDate.getCurrentDate(),
isRevoked: false,
};
const ipAddress: string | undefined = Text.truncate(
options.ipAddress,
Service.SHORT_TEXT_LIMIT,
);
if (ipAddress) {
updatePayload.ipAddress = ipAddress;
}
if (options.userAgent) {
updatePayload.userAgent = options.userAgent;
}
const deviceName: string | undefined = Text.truncate(
options.deviceName,
Service.SHORT_TEXT_LIMIT,
);
if (deviceName) {
updatePayload.deviceName = deviceName;
}
const deviceType: string | undefined = Text.truncate(
options.deviceType,
Service.SHORT_TEXT_LIMIT,
);
if (deviceType) {
updatePayload.deviceType = deviceType;
}
const deviceOS: string | undefined = Text.truncate(
options.deviceOS,
Service.SHORT_TEXT_LIMIT,
);
if (deviceOS) {
updatePayload.deviceOS = deviceOS;
}
const deviceBrowser: string | undefined = Text.truncate(
options.deviceBrowser,
Service.SHORT_TEXT_LIMIT,
);
if (deviceBrowser) {
updatePayload.deviceBrowser = deviceBrowser;
}
if (options.additionalInfo || options.session.additionalInfo) {
updatePayload.additionalInfo = {
...(options.session.additionalInfo || {}),
...(options.additionalInfo || {}),
} as JSONObject;
}
const updatedSession: Model | null = await this.updateOneByIdAndFetch({
id: options.session.id!,
data: updatePayload as any,
props: {
isRoot: true,
},
});
if (!updatedSession) {
throw new BadDataException("Unable to renew user session");
}
return {
session: updatedSession,
refreshToken: refreshToken,
refreshTokenExpiresAt: refreshTokenExpiresAt,
};
}
public async touchSession(
sessionId: ObjectID,
options: TouchSessionOptions,
): Promise<void> {
const updatePayload: Partial<Model> = {
lastActiveAt: OneUptimeDate.getCurrentDate(),
};
const ipAddress: string | undefined = Text.truncate(
options.ipAddress,
Service.SHORT_TEXT_LIMIT,
);
if (ipAddress) {
updatePayload.ipAddress = ipAddress;
}
if (options.userAgent) {
updatePayload.userAgent = options.userAgent;
}
try {
await this.updateOneById({
id: sessionId,
data: updatePayload as any,
props: {
isRoot: true,
},
});
} catch (err) {
logger.warn(
`Failed to update session activity timestamp for session ${sessionId.toString()}: ${(err as Error).message}`,
);
}
}
public async revokeSessionById(
sessionId: ObjectID,
options?: RevokeSessionOptions,
): Promise<void> {
await this.updateOneById({
id: sessionId,
data: {
isRevoked: true,
revokedAt: OneUptimeDate.getCurrentDate(),
revokedReason: options?.reason ?? null,
},
props: {
isRoot: true,
},
});
}
public async revokeSessionByRefreshToken(
refreshToken: string,
options?: RevokeSessionOptions,
): Promise<void> {
const session: Model | null =
await this.findActiveSessionByRefreshToken(refreshToken);
if (!session || !session.id) {
return;
}
await this.revokeSessionById(session.id, options);
}
private buildSessionModel(
options: CreateSessionOptions,
tokenMeta: { refreshToken: string; refreshTokenExpiresAt: Date },
): Model {
const session: Model = new Model();
session.userId = options.userId;
session.refreshToken = HashedString.fromString(tokenMeta.refreshToken);
session.refreshTokenExpiresAt = tokenMeta.refreshTokenExpiresAt;
session.lastActiveAt = OneUptimeDate.getCurrentDate();
if (options.userAgent) {
session.userAgent = options.userAgent;
}
const deviceName: string | undefined = Text.truncate(
options.deviceName,
Service.SHORT_TEXT_LIMIT,
);
if (deviceName) {
session.deviceName = deviceName;
}
const deviceType: string | undefined = Text.truncate(
options.deviceType,
Service.SHORT_TEXT_LIMIT,
);
if (deviceType) {
session.deviceType = deviceType;
}
const deviceOS: string | undefined = Text.truncate(
options.deviceOS,
Service.SHORT_TEXT_LIMIT,
);
if (deviceOS) {
session.deviceOS = deviceOS;
}
const deviceBrowser: string | undefined = Text.truncate(
options.deviceBrowser,
Service.SHORT_TEXT_LIMIT,
);
if (deviceBrowser) {
session.deviceBrowser = deviceBrowser;
}
const ipAddress: string | undefined = Text.truncate(
options.ipAddress,
Service.SHORT_TEXT_LIMIT,
);
if (ipAddress) {
session.ipAddress = ipAddress;
}
session.additionalInfo = {
...(options.additionalInfo || {}),
isGlobalLogin: options.isGlobalLogin,
} as JSONObject;
return session;
}
private static generateRefreshToken(): string {
return ObjectID.generate().toString();
}
private static getRefreshTokenExpiry(): Date {
return OneUptimeDate.getSomeDaysAfter(
Service.DEFAULT_REFRESH_TOKEN_TTL_DAYS,
);
}
}
export default new Service();

View File

@@ -4,6 +4,7 @@ import ObjectID from "../../Types/ObjectID";
import { CookieOptions } from "express";
import JSONWebToken from "./JsonWebToken";
import User from "../../Models/DatabaseModels/User";
import StatusPagePrivateUser from "../../Models/DatabaseModels/StatusPagePrivateUser";
import OneUptimeDate from "../../Types/Date";
import PositiveNumber from "../../Types/PositiveNumber";
import CookieName from "../../Types/CookieName";
@@ -12,6 +13,8 @@ import CaptureSpan from "./Telemetry/CaptureSpan";
export default class CookieUtil {
// set cookie with express response
private static readonly DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: number = 15 * 60;
@CaptureSpan()
public static getCookiesFromCookieString(
cookieString: string,
@@ -58,8 +61,23 @@ export default class CookieUtil {
expressResponse: ExpressResponse;
user: User;
isGlobalLogin: boolean;
sessionId: ObjectID;
refreshToken: string;
refreshTokenExpiresAt: Date;
accessTokenExpiresInSeconds?: number;
}): void {
const { expressResponse: res, user, isGlobalLogin } = data;
const {
expressResponse: res,
user,
isGlobalLogin,
sessionId,
refreshToken,
refreshTokenExpiresAt,
} = data;
const accessTokenExpiresInSeconds: number =
data.accessTokenExpiresInSeconds ||
CookieUtil.DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS;
const token: string = JSONWebToken.signUserLoginToken({
tokenData: {
@@ -69,13 +87,24 @@ export default class CookieUtil {
timezone: user.timezone || null,
isMasterAdmin: user.isMasterAdmin!,
isGlobalLogin: isGlobalLogin, // This is a general login without SSO. So, we will set this to true. This will give access to all the projects that dont require SSO.
sessionId: sessionId,
},
expiresInSeconds: OneUptimeDate.getSecondsInDays(new PositiveNumber(30)),
expiresInSeconds: accessTokenExpiresInSeconds,
});
// Set a cookie with token.
CookieUtil.setCookie(res, CookieUtil.getUserTokenKey(), token, {
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
maxAge: accessTokenExpiresInSeconds * 1000,
httpOnly: true,
});
const refreshTokenTtl: number = Math.max(
refreshTokenExpiresAt.getTime() - Date.now(),
0,
);
CookieUtil.setCookie(res, CookieUtil.getRefreshTokenKey(), refreshToken, {
maxAge: refreshTokenTtl,
httpOnly: true,
});
@@ -148,6 +177,62 @@ export default class CookieUtil {
}
}
@CaptureSpan()
public static setStatusPagePrivateUserCookie(data: {
expressResponse: ExpressResponse;
user: StatusPagePrivateUser;
statusPageId: ObjectID;
sessionId: ObjectID;
refreshToken: string;
refreshTokenExpiresAt: Date;
accessTokenExpiresInSeconds?: number;
}): string {
const {
expressResponse: res,
user,
statusPageId,
sessionId,
refreshToken,
refreshTokenExpiresAt,
} = data;
const accessTokenExpiresInSeconds: number =
data.accessTokenExpiresInSeconds ||
CookieUtil.DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS;
const token: string = JSONWebToken.sign({
data: {
userId: user.id!,
email: user.email!,
statusPageId: statusPageId,
sessionId: sessionId,
},
expiresInSeconds: accessTokenExpiresInSeconds,
});
CookieUtil.setCookie(res, CookieUtil.getUserTokenKey(statusPageId), token, {
maxAge: accessTokenExpiresInSeconds * 1000,
httpOnly: true,
});
const refreshTokenTtl: number = Math.max(
refreshTokenExpiresAt.getTime() - Date.now(),
0,
);
CookieUtil.setCookie(
res,
CookieUtil.getRefreshTokenKey(statusPageId),
refreshToken,
{
maxAge: refreshTokenTtl,
httpOnly: true,
},
);
return token;
}
@CaptureSpan()
public static setCookie(
res: ExpressResponse,
@@ -155,7 +240,13 @@ export default class CookieUtil {
value: string,
options: CookieOptions,
): void {
res.cookie(name, value, options);
const cookieOptions: CookieOptions = {
path: "/",
sameSite: "lax",
...options,
};
res.cookie(name, value, cookieOptions);
}
// get cookie with express request
@@ -168,11 +259,25 @@ export default class CookieUtil {
return req.cookies[name];
}
@CaptureSpan()
public static getRefreshTokenFromExpressRequest(
req: ExpressRequest,
id?: ObjectID,
): string | undefined {
return CookieUtil.getCookieFromExpressRequest(
req,
CookieUtil.getRefreshTokenKey(id),
);
}
// delete cookie with express response
@CaptureSpan()
public static removeCookie(res: ExpressResponse, name: string): void {
res.clearCookie(name);
res.clearCookie(name, {
path: "/",
sameSite: "lax",
});
}
// get all cookies with express request
@@ -190,6 +295,15 @@ export default class CookieUtil {
return `${CookieName.Token}-${id.toString()}`;
}
@CaptureSpan()
public static getRefreshTokenKey(id?: ObjectID): string {
if (!id) {
return CookieName.RefreshToken;
}
return `${CookieName.RefreshToken}-${id.toString()}`;
}
@CaptureSpan()
public static getUserSSOKey(id: ObjectID): string {
return `${this.getSSOKey()}${id.toString()}`;
@@ -210,5 +324,8 @@ export default class CookieUtil {
for (const key in cookies) {
this.removeCookie(res, key);
}
// Always attempt to remove refresh token cookie even if not parsed.
this.removeCookie(res, this.getRefreshTokenKey());
}
}

View File

@@ -46,6 +46,15 @@ export interface OneUptimeResponse extends express.Response {
logBody: JSONObjectOrArray;
}
export type RequestDeviceInfo = {
deviceName?: string;
deviceType?: string;
deviceOS?: string;
deviceBrowser?: string;
};
type HeaderValue = string | Array<string> | null | undefined;
class Express {
private static app: express.Application;
private static httpServer: Server;
@@ -101,3 +110,94 @@ class Express {
}
export default Express;
export const headerValueToString: (value: HeaderValue) => string | undefined = (
value: HeaderValue,
): string | undefined => {
if (Array.isArray(value)) {
return value.length > 0 ? value[0] : undefined;
}
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
return undefined;
};
export const extractDeviceInfo: (req: ExpressRequest) => RequestDeviceInfo = (
req: ExpressRequest,
): RequestDeviceInfo => {
const body: JSONObject = (req.body || {}) as JSONObject;
const data: JSONObject = (body["data"] as JSONObject) || {};
const getValue: (key: string) => string | undefined = (
key: string,
): string | undefined => {
const headerKey: string = key.toLowerCase();
const camelKey: string = headerKey
.split("-")
.map((part: string, index: number) => {
if (index === 0) {
return part;
}
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join("");
return (
headerValueToString(req.headers[`x-${headerKey}`]) ||
headerValueToString(body[camelKey] as HeaderValue) ||
headerValueToString(data[camelKey] as HeaderValue) ||
headerValueToString(body[key] as HeaderValue) ||
headerValueToString(data[key] as HeaderValue)
);
};
const result: RequestDeviceInfo = {};
const deviceName: string | undefined = getValue("device-name");
if (deviceName) {
result.deviceName = deviceName;
}
const deviceType: string | undefined = getValue("device-type");
if (deviceType) {
result.deviceType = deviceType;
}
const deviceOS: string | undefined = getValue("device-os");
if (deviceOS) {
result.deviceOS = deviceOS;
}
const deviceBrowser: string | undefined = getValue("device-browser");
if (deviceBrowser) {
result.deviceBrowser = deviceBrowser;
}
return result;
};
export const getClientIp: (req: ExpressRequest) => string | undefined = (
req: ExpressRequest,
): string | undefined => {
const forwarded: string | Array<string> | undefined = req.headers[
"x-forwarded-for"
] as string | Array<string> | undefined;
if (Array.isArray(forwarded) && forwarded.length > 0) {
return forwarded[0]?.split(",")[0]?.trim();
}
if (typeof forwarded === "string" && forwarded.trim().length > 0) {
return forwarded.split(",")[0]?.trim();
}
if (req.socket?.remoteAddress) {
return req.socket.remoteAddress;
}
return req.ip;
};

View File

@@ -24,6 +24,7 @@ class JSONWebToken {
isMasterAdmin: boolean;
// If this is OneUptime username and password login. This is true, if this is SSO login. Then, this is false.
isGlobalLogin: boolean;
sessionId: ObjectID;
};
expiresInSeconds: number;
}): string {
@@ -67,9 +68,9 @@ class JSONWebToken {
name: data.name?.toString() || "",
projectId: data.projectId?.toString() || "",
isMasterAdmin: data.isMasterAdmin,
sessionId: data.sessionId?.toString() || undefined,
};
}
return JSONWebToken.signJsonPayload(jsonObj, expiresInSeconds);
}
@@ -106,6 +107,9 @@ class JSONWebToken {
isMasterAdmin: false,
name: new Name("User"),
isGlobalLogin: Boolean(decoded["isGlobalLogin"]),
sessionId: decoded["sessionId"]
? new ObjectID(decoded["sessionId"] as string)
: undefined,
};
}
@@ -118,6 +122,9 @@ class JSONWebToken {
: undefined,
isMasterAdmin: Boolean(decoded["isMasterAdmin"]),
isGlobalLogin: Boolean(decoded["isGlobalLogin"]),
sessionId: decoded["sessionId"]
? new ObjectID(decoded["sessionId"] as string)
: undefined,
};
} catch (e) {
logger.error(e);

View File

@@ -636,7 +636,15 @@ export default class CompareCriteria {
}
if (typeof value === Typeof.Number) {
return (value as number).toFixed(2);
const numericValue: number = value as number;
if (Number.isInteger(numericValue)) {
return numericValue.toString();
}
const roundedValue: number = Number(numericValue.toFixed(2));
return roundedValue.toString();
}
if (typeof value === Typeof.Boolean) {

View File

@@ -0,0 +1,34 @@
import ExceptionMonitorResponse from "../../../../Types/Monitor/ExceptionMonitor/ExceptionMonitorResponse";
import CaptureSpan from "../../Telemetry/CaptureSpan";
import DataToProcess from "../DataToProcess";
import CompareCriteria from "./CompareCriteria";
import {
CheckOn,
CriteriaFilter,
} from "../../../../Types/Monitor/CriteriaFilter";
export default class ExceptionMonitorCriteria {
@CaptureSpan()
public static async isMonitorInstanceCriteriaFilterMet(input: {
dataToProcess: DataToProcess;
criteriaFilter: CriteriaFilter;
}): Promise<string | null> {
let threshold: number | string | undefined | null =
input.criteriaFilter.value;
if (input.criteriaFilter.checkOn === CheckOn.ExceptionCount) {
threshold = CompareCriteria.convertToNumber(threshold);
const currentExceptionCount: number =
(input.dataToProcess as ExceptionMonitorResponse).exceptionCount || 0;
return CompareCriteria.compareCriteriaNumbers({
value: currentExceptionCount,
threshold: threshold as number,
criteriaFilter: input.criteriaFilter,
});
}
return null;
}
}

View File

@@ -4,6 +4,7 @@ import ProbeMonitorResponse from "../../../Types/Probe/ProbeMonitorResponse";
import LogMonitorResponse from "../../../Types/Monitor/LogMonitor/LogMonitorResponse";
import TraceMonitorResponse from "../../../Types/Monitor/TraceMonitor/TraceMonitorResponse";
import MetricMonitorResponse from "../../../Types/Monitor/MetricMonitor/MetricMonitorResponse";
import ExceptionMonitorResponse from "../../../Types/Monitor/ExceptionMonitor/ExceptionMonitorResponse";
type DataToProcess =
| ProbeMonitorResponse
@@ -11,6 +12,7 @@ type DataToProcess =
| ServerMonitorResponse
| LogMonitorResponse
| TraceMonitorResponse
| MetricMonitorResponse;
| MetricMonitorResponse
| ExceptionMonitorResponse;
export default DataToProcess;

View File

@@ -14,6 +14,7 @@ import AggregatedResult from "../../../Types/BaseDatabase/AggregatedResult";
import AggregateModel from "../../../Types/BaseDatabase/AggregatedModel";
import MetricQueryConfigData from "../../../Types/Metrics/MetricQueryConfigData";
import MetricFormulaConfigData from "../../../Types/Metrics/MetricFormulaConfigData";
import ExceptionMonitorResponse from "../../../Types/Monitor/ExceptionMonitor/ExceptionMonitorResponse";
export default class MonitorCriteriaDataExtractor {
public static getProbeMonitorResponse(
@@ -79,6 +80,18 @@ export default class MonitorCriteriaDataExtractor {
return null;
}
public static getExceptionMonitorResponse(
dataToProcess: DataToProcess,
): ExceptionMonitorResponse | null {
if (
(dataToProcess as ExceptionMonitorResponse).exceptionCount !== undefined
) {
return dataToProcess as ExceptionMonitorResponse;
}
return null;
}
public static getCustomCodeMonitorResponse(
dataToProcess: DataToProcess,
): CustomCodeMonitorResponse | null {

View File

@@ -9,6 +9,7 @@ import SyntheticMonitoringCriteria from "./Criteria/SyntheticMonitor";
import LogMonitorCriteria from "./Criteria/LogMonitorCriteria";
import MetricMonitorCriteria from "./Criteria/MetricMonitorCriteria";
import TraceMonitorCriteria from "./Criteria/TraceMonitorCriteria";
import ExceptionMonitorCriteria from "./Criteria/ExceptionMonitorCriteria";
import MonitorCriteriaMessageBuilder from "./MonitorCriteriaMessageBuilder";
import DataToProcess from "./DataToProcess";
import Monitor from "../../../Models/DatabaseModels/Monitor";
@@ -434,6 +435,18 @@ export default class MonitorCriteriaEvaluator {
}
}
if (input.monitor.monitorType === MonitorType.Exceptions) {
const exceptionMonitorResult: string | null =
await ExceptionMonitorCriteria.isMonitorInstanceCriteriaFilterMet({
dataToProcess: input.dataToProcess,
criteriaFilter: input.criteriaFilter,
});
if (exceptionMonitorResult) {
return exceptionMonitorResult;
}
}
return null;
}
}

View File

@@ -19,6 +19,7 @@ import SyntheticMonitorResponse from "../../../Types/Monitor/SyntheticMonitors/S
import CustomCodeMonitorResponse from "../../../Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse";
import LogMonitorResponse from "../../../Types/Monitor/LogMonitor/LogMonitorResponse";
import TraceMonitorResponse from "../../../Types/Monitor/TraceMonitor/TraceMonitorResponse";
import ExceptionMonitorResponse from "../../../Types/Monitor/ExceptionMonitor/ExceptionMonitorResponse";
import MonitorCriteriaMessageFormatter from "./MonitorCriteriaMessageFormatter";
import MonitorCriteriaDataExtractor from "./MonitorCriteriaDataExtractor";
import MonitorCriteriaExpectationBuilder from "./MonitorCriteriaExpectationBuilder";
@@ -161,6 +162,10 @@ export default class MonitorCriteriaObservationBuilder {
return MonitorCriteriaObservationBuilder.describeMetricValueObservation(
input,
);
case CheckOn.ExceptionCount:
return MonitorCriteriaObservationBuilder.describeExceptionCountObservation(
input,
);
default:
return null;
}
@@ -1083,6 +1088,21 @@ export default class MonitorCriteriaObservationBuilder {
return `Span count was ${traceResponse.spanCount}.`;
}
private static describeExceptionCountObservation(input: {
dataToProcess: DataToProcess;
}): string | null {
const exceptionResponse: ExceptionMonitorResponse | null =
MonitorCriteriaDataExtractor.getExceptionMonitorResponse(
input.dataToProcess,
);
if (!exceptionResponse) {
return null;
}
return `Exception count was ${exceptionResponse.exceptionCount}.`;
}
private static describeMetricValueObservation(input: {
criteriaFilter: CriteriaFilter;
dataToProcess: DataToProcess;

View File

@@ -31,6 +31,7 @@ import LogMonitorResponse from "../../../Types/Monitor/LogMonitor/LogMonitorResp
import MetricMonitorResponse from "../../../Types/Monitor/MetricMonitor/MetricMonitorResponse";
import TelemetryType from "../../../Types/Telemetry/TelemetryType";
import TraceMonitorResponse from "../../../Types/Monitor/TraceMonitor/TraceMonitorResponse";
import ExceptionMonitorResponse from "../../../Types/Monitor/ExceptionMonitor/ExceptionMonitorResponse";
import { TelemetryQuery } from "../../../Types/Telemetry/TelemetryQuery";
import MonitorIncident from "./MonitorIncident";
import MonitorAlert from "./MonitorAlert";
@@ -557,6 +558,23 @@ export default class MonitorResourceUtil {
);
}
if (
dataToProcess &&
(dataToProcess as ExceptionMonitorResponse).exceptionQuery
) {
const exceptionResponse: ExceptionMonitorResponse =
dataToProcess as ExceptionMonitorResponse;
telemetryQuery = {
telemetryQuery: exceptionResponse.exceptionQuery,
telemetryType: TelemetryType.Exception,
metricViewData: null,
};
logger.debug(
`${dataToProcess.monitorId.toString()} - Exception query found.`,
);
}
const matchedCriteriaInstance: MonitorCriteriaInstance =
criteriaInstanceMap[response.criteriaMetId!]!;

View File

@@ -5,6 +5,7 @@ import OpenTelemetryAPI, {
* DiagLogLevel,
*/
Meter,
type AttributeValue,
} from "@opentelemetry/api";
import { Logger, logs } from "@opentelemetry/api-logs";
import {
@@ -22,19 +23,27 @@ import {
BatchLogRecordProcessor,
LoggerProvider,
LogRecordProcessor,
type LoggerProviderConfig,
} from "@opentelemetry/sdk-logs";
import type { Resource as LogsResource } from "@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources/build/src/Resource";
import {
Aggregation,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import type { PushMetricExporter } from "@opentelemetry/sdk-metrics/build/src/export/MetricExporter";
import * as opentelemetry from "@opentelemetry/sdk-node";
import { SpanExporter } from "@opentelemetry/sdk-trace-node";
import { SpanExporter } from "@opentelemetry/sdk-trace-base";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import URL from "../../Types/API/URL";
import Dictionary from "../../Types/Dictionary";
import { DisableTelemetry } from "../EnvironmentConfig";
import logger from "./Logger";
type ResourceWithRawAttributes = LogsResource & {
getRawAttributes?: () => Array<[string, AttributeValue | undefined]>;
};
/*
* Enable this line to see debug logs
* diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
@@ -156,26 +165,51 @@ export default class Telemetry {
url: this.getOltpTracesEndpoint()!.toString(),
headers: headers,
compression: CompressionAlgorithm.GZIP,
});
}) as unknown as SpanExporter;
}
if (this.getOltpMetricsEndpoint() && hasHeaders) {
const metricExporter: PushMetricExporter = new OTLPMetricExporter({
url: this.getOltpMetricsEndpoint()!.toString(),
headers: headers,
compression: CompressionAlgorithm.GZIP,
}) as unknown as PushMetricExporter;
// Force an SDK-side aggregation selector that matches the modern metrics API.
if (
typeof (metricExporter as { selectAggregation?: unknown })
.selectAggregation === "function"
) {
(
metricExporter as unknown as {
selectAggregation: (..._args: Array<unknown>) => Aggregation;
}
).selectAggregation = () => {
return Aggregation.Default();
};
}
this.metricReader = new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: this.getOltpMetricsEndpoint()!.toString(),
headers: headers,
compression: CompressionAlgorithm.GZIP,
}),
exporter: metricExporter,
});
}
this.loggerProvider = new LoggerProvider({
resource: this.getResource({
serviceName: data.serviceName,
}),
const resource: Resource = this.getResource({
serviceName: data.serviceName,
});
let logRecordProcessor: LogRecordProcessor | null = null;
const logRecordProcessors: Array<LogRecordProcessor> = [];
const loggerProviderResource: ResourceWithRawAttributes =
resource as unknown as ResourceWithRawAttributes;
if (typeof loggerProviderResource.getRawAttributes !== "function") {
loggerProviderResource.getRawAttributes = () => {
return Object.entries(resource.attributes) as Array<
[string, AttributeValue | undefined]
>;
};
}
if (this.getOltpLogsEndpoint() && hasHeaders) {
const logExporter: OTLPLogExporter = new OTLPLogExporter({
@@ -184,22 +218,27 @@ export default class Telemetry {
compression: CompressionAlgorithm.GZIP,
});
logRecordProcessor = new BatchLogRecordProcessor(logExporter);
logRecordProcessors.push(new BatchLogRecordProcessor(logExporter));
}
if (logRecordProcessor) {
this.loggerProvider.addLogRecordProcessor(logRecordProcessor);
const loggerProviderConfig: LoggerProviderConfig = {
resource: loggerProviderResource,
};
if (logRecordProcessors.length > 0) {
loggerProviderConfig.processors = logRecordProcessors;
}
this.loggerProvider = new LoggerProvider(loggerProviderConfig);
logs.setGlobalLoggerProvider(this.loggerProvider);
const nodeSdkConfiguration: Partial<opentelemetry.NodeSDKConfiguration> =
{
idGenerator: new AWSXRayIdGenerator(),
instrumentations: [],
resource: this.getResource({
serviceName: data.serviceName,
}),
resource:
loggerProviderResource as unknown as opentelemetry.NodeSDKConfiguration["resource"],
autoDetectResources: true,
};
@@ -214,8 +253,8 @@ export default class Telemetry {
* }
*/
if (logRecordProcessor) {
nodeSdkConfiguration.logRecordProcessor = logRecordProcessor;
if (logRecordProcessors.length > 0) {
nodeSdkConfiguration.logRecordProcessors = logRecordProcessors;
}
const sdk: opentelemetry.NodeSDK = new opentelemetry.NodeSDK(

View File

@@ -2,6 +2,7 @@ import Pill, { PillSize } from "../../../UI/Components/Pill/Pill";
import "@testing-library/jest-dom/extend-expect";
import { render, screen } from "@testing-library/react";
import Color from "../../../Types/Color";
import IconProp from "../../../Types/Icon/IconProp";
import * as React from "react";
import { describe, expect, test } from "@jest/globals";
@@ -46,4 +47,11 @@ describe("<Pill />", () => {
render(<Pill text="Love" color={color} size={PillSize.ExtraLarge} />);
expect(screen.getByTestId("pill")).toHaveStyle("backgroundColor: #786598");
});
test("renders icon when provided", () => {
const color: Color = new Color("#807149");
const { container } = render(
<Pill text="Love" color={color} icon={IconProp.Label} />,
);
expect(container.querySelector('[role="icon"]')).not.toBeNull();
});
});

View File

@@ -19,9 +19,9 @@ export default class Color extends DatabaseProperty {
this._color = v;
}
public constructor(color: string) {
public constructor(color: string | Color) {
super();
this.color = color;
this.color = color.toString();
}
public override toString(): string {

View File

@@ -2,6 +2,7 @@ enum CookieName {
UserID = "user-id",
Email = "user-email",
Token = "user-token",
RefreshToken = "user-refresh-token",
Name = "user-name",
Timezone = "user-timezone",
IsMasterAdmin = "user-is-master-admin",

View File

@@ -0,0 +1,64 @@
import type BaseModel from "../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import { ReflectionMetadataType } from "../Reflection";
import "reflect-metadata";
const colorFieldSymbol: symbol = Symbol("ColorField");
type ColorFieldColumnsFunction = <T extends BaseModel>(
target: T,
) => Array<string>;
type FirstColorFieldColumnFunction = <T extends BaseModel>(
target: T,
) => string | null;
type IsColorFieldColumnFunction = <T extends BaseModel>(
target: T,
propertyKey: string,
) => boolean;
const ColorField: () => ReflectionMetadataType = () => {
return Reflect.metadata(colorFieldSymbol, true);
};
export const isColorFieldColumn: IsColorFieldColumnFunction = <
T extends BaseModel,
>(
target: T,
propertyKey: string,
): boolean => {
return Boolean(Reflect.getMetadata(colorFieldSymbol, target, propertyKey));
};
export const getColorFieldColumns: ColorFieldColumnsFunction = <
T extends BaseModel,
>(
target: T,
): Array<string> => {
const columns: Array<string> = [];
const keys: Array<string> = Object.keys(target);
for (const key of keys) {
if (isColorFieldColumn(target, key)) {
columns.push(key);
}
}
return columns;
};
export const getFirstColorFieldColumn: FirstColorFieldColumnFunction = <
T extends BaseModel,
>(
target: T,
): string | null => {
const columns: Array<string> = getColorFieldColumns(target);
if (columns.length === 0) {
return null;
}
return columns[0] as string;
};
export default ColorField;

View File

@@ -98,6 +98,7 @@ enum IconProp {
False = "False",
Text = "Text",
Circle = "Circle",
EmptyCircle = "EmptyCircle",
Webhook = "Webhook",
SendMessage = "SendMessage",
ExternalLink = "ExternalLink",

View File

@@ -11,4 +11,5 @@ export default interface JSONWebTokenData extends JSONObject {
statusPageId?: ObjectID | undefined; // for status page logins.
projectId?: ObjectID | undefined; // for SSO logins.
isGlobalLogin: boolean; // If this is OneUptime username and password login. This is true, if this is SSO login. Then, this is false.
sessionId?: ObjectID | undefined;
}

View File

@@ -42,6 +42,9 @@ export enum CheckOn {
// Trace monitors.
SpanCount = "Span Count",
// Exception monitors.
ExceptionCount = "Exception Count",
// Metric Monitors.
MetricValue = "Metric Value",
}

View File

@@ -0,0 +1,12 @@
import Query from "../../BaseDatabase/Query";
import ObjectID from "../../ObjectID";
import ExceptionInstance from "../../../Models/AnalyticsModels/ExceptionInstance";
import MonitorEvaluationSummary from "../MonitorEvaluationSummary";
export default interface ExceptionMonitorResponse {
projectId: ObjectID;
exceptionCount: number;
exceptionQuery: Query<ExceptionInstance>;
monitorId: ObjectID;
evaluationSummary?: MonitorEvaluationSummary | undefined;
}

View File

@@ -121,6 +121,33 @@ export default class MonitorCriteriaInstance extends DatabaseProperty {
return monitorCriteriaInstance;
}
if (arg.monitorType === MonitorType.Exceptions) {
const monitorCriteriaInstance: MonitorCriteriaInstance =
new MonitorCriteriaInstance();
monitorCriteriaInstance.data = {
id: ObjectID.generate().toString(),
monitorStatusId: arg.monitorStatusId,
filterCondition: FilterCondition.Any,
filters: [
{
checkOn: CheckOn.ExceptionCount,
filterType: FilterType.EqualTo,
value: 0,
},
],
incidents: [],
alerts: [],
changeMonitorStatus: true,
createIncidents: false,
createAlerts: false,
name: `Check if ${arg.monitorName} has no exceptions`,
description: `This criteria checks if the ${arg.monitorName} has no exceptions.`,
};
return monitorCriteriaInstance;
}
if (arg.monitorType === MonitorType.Metrics) {
const monitorCriteriaInstance: MonitorCriteriaInstance =
new MonitorCriteriaInstance();
@@ -462,6 +489,46 @@ export default class MonitorCriteriaInstance extends DatabaseProperty {
};
}
if (arg.monitorType === MonitorType.Exceptions) {
monitorCriteriaInstance.data = {
id: ObjectID.generate().toString(),
monitorStatusId: arg.monitorStatusId,
filterCondition: FilterCondition.Any,
filters: [
{
checkOn: CheckOn.ExceptionCount,
filterType: FilterType.GreaterThan,
value: 0,
},
],
incidents: [
{
title: `${arg.monitorName} has exceptions`,
description: `${arg.monitorName} has active exceptions.`,
incidentSeverityId: arg.incidentSeverityId,
autoResolveIncident: true,
id: ObjectID.generate().toString(),
onCallPolicyIds: [],
},
],
alerts: [
{
title: `${arg.monitorName} has exceptions`,
description: `${arg.monitorName} has active exceptions.`,
alertSeverityId: arg.alertSeverityId,
autoResolveAlert: true,
id: ObjectID.generate().toString(),
onCallPolicyIds: [],
},
],
createAlerts: false,
changeMonitorStatus: true,
createIncidents: true,
name: `Check if ${arg.monitorName} has exceptions`,
description: `This criteria checks if the ${arg.monitorName} has exceptions.`,
};
}
if (arg.monitorType === MonitorType.Metrics) {
monitorCriteriaInstance.data = {
id: ObjectID.generate().toString(),

View File

@@ -23,6 +23,9 @@ import MonitorStepTraceMonitor, {
import MonitorStepMetricMonitor, {
MonitorStepMetricMonitorUtil,
} from "./MonitorStepMetricMonitor";
import MonitorStepExceptionMonitor, {
MonitorStepExceptionMonitorUtil,
} from "./MonitorStepExceptionMonitor";
import Zod, { ZodSchema } from "../../Utils/Schema/Zod";
export interface MonitorStepType {
@@ -57,6 +60,9 @@ export interface MonitorStepType {
// Metric Monitor
metricMonitor: MonitorStepMetricMonitor | undefined;
// Exception monitor
exceptionMonitor?: MonitorStepExceptionMonitor | undefined;
}
export default class MonitorStep extends DatabaseProperty {
@@ -80,6 +86,7 @@ export default class MonitorStep extends DatabaseProperty {
logMonitor: undefined,
traceMonitor: undefined,
metricMonitor: undefined,
exceptionMonitor: undefined,
};
}
@@ -108,6 +115,7 @@ export default class MonitorStep extends DatabaseProperty {
logMonitor: undefined,
traceMonitor: undefined,
metricMonitor: undefined,
exceptionMonitor: undefined,
};
return monitorStep;
@@ -186,6 +194,13 @@ export default class MonitorStep extends DatabaseProperty {
return this;
}
public setExceptionMonitor(
exceptionMonitor: MonitorStepExceptionMonitor,
): MonitorStep {
this.data!.exceptionMonitor = exceptionMonitor;
return this;
}
public setCustomCode(customCode: string): MonitorStep {
this.data!.customCode = customCode;
return this;
@@ -212,6 +227,7 @@ export default class MonitorStep extends DatabaseProperty {
screenSizeTypes: undefined,
browserTypes: undefined,
lgoMonitor: undefined,
exceptionMonitor: undefined,
},
};
}
@@ -310,6 +326,12 @@ export default class MonitorStep extends DatabaseProperty {
MonitorStepTraceMonitorUtil.getDefault(),
)
: undefined,
exceptionMonitor: this.data.exceptionMonitor
? MonitorStepExceptionMonitorUtil.toJSON(
this.data.exceptionMonitor ||
MonitorStepExceptionMonitorUtil.getDefault(),
)
: undefined,
},
});
}
@@ -408,6 +430,9 @@ export default class MonitorStep extends DatabaseProperty {
traceMonitor: json["traceMonitor"]
? (json["traceMonitor"] as JSONObject)
: undefined,
exceptionMonitor: json["exceptionMonitor"]
? (json["exceptionMonitor"] as JSONObject)
: undefined,
}) as any;
if (monitorStep.data && !monitorStep.data?.logMonitor) {
@@ -423,6 +448,11 @@ export default class MonitorStep extends DatabaseProperty {
MonitorStepMetricMonitorUtil.getDefault();
}
if (monitorStep.data && !monitorStep.data?.exceptionMonitor) {
monitorStep.data.exceptionMonitor =
MonitorStepExceptionMonitorUtil.getDefault();
}
return monitorStep;
}

View File

@@ -0,0 +1,94 @@
import ExceptionInstance from "../../Models/AnalyticsModels/ExceptionInstance";
import InBetween from "../BaseDatabase/InBetween";
import Includes from "../BaseDatabase/Includes";
import Query from "../BaseDatabase/Query";
import Search from "../BaseDatabase/Search";
import OneUptimeDate from "../Date";
import { JSONObject } from "../JSON";
import ObjectID from "../ObjectID";
export default interface MonitorStepExceptionMonitor {
telemetryServiceIds: Array<ObjectID>;
exceptionTypes: Array<string>;
message: string;
includeResolved: boolean;
includeArchived: boolean;
lastXSecondsOfExceptions: number;
}
export class MonitorStepExceptionMonitorUtil {
public static toAnalyticsQuery(
monitorStepExceptionMonitor: MonitorStepExceptionMonitor,
): Query<ExceptionInstance> {
const query: Query<ExceptionInstance> = {};
if (
monitorStepExceptionMonitor.telemetryServiceIds &&
monitorStepExceptionMonitor.telemetryServiceIds.length > 0
) {
query.serviceId = new Includes(
monitorStepExceptionMonitor.telemetryServiceIds,
);
}
if (
monitorStepExceptionMonitor.exceptionTypes &&
monitorStepExceptionMonitor.exceptionTypes.length > 0
) {
query.exceptionType = new Includes(
monitorStepExceptionMonitor.exceptionTypes,
);
}
if (monitorStepExceptionMonitor.message) {
query.message = new Search(monitorStepExceptionMonitor.message);
}
if (monitorStepExceptionMonitor.lastXSecondsOfExceptions) {
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveSeconds(
endDate,
monitorStepExceptionMonitor.lastXSecondsOfExceptions * -1,
);
query.time = new InBetween(startDate, endDate);
}
return query;
}
public static getDefault(): MonitorStepExceptionMonitor {
return {
telemetryServiceIds: [],
exceptionTypes: [],
message: "",
includeResolved: false,
includeArchived: false,
lastXSecondsOfExceptions: 60,
};
}
public static fromJSON(json: JSONObject): MonitorStepExceptionMonitor {
return {
telemetryServiceIds: ObjectID.fromJSONArray(
(json["telemetryServiceIds"] as Array<JSONObject>) || [],
),
exceptionTypes: (json["exceptionTypes"] as Array<string>) || [],
message: (json["message"] as string) || "",
includeResolved: Boolean(json["includeResolved"]) || false,
includeArchived: Boolean(json["includeArchived"]) || false,
lastXSecondsOfExceptions:
(json["lastXSecondsOfExceptions"] as number | undefined) || 60,
};
}
public static toJSON(monitor: MonitorStepExceptionMonitor): JSONObject {
return {
telemetryServiceIds: ObjectID.toJSONArray(monitor.telemetryServiceIds),
exceptionTypes: monitor.exceptionTypes,
message: monitor.message,
includeResolved: monitor.includeResolved,
includeArchived: monitor.includeArchived,
lastXSecondsOfExceptions: monitor.lastXSecondsOfExceptions,
};
}
}

View File

@@ -20,6 +20,7 @@ enum MonitorType {
Logs = "Logs",
Metrics = "Metrics",
Traces = "Traces",
Exceptions = "Exceptions",
}
export default MonitorType;
@@ -35,7 +36,8 @@ export class MonitorTypeHelper {
return (
monitorType === MonitorType.Logs ||
monitorType === MonitorType.Metrics ||
monitorType === MonitorType.Traces
monitorType === MonitorType.Traces ||
monitorType === MonitorType.Exceptions
);
}
@@ -123,6 +125,12 @@ export class MonitorTypeHelper {
title: "Logs",
description: "This monitor type lets you monitor logs from any source.",
},
{
monitorType: MonitorType.Exceptions,
title: "Exceptions",
description:
"This monitor type lets you monitor exceptions and error groups from any source.",
},
{
monitorType: MonitorType.Traces,
title: "Traces",
@@ -198,6 +206,7 @@ export class MonitorTypeHelper {
MonitorType.Logs,
MonitorType.Metrics,
MonitorType.Traces,
MonitorType.Exceptions,
];
}

View File

@@ -3,9 +3,10 @@ import Span from "../../Models/AnalyticsModels/Span";
import Query from "../BaseDatabase/Query";
import MetricViewData from "../Metrics/MetricViewData";
import TelemetryType from "./TelemetryType";
import ExceptionInstance from "../../Models/AnalyticsModels/ExceptionInstance";
export interface TelemetryQuery {
telemetryType: TelemetryType;
telemetryQuery: Query<Log> | Query<Span> | null;
telemetryQuery: Query<Log> | Query<Span> | Query<ExceptionInstance> | null;
metricViewData: MetricViewData | null;
}

View File

@@ -2,6 +2,7 @@ enum TelemetryType {
Metric = "Metric",
Trace = "Trace",
Log = "Log",
Exception = "Exception",
}
export default TelemetryType;

View File

@@ -297,4 +297,19 @@ export default class Text {
): string {
return sentence.split(search).join(replaceBy);
}
public static truncate(
value: string | null | undefined,
maxLength: number,
): string | undefined {
if (value === null || value === undefined) {
return undefined;
}
if (maxLength <= 0) {
return "";
}
return value.length > maxLength ? value.slice(0, maxLength) : value;
}
}

View File

@@ -6,13 +6,32 @@ import React, {
useRef,
useState,
} from "react";
import Select, { ControlProps, GroupBase, OptionProps } from "react-select";
import Color from "../../../Types/Color";
import Label from "../../../Models/DatabaseModels/Label";
import Select, {
ControlProps,
CSSObjectWithLabel,
FormatOptionLabelMeta,
GroupBase,
OptionProps,
} from "react-select";
export type DropdownValue = string | number | boolean;
export type DropdownOptionLabel =
| Label
| {
id?: string;
name: string;
color?: Color;
};
export interface DropdownOption {
value: DropdownValue;
label: string;
description?: string;
labels?: Array<DropdownOptionLabel>;
color?: Color;
}
export interface ComponentProps {
@@ -111,6 +130,333 @@ const Dropdown: FunctionComponent<ComponentProps> = (
const firstUpdate: React.MutableRefObject<boolean> = useRef(true);
interface NormalizedDropdownLabel {
id?: string;
name: string;
color?: string;
}
const normalizeLabelColor: (
color?: Color | string | null,
) => string | undefined = (
color?: Color | string | null,
): string | undefined => {
if (!color) {
return undefined;
}
if (color instanceof Color) {
return color.toString();
}
if (typeof color === "string" && color.trim().length > 0) {
return color;
}
return undefined;
};
const normalizeDropdownLabel: (
label: DropdownOptionLabel,
) => NormalizedDropdownLabel | null = (
label: DropdownOptionLabel,
): NormalizedDropdownLabel | null => {
if (!label) {
return null;
}
const getValueFromModel: (
columnName: string,
) => string | Color | null | undefined = (
columnName: string,
): string | Color | null | undefined => {
if (
typeof (label as Label).getColumnValue === "function" &&
typeof (label as Label).getTableColumnMetadata === "function"
) {
return (label as Label).getColumnValue(columnName) as
| string
| Color
| null
| undefined;
}
return (label as any)?.[columnName] as string | Color | null | undefined;
};
const labelName: string | undefined = (() => {
const valueFromGetter: string | null | undefined = getValueFromModel(
"name",
) as string | undefined | null;
if (valueFromGetter && valueFromGetter.trim().length > 0) {
return valueFromGetter;
}
const fallbackName: string | undefined = (label as any)?.name;
if (fallbackName && fallbackName.trim().length > 0) {
return fallbackName;
}
return undefined;
})();
if (!labelName) {
return null;
}
const rawColor: Color | string | null | undefined = getValueFromModel(
"color",
) as Color | string | null | undefined;
const color: string | undefined =
normalizeLabelColor(rawColor) ||
normalizeLabelColor((label as any)?.color);
const idValue: string | undefined = (() => {
if (typeof (label as Label).id !== "undefined") {
const idFromGetter: ObjectID | null | undefined = (label as Label).id;
if (idFromGetter) {
return idFromGetter.toString();
}
}
const fallbackId: string | undefined =
(label as any)?._id || (label as any)?.id;
if (fallbackId) {
return fallbackId;
}
return undefined;
})();
const normalized: NormalizedDropdownLabel = {
name: labelName,
};
if (idValue) {
normalized.id = idValue;
}
if (color) {
normalized.color = color;
}
return normalized;
};
const normalizeLabelCollection: (
labels?: Array<DropdownOptionLabel>,
) => Array<NormalizedDropdownLabel> = (
labels?: Array<DropdownOptionLabel>,
): Array<NormalizedDropdownLabel> => {
if (!labels || labels.length === 0) {
return [];
}
return labels
.map((label: DropdownOptionLabel) => {
return normalizeDropdownLabel(label);
})
.filter(
(
label: NormalizedDropdownLabel | null,
): label is NormalizedDropdownLabel => {
return label !== null;
},
);
};
const renderOptionColorIndicator: (
color?: Color | string,
) => ReactElement | null = (color?: Color | string): ReactElement | null => {
const normalizedColor: string | undefined = color
? new Color(color).toString()
: undefined;
if (!normalizedColor) {
return null;
}
return (
<span
aria-hidden="true"
className="h-2.5 w-2.5 flex-none rounded-full border border-gray-200"
style={{
backgroundColor: normalizedColor,
}}
title={normalizedColor}
></span>
);
};
const getLabelStyle: (color?: string) => {
backgroundColor: string;
color: string;
} = (color?: string): { backgroundColor: string; color: string } => {
if (!color) {
return {
backgroundColor: "#e5e7eb", // gray-200
color: "#374151", // gray-700
};
}
try {
const parsedColor: Color = Color.fromString(color);
return {
backgroundColor: parsedColor.toString(),
color: Color.shouldUseDarkText(parsedColor)
? "#111827" // gray-900
: "#f9fafb", // gray-50
};
} catch {
return {
backgroundColor: color,
color: "#111827",
};
}
};
const defaultSelectedLabelAccentColor: string = "#6366f1"; // indigo-500
const resolveSelectedLabelColor: (color?: string) => string = (
color?: string,
): string => {
if (!color) {
return defaultSelectedLabelAccentColor;
}
try {
return Color.fromString(color).toString();
} catch {
return defaultSelectedLabelAccentColor;
}
};
const renderAssociatedLabels: (
labels: Array<NormalizedDropdownLabel>,
context: FormatOptionLabelMeta<DropdownOption>["context"],
hiddenLabelCount: number,
) => ReactElement | null = (
labels: Array<NormalizedDropdownLabel>,
context: FormatOptionLabelMeta<DropdownOption>["context"],
hiddenLabelCount: number,
): ReactElement | null => {
if (labels.length === 0 && hiddenLabelCount === 0) {
return null;
}
if (context === "value") {
return (
<div className="flex flex-wrap items-center gap-1">
{labels.map((label: NormalizedDropdownLabel, index: number) => {
const accentColor: string = resolveSelectedLabelColor(label.color);
return (
<span
key={`${label.id || label.name}-selected-${index}`}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-white px-2 py-0.5 text-xs font-medium text-gray-600"
style={{
borderColor: accentColor,
}}
title={label.name}
>
<span
aria-hidden="true"
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: accentColor,
}}
></span>
{label.name}
</span>
);
})}
{hiddenLabelCount > 0 ? (
<span className="inline-flex items-center rounded-full border border-gray-200 bg-white px-2 py-0.5 text-xs font-medium text-gray-500">
+{hiddenLabelCount}
</span>
) : null}
</div>
);
}
return (
<div className="flex flex-wrap gap-1">
{labels.map((label: NormalizedDropdownLabel, index: number) => {
const { backgroundColor, color } = getLabelStyle(label.color);
return (
<span
key={`${label.id || label.name}-menu-${index}`}
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium shadow-sm"
style={{ backgroundColor, color }}
>
{label.name}
</span>
);
})}
{hiddenLabelCount > 0 ? (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
+{hiddenLabelCount}
</span>
) : null}
</div>
);
};
const formatDropdownOptionLabel: (
option: DropdownOption,
meta: FormatOptionLabelMeta<DropdownOption>,
) => ReactElement = (
option: DropdownOption,
meta: FormatOptionLabelMeta<DropdownOption>,
): ReactElement => {
const normalizedLabels: Array<NormalizedDropdownLabel> =
normalizeLabelCollection(option.labels);
const maxVisibleLabels: number = meta.context === "menu" ? 4 : 2;
const visibleLabels: Array<NormalizedDropdownLabel> =
normalizedLabels.slice(0, maxVisibleLabels);
const hiddenLabelCount: number = Math.max(
normalizedLabels.length - visibleLabels.length,
0,
);
if (meta.context === "value") {
return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
{renderOptionColorIndicator(option.color)}
<span className="text-sm font-medium text-gray-900">
{option.label}
</span>
</div>
{renderAssociatedLabels(
visibleLabels,
meta.context,
hiddenLabelCount,
)}
</div>
);
}
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
{renderOptionColorIndicator(option.color)}
<span className="text-sm font-medium text-gray-900">
{option.label}
</span>
</div>
{option.description ? (
<span className="text-xs text-gray-500">{option.description}</span>
) : null}
{renderAssociatedLabels(visibleLabels, meta.context, hiddenLabelCount)}
</div>
);
};
useLayoutEffect(() => {
if (firstUpdate.current && props.initialValue) {
firstUpdate.current = false;
@@ -138,6 +484,9 @@ const Dropdown: FunctionComponent<ComponentProps> = (
}}
>
<Select
classNamePrefix="ou-select"
unstyled={false}
formatOptionLabel={formatDropdownOptionLabel}
onBlur={() => {
props.onBlur?.();
}}
@@ -150,25 +499,169 @@ const Dropdown: FunctionComponent<ComponentProps> = (
}}
classNames={{
control: (
state: ControlProps<any, boolean, GroupBase<any>>,
state: ControlProps<DropdownOption, boolean, GroupBase<any>>,
): string => {
return state.isFocused
? "!border-indigo-500"
: "border-Gray500-300";
const classes: Array<string> = [
"!min-h-[40px] !rounded-lg !border !bg-white !shadow-sm !transition-all !duration-150",
state.isFocused
? "!border-indigo-400 !ring-2 !ring-indigo-100"
: "!border-gray-300 hover:!border-indigo-300",
state.isDisabled
? "!bg-gray-100 !text-gray-400"
: "!cursor-pointer",
];
if (props.error) {
classes.push("!border-red-400 !ring-2 !ring-red-100");
}
return classes.join(" ");
},
valueContainer: () => {
return "!gap-2 !px-2";
},
placeholder: () => {
return "text-sm text-gray-400";
},
input: () => {
return "text-sm text-gray-900";
},
singleValue: () => {
return "text-sm text-gray-900 font-medium";
},
indicatorsContainer: () => {
return "!gap-1 !px-1";
},
dropdownIndicator: () => {
return "text-gray-500 transition-colors duration-150 hover:text-indigo-400";
},
clearIndicator: () => {
return "text-gray-400 transition-colors duration-150 hover:text-red-500";
},
menu: () => {
return "!mt-2 !rounded-xl !border !border-gray-100 !bg-white !shadow-xl";
},
menuList: () => {
return "!py-2";
},
option: (
state: OptionProps<any, boolean, GroupBase<any>>,
state: OptionProps<DropdownOption, boolean, GroupBase<any>>,
): string => {
if (state.isDisabled) {
return "bg-gray-100";
return "px-3 py-2 text-sm text-gray-300 cursor-not-allowed";
}
if (state.isSelected) {
return "!bg-indigo-500";
return "px-3 py-2 text-sm bg-indigo-200 text-indigo-900";
}
if (state.isFocused) {
return "!bg-indigo-100";
return "px-3 py-2 text-sm bg-indigo-100 text-indigo-700";
}
return "";
return "px-3 py-2 text-sm text-gray-700";
},
noOptionsMessage: () => {
return "px-3 py-2 text-sm text-gray-500";
},
multiValue: () => {
return "flex items-center gap-2 rounded-lg border border-indigo-100 bg-indigo-50 px-2 py-1";
},
multiValueLabel: () => {
return "flex flex-wrap items-center gap-2 text-sm font-medium text-indigo-900";
},
multiValueRemove: () => {
return "text-indigo-400 hover:text-indigo-600 transition-colors duration-150";
},
}}
styles={{
dropdownIndicator: (
provided: CSSObjectWithLabel,
): CSSObjectWithLabel => {
return {
...provided,
padding: 8,
};
},
clearIndicator: (
provided: CSSObjectWithLabel,
): CSSObjectWithLabel => {
return {
...provided,
padding: 8,
};
},
indicatorSeparator: (): CSSObjectWithLabel => {
return {
display: "none",
} as CSSObjectWithLabel;
},
option: (
provided: CSSObjectWithLabel,
state: OptionProps<
DropdownOption,
boolean,
GroupBase<DropdownOption>
>,
): CSSObjectWithLabel => {
if (state.isSelected) {
return {
...provided,
backgroundColor: "#c7d2fe", // indigo-200
color: "#1e1b4b", // indigo-900
};
}
if (state.isFocused) {
return {
...provided,
backgroundColor: "#e0e7ff", // indigo-100
color: "#312e81", // indigo-800
};
}
return {
...provided,
color: "#374151", // gray-700
};
},
multiValue: (provided: CSSObjectWithLabel): CSSObjectWithLabel => {
return {
...provided,
backgroundColor: "#eef2ff", // indigo-50
borderRadius: 8,
border: "1px solid #c7d2fe", // indigo-200
paddingLeft: 4,
paddingRight: 4,
};
},
multiValueLabel: (
provided: CSSObjectWithLabel,
): CSSObjectWithLabel => {
return {
...provided,
color: "#312e81", // indigo-800
fontSize: "0.875rem",
fontWeight: 500,
};
},
multiValueRemove: (
provided: CSSObjectWithLabel,
): CSSObjectWithLabel => {
return {
...provided,
color: "#6366f1", // indigo-500
":hover": {
color: "#4f46e5", // indigo-600
backgroundColor: "transparent",
},
};
},
menuPortal: (base: CSSObjectWithLabel): CSSObjectWithLabel => {
return {
...base,
zIndex: 50,
};
},
}}
isClearable={true}

View File

@@ -12,6 +12,7 @@ import {
CategoryCheckboxOption,
CheckboxCategory,
} from "../CategoryCheckbox/CategoryCheckboxTypes";
import type { DropdownOption } from "../Dropdown/Dropdown";
import Loader, { LoaderType } from "../Loader/Loader";
import Pill, { PillSize } from "../Pill/Pill";
import { FormErrors, FormProps, FormSummaryConfig } from "./BasicForm";
@@ -24,6 +25,7 @@ import AnalyticsBaseModel from "../../../Models/AnalyticsModels/AnalyticsBaseMod
import AccessControlModel from "../../../Models/DatabaseModels/DatabaseBaseModel/AccessControlModel";
import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import FileModel from "../../../Models/DatabaseModels/DatabaseBaseModel/FileModel";
import Label from "../../../Models/DatabaseModels/Label";
import URL from "../../../Types/API/URL";
import { ColumnAccessControl } from "../../../Types/BaseDatabase/AccessControl";
import { Black, VeryLightGray } from "../../../Types/BrandColors";
@@ -412,17 +414,26 @@ const ModelForm: <TBaseModel extends BaseModel>(
[field.dropdownModal.valueField]: true,
} as any;
let hasAccessControlColumn: boolean = false;
let colorColumnName: string | null = null;
let shouldSelectColorColumn: boolean = false;
colorColumnName = tempModel.getFirstColorColumn();
if (colorColumnName) {
select[colorColumnName] = true;
shouldSelectColorColumn = true;
}
const accessControlColumnName: string | null =
tempModel.getAccessControlColumn();
// also select labels, so they can select resources by labels. This is useful for resources like monitors, etc.
if (tempModel.getAccessControlColumn()) {
select[tempModel.getAccessControlColumn()!] = {
if (accessControlColumnName) {
select[accessControlColumnName] = {
_id: true,
name: true,
color: true,
} as any;
hasAccessControlColumn = true;
}
const listResult: ListResult<BaseModel> =
@@ -436,22 +447,51 @@ const ModelForm: <TBaseModel extends BaseModel>(
});
if (listResult.data && listResult.data.length > 0) {
field.dropdownOptions = listResult.data.map((item: BaseModel) => {
if (!field.dropdownModal) {
throw new BadDataException("Dropdown Modal value mot found");
}
const dropdownOptions: Array<DropdownOption> = listResult.data.map(
(item: BaseModel) => {
if (!field.dropdownModal) {
throw new BadDataException("Dropdown Modal value mot found");
}
return {
label: (item as any)[
field.dropdownModal?.labelField
].toString(),
value: (item as any)[
field.dropdownModal?.valueField
].toString(),
};
});
const option: DropdownOption = {
label: (item as any)[
field.dropdownModal?.labelField
].toString(),
value: (item as any)[
field.dropdownModal?.valueField
].toString(),
};
if (hasAccessControlColumn) {
if (colorColumnName && shouldSelectColorColumn) {
const color: Color = item.getColumnValue(
colorColumnName,
) as Color;
if (color) {
option.color = color;
}
}
if (accessControlColumnName) {
const labelsForItem: Array<AccessControlModel> = (
((item as any)[
accessControlColumnName
] as Array<AccessControlModel>) || []
).filter((label: AccessControlModel | null) => {
return Boolean(label);
}) as Array<AccessControlModel>;
if (labelsForItem.length > 0) {
option.labels = labelsForItem as Array<Label>;
}
}
return option;
},
);
field.dropdownOptions = dropdownOptions;
if (accessControlColumnName) {
const categories: Array<CheckboxCategory> = [];
// populate categories.
@@ -459,8 +499,7 @@ const ModelForm: <TBaseModel extends BaseModel>(
let localLabels: Array<AccessControlModel> = [];
for (const item of listResult.data) {
const accessControlColumn: string | null =
tempModel.getAccessControlColumn()!;
const accessControlColumn: string = accessControlColumnName;
const labels: Array<AccessControlModel> =
((item as any)[
accessControlColumn
@@ -516,8 +555,7 @@ const ModelForm: <TBaseModel extends BaseModel>(
const options: Array<CategoryCheckboxOption> = [];
for (const item of listResult.data) {
const accessControlColumn: string =
tempModel.getAccessControlColumn()!;
const accessControlColumn: string = accessControlColumnName;
const labels: Array<AccessControlModel> =
((item as any)[
accessControlColumn
@@ -554,9 +592,8 @@ const ModelForm: <TBaseModel extends BaseModel>(
options: options,
},
accessControlColumnTitle:
tempModel.getTableColumnMetadata(
tempModel.getAccessControlColumn()!,
).title || "",
tempModel.getTableColumnMetadata(accessControlColumnName)
.title || "",
};
}
} else {

View File

@@ -1102,6 +1102,10 @@ const Icon: FunctionComponent<ComponentProps> = ({
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>,
);
} else if (icon === IconProp.EmptyCircle) {
return getSvgWrapper(<circle cx="12" cy="12" r="7.5" />, {
strokeWidth: "2.25",
});
} else if (icon === IconProp.Circle) {
return getSvgWrapper(
<path

View File

@@ -0,0 +1,50 @@
import React, { CSSProperties, FunctionComponent, ReactElement } from "react";
import LabelModel from "../../../Models/DatabaseModels/Label";
import Pill, { ComponentProps as PillProps, PillSize } from "../Pill/Pill";
import { Black } from "../../../Types/BrandColors";
import Color from "../../../Types/Color";
import IconProp from "../../../Types/Icon/IconProp";
export interface ComponentProps {
label: LabelModel;
size?: PillSize | undefined;
style?: CSSProperties;
isMinimal?: boolean | undefined;
}
const LabelElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const { label } = props;
const resolveColor: Color = (() => {
if (!label.color) {
return Black;
}
if (typeof label.color === "string") {
return Color.fromString(label.color);
}
return label.color;
})();
const text: string = label.name || label.slug || "";
const pillProps: PillProps = {
color: resolveColor,
text,
size: props.size,
isMinimal: props.isMinimal,
tooltip: label.description || undefined,
icon: IconProp.EmptyCircle,
};
if (props.style) {
pillProps.style = props.style;
}
return <Pill {...pillProps} />;
};
export default LabelElement;

View File

@@ -1,25 +1,29 @@
import LabelElement from "./Label";
import TableColumnListComponent from "Common/UI/Components/TableColumnList/TableColumnListComponent";
import Label from "Common/Models/DatabaseModels/Label";
import TableColumnListComponent from "../TableColumnList/TableColumnListComponent";
import LabelModel from "../../../Models/DatabaseModels/Label";
import React, { FunctionComponent, ReactElement } from "react";
export interface ComponentProps {
labels: Array<Label>;
labels: Array<LabelModel>;
}
const LabelsElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
return (
// {/** >4 because 3 labels are shown by default and then the more text is shown */}
<TableColumnListComponent
items={props.labels}
moreText={props.labels.length > 4 ? "more labels" : "more label"}
className={props.labels.length > 0 ? "-mb-1 -mt-1" : ""}
getEachElement={(label: Label) => {
getEachElement={(label: LabelModel) => {
return (
<div className={props.labels.length > 0 ? "my-2" : ""}>
<LabelElement label={label} />
<LabelElement
label={label}
style={{
marginRight: "5px",
}}
/>
</div>
);
}}

View File

@@ -38,6 +38,7 @@ import { ListDetailProps } from "../List/ListRow";
import ConfirmModal from "../Modal/ConfirmModal";
import { ModalWidth } from "../Modal/Modal";
import Filter from "../ModelFilter/Filter";
import { DropdownOption, DropdownOptionLabel } from "../Dropdown/Dropdown";
import OrderedStatesList from "../OrderedStatesList/OrderedStatesList";
import Pill from "../Pill/Pill";
import Table from "../Table/Table";
@@ -51,6 +52,7 @@ import AnalyticsBaseModel, {
import BaseModel, {
DatabaseBaseModelType,
} from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import AccessControlModel from "../../../Models/DatabaseModels/DatabaseBaseModel/AccessControlModel";
import Route from "../../../Types/API/Route";
import URL from "../../../Types/API/URL";
import { ColumnAccessControl } from "../../../Types/BaseDatabase/AccessControl";
@@ -64,6 +66,7 @@ import { Yellow } from "../../../Types/BrandColors";
import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
import Dictionary from "../../../Types/Dictionary";
import BadDataException from "../../../Types/Exception/BadDataException";
import Color from "../../../Types/Color";
import {
ErrorFunction,
PromiseVoidFunction,
@@ -641,30 +644,155 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
const query: Query<TBaseModel> = filter.filterQuery || {};
let colorColumnName: string | null = null;
let accessControlColumnName: string | null = null;
if (
filter.filterEntityType &&
filter.filterEntityType.prototype instanceof BaseModel
) {
const filterModel: BaseModel =
new (filter.filterEntityType as DatabaseBaseModelType)();
colorColumnName = filterModel.getFirstColorColumn();
accessControlColumnName = filterModel.getAccessControlColumn();
}
const select: Select<TBaseModel> = {
[filter.filterDropdownField.label]: true,
[filter.filterDropdownField.value]: true,
} as Select<TBaseModel>;
if (colorColumnName) {
(select as Dictionary<boolean>)[colorColumnName] = true;
}
if (accessControlColumnName) {
(select as Dictionary<JSONObject>)[accessControlColumnName] = {
_id: true,
name: true,
color: true,
} as JSONObject;
}
const listResult: ListResult<TBaseModel> =
await props.callbacks.getList({
modelType: filter.filterEntityType,
query: query,
limit: LIMIT_PER_PROJECT,
skip: 0,
select: {
[filter.filterDropdownField.label]: true,
[filter.filterDropdownField.value]: true,
} as any,
select: select,
sort: {},
});
filter.filterDropdownOptions = [];
for (const item of listResult.data) {
filter.filterDropdownOptions.push({
const option: DropdownOption = {
value: item.getColumnValue(
filter.filterDropdownField.value,
) as string,
label: item.getColumnValue(
filter.filterDropdownField.label,
) as string,
});
};
if (colorColumnName) {
const colorValue: Color | string | null = item.getColumnValue(
colorColumnName,
) as Color | string | null;
if (colorValue instanceof Color) {
option.color = colorValue;
} else if (
typeof colorValue === "string" &&
colorValue.trim().length > 0
) {
try {
option.color = new Color(colorValue);
} catch {
// ignore invalid colors
}
}
}
if (accessControlColumnName) {
const accessControlValue:
| AccessControlModel
| Array<AccessControlModel>
| null =
(item.getColumnValue(accessControlColumnName) as
| AccessControlModel
| Array<AccessControlModel>
| null) || null;
const accessControlItems: Array<AccessControlModel> =
Array.isArray(accessControlValue)
? accessControlValue
: accessControlValue
? [accessControlValue]
: [];
type SimplifiedDropdownLabel = {
id?: string;
name: string;
color?: Color;
};
const dropdownLabels: Array<SimplifiedDropdownLabel> =
accessControlItems
.map((label: AccessControlModel | null) => {
if (!label) {
return null;
}
const labelNameRaw: string | null = label.getColumnValue(
"name",
) as string | null;
if (!labelNameRaw) {
return null;
}
const labelName: string = labelNameRaw.toString().trim();
if (!labelName) {
return null;
}
const labelColorValue: Color | null = label.getColumnValue(
"color",
) as Color | null;
const normalizedLabel: SimplifiedDropdownLabel = {
name: labelName,
};
const labelId: ObjectID | null = label.id;
if (labelId) {
normalizedLabel.id = labelId.toString();
}
if (labelColorValue) {
normalizedLabel.color = labelColorValue;
}
return normalizedLabel;
})
.filter(
(
label: SimplifiedDropdownLabel | null,
): label is SimplifiedDropdownLabel => {
return label !== null;
},
);
if (dropdownLabels.length > 0) {
option.labels = dropdownLabels as Array<DropdownOptionLabel>;
}
}
filter.filterDropdownOptions.push(option);
}
}

View File

@@ -2,11 +2,9 @@ import Analytics from "../../Utils/Analytics";
import Breadcrumbs from "../Breadcrumbs/Breadcrumbs";
import ErrorMessage from "../ErrorMessage/ErrorMessage";
import PageLoader from "../Loader/PageLoader";
import Pill from "../Pill/Pill";
import LabelElement from "../Label/Label";
import Link from "../../../Types/Link";
import Label from "../../../Models/DatabaseModels/Label";
import Color from "../../../Types/Color";
import { Black } from "../../../Types/BrandColors";
import LabelModel from "../../../Models/DatabaseModels/Label";
import React, { FunctionComponent, ReactElement, useEffect } from "react";
export interface ComponentProps {
@@ -17,7 +15,7 @@ export interface ComponentProps {
className?: string | undefined;
isLoading?: boolean | undefined;
error?: string | undefined;
labels?: Array<Label> | undefined;
labels?: Array<LabelModel> | undefined;
}
const Page: FunctionComponent<ComponentProps> = (
@@ -71,33 +69,19 @@ const Page: FunctionComponent<ComponentProps> = (
</span>
<div className="flex flex-wrap items-center gap-2 justify-end">
{props.labels
.filter((label: Label | null) => {
.filter((label: LabelModel | null) => {
return Boolean(label && (label.name || label.slug));
})
.map((label: Label, index: number) => {
const resolveColor: Color = (() => {
if (!label.color) {
return Black;
}
if (typeof label.color === "string") {
return Color.fromString(label.color);
}
return label.color;
})();
.map((label: LabelModel, index: number) => {
return (
<Pill
<LabelElement
key={
label.id?.toString() ||
label._id ||
label.slug ||
`${label.name || "label"}-${index}`
}
color={resolveColor}
text={label.name || label.slug || "Label"}
isMinimal={false}
label={label}
/>
);
})}

View File

@@ -1,5 +1,7 @@
import { Black } from "../../../Types/BrandColors";
import Color from "../../../Types/Color";
import Icon, { SizeProp, ThickProp } from "../Icon/Icon";
import IconProp from "../../../Types/Icon/IconProp";
import React, { CSSProperties, FunctionComponent, ReactElement } from "react";
import Tooltip from "../Tooltip/Tooltip";
import { GetReactElementFunction } from "../../Types/FunctionTypes";
@@ -18,6 +20,7 @@ export interface ComponentProps {
style?: CSSProperties;
isMinimal?: boolean | undefined;
tooltip?: string | undefined;
icon?: IconProp | undefined;
}
const Pill: FunctionComponent<ComponentProps> = (
@@ -47,7 +50,7 @@ const Pill: FunctionComponent<ComponentProps> = (
return (
<span
data-testid="pill"
className="rounded-full p-1 pl-3 pr-3"
className="inline-flex items-center rounded-full p-1 pl-3 pr-3"
style={{
// https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color
@@ -63,8 +66,15 @@ const Pill: FunctionComponent<ComponentProps> = (
...props.style,
}}
>
{" "}
{props.text}{" "}
{props.icon ? (
<Icon
icon={props.icon}
size={SizeProp.Small}
thick={ThickProp.Thick}
className="mr-2"
/>
) : null}
{props.text}
</span>
);
};

View File

@@ -3,6 +3,8 @@ import Navigation from "../Navigation";
import PermissionUtil from "../Permission";
import User from "../User";
import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
import HTTPMethod from "../../../Types/API/HTTPMethod";
import HTTPResponse from "../../../Types/API/HTTPResponse";
import Headers from "../../../Types/API/Headers";
import Hostname from "../../../Types/API/Hostname";
import Protocol from "../../../Types/API/Protocol";
@@ -11,14 +13,18 @@ import URL from "../../../Types/API/URL";
import Dictionary from "../../../Types/Dictionary";
import APIException from "../../../Types/Exception/ApiException";
import Exception from "../../../Types/Exception/Exception";
import { JSONObject } from "../../../Types/JSON";
import JSONFunctions from "../../../Types/JSONFunctions";
import {
UserGlobalAccessPermission,
UserTenantAccessPermission,
} from "../../../Types/Permission";
import API from "../../../Utils/API";
import API, { AuthRetryContext } from "../../../Utils/API";
import { IDENTITY_URL } from "../../Config";
class BaseAPI extends API {
private static refreshPromise: Promise<boolean> | null = null;
public constructor(protocol: Protocol, hostname: Hostname, route?: Route) {
super(protocol, hostname, route);
}
@@ -134,6 +140,38 @@ class BaseAPI extends API {
return error;
}
protected static override async tryRefreshAuth(
_context: AuthRetryContext,
): Promise<boolean> {
if (!this.refreshPromise) {
this.refreshPromise = (async () => {
const refreshUrl: URL = URL.fromString(
IDENTITY_URL.toString(),
).addRoute("/refresh-token");
const result: HTTPResponse<JSONObject> | HTTPErrorResponse =
await super.fetch<JSONObject>({
method: HTTPMethod.POST,
url: refreshUrl,
options: {
skipAuthRefresh: true,
hasAttemptedAuthRefresh: true,
},
});
if (result instanceof HTTPResponse && result.isSuccess()) {
return true;
}
return false;
})().finally(() => {
this.refreshPromise = null;
});
}
return await this.refreshPromise;
}
protected static getLoginRoute(): Route {
return new Route("/accounts/login");
}

View File

@@ -1,6 +1,7 @@
import NumberUtil from "../../Utils/Number";
import { DropdownOption } from "../Components/Dropdown/Dropdown";
import BaseModel from "../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import Color from "../../Types/Color";
type Enum<E> = Record<keyof E, number | string> & { [k: number]: string };
@@ -52,10 +53,27 @@ export default class DropdownUtil {
valueField: string;
}): Array<DropdownOption> {
return data.array.map((item: TBaseModel) => {
return {
const option: DropdownOption = {
label: item.getColumnValue(data.labelField) as string,
value: item.getColumnValue(data.valueField) as string,
};
const colorColumnName: string | null =
typeof item.getFirstColorColumn === "function"
? item.getFirstColorColumn()
: null;
if (colorColumnName) {
const color: Color | null = item.getColumnValue(
colorColumnName,
) as Color | null;
if (color) {
option.color = color;
}
}
return option;
});
}

View File

@@ -14,6 +14,8 @@ import {
TracerConfig,
WebTracerProvider,
} from "@opentelemetry/sdk-trace-web";
import type { SpanExporter } from "@opentelemetry/sdk-trace-base";
import type { SpanExporter as WebSpanExporter } from "@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/sdk-trace-base/build/src/export/SpanExporter";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import URL from "../../../Types/API/URL";
@@ -35,16 +37,17 @@ export default class Telemetry {
const provider: WebTracerProvider = new WebTracerProvider(providerConfig);
provider.addSpanProcessor(
new BatchSpanProcessor(
new OTLPTraceExporter({
url: URL.fromString(
OpenTelemetryExporterOtlpEndpoint?.toString() + "/v1/traces",
).toString(),
headers: OpenTelemetryExporterOtlpHeaders,
}),
),
);
const traceExporter: SpanExporter = new OTLPTraceExporter({
url: URL.fromString(
OpenTelemetryExporterOtlpEndpoint?.toString() + "/v1/traces",
).toString(),
headers: OpenTelemetryExporterOtlpHeaders,
}) as unknown as SpanExporter;
const webTraceExporter: WebSpanExporter =
traceExporter as unknown as WebSpanExporter;
provider.addSpanProcessor(new BatchSpanProcessor(webTraceExporter));
provider.register({
contextManager: new ZoneContextManager(),

View File

@@ -24,6 +24,8 @@ export interface RequestOptions {
// Per-request proxy agent support (Probe supplies these instead of mutating global axios defaults)
httpAgent?: HttpAgent | undefined;
httpsAgent?: HttpsAgent | undefined;
skipAuthRefresh?: boolean | undefined;
hasAttemptedAuthRefresh?: boolean | undefined;
}
export interface APIRequestOptions {
@@ -43,6 +45,18 @@ export interface APIFetchOptions {
options?: RequestOptions;
}
export interface AuthRetryContext {
error: HTTPErrorResponse;
request: {
method: HTTPMethod;
url: URL;
data?: JSONObject | JSONArray;
headers?: Headers;
params?: Dictionary<string>;
options?: RequestOptions;
};
}
export default class API {
private _protocol: Protocol = Protocol.HTTPS;
public get protocol(): Protocol {
@@ -83,6 +97,12 @@ export default class API {
}
}
protected static async tryRefreshAuth(
_context: AuthRetryContext,
): Promise<boolean> {
return false;
}
public async get<
T extends JSONObject | JSONArray | BaseModel | Array<BaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
@@ -421,14 +441,63 @@ export default class API {
return response;
} catch (e) {
const error: Error | AxiosError = e as Error | AxiosError;
let errorResponse: HTTPErrorResponse;
if (axios.isAxiosError(error)) {
// Do whatever you want with native error
errorResponse = this.getErrorResponse(error);
} else {
if (!axios.isAxiosError(error)) {
throw new APIException(error.message);
}
const errorResponse: HTTPErrorResponse = this.getErrorResponse(error);
if (
error.response?.status === 401 &&
!options?.skipAuthRefresh &&
!options?.hasAttemptedAuthRefresh
) {
const retryUrl: URL = URL.fromString(url.toString());
const requestContext: AuthRetryContext["request"] = {
method,
url: retryUrl,
};
if (data) {
requestContext.data = data;
}
if (headers) {
requestContext.headers = headers;
}
if (params) {
requestContext.params = params;
}
if (options) {
requestContext.options = options;
}
const refreshed: boolean = await this.tryRefreshAuth({
error: errorResponse,
request: requestContext,
});
if (refreshed) {
const nextOptions: RequestOptions = {
...(options || {}),
hasAttemptedAuthRefresh: true,
};
return await this.fetchInternal(
method,
retryUrl,
data,
headers,
params,
nextOptions,
);
}
}
this.handleError(errorResponse);
return errorResponse;
}

34984
Common/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,148 +1,147 @@
{
"name": "@oneuptime/common",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "cd .. && export $(grep -v '^#' config.env | xargs) && cd Common && node node_modules/.bin/jest --runInBand ./Tests --detectOpenHandles",
"test-file": "cd .. && export $(grep -v '^#' config.env | xargs) && cd Common && node node_modules/.bin/jest --runInBand $1 --detectOpenHandles",
"coverage": "jest --detectOpenHandles --coverage",
"compile": "tsc",
"clear-modules": "rm -rf node_modules && rm package-lock.json && npm install",
"dep-check": "npm install -g depcheck && depcheck ./ --skip-missing=true",
"debug:test": "cd .. && export $(grep -v '^#' config.env | xargs) && cd Common && node --inspect node_modules/.bin/jest --runInBand ./Tests --detectOpenHandles",
"debug:test-file": "cd .. && export $(grep -v '^#' config.env | xargs) && cd Common && node --inspect node_modules/.bin/jest --runInBand $1 --detectOpenHandles"
},
"author": "OneUptime <hello@oneuptime.com> (https://oneuptime.com/)",
"license": "Apache-2.0",
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^14.4.3",
"@types/cookie-parser": "^1.4.4",
"@types/cors": "^2.8.12",
"@types/ejs": "^3.1.1",
"@types/express": "^4.17.13",
"@types/jest": "^28.1.4",
"@types/json2csv": "^5.0.3",
"@types/jsonwebtoken": "^8.5.9",
"@types/node": "^17.0.45",
"@types/node-cron": "^3.0.7",
"@types/nodemailer": "^6.4.7",
"@types/react": "^18.2.38",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-big-calendar": "^1.8.5",
"@types/react-color": "^3.0.6",
"@types/react-test-renderer": "^18.0.0",
"@types/react-toggle": "^4.0.3",
"jest": "^28.1.1",
"jest-environment-jsdom": "^29.7.0",
"jest-mock-extended": "^3.0.5",
"react-test-renderer": "^18.2.0",
"sass": "^1.89.2",
"ts-jest": "^28.0.5"
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.2",
"@bull-board/express": "^5.21.4",
"@clickhouse/client": "^1.10.1",
"@elastic/elasticsearch": "^8.12.1",
"@monaco-editor/react": "^4.4.6",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.206.0",
"@opentelemetry/context-zone": "^1.25.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.52.1",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.52.1",
"@opentelemetry/id-generator-aws-xray": "^1.2.2",
"@opentelemetry/instrumentation": "^0.52.1",
"@opentelemetry/instrumentation-fetch": "^0.52.1",
"@opentelemetry/instrumentation-xml-http-request": "^0.52.1",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-logs": "^0.52.1",
"@opentelemetry/sdk-metrics": "^1.25.1",
"@opentelemetry/sdk-node": "^0.52.1",
"@opentelemetry/sdk-trace-node": "^1.25.1",
"@opentelemetry/sdk-trace-web": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.26.0",
"@remixicon/react": "^4.2.0",
"@simplewebauthn/server": "^13.2.2",
"@tippyjs/react": "^4.2.6",
"@types/archiver": "^6.0.3",
"@types/crypto-js": "^4.2.2",
"@types/qrcode": "^1.5.5",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^8.3.4",
"@types/web-push": "^3.6.4",
"acme-client": "^5.3.0",
"airtable": "^0.12.2",
"archiver": "^7.0.1",
"axios": "^1.12.0",
"botbuilder": "^4.23.3",
"bullmq": "^5.61.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cron-parser": "^4.8.1",
"crypto-js": "^4.2.0",
"dotenv": "^16.4.4",
"ejs": "^3.1.10",
"elkjs": "^0.10.0",
"esbuild": "^0.25.5",
"express": "^4.21.1",
"formik": "^2.4.6",
"history": "^5.3.0",
"ioredis": "^5.3.2",
"json2csv": "^5.0.7",
"json5": "^2.2.3",
"jsonwebtoken": "^9.0.0",
"jwt-decode": "^4.0.0",
"marked": "^12.0.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"node-cron": "^3.0.3",
"nodemailer": "^7.0.7",
"otpauth": "^9.3.1",
"pg": "^8.7.3",
"playwright": "^1.56.0",
"posthog-js": "^1.275.3",
"prop-types": "^15.8.1",
"qrcode": "^1.5.3",
"react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",
"react-big-calendar": "^1.13.0",
"react-color": "^2.19.3",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.2",
"react-error-boundary": "^4.0.13",
"react-highlight": "^0.15.0",
"react-markdown": "^8.0.3",
"react-router-dom": "^6.24.1",
"react-select": "^5.4.0",
"react-spinners": "^0.14.1",
"react-syntax-highlighter": "^16.0.0",
"react-toggle": "^4.1.3",
"reactflow": "^11.11.4",
"recharts": "^2.12.7",
"redis-semaphore": "^5.5.1",
"reflect-metadata": "^0.2.2",
"remark-gfm": "^3.0.1",
"slackify-markdown": "^4.4.0",
"slugify": "^1.6.5",
"socket.io": "^4.7.4",
"socket.io-client": "^4.7.5",
"stripe": "^10.17.0",
"tailwind-merge": "^2.5.2",
"tippy.js": "^6.3.7",
"twilio": "^4.22.0",
"typeorm": "^0.3.26",
"typeorm-extension": "^2.2.13",
"universal-cookie": "^7.2.1",
"use-async-effect": "^2.2.6",
"uuid": "^8.3.2",
"web-push": "^3.6.7",
"zod": "^3.25.76"
}
"name": "@oneuptime/common",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "cd .. && export $(grep -v '^#' config.env | xargs) && cd Common && node node_modules/.bin/jest --runInBand ./Tests --detectOpenHandles",
"test-file": "cd .. && export $(grep -v '^#' config.env | xargs) && cd Common && node node_modules/.bin/jest --runInBand $1 --detectOpenHandles",
"coverage": "jest --detectOpenHandles --coverage",
"compile": "tsc",
"clear-modules": "rm -rf node_modules && rm package-lock.json && npm install",
"dep-check": "npm install -g depcheck && depcheck ./ --skip-missing=true",
"debug:test": "cd .. && export $(grep -v '^#' config.env | xargs) && cd Common && node --inspect node_modules/.bin/jest --runInBand ./Tests --detectOpenHandles",
"debug:test-file": "cd .. && export $(grep -v '^#' config.env | xargs) && cd Common && node --inspect node_modules/.bin/jest --runInBand $1 --detectOpenHandles"
},
"author": "OneUptime <hello@oneuptime.com> (https://oneuptime.com/)",
"license": "Apache-2.0",
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^14.4.3",
"@types/cookie-parser": "^1.4.4",
"@types/cors": "^2.8.12",
"@types/ejs": "^3.1.1",
"@types/express": "^4.17.13",
"@types/jest": "^28.1.4",
"@types/json2csv": "^5.0.3",
"@types/jsonwebtoken": "^8.5.9",
"@types/node": "^17.0.45",
"@types/node-cron": "^3.0.7",
"@types/nodemailer": "^6.4.7",
"@types/react": "^18.2.38",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-big-calendar": "^1.8.5",
"@types/react-color": "^3.0.6",
"@types/react-test-renderer": "^18.0.0",
"@types/react-toggle": "^4.0.3",
"jest": "^28.1.1",
"jest-environment-jsdom": "^29.7.0",
"jest-mock-extended": "^3.0.5",
"react-test-renderer": "^18.2.0",
"sass": "^1.89.2",
"ts-jest": "^28.0.5"
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.2",
"@bull-board/express": "^5.21.4",
"@clickhouse/client": "^1.10.1",
"@elastic/elasticsearch": "^8.12.1",
"@monaco-editor/react": "^4.4.6",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.206.0",
"@opentelemetry/context-zone": "^1.25.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.207.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.207.0",
"@opentelemetry/id-generator-aws-xray": "^1.2.2",
"@opentelemetry/instrumentation": "^0.207.0",
"@opentelemetry/instrumentation-fetch": "^0.207.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.207.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-logs": "^0.207.0",
"@opentelemetry/sdk-metrics": "^1.25.1",
"@opentelemetry/sdk-node": "^0.207.0",
"@opentelemetry/sdk-trace-web": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.26.0",
"@remixicon/react": "^4.2.0",
"@simplewebauthn/server": "^13.2.2",
"@tippyjs/react": "^4.2.6",
"@types/archiver": "^6.0.3",
"@types/crypto-js": "^4.2.2",
"@types/qrcode": "^1.5.5",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^8.3.4",
"@types/web-push": "^3.6.4",
"acme-client": "^5.3.0",
"airtable": "^0.12.2",
"archiver": "^7.0.1",
"axios": "^1.12.0",
"botbuilder": "^4.23.3",
"bullmq": "^5.61.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cron-parser": "^4.8.1",
"crypto-js": "^4.2.0",
"dotenv": "^16.4.4",
"ejs": "^3.1.10",
"elkjs": "^0.10.0",
"esbuild": "^0.25.5",
"express": "^4.21.1",
"formik": "^2.4.6",
"history": "^5.3.0",
"ioredis": "^5.3.2",
"json2csv": "^5.0.7",
"json5": "^2.2.3",
"jsonwebtoken": "^9.0.0",
"jwt-decode": "^4.0.0",
"marked": "^12.0.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"node-cron": "^3.0.3",
"nodemailer": "^7.0.7",
"otpauth": "^9.3.1",
"pg": "^8.7.3",
"playwright": "^1.56.0",
"posthog-js": "^1.275.3",
"prop-types": "^15.8.1",
"qrcode": "^1.5.3",
"react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",
"react-big-calendar": "^1.13.0",
"react-color": "^2.19.3",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.2",
"react-error-boundary": "^4.0.13",
"react-highlight": "^0.15.0",
"react-markdown": "^8.0.3",
"react-router-dom": "^6.24.1",
"react-select": "^5.4.0",
"react-spinners": "^0.14.1",
"react-syntax-highlighter": "^16.0.0",
"react-toggle": "^4.1.3",
"reactflow": "^11.11.4",
"recharts": "^2.12.7",
"redis-semaphore": "^5.5.1",
"reflect-metadata": "^0.2.2",
"remark-gfm": "^3.0.1",
"slackify-markdown": "^4.4.0",
"slugify": "^1.6.5",
"socket.io": "^4.7.4",
"socket.io-client": "^4.7.5",
"stripe": "^10.17.0",
"tailwind-merge": "^2.5.2",
"tippy.js": "^6.3.7",
"twilio": "^4.22.0",
"typeorm": "^0.3.26",
"typeorm-extension": "^2.2.13",
"universal-cookie": "^7.2.1",
"use-async-effect": "^2.2.6",
"uuid": "^8.3.2",
"web-push": "^3.6.7",
"zod": "^3.25.76"
}
}

View File

@@ -35,19 +35,18 @@
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.206.0",
"@opentelemetry/context-zone": "^1.25.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.52.1",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.207.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.207.0",
"@opentelemetry/id-generator-aws-xray": "^1.2.2",
"@opentelemetry/instrumentation": "^0.52.1",
"@opentelemetry/instrumentation-fetch": "^0.52.1",
"@opentelemetry/instrumentation-xml-http-request": "^0.52.1",
"@opentelemetry/instrumentation": "^0.207.0",
"@opentelemetry/instrumentation-fetch": "^0.207.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.207.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-logs": "^0.52.1",
"@opentelemetry/sdk-logs": "^0.207.0",
"@opentelemetry/sdk-metrics": "^1.25.1",
"@opentelemetry/sdk-node": "^0.52.1",
"@opentelemetry/sdk-trace-node": "^1.25.1",
"@opentelemetry/sdk-node": "^0.207.0",
"@opentelemetry/sdk-trace-web": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.26.0",
"@remixicon/react": "^4.2.0",

View File

@@ -40,19 +40,18 @@
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.206.0",
"@opentelemetry/context-zone": "^1.25.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.52.1",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-proto": "^0.52.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.207.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.207.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.207.0",
"@opentelemetry/id-generator-aws-xray": "^1.2.2",
"@opentelemetry/instrumentation": "^0.52.1",
"@opentelemetry/instrumentation-fetch": "^0.52.1",
"@opentelemetry/instrumentation-xml-http-request": "^0.52.1",
"@opentelemetry/instrumentation": "^0.207.0",
"@opentelemetry/instrumentation-fetch": "^0.207.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.207.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-logs": "^0.52.1",
"@opentelemetry/sdk-logs": "^0.207.0",
"@opentelemetry/sdk-metrics": "^1.25.1",
"@opentelemetry/sdk-node": "^0.52.1",
"@opentelemetry/sdk-trace-node": "^1.25.1",
"@opentelemetry/sdk-node": "^0.207.0",
"@opentelemetry/sdk-trace-web": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.26.0",
"@remixicon/react": "^4.2.0",

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import AlertElement from "./Alert";
import { Black } from "Common/Types/BrandColors";

View File

@@ -0,0 +1,165 @@
import ProjectUtil from "Common/UI/Utils/Project";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import Query from "Common/Types/BaseDatabase/Query";
import AnalyticsModelTable from "Common/UI/Components/ModelTable/AnalyticsModelTable";
import ExceptionInstance from "Common/Models/AnalyticsModels/ExceptionInstance";
import FieldType from "Common/UI/Components/Types/FieldType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useMemo,
} from "react";
import TraceElement from "../Traces/TraceElement";
import SpanStatusElement from "../Span/SpanStatusElement";
import ObjectID from "Common/Types/ObjectID";
export interface ComponentProps {
title: string;
description: string;
query: Query<ExceptionInstance>;
}
const ExceptionInstanceTable: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const computedQuery: Query<ExceptionInstance> = useMemo(() => {
const query: Query<ExceptionInstance> = {
...(props.query || {}),
};
const projectId: ObjectID | null = ProjectUtil.getCurrentProjectId();
if (projectId && !query.projectId) {
query.projectId = projectId;
}
return query;
}, [props.query]);
return (
<AnalyticsModelTable<ExceptionInstance>
modelType={ExceptionInstance}
id="exception-instance-table"
name="ExceptionInstance"
singularName="Exception"
pluralName="Exceptions"
isDeleteable={false}
isEditable={false}
isCreateable={false}
isViewable={false}
userPreferencesKey="exception-instance-table"
cardProps={{
title: props.title,
description: props.description,
}}
query={computedQuery}
sortBy="time"
sortOrder={SortOrder.Descending}
noItemsMessage="No exception instances found."
showRefreshButton={true}
showViewIdButton={true}
filters={[
{
field: {
serviceId: true,
},
type: FieldType.Text,
title: "Telemetry Service",
},
{
field: {
exceptionType: true,
},
type: FieldType.Text,
title: "Exception Type",
},
{
field: {
time: true,
},
type: FieldType.DateTime,
title: "Time",
},
{
field: {
traceId: true,
},
type: FieldType.Text,
title: "Trace ID",
},
]}
selectMoreFields={{
spanStatusCode: true,
}}
columns={[
{
field: {
time: true,
},
title: "Time",
type: FieldType.DateTime,
},
{
field: {
serviceId: true,
},
title: "Telemetry Service ID",
type: FieldType.Text,
},
{
field: {
exceptionType: true,
},
title: "Exception Type",
type: FieldType.Text,
},
{
field: {
message: true,
},
title: "Message",
type: FieldType.Text,
},
{
field: {
spanId: true,
},
title: "Span",
type: FieldType.Element,
getElement: (exceptionInstance: ExceptionInstance): ReactElement => {
if (!exceptionInstance.spanId) {
return <Fragment />;
}
return (
<SpanStatusElement
traceId={exceptionInstance.traceId?.toString()}
spanStatusCode={exceptionInstance.spanStatusCode || 0}
title={exceptionInstance.spanId?.toString()}
/>
);
},
},
{
field: {
traceId: true,
},
title: "Trace",
type: FieldType.Element,
getElement: (exceptionInstance: ExceptionInstance): ReactElement => {
if (!exceptionInstance.traceId) {
return <Fragment />;
}
return (
<TraceElement traceId={exceptionInstance.traceId.toString()} />
);
},
},
]}
/>
);
};
export default ExceptionInstanceTable;

View File

@@ -0,0 +1,296 @@
import MonitorStepExceptionMonitor, {
MonitorStepExceptionMonitorUtil,
} from "Common/Types/Monitor/MonitorStepExceptionMonitor";
import TelemetryService from "Common/Models/DatabaseModels/TelemetryService";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useMemo,
useState,
} from "react";
import BasicForm from "Common/UI/Components/Forms/BasicForm";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button";
import FieldLabelElement from "Common/UI/Components/Forms/Fields/FieldLabel";
import HorizontalRule from "Common/UI/Components/HorizontalRule/HorizontalRule";
import ObjectID from "Common/Types/ObjectID";
import JSONFunctions from "Common/Types/JSONFunctions";
import Query from "Common/Types/BaseDatabase/Query";
import ExceptionInstance from "Common/Models/AnalyticsModels/ExceptionInstance";
import ExceptionInstanceTable from "../../../Exceptions/ExceptionInstanceTable";
export interface ComponentProps {
monitorStepExceptionMonitor: MonitorStepExceptionMonitor;
onMonitorStepExceptionMonitorChanged: (
monitorStepExceptionMonitor: MonitorStepExceptionMonitor,
) => void;
telemetryServices: Array<TelemetryService>;
}
type ExceptionMonitorFormValues = {
message: string;
exceptionTypesInput: string;
telemetryServiceIds: Array<string>;
includeResolved: boolean;
includeArchived: boolean;
lastXSecondsOfExceptions: number;
};
const DURATION_OPTIONS: Array<{ label: string; value: number }> = [
{ label: "Last 5 seconds", value: 5 },
{ label: "Last 10 seconds", value: 10 },
{ label: "Last 30 seconds", value: 30 },
{ label: "Last 1 minute", value: 60 },
{ label: "Last 5 minutes", value: 300 },
{ label: "Last 15 minutes", value: 900 },
{ label: "Last 30 minutes", value: 1800 },
{ label: "Last 1 hour", value: 3600 },
{ label: "Last 6 hours", value: 21600 },
{ label: "Last 12 hours", value: 43200 },
{ label: "Last 24 hours", value: 86400 },
];
type ParseExceptionTypesFunction = (input: string) => Array<string>;
const parseExceptionTypes: ParseExceptionTypesFunction = (input: string) => {
return input
.split(",")
.map((item: string): string => {
return item.trim();
})
.filter((item: string): boolean => {
return item.length > 0;
});
};
type ToFormValuesFunction = (
monitor: MonitorStepExceptionMonitor,
) => ExceptionMonitorFormValues;
const toFormValues: ToFormValuesFunction = (
monitor: MonitorStepExceptionMonitor,
) => {
return {
message: monitor.message || "",
exceptionTypesInput: monitor.exceptionTypes.join(", "),
telemetryServiceIds: monitor.telemetryServiceIds.map(
(id: ObjectID): string => {
return id.toString();
},
),
includeResolved: monitor.includeResolved || false,
includeArchived: monitor.includeArchived || false,
lastXSecondsOfExceptions:
monitor.lastXSecondsOfExceptions ||
MonitorStepExceptionMonitorUtil.getDefault().lastXSecondsOfExceptions,
};
};
type ToMonitorConfigFunction = (
values: ExceptionMonitorFormValues,
) => MonitorStepExceptionMonitor;
const toMonitorConfig: ToMonitorConfigFunction = (
values: ExceptionMonitorFormValues,
) => {
return {
telemetryServiceIds: values.telemetryServiceIds
.filter((id: string): boolean => {
return Boolean(id);
})
.map((id: string): ObjectID => {
return new ObjectID(id);
}),
exceptionTypes: parseExceptionTypes(values.exceptionTypesInput),
message: values.message || "",
includeResolved: values.includeResolved || false,
includeArchived: values.includeArchived || false,
lastXSecondsOfExceptions:
values.lastXSecondsOfExceptions ||
MonitorStepExceptionMonitorUtil.getDefault().lastXSecondsOfExceptions,
};
};
type HasAdvancedConfigurationFunction = (
monitor: MonitorStepExceptionMonitor,
) => boolean;
const hasAdvancedConfiguration: HasAdvancedConfigurationFunction = (
monitor: MonitorStepExceptionMonitor,
) => {
return (
monitor.includeResolved ||
monitor.includeArchived ||
(monitor.telemetryServiceIds && monitor.telemetryServiceIds.length > 0)
);
};
const ExceptionMonitorStepForm: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [formValues, setFormValues] = useState<ExceptionMonitorFormValues>(
toFormValues(props.monitorStepExceptionMonitor),
);
const [showAdvancedOptions, setShowAdvancedOptions] = useState<boolean>(
hasAdvancedConfiguration(props.monitorStepExceptionMonitor),
);
useEffect(() => {
setFormValues(toFormValues(props.monitorStepExceptionMonitor));
setShowAdvancedOptions(
hasAdvancedConfiguration(props.monitorStepExceptionMonitor),
);
}, [props.monitorStepExceptionMonitor]);
type HandleFormChangeFunction = (values: ExceptionMonitorFormValues) => void;
const handleFormChange: HandleFormChangeFunction = (
values: ExceptionMonitorFormValues,
) => {
setFormValues(values);
props.onMonitorStepExceptionMonitorChanged(toMonitorConfig(values));
};
const handleAdvancedToggle: () => void = (): void => {
setShowAdvancedOptions((current: boolean): boolean => {
return !current;
});
};
const previewQuery: Query<ExceptionInstance> = useMemo(() => {
const monitorConfig: MonitorStepExceptionMonitor =
toMonitorConfig(formValues);
return JSONFunctions.anyObjectToJSONObject(
MonitorStepExceptionMonitorUtil.toAnalyticsQuery(monitorConfig),
) as Query<ExceptionInstance>;
}, [formValues]);
return (
<div>
<BasicForm
id="exception-monitor-form"
hideSubmitButton={true}
initialValues={formValues}
onChange={handleFormChange}
fields={[
{
field: {
message: true,
},
fieldType: FormFieldSchemaType.Text,
title: "Filter Exception Message",
description:
"Filter exceptions that include this text in the message.",
hideOptionalLabel: true,
},
{
field: {
exceptionTypesInput: true,
},
fieldType: FormFieldSchemaType.Text,
title: "Exception Types",
description:
"Provide a comma-separated list of exception types to monitor.",
placeholder: "TypeError, NullReferenceException",
hideOptionalLabel: true,
},
{
field: {
lastXSecondsOfExceptions: true,
},
fieldType: FormFieldSchemaType.Dropdown,
dropdownOptions: DURATION_OPTIONS,
title: "Monitor exceptions for (time)",
description:
"We will evaluate exceptions generated within this time window.",
defaultValue:
MonitorStepExceptionMonitorUtil.getDefault()
.lastXSecondsOfExceptions,
hideOptionalLabel: true,
},
{
field: {
telemetryServiceIds: true,
},
fieldType: FormFieldSchemaType.MultiSelectDropdown,
dropdownOptions: props.telemetryServices.map(
(service: TelemetryService): { label: string; value: string } => {
return {
label: service.name || "Untitled Service",
value: service.id?.toString() || "",
};
},
),
title: "Filter by Telemetry Service",
description: "Select telemetry services to scope this monitor.",
hideOptionalLabel: true,
showIf: (): boolean => {
return showAdvancedOptions;
},
},
{
field: {
includeResolved: true,
},
fieldType: FormFieldSchemaType.Checkbox,
title: "Include Resolved Exceptions",
description: "When enabled, resolved exceptions will be counted.",
hideOptionalLabel: true,
showIf: (): boolean => {
return showAdvancedOptions;
},
},
{
field: {
includeArchived: true,
},
fieldType: FormFieldSchemaType.Checkbox,
title: "Include Archived Exceptions",
description:
"When enabled, archived exceptions will be included in results.",
hideOptionalLabel: true,
showIf: (): boolean => {
return showAdvancedOptions;
},
},
]}
/>
<div className="-ml-3">
<Button
buttonStyle={ButtonStyleType.SECONDARY_LINK}
title={
showAdvancedOptions
? "Hide Advanced Options"
: "Show Advanced Options"
}
onClick={handleAdvancedToggle}
/>
</div>
<div>
<HorizontalRule />
<FieldLabelElement
title="Exceptions Preview"
description={
"Here is the preview of the exceptions that will be monitored based on the filters you have set above."
}
hideOptionalLabel={true}
isHeading={true}
/>
<div className="mt-5 mb-5">
<ExceptionInstanceTable
title="Exceptions Preview"
description="Exceptions matching the current monitor filters."
query={previewQuery}
/>
</div>
</div>
</div>
);
};
export default ExceptionMonitorStepForm;

View File

@@ -66,6 +66,10 @@ import MonitorStepMetricMonitor, {
MonitorStepMetricMonitorUtil,
} from "Common/Types/Monitor/MonitorStepMetricMonitor";
import Link from "Common/UI/Components/Link/Link";
import ExceptionMonitorStepForm from "./ExceptionMonitor/ExceptionMonitorStepForm";
import MonitorStepExceptionMonitor, {
MonitorStepExceptionMonitorUtil,
} from "Common/Types/Monitor/MonitorStepExceptionMonitor";
export interface ComponentProps {
monitorStatusDropdownOptions: Array<DropdownOption>;
@@ -648,6 +652,24 @@ return {
</div>
)}
{props.monitorType === MonitorType.Exceptions && (
<div className="mt-5">
<ExceptionMonitorStepForm
monitorStepExceptionMonitor={
monitorStep.data?.exceptionMonitor ||
MonitorStepExceptionMonitorUtil.getDefault()
}
telemetryServices={telemetryServices}
onMonitorStepExceptionMonitorChanged={(
value: MonitorStepExceptionMonitor,
) => {
monitorStep.setExceptionMonitor(value);
props.onChange?.(MonitorStep.clone(monitorStep));
}}
/>
</div>
)}
{isCodeMonitor && (
<div className="mt-5">
<FieldLabelElement

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import MonitorsElement from "../../Components/Monitor/Monitors";
import ProjectUtil from "Common/UI/Utils/Project";
import IncidentElement from "./Incident";

View File

@@ -9,7 +9,7 @@ import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import ListResult from "Common/Types/BaseDatabase/ListResult";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader";
import LabelsElement from "./Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import Exception from "Common/Types/Exception/Exception";

View File

@@ -1,24 +0,0 @@
import { Black } from "Common/Types/BrandColors";
import Pill from "Common/UI/Components/Pill/Pill";
import Label from "Common/Models/DatabaseModels/Label";
import React, { FunctionComponent, ReactElement } from "react";
export interface ComponentProps {
label: Label;
}
const LabelElement: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
return (
<Pill
color={props.label.color || Black}
text={props.label.name || ""}
style={{
marginRight: "5px",
}}
/>
);
};
export default LabelElement;

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import MonitorTypeUtil from "../../Utils/MonitorType";
import ProjectUtil from "Common/UI/Utils/Project";

View File

@@ -193,9 +193,7 @@ const SummaryInfo: FunctionComponent<ComponentProps> = (
<></>
)}
{(props.monitorType === MonitorType.Logs ||
props.monitorType === MonitorType.Traces ||
props.monitorType === MonitorType.Metrics) && (
{MonitorTypeHelper.isTelemetryMonitor(props.monitorType) && (
<div className="space-y-6">
<TelemetryMonitorSummaryView
telemetryMonitorSummary={props.telemetryMonitorSummary}

View File

@@ -1,5 +1,5 @@
import ProjectUtil from "Common/UI/Utils/Project";
import LabelsElement from "../Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import MonitorsElement from "../Monitor/Monitors";
import StatusPagesElement from "../StatusPage/StatusPagesElement";
import Route from "Common/Types/API/Route";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";

View File

@@ -16,7 +16,7 @@ import IncidentSeverityElement from "../../IncidentSeverity/IncidentSeverityElem
import IncidentStateElement from "../../IncidentState/IncidentStateElement";
import ScheduledMaintenanceStateElement from "../../ScheduledMaintenanceState/ScheduledMaintenanceStateElement";
import MonitorStatusElement from "../../MonitorStatus/MonitorStatusElement";
import LabelElement from "../../Label/Label";
import LabelElement from "Common/UI/Components/Label/Label";
import MonitorElement from "../../Monitor/Monitor";
import { GetReactElementFunction } from "Common/UI/Types/FunctionTypes";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../../PageComponentProps";
import CodeRepositoryType from "Common/Types/CodeRepository/CodeRepositoryType";
import ObjectID from "Common/Types/ObjectID";

View File

@@ -1,5 +1,5 @@
import Banner from "Common/UI/Components/Banner/Banner";
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import PageMap from "../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";

View File

@@ -1,5 +1,5 @@
import ChangeAlertState from "../../../Components/Alert/ChangeState";
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import OnCallDutyPoliciesView from "../../../Components/OnCallPolicy/OnCallPolicies";
import PageComponentProps from "../../PageComponentProps";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
@@ -49,6 +49,11 @@ import HeaderAlert, {
import IconProp from "Common/Types/Icon/IconProp";
import ColorSwatch from "Common/Types/ColorSwatch";
import AlertFeedElement from "../../../Components/Alert/AlertFeed";
import ExceptionInstanceTable from "../../../Components/Exceptions/ExceptionInstanceTable";
import Query from "Common/Types/BaseDatabase/Query";
import ExceptionInstance from "Common/Models/AnalyticsModels/ExceptionInstance";
import Span from "Common/Models/AnalyticsModels/Span";
import Log from "Common/Models/AnalyticsModels/Log";
const AlertView: FunctionComponent<PageComponentProps> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID();
@@ -526,7 +531,7 @@ const AlertView: FunctionComponent<PageComponentProps> = (): ReactElement => {
<Card title={"Logs"} description={"Logs for this alert."}>
<DashboardLogsViewer
id="logs-preview"
logQuery={telemetryQuery.telemetryQuery}
logQuery={telemetryQuery.telemetryQuery as Query<Log>}
limit={10}
noLogsMessage="No logs found"
/>
@@ -538,7 +543,9 @@ const AlertView: FunctionComponent<PageComponentProps> = (): ReactElement => {
telemetryQuery.telemetryType === TelemetryType.Trace &&
telemetryQuery.telemetryQuery && (
<div>
<TraceTable spanQuery={telemetryQuery.telemetryQuery} />
<TraceTable
spanQuery={telemetryQuery.telemetryQuery as Query<Span>}
/>
</div>
)}
@@ -578,6 +585,16 @@ const AlertView: FunctionComponent<PageComponentProps> = (): ReactElement => {
</Card>
)}
{telemetryQuery &&
telemetryQuery.telemetryType === TelemetryType.Exception &&
telemetryQuery.telemetryQuery && (
<ExceptionInstanceTable
title="Exceptions"
description="Exceptions for this alert."
query={telemetryQuery.telemetryQuery as Query<ExceptionInstance>}
/>
)}
<AlertFeedElement alertId={modelId} />
</Fragment>
);

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import PageMap from "../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";

View File

@@ -45,6 +45,8 @@ import FetchMonitors from "../../Components/Monitor/FetchMonitors";
import FetchIncidentSeverities from "../../Components/IncidentSeverity/FetchIncidentSeverity";
import IncidentState from "Common/Models/DatabaseModels/IncidentState";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import Color from "Common/Types/Color";
import { DropdownOption } from "Common/UI/Components/Dropdown/Dropdown";
const IncidentCreate: FunctionComponent<
PageComponentProps
@@ -313,18 +315,24 @@ const IncidentCreate: FunctionComponent<
select: {
_id: true,
name: true,
color: true,
},
sort: {
order: SortOrder.Ascending,
},
});
return incidentStates.data.map((state: IncidentState) => {
return {
label: state.name || "",
value: state._id?.toString() || "",
};
});
return incidentStates.data.map(
(state: IncidentState): DropdownOption => {
const option: DropdownOption = {
label: state.name || "",
value: state._id?.toString() || "",
color: state.color as Color,
};
return option;
},
);
} catch {
// Silently fail and return empty array
return [];

View File

@@ -1,5 +1,5 @@
import ChangeIncidentState from "../../../Components/Incident/ChangeState";
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import MonitorsElement from "../../../Components/Monitor/Monitors";
import OnCallDutyPoliciesView from "../../../Components/OnCallPolicy/OnCallPolicies";
import SubscriberNotificationStatus from "../../../Components/StatusPageSubscribers/SubscriberNotificationStatus";
@@ -53,6 +53,11 @@ import IncidentFeedElement from "../../../Components/Incident/IncidentFeed";
import Monitor from "Common/Models/DatabaseModels/Monitor";
import MonitorStatus from "Common/Models/DatabaseModels/MonitorStatus";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
import ExceptionInstanceTable from "../../../Components/Exceptions/ExceptionInstanceTable";
import Query from "Common/Types/BaseDatabase/Query";
import Span from "Common/Models/AnalyticsModels/Span";
import Log from "Common/Models/AnalyticsModels/Log";
import ExceptionInstance from "Common/Models/AnalyticsModels/ExceptionInstance";
const IncidentView: FunctionComponent<
PageComponentProps
@@ -620,7 +625,7 @@ const IncidentView: FunctionComponent<
<Card title={"Logs"} description={"Logs for this incident."}>
<DashboardLogsViewer
id="logs-preview"
logQuery={telemetryQuery.telemetryQuery}
logQuery={telemetryQuery.telemetryQuery as Query<Log>}
limit={10}
noLogsMessage="No logs found"
/>
@@ -632,7 +637,9 @@ const IncidentView: FunctionComponent<
telemetryQuery.telemetryType === TelemetryType.Trace &&
telemetryQuery.telemetryQuery && (
<div>
<TraceTable spanQuery={telemetryQuery.telemetryQuery} />
<TraceTable
spanQuery={telemetryQuery.telemetryQuery as Query<Span>}
/>
</div>
)}
@@ -672,6 +679,16 @@ const IncidentView: FunctionComponent<
</Card>
)}
{telemetryQuery &&
telemetryQuery.telemetryType === TelemetryType.Exception &&
telemetryQuery.telemetryQuery && (
<ExceptionInstanceTable
title="Exceptions"
description="Exceptions related to this incident."
query={telemetryQuery.telemetryQuery as Query<ExceptionInstance>}
/>
)}
<IncidentFeedElement incidentId={modelId} />
</Fragment>
);

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import DisabledWarning from "../../../Components/Monitor/DisabledWarning";
import IncomingMonitorLink from "../../../Components/Monitor/IncomingRequestMonitor/IncomingMonitorLink";
import ServerMonitorDocumentation from "../../../Components/Monitor/ServerMonitor/Documentation";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import CurrentStatusElement from "../../Components/MonitorGroup/CurrentStatus";
import ProjectUtil from "Common/UI/Utils/Project";
import PageMap from "../../Utils/PageMap";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../PageComponentProps";
import URL from "Common/Types/API/URL";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";

View File

@@ -1,4 +1,4 @@
import LabelsElement from "../../Components/Label/Labels";
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import URL from "Common/Types/API/URL";

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