mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
@@ -94,9 +94,8 @@ const MoreEmail: FunctionComponent = (): ReactElement => {
|
||||
throw new Error("Failed to send emails.");
|
||||
}
|
||||
|
||||
const data: JSONObject = response.data as JSONObject;
|
||||
setSuccess(
|
||||
`Emails sent successfully. Total users: ${data["totalUsers"]}, Sent: ${data["sentCount"]}, Errors: ${data["errorCount"]}`,
|
||||
"Broadcast email job has been started. Emails will be sent in the background.",
|
||||
);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
|
||||
@@ -51,9 +51,8 @@ const SendEmail: FunctionComponent = (): ReactElement => {
|
||||
throw new Error("Failed to send emails.");
|
||||
}
|
||||
|
||||
const data: JSONObject = response.data as JSONObject;
|
||||
setSendAllSuccess(
|
||||
`Emails sent successfully. Total users: ${data["totalUsers"]}, Sent: ${data["sentCount"]}, Errors: ${data["errorCount"]}`,
|
||||
"Broadcast email job has been started. Emails will be sent in the background.",
|
||||
);
|
||||
} catch (err) {
|
||||
setSendAllError(API.getFriendlyMessage(err));
|
||||
|
||||
@@ -29,6 +29,8 @@ import ShortLinkAPI from "Common/Server/API/ShortLinkAPI";
|
||||
import StatusPageAPI from "Common/Server/API/StatusPageAPI";
|
||||
import WorkspaceNotificationRuleAPI from "Common/Server/API/WorkspaceNotificationRuleAPI";
|
||||
import WorkspaceNotificationSummaryAPI from "Common/Server/API/WorkspaceNotificationSummaryAPI";
|
||||
import DashboardAPI from "Common/Server/API/DashboardAPI";
|
||||
import DashboardDomainAPI from "Common/Server/API/DashboardDomainAPI";
|
||||
import StatusPageDomainAPI from "Common/Server/API/StatusPageDomainAPI";
|
||||
import StatusPageSubscriberAPI from "Common/Server/API/StatusPageSubscriberAPI";
|
||||
import UserCallAPI from "Common/Server/API/UserCallAPI";
|
||||
@@ -2073,6 +2075,16 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
new StatusPageDomainAPI().getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new DashboardAPI().getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new DashboardDomainAPI().getRouter(),
|
||||
);
|
||||
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new ProjectSsoAPI().getRouter(),
|
||||
|
||||
@@ -21,15 +21,37 @@ const BlankCanvasElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
if (!props.isEditMode && props.dashboardViewConfig.components.length === 0) {
|
||||
return (
|
||||
<div className="ml-1 mr-1 rounded p-10 border-2 border-gray-100 text-sm text-gray-400 text-center pt-24 pb-24">
|
||||
No components added to this dashboard. Please add one to get started.
|
||||
<div className="mx-3 mt-4 rounded-lg border border-dashed border-gray-200 bg-gray-50/50 text-center py-20 px-10">
|
||||
<div className="mx-auto w-14 h-14 rounded-full bg-white border border-gray-200 flex items-center justify-center mb-4"
|
||||
style={{ boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.04)" }}
|
||||
>
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">No widgets yet</h3>
|
||||
<p className="text-sm text-gray-400 max-w-sm mx-auto">
|
||||
Click <strong className="text-gray-500">Edit</strong> to start adding charts, values, gauges, and more to this dashboard.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// have a grid with width cols and height rows
|
||||
return (
|
||||
<div className={`grid grid-cols-${width}`}>
|
||||
<div
|
||||
className={`grid grid-cols-${width}`}
|
||||
style={
|
||||
props.isEditMode
|
||||
? {
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, #d1d5db 0.8px, transparent 0.8px)",
|
||||
backgroundSize: "20px 20px",
|
||||
borderRadius: "8px",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{Array.from(Array(height).keys()).map((_: number, index: number) => {
|
||||
return (
|
||||
<BlankRowElement
|
||||
|
||||
@@ -20,11 +20,11 @@ const BlankDashboardUnitElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
const widthOfUnitInPx: number = heightOfUnitInPx; // its a square
|
||||
|
||||
let className: string = "";
|
||||
let className: string = "transition-all duration-150";
|
||||
|
||||
if (props.isEditMode) {
|
||||
className +=
|
||||
"border-2 border-gray-100 rounded hover:border-gray-300 hover:bg-gray-100 cursor-pointer";
|
||||
" border border-dashed border-gray-200 rounded-md hover:border-gray-300 hover:bg-blue-50/30 cursor-pointer";
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -55,7 +55,7 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
|
||||
}}
|
||||
leftFooterElement={
|
||||
<Button
|
||||
title={`Delete Component`}
|
||||
title={`Delete Widget`}
|
||||
icon={IconProp.Trash}
|
||||
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
|
||||
onClick={() => {
|
||||
@@ -67,12 +67,12 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
|
||||
<>
|
||||
{showDeleteConfirmation && (
|
||||
<ConfirmModal
|
||||
title={`Delete?`}
|
||||
description={`Are you sure you want to delete this component? This action is not recoverable.`}
|
||||
title={`Delete Widget?`}
|
||||
description={`Are you sure you want to delete this widget? This action cannot be undone.`}
|
||||
onClose={() => {
|
||||
setShowDeleteConfirmation(false);
|
||||
}}
|
||||
submitButtonText={"Delete"}
|
||||
submitButtonText={"Delete Widget"}
|
||||
onSubmit={() => {
|
||||
props.onComponentDelete(component);
|
||||
setShowDeleteConfirmation(false);
|
||||
@@ -82,6 +82,16 @@ const ComponentSettingsSideOver: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Widget type indicator */}
|
||||
<div className="flex items-center gap-2 mb-4 px-1">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-600 capitalize">
|
||||
{component.componentType} Widget
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{component.widthInDashboardUnits} x {component.heightInDashboardUnits} units
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ArgumentsForm
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ComponentProps {
|
||||
telemetryAttributes: string[];
|
||||
};
|
||||
dashboardStartAndEndDate: RangeStartAndEndDateTime;
|
||||
refreshTick?: number | undefined;
|
||||
}
|
||||
|
||||
const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
@@ -138,8 +139,25 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
|
||||
const width: number = DefaultDashboardSize.widthInDashboardUnits;
|
||||
|
||||
const canvasClassName: string = props.isEditMode
|
||||
? `grid grid-cols-${width}`
|
||||
: `grid grid-cols-${width}`;
|
||||
|
||||
return (
|
||||
<div ref={dashboardCanvasRef} className={`grid grid-cols-${width}`}>
|
||||
<div
|
||||
ref={dashboardCanvasRef}
|
||||
className={canvasClassName}
|
||||
style={
|
||||
props.isEditMode
|
||||
? {
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, #d1d5db 0.8px, transparent 0.8px)",
|
||||
backgroundSize: "20px 20px",
|
||||
borderRadius: "8px",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{finalRenderedComponents}
|
||||
</div>
|
||||
);
|
||||
@@ -221,6 +239,7 @@ const DashboardCanvas: FunctionComponent<ComponentProps> = (
|
||||
updateComponent(updatedComponent);
|
||||
}}
|
||||
isSelected={isSelected}
|
||||
refreshTick={props.refreshTick}
|
||||
onClick={() => {
|
||||
// component is selected
|
||||
props.onComponentSelected(componentId);
|
||||
|
||||
@@ -2,10 +2,18 @@ import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import DashboardTextComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTextComponent";
|
||||
import DashboardChartComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardChartComponent";
|
||||
import DashboardValueComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardValueComponent";
|
||||
import DashboardTableComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTableComponent";
|
||||
import DashboardGaugeComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardGaugeComponent";
|
||||
import DashboardLogStreamComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardLogStreamComponent";
|
||||
import DashboardTraceListComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardTraceListComponent";
|
||||
import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent";
|
||||
import DashboardChartComponent from "./DashboardChartComponent";
|
||||
import DashboardValueComponent from "./DashboardValueComponent";
|
||||
import DashboardTextComponent from "./DashboardTextComponent";
|
||||
import DashboardTableComponent from "./DashboardTableComponent";
|
||||
import DashboardGaugeComponent from "./DashboardGaugeComponent";
|
||||
import DashboardLogStreamComponent from "./DashboardLogStreamComponent";
|
||||
import DashboardTraceListComponent from "./DashboardTraceListComponent";
|
||||
import DefaultDashboardSize, {
|
||||
GetDashboardComponentHeightInDashboardUnits,
|
||||
GetDashboardComponentWidthInDashboardUnits,
|
||||
@@ -37,6 +45,7 @@ export interface DashboardBaseComponentProps {
|
||||
dashboardViewConfig: DashboardViewConfig;
|
||||
dashboardStartAndEndDate: RangeStartAndEndDateTime;
|
||||
metricTypes: Array<MetricType>;
|
||||
refreshTick?: number | undefined;
|
||||
}
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
@@ -61,14 +70,18 @@ const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
|
||||
const [topInPx, setTopInPx] = React.useState<number>(0);
|
||||
const [leftInPx, setLeftInPx] = React.useState<number>(0);
|
||||
|
||||
let className: string = `relative rounded-md col-span-${widthOfComponent} row-span-${heightOfComponent} p-2 bg-white border-2 border-solid border-gray-100`;
|
||||
let className: string = `relative rounded-lg col-span-${widthOfComponent} row-span-${heightOfComponent} p-3 bg-white border border-gray-200 transition-all duration-200 overflow-hidden`;
|
||||
|
||||
if (props.isEditMode) {
|
||||
className += " cursor-pointer";
|
||||
if (props.isEditMode && !props.isSelected) {
|
||||
className += " cursor-pointer hover:border-gray-300 hover:shadow-md";
|
||||
}
|
||||
|
||||
if (props.isSelected && props.isEditMode) {
|
||||
className += " border-2 border-blue-300";
|
||||
className += " !border-blue-400 ring-2 ring-blue-50 shadow-lg shadow-blue-100/50";
|
||||
}
|
||||
|
||||
if (!props.isEditMode) {
|
||||
className += " hover:shadow-md";
|
||||
}
|
||||
|
||||
const dashboardComponentRef: React.RefObject<HTMLDivElement> =
|
||||
@@ -292,7 +305,7 @@ const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
|
||||
window.addEventListener("mousemove", resizeWidth);
|
||||
window.addEventListener("mouseup", stopResizeAndMove);
|
||||
}}
|
||||
className={`resize-width-element ${resizeCursorIcon} absolute right-0 w-2 h-12 bg-blue-300 hover:bg-blue-400 rounded-full cursor-pointer`}
|
||||
className={`resize-width-element ${resizeCursorIcon} absolute right-0 w-1.5 h-10 bg-blue-400 hover:bg-blue-500 rounded-full cursor-pointer transition-colors duration-150 opacity-70 hover:opacity-100`}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
@@ -320,7 +333,7 @@ const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
|
||||
onMouseUp={() => {
|
||||
stopResizeAndMove();
|
||||
}}
|
||||
className="move-element cursor-move absolute w-4 h-4 bg-blue-300 hover:bg-blue-400 rounded-full cursor-pointer"
|
||||
className="move-element cursor-move absolute w-4 h-4 bg-blue-400 hover:bg-blue-500 rounded-full cursor-pointer transition-colors duration-150 opacity-70 hover:opacity-100 shadow-sm"
|
||||
onDragStart={(_event: React.DragEvent<HTMLDivElement>) => {}}
|
||||
onDragEnd={(_event: React.DragEvent<HTMLDivElement>) => {}}
|
||||
></div>
|
||||
@@ -353,7 +366,7 @@ const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
|
||||
window.addEventListener("mousemove", resizeHeight);
|
||||
window.addEventListener("mouseup", stopResizeAndMove);
|
||||
}}
|
||||
className={`resize-height-element ${resizeCursorIcon} absolute bottom-0 left-0 w-12 h-2 bg-blue-300 hover:bg-blue-400 rounded-full cursor-pointer`}
|
||||
className={`resize-height-element ${resizeCursorIcon} absolute bottom-0 left-0 w-10 h-1.5 bg-blue-400 hover:bg-blue-500 rounded-full cursor-pointer transition-colors duration-150 opacity-70 hover:opacity-100`}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
@@ -373,6 +386,7 @@ const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
|
||||
widthOfComponent +
|
||||
(SpaceBetweenUnitsInPx - 2) * (widthOfComponent - 1)
|
||||
}px`,
|
||||
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.04), 0 1px 2px -1px rgba(0, 0, 0, 0.03)",
|
||||
}}
|
||||
key={component.componentId?.toString() || Math.random().toString()}
|
||||
ref={dashboardComponentRef}
|
||||
@@ -380,6 +394,15 @@ const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
|
||||
>
|
||||
{getMoveElement()}
|
||||
|
||||
{/* Component type badge - visible in edit mode */}
|
||||
{props.isEditMode && props.isSelected && (
|
||||
<div className="absolute top-1.5 right-1.5 z-10">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500 capitalize">
|
||||
{component.componentType}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{component.componentType === DashboardComponentType.Text && (
|
||||
<DashboardTextComponent
|
||||
{...props}
|
||||
@@ -404,6 +427,38 @@ const DashboardBaseComponentElement: FunctionComponent<ComponentProps> = (
|
||||
component={component as DashboardValueComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Table && (
|
||||
<DashboardTableComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardTableComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.Gauge && (
|
||||
<DashboardGaugeComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardGaugeComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.LogStream && (
|
||||
<DashboardLogStreamComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardLogStreamComponentType}
|
||||
/>
|
||||
)}
|
||||
{component.componentType === DashboardComponentType.TraceList && (
|
||||
<DashboardTraceListComponent
|
||||
{...props}
|
||||
isEditMode={props.isEditMode}
|
||||
isSelected={props.isSelected}
|
||||
component={component as DashboardTraceListComponentType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{getResizeWidthElement()}
|
||||
{getResizeHeightElement()}
|
||||
|
||||
@@ -3,12 +3,10 @@ import DashboardChartComponent from "Common/Types/Dashboard/DashboardComponents/
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import MetricCharts from "../../Metrics/MetricCharts";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import MetricUtil from "../../Metrics/Utils/Metrics";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import MetricQueryConfigData, {
|
||||
MetricChartType,
|
||||
@@ -31,10 +29,24 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
|
||||
// Resolve query configs - support both single and multi-query
|
||||
const resolveQueryConfigs: () => Array<MetricQueryConfigData> = () => {
|
||||
if (
|
||||
props.component.arguments.metricQueryConfigs &&
|
||||
props.component.arguments.metricQueryConfigs.length > 0
|
||||
) {
|
||||
return props.component.arguments.metricQueryConfigs;
|
||||
}
|
||||
if (props.component.arguments.metricQueryConfig) {
|
||||
return [props.component.arguments.metricQueryConfig];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const queryConfigs: Array<MetricQueryConfigData> = resolveQueryConfigs();
|
||||
|
||||
const metricViewData: MetricViewData = {
|
||||
queryConfigs: props.component.arguments.metricQueryConfig
|
||||
? [props.component.arguments.metricQueryConfig]
|
||||
: [],
|
||||
queryConfigs: queryConfigs,
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
),
|
||||
@@ -97,40 +109,71 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
useEffect(() => {
|
||||
fetchAggregatedResults();
|
||||
}, [props.dashboardStartAndEndDate, props.metricTypes]);
|
||||
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
|
||||
|
||||
const [metricQueryConfig, setMetricQueryConfig] = React.useState<
|
||||
MetricQueryConfigData | undefined
|
||||
>(props.component.arguments.metricQueryConfig);
|
||||
const [prevQueryConfigs, setPrevQueryConfigs] = React.useState<
|
||||
Array<MetricQueryConfigData> | MetricQueryConfigData | undefined
|
||||
>(
|
||||
props.component.arguments.metricQueryConfigs ||
|
||||
props.component.arguments.metricQueryConfig,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// set metricQueryConfig to the new value only if it is different from the previous value
|
||||
const currentConfigs:
|
||||
| Array<MetricQueryConfigData>
|
||||
| MetricQueryConfigData
|
||||
| undefined =
|
||||
props.component.arguments.metricQueryConfigs ||
|
||||
props.component.arguments.metricQueryConfig;
|
||||
|
||||
if (
|
||||
JSONFunctions.isJSONObjectDifferent(
|
||||
metricQueryConfig || {},
|
||||
props.component.arguments.metricQueryConfig || {},
|
||||
prevQueryConfigs || {},
|
||||
currentConfigs || {},
|
||||
)
|
||||
) {
|
||||
setMetricQueryConfig(props.component.arguments.metricQueryConfig);
|
||||
setPrevQueryConfigs(currentConfigs);
|
||||
fetchAggregatedResults();
|
||||
}
|
||||
}, [props.component.arguments.metricQueryConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAggregatedResults();
|
||||
}, []);
|
||||
}, [
|
||||
props.component.arguments.metricQueryConfig,
|
||||
props.component.arguments.metricQueryConfigs,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return <ComponentLoader />;
|
||||
// Skeleton loading for chart
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col p-1 animate-pulse">
|
||||
<div className="h-3 w-28 bg-gray-100 rounded mb-3"></div>
|
||||
<div className="flex-1 flex items-end gap-1 px-2 pb-2">
|
||||
{Array.from({ length: 12 }).map((_: unknown, i: number) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-gray-100 rounded-t"
|
||||
style={{
|
||||
height: `${20 + Math.random() * 60}%`,
|
||||
opacity: 0.4 + Math.random() * 0.4,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="m-auto flex flex-col justify-center w-full h-full">
|
||||
<div className="h-7 w-7 text-gray-400 w-full text-center mx-auto">
|
||||
<Icon icon={IconProp.ChartBar} />
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<div className="h-5 w-5 text-gray-300">
|
||||
<Icon icon={IconProp.ChartBar} />
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage message={error} />
|
||||
<p className="text-xs text-gray-400 text-center max-w-48">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -142,35 +185,57 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
|
||||
heightOfChart = undefined;
|
||||
}
|
||||
|
||||
// add title and description.
|
||||
|
||||
type GetMetricChartType = () => MetricChartType;
|
||||
|
||||
// Convert dashboard chart type to metric chart type
|
||||
const getMetricChartType: GetMetricChartType = (): MetricChartType => {
|
||||
if (props.component.arguments.chartType === DashboardChartType.Bar) {
|
||||
return MetricChartType.BAR;
|
||||
}
|
||||
if (
|
||||
props.component.arguments.chartType === DashboardChartType.Area ||
|
||||
props.component.arguments.chartType === DashboardChartType.StackedArea
|
||||
) {
|
||||
return MetricChartType.AREA;
|
||||
}
|
||||
return MetricChartType.LINE;
|
||||
};
|
||||
|
||||
const chartMetricViewData: MetricViewData = {
|
||||
queryConfigs: props.component.arguments.metricQueryConfig
|
||||
? [
|
||||
{
|
||||
...props.component.arguments.metricQueryConfig!,
|
||||
queryConfigs: queryConfigs.map(
|
||||
(config: MetricQueryConfigData, index: number) => {
|
||||
// For the first query, apply the chart-level title/description/legend
|
||||
if (index === 0) {
|
||||
return {
|
||||
...config,
|
||||
metricAliasData: {
|
||||
title: props.component.arguments.chartTitle || undefined,
|
||||
title:
|
||||
config.metricAliasData?.title ||
|
||||
props.component.arguments.chartTitle ||
|
||||
undefined,
|
||||
description:
|
||||
props.component.arguments.chartDescription || undefined,
|
||||
metricVariable: undefined,
|
||||
legend: props.component.arguments.legendText || undefined,
|
||||
legendUnit: props.component.arguments.legendUnit || undefined,
|
||||
config.metricAliasData?.description ||
|
||||
props.component.arguments.chartDescription ||
|
||||
undefined,
|
||||
metricVariable:
|
||||
config.metricAliasData?.metricVariable || undefined,
|
||||
legend:
|
||||
config.metricAliasData?.legend ||
|
||||
props.component.arguments.legendText ||
|
||||
undefined,
|
||||
legendUnit:
|
||||
config.metricAliasData?.legendUnit ||
|
||||
props.component.arguments.legendUnit ||
|
||||
undefined,
|
||||
},
|
||||
chartType: getMetricChartType(),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
chartType: config.chartType || getMetricChartType(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
chartType: config.chartType || getMetricChartType(),
|
||||
};
|
||||
},
|
||||
),
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
),
|
||||
@@ -178,7 +243,7 @@ const DashboardChartComponentElement: FunctionComponent<ComponentProps> = (
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<MetricCharts
|
||||
metricResults={metricResults}
|
||||
metricTypes={props.metricTypes}
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import DashboardGaugeComponent from "Common/Types/Dashboard/DashboardComponents/DashboardGaugeComponent";
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import MetricUtil from "../../Metrics/Utils/Metrics";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
|
||||
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
component: DashboardGaugeComponent;
|
||||
}
|
||||
|
||||
const DashboardGaugeComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [metricResults, setMetricResults] = React.useState<
|
||||
Array<AggregatedResult>
|
||||
>([]);
|
||||
const [aggregationType, setAggregationType] =
|
||||
React.useState<AggregationType>(AggregationType.Avg);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
|
||||
const metricViewData: MetricViewData = {
|
||||
queryConfigs: props.component.arguments.metricQueryConfig
|
||||
? [props.component.arguments.metricQueryConfig]
|
||||
: [],
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
),
|
||||
formulaConfigs: [],
|
||||
};
|
||||
|
||||
const fetchAggregatedResults: PromiseVoidFunction =
|
||||
async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
if (
|
||||
!metricViewData.startAndEndDate?.startValue ||
|
||||
!metricViewData.startAndEndDate?.endValue
|
||||
) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!metricViewData.queryConfigs ||
|
||||
metricViewData.queryConfigs.length === 0 ||
|
||||
!metricViewData.queryConfigs[0] ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData.filterData ||
|
||||
Object.keys(metricViewData.queryConfigs[0].metricQueryData.filterData)
|
||||
.length === 0
|
||||
) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!metricViewData.queryConfigs[0] ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData.filterData ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData.filterData
|
||||
?.aggegationType
|
||||
) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAggregationType(
|
||||
(metricViewData.queryConfigs[0].metricQueryData.filterData
|
||||
?.aggegationType as AggregationType) || AggregationType.Avg,
|
||||
);
|
||||
|
||||
try {
|
||||
const results: Array<AggregatedResult> = await MetricUtil.fetchResults({
|
||||
metricViewData: metricViewData,
|
||||
});
|
||||
|
||||
setMetricResults(results);
|
||||
setError("");
|
||||
} catch (err: unknown) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const [metricQueryConfig, setMetricQueryConfig] = React.useState<
|
||||
MetricQueryConfigData | undefined
|
||||
>(props.component.arguments.metricQueryConfig);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAggregatedResults();
|
||||
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
JSONFunctions.isJSONObjectDifferent(
|
||||
metricQueryConfig || {},
|
||||
props.component.arguments.metricQueryConfig || {},
|
||||
)
|
||||
) {
|
||||
setMetricQueryConfig(props.component.arguments.metricQueryConfig);
|
||||
fetchAggregatedResults();
|
||||
}
|
||||
}, [props.component.arguments.metricQueryConfig]);
|
||||
|
||||
if (isLoading) {
|
||||
// Skeleton loading for gauge
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center animate-pulse">
|
||||
<div className="h-3 w-20 bg-gray-100 rounded mb-3"></div>
|
||||
<div
|
||||
className="bg-gray-100 rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(props.dashboardComponentWidthInPx * 0.5, 120)}px`,
|
||||
height: `${Math.min(props.dashboardComponentWidthInPx * 0.25, 60)}px`,
|
||||
borderRadius: "999px 999px 0 0",
|
||||
}}
|
||||
></div>
|
||||
<div className="h-5 w-12 bg-gray-100 rounded mt-2"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-gray-300" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center max-w-40">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show setup state if no metric configured
|
||||
if (
|
||||
!props.component.arguments.metricQueryConfig ||
|
||||
!props.component.arguments.metricQueryConfig.metricQueryData?.filterData ||
|
||||
Object.keys(
|
||||
props.component.arguments.metricQueryConfig.metricQueryData.filterData,
|
||||
).length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
|
||||
<div className="w-10 h-10 rounded-full bg-emerald-50 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-emerald-300" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5V6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-gray-500">
|
||||
{props.component.arguments.gaugeTitle || "Gauge Widget"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
Click to configure metric
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate aggregated value
|
||||
let aggregatedValue: number = 0;
|
||||
let avgCount: number = 0;
|
||||
|
||||
for (const result of metricResults) {
|
||||
for (const item of result.data) {
|
||||
const value: number = item.value;
|
||||
|
||||
if (aggregationType === AggregationType.Avg) {
|
||||
aggregatedValue += value;
|
||||
avgCount += 1;
|
||||
} else if (aggregationType === AggregationType.Sum) {
|
||||
aggregatedValue += value;
|
||||
} else if (aggregationType === AggregationType.Min) {
|
||||
aggregatedValue = Math.min(aggregatedValue, value);
|
||||
} else if (aggregationType === AggregationType.Max) {
|
||||
aggregatedValue = Math.max(aggregatedValue, value);
|
||||
} else if (aggregationType === AggregationType.Count) {
|
||||
aggregatedValue += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (aggregationType === AggregationType.Avg && avgCount > 0) {
|
||||
aggregatedValue = aggregatedValue / avgCount;
|
||||
}
|
||||
|
||||
aggregatedValue = Math.round(aggregatedValue * 100) / 100;
|
||||
|
||||
const minValue: number = props.component.arguments.minValue ?? 0;
|
||||
const maxValue: number = props.component.arguments.maxValue ?? 100;
|
||||
const warningThreshold: number | undefined =
|
||||
props.component.arguments.warningThreshold;
|
||||
const criticalThreshold: number | undefined =
|
||||
props.component.arguments.criticalThreshold;
|
||||
|
||||
// Calculate percentage for the gauge arc
|
||||
const range: number = maxValue - minValue;
|
||||
const percentage: number =
|
||||
range > 0
|
||||
? Math.min(Math.max((aggregatedValue - minValue) / range, 0), 1)
|
||||
: 0;
|
||||
|
||||
// Determine color based on thresholds
|
||||
let gaugeColor: string = "#10b981"; // green
|
||||
if (
|
||||
criticalThreshold !== undefined &&
|
||||
aggregatedValue >= criticalThreshold
|
||||
) {
|
||||
gaugeColor = "#ef4444"; // red
|
||||
} else if (
|
||||
warningThreshold !== undefined &&
|
||||
aggregatedValue >= warningThreshold
|
||||
) {
|
||||
gaugeColor = "#f59e0b"; // yellow
|
||||
}
|
||||
|
||||
// SVG gauge rendering
|
||||
const size: number = Math.min(
|
||||
props.dashboardComponentWidthInPx - 40,
|
||||
props.dashboardComponentHeightInPx - 60,
|
||||
);
|
||||
const gaugeSize: number = Math.max(size, 80);
|
||||
const strokeWidth: number = Math.max(gaugeSize * 0.1, 8);
|
||||
const radius: number = (gaugeSize - strokeWidth) / 2;
|
||||
const centerX: number = gaugeSize / 2;
|
||||
const centerY: number = gaugeSize / 2;
|
||||
|
||||
// Semi-circle arc (180 degrees, from left to right)
|
||||
const startAngle: number = Math.PI;
|
||||
const endAngle: number = 0;
|
||||
const sweepAngle: number = startAngle - endAngle;
|
||||
const currentAngle: number = startAngle - sweepAngle * percentage;
|
||||
|
||||
const arcStartX: number = centerX + radius * Math.cos(startAngle);
|
||||
const arcStartY: number = centerY - radius * Math.sin(startAngle);
|
||||
const arcEndX: number = centerX + radius * Math.cos(endAngle);
|
||||
const arcEndY: number = centerY - radius * Math.sin(endAngle);
|
||||
const arcCurrentX: number = centerX + radius * Math.cos(currentAngle);
|
||||
const arcCurrentY: number = centerY - radius * Math.sin(currentAngle);
|
||||
|
||||
const backgroundPath: string = `M ${arcStartX} ${arcStartY} A ${radius} ${radius} 0 0 1 ${arcEndX} ${arcEndY}`;
|
||||
const valuePath: string = `M ${arcStartX} ${arcStartY} A ${radius} ${radius} 0 ${percentage > 0.5 ? 1 : 0} 1 ${arcCurrentX} ${arcCurrentY}`;
|
||||
|
||||
const titleHeightInPx: number = Math.min(
|
||||
Math.max(props.dashboardComponentHeightInPx * 0.1, 12),
|
||||
16,
|
||||
);
|
||||
const valueHeightInPx: number = Math.max(gaugeSize * 0.22, 16);
|
||||
|
||||
// Generate a unique gradient ID for this component instance
|
||||
const gradientId: string = `gauge-gradient-${props.componentId?.toString() || "default"}`;
|
||||
|
||||
// Threshold marker positions on arc
|
||||
type ThresholdMarker = {
|
||||
angle: number;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const thresholdMarkers: Array<ThresholdMarker> = [];
|
||||
|
||||
if (warningThreshold !== undefined && range > 0) {
|
||||
const warningPct: number = Math.min(
|
||||
Math.max((warningThreshold - minValue) / range, 0),
|
||||
1,
|
||||
);
|
||||
const warningAngle: number = startAngle - sweepAngle * warningPct;
|
||||
thresholdMarkers.push({
|
||||
angle: warningAngle,
|
||||
x: centerX + (radius + strokeWidth * 0.7) * Math.cos(warningAngle),
|
||||
y: centerY - (radius + strokeWidth * 0.7) * Math.sin(warningAngle),
|
||||
color: "#f59e0b",
|
||||
});
|
||||
}
|
||||
|
||||
if (criticalThreshold !== undefined && range > 0) {
|
||||
const criticalPct: number = Math.min(
|
||||
Math.max((criticalThreshold - minValue) / range, 0),
|
||||
1,
|
||||
);
|
||||
const criticalAngle: number = startAngle - sweepAngle * criticalPct;
|
||||
thresholdMarkers.push({
|
||||
angle: criticalAngle,
|
||||
x: centerX + (radius + strokeWidth * 0.7) * Math.cos(criticalAngle),
|
||||
y: centerY - (radius + strokeWidth * 0.7) * Math.sin(criticalAngle),
|
||||
color: "#ef4444",
|
||||
});
|
||||
}
|
||||
|
||||
const percentDisplay: number = Math.round(percentage * 100);
|
||||
|
||||
return (
|
||||
<div className="w-full text-center h-full flex flex-col items-center justify-center">
|
||||
{props.component.arguments.gaugeTitle && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: titleHeightInPx > 0 ? `${titleHeightInPx}px` : "",
|
||||
}}
|
||||
className="text-center font-medium text-gray-400 mb-2 truncate uppercase tracking-wider"
|
||||
>
|
||||
{props.component.arguments.gaugeTitle}
|
||||
</div>
|
||||
)}
|
||||
<svg
|
||||
width={gaugeSize}
|
||||
height={gaugeSize / 2 + strokeWidth + 8}
|
||||
viewBox={`0 0 ${gaugeSize} ${gaugeSize / 2 + strokeWidth + 8}`}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor={gaugeColor} stopOpacity="0.6" />
|
||||
<stop offset="50%" stopColor={gaugeColor} stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor={gaugeColor} stopOpacity="1" />
|
||||
</linearGradient>
|
||||
<filter id={`gauge-glow-${props.componentId?.toString() || "default"}`}>
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{/* Background track */}
|
||||
<path
|
||||
d={backgroundPath}
|
||||
fill="none"
|
||||
stroke="#f0f0f0"
|
||||
strokeWidth={strokeWidth + 4}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Value arc */}
|
||||
{percentage > 0 && (
|
||||
<path
|
||||
d={valuePath}
|
||||
fill="none"
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
filter={`url(#gauge-glow-${props.componentId?.toString() || "default"})`}
|
||||
/>
|
||||
)}
|
||||
{/* Threshold markers */}
|
||||
{thresholdMarkers.map(
|
||||
(marker: ThresholdMarker, index: number) => {
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={marker.x}
|
||||
cy={marker.y}
|
||||
r={3}
|
||||
fill={marker.color}
|
||||
stroke="white"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{/* Needle tip dot at current position */}
|
||||
{percentage > 0 && (
|
||||
<circle
|
||||
cx={arcCurrentX}
|
||||
cy={arcCurrentY}
|
||||
r={strokeWidth * 0.4}
|
||||
fill="white"
|
||||
stroke={gaugeColor}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
{/* Value + percentage display */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: `-${gaugeSize * 0.2}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="font-bold text-gray-900"
|
||||
style={{
|
||||
fontSize: valueHeightInPx > 0 ? `${valueHeightInPx}px` : "",
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: "-0.03em",
|
||||
}}
|
||||
>
|
||||
{aggregatedValue}
|
||||
</div>
|
||||
<div
|
||||
className="text-gray-400 font-medium"
|
||||
style={{
|
||||
fontSize: `${Math.max(valueHeightInPx * 0.45, 10)}px`,
|
||||
}}
|
||||
>
|
||||
{percentDisplay}%
|
||||
</div>
|
||||
</div>
|
||||
{/* Min/Max labels */}
|
||||
<div
|
||||
className="flex justify-between w-full px-2 mt-0.5"
|
||||
style={{ maxWidth: `${gaugeSize + 10}px` }}
|
||||
>
|
||||
<span className="text-gray-300 tabular-nums" style={{ fontSize: "10px" }}>
|
||||
{minValue}
|
||||
</span>
|
||||
<span className="text-gray-300 tabular-nums" style={{ fontSize: "10px" }}>
|
||||
{maxValue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardGaugeComponentElement;
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import DashboardLogStreamComponent from "Common/Types/Dashboard/DashboardComponents/DashboardLogStreamComponent";
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import Log from "Common/Models/AnalyticsModels/Log";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import Query from "Common/Types/BaseDatabase/Query";
|
||||
import { queryStringToFilter, LogFilter } from "Common/Types/Log/LogQueryToFilter";
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
component: DashboardLogStreamComponent;
|
||||
}
|
||||
|
||||
type SeverityColor = {
|
||||
dot: string;
|
||||
text: string;
|
||||
bg: string;
|
||||
};
|
||||
|
||||
const getSeverityColor: (severity: string) => SeverityColor = (
|
||||
severity: string,
|
||||
): SeverityColor => {
|
||||
const lower: string = severity.toLowerCase();
|
||||
if (lower === "fatal") {
|
||||
return { dot: "bg-purple-500", text: "text-purple-700", bg: "bg-purple-50" };
|
||||
}
|
||||
if (lower === "error") {
|
||||
return { dot: "bg-red-500", text: "text-red-700", bg: "bg-red-50" };
|
||||
}
|
||||
if (lower === "warning") {
|
||||
return { dot: "bg-yellow-500", text: "text-yellow-700", bg: "bg-yellow-50" };
|
||||
}
|
||||
if (lower === "information") {
|
||||
return { dot: "bg-blue-500", text: "text-blue-700", bg: "bg-blue-50" };
|
||||
}
|
||||
if (lower === "debug") {
|
||||
return { dot: "bg-gray-400", text: "text-gray-600", bg: "bg-gray-50" };
|
||||
}
|
||||
if (lower === "trace") {
|
||||
return { dot: "bg-gray-300", text: "text-gray-500", bg: "bg-gray-50" };
|
||||
}
|
||||
return { dot: "bg-gray-300", text: "text-gray-500", bg: "bg-gray-50" };
|
||||
};
|
||||
|
||||
const DashboardLogStreamComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [logs, setLogs] = React.useState<Array<Log>>([]);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
|
||||
const maxRows: number = props.component.arguments.maxRows || 50;
|
||||
|
||||
const fetchLogs: PromiseVoidFunction = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
const startAndEndDate: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
);
|
||||
|
||||
if (!startAndEndDate.startValue || !startAndEndDate.endValue) {
|
||||
setIsLoading(false);
|
||||
setError("Please select a valid start and end date.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const query: Query<Log> = {
|
||||
time: new InBetween<Date>(
|
||||
startAndEndDate.startValue,
|
||||
startAndEndDate.endValue,
|
||||
),
|
||||
} as Query<Log>;
|
||||
|
||||
// Add severity filter if set
|
||||
if (
|
||||
props.component.arguments.severityFilter &&
|
||||
props.component.arguments.severityFilter !== ""
|
||||
) {
|
||||
(query as Record<string, unknown>)["severityText"] =
|
||||
props.component.arguments.severityFilter;
|
||||
}
|
||||
|
||||
// Add body contains filter if set
|
||||
if (
|
||||
props.component.arguments.bodyContains &&
|
||||
props.component.arguments.bodyContains.trim() !== ""
|
||||
) {
|
||||
(query as Record<string, unknown>)["body"] =
|
||||
props.component.arguments.bodyContains.trim();
|
||||
}
|
||||
|
||||
// Add attribute filters if set
|
||||
if (
|
||||
props.component.arguments.attributeFilterQuery &&
|
||||
props.component.arguments.attributeFilterQuery.trim() !== ""
|
||||
) {
|
||||
const parsedFilter: LogFilter = queryStringToFilter(
|
||||
props.component.arguments.attributeFilterQuery.trim(),
|
||||
);
|
||||
|
||||
if (parsedFilter.attributes) {
|
||||
(query as Record<string, unknown>)["attributes"] =
|
||||
parsedFilter.attributes;
|
||||
}
|
||||
}
|
||||
|
||||
const listResult: ListResult<Log> =
|
||||
await AnalyticsModelAPI.getList<Log>({
|
||||
modelType: Log,
|
||||
query: query,
|
||||
limit: maxRows,
|
||||
skip: 0,
|
||||
select: {
|
||||
time: true,
|
||||
severityText: true,
|
||||
body: true,
|
||||
serviceId: true,
|
||||
traceId: true,
|
||||
spanId: true,
|
||||
attributes: true,
|
||||
},
|
||||
sort: {
|
||||
time: SortOrder.Descending,
|
||||
},
|
||||
requestOptions: {},
|
||||
});
|
||||
|
||||
setLogs(listResult.data);
|
||||
setError("");
|
||||
} catch (err: unknown) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [props.dashboardStartAndEndDate, props.refreshTick]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [
|
||||
props.component.arguments.severityFilter,
|
||||
props.component.arguments.bodyContains,
|
||||
props.component.arguments.attributeFilterQuery,
|
||||
props.component.arguments.maxRows,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex flex-col animate-pulse">
|
||||
<div className="h-3 w-24 bg-gray-100 rounded mb-3"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{Array.from({ length: 6 }).map((_: unknown, i: number) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex gap-2 items-center"
|
||||
style={{ opacity: 1 - i * 0.12 }}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 bg-gray-200 rounded-full"></div>
|
||||
<div className="h-3 w-16 bg-gray-100 rounded"></div>
|
||||
<div
|
||||
className="h-3 bg-gray-50 rounded flex-1"
|
||||
style={{ maxWidth: `${40 + Math.random() * 50}%` }}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<div className="h-5 w-5 text-gray-300">
|
||||
<Icon icon={IconProp.List} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center max-w-48">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto flex flex-col">
|
||||
{props.component.arguments.title && (
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
{props.component.arguments.title}
|
||||
</span>
|
||||
<span className="text-xs text-gray-300 tabular-nums">
|
||||
{logs.length} entries
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto rounded-md border border-gray-100">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{logs.map((log: Log, index: number) => {
|
||||
const severity: string =
|
||||
(log.severityText as string) || "Unspecified";
|
||||
const colors: SeverityColor = getSeverityColor(severity);
|
||||
const body: string = (log.body as string) || "";
|
||||
const time: Date | undefined = log.time
|
||||
? OneUptimeDate.fromString(log.time as string)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 px-3 py-1.5 hover:bg-gray-50/50 transition-colors duration-100 group"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 shrink-0 mt-0.5">
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full ${colors.dot}`}
|
||||
></div>
|
||||
<span
|
||||
className={`text-xs font-medium ${colors.text} ${colors.bg} px-1 py-0.5 rounded w-12 text-center`}
|
||||
style={{ fontSize: "10px" }}
|
||||
>
|
||||
{severity.substring(0, 4).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{time && (
|
||||
<span
|
||||
className="text-xs text-gray-400 shrink-0 tabular-nums"
|
||||
style={{ fontSize: "11px" }}
|
||||
>
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(time, true)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-xs text-gray-600 truncate flex-1 font-mono"
|
||||
style={{ fontSize: "11px" }}
|
||||
>
|
||||
{body}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{logs.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-gray-400 text-sm">
|
||||
No logs found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardLogStreamComponentElement;
|
||||
@@ -0,0 +1,252 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import DashboardTableComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTableComponent";
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import AggregatedModel from "Common/Types/BaseDatabase/AggregatedModel";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import MetricUtil from "../../Metrics/Utils/Metrics";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
component: DashboardTableComponent;
|
||||
}
|
||||
|
||||
const DashboardTableComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [metricResults, setMetricResults] = React.useState<
|
||||
Array<AggregatedResult>
|
||||
>([]);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
|
||||
const metricViewData: MetricViewData = {
|
||||
queryConfigs: props.component.arguments.metricQueryConfig
|
||||
? [props.component.arguments.metricQueryConfig]
|
||||
: [],
|
||||
startAndEndDate: RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
),
|
||||
formulaConfigs: [],
|
||||
};
|
||||
|
||||
const fetchAggregatedResults: PromiseVoidFunction =
|
||||
async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
if (
|
||||
!metricViewData.startAndEndDate?.startValue ||
|
||||
!metricViewData.startAndEndDate?.endValue
|
||||
) {
|
||||
setIsLoading(false);
|
||||
setError("Please select a valid start and end date.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!metricViewData.queryConfigs ||
|
||||
metricViewData.queryConfigs.length === 0 ||
|
||||
!metricViewData.queryConfigs[0] ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData.filterData ||
|
||||
Object.keys(metricViewData.queryConfigs[0].metricQueryData.filterData)
|
||||
.length === 0
|
||||
) {
|
||||
setIsLoading(false);
|
||||
setError("Please select a metric. Click here to add a metric.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!metricViewData.queryConfigs[0] ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData.filterData ||
|
||||
!metricViewData.queryConfigs[0].metricQueryData.filterData
|
||||
?.aggegationType
|
||||
) {
|
||||
setIsLoading(false);
|
||||
setError(
|
||||
"Please select an aggregation. Click here to add an aggregation.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results: Array<AggregatedResult> = await MetricUtil.fetchResults({
|
||||
metricViewData: metricViewData,
|
||||
});
|
||||
|
||||
setMetricResults(results);
|
||||
setError("");
|
||||
} catch (err: unknown) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAggregatedResults();
|
||||
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
|
||||
|
||||
const [metricQueryConfig, setMetricQueryConfig] = React.useState<
|
||||
MetricQueryConfigData | undefined
|
||||
>(props.component.arguments.metricQueryConfig);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
JSONFunctions.isJSONObjectDifferent(
|
||||
metricQueryConfig || {},
|
||||
props.component.arguments.metricQueryConfig || {},
|
||||
)
|
||||
) {
|
||||
setMetricQueryConfig(props.component.arguments.metricQueryConfig);
|
||||
fetchAggregatedResults();
|
||||
}
|
||||
}, [props.component.arguments.metricQueryConfig]);
|
||||
|
||||
if (isLoading) {
|
||||
// Skeleton loading for table
|
||||
return (
|
||||
<div className="h-full flex flex-col animate-pulse">
|
||||
<div className="h-3 w-24 bg-gray-100 rounded mb-3"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex gap-4">
|
||||
<div className="h-3 w-32 bg-gray-100 rounded"></div>
|
||||
<div className="h-3 w-16 bg-gray-100 rounded ml-auto"></div>
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_: unknown, i: number) => {
|
||||
return (
|
||||
<div key={i} className="flex gap-4" style={{ opacity: 1 - i * 0.15 }}>
|
||||
<div className="h-3 w-28 bg-gray-50 rounded"></div>
|
||||
<div className="h-3 w-14 bg-gray-50 rounded ml-auto"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<div className="h-5 w-5 text-gray-300">
|
||||
<Icon icon={IconProp.TableCells} />
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxRows: number = props.component.arguments.maxRows || 20;
|
||||
|
||||
const allData: Array<AggregatedModel> = [];
|
||||
for (const result of metricResults) {
|
||||
for (const item of result.data) {
|
||||
allData.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const displayData: Array<AggregatedModel> = allData.slice(0, maxRows);
|
||||
|
||||
// Calculate max value for bar visualization
|
||||
const maxDataValue: number =
|
||||
displayData.length > 0
|
||||
? Math.max(
|
||||
...displayData.map((item: AggregatedModel) => {
|
||||
return Math.abs(item.value);
|
||||
}),
|
||||
)
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto flex flex-col">
|
||||
{props.component.arguments.tableTitle && (
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
{props.component.arguments.tableTitle}
|
||||
</span>
|
||||
<span className="text-xs text-gray-300 tabular-nums">
|
||||
{displayData.length} rows
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto rounded-md border border-gray-100">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs text-gray-400 uppercase bg-gray-50/80 sticky top-0 border-b border-gray-100">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 font-medium tracking-wider" style={{ width: "45%" }}>
|
||||
Timestamp
|
||||
</th>
|
||||
<th className="px-4 py-2.5 font-medium tracking-wider text-right" style={{ width: "25%" }}>
|
||||
Value
|
||||
</th>
|
||||
<th className="px-4 py-2.5 font-medium tracking-wider" style={{ width: "30%" }}>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{displayData.map((item: AggregatedModel, index: number) => {
|
||||
const roundedValue: number =
|
||||
Math.round(item.value * 100) / 100;
|
||||
const barWidth: number =
|
||||
maxDataValue > 0
|
||||
? (Math.abs(roundedValue) / maxDataValue) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className="hover:bg-gray-50/50 transition-colors duration-100 group"
|
||||
>
|
||||
<td className="px-4 py-2 text-gray-500 text-xs">
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(
|
||||
OneUptimeDate.fromString(item.timestamp),
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-semibold text-gray-900 text-right tabular-nums text-xs">
|
||||
{roundedValue}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="w-full h-3 bg-gray-50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${barWidth}%`,
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(99, 102, 241, 0.2) 0%, rgba(99, 102, 241, 0.4) 100%)",
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{displayData.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-4 py-8 text-center text-gray-400 text-sm"
|
||||
>
|
||||
No data available
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTableComponentElement;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import DashboardTextComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTextComponent";
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import LazyMarkdownViewer from "Common/UI/Components/Markdown.tsx/LazyMarkdownViewer";
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
component: DashboardTextComponent;
|
||||
@@ -9,18 +10,28 @@ export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
const DashboardTextComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const textClassName: string = `m-auto truncate flex flex-col justify-center h-full ${props.component.arguments.isBold ? "font-medium" : ""} ${props.component.arguments.isItalic ? "italic" : ""} ${props.component.arguments.isUnderline ? "underline" : ""}`;
|
||||
const textHeightInxPx: number = props.dashboardComponentHeightInPx * 0.4;
|
||||
if (props.component.arguments.isMarkdown) {
|
||||
return (
|
||||
<div className="h-full overflow-auto p-2">
|
||||
<LazyMarkdownViewer text={props.component.arguments.text || ""} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const textClassName: string = `flex items-center justify-center h-full text-gray-800 leading-snug ${props.component.arguments.isBold ? "font-semibold" : "font-normal"} ${props.component.arguments.isItalic ? "italic" : ""} ${props.component.arguments.isUnderline ? "underline decoration-gray-300 underline-offset-4" : ""}`;
|
||||
const textHeightInxPx: number = Math.min(props.dashboardComponentHeightInPx * 0.35, 64);
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="h-full px-2">
|
||||
<div
|
||||
className={textClassName}
|
||||
style={{
|
||||
fontSize: textHeightInxPx > 0 ? `${textHeightInxPx}px` : "",
|
||||
}}
|
||||
>
|
||||
{props.component.arguments.text}
|
||||
{props.component.arguments.text || (
|
||||
<span className="text-gray-300 text-sm">No text configured</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import DashboardTraceListComponent from "Common/Types/Dashboard/DashboardComponents/DashboardTraceListComponent";
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import InBetween from "Common/Types/BaseDatabase/InBetween";
|
||||
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import Query from "Common/Types/BaseDatabase/Query";
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
component: DashboardTraceListComponent;
|
||||
}
|
||||
|
||||
type StatusStyle = {
|
||||
label: string;
|
||||
textClass: string;
|
||||
bgClass: string;
|
||||
};
|
||||
|
||||
const getStatusStyle: (statusCode: number) => StatusStyle = (
|
||||
statusCode: number,
|
||||
): StatusStyle => {
|
||||
if (statusCode === SpanStatus.Error) {
|
||||
return {
|
||||
label: "Error",
|
||||
textClass: "text-red-700",
|
||||
bgClass: "bg-red-50 border-red-100",
|
||||
};
|
||||
}
|
||||
if (statusCode === SpanStatus.Ok) {
|
||||
return {
|
||||
label: "Ok",
|
||||
textClass: "text-green-700",
|
||||
bgClass: "bg-green-50 border-green-100",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Unset",
|
||||
textClass: "text-gray-500",
|
||||
bgClass: "bg-gray-50 border-gray-100",
|
||||
};
|
||||
};
|
||||
|
||||
const formatDuration: (durationNano: number) => string = (
|
||||
durationNano: number,
|
||||
): string => {
|
||||
if (durationNano < 1000) {
|
||||
return `${durationNano}ns`;
|
||||
}
|
||||
const durationMicro: number = durationNano / 1000;
|
||||
if (durationMicro < 1000) {
|
||||
return `${Math.round(durationMicro)}µs`;
|
||||
}
|
||||
const durationMs: number = durationMicro / 1000;
|
||||
if (durationMs < 1000) {
|
||||
return `${Math.round(durationMs * 10) / 10}ms`;
|
||||
}
|
||||
const durationS: number = durationMs / 1000;
|
||||
return `${Math.round(durationS * 100) / 100}s`;
|
||||
};
|
||||
|
||||
const DashboardTraceListComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [spans, setSpans] = React.useState<Array<Span>>([]);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
|
||||
const maxRows: number = props.component.arguments.maxRows || 50;
|
||||
|
||||
const fetchTraces: PromiseVoidFunction = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
|
||||
const startAndEndDate: InBetween<Date> =
|
||||
RangeStartAndEndDateTimeUtil.getStartAndEndDate(
|
||||
props.dashboardStartAndEndDate,
|
||||
);
|
||||
|
||||
if (!startAndEndDate.startValue || !startAndEndDate.endValue) {
|
||||
setIsLoading(false);
|
||||
setError("Please select a valid start and end date.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const query: Query<Span> = {
|
||||
startTime: new InBetween<Date>(
|
||||
startAndEndDate.startValue,
|
||||
startAndEndDate.endValue,
|
||||
),
|
||||
} as Query<Span>;
|
||||
|
||||
// Add status filter if set
|
||||
if (
|
||||
props.component.arguments.statusFilter &&
|
||||
props.component.arguments.statusFilter !== ""
|
||||
) {
|
||||
(query as Record<string, unknown>)["statusCode"] = parseInt(
|
||||
props.component.arguments.statusFilter,
|
||||
);
|
||||
}
|
||||
|
||||
const listResult: ListResult<Span> =
|
||||
await AnalyticsModelAPI.getList<Span>({
|
||||
modelType: Span,
|
||||
query: query,
|
||||
limit: maxRows,
|
||||
skip: 0,
|
||||
select: {
|
||||
startTime: true,
|
||||
name: true,
|
||||
statusCode: true,
|
||||
durationUnixNano: true,
|
||||
traceId: true,
|
||||
spanId: true,
|
||||
kind: true,
|
||||
serviceId: true,
|
||||
},
|
||||
sort: {
|
||||
startTime: SortOrder.Descending,
|
||||
},
|
||||
requestOptions: {},
|
||||
});
|
||||
|
||||
setSpans(listResult.data);
|
||||
setError("");
|
||||
} catch (err: unknown) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTraces();
|
||||
}, [props.dashboardStartAndEndDate, props.refreshTick]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTraces();
|
||||
}, [
|
||||
props.component.arguments.statusFilter,
|
||||
props.component.arguments.maxRows,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex flex-col animate-pulse">
|
||||
<div className="h-3 w-24 bg-gray-100 rounded mb-3"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex gap-4">
|
||||
<div className="h-3 w-32 bg-gray-100 rounded"></div>
|
||||
<div className="h-3 w-16 bg-gray-100 rounded"></div>
|
||||
<div className="h-3 w-12 bg-gray-100 rounded ml-auto"></div>
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_: unknown, i: number) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex gap-4"
|
||||
style={{ opacity: 1 - i * 0.15 }}
|
||||
>
|
||||
<div className="h-3 w-28 bg-gray-50 rounded"></div>
|
||||
<div className="h-3 w-14 bg-gray-50 rounded"></div>
|
||||
<div className="h-3 w-10 bg-gray-50 rounded ml-auto"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<div className="h-5 w-5 text-gray-300">
|
||||
<Icon icon={IconProp.Activity} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center max-w-48">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto flex flex-col">
|
||||
{props.component.arguments.title && (
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
{props.component.arguments.title}
|
||||
</span>
|
||||
<span className="text-xs text-gray-300 tabular-nums">
|
||||
{spans.length} traces
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto rounded-md border border-gray-100">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs text-gray-400 uppercase bg-gray-50/80 sticky top-0 border-b border-gray-100">
|
||||
<tr>
|
||||
<th
|
||||
className="px-3 py-2.5 font-medium tracking-wider"
|
||||
style={{ width: "35%" }}
|
||||
>
|
||||
Span Name
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2.5 font-medium tracking-wider"
|
||||
style={{ width: "20%" }}
|
||||
>
|
||||
Duration
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2.5 font-medium tracking-wider"
|
||||
style={{ width: "15%" }}
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-2.5 font-medium tracking-wider"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
Time
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{spans.map((span: Span, index: number) => {
|
||||
const statusCode: number =
|
||||
(span.statusCode as number) || SpanStatus.Unset;
|
||||
const statusStyle: StatusStyle = getStatusStyle(statusCode);
|
||||
const durationNano: number =
|
||||
(span.durationUnixNano as number) || 0;
|
||||
const startTime: Date | undefined = span.startTime
|
||||
? OneUptimeDate.fromString(span.startTime as string)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className="hover:bg-gray-50/50 transition-colors duration-100 group"
|
||||
>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 font-mono truncate">
|
||||
{(span.name as string) || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600 tabular-nums font-medium">
|
||||
{formatDuration(durationNano)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium border ${statusStyle.textClass} ${statusStyle.bgClass}`}
|
||||
style={{ fontSize: "10px" }}
|
||||
>
|
||||
{statusStyle.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 tabular-nums">
|
||||
{startTime
|
||||
? OneUptimeDate.getDateAsLocalFormattedString(
|
||||
startTime,
|
||||
true,
|
||||
)
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{spans.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-4 py-8 text-center text-gray-400 text-sm"
|
||||
>
|
||||
No traces found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardTraceListComponentElement;
|
||||
@@ -1,24 +1,89 @@
|
||||
import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
import { DashboardBaseComponentProps } from "./DashboardBaseComponent";
|
||||
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import AggregatedModel from "Common/Types/BaseDatabase/AggregatedModel";
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import MetricViewData from "Common/Types/Metrics/MetricViewData";
|
||||
import MetricUtil from "../../Metrics/Utils/Metrics";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import DashboardValueComponent from "Common/Types/Dashboard/DashboardComponents/DashboardValueComponent";
|
||||
import DashboardValueComponentType from "Common/Types/Dashboard/DashboardComponents/DashboardValueComponent";
|
||||
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
|
||||
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import Icon from "Common/UI/Components/Icon/Icon";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { RangeStartAndEndDateTimeUtil } from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
|
||||
export interface ComponentProps extends DashboardBaseComponentProps {
|
||||
component: DashboardValueComponent;
|
||||
component: DashboardValueComponentType;
|
||||
}
|
||||
|
||||
const DashboardValueComponent: FunctionComponent<ComponentProps> = (
|
||||
// Mini sparkline SVG component
|
||||
interface SparklineProps {
|
||||
data: Array<number>;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
fillColor: string;
|
||||
}
|
||||
|
||||
const Sparkline: FunctionComponent<SparklineProps> = (
|
||||
sparklineProps: SparklineProps,
|
||||
): ReactElement => {
|
||||
if (sparklineProps.data.length < 2) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const dataPoints: Array<number> = sparklineProps.data;
|
||||
const minVal: number = Math.min(...dataPoints);
|
||||
const maxVal: number = Math.max(...dataPoints);
|
||||
const range: number = maxVal - minVal || 1;
|
||||
const padding: number = 2;
|
||||
|
||||
const points: string = dataPoints
|
||||
.map((value: number, index: number) => {
|
||||
const x: number =
|
||||
padding +
|
||||
(index / (dataPoints.length - 1)) *
|
||||
(sparklineProps.width - padding * 2);
|
||||
const y: number =
|
||||
sparklineProps.height -
|
||||
padding -
|
||||
((value - minVal) / range) * (sparklineProps.height - padding * 2);
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
// Create fill area path
|
||||
const firstX: number = padding;
|
||||
const lastX: number =
|
||||
padding +
|
||||
((dataPoints.length - 1) / (dataPoints.length - 1)) *
|
||||
(sparklineProps.width - padding * 2);
|
||||
const fillPoints: string = `${firstX},${sparklineProps.height} ${points} ${lastX},${sparklineProps.height}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={sparklineProps.width}
|
||||
height={sparklineProps.height}
|
||||
viewBox={`0 0 ${sparklineProps.width} ${sparklineProps.height}`}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<polygon points={fillPoints} fill={sparklineProps.fillColor} />
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={sparklineProps.color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardValueComponentElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [metricResults, setMetricResults] = React.useState<
|
||||
@@ -99,14 +164,9 @@ const DashboardValueComponent: FunctionComponent<ComponentProps> = (
|
||||
|
||||
useEffect(() => {
|
||||
fetchAggregatedResults();
|
||||
}, [props.dashboardStartAndEndDate, props.metricTypes]);
|
||||
}, [props.dashboardStartAndEndDate, props.metricTypes, props.refreshTick]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAggregatedResults();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// set metricQueryConfig to the new value only if it is different from the previous value
|
||||
if (
|
||||
JSONFunctions.isJSONObjectDifferent(
|
||||
metricQueryConfig || {},
|
||||
@@ -119,39 +179,89 @@ const DashboardValueComponent: FunctionComponent<ComponentProps> = (
|
||||
}, [props.component.arguments.metricQueryConfig]);
|
||||
|
||||
if (isLoading) {
|
||||
return <ComponentLoader />;
|
||||
// Skeleton loading state
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center rounded-md animate-pulse">
|
||||
<div className="h-3 w-16 bg-gray-100 rounded mb-3"></div>
|
||||
<div className="h-8 w-24 bg-gray-100 rounded mb-2"></div>
|
||||
<div className="h-6 w-32 bg-gray-50 rounded mt-1"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<div className="h-5 w-5 text-gray-300">
|
||||
<Icon icon={IconProp.ChartBar} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center max-w-40">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let heightOfText: number | undefined =
|
||||
(props.dashboardComponentHeightInPx || 0) - 100;
|
||||
// Show setup state if no metric configured
|
||||
if (
|
||||
!props.component.arguments.metricQueryConfig ||
|
||||
!props.component.arguments.metricQueryConfig.metricQueryData?.filterData ||
|
||||
Object.keys(
|
||||
props.component.arguments.metricQueryConfig.metricQueryData.filterData,
|
||||
).length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-1.5">
|
||||
<div className="w-10 h-10 rounded-full bg-indigo-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-indigo-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-gray-500">
|
||||
{props.component.arguments.title || "Value Widget"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
Click to configure metric
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (heightOfText < 0) {
|
||||
heightOfText = undefined;
|
||||
// Collect all data points for sparkline and aggregation
|
||||
const allDataPoints: Array<AggregatedModel> = [];
|
||||
for (const result of metricResults) {
|
||||
for (const item of result.data) {
|
||||
allDataPoints.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let aggregatedValue: number = 0;
|
||||
let avgCount: number = 0;
|
||||
|
||||
for (const result of metricResults) {
|
||||
for (const item of result.data) {
|
||||
const value: number = item.value;
|
||||
for (const item of allDataPoints) {
|
||||
const value: number = item.value;
|
||||
|
||||
if (aggregationType === AggregationType.Avg) {
|
||||
aggregatedValue += value;
|
||||
avgCount += 1;
|
||||
} else if (aggregationType === AggregationType.Sum) {
|
||||
aggregatedValue += value;
|
||||
} else if (aggregationType === AggregationType.Min) {
|
||||
aggregatedValue = Math.min(aggregatedValue, value);
|
||||
} else if (aggregationType === AggregationType.Max) {
|
||||
aggregatedValue = Math.max(aggregatedValue, value);
|
||||
} else if (aggregationType === AggregationType.Count) {
|
||||
aggregatedValue += 1;
|
||||
}
|
||||
if (aggregationType === AggregationType.Avg) {
|
||||
aggregatedValue += value;
|
||||
avgCount += 1;
|
||||
} else if (aggregationType === AggregationType.Sum) {
|
||||
aggregatedValue += value;
|
||||
} else if (aggregationType === AggregationType.Min) {
|
||||
aggregatedValue = Math.min(aggregatedValue, value);
|
||||
} else if (aggregationType === AggregationType.Max) {
|
||||
aggregatedValue = Math.max(aggregatedValue, value);
|
||||
} else if (aggregationType === AggregationType.Count) {
|
||||
aggregatedValue += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,8 +272,17 @@ const DashboardValueComponent: FunctionComponent<ComponentProps> = (
|
||||
// round to 2 decimal places
|
||||
aggregatedValue = Math.round(aggregatedValue * 100) / 100;
|
||||
|
||||
const valueHeightInPx: number = props.dashboardComponentHeightInPx * 0.4;
|
||||
const titleHeightInPx: number = props.dashboardComponentHeightInPx * 0.13;
|
||||
// Sparkline data - take raw values in order
|
||||
const sparklineData: Array<number> = allDataPoints.map(
|
||||
(item: AggregatedModel) => {
|
||||
return item.value;
|
||||
},
|
||||
);
|
||||
|
||||
const valueHeightInPx: number = props.dashboardComponentHeightInPx * 0.35;
|
||||
const titleHeightInPx: number = props.dashboardComponentHeightInPx * 0.11;
|
||||
const showSparkline: boolean =
|
||||
sparklineData.length >= 2 && props.dashboardComponentHeightInPx > 100;
|
||||
|
||||
const unit: string | undefined =
|
||||
props.metricTypes?.find((item: MetricType) => {
|
||||
@@ -173,27 +292,144 @@ const DashboardValueComponent: FunctionComponent<ComponentProps> = (
|
||||
);
|
||||
})?.unit || "";
|
||||
|
||||
// Determine color based on thresholds
|
||||
let valueColorClass: string = "text-gray-900";
|
||||
let bgStyle: React.CSSProperties = {};
|
||||
let sparklineColor: string = "#6366f1"; // indigo
|
||||
let sparklineFill: string = "rgba(99, 102, 241, 0.08)";
|
||||
const warningThreshold: number | undefined =
|
||||
props.component.arguments.warningThreshold;
|
||||
const criticalThreshold: number | undefined =
|
||||
props.component.arguments.criticalThreshold;
|
||||
|
||||
if (
|
||||
criticalThreshold !== undefined &&
|
||||
aggregatedValue >= criticalThreshold
|
||||
) {
|
||||
valueColorClass = "text-red-600";
|
||||
bgStyle = {
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(254, 226, 226, 0.4) 0%, rgba(254, 202, 202, 0.2) 100%)",
|
||||
};
|
||||
sparklineColor = "#ef4444";
|
||||
sparklineFill = "rgba(239, 68, 68, 0.08)";
|
||||
} else if (
|
||||
warningThreshold !== undefined &&
|
||||
aggregatedValue >= warningThreshold
|
||||
) {
|
||||
valueColorClass = "text-amber-600";
|
||||
bgStyle = {
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(254, 243, 199, 0.4) 0%, rgba(253, 230, 138, 0.2) 100%)",
|
||||
};
|
||||
sparklineColor = "#f59e0b";
|
||||
sparklineFill = "rgba(245, 158, 11, 0.08)";
|
||||
}
|
||||
|
||||
// Calculate trend (compare first half avg to second half avg)
|
||||
let trendPercent: number | null = null;
|
||||
let trendDirection: "up" | "down" | "flat" = "flat";
|
||||
|
||||
if (sparklineData.length >= 4) {
|
||||
const midpoint: number = Math.floor(sparklineData.length / 2);
|
||||
const firstHalf: Array<number> = sparklineData.slice(0, midpoint);
|
||||
const secondHalf: Array<number> = sparklineData.slice(midpoint);
|
||||
const firstAvg: number =
|
||||
firstHalf.reduce((a: number, b: number) => {
|
||||
return a + b;
|
||||
}, 0) / firstHalf.length;
|
||||
const secondAvg: number =
|
||||
secondHalf.reduce((a: number, b: number) => {
|
||||
return a + b;
|
||||
}, 0) / secondHalf.length;
|
||||
|
||||
if (firstAvg !== 0) {
|
||||
trendPercent =
|
||||
Math.round(((secondAvg - firstAvg) / Math.abs(firstAvg)) * 1000) / 10;
|
||||
trendDirection =
|
||||
trendPercent > 0.5 ? "up" : trendPercent < -0.5 ? "down" : "flat";
|
||||
}
|
||||
}
|
||||
|
||||
const sparklineWidth: number = Math.min(
|
||||
props.dashboardComponentWidthInPx * 0.6,
|
||||
120,
|
||||
);
|
||||
const sparklineHeight: number = Math.min(
|
||||
props.dashboardComponentHeightInPx * 0.18,
|
||||
30,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full text-center h-full m-auto">
|
||||
<div
|
||||
style={{
|
||||
fontSize: titleHeightInPx > 0 ? `${titleHeightInPx}px` : "",
|
||||
}}
|
||||
className="text-center text-bold mb-1 truncate"
|
||||
>
|
||||
{props.component.arguments.title || " "}
|
||||
<div
|
||||
className="w-full h-full flex flex-col items-center justify-center rounded-md relative overflow-hidden"
|
||||
style={bgStyle}
|
||||
>
|
||||
{/* Title */}
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span
|
||||
style={{
|
||||
fontSize:
|
||||
titleHeightInPx > 0
|
||||
? `${Math.max(Math.min(titleHeightInPx, 14), 11)}px`
|
||||
: "12px",
|
||||
}}
|
||||
className="text-center font-medium text-gray-400 truncate uppercase tracking-wider"
|
||||
>
|
||||
{props.component.arguments.title || " "}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div
|
||||
className="text-center text-semibold truncate"
|
||||
className={`text-center font-bold truncate ${valueColorClass}`}
|
||||
style={{
|
||||
fontSize: valueHeightInPx > 0 ? `${valueHeightInPx}px` : "",
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: "-0.03em",
|
||||
}}
|
||||
>
|
||||
{aggregatedValue || "0"}
|
||||
{unit}
|
||||
<span
|
||||
className="text-gray-400 font-normal"
|
||||
style={{
|
||||
fontSize:
|
||||
valueHeightInPx > 0 ? `${valueHeightInPx * 0.3}px` : "",
|
||||
}}
|
||||
>
|
||||
{unit ? ` ${unit}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Trend indicator */}
|
||||
{trendPercent !== null && trendDirection !== "flat" && (
|
||||
<div
|
||||
className={`flex items-center gap-0.5 mt-0.5 ${
|
||||
trendDirection === "up" ? "text-emerald-500" : "text-red-500"
|
||||
}`}
|
||||
style={{ fontSize: `${Math.max(Math.min(titleHeightInPx, 12), 10)}px` }}
|
||||
>
|
||||
<span>{trendDirection === "up" ? "\u2191" : "\u2193"}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{Math.abs(trendPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sparkline */}
|
||||
{showSparkline && (
|
||||
<div className="mt-1">
|
||||
<Sparkline
|
||||
data={sparklineData}
|
||||
width={sparklineWidth}
|
||||
height={sparklineHeight}
|
||||
color={sparklineColor}
|
||||
fillColor={sparklineFill}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardValueComponent;
|
||||
export default DashboardValueComponentElement;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -9,12 +10,19 @@ import DashboardToolbar from "./Toolbar/DashboardToolbar";
|
||||
import DashboardCanvas from "./Canvas/Index";
|
||||
import DashboardMode from "Common/Types/Dashboard/DashboardMode";
|
||||
import DashboardComponentType from "Common/Types/Dashboard/DashboardComponentType";
|
||||
import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import DashboardViewConfig, {
|
||||
AutoRefreshInterval,
|
||||
getAutoRefreshIntervalInMs,
|
||||
} from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import { ObjectType } from "Common/Types/JSON";
|
||||
import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent";
|
||||
import DashboardChartComponentUtil from "Common/Utils/Dashboard/Components/DashboardChartComponent";
|
||||
import DashboardValueComponentUtil from "Common/Utils/Dashboard/Components/DashboardValueComponent";
|
||||
import DashboardTextComponentUtil from "Common/Utils/Dashboard/Components/DashboardTextComponent";
|
||||
import DashboardTableComponentUtil from "Common/Utils/Dashboard/Components/DashboardTableComponent";
|
||||
import DashboardGaugeComponentUtil from "Common/Utils/Dashboard/Components/DashboardGaugeComponent";
|
||||
import DashboardLogStreamComponentUtil from "Common/Utils/Dashboard/Components/DashboardLogStreamComponent";
|
||||
import DashboardTraceListComponentUtil from "Common/Utils/Dashboard/Components/DashboardTraceListComponent";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Dashboard from "Common/Models/DatabaseModels/Dashboard";
|
||||
@@ -30,6 +38,7 @@ import MetricUtil from "../Metrics/Utils/Metrics";
|
||||
import RangeStartAndEndDateTime from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import TimeRange from "Common/Types/Time/TimeRange";
|
||||
import MetricType from "Common/Models/DatabaseModels/MetricType";
|
||||
import DashboardVariable from "Common/Types/Dashboard/DashboardVariable";
|
||||
|
||||
export interface ComponentProps {
|
||||
dashboardId: ObjectID;
|
||||
@@ -49,6 +58,23 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
|
||||
// Auto-refresh state
|
||||
const [autoRefreshInterval, setAutoRefreshInterval] =
|
||||
useState<AutoRefreshInterval>(AutoRefreshInterval.OFF);
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
const [dashboardVariables, setDashboardVariables] = useState<
|
||||
Array<DashboardVariable>
|
||||
>([]);
|
||||
|
||||
// Zoom stack for time range
|
||||
const [timeRangeStack, setTimeRangeStack] = useState<
|
||||
Array<RangeStartAndEndDateTime>
|
||||
>([]);
|
||||
const autoRefreshTimerRef: React.MutableRefObject<ReturnType<
|
||||
typeof setInterval
|
||||
> | null> = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [refreshTick, setRefreshTick] = useState<number>(0);
|
||||
|
||||
// ref for dashboard div.
|
||||
|
||||
const dashboardViewRef: React.RefObject<HTMLDivElement> =
|
||||
@@ -140,13 +166,23 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
return;
|
||||
}
|
||||
|
||||
setDashboardViewConfig(
|
||||
JSONFunctions.deserializeValue(
|
||||
dashboard.dashboardViewConfig ||
|
||||
DashboardViewConfigUtil.createDefaultDashboardViewConfig(),
|
||||
) as DashboardViewConfig,
|
||||
);
|
||||
const config: DashboardViewConfig = JSONFunctions.deserializeValue(
|
||||
dashboard.dashboardViewConfig ||
|
||||
DashboardViewConfigUtil.createDefaultDashboardViewConfig(),
|
||||
) as DashboardViewConfig;
|
||||
|
||||
setDashboardViewConfig(config);
|
||||
setDashboardName(dashboard.name || "Untitled Dashboard");
|
||||
|
||||
// Restore saved auto-refresh interval
|
||||
if (config.refreshInterval) {
|
||||
setAutoRefreshInterval(config.refreshInterval);
|
||||
}
|
||||
|
||||
// Restore saved variables
|
||||
if (config.variables) {
|
||||
setDashboardVariables(config.variables);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPage: PromiseVoidFunction = async (): Promise<void> => {
|
||||
@@ -169,6 +205,47 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-refresh timer management
|
||||
const triggerRefresh: () => void = useCallback(() => {
|
||||
setIsRefreshing(true);
|
||||
setRefreshTick((prev: number) => {
|
||||
return prev + 1;
|
||||
});
|
||||
// Brief indicator
|
||||
setTimeout(() => {
|
||||
setIsRefreshing(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear existing timer
|
||||
if (autoRefreshTimerRef.current) {
|
||||
clearInterval(autoRefreshTimerRef.current);
|
||||
autoRefreshTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Don't auto-refresh in edit mode
|
||||
if (dashboardMode === DashboardMode.Edit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs: number | null =
|
||||
getAutoRefreshIntervalInMs(autoRefreshInterval);
|
||||
|
||||
if (intervalMs !== null) {
|
||||
autoRefreshTimerRef.current = setInterval(() => {
|
||||
triggerRefresh();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoRefreshTimerRef.current) {
|
||||
clearInterval(autoRefreshTimerRef.current);
|
||||
autoRefreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefreshInterval, dashboardMode, triggerRefresh]);
|
||||
|
||||
const isEditMode: boolean = dashboardMode === DashboardMode.Edit;
|
||||
|
||||
const sideBarWidth: number = isEditMode && selectedComponentId ? 650 : 0;
|
||||
@@ -191,9 +268,13 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
return (
|
||||
<div
|
||||
ref={dashboardViewRef}
|
||||
className="min-h-screen"
|
||||
style={{
|
||||
minWidth: "1000px",
|
||||
width: `calc(100% - ${sideBarWidth}px)`,
|
||||
background: isEditMode
|
||||
? "linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%)"
|
||||
: "#fafbfc",
|
||||
}}
|
||||
>
|
||||
<DashboardToolbar
|
||||
@@ -219,15 +300,33 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
dashboardName={dashboardName}
|
||||
isSaving={isSaving}
|
||||
onSaveClick={() => {
|
||||
// Save auto-refresh interval with the config
|
||||
const configWithRefresh: DashboardViewConfig = {
|
||||
...dashboardViewConfig,
|
||||
refreshInterval: autoRefreshInterval,
|
||||
};
|
||||
setDashboardViewConfig(configWithRefresh);
|
||||
|
||||
saveDashboardViewConfig().catch((err: Error) => {
|
||||
setError(API.getFriendlyErrorMessage(err));
|
||||
});
|
||||
setDashboardMode(DashboardMode.View);
|
||||
}}
|
||||
startAndEndDate={startAndEndDate}
|
||||
canResetZoom={timeRangeStack.length > 0}
|
||||
onResetZoom={() => {
|
||||
if (timeRangeStack.length > 0) {
|
||||
const previousRange: RangeStartAndEndDateTime =
|
||||
timeRangeStack[timeRangeStack.length - 1]!;
|
||||
setStartAndEndDate(previousRange);
|
||||
setTimeRangeStack(timeRangeStack.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
onStartAndEndDateChange={(
|
||||
newStartAndEndDate: RangeStartAndEndDateTime,
|
||||
) => {
|
||||
// Push current range to zoom stack before changing
|
||||
setTimeRangeStack([...timeRangeStack, startAndEndDate]);
|
||||
setStartAndEndDate(newStartAndEndDate);
|
||||
}}
|
||||
onCancelEditClick={async () => {
|
||||
@@ -238,6 +337,26 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
onEditClick={() => {
|
||||
setDashboardMode(DashboardMode.Edit);
|
||||
}}
|
||||
autoRefreshInterval={autoRefreshInterval}
|
||||
onAutoRefreshIntervalChange={(interval: AutoRefreshInterval) => {
|
||||
setAutoRefreshInterval(interval);
|
||||
}}
|
||||
isRefreshing={isRefreshing}
|
||||
variables={dashboardVariables}
|
||||
onVariableValueChange={(variableId: string, value: string) => {
|
||||
const updatedVariables: Array<DashboardVariable> =
|
||||
dashboardVariables.map((v: DashboardVariable) => {
|
||||
if (v.id === variableId) {
|
||||
return { ...v, selectedValue: value };
|
||||
}
|
||||
return v;
|
||||
});
|
||||
setDashboardVariables(updatedVariables);
|
||||
// Trigger refresh when variable changes
|
||||
setRefreshTick((prev: number) => {
|
||||
return prev + 1;
|
||||
});
|
||||
}}
|
||||
onAddComponentClick={(componentType: DashboardComponentType) => {
|
||||
let newComponent: DashboardBaseComponent | null = null;
|
||||
|
||||
@@ -253,6 +372,24 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
newComponent = DashboardTextComponentUtil.getDefaultComponent();
|
||||
}
|
||||
|
||||
if (componentType === DashboardComponentType.Table) {
|
||||
newComponent = DashboardTableComponentUtil.getDefaultComponent();
|
||||
}
|
||||
|
||||
if (componentType === DashboardComponentType.Gauge) {
|
||||
newComponent = DashboardGaugeComponentUtil.getDefaultComponent();
|
||||
}
|
||||
|
||||
if (componentType === DashboardComponentType.LogStream) {
|
||||
newComponent =
|
||||
DashboardLogStreamComponentUtil.getDefaultComponent();
|
||||
}
|
||||
|
||||
if (componentType === DashboardComponentType.TraceList) {
|
||||
newComponent =
|
||||
DashboardTraceListComponentUtil.getDefaultComponent();
|
||||
}
|
||||
|
||||
if (!newComponent) {
|
||||
throw new BadDataException(
|
||||
`Unknown component type: ${componentType}`,
|
||||
@@ -270,7 +407,7 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
setDashboardViewConfig(newDashboardConfig);
|
||||
}}
|
||||
/>
|
||||
<div ref={dashboardCanvasRef}>
|
||||
<div ref={dashboardCanvasRef} className="px-1 pb-4">
|
||||
<DashboardCanvas
|
||||
dashboardViewConfig={dashboardViewConfig}
|
||||
onDashboardViewConfigChange={(newConfig: DashboardViewConfig) => {
|
||||
@@ -291,6 +428,7 @@ const DashboardViewer: FunctionComponent<ComponentProps> = (
|
||||
telemetryAttributes,
|
||||
metricTypes,
|
||||
}}
|
||||
refreshTick={refreshTick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,14 @@ import MoreMenuItem from "Common/UI/Components/MoreMenu/MoreMenuItem";
|
||||
import DashboardComponentType from "Common/Types/Dashboard/DashboardComponentType";
|
||||
import RangeStartAndEndDateTime from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
|
||||
import DashboardViewConfig from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import DashboardViewConfig, {
|
||||
AutoRefreshInterval,
|
||||
getAutoRefreshIntervalLabel,
|
||||
} from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import DashboardVariable from "Common/Types/Dashboard/DashboardVariable";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import Loader from "Common/UI/Components/Loader/Loader";
|
||||
import DashboardVariableSelector from "./DashboardVariableSelector";
|
||||
|
||||
export interface ComponentProps {
|
||||
onEditClick: () => void;
|
||||
@@ -23,6 +28,13 @@ export interface ComponentProps {
|
||||
startAndEndDate: RangeStartAndEndDateTime;
|
||||
onStartAndEndDateChange: (startAndEndDate: RangeStartAndEndDateTime) => void;
|
||||
dashboardViewConfig: DashboardViewConfig;
|
||||
autoRefreshInterval: AutoRefreshInterval;
|
||||
onAutoRefreshIntervalChange: (interval: AutoRefreshInterval) => void;
|
||||
isRefreshing?: boolean | undefined;
|
||||
variables?: Array<DashboardVariable> | undefined;
|
||||
onVariableValueChange?: ((variableId: string, value: string) => void) | undefined;
|
||||
canResetZoom?: boolean | undefined;
|
||||
onResetZoom?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
@@ -34,105 +46,234 @@ const DashboardToolbar: FunctionComponent<ComponentProps> = (
|
||||
|
||||
const isSaving: boolean = props.isSaving;
|
||||
|
||||
const hasComponents: boolean = !!(
|
||||
props.dashboardViewConfig &&
|
||||
props.dashboardViewConfig.components &&
|
||||
props.dashboardViewConfig.components.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-1.5 mb-1.5 ml-1 mr-1 p-1 h-20 pt-5 pb-5 pl-4 pr-4 rounded bg-white border-2 border-gray-100`}
|
||||
className="mx-3 mt-3 mb-2 rounded-lg bg-white border border-gray-200"
|
||||
style={{
|
||||
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px -1px rgba(0, 0, 0, 0.04)",
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex justify-between">
|
||||
<div className="text-md font-medium mt-2">
|
||||
{/* Name Component */}
|
||||
{props.dashboardName}
|
||||
{/* Accent top bar */}
|
||||
<div
|
||||
className="h-0.5 rounded-t-lg"
|
||||
style={{
|
||||
background: isEditMode
|
||||
? "linear-gradient(90deg, #3b82f6 0%, #6366f1 50%, #8b5cf6 100%)"
|
||||
: "linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%)",
|
||||
}}
|
||||
></div>
|
||||
{/* Top row: Dashboard name + action buttons */}
|
||||
<div className="flex items-center justify-between px-5 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h1 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{props.dashboardName}
|
||||
</h1>
|
||||
{isEditMode && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-600 border border-blue-100 animate-pulse">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full mr-1.5"></span>
|
||||
Editing
|
||||
</span>
|
||||
)}
|
||||
{hasComponents && !isEditMode && (
|
||||
<span className="text-xs text-gray-400 tabular-nums">
|
||||
{props.dashboardViewConfig.components.length} widget{props.dashboardViewConfig.components.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{/* Refreshing indicator */}
|
||||
{props.isRefreshing &&
|
||||
props.autoRefreshInterval !== AutoRefreshInterval.OFF && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-blue-600">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>
|
||||
Refreshing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isSaving && (
|
||||
<div className="flex">
|
||||
{props.dashboardViewConfig &&
|
||||
props.dashboardViewConfig.components &&
|
||||
props.dashboardViewConfig.components.length > 0 && (
|
||||
<div className="mt-1.5">
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={props.startAndEndDate}
|
||||
onChange={(startAndEndDate: RangeStartAndEndDateTime) => {
|
||||
props.onStartAndEndDateChange(startAndEndDate);
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<MoreMenu menuIcon={IconProp.Add} text="Add Widget">
|
||||
<MoreMenuItem
|
||||
text={"Chart"}
|
||||
icon={IconProp.ChartBar}
|
||||
key={"add-chart"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Chart);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<MoreMenuItem
|
||||
text={"Value"}
|
||||
icon={IconProp.Hashtag}
|
||||
key={"add-value"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Value);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Text"}
|
||||
icon={IconProp.Text}
|
||||
key={"add-text"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Text);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Table"}
|
||||
icon={IconProp.TableCells}
|
||||
key={"add-table"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Table);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Gauge"}
|
||||
icon={IconProp.Activity}
|
||||
key={"add-gauge"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Gauge);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Log Stream"}
|
||||
icon={IconProp.Logs}
|
||||
key={"add-log-stream"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(
|
||||
DashboardComponentType.LogStream,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Trace List"}
|
||||
icon={IconProp.QueueList}
|
||||
key={"add-trace-list"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(
|
||||
DashboardComponentType.TraceList,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</MoreMenu>
|
||||
|
||||
{isEditMode ? (
|
||||
<MoreMenu menuIcon={IconProp.Add} text="Add Component">
|
||||
<MoreMenuItem
|
||||
text={"Add Chart"}
|
||||
key={"add-chart"}
|
||||
<div className="w-px h-6 bg-gray-200 mx-1"></div>
|
||||
|
||||
<Button
|
||||
icon={IconProp.Check}
|
||||
title="Save"
|
||||
buttonStyle={ButtonStyleType.HOVER_PRIMARY_OUTLINE}
|
||||
onClick={props.onSaveClick}
|
||||
/>
|
||||
<Button
|
||||
icon={IconProp.Close}
|
||||
title="Cancel"
|
||||
buttonStyle={ButtonStyleType.HOVER_DANGER_OUTLINE}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Chart);
|
||||
setShowCancelModal(true);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Add Value"}
|
||||
key={"add-value"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Value);
|
||||
}}
|
||||
/>
|
||||
<MoreMenuItem
|
||||
text={"Add Text"}
|
||||
key={"add-text"}
|
||||
onClick={() => {
|
||||
props.onAddComponentClick(DashboardComponentType.Text);
|
||||
}}
|
||||
/>
|
||||
</MoreMenu>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<>
|
||||
{/* Reset Zoom button */}
|
||||
{props.canResetZoom && props.onResetZoom && (
|
||||
<Button
|
||||
icon={IconProp.Refresh}
|
||||
title="Reset Zoom"
|
||||
buttonStyle={ButtonStyleType.HOVER_PRIMARY_OUTLINE}
|
||||
onClick={props.onResetZoom}
|
||||
tooltip="Reset to original time range"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isEditMode && (
|
||||
<Button
|
||||
icon={IconProp.Expand}
|
||||
buttonStyle={ButtonStyleType.ICON}
|
||||
onClick={props.onFullScreenClick}
|
||||
tooltip="Full Screen"
|
||||
/>
|
||||
)}
|
||||
{/* Auto-refresh dropdown */}
|
||||
{hasComponents && (
|
||||
<MoreMenu
|
||||
menuIcon={IconProp.Refresh}
|
||||
text={
|
||||
props.autoRefreshInterval !== AutoRefreshInterval.OFF
|
||||
? getAutoRefreshIntervalLabel(props.autoRefreshInterval)
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{Object.values(AutoRefreshInterval).map(
|
||||
(interval: AutoRefreshInterval) => {
|
||||
return (
|
||||
<MoreMenuItem
|
||||
key={interval}
|
||||
text={getAutoRefreshIntervalLabel(interval)}
|
||||
onClick={() => {
|
||||
props.onAutoRefreshIntervalChange(interval);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</MoreMenu>
|
||||
)}
|
||||
|
||||
{!isEditMode && (
|
||||
<Button
|
||||
icon={IconProp.Pencil}
|
||||
title="Edit"
|
||||
buttonStyle={ButtonStyleType.ICON}
|
||||
onClick={props.onEditClick}
|
||||
tooltip="Edit"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
icon={IconProp.Expand}
|
||||
buttonStyle={ButtonStyleType.ICON}
|
||||
onClick={props.onFullScreenClick}
|
||||
tooltip="Full Screen"
|
||||
/>
|
||||
|
||||
{isEditMode && (
|
||||
<Button
|
||||
icon={IconProp.Check}
|
||||
title="Save"
|
||||
buttonStyle={ButtonStyleType.HOVER_PRIMARY_OUTLINE}
|
||||
onClick={props.onSaveClick}
|
||||
/>
|
||||
)}
|
||||
{isEditMode && (
|
||||
<Button
|
||||
icon={IconProp.Close}
|
||||
title="Cancel"
|
||||
buttonStyle={ButtonStyleType.HOVER_DANGER_OUTLINE}
|
||||
onClick={() => {
|
||||
setShowCancelModal(true);
|
||||
}}
|
||||
/>
|
||||
<div className="w-px h-6 bg-gray-200 mx-0.5"></div>
|
||||
|
||||
<Button
|
||||
icon={IconProp.Pencil}
|
||||
title="Edit"
|
||||
buttonStyle={ButtonStyleType.ICON}
|
||||
onClick={props.onEditClick}
|
||||
tooltip="Edit Dashboard"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSaving && (
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader />
|
||||
<div className="ml-2 text-sm text-gray-400">Saving...</div>
|
||||
<span className="text-sm text-gray-500">Saving...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom row: Time range + variables (only when components exist and not in edit mode) */}
|
||||
{hasComponents && !isEditMode && (
|
||||
<div className="flex items-center gap-3 px-5 pb-3 pt-0 flex-wrap">
|
||||
<div>
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={props.startAndEndDate}
|
||||
onChange={(startAndEndDate: RangeStartAndEndDateTime) => {
|
||||
props.onStartAndEndDateChange(startAndEndDate);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template variables */}
|
||||
{props.variables &&
|
||||
props.variables.length > 0 &&
|
||||
props.onVariableValueChange && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-gray-200"></div>
|
||||
<DashboardVariableSelector
|
||||
variables={props.variables}
|
||||
onVariableValueChange={props.onVariableValueChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCancelModal ? (
|
||||
<ConfirmModal
|
||||
title={`Are you sure?`}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import DashboardVariable from "Common/Types/Dashboard/DashboardVariable";
|
||||
|
||||
export interface ComponentProps {
|
||||
variables: Array<DashboardVariable>;
|
||||
onVariableValueChange: (variableId: string, value: string) => void;
|
||||
}
|
||||
|
||||
const DashboardVariableSelector: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
if (!props.variables || props.variables.length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{props.variables.map((variable: DashboardVariable) => {
|
||||
const options: Array<string> = variable.customListValues
|
||||
? variable.customListValues.split(",").map((v: string) => {
|
||||
return v.trim();
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div key={variable.id} className="flex items-center gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-400 uppercase tracking-wide">
|
||||
{variable.label || variable.name}
|
||||
</label>
|
||||
{options.length > 0 ? (
|
||||
<select
|
||||
className="text-xs border border-gray-200 rounded-md px-2.5 py-1.5 bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-300 transition-colors"
|
||||
value={variable.selectedValue || variable.defaultValue || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
props.onVariableValueChange(variable.id, e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">All</option>
|
||||
{options.map((option: string) => {
|
||||
return (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="text-xs border border-gray-200 rounded-md px-2.5 py-1.5 bg-white text-gray-700 w-28 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-300 transition-colors"
|
||||
value={variable.selectedValue || variable.defaultValue || ""}
|
||||
placeholder={variable.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
props.onVariableValueChange(variable.id, e.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardVariableSelector;
|
||||
@@ -35,6 +35,13 @@ export interface ComponentProps {
|
||||
monitorStep: MonitorStep;
|
||||
}
|
||||
|
||||
const isMetricOnlyMonitorType = (monitorType: MonitorType): boolean => {
|
||||
return (
|
||||
monitorType === MonitorType.Kubernetes ||
|
||||
monitorType === MonitorType.Metrics
|
||||
);
|
||||
};
|
||||
|
||||
const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
@@ -77,6 +84,18 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
);
|
||||
}, [criteriaFilter]);
|
||||
|
||||
const isMetricOnly: boolean = isMetricOnlyMonitorType(props.monitorType);
|
||||
|
||||
// Auto-select MetricValue for metric-only monitor types (Kubernetes, Metrics)
|
||||
useEffect(() => {
|
||||
if (isMetricOnly && criteriaFilter && criteriaFilter.checkOn !== CheckOn.MetricValue) {
|
||||
props.onChange?.({
|
||||
...criteriaFilter,
|
||||
checkOn: CheckOn.MetricValue,
|
||||
});
|
||||
}
|
||||
}, [isMetricOnly]);
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
}
|
||||
@@ -125,15 +144,20 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
);
|
||||
});
|
||||
|
||||
// Collect metric variables from both metricMonitor and kubernetesMonitor configs
|
||||
const metricViewConfig =
|
||||
props.monitorStep.data?.metricMonitor?.metricViewConfig ||
|
||||
props.monitorStep.data?.kubernetesMonitor?.metricViewConfig;
|
||||
|
||||
let metricVariables: Array<string> =
|
||||
props.monitorStep.data?.metricMonitor?.metricViewConfig?.queryConfigs?.map(
|
||||
metricViewConfig?.queryConfigs?.map(
|
||||
(queryConfig: MetricQueryConfigData) => {
|
||||
return queryConfig.metricAliasData?.metricVariable || "";
|
||||
},
|
||||
) || [];
|
||||
|
||||
// push formula variables as well.
|
||||
props.monitorStep.data?.metricMonitor?.metricViewConfig?.formulaConfigs?.forEach(
|
||||
metricViewConfig?.formulaConfigs?.forEach(
|
||||
(formulaConfig: MetricFormulaConfigData) => {
|
||||
metricVariables.push(formulaConfig.metricAliasData.metricVariable || "");
|
||||
},
|
||||
@@ -168,6 +192,8 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-md p-2 bg-gray-50 my-5 border-gray-200 border-solid border-2">
|
||||
{/* Hide Filter Type dropdown for metric-only monitors since MetricValue is the only option */}
|
||||
{!isMetricOnly && (
|
||||
<div className="">
|
||||
<FieldLabelElement title="Filter Type" />
|
||||
<Dropdown
|
||||
@@ -186,6 +212,7 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{criteriaFilter?.checkOn &&
|
||||
criteriaFilter?.checkOn === CheckOn.DiskUsagePercent && (
|
||||
@@ -210,7 +237,10 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
{criteriaFilter?.checkOn &&
|
||||
criteriaFilter?.checkOn === CheckOn.MetricValue && (
|
||||
<div className="mt-1">
|
||||
<FieldLabelElement title="Select Metric Variable" />
|
||||
<FieldLabelElement
|
||||
title={isMetricOnly ? "Metric" : "Select Metric Variable"}
|
||||
description={isMetricOnly ? "Which metric query should this alert rule check?" : undefined}
|
||||
/>
|
||||
<Dropdown
|
||||
value={selectedMetricVariableOption}
|
||||
options={metricVariableOptions}
|
||||
@@ -232,7 +262,10 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
{criteriaFilter?.checkOn &&
|
||||
criteriaFilter?.checkOn === CheckOn.MetricValue && (
|
||||
<div className="mt-1">
|
||||
<FieldLabelElement title="Select Aggregation" />
|
||||
<FieldLabelElement
|
||||
title={isMetricOnly ? "Aggregation" : "Select Aggregation"}
|
||||
description={isMetricOnly ? "How to combine multiple data points (e.g. Average, Max, Min)." : undefined}
|
||||
/>
|
||||
<Dropdown
|
||||
value={metricAggregationValue}
|
||||
options={metricAggregationOptions}
|
||||
@@ -350,7 +383,10 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
{!criteriaFilter?.checkOn ||
|
||||
(criteriaFilter?.checkOn && (
|
||||
<div className="mt-1">
|
||||
<FieldLabelElement title="Filter Condition" />
|
||||
<FieldLabelElement
|
||||
title={isMetricOnly ? "Condition" : "Filter Condition"}
|
||||
description={isMetricOnly ? "When should this alert trigger?" : undefined}
|
||||
/>
|
||||
<Dropdown
|
||||
value={filterConditionValue}
|
||||
options={filterTypeOptions}
|
||||
@@ -377,7 +413,10 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
checkOn: criteriaFilter?.checkOn,
|
||||
}) && (
|
||||
<div className="mt-1">
|
||||
<FieldLabelElement title="Value" />
|
||||
<FieldLabelElement
|
||||
title={isMetricOnly ? "Threshold" : "Value"}
|
||||
description={isMetricOnly ? "The value to compare against." : undefined}
|
||||
/>
|
||||
<Input
|
||||
placeholder={valuePlaceholder}
|
||||
value={criteriaFilter?.value?.toString()}
|
||||
@@ -425,7 +464,7 @@ const CriteriaFilterElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
<div className="mt-3 -mr-2 w-full flex justify-end">
|
||||
<Button
|
||||
title="Delete Filter"
|
||||
title={isMetricOnly ? "Delete Rule" : "Delete Filter"}
|
||||
buttonStyle={ButtonStyleType.DANGER_OUTLINE}
|
||||
icon={IconProp.Trash}
|
||||
buttonSize={ButtonSize.Small}
|
||||
|
||||
@@ -3,6 +3,7 @@ import IconProp from "Common/Types/Icon/IconProp";
|
||||
import {
|
||||
CheckOn,
|
||||
CriteriaFilter,
|
||||
EvaluateOverTimeType,
|
||||
FilterType,
|
||||
} from "Common/Types/Monitor/CriteriaFilter";
|
||||
import MonitorStep from "Common/Types/Monitor/MonitorStep";
|
||||
@@ -98,18 +99,39 @@ const CriteriaFilters: FunctionComponent<ComponentProps> = (
|
||||
})}
|
||||
<div className="mt-3 -ml-3">
|
||||
<Button
|
||||
title="Add Filter"
|
||||
title={
|
||||
props.monitorType === MonitorType.Kubernetes ||
|
||||
props.monitorType === MonitorType.Metrics
|
||||
? "Add Rule"
|
||||
: "Add Filter"
|
||||
}
|
||||
buttonSize={ButtonSize.Small}
|
||||
icon={IconProp.Add}
|
||||
onClick={() => {
|
||||
const newCriteriaFilters: Array<CriteriaFilter> = [
|
||||
...criteriaFilters,
|
||||
];
|
||||
newCriteriaFilters.push({
|
||||
checkOn: CheckOn.IsOnline,
|
||||
filterType: FilterType.EqualTo,
|
||||
value: "",
|
||||
});
|
||||
|
||||
const isMetricOnly: boolean =
|
||||
props.monitorType === MonitorType.Kubernetes ||
|
||||
props.monitorType === MonitorType.Metrics;
|
||||
|
||||
newCriteriaFilters.push(
|
||||
isMetricOnly
|
||||
? {
|
||||
checkOn: CheckOn.MetricValue,
|
||||
filterType: FilterType.GreaterThan,
|
||||
value: "",
|
||||
metricMonitorOptions: {
|
||||
metricAggregationType: EvaluateOverTimeType.AnyValue,
|
||||
},
|
||||
}
|
||||
: {
|
||||
checkOn: CheckOn.IsOnline,
|
||||
filterType: FilterType.EqualTo,
|
||||
value: "",
|
||||
},
|
||||
);
|
||||
|
||||
props.onChange?.(newCriteriaFilters);
|
||||
}}
|
||||
@@ -117,8 +139,18 @@ const CriteriaFilters: FunctionComponent<ComponentProps> = (
|
||||
</div>
|
||||
{showCantDeleteModal ? (
|
||||
<ConfirmModal
|
||||
description={`We need at least one filter for this criteria. We cant delete one remaining filter. If you don't need filters, please feel free to delete criteria instead.`}
|
||||
title={`Cannot delete last remaining filter.`}
|
||||
description={
|
||||
props.monitorType === MonitorType.Kubernetes ||
|
||||
props.monitorType === MonitorType.Metrics
|
||||
? `At least one alert rule is required. If you don't need rules, you can delete the entire criteria instead.`
|
||||
: `We need at least one filter for this criteria. We cant delete one remaining filter. If you don't need filters, please feel free to delete criteria instead.`
|
||||
}
|
||||
title={
|
||||
props.monitorType === MonitorType.Kubernetes ||
|
||||
props.monitorType === MonitorType.Metrics
|
||||
? `Cannot delete last remaining rule.`
|
||||
: `Cannot delete last remaining filter.`
|
||||
}
|
||||
onSubmit={() => {
|
||||
setShowCantDeleteModal(false);
|
||||
}}
|
||||
|
||||
@@ -221,14 +221,10 @@ const KubernetesMonitorStepForm: FunctionComponent<ComponentProps> = (
|
||||
const clusterIdentifier: string =
|
||||
monitorStepKubernetesMonitor.clusterIdentifier;
|
||||
|
||||
if (!clusterIdentifier) {
|
||||
// Just store the template selection, config will be built when cluster is selected
|
||||
return;
|
||||
}
|
||||
|
||||
// Get a dummy monitor step from the template to extract the kubernetes config
|
||||
// Build even without a cluster so the metricViewConfig is populated for the METRIC dropdown
|
||||
const dummyStep: MonitorStep = template.getMonitorStep({
|
||||
clusterIdentifier,
|
||||
clusterIdentifier: clusterIdentifier || "",
|
||||
onlineMonitorStatusId: ObjectID.generate(),
|
||||
offlineMonitorStatusId: ObjectID.generate(),
|
||||
defaultIncidentSeverityId: ObjectID.generate(),
|
||||
@@ -240,7 +236,7 @@ const KubernetesMonitorStepForm: FunctionComponent<ComponentProps> = (
|
||||
if (dummyStep.data?.kubernetesMonitor) {
|
||||
props.onChange({
|
||||
...dummyStep.data.kubernetesMonitor,
|
||||
clusterIdentifier,
|
||||
clusterIdentifier: clusterIdentifier || "",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -247,8 +247,16 @@ const MonitorCriteriaInstanceElement: FunctionComponent<ComponentProps> = (
|
||||
|
||||
{/* Filters Section - Collapsible */}
|
||||
<CollapsibleSection
|
||||
title="Filters"
|
||||
description="Add criteria for different monitor properties."
|
||||
title={
|
||||
props.monitorType === MonitorType.Kubernetes || props.monitorType === MonitorType.Metrics
|
||||
? "Alert Rules"
|
||||
: "Filters"
|
||||
}
|
||||
description={
|
||||
props.monitorType === MonitorType.Kubernetes || props.monitorType === MonitorType.Metrics
|
||||
? "Define when this alert should trigger based on metric values."
|
||||
: "Add criteria for different monitor properties."
|
||||
}
|
||||
badge={filterSummary}
|
||||
variant="bordered"
|
||||
defaultCollapsed={false}
|
||||
@@ -257,8 +265,16 @@ const MonitorCriteriaInstanceElement: FunctionComponent<ComponentProps> = (
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<FieldLabelElement
|
||||
title="Filter Condition"
|
||||
description="Select All if you want all the criteria to be met. Select any if you like any criteria to be met."
|
||||
title={
|
||||
props.monitorType === MonitorType.Kubernetes || props.monitorType === MonitorType.Metrics
|
||||
? "Match Condition"
|
||||
: "Filter Condition"
|
||||
}
|
||||
description={
|
||||
props.monitorType === MonitorType.Kubernetes || props.monitorType === MonitorType.Metrics
|
||||
? "Should all rules match, or just any one of them?"
|
||||
: "Select All if you want all the criteria to be met. Select any if you like any criteria to be met."
|
||||
}
|
||||
required={true}
|
||||
/>
|
||||
<Radio
|
||||
|
||||
@@ -159,11 +159,16 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
|
||||
});
|
||||
}
|
||||
|
||||
// Determine chart type - use BAR for bar chart type, AREA for everything else
|
||||
const chartType: ChartType =
|
||||
queryConfig.chartType === MetricChartType.BAR
|
||||
? ChartType.BAR
|
||||
: ChartType.AREA;
|
||||
let chartType: ChartType;
|
||||
if (queryConfig.chartType === MetricChartType.BAR) {
|
||||
chartType = ChartType.BAR;
|
||||
} else if (queryConfig.chartType === MetricChartType.AREA) {
|
||||
chartType = ChartType.AREA;
|
||||
} else if (queryConfig.chartType === MetricChartType.LINE) {
|
||||
chartType = ChartType.LINE;
|
||||
} else {
|
||||
chartType = ChartType.AREA;
|
||||
}
|
||||
|
||||
const chart: Chart = {
|
||||
id: index.toString(),
|
||||
|
||||
@@ -50,6 +50,7 @@ import { CustomElementProps } from "Common/UI/Components/Forms/Types/Field";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import CheckboxElement from "Common/UI/Components/Checkbox/Checkbox";
|
||||
|
||||
export interface ComponentProps {
|
||||
workspaceType: WorkspaceType;
|
||||
@@ -322,13 +323,13 @@ const WorkspaceSummaryTable: FunctionComponent<ComponentProps> = (
|
||||
});
|
||||
}
|
||||
|
||||
// Default to all summary items if none selected
|
||||
// Default to "All" if none selected
|
||||
if (
|
||||
!values.summaryItems ||
|
||||
(Array.isArray(values.summaryItems) &&
|
||||
values.summaryItems.length === 0)
|
||||
) {
|
||||
values.summaryItems = allSummaryItems;
|
||||
values.summaryItems = [WorkspaceNotificationSummaryItem.All];
|
||||
}
|
||||
|
||||
if (values.isEnabled === undefined || values.isEnabled === null) {
|
||||
@@ -493,18 +494,91 @@ const WorkspaceSummaryTable: FunctionComponent<ComponentProps> = (
|
||||
},
|
||||
title: "What to Include",
|
||||
description:
|
||||
"Choose which sections appear in the summary. The report will be formatted with headers, statistics, and a detailed list.",
|
||||
fieldType: FormFieldSchemaType.MultiSelectDropdown,
|
||||
required: true,
|
||||
"Choose which sections appear in the summary. Select \"All\" to include everything, or pick specific sections.",
|
||||
fieldType: FormFieldSchemaType.CustomComponent,
|
||||
required: false,
|
||||
stepId: "content",
|
||||
dropdownOptions: allSummaryItems.map(
|
||||
(item: WorkspaceNotificationSummaryItem) => {
|
||||
return {
|
||||
label: item,
|
||||
value: item,
|
||||
};
|
||||
},
|
||||
),
|
||||
getCustomElement: (
|
||||
value: FormValues<WorkspaceNotificationSummary>,
|
||||
elementProps: CustomElementProps,
|
||||
): ReactElement => {
|
||||
const currentItems: Array<WorkspaceNotificationSummaryItem> =
|
||||
(value.summaryItems as Array<WorkspaceNotificationSummaryItem>) ||
|
||||
[WorkspaceNotificationSummaryItem.All];
|
||||
|
||||
const isAllSelected: boolean = currentItems.includes(
|
||||
WorkspaceNotificationSummaryItem.All,
|
||||
);
|
||||
|
||||
const individualItems: Array<WorkspaceNotificationSummaryItem> =
|
||||
allSummaryItems.filter(
|
||||
(item: WorkspaceNotificationSummaryItem) => {
|
||||
return item !== WorkspaceNotificationSummaryItem.All;
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<CheckboxElement
|
||||
title="All"
|
||||
value={isAllSelected}
|
||||
onChange={(checked: boolean) => {
|
||||
if (elementProps.onChange) {
|
||||
if (checked) {
|
||||
elementProps.onChange([
|
||||
WorkspaceNotificationSummaryItem.All,
|
||||
]);
|
||||
} else {
|
||||
elementProps.onChange([]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="ml-6 space-y-2">
|
||||
{individualItems.map(
|
||||
(item: WorkspaceNotificationSummaryItem) => {
|
||||
return (
|
||||
<CheckboxElement
|
||||
key={item}
|
||||
title={item}
|
||||
disabled={isAllSelected}
|
||||
value={
|
||||
isAllSelected || currentItems.includes(item)
|
||||
}
|
||||
onChange={(checked: boolean) => {
|
||||
if (elementProps.onChange) {
|
||||
let newItems: Array<WorkspaceNotificationSummaryItem> =
|
||||
currentItems.filter(
|
||||
(i: WorkspaceNotificationSummaryItem) => {
|
||||
return (
|
||||
i !==
|
||||
WorkspaceNotificationSummaryItem.All &&
|
||||
i !== item
|
||||
);
|
||||
},
|
||||
);
|
||||
if (checked) {
|
||||
newItems.push(item);
|
||||
}
|
||||
// If all individual items are selected, switch to "All"
|
||||
if (
|
||||
newItems.length === individualItems.length
|
||||
) {
|
||||
newItems = [
|
||||
WorkspaceNotificationSummaryItem.All,
|
||||
];
|
||||
}
|
||||
elementProps.onChange(newItems);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
@@ -700,12 +774,6 @@ const WorkspaceSummaryTable: FunctionComponent<ComponentProps> = (
|
||||
}
|
||||
submitButtonType={ButtonStyleType.NORMAL}
|
||||
submitButtonText={"Close"}
|
||||
onClose={() => {
|
||||
setShowTestSuccessModal(false);
|
||||
setTestSummary(undefined);
|
||||
setShowTestModal(false);
|
||||
setTestError("");
|
||||
}}
|
||||
onSubmit={async () => {
|
||||
setShowTestSuccessModal(false);
|
||||
setTestSummary(undefined);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import Dashboard from "Common/Models/DatabaseModels/Dashboard";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
|
||||
const DashboardAuthenticationSettings: FunctionComponent<
|
||||
PageComponentProps
|
||||
> = (): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<CardModelDetail<Dashboard>
|
||||
name="Dashboard > Authentication Settings"
|
||||
cardProps={{
|
||||
title: "Authentication Settings",
|
||||
description: "Authentication settings for this dashboard.",
|
||||
}}
|
||||
editButtonText="Edit Settings"
|
||||
isEditable={true}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
isPublicDashboard: true,
|
||||
},
|
||||
title: "Is Visible to Public",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
required: false,
|
||||
placeholder: "Is this dashboard visible to public",
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: Dashboard,
|
||||
id: "model-detail-dashboard",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
isPublicDashboard: true,
|
||||
},
|
||||
fieldType: FieldType.Boolean,
|
||||
title: "Is Visible to Public",
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardModelDetail<Dashboard>
|
||||
name="Dashboard > Master Password"
|
||||
cardProps={{
|
||||
title: "Master Password",
|
||||
description:
|
||||
"Rotate the password required to unlock a private dashboard. This value is stored as a secure hash and cannot be retrieved.",
|
||||
}}
|
||||
editButtonText="Update Master Password"
|
||||
isEditable={true}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
enableMasterPassword: true,
|
||||
},
|
||||
title: "Require Master Password",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
required: false,
|
||||
description:
|
||||
"When enabled, visitors must enter the master password before viewing a private dashboard.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
masterPassword: true,
|
||||
},
|
||||
title: "Master Password",
|
||||
fieldType: FormFieldSchemaType.Password,
|
||||
required: false,
|
||||
placeholder: "Enter a new master password",
|
||||
description:
|
||||
"Updating this value immediately replaces the existing master password.",
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: Dashboard,
|
||||
id: "model-detail-dashboard-master-password",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
enableMasterPassword: true,
|
||||
},
|
||||
fieldType: FieldType.Boolean,
|
||||
title: "Require Master Password",
|
||||
placeholder: "No",
|
||||
},
|
||||
{
|
||||
title: "Master Password",
|
||||
fieldType: FieldType.Element,
|
||||
placeholder: "Hidden",
|
||||
getElement: (): ReactElement => {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
For security reasons, the current master password is never
|
||||
displayed. Use the update button to set a new password at
|
||||
any time.
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardModelDetail<Dashboard>
|
||||
name="Dashboard > IP Whitelist"
|
||||
cardProps={{
|
||||
title: "IP Whitelist",
|
||||
description:
|
||||
"IP Whitelist for this dashboard. If the dashboard is public then only IP addresses in this whitelist will be able to access the dashboard. If the dashboard is not public then only users who have access from the IP addresses in this whitelist will be able to access the dashboard.",
|
||||
}}
|
||||
editButtonText="Edit IP Whitelist"
|
||||
isEditable={true}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
ipWhitelist: true,
|
||||
},
|
||||
title: "IP Whitelist",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
placeholder:
|
||||
"Please enter the IP addresses or CIDR ranges to whitelist. One per line. This can be IPv4 or IPv6 addresses.",
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: Dashboard,
|
||||
id: "model-detail-dashboard-ip-whitelist",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
ipWhitelist: true,
|
||||
},
|
||||
fieldType: FieldType.LongText,
|
||||
title: "IP Whitelist",
|
||||
placeholder:
|
||||
"No IP addresses or CIDR ranges whitelisted. This will allow all IP addresses to access the dashboard.",
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardAuthenticationSettings;
|
||||
@@ -0,0 +1,489 @@
|
||||
import PageComponentProps from "../../PageComponentProps";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import { ErrorFunction, VoidFunction } from "Common/Types/FunctionTypes";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
|
||||
import FieldType from "Common/UI/Components/Types/FieldType";
|
||||
import { APP_API_URL, DashboardCNameRecord } from "Common/UI/Config";
|
||||
import API from "Common/UI/Utils/API/API";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import Domain from "Common/Models/DatabaseModels/Domain";
|
||||
import DashboardDomain from "Common/Models/DatabaseModels/DashboardDomain";
|
||||
import React, {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useState,
|
||||
} from "react";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import FormValues from "Common/UI/Components/Forms/Types/FormValues";
|
||||
import ProjectUtil from "Common/UI/Utils/Project";
|
||||
|
||||
const DashboardCustomDomains: FunctionComponent<PageComponentProps> = (
|
||||
props: PageComponentProps,
|
||||
): ReactElement => {
|
||||
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
|
||||
|
||||
const [refreshToggle, setRefreshToggle] = useState<string>(
|
||||
OneUptimeDate.getCurrentDate().toString(),
|
||||
);
|
||||
|
||||
const [showCnameModal, setShowCnameModal] = useState<boolean>(false);
|
||||
|
||||
const [selectedDashboardDomain, setSelectedDashboardDomain] =
|
||||
useState<DashboardDomain | null>(null);
|
||||
|
||||
const [verifyCnameLoading, setVerifyCnameLoading] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [orderSslLoading, setOrderSslLoading] = useState<boolean>(false);
|
||||
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [showOrderSSLModal, setShowOrderSSLModal] =
|
||||
useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<ModelTable<DashboardDomain>
|
||||
modelType={DashboardDomain}
|
||||
query={{
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
dashboardId: modelId,
|
||||
}}
|
||||
name="Dashboard > Domains"
|
||||
userPreferencesKey="dashboard-domains-table"
|
||||
id="dashboard-domains-table"
|
||||
isDeleteable={true}
|
||||
isCreateable={true}
|
||||
isEditable={true}
|
||||
cardProps={{
|
||||
title: "Custom Domains",
|
||||
description: `Important: Please add a CNAME record pointing to ${DashboardCNameRecord} for these domains for this to work.`,
|
||||
}}
|
||||
refreshToggle={refreshToggle}
|
||||
onBeforeCreate={(
|
||||
item: DashboardDomain,
|
||||
): Promise<DashboardDomain> => {
|
||||
if (!props.currentProject || !props.currentProject._id) {
|
||||
throw new BadDataException("Project ID cannot be null");
|
||||
}
|
||||
item.dashboardId = modelId;
|
||||
item.projectId = new ObjectID(props.currentProject._id);
|
||||
return Promise.resolve(item);
|
||||
}}
|
||||
actionButtons={[
|
||||
{
|
||||
title: "Add CNAME",
|
||||
buttonStyleType: ButtonStyleType.SUCCESS_OUTLINE,
|
||||
icon: IconProp.Check,
|
||||
isVisible: (item: DashboardDomain): boolean => {
|
||||
if (item["isCnameVerified"]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
onClick: async (
|
||||
item: DashboardDomain,
|
||||
onCompleteAction: VoidFunction,
|
||||
onError: ErrorFunction,
|
||||
) => {
|
||||
try {
|
||||
setShowCnameModal(true);
|
||||
setSelectedDashboardDomain(item);
|
||||
onCompleteAction();
|
||||
} catch (err) {
|
||||
onCompleteAction();
|
||||
onError(err as Error);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Order Free SSL",
|
||||
buttonStyleType: ButtonStyleType.SUCCESS_OUTLINE,
|
||||
icon: IconProp.Check,
|
||||
isVisible: (item: DashboardDomain): boolean => {
|
||||
if (
|
||||
!item.isCustomCertificate &&
|
||||
item["isCnameVerified"] &&
|
||||
!item.isSslOrdered
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
onClick: async (
|
||||
item: DashboardDomain,
|
||||
onCompleteAction: VoidFunction,
|
||||
onError: ErrorFunction,
|
||||
) => {
|
||||
try {
|
||||
setShowOrderSSLModal(true);
|
||||
setSelectedDashboardDomain(item);
|
||||
onCompleteAction();
|
||||
} catch (err) {
|
||||
onCompleteAction();
|
||||
setSelectedDashboardDomain(null);
|
||||
onError(err as Error);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
noItemsMessage={"No custom domains found."}
|
||||
viewPageRoute={Navigation.getCurrentRoute()}
|
||||
selectMoreFields={{
|
||||
isSslOrdered: true,
|
||||
isSslProvisioned: true,
|
||||
isCnameVerified: true,
|
||||
isCustomCertificate: true,
|
||||
}}
|
||||
formSteps={[
|
||||
{
|
||||
title: "Basic",
|
||||
id: "basic",
|
||||
},
|
||||
{
|
||||
title: "More",
|
||||
id: "more",
|
||||
},
|
||||
]}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
subdomain: true,
|
||||
},
|
||||
title: "Subdomain",
|
||||
fieldType: FormFieldSchemaType.Text,
|
||||
required: false,
|
||||
placeholder: "dashboard (leave blank for root)",
|
||||
description:
|
||||
"Enter the subdomain label only (for example, dashboard). Leave blank or enter @ to use the root/apex domain.",
|
||||
stepId: "basic",
|
||||
disableSpellCheck: true,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
domain: true,
|
||||
},
|
||||
title: "Domain",
|
||||
description:
|
||||
"Please select a verified domain from this list. If you do not see any domains in this list, please head over to More -> Project Settings -> Custom Domains to add one.",
|
||||
fieldType: FormFieldSchemaType.Dropdown,
|
||||
dropdownModal: {
|
||||
type: Domain,
|
||||
labelField: "domain",
|
||||
valueField: "_id",
|
||||
},
|
||||
required: true,
|
||||
placeholder: "Select domain",
|
||||
stepId: "basic",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isCustomCertificate: true,
|
||||
},
|
||||
title: "Upload Custom Certificate",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
stepId: "more",
|
||||
description:
|
||||
"If you have a custom certificate, you can upload it here. If you do not have a certificate, we will order a free SSL certificate for you.",
|
||||
},
|
||||
{
|
||||
field: {
|
||||
customCertificate: true,
|
||||
},
|
||||
title: "Certificate",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
stepId: "more",
|
||||
placeholder:
|
||||
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
|
||||
disableSpellCheck: true,
|
||||
showIf: (item: FormValues<DashboardDomain>): boolean => {
|
||||
return Boolean(item.isCustomCertificate);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: {
|
||||
customCertificateKey: true,
|
||||
},
|
||||
title: "Certificate Private Key",
|
||||
fieldType: FormFieldSchemaType.LongText,
|
||||
required: false,
|
||||
placeholder:
|
||||
"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
|
||||
stepId: "more",
|
||||
disableSpellCheck: true,
|
||||
showIf: (item: FormValues<DashboardDomain>): boolean => {
|
||||
return Boolean(item.isCustomCertificate);
|
||||
},
|
||||
},
|
||||
]}
|
||||
showRefreshButton={true}
|
||||
filters={[
|
||||
{
|
||||
field: {
|
||||
fullDomain: true,
|
||||
},
|
||||
title: "Domain",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {},
|
||||
title: "CNAME Valid",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
{
|
||||
field: {},
|
||||
title: "SSL Provisioned",
|
||||
type: FieldType.Boolean,
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
field: {
|
||||
fullDomain: true,
|
||||
},
|
||||
title: "Domain",
|
||||
type: FieldType.Text,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
isCnameVerified: true,
|
||||
},
|
||||
title: "Status",
|
||||
type: FieldType.Element,
|
||||
|
||||
getElement: (item: DashboardDomain): ReactElement => {
|
||||
if (!item.isCnameVerified) {
|
||||
return (
|
||||
<span>
|
||||
<span className="font-semibold">
|
||||
Action Required:
|
||||
</span>{" "}
|
||||
Please add your CNAME record.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.isCustomCertificate) {
|
||||
return (
|
||||
<span>
|
||||
No action is required. Please allow 30 minutes for
|
||||
the certificate to be provisioned.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item.isSslOrdered) {
|
||||
return (
|
||||
<span>
|
||||
<span className="font-semibold">
|
||||
Action Required:
|
||||
</span>{" "}
|
||||
Please order SSL certificate.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item.isSslProvisioned) {
|
||||
return (
|
||||
<span>
|
||||
No action is required. This SSL certificate will be
|
||||
provisioned in 1 hour. If this does not happen.
|
||||
Please contact support.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
Certificate Provisioned. We will automatically renew
|
||||
this certificate. No action required.{" "}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{selectedDashboardDomain?.fullDomain && showCnameModal && (
|
||||
<ConfirmModal
|
||||
title={`Add CNAME`}
|
||||
description={
|
||||
DashboardCNameRecord ? (
|
||||
<div>
|
||||
<span>
|
||||
Please add CNAME record to your domain. Details of
|
||||
the CNAME records are:
|
||||
</span>
|
||||
<br />
|
||||
<br />
|
||||
<span>
|
||||
<b>Record Type: </b> CNAME
|
||||
</span>
|
||||
<br />
|
||||
<span>
|
||||
<b>Name: </b>
|
||||
{selectedDashboardDomain?.fullDomain}
|
||||
</span>
|
||||
<br />
|
||||
<span>
|
||||
<b>Content: </b>
|
||||
{DashboardCNameRecord}
|
||||
</span>
|
||||
<br />
|
||||
<br />
|
||||
<span>
|
||||
Once you have done this, it should take 24 hours to
|
||||
automatically verify.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span>
|
||||
Custom Domains not enabled for this OneUptime
|
||||
installation. Please contact your server admin to
|
||||
enable this feature. To enable this feature, if you
|
||||
are using Docker compose, the
|
||||
<b>DASHBOARD_CNAME_RECORD</b> environment variable
|
||||
must be set when starting the OneUptime cluster. If
|
||||
you are using Helm and Kubernetes then set
|
||||
dashboard.cnameRecord in the values.yaml file.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
submitButtonText={"Verify CNAME"}
|
||||
onClose={() => {
|
||||
setShowCnameModal(false);
|
||||
setError("");
|
||||
return setSelectedDashboardDomain(null);
|
||||
}}
|
||||
isLoading={verifyCnameLoading}
|
||||
error={error}
|
||||
onSubmit={async () => {
|
||||
try {
|
||||
setVerifyCnameLoading(true);
|
||||
setError("");
|
||||
|
||||
const response:
|
||||
| HTTPResponse<JSONObject>
|
||||
| HTTPErrorResponse =
|
||||
await API.get<JSONObject>({
|
||||
url: URL.fromString(
|
||||
APP_API_URL.toString(),
|
||||
).addRoute(
|
||||
`/${
|
||||
new DashboardDomain().crudApiPath
|
||||
}/verify-cname/${selectedDashboardDomain?.id?.toString()}`,
|
||||
),
|
||||
data: {},
|
||||
headers: ModelAPI.getCommonHeaders(),
|
||||
});
|
||||
|
||||
if (response.isFailure()) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
setShowCnameModal(false);
|
||||
setRefreshToggle(
|
||||
OneUptimeDate.getCurrentDate().toString(),
|
||||
);
|
||||
setSelectedDashboardDomain(null);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
}
|
||||
|
||||
setVerifyCnameLoading(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOrderSSLModal && selectedDashboardDomain && (
|
||||
<ConfirmModal
|
||||
title={`Order Free SSL Certificate for this Dashboard`}
|
||||
description={
|
||||
DashboardCNameRecord ? (
|
||||
<div>
|
||||
Please click on the button below to order SSL for this
|
||||
domain. We will use LetsEncrypt to order a certificate.
|
||||
This process is secure and completely free. The
|
||||
certificate takes 3 hours to provision after its been
|
||||
ordered.
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span>
|
||||
Custom Domains not enabled for this OneUptime
|
||||
installation. Please contact your server admin to
|
||||
enable this feature.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
submitButtonText={"Order Free SSL"}
|
||||
onClose={() => {
|
||||
setShowOrderSSLModal(false);
|
||||
setError("");
|
||||
return setSelectedDashboardDomain(null);
|
||||
}}
|
||||
isLoading={orderSslLoading}
|
||||
error={error}
|
||||
onSubmit={async () => {
|
||||
try {
|
||||
setOrderSslLoading(true);
|
||||
setError("");
|
||||
|
||||
const response:
|
||||
| HTTPResponse<JSONObject>
|
||||
| HTTPErrorResponse =
|
||||
await API.get<JSONObject>({
|
||||
url: URL.fromString(
|
||||
APP_API_URL.toString(),
|
||||
).addRoute(
|
||||
`/${
|
||||
new DashboardDomain().crudApiPath
|
||||
}/order-ssl/${selectedDashboardDomain?.id?.toString()}`,
|
||||
),
|
||||
data: {},
|
||||
headers: ModelAPI.getCommonHeaders(),
|
||||
});
|
||||
|
||||
if (response.isFailure()) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
setShowOrderSSLModal(false);
|
||||
setRefreshToggle(
|
||||
OneUptimeDate.getCurrentDate().toString(),
|
||||
);
|
||||
setSelectedDashboardDomain(null);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
}
|
||||
|
||||
setOrderSslLoading(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardCustomDomains;
|
||||
@@ -0,0 +1,37 @@
|
||||
import URL from "Common/Types/API/URL";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import { PUBLIC_DASHBOARD_URL } from "Common/UI/Config";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import Link from "Common/UI/Components/Link/Link";
|
||||
|
||||
export interface ComponentProps {
|
||||
modelId: ObjectID;
|
||||
}
|
||||
|
||||
const DashboardPreviewLink: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={`Dashboard Preview URL`}
|
||||
description={
|
||||
<span>
|
||||
Here's a link to preview your public dashboard:{" "}
|
||||
<Link
|
||||
openInNewTab={true}
|
||||
to={URL.fromString(
|
||||
`${PUBLIC_DASHBOARD_URL.toString()}/${props.modelId}`,
|
||||
)}
|
||||
>
|
||||
<span>{`${PUBLIC_DASHBOARD_URL.toString()}/${props.modelId}`}</span>
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPreviewLink;
|
||||
@@ -8,6 +8,7 @@ import Navigation from "Common/UI/Utils/Navigation";
|
||||
import Label from "Common/Models/DatabaseModels/Label";
|
||||
import Dashboard from "Common/Models/DatabaseModels/Dashboard";
|
||||
import React, { Fragment, FunctionComponent, ReactElement } from "react";
|
||||
import DashboardPreviewLink from "./DashboardPreviewLink";
|
||||
|
||||
const DashboardView: FunctionComponent<
|
||||
PageComponentProps
|
||||
@@ -16,6 +17,7 @@ const DashboardView: FunctionComponent<
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<DashboardPreviewLink modelId={modelId} />
|
||||
{/* Dashboard View */}
|
||||
<CardModelDetail<Dashboard>
|
||||
name="Dashboard > Dashboard Details"
|
||||
|
||||
@@ -41,7 +41,33 @@ const DashboardSideMenu: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
</SideMenuSection>
|
||||
|
||||
<SideMenuSection title="Custom Domains">
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "Custom Domains",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.DASHBOARD_VIEW_CUSTOM_DOMAINS] as Route,
|
||||
{ modelId: props.modelId },
|
||||
),
|
||||
}}
|
||||
icon={IconProp.Globe}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
|
||||
<SideMenuSection title="Advanced">
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "Authentication",
|
||||
to: RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS
|
||||
] as Route,
|
||||
{ modelId: props.modelId },
|
||||
),
|
||||
}}
|
||||
icon={IconProp.Lock}
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
link={{
|
||||
title: "Settings",
|
||||
|
||||
@@ -116,7 +116,7 @@ const StatusPageDelete: FunctionComponent<PageComponentProps> = (
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
description:
|
||||
"Show uptime percentage for the past 90 days beside this group on your status page.",
|
||||
"Show uptime percentage beside this group on your status page. The number of days is configured in Status Page Settings.",
|
||||
stepId: "advanced",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -218,7 +218,7 @@ const StatusPageDelete: FunctionComponent<PageComponentProps> = (
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
description:
|
||||
"Show uptime percentage for the past 90 days beside this resource on your status page.",
|
||||
"Show uptime percentage beside this resource on your status page. The number of days is configured in Status Page Settings.",
|
||||
stepId: "advanced",
|
||||
},
|
||||
{
|
||||
@@ -242,7 +242,7 @@ const StatusPageDelete: FunctionComponent<PageComponentProps> = (
|
||||
title: "Show Status History Chart",
|
||||
fieldType: FormFieldSchemaType.Toggle,
|
||||
required: false,
|
||||
description: "Show resource status history for the past 90 days. ",
|
||||
description: "Show resource status history chart. The number of days is configured in Status Page Settings.",
|
||||
defaultValue: true,
|
||||
stepId: "advanced",
|
||||
},
|
||||
|
||||
@@ -270,6 +270,47 @@ const StatusPageDelete: FunctionComponent<
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardModelDetail<StatusPage>
|
||||
name="Status Page > Settings"
|
||||
cardProps={{
|
||||
title: "Uptime History Settings",
|
||||
description:
|
||||
"Configure how many days of uptime history to show on the status page",
|
||||
}}
|
||||
editButtonText="Edit Settings"
|
||||
isEditable={true}
|
||||
formFields={[
|
||||
{
|
||||
field: {
|
||||
showUptimeHistoryInDays: true,
|
||||
},
|
||||
title: "Show Uptime History (in days)",
|
||||
fieldType: FormFieldSchemaType.Number,
|
||||
required: true,
|
||||
placeholder: "90",
|
||||
validation: {
|
||||
minValue: 1,
|
||||
maxValue: 90,
|
||||
},
|
||||
},
|
||||
]}
|
||||
modelDetailProps={{
|
||||
showDetailsInNumberOfColumns: 1,
|
||||
modelType: StatusPage,
|
||||
id: "model-detail-status-page-uptime-history",
|
||||
fields: [
|
||||
{
|
||||
field: {
|
||||
showUptimeHistoryInDays: true,
|
||||
},
|
||||
fieldType: FieldType.Number,
|
||||
title: "Show Uptime History (in days)",
|
||||
},
|
||||
],
|
||||
modelId: modelId,
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardModelDetail<StatusPage>
|
||||
name="Status Page > Settings"
|
||||
cardProps={{
|
||||
|
||||
@@ -13,7 +13,6 @@ import ComponentMetadata, {
|
||||
NodeType,
|
||||
} from "Common/Types/Workflow/Component";
|
||||
import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import Card from "Common/UI/Components/Card/Card";
|
||||
import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader";
|
||||
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
|
||||
import { loadComponentsAndCategories } from "Common/UI/Components/Workflow/Utils";
|
||||
@@ -231,11 +230,11 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
},
|
||||
});
|
||||
|
||||
setSaveStatus("Changes Saved.");
|
||||
setSaveStatus("Saved");
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
|
||||
setSaveStatus("Save Error.");
|
||||
setSaveStatus("Error saving");
|
||||
}
|
||||
|
||||
if (saveTimeout) {
|
||||
@@ -250,100 +249,146 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
await loadGraph();
|
||||
}, []);
|
||||
|
||||
type GetSaveStatusColorFunction = () => string;
|
||||
|
||||
const getSaveStatusColor: GetSaveStatusColorFunction = (): string => {
|
||||
if (saveStatus === "Saved") {
|
||||
return "#10b981";
|
||||
}
|
||||
if (saveStatus === "Error saving") {
|
||||
return "#ef4444";
|
||||
}
|
||||
return "#94a3b8";
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<Card
|
||||
title={"Workflow Builder"}
|
||||
description={"Workflow builder for OneUptime"}
|
||||
rightElement={
|
||||
<div className="flex">
|
||||
<p className="text-sm text-gray-400 mr-3 mt-2">{saveStatus}</p>
|
||||
<div className="hidden md:block">
|
||||
<Button
|
||||
title="Watch Demo"
|
||||
icon={IconProp.Play}
|
||||
buttonStyle={ButtonStyleType.OUTLINE}
|
||||
onClick={() => {
|
||||
Navigation.navigate(
|
||||
URL.fromString("https://youtu.be/k1-reCQTZnM"),
|
||||
{
|
||||
openInNewTab: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
title="Add Component"
|
||||
icon={IconProp.Add}
|
||||
onClick={() => {
|
||||
setShowComponentPickerModal(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
title="Run Workflow Manually"
|
||||
icon={IconProp.Play}
|
||||
onClick={() => {
|
||||
setShowRunModal(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0.75rem 1rem",
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: "10px",
|
||||
border: "1px solid #e2e8f0",
|
||||
marginBottom: "0.75rem",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.03)",
|
||||
}}
|
||||
>
|
||||
{isLoading ? <ComponentLoader /> : <></>}
|
||||
|
||||
{!isLoading ? (
|
||||
<Workflow
|
||||
workflowId={modelId}
|
||||
showComponentsPickerModal={showComponentPickerModal}
|
||||
onComponentPickerModalUpdate={(value: boolean) => {
|
||||
setShowComponentPickerModal(value);
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.375rem",
|
||||
}}
|
||||
initialNodes={nodes}
|
||||
onRunModalUpdate={(value: boolean) => {
|
||||
setShowRunModal(value);
|
||||
}}
|
||||
showRunModal={showRunModal}
|
||||
initialEdges={edges}
|
||||
onWorkflowUpdated={async (
|
||||
nodes: Array<Node>,
|
||||
edges: Array<Edge>,
|
||||
) => {
|
||||
setNodes(nodes);
|
||||
setEdges(edges);
|
||||
await saveGraph(nodes, edges);
|
||||
}}
|
||||
onRun={async (component: NodeDataProp) => {
|
||||
try {
|
||||
const result: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.post({
|
||||
url: URL.fromString(WORKFLOW_URL.toString()).addRoute(
|
||||
"/manual/run/" + modelId.toString(),
|
||||
),
|
||||
data: {
|
||||
data: component.arguments,
|
||||
},
|
||||
});
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "7px",
|
||||
height: "7px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: getSaveStatusColor(),
|
||||
transition: "background-color 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: getSaveStatusColor(),
|
||||
fontWeight: 500,
|
||||
transition: "color 0.3s ease",
|
||||
}}
|
||||
>
|
||||
{saveStatus || "Ready"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if (result instanceof HTTPErrorResponse) {
|
||||
throw result;
|
||||
}
|
||||
|
||||
setShowRunSuccessConfirmation(true);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<Button
|
||||
title="Add Component"
|
||||
icon={IconProp.Add}
|
||||
buttonStyle={ButtonStyleType.OUTLINE}
|
||||
onClick={() => {
|
||||
setShowComponentPickerModal(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Card>
|
||||
<Button
|
||||
title="Run Workflow"
|
||||
icon={IconProp.Play}
|
||||
buttonStyle={ButtonStyleType.SUCCESS_OUTLINE}
|
||||
onClick={() => {
|
||||
setShowRunModal(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
{isLoading ? (
|
||||
<div
|
||||
style={{
|
||||
height: "calc(100vh - 280px)",
|
||||
minHeight: "500px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: "10px",
|
||||
border: "1px solid #e2e8f0",
|
||||
}}
|
||||
>
|
||||
<ComponentLoader />
|
||||
</div>
|
||||
) : (
|
||||
<Workflow
|
||||
workflowId={modelId}
|
||||
showComponentsPickerModal={showComponentPickerModal}
|
||||
onComponentPickerModalUpdate={(value: boolean) => {
|
||||
setShowComponentPickerModal(value);
|
||||
}}
|
||||
initialNodes={nodes}
|
||||
onRunModalUpdate={(value: boolean) => {
|
||||
setShowRunModal(value);
|
||||
}}
|
||||
showRunModal={showRunModal}
|
||||
initialEdges={edges}
|
||||
onWorkflowUpdated={async (
|
||||
nodes: Array<Node>,
|
||||
edges: Array<Edge>,
|
||||
) => {
|
||||
setNodes(nodes);
|
||||
setEdges(edges);
|
||||
await saveGraph(nodes, edges);
|
||||
}}
|
||||
onRun={async (component: NodeDataProp) => {
|
||||
try {
|
||||
const result: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.post({
|
||||
url: URL.fromString(WORKFLOW_URL.toString()).addRoute(
|
||||
"/manual/run/" + modelId.toString(),
|
||||
),
|
||||
data: {
|
||||
data: component.arguments,
|
||||
},
|
||||
});
|
||||
|
||||
if (result instanceof HTTPErrorResponse) {
|
||||
throw result;
|
||||
}
|
||||
|
||||
setShowRunSuccessConfirmation(true);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<ConfirmModal
|
||||
title={`Error`}
|
||||
@@ -358,9 +403,9 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
|
||||
|
||||
{showRunSuccessConfirmation && (
|
||||
<ConfirmModal
|
||||
title={`Workflow scheduled to execute`}
|
||||
description={`This workflow is scheduled to execute soon. You can see the status of the run in the Runs and Logs section.`}
|
||||
submitButtonText={"Close"}
|
||||
title={`Workflow Triggered`}
|
||||
description={`Your workflow has been scheduled to execute. Check the Logs tab to monitor the run.`}
|
||||
submitButtonText={"Got it"}
|
||||
onSubmit={() => {
|
||||
setShowRunSuccessConfirmation(false);
|
||||
}}
|
||||
|
||||
@@ -16,6 +16,10 @@ import DashboardViewDelete from "../Pages/Dashboards/View/Delete";
|
||||
|
||||
import DashboardViewSettings from "../Pages/Dashboards/View/Settings";
|
||||
|
||||
import DashboardViewAuthenticationSettings from "../Pages/Dashboards/View/AuthenticationSettings";
|
||||
|
||||
import DashboardViewCustomDomains from "../Pages/Dashboards/View/CustomDomains";
|
||||
|
||||
const DashboardsRoutes: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
@@ -74,6 +78,36 @@ const DashboardsRoutes: FunctionComponent<ComponentProps> = (
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(
|
||||
PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS,
|
||||
)}
|
||||
element={
|
||||
<DashboardViewAuthenticationSettings
|
||||
{...props}
|
||||
pageRoute={
|
||||
RouteMap[
|
||||
PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS
|
||||
] as Route
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteUtil.getLastPathForKey(
|
||||
PageMap.DASHBOARD_VIEW_CUSTOM_DOMAINS,
|
||||
)}
|
||||
element={
|
||||
<DashboardViewCustomDomains
|
||||
{...props}
|
||||
pageRoute={
|
||||
RouteMap[PageMap.DASHBOARD_VIEW_CUSTOM_DOMAINS] as Route
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageRoute>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -268,6 +268,8 @@ enum PageMap {
|
||||
DASHBOARD_VIEW_OVERVIEW = "DASHBOARD_VIEW_OVERVIEW",
|
||||
DASHBOARD_VIEW_DELETE = "DASHBOARD_VIEW_DELETE",
|
||||
DASHBOARD_VIEW_SETTINGS = "DASHBOARD_VIEW_SETTINGS",
|
||||
DASHBOARD_VIEW_AUTHENTICATION_SETTINGS = "DASHBOARD_VIEW_AUTHENTICATION_SETTINGS",
|
||||
DASHBOARD_VIEW_CUSTOM_DOMAINS = "DASHBOARD_VIEW_CUSTOM_DOMAINS",
|
||||
|
||||
STATUS_PAGES_ROOT = "STATUS_PAGES_ROOT",
|
||||
STATUS_PAGES = "STATUS_PAGES",
|
||||
|
||||
@@ -148,6 +148,8 @@ export const DashboardsRoutePath: Dictionary<string> = {
|
||||
[PageMap.DASHBOARD_VIEW_OVERVIEW]: `${RouteParams.ModelID}/overview`,
|
||||
[PageMap.DASHBOARD_VIEW_DELETE]: `${RouteParams.ModelID}/delete`,
|
||||
[PageMap.DASHBOARD_VIEW_SETTINGS]: `${RouteParams.ModelID}/settings`,
|
||||
[PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS]: `${RouteParams.ModelID}/authentication-settings`,
|
||||
[PageMap.DASHBOARD_VIEW_CUSTOM_DOMAINS]: `${RouteParams.ModelID}/custom-domains`,
|
||||
};
|
||||
|
||||
export const StatusPagesRoutePath: Dictionary<string> = {
|
||||
@@ -1754,6 +1756,18 @@ const RouteMap: Dictionary<Route> = {
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/dashboards/${
|
||||
DashboardsRoutePath[PageMap.DASHBOARD_VIEW_AUTHENTICATION_SETTINGS]
|
||||
}`,
|
||||
),
|
||||
|
||||
[PageMap.DASHBOARD_VIEW_CUSTOM_DOMAINS]: new Route(
|
||||
`/dashboard/${RouteParams.ProjectID}/dashboards/${
|
||||
DashboardsRoutePath[PageMap.DASHBOARD_VIEW_CUSTOM_DOMAINS]
|
||||
}`,
|
||||
),
|
||||
|
||||
// Status Pages
|
||||
|
||||
[PageMap.STATUS_PAGES_ROOT]: new Route(
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
StatusPageData,
|
||||
getStatusPageData,
|
||||
} from "./Utils/StatusPage";
|
||||
import DashboardDomainService from "Common/Server/Services/DashboardDomainService";
|
||||
import DashboardDomain from "Common/Models/DatabaseModels/DashboardDomain";
|
||||
|
||||
const app: ExpressApplication = Express.getExpressApp();
|
||||
|
||||
@@ -44,6 +46,11 @@ const StatusPagePublicPath: string =
|
||||
const StatusPageViewPath: string =
|
||||
"/usr/src/app/FeatureSet/StatusPage/views/index.ejs";
|
||||
|
||||
const PublicDashboardPublicPath: string =
|
||||
"/usr/src/app/FeatureSet/PublicDashboard/public";
|
||||
const PublicDashboardViewPath: string =
|
||||
"/usr/src/app/FeatureSet/PublicDashboard/views/index.ejs";
|
||||
|
||||
interface FrontendConfig {
|
||||
routePrefix: string;
|
||||
publicPath: string;
|
||||
@@ -67,6 +74,8 @@ const DashboardFallbackRoutePrefixesToSkip: Array<string> = [
|
||||
"/status-page-api",
|
||||
"/status-page-sso-api",
|
||||
"/status-page-identity-api",
|
||||
"/public-dashboard",
|
||||
"/public-dashboard-api",
|
||||
"/api",
|
||||
"/identity",
|
||||
"/notification",
|
||||
@@ -101,6 +110,7 @@ const StatusPageDomainFallbackRoutePrefixesToSkip: Array<string> = [
|
||||
"/status-page-api",
|
||||
"/status-page-sso-api",
|
||||
"/status-page-identity-api",
|
||||
"/public-dashboard-api",
|
||||
"/.well-known",
|
||||
"/rss",
|
||||
];
|
||||
@@ -140,6 +150,12 @@ const DashboardFrontendConfig: FrontendConfig = {
|
||||
primaryHostOnly: true,
|
||||
};
|
||||
|
||||
const PublicDashboardFrontendConfig: FrontendConfig = {
|
||||
routePrefix: "/public-dashboard",
|
||||
publicPath: PublicDashboardPublicPath,
|
||||
indexViewPath: PublicDashboardViewPath,
|
||||
};
|
||||
|
||||
const DashboardRootPwaFileMap: Array<{ route: string; file: string }> = [
|
||||
{ route: "/manifest.json", file: "manifest.json" },
|
||||
{ route: "/sw.js", file: "sw.js" },
|
||||
@@ -397,7 +413,32 @@ const registerFrontendApp: (frontendConfig: FrontendConfig) => void = (
|
||||
);
|
||||
};
|
||||
|
||||
const registerStatusPageCustomDomainFallback: () => void = (): void => {
|
||||
const isDashboardDomain: (hostname: string) => Promise<boolean> = async (
|
||||
hostname: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const dashboardDomain: DashboardDomain | null =
|
||||
await DashboardDomainService.findOneBy({
|
||||
query: {
|
||||
fullDomain: hostname,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
return dashboardDomain !== null;
|
||||
} catch (err) {
|
||||
logger.error("Error checking if domain is a dashboard domain:");
|
||||
logger.error(err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const registerCustomDomainFallback: () => void = (): void => {
|
||||
app.get(
|
||||
"*",
|
||||
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
|
||||
@@ -409,6 +450,20 @@ const registerStatusPageCustomDomainFallback: () => void = (): void => {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check if this custom domain belongs to a PublicDashboard.
|
||||
// If so, serve the PublicDashboard SPA instead of StatusPage.
|
||||
const requestHostname: string = getRequestHostname(req);
|
||||
|
||||
if (requestHostname && (await isDashboardDomain(requestHostname))) {
|
||||
return renderFrontendIndexPage({
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
frontendConfig: PublicDashboardFrontendConfig,
|
||||
});
|
||||
}
|
||||
|
||||
// Default: serve StatusPage for custom domains
|
||||
return renderFrontendIndexPage({
|
||||
req,
|
||||
res,
|
||||
@@ -483,8 +538,10 @@ const init: PromiseVoidFunction = async (): Promise<void> => {
|
||||
|
||||
registerFrontendApp(StatusPageFrontendConfig);
|
||||
|
||||
registerFrontendApp(PublicDashboardFrontendConfig);
|
||||
|
||||
registerDashboardRootPwaFiles();
|
||||
registerStatusPageCustomDomainFallback();
|
||||
registerCustomDomainFallback();
|
||||
registerDashboardFallbackForPrimaryHost();
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,79 @@ import User from "Common/Models/DatabaseModels/User";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
const BATCH_SIZE: number = 100;
|
||||
|
||||
async function sendBroadcastEmailsInBackground(data: {
|
||||
subject: string;
|
||||
htmlMessage: string;
|
||||
}): Promise<void> {
|
||||
let skip: number = 0;
|
||||
let sentCount: number = 0;
|
||||
let errorCount: number = 0;
|
||||
let totalUsers: number = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const users: Array<User> = await UserService.findBy({
|
||||
query: {},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
skip: skip,
|
||||
limit: BATCH_SIZE,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (users.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
totalUsers += users.length;
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const mail: EmailMessage = {
|
||||
templateType: EmailTemplateType.SimpleMessage,
|
||||
toEmail: user.email,
|
||||
subject: data.subject,
|
||||
vars: {
|
||||
subject: data.subject,
|
||||
message: data.htmlMessage,
|
||||
},
|
||||
body: "",
|
||||
};
|
||||
|
||||
await MailService.send(mail);
|
||||
sentCount++;
|
||||
} catch (err) {
|
||||
errorCount++;
|
||||
logger.error(
|
||||
`Failed to send broadcast email to ${user.email.toString()}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (users.length < BATCH_SIZE) {
|
||||
break;
|
||||
}
|
||||
|
||||
skip += users.length;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Broadcast email completed. Total users: ${totalUsers}, Sent: ${sentCount}, Errors: ${errorCount}`,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Broadcast email background job failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/send-test",
|
||||
MasterAdminAuthorization.isAuthorizedMasterAdminMiddleware,
|
||||
@@ -85,56 +158,24 @@ router.post(
|
||||
throw new BadDataException("Message is required");
|
||||
}
|
||||
|
||||
const users: Array<User> = await UserService.findAllBy({
|
||||
query: {},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
const htmlMessage: string = await Markdown.convertToHTML(
|
||||
message,
|
||||
MarkdownContentType.Email,
|
||||
);
|
||||
|
||||
let sentCount: number = 0;
|
||||
let errorCount: number = 0;
|
||||
// Send response immediately so the request doesn't timeout.
|
||||
// Emails are sent in the background.
|
||||
Response.sendJsonObjectResponse(req, res, {
|
||||
message:
|
||||
"Broadcast email job has been started. Emails will be sent in the background.",
|
||||
});
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const mail: EmailMessage = {
|
||||
templateType: EmailTemplateType.SimpleMessage,
|
||||
toEmail: user.email,
|
||||
subject: subject,
|
||||
vars: {
|
||||
subject: subject,
|
||||
message: htmlMessage,
|
||||
},
|
||||
body: "",
|
||||
};
|
||||
|
||||
await MailService.send(mail);
|
||||
sentCount++;
|
||||
} catch (err) {
|
||||
errorCount++;
|
||||
logger.error(
|
||||
`Failed to send broadcast email to ${user.email.toString()}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
totalUsers: users.length,
|
||||
sentCount: sentCount,
|
||||
errorCount: errorCount,
|
||||
// Process emails in the background after the response is sent.
|
||||
sendBroadcastEmailsInBackground({
|
||||
subject,
|
||||
htmlMessage,
|
||||
}).catch((err: Error) => {
|
||||
logger.error(`Broadcast email background job failed: ${err}`);
|
||||
});
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
|
||||
65
App/FeatureSet/PublicDashboard/Serve.ts
Normal file
65
App/FeatureSet/PublicDashboard/Serve.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
|
||||
import Express, {
|
||||
ExpressApplication,
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import App from "Common/Server/Utils/StartServer";
|
||||
import "ejs";
|
||||
import {
|
||||
getPublicDashboardData,
|
||||
PublicDashboardData,
|
||||
} from "./src/Server/Utils/PublicDashboard";
|
||||
|
||||
export const APP_NAME: string = "public-dashboard";
|
||||
|
||||
const app: ExpressApplication = Express.getExpressApp();
|
||||
|
||||
const init: PromiseVoidFunction = async (): Promise<void> => {
|
||||
try {
|
||||
// init the app
|
||||
await App.init({
|
||||
appName: APP_NAME,
|
||||
port: undefined,
|
||||
isFrontendApp: true,
|
||||
statusOptions: {
|
||||
liveCheck: async () => {},
|
||||
readyCheck: async () => {},
|
||||
},
|
||||
getVariablesToRenderIndexPage: async (
|
||||
req: ExpressRequest,
|
||||
_res: ExpressResponse,
|
||||
) => {
|
||||
const dashboardData: PublicDashboardData | null =
|
||||
await getPublicDashboardData(req);
|
||||
|
||||
if (dashboardData) {
|
||||
return {
|
||||
title: dashboardData.title,
|
||||
description: dashboardData.description,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Dashboard",
|
||||
description: "View dashboard metrics and insights.",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// add default routes
|
||||
await App.addDefaultRoutes();
|
||||
} catch (err) {
|
||||
logger.error("App Init Failed:");
|
||||
logger.error(err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
init().catch((err: Error) => {
|
||||
logger.error(err);
|
||||
logger.error("Exiting node process");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export default app;
|
||||
12
App/FeatureSet/PublicDashboard/esbuild.config.js
Normal file
12
App/FeatureSet/PublicDashboard/esbuild.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const { createConfig, build, watch } = require('Common/UI/esbuild-config');
|
||||
|
||||
const config = createConfig({
|
||||
serviceName: 'PublicDashboard',
|
||||
publicPath: '/public-dashboard/dist/',
|
||||
});
|
||||
|
||||
if (process.argv.includes('--watch')) {
|
||||
watch(config, 'PublicDashboard');
|
||||
} else {
|
||||
build(config, 'PublicDashboard');
|
||||
}
|
||||
30
App/FeatureSet/PublicDashboard/index.d.ts
vendored
Normal file
30
App/FeatureSet/PublicDashboard/index.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
declare module "*.png";
|
||||
declare module "*.svg";
|
||||
declare module "*.jpg";
|
||||
declare module "*.gif";
|
||||
|
||||
declare module "react-syntax-highlighter/dist/esm/prism-light";
|
||||
declare module "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/javascript";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/typescript";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/jsx";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/tsx";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/python";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/bash";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/json";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/yaml";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/sql";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/go";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/java";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/css";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/markup";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/markdown";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/docker";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/rust";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/c";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/cpp";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/csharp";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/ruby";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/php";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/graphql";
|
||||
declare module "react-syntax-highlighter/dist/esm/languages/prism/http";
|
||||
17
App/FeatureSet/PublicDashboard/nodemon.json
Normal file
17
App/FeatureSet/PublicDashboard/nodemon.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"watch": ["./*","../../Common/UI", "../../Common/Types", "../../Common/Utils", "../../Common/Models"],
|
||||
"ext": "ts,tsx",
|
||||
"ignore": [
|
||||
"./node_modules/**",
|
||||
"./public/**",
|
||||
"./bin/**",
|
||||
"./public/**",
|
||||
"./public/dist/**",
|
||||
"./build/*",
|
||||
"./build/**",
|
||||
"./build/dist/*",
|
||||
"./build/dist/**",
|
||||
"../../Common/Server/**"
|
||||
],
|
||||
"exec": " npm run dev-build && npm run start"
|
||||
}
|
||||
995
App/FeatureSet/PublicDashboard/package-lock.json
generated
Normal file
995
App/FeatureSet/PublicDashboard/package-lock.json
generated
Normal file
@@ -0,0 +1,995 @@
|
||||
{
|
||||
"name": "@oneuptime/public-dashboard",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@oneuptime/public-dashboard",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"Common": "file:../../../Common",
|
||||
"ejs": "^3.1.10",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.2",
|
||||
"use-async-effect": "^2.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.35",
|
||||
"@types/react": "^18.2.38",
|
||||
"@types/react-dom": "^18.0.4",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"nodemon": "^2.0.20",
|
||||
"ts-node": "^10.9.1"
|
||||
}
|
||||
},
|
||||
"../../../Common": {
|
||||
"name": "@oneuptime/common",
|
||||
"version": "1.0.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "^7.3.2",
|
||||
"@bull-board/express": "^5.21.4",
|
||||
"@clickhouse/client": "^1.10.1",
|
||||
"@elastic/elasticsearch": "^8.12.1",
|
||||
"@hcaptcha/react-hcaptcha": "^1.14.0",
|
||||
"@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.37.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/dompurify": "^3.0.5",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@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",
|
||||
"dompurify": "^3.3.2",
|
||||
"dotenv": "^16.4.4",
|
||||
"ejs": "^3.1.10",
|
||||
"elkjs": "^0.10.0",
|
||||
"esbuild": "^0.25.5",
|
||||
"expo-server-sdk": "^3.15.0",
|
||||
"express": "^4.21.1",
|
||||
"formik": "^2.4.6",
|
||||
"history": "^5.3.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"json2csv": "^5.0.7",
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"marked": "^12.0.2",
|
||||
"mermaid": "^11.12.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^7.0.7",
|
||||
"otpauth": "^9.3.1",
|
||||
"pg": "^8.16.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.19.4",
|
||||
"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": "^9.0.0",
|
||||
"react-router-dom": "^6.30.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": "^4.0.0",
|
||||
"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.6.0",
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/history": {
|
||||
"version": "4.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
|
||||
"integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "16.18.126",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
|
||||
"integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-router": {
|
||||
"version": "5.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
|
||||
"integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-router-dom": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
|
||||
"integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/react": "*",
|
||||
"@types/react-router": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/Common": {
|
||||
"resolved": "../../../Common",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jake": "^10.8.5"
|
||||
},
|
||||
"bin": {
|
||||
"ejs": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-by-default": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
||||
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jake": {
|
||||
"version": "10.9.4",
|
||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
|
||||
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"async": "^3.2.6",
|
||||
"filelist": "^1.0.4",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"jake": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "2.0.22",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",
|
||||
"integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.2",
|
||||
"debug": "^3.2.7",
|
||||
"ignore-by-default": "^1.0.1",
|
||||
"minimatch": "^3.1.2",
|
||||
"pstree.remy": "^1.1.8",
|
||||
"semver": "^5.7.1",
|
||||
"simple-update-notifier": "^1.0.7",
|
||||
"supports-color": "^5.5.0",
|
||||
"touch": "^3.1.0",
|
||||
"undefsafe": "^2.0.5"
|
||||
},
|
||||
"bin": {
|
||||
"nodemon": "bin/nodemon.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/nodemon"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
||||
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
||||
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
|
||||
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "~7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier/node_modules/semver": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
|
||||
"integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/touch": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
|
||||
"integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"nodetouch": "bin/nodetouch.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"acorn": "^8.4.1",
|
||||
"acorn-walk": "^8.1.1",
|
||||
"arg": "^4.1.0",
|
||||
"create-require": "^1.1.0",
|
||||
"diff": "^4.0.1",
|
||||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-async-effect": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/use-async-effect/-/use-async-effect-2.2.7.tgz",
|
||||
"integrity": "sha512-Vq94tKPyo/9Nok4LOapV0GoGgZPhbeDW/bP6bulLPV4+lIoftaBRBBbGjTbM+j5W1Bm2EkUHJgapeu5YnQvKEA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
App/FeatureSet/PublicDashboard/package.json
Normal file
52
App/FeatureSet/PublicDashboard/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@oneuptime/public-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/OneUptime/oneuptime"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npx nodemon",
|
||||
"build": "NODE_ENV=production node esbuild.config.js",
|
||||
"dev-build": "NODE_ENV=development node esbuild.config.js",
|
||||
"analyze": "analyze=true NODE_ENV=production node esbuild.config.js",
|
||||
"test": "",
|
||||
"compile": "tsc",
|
||||
"clear-modules": "rm -rf node_modules && rm package-lock.json && npm install",
|
||||
"start": "node --require ts-node/register Serve.ts",
|
||||
"audit": "npm audit --audit-level=low",
|
||||
"configure": "npx npm-force-resolutions || echo 'No package-lock.json file. Skipping force resolutions'",
|
||||
"dep-check": "npm install -g depcheck && depcheck ./ --skip-missing=true"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"Common": "file:../../../Common",
|
||||
|
||||
"ejs": "^3.1.10",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.2",
|
||||
"use-async-effect": "^2.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.35",
|
||||
"@types/react": "^18.2.38",
|
||||
"@types/react-dom": "^18.0.4",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"nodemon": "^2.0.20",
|
||||
|
||||
"ts-node": "^10.9.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
229
App/FeatureSet/PublicDashboard/src/App.tsx
Normal file
229
App/FeatureSet/PublicDashboard/src/App.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import PageMap from "./Utils/PageMap";
|
||||
import RouteMap from "./Utils/RouteMap";
|
||||
import RouteParams from "./Utils/RouteParams";
|
||||
import PublicDashboardUtil from "./Utils/PublicDashboard";
|
||||
import { PUBLIC_DASHBOARD_API_URL } from "./Utils/Config";
|
||||
import API from "./Utils/API";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import React, { lazy, Suspense, useEffect, useState } from "react";
|
||||
import {
|
||||
Route as PageRoute,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import useAsyncEffect from "use-async-effect";
|
||||
|
||||
// Lazy load page components
|
||||
type AllPagesModule = typeof import("./Pages/AllPages");
|
||||
|
||||
const DashboardViewPage: React.LazyExoticComponent<
|
||||
AllPagesModule["DashboardViewPage"]
|
||||
> = lazy(() => {
|
||||
return import("./Pages/AllPages").then((m: AllPagesModule) => {
|
||||
return { default: m.DashboardViewPage };
|
||||
});
|
||||
});
|
||||
|
||||
const MasterPassword: React.LazyExoticComponent<
|
||||
AllPagesModule["MasterPassword"]
|
||||
> = lazy(() => {
|
||||
return import("./Pages/AllPages").then((m: AllPagesModule) => {
|
||||
return { default: m.MasterPassword };
|
||||
});
|
||||
});
|
||||
|
||||
const NotFoundPage: React.LazyExoticComponent<
|
||||
AllPagesModule["NotFoundPage"]
|
||||
> = lazy(() => {
|
||||
return import("./Pages/AllPages").then((m: AllPagesModule) => {
|
||||
return { default: m.NotFoundPage };
|
||||
});
|
||||
});
|
||||
|
||||
const ForbiddenPage: React.LazyExoticComponent<
|
||||
AllPagesModule["ForbiddenPage"]
|
||||
> = lazy(() => {
|
||||
return import("./Pages/AllPages").then((m: AllPagesModule) => {
|
||||
return { default: m.ForbiddenPage };
|
||||
});
|
||||
});
|
||||
|
||||
const App: () => JSX.Element = () => {
|
||||
Navigation.setNavigateHook(useNavigate());
|
||||
Navigation.setLocation(useLocation());
|
||||
Navigation.setParams(useParams());
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dashboardId, setDashboardId] = useState<ObjectID | null>(null);
|
||||
const [dashboardName, setDashboardName] = useState<string>("Dashboard");
|
||||
const [isPreview, setIsPreview] = useState<boolean>(false);
|
||||
|
||||
type GetIdFunction = () => Promise<ObjectID>;
|
||||
|
||||
const getId: GetIdFunction = async (): Promise<ObjectID> => {
|
||||
if (PublicDashboardUtil.isPreviewPage()) {
|
||||
const id: string | null = Navigation.getParamByName(
|
||||
RouteParams.DashboardId,
|
||||
RouteMap[PageMap.PREVIEW_OVERVIEW]!,
|
||||
);
|
||||
if (id) {
|
||||
return new ObjectID(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Get dashboard ID by hostname (custom domain)
|
||||
const response: HTTPResponse<JSONObject> = await API.post<JSONObject>({
|
||||
url: URL.fromString(PUBLIC_DASHBOARD_API_URL.toString()).addRoute(
|
||||
`/domain`,
|
||||
),
|
||||
data: {
|
||||
domain: Navigation.getHostname().toString(),
|
||||
},
|
||||
headers: {},
|
||||
});
|
||||
|
||||
if (response.data && response.data["dashboardId"]) {
|
||||
return new ObjectID(response.data["dashboardId"] as string);
|
||||
}
|
||||
|
||||
throw new BadDataException("Dashboard not found for this domain");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const preview: boolean = PublicDashboardUtil.isPreviewPage();
|
||||
setIsPreview(preview);
|
||||
}, []);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const id: ObjectID = await getId();
|
||||
setDashboardId(id);
|
||||
PublicDashboardUtil.setDashboardId(id);
|
||||
|
||||
// Fetch dashboard metadata
|
||||
const response: HTTPResponse<JSONObject> = await API.post<JSONObject>({
|
||||
url: URL.fromString(PUBLIC_DASHBOARD_API_URL.toString()).addRoute(
|
||||
`/metadata/${id.toString()}`,
|
||||
),
|
||||
data: {},
|
||||
headers: {},
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
const name: string =
|
||||
(response.data["name"] as string) || "Dashboard";
|
||||
setDashboardName(name);
|
||||
document.title = name;
|
||||
|
||||
const enableMasterPassword: boolean = Boolean(
|
||||
response.data["enableMasterPassword"],
|
||||
);
|
||||
const isPublicDashboard: boolean = Boolean(
|
||||
response.data["isPublicDashboard"],
|
||||
);
|
||||
|
||||
if (!isPublicDashboard && enableMasterPassword) {
|
||||
PublicDashboardUtil.setRequiresMasterPassword(true);
|
||||
} else {
|
||||
PublicDashboardUtil.setRequiresMasterPassword(false);
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyMessage(err));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<ErrorMessage message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if master password is required and not validated
|
||||
if (
|
||||
dashboardId &&
|
||||
PublicDashboardUtil.requiresMasterPassword() &&
|
||||
!PublicDashboardUtil.isMasterPasswordValidated() &&
|
||||
!Navigation.getCurrentRoute().toString().includes("master-password")
|
||||
) {
|
||||
PublicDashboardUtil.navigateToMasterPasswordPage();
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageLoader isVisible={true} />}>
|
||||
<Routes>
|
||||
{/* Live routes (custom domain) */}
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.OVERVIEW]?.toString() || ""}
|
||||
element={
|
||||
dashboardId ? (
|
||||
<DashboardViewPage dashboardId={dashboardId} />
|
||||
) : (
|
||||
<NotFoundPage />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.MASTER_PASSWORD]?.toString() || ""}
|
||||
element={<MasterPassword dashboardName={dashboardName} />}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.FORBIDDEN]?.toString() || ""}
|
||||
element={<ForbiddenPage />}
|
||||
/>
|
||||
|
||||
{/* Preview routes (via /public-dashboard/:dashboardId) */}
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.PREVIEW_OVERVIEW]?.toString() || ""}
|
||||
element={
|
||||
dashboardId ? (
|
||||
<DashboardViewPage dashboardId={dashboardId} />
|
||||
) : (
|
||||
<NotFoundPage />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.PREVIEW_MASTER_PASSWORD]?.toString() || ""}
|
||||
element={<MasterPassword dashboardName={dashboardName} />}
|
||||
/>
|
||||
|
||||
<PageRoute
|
||||
path={RouteMap[PageMap.PREVIEW_FORBIDDEN]?.toString() || ""}
|
||||
element={<ForbiddenPage />}
|
||||
/>
|
||||
|
||||
{/* Catch-all */}
|
||||
<PageRoute path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,6 @@
|
||||
// Re-export the DashboardCanvas from the Dashboard FeatureSet
|
||||
// The PublicDashboard app reuses the same canvas rendering logic
|
||||
export {
|
||||
default,
|
||||
type ComponentProps,
|
||||
} from "../../../Dashboard/src/Components/Dashboard/Canvas/Index";
|
||||
19
App/FeatureSet/PublicDashboard/src/Index.tsx
Normal file
19
App/FeatureSet/PublicDashboard/src/Index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import App from "./App";
|
||||
import Telemetry from "Common/UI/Utils/Telemetry/Telemetry";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
Telemetry.init({
|
||||
serviceName: "public-dashboard",
|
||||
});
|
||||
|
||||
const root: any = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement,
|
||||
);
|
||||
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>,
|
||||
);
|
||||
4
App/FeatureSet/PublicDashboard/src/Pages/AllPages.tsx
Normal file
4
App/FeatureSet/PublicDashboard/src/Pages/AllPages.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as DashboardViewPage } from "./DashboardView/DashboardViewPage";
|
||||
export { default as MasterPassword } from "./MasterPassword/MasterPassword";
|
||||
export { default as NotFoundPage } from "./NotFound/NotFound";
|
||||
export { default as ForbiddenPage } from "./Forbidden/Forbidden";
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import DashboardVariable from "Common/Types/Dashboard/DashboardVariable";
|
||||
|
||||
export interface ComponentProps {
|
||||
variables: Array<DashboardVariable>;
|
||||
onVariableValueChange: (variableId: string, value: string) => void;
|
||||
}
|
||||
|
||||
const DashboardVariableSelector: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{props.variables.map((variable: DashboardVariable) => {
|
||||
return (
|
||||
<div key={variable.id} className="flex items-center gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-500">
|
||||
{variable.name}:
|
||||
</label>
|
||||
{variable.options && variable.options.length > 0 ? (
|
||||
<select
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white text-gray-700"
|
||||
value={variable.currentValue || variable.defaultValue || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
props.onVariableValueChange(variable.id!, e.target.value);
|
||||
}}
|
||||
>
|
||||
{variable.options.map((option: string) => {
|
||||
return (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white text-gray-700 w-24"
|
||||
value={variable.currentValue || variable.defaultValue || ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
props.onVariableValueChange(variable.id!, e.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardVariableSelector;
|
||||
@@ -0,0 +1,422 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import DashboardCanvas from "../../Components/DashboardCanvas";
|
||||
import DashboardMode from "Common/Types/Dashboard/DashboardMode";
|
||||
import DashboardViewConfig, {
|
||||
AutoRefreshInterval,
|
||||
getAutoRefreshIntervalInMs,
|
||||
getAutoRefreshIntervalLabel,
|
||||
} from "Common/Types/Dashboard/DashboardViewConfig";
|
||||
import { ObjectType } from "Common/Types/JSON";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Dashboard from "Common/Models/DatabaseModels/Dashboard";
|
||||
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
|
||||
import API from "../../Utils/API";
|
||||
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import DashboardViewConfigUtil from "Common/Utils/Dashboard/DashboardViewConfig";
|
||||
import DefaultDashboardSize from "Common/Types/Dashboard/DashboardSize";
|
||||
import { PromiseVoidFunction, VoidFunction } from "Common/Types/FunctionTypes";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import RangeStartAndEndDateTime from "Common/Types/Time/RangeStartAndEndDateTime";
|
||||
import TimeRange from "Common/Types/Time/TimeRange";
|
||||
import DashboardVariable from "Common/Types/Dashboard/DashboardVariable";
|
||||
import RangeStartAndEndDateView from "Common/UI/Components/Date/RangeStartAndEndDateView";
|
||||
import MoreMenu from "Common/UI/Components/MoreMenu/MoreMenu";
|
||||
import MoreMenuItem from "Common/UI/Components/MoreMenu/MoreMenuItem";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button";
|
||||
import DashboardVariableSelector from "./DashboardVariableSelector";
|
||||
import DashboardBaseComponent from "Common/Types/Dashboard/DashboardComponents/DashboardBaseComponent";
|
||||
import NavBar from "Common/UI/Components/Navbar/NavBar";
|
||||
import NavBarItem from "Common/UI/Components/Navbar/NavBarItem";
|
||||
import PageMap from "../../Utils/PageMap";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import PublicDashboardUtil from "../../Utils/PublicDashboard";
|
||||
import Route from "Common/Types/API/Route";
|
||||
|
||||
export interface ComponentProps {
|
||||
dashboardId: ObjectID;
|
||||
onLoadComplete?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
const DashboardViewPage: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [startAndEndDate, setStartAndEndDate] =
|
||||
useState<RangeStartAndEndDateTime>({
|
||||
range: TimeRange.PAST_ONE_HOUR,
|
||||
});
|
||||
|
||||
const [autoRefreshInterval, setAutoRefreshInterval] =
|
||||
useState<AutoRefreshInterval>(AutoRefreshInterval.OFF);
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
const [dashboardVariables, setDashboardVariables] = useState<
|
||||
Array<DashboardVariable>
|
||||
>([]);
|
||||
const [timeRangeStack, setTimeRangeStack] = useState<
|
||||
Array<RangeStartAndEndDateTime>
|
||||
>([]);
|
||||
const autoRefreshTimerRef: React.MutableRefObject<ReturnType<
|
||||
typeof setInterval
|
||||
> | null> = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [refreshTick, setRefreshTick] = useState<number>(0);
|
||||
|
||||
const dashboardViewRef: React.RefObject<HTMLDivElement> =
|
||||
useRef<HTMLDivElement>(null);
|
||||
|
||||
const [dashboardTotalWidth, setDashboardTotalWidth] = useState<number>(0);
|
||||
const [dashboardName, setDashboardName] = useState<string>("");
|
||||
|
||||
const handleResize: VoidFunction = (): void => {
|
||||
setDashboardTotalWidth(dashboardViewRef.current?.offsetWidth || 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDashboardTotalWidth(dashboardViewRef.current?.offsetWidth || 0);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [dashboardViewConfig, setDashboardViewConfig] =
|
||||
useState<DashboardViewConfig>({
|
||||
_type: ObjectType.DashboardViewConfig,
|
||||
components: [],
|
||||
heightInDashboardUnits: DefaultDashboardSize.heightInDashboardUnits,
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
const hasComponents: boolean = !!(
|
||||
dashboardViewConfig &&
|
||||
dashboardViewConfig.components &&
|
||||
dashboardViewConfig.components.length > 0
|
||||
);
|
||||
|
||||
const fetchDashboardViewConfig: PromiseVoidFunction =
|
||||
async (): Promise<void> => {
|
||||
const dashboard: Dashboard | null = await ModelAPI.getItem({
|
||||
modelType: Dashboard,
|
||||
id: props.dashboardId,
|
||||
select: {
|
||||
dashboardViewConfig: true,
|
||||
name: true,
|
||||
description: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
setError("Dashboard not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const config: DashboardViewConfig = JSONFunctions.deserializeValue(
|
||||
dashboard.dashboardViewConfig ||
|
||||
DashboardViewConfigUtil.createDefaultDashboardViewConfig(),
|
||||
) as DashboardViewConfig;
|
||||
|
||||
setDashboardViewConfig(config);
|
||||
setDashboardName(dashboard.name || "Untitled Dashboard");
|
||||
|
||||
if (config.refreshInterval) {
|
||||
setAutoRefreshInterval(config.refreshInterval);
|
||||
}
|
||||
|
||||
if (config.variables) {
|
||||
setDashboardVariables(config.variables);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPage: PromiseVoidFunction = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fetchDashboardViewConfig();
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
props.onLoadComplete?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadPage().catch((err: Error) => {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-refresh
|
||||
const triggerRefresh: () => void = useCallback(() => {
|
||||
setIsRefreshing(true);
|
||||
setRefreshTick((prev: number) => prev + 1);
|
||||
setTimeout(() => {
|
||||
setIsRefreshing(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefreshTimerRef.current) {
|
||||
clearInterval(autoRefreshTimerRef.current);
|
||||
autoRefreshTimerRef.current = null;
|
||||
}
|
||||
|
||||
const intervalMs: number | null =
|
||||
getAutoRefreshIntervalInMs(autoRefreshInterval);
|
||||
|
||||
if (intervalMs !== null) {
|
||||
autoRefreshTimerRef.current = setInterval(() => {
|
||||
triggerRefresh();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoRefreshTimerRef.current) {
|
||||
clearInterval(autoRefreshTimerRef.current);
|
||||
autoRefreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefreshInterval, triggerRefresh]);
|
||||
|
||||
const dashboardCanvasRef: React.RefObject<HTMLDivElement> =
|
||||
useRef<HTMLDivElement>(null);
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
const isPreview: boolean = PublicDashboardUtil.isPreviewPage();
|
||||
|
||||
const overviewRoute: Route = RouteUtil.populateRouteParams(
|
||||
isPreview
|
||||
? (RouteMap[PageMap.PREVIEW_OVERVIEW] as Route)
|
||||
: (RouteMap[PageMap.OVERVIEW] as Route),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dashboardViewRef}
|
||||
className="min-h-screen"
|
||||
style={{
|
||||
minWidth: "1000px",
|
||||
background: "#fafbfc",
|
||||
}}
|
||||
>
|
||||
{/* Header and NavBar */}
|
||||
<div className="max-w-5xl mx-auto px-3 sm:px-5">
|
||||
<div className="flex items-center justify-between mt-5">
|
||||
<h1 className="text-xl font-semibold text-gray-900 truncate">
|
||||
{dashboardName}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<NavBar
|
||||
className="bg-white flex text-center justify-between py-2 mt-5 rounded-lg shadow px-5"
|
||||
>
|
||||
<NavBarItem
|
||||
id="overview-nav-bar-item"
|
||||
title="Overview"
|
||||
icon={IconProp.CheckCircle}
|
||||
exact={true}
|
||||
route={overviewRoute}
|
||||
/>
|
||||
</NavBar>
|
||||
</div>
|
||||
|
||||
{/* Public Dashboard Toolbar */}
|
||||
<div
|
||||
className="mx-3 mt-3 mb-2 rounded-lg bg-white border border-gray-200"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px -1px rgba(0, 0, 0, 0.04)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-0.5 rounded-t-lg"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%)",
|
||||
}}
|
||||
></div>
|
||||
<div className="flex items-center justify-between px-5 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{hasComponents && (
|
||||
<span className="text-xs text-gray-400 tabular-nums">
|
||||
{dashboardViewConfig.components.length} widget
|
||||
{dashboardViewConfig.components.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{isRefreshing &&
|
||||
autoRefreshInterval !== AutoRefreshInterval.OFF && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-blue-600">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>
|
||||
Refreshing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Reset Zoom button */}
|
||||
{timeRangeStack.length > 0 && (
|
||||
<Button
|
||||
icon={IconProp.Refresh}
|
||||
title="Reset Zoom"
|
||||
buttonStyle={ButtonStyleType.HOVER_PRIMARY_OUTLINE}
|
||||
onClick={() => {
|
||||
const previousRange: RangeStartAndEndDateTime | undefined =
|
||||
timeRangeStack[0];
|
||||
if (previousRange) {
|
||||
setStartAndEndDate(previousRange);
|
||||
setTimeRangeStack([]);
|
||||
}
|
||||
}}
|
||||
tooltip="Reset to original time range"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Auto-refresh dropdown */}
|
||||
{hasComponents && (
|
||||
<MoreMenu
|
||||
menuIcon={IconProp.Refresh}
|
||||
text={
|
||||
autoRefreshInterval !== AutoRefreshInterval.OFF
|
||||
? getAutoRefreshIntervalLabel(autoRefreshInterval)
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{Object.values(AutoRefreshInterval).map(
|
||||
(interval: AutoRefreshInterval) => {
|
||||
return (
|
||||
<MoreMenuItem
|
||||
key={interval}
|
||||
text={getAutoRefreshIntervalLabel(interval)}
|
||||
onClick={() => {
|
||||
setAutoRefreshInterval(interval);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</MoreMenu>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon={IconProp.Expand}
|
||||
buttonStyle={ButtonStyleType.ICON}
|
||||
onClick={() => {
|
||||
const canvasElement: HTMLDivElement | null =
|
||||
dashboardCanvasRef.current;
|
||||
|
||||
if (!canvasElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canvasElement.requestFullscreen) {
|
||||
canvasElement.requestFullscreen();
|
||||
}
|
||||
}}
|
||||
tooltip="Full Screen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom row: Time range + variables */}
|
||||
{hasComponents && (
|
||||
<div className="flex items-center gap-3 px-5 pb-3 pt-0 flex-wrap">
|
||||
<div>
|
||||
<RangeStartAndEndDateView
|
||||
dashboardStartAndEndDate={startAndEndDate}
|
||||
onChange={(newRange: RangeStartAndEndDateTime) => {
|
||||
setTimeRangeStack([
|
||||
...timeRangeStack,
|
||||
startAndEndDate,
|
||||
]);
|
||||
setStartAndEndDate(newRange);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dashboardVariables.length > 0 && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-gray-200"></div>
|
||||
<DashboardVariableSelector
|
||||
variables={dashboardVariables}
|
||||
onVariableValueChange={(
|
||||
variableId: string,
|
||||
value: string,
|
||||
) => {
|
||||
setDashboardVariables(
|
||||
dashboardVariables.map(
|
||||
(v: DashboardVariable) => {
|
||||
if (v.id === variableId) {
|
||||
return { ...v, currentValue: value };
|
||||
}
|
||||
return v;
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={dashboardCanvasRef}>
|
||||
<DashboardCanvas
|
||||
dashboardViewConfig={dashboardViewConfig}
|
||||
onDashboardViewConfigChange={(_config: DashboardViewConfig) => {
|
||||
// Read-only in public view
|
||||
}}
|
||||
dashboardMode={DashboardMode.View}
|
||||
selectedComponentId={null}
|
||||
onComponentSelected={(_id: ObjectID | null) => {
|
||||
// No selection in public view
|
||||
}}
|
||||
dashboardTotalWidth={dashboardTotalWidth}
|
||||
startAndEndDate={startAndEndDate}
|
||||
onStartAndEndDateChange={(
|
||||
newRange: RangeStartAndEndDateTime,
|
||||
) => {
|
||||
setTimeRangeStack([...timeRangeStack, startAndEndDate]);
|
||||
setStartAndEndDate(newRange);
|
||||
}}
|
||||
refreshTick={refreshTick}
|
||||
dashboardVariables={dashboardVariables}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="max-w-5xl mx-auto px-3 sm:px-5 py-5">
|
||||
<div className="flex items-center justify-center text-xs text-gray-400">
|
||||
<span>Powered by</span>
|
||||
<a
|
||||
href="https://oneuptime.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1 text-gray-500 hover:text-gray-700 font-medium"
|
||||
>
|
||||
OneUptime
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardViewPage;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
const ForbiddenPage: FunctionComponent = (): ReactElement => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-gray-300">403</h1>
|
||||
<h2 className="mt-4 text-xl font-semibold text-gray-700">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
You do not have permission to view this dashboard. Your IP address may
|
||||
be restricted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForbiddenPage;
|
||||
@@ -0,0 +1,167 @@
|
||||
import { PUBLIC_DASHBOARD_API_URL } from "../../Utils/Config";
|
||||
import PublicDashboardUtil from "../../Utils/PublicDashboard";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import BasicForm from "Common/UI/Components/Forms/BasicForm";
|
||||
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import API from "../../Utils/API";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
import PageLoader from "Common/UI/Components/Loader/PageLoader";
|
||||
import React, { FunctionComponent, useEffect, useState } from "react";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
|
||||
export interface ComponentProps {
|
||||
dashboardName: string;
|
||||
}
|
||||
|
||||
const MasterPasswordPage: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): JSX.Element => {
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const dashboardId: ObjectID | null = PublicDashboardUtil.getDashboardId();
|
||||
|
||||
const redirectToOverview: () => void = (): void => {
|
||||
const path: string = PublicDashboardUtil.isPreviewPage()
|
||||
? `/public-dashboard/${PublicDashboardUtil.getDashboardId()?.toString()}`
|
||||
: "/";
|
||||
|
||||
Navigation.navigate(new Route(path), { forceNavigate: true });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!PublicDashboardUtil.requiresMasterPassword()) {
|
||||
redirectToOverview();
|
||||
return;
|
||||
}
|
||||
|
||||
if (PublicDashboardUtil.isMasterPasswordValidated()) {
|
||||
redirectToOverview();
|
||||
return;
|
||||
}
|
||||
}, [dashboardId]);
|
||||
|
||||
if (!dashboardId || !PublicDashboardUtil.requiresMasterPassword()) {
|
||||
return <PageLoader isVisible={true} />;
|
||||
}
|
||||
|
||||
const handleFormSubmit: (
|
||||
values: JSONObject,
|
||||
onSubmitSuccessful?: () => void,
|
||||
) => Promise<void> = async (
|
||||
values: JSONObject,
|
||||
onSubmitSuccessful?: () => void,
|
||||
): Promise<void> => {
|
||||
const submittedPassword: string =
|
||||
(values["password"] as { toString: () => string } | undefined)
|
||||
?.toString()
|
||||
.trim() || "";
|
||||
|
||||
if (!submittedPassword) {
|
||||
setFormError("Password is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dashboardId) {
|
||||
throw new BadDataException("Dashboard ID not found");
|
||||
}
|
||||
|
||||
const url: URL = URL.fromString(
|
||||
PUBLIC_DASHBOARD_API_URL.toString(),
|
||||
).addRoute(`/master-password/${dashboardId.toString()}`);
|
||||
|
||||
setIsSubmitting(true);
|
||||
setFormError(null);
|
||||
|
||||
try {
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post<JSONObject>({
|
||||
url,
|
||||
data: {
|
||||
password: submittedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.isFailure()) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
PublicDashboardUtil.setMasterPasswordValidated(true);
|
||||
|
||||
const redirectUrl: string | null =
|
||||
Navigation.getQueryStringByName("redirectUrl");
|
||||
|
||||
if (redirectUrl) {
|
||||
Navigation.navigate(new Route(redirectUrl), {
|
||||
forceNavigate: true,
|
||||
});
|
||||
} else {
|
||||
redirectToOverview();
|
||||
}
|
||||
|
||||
onSubmitSuccessful?.();
|
||||
} catch (err) {
|
||||
setFormError(API.getFriendlyMessage(err));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="mt-6 text-center text-2xl tracking-tight text-gray-900">
|
||||
{props.dashboardName
|
||||
? `Enter ${props.dashboardName} Password`
|
||||
: "Enter Password"}
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Please enter the password to view this dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
<BasicForm
|
||||
id="master-password-form"
|
||||
name="Password Unlock"
|
||||
initialValues={{
|
||||
password: "",
|
||||
}}
|
||||
fields={[
|
||||
{
|
||||
field: {
|
||||
password: true,
|
||||
},
|
||||
title: "Password",
|
||||
description: "Enter the password to unlock this dashboard.",
|
||||
required: true,
|
||||
placeholder: "Enter password",
|
||||
fieldType: FormFieldSchemaType.Password,
|
||||
disableSpellCheck: true,
|
||||
},
|
||||
]}
|
||||
submitButtonText="Unlock Dashboard"
|
||||
maxPrimaryButtonWidth={true}
|
||||
isLoading={isSubmitting}
|
||||
error={formError || undefined}
|
||||
onSubmit={(
|
||||
values: JSONObject,
|
||||
onSubmitSuccessful?: () => void,
|
||||
) => {
|
||||
void handleFormSubmit(values, onSubmitSuccessful);
|
||||
}}
|
||||
footer={<></>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MasterPasswordPage;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
const NotFoundPage: FunctionComponent = (): ReactElement => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-gray-300">404</h1>
|
||||
<h2 className="mt-4 text-xl font-semibold text-gray-700">
|
||||
Dashboard Not Found
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
The dashboard you are looking for does not exist or is no longer
|
||||
available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFoundPage;
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ExpressRequest } from "Common/Server/Utils/Express";
|
||||
import API from "Common/Utils/API";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import HTTPResponse from "Common/Types/API/HTTPResponse";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
|
||||
const DashboardApiInternalUrl: string =
|
||||
process.env["DASHBOARD_API_URL"] ||
|
||||
`http://${process.env["SERVER_APP_HOSTNAME"] || "localhost"}:${process.env["APP_PORT"] || "3002"}/api/dashboard`;
|
||||
|
||||
export interface PublicDashboardData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const getPublicDashboardData: (
|
||||
req: ExpressRequest,
|
||||
) => Promise<PublicDashboardData | null> = async (
|
||||
req: ExpressRequest,
|
||||
): Promise<PublicDashboardData | null> => {
|
||||
try {
|
||||
logger.debug("Getting public dashboard data");
|
||||
|
||||
let dashboardIdOrDomain: string = "";
|
||||
let isPreview: boolean = false;
|
||||
|
||||
const path: string = req.path;
|
||||
logger.debug(`Request path: ${path}`);
|
||||
|
||||
if (path && path.includes("/public-dashboard/")) {
|
||||
dashboardIdOrDomain =
|
||||
path.split("/public-dashboard/")[1]?.split("/")[0] || "";
|
||||
isPreview = true;
|
||||
logger.debug(`Found dashboard ID in URL: ${dashboardIdOrDomain}`);
|
||||
} else {
|
||||
const host: string =
|
||||
req.hostname?.toString() || req.headers["host"]?.toString() || "";
|
||||
if (host) {
|
||||
dashboardIdOrDomain = host;
|
||||
logger.debug(
|
||||
`Found domain in request headers: ${dashboardIdOrDomain}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dashboardIdOrDomain) {
|
||||
logger.debug("No dashboard ID or domain found");
|
||||
return null;
|
||||
}
|
||||
|
||||
let dashboardId: string;
|
||||
let title: string = "Dashboard";
|
||||
let description: string = "View dashboard metrics and insights.";
|
||||
|
||||
if (isPreview) {
|
||||
dashboardId = dashboardIdOrDomain;
|
||||
} else {
|
||||
logger.debug(
|
||||
`Pinging the API with dashboardIdOrDomain: ${dashboardIdOrDomain}`,
|
||||
);
|
||||
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.get({
|
||||
url: URL.fromString(DashboardApiInternalUrl).addRoute(
|
||||
`/seo/${dashboardIdOrDomain}`,
|
||||
),
|
||||
});
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
logger.debug(`Received error response from API: ${response}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug("Successfully received response from API");
|
||||
|
||||
dashboardId = response.data?.["_id"] as string;
|
||||
if (!dashboardId) {
|
||||
logger.debug("No dashboard ID in response");
|
||||
return null;
|
||||
}
|
||||
|
||||
title = (response.data?.["title"] as string) || title;
|
||||
description = (response.data?.["description"] as string) || description;
|
||||
}
|
||||
|
||||
return {
|
||||
id: dashboardId,
|
||||
title,
|
||||
description,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error("Error getting public dashboard data:");
|
||||
logger.error(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
48
App/FeatureSet/PublicDashboard/src/Utils/API.ts
Normal file
48
App/FeatureSet/PublicDashboard/src/Utils/API.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import PublicDashboardUtil from "./PublicDashboard";
|
||||
import Headers from "Common/Types/API/Headers";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import BaseAPI from "Common/UI/Utils/API/API";
|
||||
|
||||
export default class API extends BaseAPI {
|
||||
public static override getDefaultHeaders(): Headers {
|
||||
const dashboardId: ObjectID | null =
|
||||
PublicDashboardUtil.getDashboardId();
|
||||
|
||||
if (!dashboardId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"dashboard-id": dashboardId.toString(),
|
||||
tenantid: "",
|
||||
};
|
||||
}
|
||||
|
||||
public static override getLoginRoute(): Route {
|
||||
const basePath: string = PublicDashboardUtil.isPreviewPage()
|
||||
? `/public-dashboard/${PublicDashboardUtil.getDashboardId()?.toString()}`
|
||||
: "";
|
||||
|
||||
if (
|
||||
PublicDashboardUtil.requiresMasterPassword() &&
|
||||
!PublicDashboardUtil.isMasterPasswordValidated()
|
||||
) {
|
||||
return new Route(`${basePath}/master-password`);
|
||||
}
|
||||
|
||||
return new Route(`${basePath}/`);
|
||||
}
|
||||
|
||||
public static override logoutUser(): void {
|
||||
// No-op for public dashboards
|
||||
}
|
||||
|
||||
public static override getForbiddenRoute(): Route {
|
||||
return new Route(
|
||||
PublicDashboardUtil.isPreviewPage()
|
||||
? `/public-dashboard/${PublicDashboardUtil.getDashboardId()?.toString()}/forbidden`
|
||||
: "/forbidden",
|
||||
);
|
||||
}
|
||||
}
|
||||
13
App/FeatureSet/PublicDashboard/src/Utils/Config.ts
Normal file
13
App/FeatureSet/PublicDashboard/src/Utils/Config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Protocol from "Common/Types/API/Protocol";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import URL from "Common/Types/API/URL";
|
||||
|
||||
const PROTOCOL: Protocol = window.location.protocol.includes("https")
|
||||
? Protocol.HTTPS
|
||||
: Protocol.HTTP;
|
||||
|
||||
export const PUBLIC_DASHBOARD_API_URL: URL = new URL(
|
||||
PROTOCOL,
|
||||
window.location.host,
|
||||
new Route("/public-dashboard-api"),
|
||||
);
|
||||
13
App/FeatureSet/PublicDashboard/src/Utils/PageMap.ts
Normal file
13
App/FeatureSet/PublicDashboard/src/Utils/PageMap.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
enum PageMap {
|
||||
OVERVIEW = "OVERVIEW",
|
||||
NOT_FOUND = "NOT_FOUND",
|
||||
FORBIDDEN = "FORBIDDEN",
|
||||
MASTER_PASSWORD = "MASTER_PASSWORD",
|
||||
|
||||
// Preview routes (accessed via /public-dashboard/:dashboardId)
|
||||
PREVIEW_OVERVIEW = "PREVIEW_OVERVIEW",
|
||||
PREVIEW_FORBIDDEN = "PREVIEW_FORBIDDEN",
|
||||
PREVIEW_MASTER_PASSWORD = "PREVIEW_MASTER_PASSWORD",
|
||||
}
|
||||
|
||||
export default PageMap;
|
||||
113
App/FeatureSet/PublicDashboard/src/Utils/PublicDashboard.ts
Normal file
113
App/FeatureSet/PublicDashboard/src/Utils/PublicDashboard.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Typeof from "Common/Types/Typeof";
|
||||
import LocalStorage from "Common/UI/Utils/LocalStorage";
|
||||
import Navigation from "Common/UI/Utils/Navigation";
|
||||
|
||||
export default class PublicDashboardUtil {
|
||||
public static getDashboardId(): ObjectID | null {
|
||||
const value: ObjectID | null = LocalStorage.getItem(
|
||||
"dashboardId",
|
||||
) as ObjectID | null;
|
||||
|
||||
if (value && typeof value === Typeof.String) {
|
||||
return new ObjectID(value.toString());
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static setDashboardId(id: ObjectID | null): void {
|
||||
LocalStorage.setItem("dashboardId", id);
|
||||
}
|
||||
|
||||
public static setRequiresMasterPassword(value: boolean): void {
|
||||
const storageKey: string = PublicDashboardUtil.getRequiresMasterPasswordStorageKey();
|
||||
LocalStorage.setItem(storageKey, value);
|
||||
|
||||
if (!value) {
|
||||
PublicDashboardUtil.setMasterPasswordValidated(false);
|
||||
}
|
||||
}
|
||||
|
||||
public static requiresMasterPassword(): boolean {
|
||||
const storageKey: string = PublicDashboardUtil.getRequiresMasterPasswordStorageKey();
|
||||
return Boolean(LocalStorage.getItem(storageKey));
|
||||
}
|
||||
|
||||
private static getDashboardScopedStorageKey(baseKey: string): string {
|
||||
const dashboardId: ObjectID | null = PublicDashboardUtil.getDashboardId();
|
||||
|
||||
if (!dashboardId) {
|
||||
return baseKey;
|
||||
}
|
||||
|
||||
return `${baseKey}-${dashboardId.toString()}`;
|
||||
}
|
||||
|
||||
private static getRequiresMasterPasswordStorageKey(): string {
|
||||
return PublicDashboardUtil.getDashboardScopedStorageKey(
|
||||
"dashboardRequiresMasterPassword",
|
||||
);
|
||||
}
|
||||
|
||||
private static getMasterPasswordValidationStorageKey(): string {
|
||||
const dashboardId: ObjectID | null = PublicDashboardUtil.getDashboardId();
|
||||
|
||||
if (!dashboardId) {
|
||||
return "dashboardMasterPasswordValidated";
|
||||
}
|
||||
|
||||
return `dashboardMasterPasswordValidated-${dashboardId.toString()}`;
|
||||
}
|
||||
|
||||
public static setMasterPasswordValidated(value: boolean): void {
|
||||
const storageKey: string =
|
||||
PublicDashboardUtil.getMasterPasswordValidationStorageKey();
|
||||
LocalStorage.setItem(storageKey, value);
|
||||
}
|
||||
|
||||
public static isMasterPasswordValidated(): boolean {
|
||||
const storageKey: string =
|
||||
PublicDashboardUtil.getMasterPasswordValidationStorageKey();
|
||||
return Boolean(LocalStorage.getItem(storageKey));
|
||||
}
|
||||
|
||||
public static isPreviewPage(): boolean {
|
||||
return Navigation.containsInPath("/public-dashboard/");
|
||||
}
|
||||
|
||||
public static navigateToMasterPasswordPage(): void {
|
||||
if (Navigation.getCurrentRoute().toString().includes("master-password")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath: string = Navigation.getCurrentPath().toString();
|
||||
const basePath: string = PublicDashboardUtil.isPreviewPage()
|
||||
? `/public-dashboard/${PublicDashboardUtil.getDashboardId()?.toString()}`
|
||||
: "";
|
||||
|
||||
const route: Route = new Route(
|
||||
`${basePath}/master-password?redirectUrl=${currentPath}`,
|
||||
);
|
||||
|
||||
Navigation.navigate(route, { forceNavigate: true });
|
||||
}
|
||||
|
||||
public static async checkIfTheUserIsAuthenticated(
|
||||
errorResponse: HTTPErrorResponse,
|
||||
): Promise<void> {
|
||||
if (
|
||||
errorResponse instanceof HTTPErrorResponse &&
|
||||
errorResponse.statusCode === 401
|
||||
) {
|
||||
if (
|
||||
PublicDashboardUtil.requiresMasterPassword() &&
|
||||
!PublicDashboardUtil.isMasterPasswordValidated()
|
||||
) {
|
||||
PublicDashboardUtil.navigateToMasterPasswordPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
App/FeatureSet/PublicDashboard/src/Utils/RouteMap.ts
Normal file
47
App/FeatureSet/PublicDashboard/src/Utils/RouteMap.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import PageMap from "./PageMap";
|
||||
import RouteParams from "./RouteParams";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import LocalStorage from "Common/UI/Utils/LocalStorage";
|
||||
|
||||
const RouteMap: Dictionary<Route> = {
|
||||
[PageMap.OVERVIEW]: new Route(`/`),
|
||||
[PageMap.NOT_FOUND]: new Route(`/not-found`),
|
||||
[PageMap.FORBIDDEN]: new Route(`/forbidden`),
|
||||
[PageMap.MASTER_PASSWORD]: new Route(`/master-password`),
|
||||
|
||||
// Preview routes
|
||||
[PageMap.PREVIEW_OVERVIEW]: new Route(
|
||||
`/public-dashboard/${RouteParams.DashboardId}`,
|
||||
),
|
||||
[PageMap.PREVIEW_FORBIDDEN]: new Route(
|
||||
`/public-dashboard/${RouteParams.DashboardId}/forbidden`,
|
||||
),
|
||||
[PageMap.PREVIEW_MASTER_PASSWORD]: new Route(
|
||||
`/public-dashboard/${RouteParams.DashboardId}/master-password`,
|
||||
),
|
||||
};
|
||||
|
||||
export class RouteUtil {
|
||||
public static populateRouteParams(route: Route, modelId?: ObjectID): Route {
|
||||
const tempRoute: Route = new Route(route.toString());
|
||||
|
||||
if (modelId) {
|
||||
route = tempRoute.addRouteParam(RouteParams.ModelID, modelId.toString());
|
||||
}
|
||||
|
||||
const id: ObjectID = LocalStorage.getItem("dashboardId") as ObjectID;
|
||||
|
||||
if (id) {
|
||||
route = tempRoute.addRouteParam(
|
||||
RouteParams.DashboardId,
|
||||
id.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
return tempRoute;
|
||||
}
|
||||
}
|
||||
|
||||
export default RouteMap;
|
||||
6
App/FeatureSet/PublicDashboard/src/Utils/RouteParams.ts
Normal file
6
App/FeatureSet/PublicDashboard/src/Utils/RouteParams.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
enum RouteParams {
|
||||
DashboardId = ":dashboardId",
|
||||
ModelID = ":id",
|
||||
}
|
||||
|
||||
export default RouteParams;
|
||||
43
App/FeatureSet/PublicDashboard/tsconfig.json
Normal file
43
App/FeatureSet/PublicDashboard/tsconfig.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
},
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"jsx": "react",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"module": "es2022",
|
||||
"moduleResolution": "node",
|
||||
"typeRoots": [
|
||||
"./node_modules/@types"
|
||||
],
|
||||
"types": ["node"],
|
||||
"sourceMap": true,
|
||||
"outDir": "./build/dist",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
87
App/FeatureSet/PublicDashboard/views/index.ejs
Normal file
87
App/FeatureSet/PublicDashboard/views/index.ejs
Normal file
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta charSet='utf-8' />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<meta name="description" content="<%= typeof description !== 'undefined' ? description : 'View dashboard metrics and insights.' %>">
|
||||
<script src="/public-dashboard/env.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" as="style">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||
background: transparent;
|
||||
bottom: 0;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
height: auto;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
background: transparent;
|
||||
bottom: 0;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
height: auto;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: auto;
|
||||
}
|
||||
</style>
|
||||
<link rel="preload" href="/public-dashboard/assets/js/tailwind-3.4.5.js" as="script">
|
||||
<script src="/public-dashboard/assets/js/tailwind-3.4.5.js"></script>
|
||||
<title><%= typeof title !== 'undefined' ? title : 'Dashboard' %></title>
|
||||
|
||||
<% if(typeof enableGoogleTagManager !== 'undefined' ? enableGoogleTagManager : false){ %>
|
||||
<!-- Google Tag Manager -->
|
||||
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','GTM-PKQD5WH');</script>
|
||||
<!-- End Google Tag Manager -->
|
||||
<% } %>
|
||||
|
||||
</head>
|
||||
<body class="h-full bg-gray-50">
|
||||
<% if(typeof enableGoogleTagManager !== 'undefined' ? enableGoogleTagManager : false){ %>
|
||||
<!-- Google Tag Manager (noscript) -->
|
||||
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-PKQD5WH"
|
||||
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
|
||||
<!-- End Google Tag Manager (noscript) -->
|
||||
<% } %>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/public-dashboard/dist/Index.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend:
|
||||
{
|
||||
fontFamily: {
|
||||
'display': ['Inter', 'sans-serif'],
|
||||
'body': ['Inter', 'sans-serif']
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -30,6 +30,7 @@ export interface ComponentProps {
|
||||
statusPageHistoryChartBarColorRules: Array<StatusPageHistoryChartBarColorRule>;
|
||||
downtimeMonitorStatuses: Array<MonitorStatus>;
|
||||
defaultBarColor: Color;
|
||||
uptimeHistoryDays?: number | undefined;
|
||||
}
|
||||
|
||||
const MonitorOverview: FunctionComponent<ComponentProps> = (
|
||||
@@ -143,7 +144,7 @@ const MonitorOverview: FunctionComponent<ComponentProps> = (
|
||||
{/* Time labels: Visible on all screen sizes */}
|
||||
{props.showHistoryChart && (
|
||||
<div className="text-xs sm:text-sm text-gray-400 mt-1 justify-between flex">
|
||||
<div>90 days ago</div>
|
||||
<div>{props.uptimeHistoryDays || 90} days ago</div>
|
||||
<div>Today</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -126,7 +126,9 @@ const Overview: FunctionComponent<PageComponentProps> = (
|
||||
scheduledMaintenanceStateTimelines,
|
||||
setScheduledMaintenanceStateTimelines,
|
||||
] = useState<Array<ScheduledMaintenanceStateTimeline>>([]);
|
||||
const startDate: Date = OneUptimeDate.getSomeDaysAgo(90);
|
||||
const uptimeHistoryDays: number =
|
||||
statusPage?.showUptimeHistoryInDays || 90;
|
||||
const startDate: Date = OneUptimeDate.getSomeDaysAgo(uptimeHistoryDays);
|
||||
const endDate: Date = OneUptimeDate.getCurrentDate();
|
||||
const [currentStatus, setCurrentStatus] = useState<MonitorStatus | null>(
|
||||
null,
|
||||
@@ -493,6 +495,7 @@ const Overview: FunctionComponent<PageComponentProps> = (
|
||||
showCurrentStatus={resource.showCurrentStatus}
|
||||
uptimeGraphHeight={10}
|
||||
defaultBarColor={statusPage?.defaultBarColor || Green}
|
||||
uptimeHistoryDays={uptimeHistoryDays}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -548,6 +551,7 @@ const Overview: FunctionComponent<PageComponentProps> = (
|
||||
showCurrentStatus={resource.showCurrentStatus}
|
||||
uptimeGraphHeight={10}
|
||||
defaultBarColor={statusPage?.defaultBarColor || Green}
|
||||
uptimeHistoryDays={uptimeHistoryDays}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,16 +12,19 @@
|
||||
"build-frontend:dashboard": "bash ./scripts/frontend-run.sh FeatureSet/Dashboard dev-build",
|
||||
"build-frontend:admin-dashboard": "bash ./scripts/frontend-run.sh FeatureSet/AdminDashboard dev-build",
|
||||
"build-frontend:status-page": "bash ./scripts/frontend-run.sh FeatureSet/StatusPage dev-build",
|
||||
"build-frontends": "npm run build-frontend:accounts && npm run build-frontend:dashboard && npm run build-frontend:admin-dashboard && npm run build-frontend:status-page",
|
||||
"build-frontend:public-dashboard": "bash ./scripts/frontend-run.sh FeatureSet/PublicDashboard dev-build",
|
||||
"build-frontends": "npm run build-frontend:accounts && npm run build-frontend:dashboard && npm run build-frontend:admin-dashboard && npm run build-frontend:status-page && npm run build-frontend:public-dashboard",
|
||||
"build-frontend:accounts:prod": "bash ./scripts/frontend-run.sh FeatureSet/Accounts build",
|
||||
"build-frontend:dashboard:prod": "bash ./scripts/frontend-run.sh FeatureSet/Dashboard build",
|
||||
"build-frontend:admin-dashboard:prod": "bash ./scripts/frontend-run.sh FeatureSet/AdminDashboard build",
|
||||
"build-frontend:status-page:prod": "bash ./scripts/frontend-run.sh FeatureSet/StatusPage build",
|
||||
"build-frontends:prod": "npm run build-frontend:accounts:prod && npm run build-frontend:dashboard:prod && npm run build-frontend:admin-dashboard:prod && npm run build-frontend:status-page:prod",
|
||||
"build-frontend:public-dashboard:prod": "bash ./scripts/frontend-run.sh FeatureSet/PublicDashboard build",
|
||||
"build-frontends:prod": "npm run build-frontend:accounts:prod && npm run build-frontend:dashboard:prod && npm run build-frontend:admin-dashboard:prod && npm run build-frontend:status-page:prod && npm run build-frontend:public-dashboard:prod",
|
||||
"watch-frontend:accounts": "bash ./scripts/frontend-run.sh FeatureSet/Accounts dev-build --watch",
|
||||
"watch-frontend:dashboard": "bash ./scripts/frontend-run.sh FeatureSet/Dashboard dev-build --watch",
|
||||
"watch-frontend:admin-dashboard": "bash ./scripts/frontend-run.sh FeatureSet/AdminDashboard dev-build --watch",
|
||||
"watch-frontend:status-page": "bash ./scripts/frontend-run.sh FeatureSet/StatusPage dev-build --watch",
|
||||
"watch-frontend:public-dashboard": "bash ./scripts/frontend-run.sh FeatureSet/PublicDashboard dev-build --watch",
|
||||
"dev:api": "npx nodemon",
|
||||
"start": "export NODE_OPTIONS='--max-old-space-size=8096' && node --require ts-node/register Index.ts",
|
||||
"compile": "tsc",
|
||||
|
||||
@@ -28,6 +28,9 @@ pids+=($!)
|
||||
npm run watch-frontend:status-page &
|
||||
pids+=($!)
|
||||
|
||||
npm run watch-frontend:public-dashboard &
|
||||
pids+=($!)
|
||||
|
||||
npm run dev:api &
|
||||
pids+=($!)
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Project from "./Project";
|
||||
import User from "./User";
|
||||
import Route from "../../Types/API/Route";
|
||||
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import ColumnBillingAccessControl from "../../Types/Database/AccessControl/ColumnBillingAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import ColumnLength from "../../Types/Database/ColumnLength";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
@@ -15,6 +17,7 @@ import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import UniqueColumnBy from "../../Types/Database/UniqueColumnBy";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import HashedString from "../../Types/HashedString";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
@@ -448,4 +451,147 @@ export default class Dashboard extends BaseModel {
|
||||
type: ColumnType.JSON,
|
||||
})
|
||||
public dashboardViewConfig?: DashboardViewConfig = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateDashboard,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboard,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditDashboard,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Public Dashboard",
|
||||
description: "Is this dashboard public?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
})
|
||||
@ColumnBillingAccessControl({
|
||||
read: PlanType.Free,
|
||||
update: PlanType.Growth,
|
||||
create: PlanType.Free,
|
||||
})
|
||||
public isPublicDashboard?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateDashboard,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboard,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditDashboard,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Enable Master Password",
|
||||
description:
|
||||
"Require visitors to enter a master password before viewing a private dashboard.",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
})
|
||||
public enableMasterPassword?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateDashboard,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboard,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditDashboard,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
title: "Master Password",
|
||||
description:
|
||||
"Password required to unlock a private dashboard. This value is stored as a secure hash.",
|
||||
hashed: true,
|
||||
type: TableColumnType.HashedString,
|
||||
placeholder: "Enter a new master password",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.HashedString,
|
||||
length: ColumnLength.HashedString,
|
||||
nullable: true,
|
||||
transformer: HashedString.getDatabaseTransformer(),
|
||||
})
|
||||
public masterPassword?: HashedString = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateDashboard,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboard,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditDashboard,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: false,
|
||||
required: false,
|
||||
type: TableColumnType.VeryLongText,
|
||||
title: "IP Whitelist",
|
||||
description:
|
||||
"IP Whitelist for this Dashboard. One IP per line. Only used if the dashboard is private.",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.VeryLongText,
|
||||
nullable: true,
|
||||
})
|
||||
@ColumnBillingAccessControl({
|
||||
read: PlanType.Free,
|
||||
update: PlanType.Scale,
|
||||
create: PlanType.Free,
|
||||
})
|
||||
public ipWhitelist?: string = undefined;
|
||||
}
|
||||
|
||||
659
Common/Models/DatabaseModels/DashboardDomain.ts
Normal file
659
Common/Models/DatabaseModels/DashboardDomain.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
import Dashboard from "./Dashboard";
|
||||
import Domain from "./Domain";
|
||||
import Project from "./Project";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
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 EnableDocumentation from "../../Types/Database/EnableDocumentation";
|
||||
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
|
||||
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 UniqueColumnBy from "../../Types/Database/UniqueColumnBy";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
@EnableDocumentation()
|
||||
@CanAccessIfCanReadOn("dashboard")
|
||||
@TenantColumn("projectId")
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
delete: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.DeleteDashboardDomain,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditDashboardDomain,
|
||||
],
|
||||
})
|
||||
@EnableWorkflow({
|
||||
create: true,
|
||||
delete: true,
|
||||
update: true,
|
||||
read: true,
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/dashboard-domain"))
|
||||
@TableMetadata({
|
||||
tableName: "DashboardDomain",
|
||||
singularName: "Dashboard Domain",
|
||||
pluralName: "Dashboard Domains",
|
||||
icon: IconProp.Globe,
|
||||
tableDescription: "Manage custom domains for your dashboard",
|
||||
})
|
||||
@Entity({
|
||||
name: "DashboardDomain",
|
||||
})
|
||||
export default class DashboardDomain extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
example: "5f8b9c0d-e1a2-4b3c-8d5e-6f7a8b9c0d1e",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "domainId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Domain,
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Domain;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "domainId" })
|
||||
public domain?: Domain = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
example: "d4e5f6a7-b8c9-0123-def4-567890abcdef",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public domainId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "dashboardId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Dashboard,
|
||||
title: "Dashboard",
|
||||
description:
|
||||
"Relation to Dashboard Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Dashboard;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "dashboardId" })
|
||||
public dashboard?: Dashboard = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
title: "Dashboard ID",
|
||||
description: "ID of your Dashboard resource where this object belongs",
|
||||
example: "b2c3d4e5-f6a7-8901-bcde-f1234567890a",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public dashboardId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditDashboardDomain,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.ShortText,
|
||||
title: "Subdomain",
|
||||
description:
|
||||
"Subdomain label for your dashboard such as 'dashboard'. Leave blank or enter @ to use the root domain.",
|
||||
example: "dashboard",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
})
|
||||
public subdomain?: string = undefined;
|
||||
|
||||
@UniqueColumnBy("projectId")
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
computed: true,
|
||||
type: TableColumnType.ShortText,
|
||||
title: "Full Domain",
|
||||
description:
|
||||
"Full domain of your dashboard (like dashboard.acmeinc.com). This is autogenerated and is derived from subdomain and domain.",
|
||||
example: "dashboard.acmeinc.com",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
})
|
||||
public fullDomain?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "createdByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Created by User",
|
||||
description:
|
||||
"Relation to User who created this object (if this object was created by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "createdByUserId" })
|
||||
public createdByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Created by User ID",
|
||||
description:
|
||||
"User ID who created this object (if this object was created by a User)",
|
||||
example: "c3d4e5f6-a7b8-9012-cdef-234567890abc",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public createdByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "deletedByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
title: "Deleted by User",
|
||||
modelType: User,
|
||||
description:
|
||||
"Relation to User who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
cascade: false,
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "deletedByUserId" })
|
||||
public deletedByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
computed: true,
|
||||
type: TableColumnType.ShortText,
|
||||
title: "CNAME Verification Token",
|
||||
description: "CNAME Verification Token",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
})
|
||||
public cnameVerificationToken?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
required: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "CNAME Verified",
|
||||
description: "Is CNAME Verified?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
default: false,
|
||||
})
|
||||
public isCnameVerified?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
required: true,
|
||||
computed: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "SSL Ordered",
|
||||
description: "Is SSL ordered?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
default: false,
|
||||
})
|
||||
public isSslOrdered?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
required: true,
|
||||
computed: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "SSL Provisioned",
|
||||
description: "Is SSL provisioned?",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
default: false,
|
||||
})
|
||||
public isSslProvisioned?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Deleted by User ID",
|
||||
description:
|
||||
"User ID who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public deletedByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditDashboardDomain,
|
||||
],
|
||||
})
|
||||
@TableColumn({ type: TableColumnType.VeryLongText })
|
||||
@Column({
|
||||
type: ColumnType.VeryLongText,
|
||||
nullable: true,
|
||||
unique: false,
|
||||
})
|
||||
public customCertificate?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditDashboardDomain,
|
||||
],
|
||||
})
|
||||
@TableColumn({ type: TableColumnType.VeryLongText })
|
||||
@Column({
|
||||
type: ColumnType.VeryLongText,
|
||||
nullable: true,
|
||||
unique: false,
|
||||
})
|
||||
public customCertificateKey?: string = undefined;
|
||||
|
||||
// If this is true, then the certificate is custom and not managed by OneUptime (LetsEncrypt)
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateDashboardDomain,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadDashboardDomain,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditDashboardDomain,
|
||||
],
|
||||
})
|
||||
@TableColumn({ type: TableColumnType.Boolean })
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
default: false,
|
||||
})
|
||||
public isCustomCertificate?: boolean = undefined;
|
||||
}
|
||||
@@ -224,6 +224,7 @@ import IncidentSla from "./IncidentSla";
|
||||
|
||||
import TableView from "./TableView";
|
||||
import Dashboard from "./Dashboard";
|
||||
import DashboardDomain from "./DashboardDomain";
|
||||
|
||||
import MonitorTest from "./MonitorTest";
|
||||
import ScheduledMaintenanceFeed from "./ScheduledMaintenanceFeed";
|
||||
@@ -486,6 +487,7 @@ const AllModelTypes: Array<{
|
||||
|
||||
// Dashboards
|
||||
Dashboard,
|
||||
DashboardDomain,
|
||||
|
||||
MonitorTest,
|
||||
|
||||
|
||||
@@ -2638,6 +2638,47 @@ export default class StatusPage extends BaseModel {
|
||||
})
|
||||
public enableEmbeddedOverallStatus?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateProjectStatusPage,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectStatusPage,
|
||||
Permission.ReadAllProjectResources,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditProjectStatusPage,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Number,
|
||||
title: "Show Uptime History In Days",
|
||||
description:
|
||||
"How many days of uptime history should be shown on the status page? Maximum is 90 days.",
|
||||
defaultValue: 90,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Number,
|
||||
default: 90,
|
||||
nullable: false,
|
||||
})
|
||||
@ColumnBillingAccessControl({
|
||||
read: PlanType.Free,
|
||||
update: PlanType.Free,
|
||||
create: PlanType.Free,
|
||||
})
|
||||
public showUptimeHistoryInDays?: number = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
|
||||
312
Common/Server/API/DashboardAPI.ts
Normal file
312
Common/Server/API/DashboardAPI.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import UserMiddleware from "../Middleware/UserAuthorization";
|
||||
import DashboardService, {
|
||||
Service as DashboardServiceType,
|
||||
} from "../Services/DashboardService";
|
||||
import DashboardDomainService from "../Services/DashboardDomainService";
|
||||
import CookieUtil from "../Utils/Cookie";
|
||||
import logger from "../Utils/Logger";
|
||||
import {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
NextFunction,
|
||||
} from "../Utils/Express";
|
||||
import Response from "../Utils/Response";
|
||||
import BaseAPI from "./BaseAPI";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import NotFoundException from "../../Types/Exception/NotFoundException";
|
||||
import HashedString from "../../Types/HashedString";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Dashboard from "../../Models/DatabaseModels/Dashboard";
|
||||
import DashboardDomain from "../../Models/DatabaseModels/DashboardDomain";
|
||||
import { EncryptionSecret } from "../EnvironmentConfig";
|
||||
import { DASHBOARD_MASTER_PASSWORD_INVALID_MESSAGE } from "../../Types/Dashboard/MasterPassword";
|
||||
|
||||
export default class DashboardAPI extends BaseAPI<
|
||||
Dashboard,
|
||||
DashboardServiceType
|
||||
> {
|
||||
public constructor() {
|
||||
super(Dashboard, DashboardService);
|
||||
|
||||
// SEO endpoint - resolve dashboard by ID or domain
|
||||
this.router.get(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/seo/:dashboardIdOrDomain`,
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
try {
|
||||
const dashboardIdOrDomain: string = req.params[
|
||||
"dashboardIdOrDomain"
|
||||
] as string;
|
||||
|
||||
let dashboardId: ObjectID | null = null;
|
||||
|
||||
if (
|
||||
dashboardIdOrDomain &&
|
||||
dashboardIdOrDomain.includes(".")
|
||||
) {
|
||||
// This is a domain - resolve to dashboard ID
|
||||
const dashboardDomain: DashboardDomain | null =
|
||||
await DashboardDomainService.findOneBy({
|
||||
query: {
|
||||
fullDomain: dashboardIdOrDomain,
|
||||
domain: {
|
||||
isVerified: true,
|
||||
} as any,
|
||||
},
|
||||
select: {
|
||||
dashboardId: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboardDomain || !dashboardDomain.dashboardId) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new NotFoundException("Dashboard not found"),
|
||||
);
|
||||
}
|
||||
|
||||
dashboardId = dashboardDomain.dashboardId;
|
||||
} else {
|
||||
try {
|
||||
dashboardId = new ObjectID(dashboardIdOrDomain);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid dashboard ID"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const dashboard: Dashboard | null =
|
||||
await DashboardService.findOneById({
|
||||
id: dashboardId,
|
||||
select: {
|
||||
_id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new NotFoundException("Dashboard not found"),
|
||||
);
|
||||
}
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
_id: dashboard._id?.toString() || "",
|
||||
title: dashboard.name || "Dashboard",
|
||||
description:
|
||||
dashboard.description ||
|
||||
"View dashboard metrics and insights.",
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Domain resolution endpoint
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/domain`,
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
try {
|
||||
if (!req.body["domain"]) {
|
||||
throw new BadDataException(
|
||||
"domain is required in request body",
|
||||
);
|
||||
}
|
||||
|
||||
const domain: string = req.body["domain"] as string;
|
||||
|
||||
const dashboardDomain: DashboardDomain | null =
|
||||
await DashboardDomainService.findOneBy({
|
||||
query: {
|
||||
fullDomain: domain,
|
||||
domain: {
|
||||
isVerified: true,
|
||||
} as any,
|
||||
},
|
||||
select: {
|
||||
dashboardId: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboardDomain) {
|
||||
throw new BadDataException(
|
||||
"No dashboard found with this domain",
|
||||
);
|
||||
}
|
||||
|
||||
const objectId: ObjectID = dashboardDomain.dashboardId!;
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
dashboardId: objectId.toString(),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Metadata endpoint - returns dashboard info for the public viewer
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/metadata/:dashboardId`,
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
try {
|
||||
const dashboardId: ObjectID = new ObjectID(
|
||||
req.params["dashboardId"] as string,
|
||||
);
|
||||
|
||||
const dashboard: Dashboard | null =
|
||||
await DashboardService.findOneById({
|
||||
id: dashboardId,
|
||||
select: {
|
||||
_id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
isPublicDashboard: true,
|
||||
enableMasterPassword: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new NotFoundException("Dashboard not found");
|
||||
}
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
_id: dashboard._id?.toString() || "",
|
||||
name: dashboard.name || "Dashboard",
|
||||
description:
|
||||
dashboard.description || "",
|
||||
isPublicDashboard: dashboard.isPublicDashboard || false,
|
||||
enableMasterPassword:
|
||||
dashboard.enableMasterPassword || false,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/master-password/:dashboardId`,
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
try {
|
||||
if (!req.params["dashboardId"]) {
|
||||
throw new BadDataException("Dashboard ID not found");
|
||||
}
|
||||
|
||||
const dashboardId: ObjectID = new ObjectID(
|
||||
req.params["dashboardId"] as string,
|
||||
);
|
||||
|
||||
const password: string | undefined =
|
||||
req.body && (req.body["password"] as string);
|
||||
|
||||
if (!password) {
|
||||
throw new BadDataException("Master password is required.");
|
||||
}
|
||||
|
||||
const dashboard: Dashboard | null =
|
||||
await DashboardService.findOneById({
|
||||
id: dashboardId,
|
||||
select: {
|
||||
_id: true,
|
||||
projectId: true,
|
||||
enableMasterPassword: true,
|
||||
masterPassword: true,
|
||||
isPublicDashboard: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new NotFoundException("Dashboard not found");
|
||||
}
|
||||
|
||||
if (dashboard.isPublicDashboard) {
|
||||
throw new BadDataException(
|
||||
"This dashboard is already visible to everyone.",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!dashboard.enableMasterPassword ||
|
||||
!dashboard.masterPassword
|
||||
) {
|
||||
throw new BadDataException(
|
||||
"Master password has not been configured for this dashboard.",
|
||||
);
|
||||
}
|
||||
|
||||
const hashedInput: string = await HashedString.hashValue(
|
||||
password,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
if (hashedInput !== dashboard.masterPassword.toString()) {
|
||||
throw new BadDataException(
|
||||
DASHBOARD_MASTER_PASSWORD_INVALID_MESSAGE,
|
||||
);
|
||||
}
|
||||
|
||||
CookieUtil.setDashboardMasterPasswordCookie({
|
||||
expressResponse: res,
|
||||
dashboardId,
|
||||
});
|
||||
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
244
Common/Server/API/DashboardDomainAPI.ts
Normal file
244
Common/Server/API/DashboardDomainAPI.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { DashboardCNameRecord } from "../EnvironmentConfig";
|
||||
import UserMiddleware from "../Middleware/UserAuthorization";
|
||||
import DashboardDomainService, {
|
||||
Service as DashboardDomainServiceType,
|
||||
} from "../Services/DashboardDomainService";
|
||||
import {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
NextFunction,
|
||||
} from "../Utils/Express";
|
||||
import logger from "../Utils/Logger";
|
||||
import Response from "../Utils/Response";
|
||||
import BaseAPI from "./BaseAPI";
|
||||
import CommonAPI from "./CommonAPI";
|
||||
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import DashboardDomain from "../../Models/DatabaseModels/DashboardDomain";
|
||||
|
||||
export default class DashboardDomainAPI extends BaseAPI<
|
||||
DashboardDomain,
|
||||
DashboardDomainServiceType
|
||||
> {
|
||||
public constructor() {
|
||||
super(DashboardDomain, DashboardDomainService);
|
||||
|
||||
// CNAME verification api
|
||||
this.router.get(
|
||||
`${new this.entityType().getCrudApiPath()?.toString()}/verify-cname/:id`,
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
try {
|
||||
if (!DashboardCNameRecord) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException(
|
||||
`Custom Domains not enabled for this
|
||||
OneUptime installation. Please contact
|
||||
your server admin to enable this
|
||||
feature.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const databaseProps: DatabaseCommonInteractionProps =
|
||||
await CommonAPI.getDatabaseCommonInteractionProps(req);
|
||||
|
||||
const id: ObjectID = new ObjectID(req.params["id"] as string);
|
||||
|
||||
const domainCount: PositiveNumber =
|
||||
await DashboardDomainService.countBy({
|
||||
query: {
|
||||
_id: id.toString(),
|
||||
},
|
||||
props: databaseProps,
|
||||
});
|
||||
|
||||
if (domainCount.toNumber() === 0) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException(
|
||||
"The domain does not exist or user does not have access to it.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const domain: DashboardDomain | null =
|
||||
await DashboardDomainService.findOneBy({
|
||||
query: {
|
||||
_id: id.toString(),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
fullDomain: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!domain) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid token."),
|
||||
);
|
||||
}
|
||||
|
||||
if (!domain.fullDomain) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid domain."),
|
||||
);
|
||||
}
|
||||
|
||||
const isValid: boolean =
|
||||
await DashboardDomainService.isCnameValid(domain.fullDomain!);
|
||||
|
||||
if (!isValid) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException(
|
||||
"CNAME is not verified. Please make sure you have the correct record and please verify CNAME again. If you are sure that the record is correct, please wait for some time for the DNS to propagate.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Provision SSL API
|
||||
this.router.get(
|
||||
`${new this.entityType().getCrudApiPath()?.toString()}/order-ssl/:id`,
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
try {
|
||||
if (!DashboardCNameRecord) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException(
|
||||
`Custom Domains not enabled for this
|
||||
OneUptime installation. Please contact
|
||||
your server admin to enable this
|
||||
feature.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const databaseProps: DatabaseCommonInteractionProps =
|
||||
await CommonAPI.getDatabaseCommonInteractionProps(req);
|
||||
|
||||
const id: ObjectID = new ObjectID(req.params["id"] as string);
|
||||
|
||||
const domainCount: PositiveNumber =
|
||||
await DashboardDomainService.countBy({
|
||||
query: {
|
||||
_id: id.toString(),
|
||||
},
|
||||
props: databaseProps,
|
||||
});
|
||||
|
||||
if (domainCount.toNumber() === 0) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException(
|
||||
"The domain does not exist or user does not have access to it.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const domain: DashboardDomain | null =
|
||||
await DashboardDomainService.findOneBy({
|
||||
query: {
|
||||
_id: id.toString(),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
fullDomain: true,
|
||||
cnameVerificationToken: true,
|
||||
isCnameVerified: true,
|
||||
isSslProvisioned: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!domain) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid token."),
|
||||
);
|
||||
}
|
||||
|
||||
if (!domain.cnameVerificationToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid token."),
|
||||
);
|
||||
}
|
||||
|
||||
if (!domain.isCnameVerified) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException(
|
||||
"CNAME is not verified. Please verify CNAME first before you provision SSL.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (domain.isSslProvisioned) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("SSL is already provisioned."),
|
||||
);
|
||||
}
|
||||
|
||||
if (!domain.fullDomain) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid domain."),
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("Ordering SSL");
|
||||
|
||||
await DashboardDomainService.orderCert(domain);
|
||||
|
||||
logger.debug(
|
||||
"SSL Provisioned for domain - " + domain.fullDomain,
|
||||
);
|
||||
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1443,7 +1443,33 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
req: req,
|
||||
});
|
||||
|
||||
const startDate: Date = OneUptimeDate.getSomeDaysAgo(90);
|
||||
// First fetch the status page to get the configured uptime history days
|
||||
const statusPageForDays: StatusPage | null =
|
||||
await StatusPageService.findOneBy({
|
||||
query: {
|
||||
_id: statusPageId.toString(),
|
||||
},
|
||||
select: {
|
||||
showUptimeHistoryInDays: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
let uptimeHistoryDays: number =
|
||||
statusPageForDays?.showUptimeHistoryInDays || 90;
|
||||
|
||||
if (uptimeHistoryDays > 90) {
|
||||
uptimeHistoryDays = 90;
|
||||
}
|
||||
|
||||
if (uptimeHistoryDays < 1) {
|
||||
uptimeHistoryDays = 1;
|
||||
}
|
||||
|
||||
const startDate: Date =
|
||||
OneUptimeDate.getSomeDaysAgo(uptimeHistoryDays);
|
||||
const endDate: Date = OneUptimeDate.getCurrentDate();
|
||||
|
||||
const {
|
||||
@@ -4448,6 +4474,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
showIncidentsOnStatusPage: true,
|
||||
showEpisodesOnStatusPage: true,
|
||||
showScheduledMaintenanceEventsOnStatusPage: true,
|
||||
showUptimeHistoryInDays: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
|
||||
@@ -35,6 +35,7 @@ const FRONTEND_ENV_ALLOW_LIST: Array<string> = [
|
||||
"VAPID_SUBJECT",
|
||||
"VERSION",
|
||||
"STATUS_PAGE_CNAME_RECORD",
|
||||
"DASHBOARD_CNAME_RECORD",
|
||||
"ANALYTICS_KEY",
|
||||
"ANALYTICS_HOST",
|
||||
"GIT_SHA",
|
||||
@@ -252,6 +253,9 @@ export const ClickhouseHost: Hostname = Hostname.fromString(
|
||||
export const StatusPageCNameRecord: string =
|
||||
process.env["STATUS_PAGE_CNAME_RECORD"] || "";
|
||||
|
||||
export const DashboardCNameRecord: string =
|
||||
process.env["DASHBOARD_CNAME_RECORD"] || "";
|
||||
|
||||
export const ClickhousePort: Port = new Port(
|
||||
process.env["CLICKHOUSE_PORT"] || "8123",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1774524742177 implements MigrationInterface {
|
||||
name = 'MigrationName1774524742177'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "DashboardDomain" ("_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, "domainId" uuid NOT NULL, "dashboardId" uuid NOT NULL, "subdomain" character varying(100) NOT NULL, "fullDomain" character varying(100) NOT NULL, "createdByUserId" uuid, "cnameVerificationToken" character varying(100) NOT NULL, "isCnameVerified" boolean NOT NULL DEFAULT false, "isSslOrdered" boolean NOT NULL DEFAULT false, "isSslProvisioned" boolean NOT NULL DEFAULT false, "deletedByUserId" uuid, "customCertificate" text, "customCertificateKey" text, "isCustomCertificate" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_3897ff3212d5d8ddbdeca684bf6" PRIMARY KEY ("_id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_8c0e357d0490d45c89ee673005" ON "DashboardDomain" ("projectId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_0f58973f28172817bf9c1b34e7" ON "DashboardDomain" ("domainId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_601f68ad16b421ede8b06b3f40" ON "DashboardDomain" ("dashboardId") `);
|
||||
await queryRunner.query(`ALTER TABLE "Dashboard" ADD "isPublicDashboard" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "Dashboard" ADD "enableMasterPassword" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "Dashboard" ADD "masterPassword" character varying(64)`);
|
||||
await queryRunner.query(`ALTER TABLE "Dashboard" ADD "ipWhitelist" text`);
|
||||
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 "DashboardDomain" ADD CONSTRAINT "FK_8c0e357d0490d45c89ee673005c" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "DashboardDomain" ADD CONSTRAINT "FK_0f58973f28172817bf9c1b34e73" FOREIGN KEY ("domainId") REFERENCES "Domain"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "DashboardDomain" ADD CONSTRAINT "FK_601f68ad16b421ede8b06b3f40c" FOREIGN KEY ("dashboardId") REFERENCES "Dashboard"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "DashboardDomain" ADD CONSTRAINT "FK_de80950ba9f0d034f5c47940b3c" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "DashboardDomain" ADD CONSTRAINT "FK_de0c87b9c94b5dfeb21f1ce106f" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "DashboardDomain" DROP CONSTRAINT "FK_de0c87b9c94b5dfeb21f1ce106f"`);
|
||||
await queryRunner.query(`ALTER TABLE "DashboardDomain" DROP CONSTRAINT "FK_de80950ba9f0d034f5c47940b3c"`);
|
||||
await queryRunner.query(`ALTER TABLE "DashboardDomain" DROP CONSTRAINT "FK_601f68ad16b421ede8b06b3f40c"`);
|
||||
await queryRunner.query(`ALTER TABLE "DashboardDomain" DROP CONSTRAINT "FK_0f58973f28172817bf9c1b34e73"`);
|
||||
await queryRunner.query(`ALTER TABLE "DashboardDomain" DROP CONSTRAINT "FK_8c0e357d0490d45c89ee673005c"`);
|
||||
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(`ALTER TABLE "Dashboard" DROP COLUMN "ipWhitelist"`);
|
||||
await queryRunner.query(`ALTER TABLE "Dashboard" DROP COLUMN "masterPassword"`);
|
||||
await queryRunner.query(`ALTER TABLE "Dashboard" DROP COLUMN "enableMasterPassword"`);
|
||||
await queryRunner.query(`ALTER TABLE "Dashboard" DROP COLUMN "isPublicDashboard"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_601f68ad16b421ede8b06b3f40"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_0f58973f28172817bf9c1b34e7"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_8c0e357d0490d45c89ee673005"`);
|
||||
await queryRunner.query(`DROP TABLE "DashboardDomain"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1774524742178 implements MigrationInterface {
|
||||
name = 'MigrationName1774524742178'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "StatusPage" ADD "showUptimeHistoryInDays" integer NOT NULL DEFAULT 90`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "StatusPage" DROP COLUMN "showUptimeHistoryInDays"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -270,6 +270,8 @@ import { MigrationName1774000000000 } from "./1774000000000-MigrationName";
|
||||
import { MigrationName1774000000001 } from "./1774000000001-MigrationName";
|
||||
import { MigrationName1774355321449 } from "./1774355321449-MigrationName";
|
||||
import { MigrationName1774357353502 } from "./1774357353502-MigrationName";
|
||||
import { MigrationName1774524742177 } from "./1774524742177-MigrationName";
|
||||
import { MigrationName1774524742178 } from "./1774524742178-MigrationName";
|
||||
|
||||
export default [
|
||||
InitialMigration,
|
||||
@@ -544,4 +546,6 @@ export default [
|
||||
MigrationName1774000000001,
|
||||
MigrationName1774355321449,
|
||||
MigrationName1774357353502,
|
||||
MigrationName1774524742177,
|
||||
MigrationName1774524742178
|
||||
];
|
||||
|
||||
655
Common/Server/Services/DashboardDomainService.ts
Normal file
655
Common/Server/Services/DashboardDomainService.ts
Normal file
@@ -0,0 +1,655 @@
|
||||
import CreateBy from "../Types/Database/CreateBy";
|
||||
import DeleteBy from "../Types/Database/DeleteBy";
|
||||
import { OnCreate, OnDelete } from "../Types/Database/Hooks";
|
||||
import GreenlockUtil from "../Utils/Greenlock/Greenlock";
|
||||
import logger from "../Utils/Logger";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import DomainService from "./DomainService";
|
||||
import HTTPErrorResponse from "../../Types/API/HTTPErrorResponse";
|
||||
import HTTPResponse from "../../Types/API/HTTPResponse";
|
||||
import URL from "../../Types/API/URL";
|
||||
import LIMIT_MAX from "../../Types/Database/LimitMax";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import { JSONObject } from "../../Types/JSON";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import API from "../../Utils/API";
|
||||
import AcmeCertificate from "../../Models/DatabaseModels/AcmeCertificate";
|
||||
import DomainModel from "../../Models/DatabaseModels/Domain";
|
||||
import DashboardDomain from "../../Models/DatabaseModels/DashboardDomain";
|
||||
import AcmeCertificateService from "./AcmeCertificateService";
|
||||
import Telemetry, { Span } from "../Utils/Telemetry";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import { DashboardCNameRecord } from "../EnvironmentConfig";
|
||||
import Domain from "../Types/Domain";
|
||||
|
||||
export class Service extends DatabaseService<DashboardDomain> {
|
||||
public constructor() {
|
||||
super(DashboardDomain);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
protected override async onBeforeCreate(
|
||||
createBy: CreateBy<DashboardDomain>,
|
||||
): Promise<OnCreate<DashboardDomain>> {
|
||||
const domain: DomainModel | null = await DomainService.findOneBy({
|
||||
query: {
|
||||
_id:
|
||||
createBy.data.domainId?.toString() ||
|
||||
createBy.data.domain?._id ||
|
||||
"",
|
||||
},
|
||||
select: { domain: true, isVerified: true },
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!domain?.isVerified) {
|
||||
throw new BadDataException(
|
||||
"This domain is not verified. Please verify it by going to Settings > Domains",
|
||||
);
|
||||
}
|
||||
|
||||
let normalizedSubdomain: string =
|
||||
createBy.data.subdomain?.trim().toLowerCase() || "";
|
||||
|
||||
if (normalizedSubdomain === "@") {
|
||||
normalizedSubdomain = "";
|
||||
}
|
||||
|
||||
createBy.data.subdomain = normalizedSubdomain;
|
||||
|
||||
if (domain) {
|
||||
const baseDomain: string =
|
||||
domain.domain?.toString().toLowerCase().trim() || "";
|
||||
|
||||
if (!baseDomain) {
|
||||
throw new BadDataException("Please select a valid domain.");
|
||||
}
|
||||
|
||||
createBy.data.fullDomain = normalizedSubdomain
|
||||
? `${normalizedSubdomain}.${baseDomain}`
|
||||
: baseDomain;
|
||||
}
|
||||
|
||||
createBy.data.cnameVerificationToken = ObjectID.generate().toString();
|
||||
|
||||
if (createBy.data.isCustomCertificate) {
|
||||
if (
|
||||
!createBy.data.customCertificate ||
|
||||
!createBy.data.customCertificateKey
|
||||
) {
|
||||
throw new BadDataException(
|
||||
"Custom certificate or private key is missing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { createBy, carryForward: null };
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
protected override async onBeforeDelete(
|
||||
deleteBy: DeleteBy<DashboardDomain>,
|
||||
): Promise<OnDelete<DashboardDomain>> {
|
||||
const domains: Array<DashboardDomain> = await this.findBy({
|
||||
query: {
|
||||
...deleteBy.query,
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_MAX,
|
||||
select: { fullDomain: true },
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { deleteBy, carryForward: domains };
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
protected override async onDeleteSuccess(
|
||||
onDelete: OnDelete<DashboardDomain>,
|
||||
_itemIdsBeforeDelete: ObjectID[],
|
||||
): Promise<OnDelete<DashboardDomain>> {
|
||||
for (const domain of onDelete.carryForward) {
|
||||
await this.removeDomainFromGreenlock(domain.fullDomain as string);
|
||||
}
|
||||
|
||||
return onDelete;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async removeDomainFromGreenlock(domain: string): Promise<void> {
|
||||
await GreenlockUtil.removeDomain(domain);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async orderCert(dashboardDomain: DashboardDomain): Promise<void> {
|
||||
return Telemetry.startActiveSpan<Promise<void>>({
|
||||
name: "DashboardDomainService.orderCert",
|
||||
options: {
|
||||
attributes: {
|
||||
fullDomain: dashboardDomain.fullDomain,
|
||||
_id: dashboardDomain.id?.toString(),
|
||||
},
|
||||
},
|
||||
fn: async (span: Span): Promise<void> => {
|
||||
try {
|
||||
if (!dashboardDomain.fullDomain) {
|
||||
const fetchedDashboardDomain: DashboardDomain | null =
|
||||
await this.findOneBy({
|
||||
query: {
|
||||
_id: dashboardDomain.id!.toString(),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
fullDomain: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!fetchedDashboardDomain) {
|
||||
throw new BadDataException("DomainModel not found");
|
||||
}
|
||||
|
||||
dashboardDomain = fetchedDashboardDomain;
|
||||
}
|
||||
|
||||
if (!dashboardDomain.fullDomain) {
|
||||
throw new BadDataException(
|
||||
"Unable to order certificate because domain is null",
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"Ordering SSL for domain: " + dashboardDomain.fullDomain,
|
||||
);
|
||||
|
||||
await GreenlockUtil.orderCert({
|
||||
domain: dashboardDomain.fullDomain as string,
|
||||
validateCname: async (fullDomain: string) => {
|
||||
return await this.isCnameValid(fullDomain);
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
"SSL ordered for domain: " + dashboardDomain.fullDomain,
|
||||
);
|
||||
|
||||
await this.updateOneById({
|
||||
id: dashboardDomain.id!,
|
||||
data: {
|
||||
isSslOrdered: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
Telemetry.endSpan(span);
|
||||
} catch (err) {
|
||||
Telemetry.recordExceptionMarkSpanAsErrorAndEndSpan({
|
||||
span,
|
||||
exception: err,
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async updateSslProvisioningStatusForAllDomains(): Promise<void> {
|
||||
const domains: Array<DashboardDomain> = await this.findBy({
|
||||
query: {
|
||||
isSslOrdered: true,
|
||||
isCustomCertificate: false,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
limit: LIMIT_MAX,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const domain of domains) {
|
||||
await this.updateSslProvisioningStatus(domain);
|
||||
}
|
||||
}
|
||||
|
||||
private async isSSLProvisioned(
|
||||
fulldomain: string,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.get({
|
||||
url: URL.fromString(
|
||||
"https://" +
|
||||
fulldomain +
|
||||
"/dashboard-api/cname-verification/" +
|
||||
token,
|
||||
),
|
||||
});
|
||||
|
||||
if (result.isFailure()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async updateCnameStatusForDashboardDomain(data: {
|
||||
domain: string;
|
||||
cnameStatus: boolean;
|
||||
}): Promise<void> {
|
||||
if (!data.cnameStatus) {
|
||||
await this.updateOneBy({
|
||||
query: {
|
||||
fullDomain: data.domain,
|
||||
},
|
||||
data: {
|
||||
isCnameVerified: false,
|
||||
isSslOrdered: false,
|
||||
isSslProvisioned: false,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.updateOneBy({
|
||||
query: {
|
||||
fullDomain: data.domain,
|
||||
},
|
||||
data: {
|
||||
isCnameVerified: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async isCnameValid(fullDomain: string): Promise<boolean> {
|
||||
try {
|
||||
logger.debug("Checking for CNAME " + fullDomain);
|
||||
|
||||
const dashboardDomain: DashboardDomain | null = await this.findOneBy({
|
||||
query: {
|
||||
fullDomain: fullDomain,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
cnameVerificationToken: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboardDomain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token: string = dashboardDomain.cnameVerificationToken!;
|
||||
|
||||
logger.debug(
|
||||
"Checking for CNAME " + fullDomain + " with token " + token,
|
||||
);
|
||||
|
||||
try {
|
||||
const result: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.get({
|
||||
url: URL.fromString(
|
||||
"http://" +
|
||||
fullDomain +
|
||||
"/dashboard-api/cname-verification/" +
|
||||
token,
|
||||
),
|
||||
});
|
||||
|
||||
logger.debug("CNAME verification result");
|
||||
logger.debug(result);
|
||||
|
||||
if (result.isSuccess()) {
|
||||
await this.updateCnameStatusForDashboardDomain({
|
||||
domain: fullDomain,
|
||||
cnameStatus: true,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug("Failed checking for CNAME " + fullDomain);
|
||||
logger.debug(err);
|
||||
}
|
||||
|
||||
try {
|
||||
const resultHttps: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
||||
await API.get({
|
||||
url: URL.fromString(
|
||||
"https://" +
|
||||
fullDomain +
|
||||
"/dashboard-api/cname-verification/" +
|
||||
token,
|
||||
),
|
||||
});
|
||||
|
||||
logger.debug("CNAME verification result for https");
|
||||
logger.debug(resultHttps);
|
||||
|
||||
if (resultHttps.isSuccess()) {
|
||||
await this.updateCnameStatusForDashboardDomain({
|
||||
domain: fullDomain,
|
||||
cnameStatus: true,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug("Failed checking for CNAME " + fullDomain);
|
||||
logger.debug(err);
|
||||
}
|
||||
|
||||
try {
|
||||
if (DashboardCNameRecord) {
|
||||
const cnameRecords: Array<string> = await Domain.getCnameRecords({
|
||||
domain: fullDomain,
|
||||
});
|
||||
|
||||
let cnameRecord: string | undefined = undefined;
|
||||
if (cnameRecords.length > 0) {
|
||||
cnameRecord = cnameRecords[0];
|
||||
}
|
||||
|
||||
if (!cnameRecord) {
|
||||
logger.debug(
|
||||
`No CNAME record found for ${fullDomain}. Expected record: ${DashboardCNameRecord}`,
|
||||
);
|
||||
await this.updateCnameStatusForDashboardDomain({
|
||||
domain: fullDomain,
|
||||
cnameStatus: false,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
cnameRecord &&
|
||||
cnameRecord.trim().toLocaleLowerCase() ===
|
||||
DashboardCNameRecord.trim().toLocaleLowerCase()
|
||||
) {
|
||||
logger.debug(
|
||||
`CNAME record for ${fullDomain} matches the expected record: ${DashboardCNameRecord}`,
|
||||
);
|
||||
|
||||
await this.updateCnameStatusForDashboardDomain({
|
||||
domain: fullDomain,
|
||||
cnameStatus: true,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`CNAME record for ${fullDomain} is ${cnameRecord} and it does not match the expected record: ${DashboardCNameRecord}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug("Failed checking for CNAME " + fullDomain);
|
||||
logger.debug(err);
|
||||
}
|
||||
|
||||
await this.updateCnameStatusForDashboardDomain({
|
||||
domain: fullDomain,
|
||||
cnameStatus: false,
|
||||
});
|
||||
|
||||
return false;
|
||||
} catch (err) {
|
||||
logger.debug("Failed checking for CNAME " + fullDomain);
|
||||
logger.debug(err);
|
||||
|
||||
await this.updateCnameStatusForDashboardDomain({
|
||||
domain: fullDomain,
|
||||
cnameStatus: false,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async updateSslProvisioningStatus(
|
||||
domain: DashboardDomain,
|
||||
): Promise<void> {
|
||||
if (!domain.id) {
|
||||
throw new BadDataException("DomainModel ID is required");
|
||||
}
|
||||
|
||||
const dashboardDomain: DashboardDomain | null = await this.findOneBy({
|
||||
query: {
|
||||
_id: domain.id?.toString(),
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
fullDomain: true,
|
||||
cnameVerificationToken: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dashboardDomain) {
|
||||
throw new BadDataException("DomainModel not found");
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`DashboardCerts:RemoveCerts - Checking CNAME ${dashboardDomain.fullDomain}`,
|
||||
);
|
||||
|
||||
const isValid: boolean = await this.isSSLProvisioned(
|
||||
dashboardDomain.fullDomain!,
|
||||
dashboardDomain.cnameVerificationToken!,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
const isCnameValid: boolean = await this.isCnameValid(
|
||||
dashboardDomain.fullDomain!,
|
||||
);
|
||||
|
||||
await this.updateOneById({
|
||||
id: dashboardDomain.id!,
|
||||
data: {
|
||||
isSslProvisioned: false,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (isCnameValid) {
|
||||
try {
|
||||
await this.orderCert(dashboardDomain);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
"Cannot order cert for domain: " + dashboardDomain.fullDomain,
|
||||
);
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await this.updateOneById({
|
||||
id: dashboardDomain.id!,
|
||||
data: {
|
||||
isSslProvisioned: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async orderSSLForDomainsWhichAreNotOrderedYet(): Promise<void> {
|
||||
return Telemetry.startActiveSpan<Promise<void>>({
|
||||
name: "DashboardDomainService.orderSSLForDomainsWhichAreNotOrderedYet",
|
||||
options: { attributes: {} },
|
||||
fn: async (span: Span): Promise<void> => {
|
||||
try {
|
||||
const domains: Array<DashboardDomain> = await this.findBy({
|
||||
query: {
|
||||
isSslOrdered: false,
|
||||
isCustomCertificate: false,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
fullDomain: true,
|
||||
},
|
||||
limit: LIMIT_MAX,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const domain of domains) {
|
||||
try {
|
||||
logger.debug("Ordering SSL for domain: " + domain.fullDomain);
|
||||
await this.orderCert(domain);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Telemetry.endSpan(span);
|
||||
} catch (err) {
|
||||
Telemetry.recordExceptionMarkSpanAsErrorAndEndSpan({
|
||||
span,
|
||||
exception: err,
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async verifyCnameWhoseCnameisNotVerified(): Promise<void> {
|
||||
const domains: Array<DashboardDomain> = await this.findBy({
|
||||
query: {
|
||||
isCnameVerified: false,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
fullDomain: true,
|
||||
},
|
||||
limit: LIMIT_MAX,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const domain of domains) {
|
||||
try {
|
||||
await this.isCnameValid(domain.fullDomain as string);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async renewCertsWhichAreExpiringSoon(): Promise<void> {
|
||||
await GreenlockUtil.renewAllCertsWhichAreExpiringSoon({
|
||||
validateCname: async (fullDomain: string) => {
|
||||
return await this.isCnameValid(fullDomain);
|
||||
},
|
||||
notifyDomainRemoved: async (domain: string) => {
|
||||
await this.updateOneBy({
|
||||
query: {
|
||||
fullDomain: domain,
|
||||
},
|
||||
data: {
|
||||
isSslOrdered: false,
|
||||
isSslProvisioned: false,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`DomainModel removed from greenlock: ${domain}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async checkOrderStatus(): Promise<void> {
|
||||
const domains: Array<DashboardDomain> = await this.findBy({
|
||||
query: {
|
||||
isSslOrdered: true,
|
||||
isCustomCertificate: false,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
fullDomain: true,
|
||||
cnameVerificationToken: true,
|
||||
},
|
||||
limit: LIMIT_MAX,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const domain of domains) {
|
||||
if (!domain.fullDomain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const acmeCert: AcmeCertificate | null =
|
||||
await AcmeCertificateService.findOneBy({
|
||||
query: {
|
||||
domain: domain.fullDomain,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!acmeCert) {
|
||||
try {
|
||||
await this.orderCert(domain);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
"Cannot order cert for domain: " + domain.fullDomain,
|
||||
);
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export default new Service();
|
||||
@@ -1,12 +1,27 @@
|
||||
import CreateBy from "../Types/Database/CreateBy";
|
||||
import { OnCreate } from "../Types/Database/Hooks";
|
||||
import CookieUtil from "../Utils/Cookie";
|
||||
import { ExpressRequest } from "../Utils/Express";
|
||||
import JSONWebToken from "../Utils/JsonWebToken";
|
||||
import logger from "../Utils/Logger";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import NotAuthenticatedException from "../../Types/Exception/NotAuthenticatedException";
|
||||
import ForbiddenException from "../../Types/Exception/ForbiddenException";
|
||||
import MasterPasswordRequiredException from "../../Types/Exception/MasterPasswordRequiredException";
|
||||
import Model from "../../Models/DatabaseModels/Dashboard";
|
||||
import { IsBillingEnabled } from "../EnvironmentConfig";
|
||||
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
|
||||
import DashboardViewConfigUtil from "../../Utils/Dashboard/DashboardViewConfig";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import { JSONObject } from "../../Types/JSON";
|
||||
import IP from "../../Types/IP/IP";
|
||||
import {
|
||||
DASHBOARD_MASTER_PASSWORD_COOKIE_IDENTIFIER,
|
||||
DASHBOARD_MASTER_PASSWORD_REQUIRED_MESSAGE,
|
||||
} from "../../Types/Dashboard/MasterPassword";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
@@ -46,6 +61,145 @@ export class Service extends DatabaseService<Model> {
|
||||
|
||||
return Promise.resolve({ createBy, carryForward: null });
|
||||
}
|
||||
|
||||
public async hasReadAccess(data: {
|
||||
dashboardId: ObjectID;
|
||||
req: ExpressRequest;
|
||||
}): Promise<{
|
||||
hasReadAccess: boolean;
|
||||
error?: NotAuthenticatedException | ForbiddenException;
|
||||
}> {
|
||||
const dashboardId: ObjectID = data.dashboardId;
|
||||
const req: ExpressRequest = data.req;
|
||||
|
||||
try {
|
||||
const dashboard: Model | null = await this.findOneById({
|
||||
id: dashboardId,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
isPublicDashboard: true,
|
||||
ipWhitelist: true,
|
||||
enableMasterPassword: true,
|
||||
masterPassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (dashboard?.ipWhitelist && dashboard.ipWhitelist.length > 0) {
|
||||
const ipWhitelist: Array<string> = dashboard.ipWhitelist?.split("\n");
|
||||
|
||||
const ipAccessedFrom: string | undefined =
|
||||
req.headers["x-forwarded-for"]?.toString() ||
|
||||
req.headers["x-real-ip"]?.toString() ||
|
||||
req.socket.remoteAddress ||
|
||||
req.ip ||
|
||||
req.ips[0];
|
||||
|
||||
if (!ipAccessedFrom) {
|
||||
logger.error("IP address not found in request.");
|
||||
return {
|
||||
hasReadAccess: false,
|
||||
error: new ForbiddenException(
|
||||
"Unable to verify IP address for dashboard access.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const isIPWhitelisted: boolean = IP.isInWhitelist({
|
||||
ips:
|
||||
ipAccessedFrom?.split(",").map((i: string) => {
|
||||
return i.trim();
|
||||
}) || [],
|
||||
whitelist: ipWhitelist,
|
||||
});
|
||||
|
||||
if (!isIPWhitelisted) {
|
||||
logger.error(
|
||||
`IP address ${ipAccessedFrom} is not whitelisted for dashboard ${dashboardId.toString()}.`,
|
||||
);
|
||||
|
||||
return {
|
||||
hasReadAccess: false,
|
||||
error: new ForbiddenException(
|
||||
`Your IP address ${ipAccessedFrom} is blocked from accessing this dashboard.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (dashboard && dashboard.isPublicDashboard) {
|
||||
return {
|
||||
hasReadAccess: true,
|
||||
};
|
||||
}
|
||||
|
||||
const shouldEnforceMasterPassword: boolean = Boolean(
|
||||
dashboard &&
|
||||
dashboard.enableMasterPassword &&
|
||||
dashboard.masterPassword &&
|
||||
!dashboard.isPublicDashboard,
|
||||
);
|
||||
|
||||
if (shouldEnforceMasterPassword) {
|
||||
const hasValidMasterPassword: boolean =
|
||||
this.hasValidMasterPasswordCookie({
|
||||
req,
|
||||
dashboardId,
|
||||
});
|
||||
|
||||
if (hasValidMasterPassword) {
|
||||
return {
|
||||
hasReadAccess: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasReadAccess: false,
|
||||
error: new MasterPasswordRequiredException(
|
||||
DASHBOARD_MASTER_PASSWORD_REQUIRED_MESSAGE,
|
||||
),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
|
||||
return {
|
||||
hasReadAccess: false,
|
||||
error: new NotAuthenticatedException(
|
||||
"You do not have access to this dashboard. Please login to view the dashboard.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private hasValidMasterPasswordCookie(data: {
|
||||
req: ExpressRequest;
|
||||
dashboardId: ObjectID;
|
||||
}): boolean {
|
||||
const token: string | undefined = CookieUtil.getCookieFromExpressRequest(
|
||||
data.req,
|
||||
CookieUtil.getDashboardMasterPasswordKey(data.dashboardId),
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: JSONObject = JSONWebToken.decodeJsonPayload(token);
|
||||
|
||||
return (
|
||||
payload["dashboardId"] === data.dashboardId.toString() &&
|
||||
payload["type"] === DASHBOARD_MASTER_PASSWORD_COOKIE_IDENTIFIER
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
|
||||
@@ -105,6 +105,7 @@ import SpanService from "./SpanService";
|
||||
import StatusPageAnnouncementService from "./StatusPageAnnouncementService";
|
||||
import StatusPageAnnouncementTemplateService from "./StatusPageAnnouncementTemplateService";
|
||||
import StatusPageCustomFieldService from "./StatusPageCustomFieldService";
|
||||
import DashboardDomainService from "./DashboardDomainService";
|
||||
import StatusPageDomainService from "./StatusPageDomainService";
|
||||
import StatusPageFooterLinkService from "./StatusPageFooterLinkService";
|
||||
import StatusPageGroupService from "./StatusPageGroupService";
|
||||
@@ -305,6 +306,7 @@ const services: Array<BaseService> = [
|
||||
StatusPageAnnouncementService,
|
||||
StatusPageAnnouncementTemplateService,
|
||||
StatusPageCustomFieldService,
|
||||
DashboardDomainService,
|
||||
StatusPageDomainService,
|
||||
StatusPageFooterLinkService,
|
||||
StatusPageGroupService,
|
||||
|
||||
@@ -241,6 +241,9 @@ export class Service extends DatabaseService<WorkspaceNotificationSummary> {
|
||||
items: Array<WorkspaceNotificationSummaryItem>,
|
||||
item: WorkspaceNotificationSummaryItem,
|
||||
): boolean {
|
||||
if (items.includes(WorkspaceNotificationSummaryItem.All)) {
|
||||
return true;
|
||||
}
|
||||
return items.includes(item);
|
||||
}
|
||||
|
||||
@@ -822,6 +825,11 @@ export class Service extends DatabaseService<WorkspaceNotificationSummary> {
|
||||
ackResolve.push(
|
||||
`Ack: ${Service.bold(td.ackBy)} in ${Service.formatDuration(OneUptimeDate.getMinutesBetweenTwoDates(td.declaredAt || inc.createdAt!, td.ackAt))}`,
|
||||
);
|
||||
} else if (td?.resolvedBy && td?.resolvedAt) {
|
||||
// If not explicitly acknowledged but resolved, ack time = resolve time
|
||||
ackResolve.push(
|
||||
`Ack: ${Service.bold(td.resolvedBy)} in ${Service.formatDuration(OneUptimeDate.getMinutesBetweenTwoDates(td.declaredAt || inc.createdAt!, td.resolvedAt))}`,
|
||||
);
|
||||
} else {
|
||||
ackResolve.push(`_Not yet acknowledged_`);
|
||||
}
|
||||
@@ -1251,6 +1259,11 @@ export class Service extends DatabaseService<WorkspaceNotificationSummary> {
|
||||
ackResolve.push(
|
||||
`Ack: ${Service.bold(td.ackBy)} in ${Service.formatDuration(OneUptimeDate.getMinutesBetweenTwoDates(td.declaredAt || a.createdAt!, td.ackAt))}`,
|
||||
);
|
||||
} else if (td?.resolvedBy && td?.resolvedAt) {
|
||||
// If not explicitly acknowledged but resolved, ack time = resolve time
|
||||
ackResolve.push(
|
||||
`Ack: ${Service.bold(td.resolvedBy)} in ${Service.formatDuration(OneUptimeDate.getMinutesBetweenTwoDates(td.declaredAt || a.createdAt!, td.resolvedAt))}`,
|
||||
);
|
||||
} else {
|
||||
ackResolve.push(`_Not yet acknowledged_`);
|
||||
}
|
||||
@@ -1433,8 +1446,9 @@ export class Service extends DatabaseService<WorkspaceNotificationSummary> {
|
||||
let total: number = 0;
|
||||
let count: number = 0;
|
||||
for (const [, td] of tlMap) {
|
||||
// For ack: if not explicitly acknowledged but resolved, use resolve time as ack time
|
||||
const eventTime: Date | undefined =
|
||||
kind === "ack" ? td.ackAt : td.resolvedAt;
|
||||
kind === "ack" ? (td.ackAt || td.resolvedAt) : td.resolvedAt;
|
||||
if (eventTime && td.declaredAt) {
|
||||
total += OneUptimeDate.getMinutesBetweenTwoDates(
|
||||
td.declaredAt,
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
MASTER_PASSWORD_COOKIE_IDENTIFIER,
|
||||
MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS,
|
||||
} from "../../Types/StatusPage/MasterPassword";
|
||||
import {
|
||||
DASHBOARD_MASTER_PASSWORD_COOKIE_IDENTIFIER,
|
||||
DASHBOARD_MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS,
|
||||
} from "../../Types/Dashboard/MasterPassword";
|
||||
import CaptureSpan from "./Telemetry/CaptureSpan";
|
||||
|
||||
export default class CookieUtil {
|
||||
@@ -323,6 +327,50 @@ export default class CookieUtil {
|
||||
);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static setDashboardMasterPasswordCookie(data: {
|
||||
expressResponse: ExpressResponse;
|
||||
dashboardId: ObjectID;
|
||||
}): void {
|
||||
const expiresInDays: PositiveNumber = new PositiveNumber(
|
||||
DASHBOARD_MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS,
|
||||
);
|
||||
|
||||
const token: string = JSONWebToken.signJsonPayload(
|
||||
{
|
||||
dashboardId: data.dashboardId.toString(),
|
||||
type: DASHBOARD_MASTER_PASSWORD_COOKIE_IDENTIFIER,
|
||||
},
|
||||
OneUptimeDate.getSecondsInDays(expiresInDays),
|
||||
);
|
||||
|
||||
CookieUtil.setCookie(
|
||||
data.expressResponse,
|
||||
CookieUtil.getDashboardMasterPasswordKey(data.dashboardId),
|
||||
token,
|
||||
{
|
||||
maxAge: OneUptimeDate.getMillisecondsInDays(expiresInDays),
|
||||
httpOnly: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static removeDashboardMasterPasswordCookie(
|
||||
res: ExpressResponse,
|
||||
dashboardId: ObjectID,
|
||||
): void {
|
||||
CookieUtil.removeCookie(
|
||||
res,
|
||||
CookieUtil.getDashboardMasterPasswordKey(dashboardId),
|
||||
);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static getDashboardMasterPasswordKey(id: ObjectID): string {
|
||||
return `${CookieName.DashboardMasterPassword}-${id.toString()}`;
|
||||
}
|
||||
|
||||
// get all cookies with express request
|
||||
@CaptureSpan()
|
||||
public static getAllCookies(req: ExpressRequest): Dictionary<string> {
|
||||
|
||||
@@ -43,6 +43,10 @@ import URL from "../../../Types/API/URL";
|
||||
import IP from "../../../Types/IP/IP";
|
||||
import Hostname from "../../../Types/API/Hostname";
|
||||
import Port from "../../../Types/Port";
|
||||
import MetricMonitorResponse, {
|
||||
KubernetesAffectedResource,
|
||||
KubernetesResourceBreakdown,
|
||||
} from "../../../Types/Monitor/MetricMonitor/MetricMonitorResponse";
|
||||
|
||||
export default class MonitorCriteriaEvaluator {
|
||||
public static async processMonitorStep(input: {
|
||||
@@ -545,6 +549,11 @@ ${contextBlock}
|
||||
monitorStep: MonitorStep;
|
||||
monitor: Monitor;
|
||||
}): string | null {
|
||||
// Handle Kubernetes monitors with rich resource context
|
||||
if (input.monitor.monitorType === MonitorType.Kubernetes) {
|
||||
return MonitorCriteriaEvaluator.buildKubernetesRootCauseContext(input);
|
||||
}
|
||||
|
||||
const requestDetails: Array<string> = [];
|
||||
const responseDetails: Array<string> = [];
|
||||
const failureDetails: Array<string> = [];
|
||||
@@ -653,6 +662,293 @@ ${contextBlock}
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
private static buildKubernetesRootCauseContext(input: {
|
||||
dataToProcess: DataToProcess;
|
||||
monitorStep: MonitorStep;
|
||||
monitor: Monitor;
|
||||
}): string | null {
|
||||
const metricResponse: MetricMonitorResponse =
|
||||
input.dataToProcess as MetricMonitorResponse;
|
||||
|
||||
const breakdown: KubernetesResourceBreakdown | undefined =
|
||||
metricResponse.kubernetesResourceBreakdown;
|
||||
|
||||
if (!breakdown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sections: Array<string> = [];
|
||||
|
||||
// Cluster context
|
||||
const clusterDetails: Array<string> = [];
|
||||
clusterDetails.push(`- Cluster: ${breakdown.clusterName}`);
|
||||
clusterDetails.push(`- Metric: ${breakdown.metricFriendlyName} (\`${breakdown.metricName}\`)`);
|
||||
|
||||
if (breakdown.attributes["k8s.namespace.name"]) {
|
||||
clusterDetails.push(
|
||||
`- Namespace: ${breakdown.attributes["k8s.namespace.name"]}`,
|
||||
);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
`**Kubernetes Cluster Details**\n${clusterDetails.join("\n")}`,
|
||||
);
|
||||
|
||||
// Affected resources
|
||||
if (
|
||||
breakdown.affectedResources &&
|
||||
breakdown.affectedResources.length > 0
|
||||
) {
|
||||
const resourceLines: Array<string> = [];
|
||||
|
||||
// Sort by metric value descending (worst first)
|
||||
const sortedResources: Array<KubernetesAffectedResource> = [
|
||||
...breakdown.affectedResources,
|
||||
].sort(
|
||||
(
|
||||
a: KubernetesAffectedResource,
|
||||
b: KubernetesAffectedResource,
|
||||
) => {
|
||||
return b.metricValue - a.metricValue;
|
||||
},
|
||||
);
|
||||
|
||||
// Show top 10 affected resources
|
||||
const resourcesToShow: Array<KubernetesAffectedResource> =
|
||||
sortedResources.slice(0, 10);
|
||||
|
||||
for (const resource of resourcesToShow) {
|
||||
const details: Array<string> = [];
|
||||
|
||||
if (resource.namespace) {
|
||||
details.push(`Namespace: \`${resource.namespace}\``);
|
||||
}
|
||||
if (resource.workloadType && resource.workloadName) {
|
||||
details.push(
|
||||
`${resource.workloadType}: \`${resource.workloadName}\``,
|
||||
);
|
||||
}
|
||||
if (resource.podName) {
|
||||
details.push(`Pod: \`${resource.podName}\``);
|
||||
}
|
||||
if (resource.containerName) {
|
||||
details.push(`Container: \`${resource.containerName}\``);
|
||||
}
|
||||
if (resource.nodeName) {
|
||||
details.push(`Node: \`${resource.nodeName}\``);
|
||||
}
|
||||
|
||||
details.push(`Value: **${resource.metricValue}**`);
|
||||
|
||||
resourceLines.push(`- ${details.join(" | ")}`);
|
||||
}
|
||||
|
||||
if (sortedResources.length > 10) {
|
||||
resourceLines.push(
|
||||
`- ... and ${sortedResources.length - 10} more affected resources`,
|
||||
);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
`\n\n**Affected Resources** (${sortedResources.length} total)\n${resourceLines.join("\n")}`,
|
||||
);
|
||||
|
||||
// Add root cause analysis based on metric type
|
||||
const analysis: string | null =
|
||||
MonitorCriteriaEvaluator.buildKubernetesRootCauseAnalysis({
|
||||
breakdown: breakdown,
|
||||
topResource: resourcesToShow[0]!,
|
||||
});
|
||||
|
||||
if (analysis) {
|
||||
sections.push(`\n\n**Root Cause Analysis**\n${analysis}`);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
private static buildKubernetesRootCauseAnalysis(input: {
|
||||
breakdown: KubernetesResourceBreakdown;
|
||||
topResource: KubernetesAffectedResource;
|
||||
}): string | null {
|
||||
const { breakdown, topResource } = input;
|
||||
const metricName: string = breakdown.metricName;
|
||||
const lines: Array<string> = [];
|
||||
|
||||
if (
|
||||
metricName === "k8s.container.restarts" ||
|
||||
metricName.includes("restart")
|
||||
) {
|
||||
lines.push(
|
||||
`Container restart count is elevated, indicating a potential CrashLoopBackOff condition.`,
|
||||
);
|
||||
if (topResource.containerName) {
|
||||
lines.push(
|
||||
`The container \`${topResource.containerName}\` in pod \`${topResource.podName || "unknown"}\` has restarted **${topResource.metricValue}** times.`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`Common causes: application crash on startup, misconfigured environment variables, missing dependencies, OOM (Out of Memory) kills, failed health checks, or missing config maps/secrets.`,
|
||||
);
|
||||
lines.push(
|
||||
`Recommended actions: Check container logs with \`kubectl logs ${topResource.podName || "<pod-name>"} -c ${topResource.containerName || "<container>"} --previous\` and inspect events with \`kubectl describe pod ${topResource.podName || "<pod-name>"}\`.`,
|
||||
);
|
||||
} else if (
|
||||
metricName === "k8s.pod.phase" &&
|
||||
breakdown.attributes["k8s.pod.phase"] === "Pending"
|
||||
) {
|
||||
lines.push(
|
||||
`Pods are stuck in Pending phase and unable to be scheduled.`,
|
||||
);
|
||||
lines.push(
|
||||
`Common causes: insufficient CPU/memory resources on nodes, node affinity/taint restrictions preventing scheduling, PersistentVolumeClaim pending, or resource quota exceeded.`,
|
||||
);
|
||||
if (topResource.podName) {
|
||||
lines.push(
|
||||
`Recommended actions: Check scheduling events with \`kubectl describe pod ${topResource.podName}\` and verify node resources with \`kubectl describe nodes\`.`,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
metricName === "k8s.node.condition_ready" ||
|
||||
metricName.includes("node") && metricName.includes("condition")
|
||||
) {
|
||||
lines.push(`One or more nodes have transitioned to a NotReady state.`);
|
||||
if (topResource.nodeName) {
|
||||
lines.push(
|
||||
`Node \`${topResource.nodeName}\` is reporting NotReady (value: ${topResource.metricValue}).`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`Common causes: kubelet process failure, node resource exhaustion (disk pressure, memory pressure, PID pressure), network connectivity issues, or underlying VM/hardware failure.`,
|
||||
);
|
||||
lines.push(
|
||||
`Recommended actions: Check node conditions with \`kubectl describe node ${topResource.nodeName || "<node-name>"}\` and verify kubelet status on the node.`,
|
||||
);
|
||||
} else if (
|
||||
metricName === "k8s.node.cpu.utilization" ||
|
||||
metricName.includes("cpu") && metricName.includes("utilization")
|
||||
) {
|
||||
lines.push(`Node CPU utilization has exceeded the configured threshold.`);
|
||||
if (topResource.nodeName) {
|
||||
lines.push(
|
||||
`Node \`${topResource.nodeName}\` is at **${topResource.metricValue.toFixed(1)}%** CPU utilization.`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`Common causes: resource-intensive workloads, insufficient resource limits on pods, noisy neighbor pods consuming excessive CPU, or insufficient cluster capacity.`,
|
||||
);
|
||||
lines.push(
|
||||
`Recommended actions: Identify top CPU consumers with \`kubectl top pods --all-namespaces --sort-by=cpu\` and consider scaling the cluster or adjusting pod resource limits.`,
|
||||
);
|
||||
} else if (
|
||||
metricName === "k8s.node.memory.usage" ||
|
||||
metricName.includes("memory") && metricName.includes("usage")
|
||||
) {
|
||||
lines.push(
|
||||
`Node memory utilization has exceeded the configured threshold.`,
|
||||
);
|
||||
if (topResource.nodeName) {
|
||||
lines.push(
|
||||
`Node \`${topResource.nodeName}\` memory usage is at **${topResource.metricValue.toFixed(1)}%**.`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`Common causes: memory leaks in applications, insufficient memory limits on pods, too many pods scheduled on the node, or growing dataset sizes.`,
|
||||
);
|
||||
lines.push(
|
||||
`Recommended actions: Check memory consumers with \`kubectl top pods --all-namespaces --sort-by=memory\` and review pod memory limits. Consider scaling the cluster or adding nodes with more memory.`,
|
||||
);
|
||||
} else if (
|
||||
metricName === "k8s.deployment.unavailable_replicas" ||
|
||||
metricName.includes("unavailable")
|
||||
) {
|
||||
lines.push(
|
||||
`Deployment has unavailable replicas, indicating a mismatch between desired and available replicas.`,
|
||||
);
|
||||
if (topResource.workloadName) {
|
||||
lines.push(
|
||||
`${topResource.workloadType || "Deployment"} \`${topResource.workloadName}\` has **${topResource.metricValue}** unavailable replica(s).`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`Common causes: failed rolling update, image pull errors (wrong image tag or missing registry credentials), pod crash loops, insufficient cluster resources to schedule new pods, or PodDisruptionBudget blocking updates.`,
|
||||
);
|
||||
lines.push(
|
||||
`Recommended actions: Check deployment rollout status with \`kubectl rollout status deployment/${topResource.workloadName || "<deployment>"}\` and inspect pod events.`,
|
||||
);
|
||||
} else if (
|
||||
metricName === "k8s.job.failed_pods" ||
|
||||
metricName.includes("job") && metricName.includes("fail")
|
||||
) {
|
||||
lines.push(`Kubernetes Job has failed pods.`);
|
||||
if (topResource.workloadName) {
|
||||
lines.push(
|
||||
`Job \`${topResource.workloadName}\` has **${topResource.metricValue}** failed pod(s).`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`Common causes: application error or non-zero exit code, resource limits exceeded (OOMKilled), misconfigured command or arguments, missing environment variables, or timeout exceeded.`,
|
||||
);
|
||||
lines.push(
|
||||
`Recommended actions: Check job status with \`kubectl describe job ${topResource.workloadName || "<job-name>"}\` and review pod logs for the failed pod(s).`,
|
||||
);
|
||||
} else if (
|
||||
metricName === "k8s.node.filesystem.usage" ||
|
||||
metricName.includes("disk") ||
|
||||
metricName.includes("filesystem")
|
||||
) {
|
||||
lines.push(
|
||||
`Node disk/filesystem usage has exceeded the configured threshold.`,
|
||||
);
|
||||
if (topResource.nodeName) {
|
||||
lines.push(
|
||||
`Node \`${topResource.nodeName}\` filesystem usage is at **${topResource.metricValue.toFixed(1)}%**.`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`Common causes: container image layers consuming disk space, excessive logging, large emptyDir volumes, or accumulation of unused container images.`,
|
||||
);
|
||||
lines.push(
|
||||
`Recommended actions: Clean up unused images with \`docker system prune\` or \`crictl rmi --prune\`, check for large log files, and review PersistentVolumeClaim usage.`,
|
||||
);
|
||||
} else if (
|
||||
metricName === "k8s.daemonset.misscheduled_nodes" ||
|
||||
metricName.includes("daemonset")
|
||||
) {
|
||||
lines.push(
|
||||
`DaemonSet has misscheduled or unavailable nodes.`,
|
||||
);
|
||||
if (topResource.workloadName) {
|
||||
lines.push(
|
||||
`DaemonSet \`${topResource.workloadName}\` has **${topResource.metricValue}** misscheduled node(s).`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`Common causes: node taints preventing scheduling, incorrect node selectors, or node affinity rules excluding certain nodes.`,
|
||||
);
|
||||
lines.push(
|
||||
`Recommended actions: Check DaemonSet status with \`kubectl describe daemonset ${topResource.workloadName || "<daemonset>"}\` and verify node labels and taints.`,
|
||||
);
|
||||
} else {
|
||||
// Generic Kubernetes context
|
||||
lines.push(
|
||||
`Kubernetes metric \`${metricName}\` (${breakdown.metricFriendlyName}) has breached the configured threshold.`,
|
||||
);
|
||||
if (topResource.podName) {
|
||||
lines.push(`Most affected pod: \`${topResource.podName}\``);
|
||||
}
|
||||
if (topResource.nodeName) {
|
||||
lines.push(`Most affected node: \`${topResource.nodeName}\``);
|
||||
}
|
||||
lines.push(
|
||||
`Recommended actions: Investigate the affected resources using \`kubectl describe\` and \`kubectl logs\` commands.`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private static getMonitorDestinationString(input: {
|
||||
monitorStep: MonitorStep;
|
||||
probeResponse: ProbeMonitorResponse | null;
|
||||
|
||||
@@ -1023,12 +1023,41 @@ export default class MicrosoftTeamsUtil extends WorkspaceBase {
|
||||
logger.debug("Sending message to Microsoft Teams with data:");
|
||||
logger.debug(data);
|
||||
|
||||
const adaptiveCard: JSONObject = this.buildAdaptiveCardFromMessageBlocks({
|
||||
messageBlocks: data.workspaceMessagePayload.messageBlocks,
|
||||
});
|
||||
// Teams adaptive cards have a ~28KB payload limit.
|
||||
// Split message blocks into chunks of 40 to avoid hitting the limit.
|
||||
const maxBlocksPerCard: number = 40;
|
||||
const allMessageBlocks: Array<WorkspaceMessageBlock> =
|
||||
data.workspaceMessagePayload.messageBlocks;
|
||||
|
||||
logger.debug("Adaptive card built successfully:");
|
||||
logger.debug(JSON.stringify(adaptiveCard, null, 2));
|
||||
const adaptiveCards: Array<JSONObject> = [];
|
||||
|
||||
if (allMessageBlocks.length <= maxBlocksPerCard) {
|
||||
adaptiveCards.push(
|
||||
this.buildAdaptiveCardFromMessageBlocks({
|
||||
messageBlocks: allMessageBlocks,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
for (
|
||||
let i: number = 0;
|
||||
i < allMessageBlocks.length;
|
||||
i += maxBlocksPerCard
|
||||
) {
|
||||
const chunk: Array<WorkspaceMessageBlock> = allMessageBlocks.slice(
|
||||
i,
|
||||
i + maxBlocksPerCard,
|
||||
);
|
||||
adaptiveCards.push(
|
||||
this.buildAdaptiveCardFromMessageBlocks({
|
||||
messageBlocks: chunk,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Built ${adaptiveCards.length} adaptive card(s) from ${allMessageBlocks.length} message blocks`,
|
||||
);
|
||||
|
||||
const workspaceChannelsToPostTo: Array<WorkspaceChannel> = [];
|
||||
|
||||
@@ -1122,18 +1151,24 @@ export default class MicrosoftTeamsUtil extends WorkspaceBase {
|
||||
);
|
||||
}
|
||||
|
||||
const thread: WorkspaceThread = await this.sendAdaptiveCardToChannel({
|
||||
authToken: data.authToken,
|
||||
teamId: data.workspaceMessagePayload.teamId!,
|
||||
workspaceChannel: channel,
|
||||
adaptiveCard: adaptiveCard,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
// Send each adaptive card chunk to the channel
|
||||
let lastThread: WorkspaceThread | undefined;
|
||||
for (const adaptiveCard of adaptiveCards) {
|
||||
lastThread = await this.sendAdaptiveCardToChannel({
|
||||
authToken: data.authToken,
|
||||
teamId: data.workspaceMessagePayload.teamId!,
|
||||
workspaceChannel: channel,
|
||||
adaptiveCard: adaptiveCard,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Message sent successfully to channel ${channel.name}, thread: ${JSON.stringify(thread)}`,
|
||||
);
|
||||
workspaceMessageResponse.threads.push(thread);
|
||||
if (lastThread) {
|
||||
logger.debug(
|
||||
`Message sent successfully to channel ${channel.name}, thread: ${JSON.stringify(lastThread)}`,
|
||||
);
|
||||
workspaceMessageResponse.threads.push(lastThread);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`Error sending message to channel ID ${channel.id}:`);
|
||||
logger.error(e);
|
||||
|
||||
@@ -1129,13 +1129,37 @@ export default class SlackUtil extends WorkspaceBase {
|
||||
}
|
||||
}
|
||||
|
||||
const thread: WorkspaceThread = await this.sendPayloadBlocksToChannel({
|
||||
authToken: data.authToken,
|
||||
workspaceChannel: channel,
|
||||
blocks: blocks,
|
||||
});
|
||||
// Slack has a limit of 50 blocks per message. Split into batches if needed.
|
||||
const maxBlocksPerMessage: number = 50;
|
||||
let lastThread: WorkspaceThread | undefined;
|
||||
|
||||
workspaspaceMessageResponse.threads.push(thread);
|
||||
if (blocks.length <= maxBlocksPerMessage) {
|
||||
lastThread = await this.sendPayloadBlocksToChannel({
|
||||
authToken: data.authToken,
|
||||
workspaceChannel: channel,
|
||||
blocks: blocks,
|
||||
});
|
||||
} else {
|
||||
for (
|
||||
let i: number = 0;
|
||||
i < blocks.length;
|
||||
i += maxBlocksPerMessage
|
||||
) {
|
||||
const chunk: Array<JSONObject> = blocks.slice(
|
||||
i,
|
||||
i + maxBlocksPerMessage,
|
||||
);
|
||||
lastThread = await this.sendPayloadBlocksToChannel({
|
||||
authToken: data.authToken,
|
||||
workspaceChannel: channel,
|
||||
blocks: chunk,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lastThread) {
|
||||
workspaspaceMessageResponse.threads.push(lastThread);
|
||||
}
|
||||
|
||||
logger.debug(`Message sent to channel ID ${channel.id} successfully.`);
|
||||
} catch (e) {
|
||||
|
||||
@@ -35,3 +35,5 @@ export const RealtimeRoute: Route = new Route("/realtime/socket");
|
||||
export const DocsRoute: Route = new Route("/docs");
|
||||
|
||||
export const StatusPageApiRoute: Route = new Route("/status-page-api");
|
||||
|
||||
export const PublicDashboardRoute: Route = new Route("/public-dashboard");
|
||||
|
||||
@@ -8,6 +8,7 @@ enum CookieName {
|
||||
IsMasterAdmin = "user-is-master-admin",
|
||||
ProfilePicID = "user-profile-pic-id",
|
||||
StatusPageMasterPassword = "status-page-master-password",
|
||||
DashboardMasterPassword = "dashboard-master-password",
|
||||
}
|
||||
|
||||
export default CookieName;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
enum DashboardChartType {
|
||||
Line = "Line",
|
||||
Bar = "Bar",
|
||||
Area = "Area",
|
||||
StackedArea = "Stacked Area",
|
||||
Pie = "Pie",
|
||||
Heatmap = "Heatmap",
|
||||
Histogram = "Histogram",
|
||||
}
|
||||
|
||||
export default DashboardChartType;
|
||||
|
||||
@@ -2,6 +2,10 @@ enum DashboardComponentType {
|
||||
Chart = `Chart`,
|
||||
Value = `Value`,
|
||||
Text = `Text`,
|
||||
Table = `Table`,
|
||||
Gauge = `Gauge`,
|
||||
LogStream = `LogStream`,
|
||||
TraceList = `TraceList`,
|
||||
}
|
||||
|
||||
export default DashboardComponentType;
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum ComponentInputType {
|
||||
Number = "Number",
|
||||
Decimal = "Decimal",
|
||||
MetricsQueryConfig = "MetricsQueryConfig",
|
||||
MetricsQueryConfigs = "MetricsQueryConfigs",
|
||||
LongText = "Long Text",
|
||||
Dropdown = "Dropdown",
|
||||
}
|
||||
|
||||
@@ -4,15 +4,24 @@ import DashboardComponentType from "../DashboardComponentType";
|
||||
import DashboardChartType from "../Chart/ChartType";
|
||||
import BaseComponent from "./DashboardBaseComponent";
|
||||
|
||||
export interface ChartThreshold {
|
||||
value: number;
|
||||
label?: string | undefined;
|
||||
color?: string | undefined;
|
||||
}
|
||||
|
||||
export default interface DashboardChartComponent extends BaseComponent {
|
||||
componentType: DashboardComponentType.Chart;
|
||||
componentId: ObjectID;
|
||||
arguments: {
|
||||
metricQueryConfig?: MetricQueryConfigData | undefined;
|
||||
metricQueryConfigs?: Array<MetricQueryConfigData> | undefined;
|
||||
chartTitle?: string | undefined;
|
||||
chartDescription?: string | undefined;
|
||||
legendText?: string | undefined;
|
||||
legendUnit?: string | undefined;
|
||||
chartType?: DashboardChartType | undefined;
|
||||
warningThreshold?: number | undefined;
|
||||
criticalThreshold?: number | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import MetricQueryConfigData from "../../Metrics/MetricQueryConfigData";
|
||||
import ObjectID from "../../ObjectID";
|
||||
import DashboardComponentType from "../DashboardComponentType";
|
||||
import BaseComponent from "./DashboardBaseComponent";
|
||||
|
||||
export default interface DashboardGaugeComponent extends BaseComponent {
|
||||
componentType: DashboardComponentType.Gauge;
|
||||
componentId: ObjectID;
|
||||
arguments: {
|
||||
metricQueryConfig?: MetricQueryConfigData | undefined;
|
||||
gaugeTitle?: string | undefined;
|
||||
minValue?: number | undefined;
|
||||
maxValue?: number | undefined;
|
||||
warningThreshold?: number | undefined;
|
||||
criticalThreshold?: number | undefined;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import ObjectID from "../../ObjectID";
|
||||
import DashboardComponentType from "../DashboardComponentType";
|
||||
import BaseComponent from "./DashboardBaseComponent";
|
||||
|
||||
export default interface DashboardLogStreamComponent extends BaseComponent {
|
||||
componentType: DashboardComponentType.LogStream;
|
||||
componentId: ObjectID;
|
||||
arguments: {
|
||||
title?: string | undefined;
|
||||
severityFilter?: string | undefined;
|
||||
bodyContains?: string | undefined;
|
||||
attributeFilterQuery?: string | undefined;
|
||||
maxRows?: number | undefined;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import MetricQueryConfigData from "../../Metrics/MetricQueryConfigData";
|
||||
import ObjectID from "../../ObjectID";
|
||||
import DashboardComponentType from "../DashboardComponentType";
|
||||
import BaseComponent from "./DashboardBaseComponent";
|
||||
|
||||
export default interface DashboardTableComponent extends BaseComponent {
|
||||
componentType: DashboardComponentType.Table;
|
||||
componentId: ObjectID;
|
||||
arguments: {
|
||||
metricQueryConfig?: MetricQueryConfigData | undefined;
|
||||
tableTitle?: string | undefined;
|
||||
maxRows?: number | undefined;
|
||||
};
|
||||
}
|
||||
@@ -10,5 +10,6 @@ export default interface DashboardTextComponent extends BaseComponent {
|
||||
isBold: boolean;
|
||||
isItalic: boolean;
|
||||
isUnderline: boolean;
|
||||
isMarkdown?: boolean | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import ObjectID from "../../ObjectID";
|
||||
import DashboardComponentType from "../DashboardComponentType";
|
||||
import BaseComponent from "./DashboardBaseComponent";
|
||||
|
||||
export default interface DashboardTraceListComponent extends BaseComponent {
|
||||
componentType: DashboardComponentType.TraceList;
|
||||
componentId: ObjectID;
|
||||
arguments: {
|
||||
title?: string | undefined;
|
||||
statusFilter?: string | undefined;
|
||||
maxRows?: number | undefined;
|
||||
};
|
||||
}
|
||||
@@ -9,5 +9,7 @@ export default interface DashboardValueComponent extends BaseComponent {
|
||||
arguments: {
|
||||
metricQueryConfig?: MetricQueryConfigData | undefined;
|
||||
title: string;
|
||||
warningThreshold?: number | undefined;
|
||||
criticalThreshold?: number | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
23
Common/Types/Dashboard/DashboardVariable.ts
Normal file
23
Common/Types/Dashboard/DashboardVariable.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export enum DashboardVariableType {
|
||||
CustomList = "Custom List",
|
||||
Query = "Query",
|
||||
TextInput = "Text Input",
|
||||
}
|
||||
|
||||
export default interface DashboardVariable {
|
||||
id: string;
|
||||
name: string;
|
||||
label?: string | undefined;
|
||||
type: DashboardVariableType;
|
||||
// For CustomList: comma-separated values
|
||||
customListValues?: string | undefined;
|
||||
// For Query: a ClickHouse query to populate options
|
||||
query?: string | undefined;
|
||||
// Current selected value(s)
|
||||
selectedValue?: string | undefined;
|
||||
selectedValues?: Array<string> | undefined;
|
||||
// Whether multi-select is enabled
|
||||
isMultiSelect?: boolean | undefined;
|
||||
// Default value
|
||||
defaultValue?: string | undefined;
|
||||
}
|
||||
@@ -1,8 +1,67 @@
|
||||
import { ObjectType } from "../JSON";
|
||||
import DashboardBaseComponent from "./DashboardComponents/DashboardBaseComponent";
|
||||
import DashboardVariable from "./DashboardVariable";
|
||||
|
||||
export enum AutoRefreshInterval {
|
||||
OFF = "off",
|
||||
FIVE_SECONDS = "5s",
|
||||
TEN_SECONDS = "10s",
|
||||
THIRTY_SECONDS = "30s",
|
||||
ONE_MINUTE = "1m",
|
||||
FIVE_MINUTES = "5m",
|
||||
FIFTEEN_MINUTES = "15m",
|
||||
}
|
||||
|
||||
export function getAutoRefreshIntervalInMs(
|
||||
interval: AutoRefreshInterval,
|
||||
): number | null {
|
||||
switch (interval) {
|
||||
case AutoRefreshInterval.OFF:
|
||||
return null;
|
||||
case AutoRefreshInterval.FIVE_SECONDS:
|
||||
return 5000;
|
||||
case AutoRefreshInterval.TEN_SECONDS:
|
||||
return 10000;
|
||||
case AutoRefreshInterval.THIRTY_SECONDS:
|
||||
return 30000;
|
||||
case AutoRefreshInterval.ONE_MINUTE:
|
||||
return 60000;
|
||||
case AutoRefreshInterval.FIVE_MINUTES:
|
||||
return 300000;
|
||||
case AutoRefreshInterval.FIFTEEN_MINUTES:
|
||||
return 900000;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAutoRefreshIntervalLabel(
|
||||
interval: AutoRefreshInterval,
|
||||
): string {
|
||||
switch (interval) {
|
||||
case AutoRefreshInterval.OFF:
|
||||
return "Off";
|
||||
case AutoRefreshInterval.FIVE_SECONDS:
|
||||
return "5s";
|
||||
case AutoRefreshInterval.TEN_SECONDS:
|
||||
return "10s";
|
||||
case AutoRefreshInterval.THIRTY_SECONDS:
|
||||
return "30s";
|
||||
case AutoRefreshInterval.ONE_MINUTE:
|
||||
return "1m";
|
||||
case AutoRefreshInterval.FIVE_MINUTES:
|
||||
return "5m";
|
||||
case AutoRefreshInterval.FIFTEEN_MINUTES:
|
||||
return "15m";
|
||||
default:
|
||||
return "Off";
|
||||
}
|
||||
}
|
||||
|
||||
export default interface DashboardViewConfig {
|
||||
_type: ObjectType.DashboardViewConfig;
|
||||
components: Array<DashboardBaseComponent>;
|
||||
heightInDashboardUnits: number;
|
||||
refreshInterval?: AutoRefreshInterval | undefined;
|
||||
variables?: Array<DashboardVariable> | undefined;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user