Compare commits

..

2 Commits

Author SHA1 Message Date
Abhi Kumar
ec35ef86cf feat: added changes for spangaps thresholds 2026-03-12 17:13:03 +05:30
Abhi Kumar
ca1cc0a4ac feat: added section in panel settings 2026-03-12 15:41:56 +05:30
31 changed files with 893 additions and 1654 deletions

View File

@@ -1768,19 +1768,19 @@ components:
createdAt:
format: date-time
type: string
expiresAt:
expires_at:
minimum: 0
type: integer
id:
type: string
key:
type: string
lastObservedAt:
last_used:
format: date-time
type: string
name:
type: string
serviceAccountId:
service_account_id:
type: string
updatedAt:
format: date-time
@@ -1788,9 +1788,9 @@ components:
required:
- id
- key
- expiresAt
- lastObservedAt
- serviceAccountId
- expires_at
- last_used
- service_account_id
type: object
ServiceaccounttypesGettableFactorAPIKeyWithKey:
properties:
@@ -1804,14 +1804,14 @@ components:
type: object
ServiceaccounttypesPostableFactorAPIKey:
properties:
expiresAt:
expires_at:
minimum: 0
type: integer
name:
type: string
required:
- name
- expiresAt
- expires_at
type: object
ServiceaccounttypesPostableServiceAccount:
properties:
@@ -1833,16 +1833,13 @@ components:
createdAt:
format: date-time
type: string
deletedAt:
format: date-time
type: string
email:
type: string
id:
type: string
name:
type: string
orgId:
orgID:
type: string
roles:
items:
@@ -1859,19 +1856,18 @@ components:
- email
- roles
- status
- orgId
- deletedAt
- orgID
type: object
ServiceaccounttypesUpdatableFactorAPIKey:
properties:
expiresAt:
expires_at:
minimum: 0
type: integer
name:
type: string
required:
- name
- expiresAt
- expires_at
type: object
ServiceaccounttypesUpdatableServiceAccount:
properties:

View File

@@ -2100,7 +2100,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
* @type integer
* @minimum 0
*/
expiresAt: number;
expires_at: number;
/**
* @type string
*/
@@ -2113,7 +2113,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
* @type string
* @format date-time
*/
lastObservedAt: Date;
last_used: Date;
/**
* @type string
*/
@@ -2121,7 +2121,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
/**
* @type string
*/
serviceAccountId: string;
service_account_id: string;
/**
* @type string
* @format date-time
@@ -2145,7 +2145,7 @@ export interface ServiceaccounttypesPostableFactorAPIKeyDTO {
* @type integer
* @minimum 0
*/
expiresAt: number;
expires_at: number;
/**
* @type string
*/
@@ -2173,11 +2173,6 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @format date-time
*/
createdAt?: Date;
/**
* @type string
* @format date-time
*/
deletedAt: Date;
/**
* @type string
*/
@@ -2193,7 +2188,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
/**
* @type string
*/
orgId: string;
orgID: string;
/**
* @type array
*/
@@ -2214,7 +2209,7 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
* @type integer
* @minimum 0
*/
expiresAt: number;
expires_at: number;
/**
* @type string
*/

View File

@@ -0,0 +1,67 @@
.settings-section {
border-top: 1px solid var(--bg-slate-500);
}
.settings-section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 12px;
min-height: 44px;
background: transparent;
border: none;
outline: none;
cursor: pointer;
.settings-section-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-weight: 400;
text-transform: uppercase;
}
.chevron-icon {
color: var(--bg-vanilla-400);
transition: transform 0.2s ease-in-out;
&.open {
transform: rotate(180deg);
}
}
}
.settings-section-content {
padding: 0 16px 24px 16px;
display: flex;
flex-direction: column;
gap: 20px;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 0.25s ease, opacity 0.25s ease;
&.open {
max-height: 1000px;
opacity: 1;
}
}
.lightMode {
.settings-section-header {
.chevron-icon {
color: var(--bg-ink-400);
}
.settings-section-title {
color: var(--bg-ink-400);
}
}
.settings-section {
border-top: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -0,0 +1,51 @@
import { ReactNode, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import './SettingsSection.styles.scss';
export interface SettingsSectionProps {
title: string;
defaultOpen?: boolean;
children: ReactNode;
icon?: ReactNode;
}
function SettingsSection({
title,
defaultOpen = false,
children,
icon,
}: SettingsSectionProps): JSX.Element {
const [isOpen, setIsOpen] = useState(defaultOpen);
const toggleOpen = (): void => {
setIsOpen((prev) => !prev);
};
return (
<section className="settings-section">
<button
type="button"
className="settings-section-header"
onClick={toggleOpen}
>
<span className="settings-section-title">
{icon ? icon : null} {title}
</span>
<ChevronDown
size={16}
className={isOpen ? 'chevron-icon open' : 'chevron-icon'}
/>
</button>
<div
className={
isOpen ? 'settings-section-content open' : 'settings-section-content'
}
>
{children}
</div>
</section>
);
}
export default SettingsSection;

View File

@@ -14,7 +14,7 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button, Modal, Typography } from 'antd';
import { Button, Modal } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react';
import {
@@ -134,11 +134,16 @@ function ContextLinks({
return (
<div className="context-links-container">
<Typography.Text className="context-links-text">
Context Links
</Typography.Text>
<div className="context-links-list">
<Button
type="default"
className="add-context-link-button"
icon={<Plus size={12} />}
style={{ width: '100%' }}
onClick={handleAddContextLink}
>
Add Context Link
</Button>
<OverlayScrollbar>
<DndContext
sensors={sensors}
@@ -160,16 +165,6 @@ function ContextLinks({
</SortableContext>
</DndContext>
</OverlayScrollbar>
{/* button to add context link */}
<Button
type="primary"
className="add-context-link-button"
icon={<Plus size={12} />}
onClick={handleAddContextLink}
>
Context Link
</Button>
</div>
<Modal

View File

@@ -2,7 +2,6 @@
display: flex;
flex-direction: column;
gap: 16px;
margin: 12px;
}
.context-links-text {
@@ -110,10 +109,7 @@
}
.add-context-link-button {
display: flex;
align-items: center;
margin: auto;
width: fit-content;
width: 100%;
}
.lightMode {

View File

@@ -24,14 +24,14 @@
letter-spacing: -0.07px;
}
}
.name-description {
.control-container {
display: flex;
flex-direction: column;
padding: 12px 12px 16px 12px;
border-top: 1px solid var(--bg-slate-500);
border-bottom: 1px solid var(--bg-slate-500);
gap: 8px;
}
.name-description {
padding: 0 0 4px 0;
.typography {
color: var(--bg-vanilla-400);
@@ -88,9 +88,6 @@
.panel-config {
display: flex;
flex-direction: column;
padding: 12px 12px 16px 12px;
gap: 8px;
border-bottom: 1px solid var(--bg-slate-500);
.typography {
color: var(--bg-vanilla-400);
@@ -137,7 +134,6 @@
}
.fill-gaps {
margin-top: 16px;
display: flex;
padding: 12px;
justify-content: space-between;
@@ -156,31 +152,24 @@
letter-spacing: 0.52px;
text-transform: uppercase;
}
.fill-gaps-text-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
opacity: 0.6;
line-height: 16px; /* 133.333% */
}
}
.log-scale,
.decimal-precision-selector {
margin-top: 16px;
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.decimal-precision-selector,
.legend-position {
margin-top: 16px;
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.legend-colors {
margin-top: 16px;
}
.panel-time-text {
margin-top: 16px;
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
@@ -193,7 +182,6 @@
.y-axis-unit-selector,
.y-axis-unit-selector-v2 {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
@@ -278,11 +266,8 @@
}
.stack-chart {
margin-top: 16px;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 8px;
.label {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
@@ -296,11 +281,6 @@
}
.bucket-config {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
.label {
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
@@ -352,16 +332,13 @@
}
}
.context-links {
border-bottom: 1px solid var(--bg-slate-500);
}
.alerts {
display: flex;
padding: 12px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--bg-slate-500);
padding: 12px 12px 32px 12px;
min-height: 44px;
border-top: 1px solid var(--bg-slate-500);
cursor: pointer;
.left-section {
@@ -387,6 +364,16 @@
color: var(--bg-vanilla-400);
}
}
.context-links {
padding: 12px 12px 16px 12px;
border-bottom: 1px solid var(--bg-slate-500);
}
.thresholds-section {
padding: 12px 12px 16px 12px;
border-top: 1px solid var(--bg-slate-500);
}
}
.select-option {
@@ -441,8 +428,6 @@
}
.panel-config {
border-bottom: 1px solid var(--bg-vanilla-300);
.typography {
color: var(--bg-ink-400);
}
@@ -478,6 +463,9 @@
.fill-gaps-text {
color: var(--bg-ink-400);
}
.fill-gaps-text-description {
color: var(--bg-ink-400);
}
}
.bucket-config {
@@ -530,7 +518,7 @@
}
.alerts {
border-bottom: 1px solid var(--bg-vanilla-300);
border-top: 1px solid var(--bg-vanilla-300);
.left-section {
.bell-icon {
@@ -549,6 +537,10 @@
.context-links {
border-bottom: 1px solid var(--bg-vanilla-300);
}
.thresholds-section {
border-top: 1px solid var(--bg-vanilla-300);
}
}
.select-option {

View File

@@ -1,5 +1,4 @@
.threshold-selector-container {
padding: 12px;
padding-bottom: 80px;
.threshold-select {

View File

@@ -1,10 +1,10 @@
import { useCallback } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Typography } from 'antd';
import { Button } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryLabels } from 'hooks/useGetQueryLabels';
import { Antenna, Plus } from 'lucide-react';
import { Plus } from 'lucide-react';
import { v4 as uuid } from 'uuid';
import Threshold from './Threshold';
@@ -68,11 +68,14 @@ function ThresholdSelector({
<DndProvider backend={HTML5Backend}>
<div className="threshold-selector-container">
<div className="threshold-select" onClick={addThresholdHandler}>
<div className="left-section">
<Antenna size={14} className="icon" />
<Typography.Text className="text">Thresholds</Typography.Text>
</div>
<Plus size={14} onClick={addThresholdHandler} className="icon" />
<Button
type="default"
icon={<Plus size={14} />}
style={{ width: '100%' }}
onClick={addThresholdHandler}
>
Add Threshold
</Button>
</div>
{thresholds.map((threshold, idx) => (
<Threshold

View File

@@ -14,11 +14,11 @@ import {
Input,
InputNumber,
Select,
Space,
Switch,
Typography,
} from 'antd';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import SettingsSection from 'components/SettingsSection/SettingsSection';
import TimePreference from 'components/TimePreferenceDropDown';
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
import GraphTypes, {
@@ -28,8 +28,14 @@ import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
Antenna,
Axis3D,
ConciergeBell,
Layers,
LayoutDashboard,
LineChart,
Link,
Pencil,
Plus,
Spline,
SquareArrowOutUpRight,
@@ -263,269 +269,304 @@ function RightContainer({
<div className="right-container">
<section className="header">
<div className="purple-dot" />
<Typography.Text className="header-text">Panel details</Typography.Text>
<Typography.Text className="header-text">Panel Settings</Typography.Text>
</section>
<section className="name-description">
<Typography.Text className="typography">Name</Typography.Text>
<AutoComplete
options={dashboardVariableOptions}
value={inputValue}
onChange={onInputChange}
onSelect={onSelect}
filterOption={filterOption}
style={{ width: '100%' }}
getPopupContainer={popupContainer}
placeholder="Enter the panel name here..."
open={autoCompleteOpen}
>
<Input
rootClassName="name-input"
ref={inputRef}
onSelect={handleInputCursor}
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/>
</AutoComplete>
<Typography.Text className="typography">Description</Typography.Text>
<TextArea
placeholder="Enter the panel description here..."
bordered
allowClear
value={description}
onChange={(event): void =>
onChangeHandler(setDescription, event.target.value)
}
rootClassName="description-input"
/>
</section>
<section className="panel-config">
<Typography.Text className="typography">Panel Type</Typography.Text>
<Select
onChange={setGraphHandler}
value={selectedGraph}
style={{ width: '100%' }}
className="panel-type-select"
data-testid="panel-change-select"
data-stacking-state={stackedBarChart ? 'true' : 'false'}
>
{graphTypes.map((item) => (
<Option key={item.name} value={item.name}>
<div className="select-option">
<div className="icon">{item.icon}</div>
<Typography.Text className="display">{item.display}</Typography.Text>
</div>
</Option>
))}
</Select>
{allowFillSpans && (
<Space className="fill-gaps">
<Typography className="fill-gaps-text">Fill gaps</Typography>
<Switch
checked={isFillSpans}
size="small"
onChange={(checked): void => setIsFillSpans(checked)}
<SettingsSection
title="Panel Details"
defaultOpen
icon={<Pencil size={14} />}
>
<section className="name-description control-container">
<Typography.Text className="typography">Name</Typography.Text>
<AutoComplete
options={dashboardVariableOptions}
value={inputValue}
onChange={onInputChange}
onSelect={onSelect}
filterOption={filterOption}
style={{ width: '100%' }}
getPopupContainer={popupContainer}
placeholder="Enter the panel name here..."
open={autoCompleteOpen}
>
<Input
rootClassName="name-input"
ref={inputRef}
onSelect={handleInputCursor}
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/>
</Space>
)}
{allowPanelTimePreference && (
<>
<Typography.Text className="panel-time-text">
Panel Time Preference
</Typography.Text>
<TimePreference
{...{
selectedTime,
setSelectedTime,
}}
/>
</>
)}
{allowPanelColumnPreference && (
<ColumnUnitSelector
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
/>
)}
{allowYAxisUnit && (
<DashboardYAxisUnitSelectorWrapper
onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={
selectedGraphType === PanelDisplay.VALUE ||
selectedGraphType === PanelDisplay.PIE
? 'Unit'
: 'Y Axis Unit'
</AutoComplete>
<Typography.Text className="typography">Description</Typography.Text>
<TextArea
placeholder="Enter the panel description here..."
bordered
allowClear
value={description}
onChange={(event): void =>
onChangeHandler(setDescription, event.target.value)
}
// Only update the y-axis unit value automatically in create mode
shouldUpdateYAxisUnit={isNewDashboard}
rootClassName="description-input"
/>
)}
</section>
</SettingsSection>
{allowDecimalPrecision && (
<section className="decimal-precision-selector">
<Typography.Text className="typography">
Decimal Precision
</Typography.Text>
<section className="panel-config">
<SettingsSection title="Visualization" icon={<LayoutDashboard size={14} />}>
<section className="panel-type control-container">
<Typography.Text className="typography">Panel Type</Typography.Text>
<Select
options={[
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
{ label: '4 decimals', value: PrecisionOptionsEnum.FOUR },
{ label: 'Full Precision', value: PrecisionOptionsEnum.FULL },
]}
value={decimalPrecision}
onChange={setGraphHandler}
value={selectedGraph}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={PrecisionOptionsEnum.TWO}
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
/>
data-testid="panel-change-select"
data-stacking-state={stackedBarChart ? 'true' : 'false'}
>
{graphTypes.map((item) => (
<Option key={item.name} value={item.name}>
<div className="select-option">
<div className="icon">{item.icon}</div>
<Typography.Text className="display">{item.display}</Typography.Text>
</div>
</Option>
))}
</Select>
</section>
)}
{allowSoftMinMax && (
<section className="soft-min-max">
<section className="container">
<Typography.Text className="text">Soft Min</Typography.Text>
<InputNumber
type="number"
value={softMin}
onChange={softMinHandler}
rootClassName="input"
{allowStackingBarChart && (
<section className="stack-chart control-container">
<Typography.Text className="label">Stack series</Typography.Text>
<Switch
checked={stackedBarChart}
size="small"
onChange={(checked): void => setStackedBarChart(checked)}
/>
</section>
<section className="container">
<Typography.Text className="text">Soft Max</Typography.Text>
<InputNumber
value={softMax}
type="number"
rootClassName="input"
onChange={softMaxHandler}
)}
{allowFillSpans && (
<section className="fill-gaps">
<div className="fill-gaps-text-container">
<Typography className="fill-gaps-text">Fill gaps</Typography>
<Typography.Text className="fill-gaps-text-description">
Fill gaps in data with 0 for continuity
</Typography.Text>
</div>
<Switch
checked={isFillSpans}
size="small"
onChange={(checked): void => setIsFillSpans(checked)}
/>
</section>
</section>
)}
{allowPanelTimePreference && (
<section className="panel-time-preference control-container">
<Typography.Text className="panel-time-text">
Panel Time Preference
</Typography.Text>
<TimePreference
{...{
selectedTime,
setSelectedTime,
}}
/>
</section>
)}
{allowPanelColumnPreference && (
<ColumnUnitSelector
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
/>
)}
</SettingsSection>
{(allowYAxisUnit ||
allowDecimalPrecision ||
allowSoftMinMax ||
allowLogScale) && (
<SettingsSection title="Axis" icon={<Axis3D size={14} />}>
{allowYAxisUnit && (
<DashboardYAxisUnitSelectorWrapper
onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={
selectedGraphType === PanelDisplay.VALUE ||
selectedGraphType === PanelDisplay.PIE
? 'Unit'
: 'Y Axis Unit'
}
// Only update the y-axis unit value automatically in create mode
shouldUpdateYAxisUnit={isNewDashboard}
/>
)}
{allowDecimalPrecision && (
<section className="decimal-precision-selector control-container">
<Typography.Text className="typography">
Decimal Precision
</Typography.Text>
<Select
options={[
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
{ label: '4 decimals', value: PrecisionOptionsEnum.FOUR },
{
label: 'Full Precision',
value: PrecisionOptionsEnum.FULL,
},
]}
value={decimalPrecision}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={PrecisionOptionsEnum.TWO}
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
/>
</section>
)}
{allowSoftMinMax && (
<section className="soft-min-max">
<section className="container">
<Typography.Text className="text">Soft Min</Typography.Text>
<InputNumber
type="number"
value={softMin}
onChange={softMinHandler}
rootClassName="input"
/>
</section>
<section className="container">
<Typography.Text className="text">Soft Max</Typography.Text>
<InputNumber
value={softMax}
type="number"
rootClassName="input"
onChange={softMaxHandler}
/>
</section>
</section>
)}
{allowLogScale && (
<section className="log-scale control-container">
<Typography.Text className="typography">Y Axis Scale</Typography.Text>
<Select
onChange={(value): void =>
setIsLogScale(value === LogScale.LOGARITHMIC)
}
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LogScale.LINEAR}
>
<Option value={LogScale.LINEAR}>
<div className="select-option">
<div className="icon">
<LineChart size={16} />
</div>
<Typography.Text className="display">Linear</Typography.Text>
</div>
</Option>
<Option value={LogScale.LOGARITHMIC}>
<div className="select-option">
<div className="icon">
<Spline size={16} />
</div>
<Typography.Text className="display">Logarithmic</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
</SettingsSection>
)}
{allowStackingBarChart && (
<section className="stack-chart">
<Typography.Text className="label">Stack series</Typography.Text>
<Switch
checked={stackedBarChart}
size="small"
onChange={(checked): void => setStackedBarChart(checked)}
/>
</section>
{(allowLegendPosition || allowLegendColors) && (
<SettingsSection title="Legend" icon={<Layers size={14} />}>
{allowLegendPosition && (
<section className="legend-position control-container">
<Typography.Text className="typography">Position</Typography.Text>
<Select
onChange={(value: LegendPosition): void => setLegendPosition(value)}
value={legendPosition}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LegendPosition.BOTTOM}
>
<Option value={LegendPosition.BOTTOM}>
<div className="select-option">
<Typography.Text className="display">Bottom</Typography.Text>
</div>
</Option>
<Option value={LegendPosition.RIGHT}>
<div className="select-option">
<Typography.Text className="display">Right</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendColors && (
<section className="legend-colors">
<LegendColors
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
</section>
)}
</SettingsSection>
)}
{allowBucketConfig && (
<section className="bucket-config">
<Typography.Text className="label">Number of buckets</Typography.Text>
<InputNumber
value={bucketCount || null}
type="number"
min={0}
rootClassName="bucket-input"
placeholder="Default: 30"
onChange={(val): void => {
setBucketCount(val || 0);
}}
/>
<Typography.Text className="label bucket-size-label">
Bucket width
</Typography.Text>
<InputNumber
value={bucketWidth || null}
type="number"
precision={2}
placeholder="Default: Auto"
step={0.1}
min={0.0}
rootClassName="bucket-input"
onChange={(val): void => {
setBucketWidth(val || 0);
}}
/>
<section className="combine-hist">
<Typography.Text className="label">
Merge all series into one
</Typography.Text>
<Switch
checked={combineHistogram}
size="small"
onChange={(checked): void => setCombineHistogram(checked)}
<SettingsSection title="Histogram / Buckets">
<section className="bucket-config control-container">
<Typography.Text className="label">Number of buckets</Typography.Text>
<InputNumber
value={bucketCount || null}
type="number"
min={0}
rootClassName="bucket-input"
placeholder="Default: 30"
onChange={(val): void => {
setBucketCount(val || 0);
}}
/>
<Typography.Text className="label bucket-size-label">
Bucket width
</Typography.Text>
<InputNumber
value={bucketWidth || null}
type="number"
precision={2}
placeholder="Default: Auto"
step={0.1}
min={0.0}
rootClassName="bucket-input"
onChange={(val): void => {
setBucketWidth(val || 0);
}}
/>
<section className="combine-hist">
<Typography.Text className="label">
Merge all series into one
</Typography.Text>
<Switch
checked={combineHistogram}
size="small"
onChange={(checked): void => setCombineHistogram(checked)}
/>
</section>
</section>
</section>
)}
{allowLogScale && (
<section className="log-scale">
<Typography.Text className="typography">Y Axis Scale</Typography.Text>
<Select
onChange={(value): void => setIsLogScale(value === LogScale.LOGARITHMIC)}
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LogScale.LINEAR}
>
<Option value={LogScale.LINEAR}>
<div className="select-option">
<div className="icon">
<LineChart size={16} />
</div>
<Typography.Text className="display">Linear</Typography.Text>
</div>
</Option>
<Option value={LogScale.LOGARITHMIC}>
<div className="select-option">
<div className="icon">
<Spline size={16} />
</div>
<Typography.Text className="display">Logarithmic</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendPosition && (
<section className="legend-position">
<Typography.Text className="typography">Legend Position</Typography.Text>
<Select
onChange={(value: LegendPosition): void => setLegendPosition(value)}
value={legendPosition}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LegendPosition.BOTTOM}
>
<Option value={LegendPosition.BOTTOM}>
<div className="select-option">
<Typography.Text className="display">Bottom</Typography.Text>
</div>
</Option>
<Option value={LegendPosition.RIGHT}>
<div className="select-option">
<Typography.Text className="display">Right</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendColors && (
<section className="legend-colors">
<LegendColors
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
</section>
</SettingsSection>
)}
</section>
@@ -541,17 +582,25 @@ function RightContainer({
)}
{allowContextLinks && (
<section className="context-links">
<SettingsSection
title="Context Links"
icon={<Link size={14} />}
defaultOpen={!!contextLinks.linksData.length}
>
<ContextLinks
contextLinks={contextLinks}
setContextLinks={setContextLinks}
selectedWidget={selectedWidget}
/>
</section>
</SettingsSection>
)}
{allowThreshold && (
<section>
<SettingsSection
title="Thresholds"
icon={<Antenna size={14} />}
defaultOpen={!!thresholds.length}
>
<ThresholdSelector
thresholds={thresholds}
setThresholds={setThresholds}
@@ -559,7 +608,7 @@ function RightContainer({
selectedGraph={selectedGraph}
columnUnits={columnUnits}
/>
</section>
</SettingsSection>
)}
</div>
);

View File

@@ -7,6 +7,7 @@ import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFall
import uPlot, { AlignedData, Options } from 'uplot';
import { usePlotContext } from '../context/PlotContext';
import { applySpanGapsToAlignedData } from '../utils/dataUtils';
import { UPlotChartProps } from './types';
/**
@@ -84,7 +85,13 @@ export default function UPlotChart({
} as Options;
// Create new plot instance
const plot = new uPlot(plotConfig, data as AlignedData, containerRef.current);
const seriesSpanGaps = config.getSeriesSpanGapsOptions();
const preparedData =
seriesSpanGaps.length > 0
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
: (data as AlignedData);
const plot = new uPlot(plotConfig, preparedData, containerRef.current);
if (plotRef) {
plotRef(plot);
@@ -162,7 +169,13 @@ export default function UPlotChart({
}
// Update data if only data changed
else if (!sameData(prevProps, currentProps) && plotInstanceRef.current) {
plotInstanceRef.current.setData(data as AlignedData);
const seriesSpanGaps = config.getSeriesSpanGapsOptions?.() ?? [];
const preparedData =
seriesSpanGaps.length > 0
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
: (data as AlignedData);
plotInstanceRef.current.setData(preparedData as AlignedData);
}
prevPropsRef.current = currentProps;

View File

@@ -14,6 +14,7 @@ import {
STEP_INTERVAL_MULTIPLIER,
} from '../constants';
import { calculateWidthBasedOnStepInterval } from '../utils';
import { SeriesSpanGapsOption } from '../utils/dataUtils';
import {
ConfigBuilder,
ConfigBuilderProps,
@@ -161,6 +162,13 @@ export class UPlotConfigBuilder extends ConfigBuilder<
this.series.push(new UPlotSeriesBuilder(props));
}
getSeriesSpanGapsOptions(): SeriesSpanGapsOption[] {
return this.series.map((s) => {
const { spanGaps } = s.props;
return { spanGaps };
});
}
/**
* Add a hook for extensibility
*/

View File

@@ -201,7 +201,12 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
return {
scale: scaleKey,
label,
spanGaps: typeof spanGaps === 'boolean' ? spanGaps : false,
// When spanGaps is numeric, we always disable uPlot's internal
// spanGaps behavior and rely on data-prep to implement the
// threshold-based null handling. When spanGaps is boolean we
// map it directly. When spanGaps is undefined we fall back to
// the default of false.
spanGaps: typeof spanGaps === 'number' ? false : !!spanGaps,
value: (): string => '',
pxAlign: true,
show,

View File

@@ -45,6 +45,37 @@ describe('UPlotSeriesBuilder', () => {
expect(typeof config.value).toBe('function');
});
it('maps boolean spanGaps directly to uPlot spanGaps', () => {
const trueBuilder = new UPlotSeriesBuilder(
createBaseProps({
spanGaps: true,
}),
);
const falseBuilder = new UPlotSeriesBuilder(
createBaseProps({
spanGaps: false,
}),
);
const trueConfig = trueBuilder.getConfig();
const falseConfig = falseBuilder.getConfig();
expect(trueConfig.spanGaps).toBe(true);
expect(falseConfig.spanGaps).toBe(false);
});
it('disables uPlot spanGaps when spanGaps is a number', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
spanGaps: 10000,
}),
);
const config = builder.getConfig();
expect(config.spanGaps).toBe(false);
});
it('uses explicit lineColor when provided, regardless of mapping', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({

View File

@@ -175,7 +175,16 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
pointsFilter?: Series.Points.Filter;
pointsBuilder?: Series.Points.Show;
show?: boolean;
spanGaps?: boolean;
/**
* Controls how nulls are treated for this series.
*
* - boolean: mapped directly to uPlot's spanGaps behavior
* - number: interpreted as an X-axis threshold (same unit as ref values),
* where gaps smaller than this threshold are spanned by
* converting short null runs to undefined during data prep
* while uPlot's internal spanGaps is kept disabled.
*/
spanGaps?: boolean | number;
fillColor?: string;
isDarkMode?: boolean;
stepInterval?: number;

View File

@@ -1,4 +1,11 @@
import { isInvalidPlotValue, normalizePlotValue } from '../dataUtils';
import uPlot from 'uplot';
import {
applySpanGapsToAlignedData,
isInvalidPlotValue,
normalizePlotValue,
SeriesSpanGapsOption,
} from '../dataUtils';
describe('dataUtils', () => {
describe('isInvalidPlotValue', () => {
@@ -59,4 +66,56 @@ describe('dataUtils', () => {
expect(normalizePlotValue(42.5)).toBe(42.5);
});
});
describe('applyspanGapsToAlignedData', () => {
const xs: uPlot.AlignedData[0] = [0, 10, 20, 30];
it('returns original data when there are no series', () => {
const data: uPlot.AlignedData = [xs];
const result = applySpanGapsToAlignedData(data, []);
expect(result).toBe(data);
});
it('leaves data unchanged when spanGaps is undefined', () => {
const ys = [1, null, 2, null];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{}];
const result = applySpanGapsToAlignedData(data, options);
expect(result[1]).toEqual(ys);
});
it('converts nulls to undefined when spanGaps is true', () => {
const ys = [1, null, 2, null];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{ spanGaps: true }];
const result = applySpanGapsToAlignedData(data, options);
expect(result[1]).toEqual([1, undefined, 2, undefined]);
});
it('leaves data unchanged when spanGaps is false', () => {
const ys = [1, null, 2, null];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{ spanGaps: false }];
const result = applySpanGapsToAlignedData(data, options);
expect(result[1]).toEqual(ys);
});
it('runs threshold-based null handling when spanGaps is numeric', () => {
const ys = [1, null, null, 2];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 25 }];
const result = applySpanGapsToAlignedData(data, options);
// gap between x=0 and x=30 is 30, so with threshold 25 it should stay null
expect(result[1]).toEqual([1, null, null, 2]);
});
});
});

View File

@@ -0,0 +1,39 @@
import { nullToUndefThreshold } from '../nullHandling';
describe('nullToUndefThreshold', () => {
it('converts short null gaps to undefined', () => {
const xs = [0, 10, 20, 30, 40];
const ys: Array<number | null | undefined> = [1, null, null, 2, 3];
const result = nullToUndefThreshold(xs, ys, 25);
expect(result).toEqual([1, undefined, undefined, 2, 3]);
});
it('keeps long null gaps as null', () => {
const xs = [0, 10, 100, 200];
const ys: Array<number | null | undefined> = [1, null, null, 2];
const result = nullToUndefThreshold(xs, ys, 50);
expect(result).toEqual([1, null, null, 2]);
});
it('leaves leading and trailing nulls as-is', () => {
const xs = [0, 10, 20, 30];
const ys: Array<number | null | undefined> = [null, null, 1, null];
const result = nullToUndefThreshold(xs, ys, 50);
expect(result).toEqual([null, null, 1, null]);
});
it('is a no-op when there are no nulls', () => {
const xs = [0, 10, 20];
const ys: Array<number | null | undefined> = [1, 2, 3];
const result = nullToUndefThreshold(xs, ys, 50);
expect(result).toEqual([1, 2, 3]);
});
});

View File

@@ -1,3 +1,5 @@
import { nullToUndefThreshold } from './nullHandling';
/**
* Checks if a value is invalid for plotting
*
@@ -51,3 +53,52 @@ export function normalizePlotValue(
// Already a valid number
return value as number;
}
export interface SeriesSpanGapsOption {
spanGaps?: boolean | number;
}
/**
* Apply per-series spanGaps (boolean | threshold) handling to an aligned dataset.
*
* The input data is expected to be of the form:
* [xValues, series1Values, series2Values, ...]
*/
export function applySpanGapsToAlignedData(
data: uPlot.AlignedData,
seriesOptions: SeriesSpanGapsOption[],
): uPlot.AlignedData {
const [xValues, ...seriesValues] = data;
if (!Array.isArray(xValues) || seriesValues.length === 0) {
return data;
}
const transformedSeries = seriesValues.map((ys, idx) => {
const { spanGaps } = seriesOptions[idx] || {};
if (spanGaps === undefined) {
return ys;
}
if (typeof spanGaps === 'boolean') {
if (!spanGaps) {
return ys;
}
// spanGaps === true -> treat nulls as soft gaps (convert to undefined)
return (ys as Array<number | null | undefined>).map((v) =>
v === null ? undefined : v,
) as uPlot.AlignedData[0];
}
// Numeric spanGaps: threshold-based null handling
return nullToUndefThreshold(
xValues as uPlot.AlignedData[0],
ys as Array<number | null | undefined>,
spanGaps,
);
});
return [xValues, ...transformedSeries] as uPlot.AlignedData;
}

View File

@@ -0,0 +1,73 @@
import { AlignedData } from 'uplot';
/**
* Convert short runs of nulls between two defined points into undefined so that
* uPlot treats them as "no point" but keeps the line continuous for gaps
* smaller than the provided time threshold.
*/
type AlignedXValues = AlignedData[0];
type YValues = Array<number | null | undefined>;
interface GapArgs {
xValues: AlignedXValues;
yValues: YValues;
maxGapThreshold: number;
startIndex: number;
endIndex: number;
}
function spanShortGap(args: GapArgs): void {
const { xValues, yValues, maxGapThreshold, startIndex, endIndex } = args;
const gapSize = xValues[endIndex] - xValues[startIndex];
if (gapSize >= maxGapThreshold) {
return;
}
for (let index = startIndex + 1; index < endIndex; index += 1) {
if (yValues[index] === null || yValues[index] === undefined) {
// Use undefined to indicate "no sample" so the line can span
yValues[index] = undefined;
}
}
}
export function nullToUndefThreshold(
xValues: AlignedXValues,
yValues: YValues,
maxGapThreshold: number,
): YValues {
if (!Array.isArray(xValues) || !Array.isArray(yValues)) {
return yValues;
}
const length = Math.min(xValues.length, yValues.length);
if (length === 0 || maxGapThreshold <= 0) {
return yValues;
}
let previousDefinedIndex: number | null = null;
for (let index = 0; index < length; index += 1) {
const value = yValues[index];
if (value === null || value === undefined) {
continue;
}
if (previousDefinedIndex !== null && index - previousDefinedIndex > 1) {
spanShortGap({
xValues,
yValues,
maxGapThreshold,
startIndex: previousDefinedIndex,
endIndex: index,
});
}
previousDefinedIndex = index;
}
return yValues;
}

View File

@@ -1,62 +0,0 @@
package cloudintegration
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
GetName() cloudintegrationtypes.CloudProviderType
// AgentCheckIn is called by agent to heartbeat and get latest config in response.
AgentCheckIn(ctx context.Context, req *cloudintegrationtypes.PostableAgentCheckInPayload) (any, error)
GenerateConnectionParams(ctx context.Context) (*cloudintegrationtypes.GettableCloudIntegrationConnectionParams, error)
// GenerateConnectionArtifact generates cloud provider specific connection information, client side handles how this information is shown
GenerateConnectionArtifact(ctx context.Context, req *cloudintegrationtypes.PostableConnectionArtifact) (any, error)
// GetAccountStatus returns agent connection status for a cloud integration account
GetAccountStatus(ctx context.Context, orgID, accountID string) (*cloudintegrationtypes.GettableAccountStatus, error)
// ListConnectedAccounts lists accounts where agent is connected
ListConnectedAccounts(ctx context.Context, orgID string) (*cloudintegrationtypes.GettableConnectedAccountsList, error)
// LIstServices return list of services for a cloud provider attached with the accountID. This just returns a summary
ListServices(ctx context.Context, orgID string, accountID *string) (any, error) // returns either GettableAWSServices or GettableAzureServices
// GetServiceDetails returns service definition details for a serviceId. This returns config and other details required to show in service details page on client.
GetServiceDetails(ctx context.Context, req *cloudintegrationtypes.GetServiceDetailsReq) (any, error)
// GetDashboard returns dashboard json for a give cloud integration service dashboard.
// this only returns the dashboard when account is connected and service is enabled
GetDashboard(ctx context.Context, id string, orgID valuer.UUID) (*dashboardtypes.Dashboard, error)
// GetAvailableDashboards returns list of available dashboards across all connected cloud integration accounts in the org.
// this list gets added to dashboard list page
GetAvailableDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error)
// UpdateAccountConfig updates cloud integration account config
UpdateAccountConfig(ctx context.Context, orgId valuer.UUID, accountId string, config []byte) (any, error)
// UpdateServiceConfig updates cloud integration service config
UpdateServiceConfig(ctx context.Context, serviceId string, orgID valuer.UUID, config []byte) (any, error)
// DisconnectAccount soft deletes/removes a cloud integration account.
DisconnectAccount(ctx context.Context, orgID, accountID string) (*cloudintegrationtypes.CloudIntegration, error)
}
type Handler interface {
AgentCheckIn(http.ResponseWriter, *http.Request)
GenerateConnectionParams(http.ResponseWriter, *http.Request)
GenerateConnectionArtifact(http.ResponseWriter, *http.Request)
ListConnectedAccounts(http.ResponseWriter, *http.Request)
GetAccountStatus(http.ResponseWriter, *http.Request)
ListServices(http.ResponseWriter, *http.Request)
GetServiceDetails(http.ResponseWriter, *http.Request)
UpdateAccountConfig(http.ResponseWriter, *http.Request)
UpdateServiceConfig(http.ResponseWriter, *http.Request)
DisconnectAccount(http.ResponseWriter, *http.Request)
}

View File

@@ -111,12 +111,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
err = serviceAccount.Update(req.Name, req.Email, req.Roles)
if err != nil {
render.Error(rw, err)
return
}
serviceAccount.Update(req.Name, req.Email, req.Roles)
err = handler.module.Update(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
if err != nil {
render.Error(rw, err)
@@ -152,12 +147,7 @@ func (handler *handler) UpdateStatus(rw http.ResponseWriter, r *http.Request) {
return
}
err = serviceAccount.UpdateStatus(req.Status)
if err != nil {
render.Error(rw, err)
return
}
serviceAccount.UpdateStatus(req.Status)
err = handler.module.UpdateStatus(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
if err != nil {
render.Error(rw, err)
@@ -300,7 +290,7 @@ func (handler *handler) UpdateFactorAPIKey(rw http.ResponseWriter, r *http.Reque
}
factorAPIKey.Update(req.Name, req.ExpiresAt)
err = handler.module.UpdateFactorAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount.ID, factorAPIKey)
err = handler.module.UpdateFactorAPIKey(ctx, serviceAccount.ID, factorAPIKey)
if err != nil {
render.Error(rw, err)
return

View File

@@ -5,7 +5,6 @@ import (
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -61,24 +60,6 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAcco
return nil
}
func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceaccounttypes.ServiceAccount) (*serviceaccounttypes.ServiceAccount, error) {
existingServiceAccount, err := module.store.GetActiveByOrgIDAndName(ctx, serviceAccount.OrgID, serviceAccount.Name)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingServiceAccount != nil {
return serviceAccount, nil
}
err = module.Create(ctx, serviceAccount.OrgID, serviceAccount)
if err != nil {
return nil, err
}
return serviceAccount, nil
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccount, error) {
storableServiceAccount, err := module.store.Get(ctx, orgID, id)
if err != nil {
@@ -190,28 +171,26 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serv
}
func (module *module) UpdateStatus(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
err := module.authz.Revoke(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, input.ID.String(), orgID, nil))
serviceAccount, err := module.Get(ctx, orgID, input.ID)
if err != nil {
return err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
// revoke all the API keys on disable
err := module.store.RevokeAllFactorAPIKeys(ctx, input.ID)
if err != nil {
return err
}
// update the status but do not delete the role mappings as we will use them for audits
err = module.store.Update(ctx, orgID, serviceaccounttypes.NewStorableServiceAccount(input))
if err != nil {
return err
}
if input.Status == serviceAccount.Status {
return nil
})
if err != nil {
return err
}
switch input.Status {
case serviceaccounttypes.StatusActive:
err := module.activateServiceAccount(ctx, orgID, input)
if err != nil {
return err
}
case serviceaccounttypes.StatusDisabled:
err := module.disableServiceAccount(ctx, orgID, input)
if err != nil {
return err
}
}
return nil
@@ -297,13 +276,8 @@ func (module *module) ListFactorAPIKey(ctx context.Context, serviceAccountID val
return serviceaccounttypes.NewFactorAPIKeyFromStorables(storables), nil
}
func (module *module) UpdateFactorAPIKey(ctx context.Context, _ valuer.UUID, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
err := module.store.UpdateFactorAPIKey(ctx, serviceAccountID, serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey))
if err != nil {
return err
}
return nil
func (module *module) UpdateFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
return module.store.UpdateFactorAPIKey(ctx, serviceAccountID, serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey))
}
func (module *module) RevokeFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) error {
@@ -333,3 +307,45 @@ func (module *module) RevokeFactorAPIKey(ctx context.Context, serviceAccountID v
return nil
}
func (module *module) disableServiceAccount(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
err := module.authz.Revoke(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, input.ID.String(), orgID, nil))
if err != nil {
return err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
// revoke all the API keys on disable
err := module.store.RevokeAllFactorAPIKeys(ctx, input.ID)
if err != nil {
return err
}
// update the status but do not delete the role mappings as we will reuse them on activation.
err = module.Update(ctx, orgID, input)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
return nil
}
func (module *module) activateServiceAccount(ctx context.Context, orgID valuer.UUID, input *serviceaccounttypes.ServiceAccount) error {
err := module.authz.Grant(ctx, orgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableServiceAccount, input.ID.String(), orgID, nil))
if err != nil {
return err
}
err = module.Update(ctx, orgID, input)
if err != nil {
return err
}
return nil
}

View File

@@ -48,25 +48,6 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storable, nil
}
func (store *store) GetActiveByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*serviceaccounttypes.StorableServiceAccount, error) {
storable := new(serviceaccounttypes.StorableServiceAccount)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
Where("name = ?", name).
Where("status = ?", serviceaccounttypes.StatusActive).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountNotFound, "service account with name: %s doesn't exist in org: %s", name, orgID.String())
}
return storable, nil
}
func (store *store) GetByID(ctx context.Context, id valuer.UUID) (*serviceaccounttypes.StorableServiceAccount, error) {
storable := new(serviceaccounttypes.StorableServiceAccount)
@@ -207,7 +188,7 @@ func (store *store) CreateFactorAPIKey(ctx context.Context, storable *serviceacc
Model(storable).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeAPIKeyAlreadyExists, "api key with name: %s already exists for service account: %s", storable.Name, storable.ServiceAccountID)
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeServiceAccountFactorAPIKeyAlreadyExists, "api key with name: %s already exists for service account: %s", storable.Name, storable.ServiceAccountID)
}
return nil
@@ -225,7 +206,7 @@ func (store *store) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer
Where("service_account_id = ?", serviceAccountID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeAPIKeytNotFound, "api key with id: %s doesn't exist for service account: %s", id, serviceAccountID)
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccounFactorAPIKeytNotFound, "api key with id: %s doesn't exist for service account: %s", id, serviceAccountID)
}
return storable, nil

View File

@@ -15,9 +15,6 @@ type Module interface {
// Gets a service account by id.
Get(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
// Gets or creates a service account by name
GetOrCreate(context.Context, *serviceaccounttypes.ServiceAccount) (*serviceaccounttypes.ServiceAccount, error)
// Gets a service account by id without fetching roles.
GetWithoutRoles(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccount, error)
@@ -43,7 +40,7 @@ type Module interface {
ListFactorAPIKey(context.Context, valuer.UUID) ([]*serviceaccounttypes.FactorAPIKey, error)
// Updates an existing API key for a service account
UpdateFactorAPIKey(context.Context, valuer.UUID, valuer.UUID, *serviceaccounttypes.FactorAPIKey) error
UpdateFactorAPIKey(context.Context, valuer.UUID, *serviceaccounttypes.FactorAPIKey) error
// Revokes an existing API key for a service account
RevokeFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) error

View File

@@ -1,643 +0,0 @@
// NOTE:
// - When Account keyword is used in struct names, it refers cloud integration account. CloudIntegration refers to DB schema.
// - When Account Config keyword is used in struct names, it refers to configuration for cloud integration accounts
// - When Service keyword is used in struct names, it refers to cloud integration service. CloudIntegrationService refers to DB schema.
// where `service` is services provided by each cloud provider like AWS S3, Azure BlobStorage etc.
// - When Service Config keyword is used in struct names, it refers to configuration for cloud integration services
package cloudintegrationtypes
import (
"database/sql/driver"
"encoding/json"
"strings"
"time"
"github.com/uptrace/bun"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
// CloudProviderType type alias
type CloudProviderType struct{ valuer.String }
var (
CloudProviderTypeAWS = valuer.NewString("aws")
CloudProviderTypeAzure = valuer.NewString("azure")
)
var ErrCodeCloudProviderInvalidInput = errors.MustNewCode("invalid_cloud_provider")
// NewCloudProvider returns a new CloudProviderType from a string. It validates the input and returns an error if the input is not valid.
func NewCloudProvider(provider string) (CloudProviderType, error) {
switch provider {
case CloudProviderTypeAWS.String():
return CloudProviderType{CloudProviderTypeAWS}, nil
case CloudProviderTypeAzure.String():
return CloudProviderType{CloudProviderTypeAzure}, nil
default:
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
}
}
var (
AWSIntegrationUserEmail = valuer.MustNewEmail("aws-integration@signoz.io")
AzureIntegrationUserEmail = valuer.MustNewEmail("azure-integration@signoz.io")
)
// CloudIntegrationUserEmails is the list of valid emails for Cloud One Click integrations.
// This is used for validation and restrictions in different contexts, across codebase.
var CloudIntegrationUserEmails = []valuer.Email{
AWSIntegrationUserEmail,
AzureIntegrationUserEmail,
}
func IsCloudIntegrationDashboardUuid(dashboardUuid string) bool {
parts := strings.SplitN(dashboardUuid, "--", 4)
if len(parts) != 4 {
return false
}
return parts[0] == "cloud-integration"
}
// GetCloudIntegrationDashboardID returns the cloud provider from dashboard id, if it's a cloud integration dashboard id.
// throws an error if invalid format or invalid cloud provider is provided in the dashboard id.
func GetCloudProviderFromDashboardID(dashboardUuid string) (CloudProviderType, error) {
parts := strings.SplitN(dashboardUuid, "--", 4)
if len(parts) != 4 {
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid dashboard uuid: %s", dashboardUuid)
}
providerStr := parts[1]
cloudProvider, err := NewCloudProvider(providerStr)
if err != nil {
return CloudProviderType{}, err
}
return cloudProvider, nil
}
// Generic utility functions for JSON serialization/deserialization
// this is helpful to return right errors from a common place and avoid repeating the same code in multiple places.
// UnmarshalJSON is a generic function to unmarshal JSON data into any type
func UnmarshalJSON[T any](src []byte, target *T) error {
err := json.Unmarshal(src, target)
if err != nil {
return errors.WrapInternalf(
err, errors.CodeInternal, "couldn't deserialize JSON",
)
}
return nil
}
// MarshalJSON is a generic function to marshal any type to JSON
func MarshalJSON[T any](source *T) ([]byte, error) {
if source == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "source is nil")
}
serialized, err := json.Marshal(source)
if err != nil {
return nil, errors.WrapInternalf(
err, errors.CodeInternal, "couldn't serialize to JSON",
)
}
return serialized, nil
}
// GettableConnectedAccountsList is the response for listing connected accounts for a cloud provider.
type GettableConnectedAccountsList struct {
Accounts []*Account `json:"accounts"`
}
// SigNozAgentConfig represents parameters required for agent deployment in cloud provider accounts
// these represent parameters passed during agent deployment, how they are passed might change for each cloud provider but the purpose is same.
type SigNozAgentConfig struct {
Region string `json:"region,omitempty"` // AWS-specific: The region in which SigNoz agent should be installed
IngestionUrl string `json:"ingestion_url"`
IngestionKey string `json:"ingestion_key"`
SigNozAPIUrl string `json:"signoz_api_url"`
SigNozAPIKey string `json:"signoz_api_key"`
Version string `json:"version,omitempty"`
}
// PostableConnectionArtifact represent request body for generating connection artifact API.
// Data is request body raw bytes since each cloud provider will have have different request body structure and generics hardly help in such cases.
// Artifact is a generic name for different types of connection methods like connection URL for AWS, connection command for Azure etc.
type PostableConnectionArtifact struct {
OrgID string
Data []byte // either PostableAWSConnectionUrl or PostableAzureConnectionCommand
}
// PostableAWSConnectionUrl is request body for AWS connection artifact API
type PostableAWSConnectionUrl struct {
AgentConfig *SigNozAgentConfig `json:"agent_config"`
AccountConfig *AWSAccountConfig `json:"account_config"`
}
// PostableAzureConnectionCommand is request body for Azure connection artifact API
type PostableAzureConnectionCommand struct {
AgentConfig *SigNozAgentConfig `json:"agent_config"`
AccountConfig *AzureAccountConfig `json:"account_config"`
}
// GettableAzureConnectionArtifact is Azure specific connection artifact which contains connection commands for agent deployment
type GettableAzureConnectionArtifact struct {
AzureShellConnectionCommand string `json:"az_shell_connection_command"`
AzureCliConnectionCommand string `json:"az_cli_connection_command"`
}
// GettableAWSConnectionUrl is AWS specific connection artifact which contains connection url for agent deployment
type GettableAWSConnectionUrl struct {
AccountId string `json:"account_id"`
ConnectionUrl string `json:"connection_url"`
}
// GettableAzureConnectionCommand is Azure specific connection artifact which contains connection commands for agent deployment
type GettableAzureConnectionCommand struct {
AccountId string `json:"account_id"`
AzureShellConnectionCommand string `json:"az_shell_connection_command"`
AzureCliConnectionCommand string `json:"az_cli_connection_command"`
}
// GettableAccountStatus is cloud integration account status response
type GettableAccountStatus struct {
Id string `json:"id"`
CloudAccountId *string `json:"cloud_account_id,omitempty"`
Status AccountStatus `json:"status"`
}
// PostableAgentCheckInPayload is request body for agent check-in API.
// This is used by agent to send heartbeat.
type PostableAgentCheckInPayload struct {
ID string `json:"account_id"`
AccountID string `json:"cloud_account_id"`
// Arbitrary cloud specific Agent data
Data map[string]any `json:"data,omitempty"`
OrgID string `json:"-"`
}
// AWSAgentIntegrationConfig is used by agent for deploying infra to send telemetry to SigNoz
type AWSAgentIntegrationConfig struct {
EnabledRegions []string `json:"enabled_regions"`
TelemetryCollectionStrategy *AWSCollectionStrategy `json:"telemetry,omitempty"`
}
// AzureAgentIntegrationConfig is used by agent for deploying infra to send telemetry to SigNoz
type AzureAgentIntegrationConfig struct {
DeploymentRegion string `json:"deployment_region"` // will not be changed once set
EnabledResourceGroups []string `json:"resource_groups"`
// TelemetryCollectionStrategy is map of service to telemetry config
TelemetryCollectionStrategy map[string]*AzureCollectionStrategy `json:"telemetry,omitempty"`
}
// GettableAgentCheckInRes is generic response from agent check-in API.
// AWSAgentIntegrationConfig and AzureAgentIntegrationConfig these configs are used by agent to deploy the infra and send telemetry to SigNoz
type GettableAgentCheckInRes[AgentConfigT any] struct {
AccountId string `json:"account_id"`
CloudAccountId string `json:"cloud_account_id"`
RemovedAt *time.Time `json:"removed_at"`
IntegrationConfig AgentConfigT `json:"integration_config"`
}
// UpdatableServiceConfig is generic
type UpdatableServiceConfig[ServiceConfigT any] struct {
CloudAccountId string `json:"cloud_account_id"`
Config ServiceConfigT `json:"config"`
}
// ServiceConfigTyped is a generic interface for cloud integration service's configuration
// this is generic interface to define helper functions for CloudIntegrationService.Config field.
type ServiceConfigTyped[definition Definition] interface {
Validate(def definition) error
IsMetricsEnabled() bool
IsLogsEnabled() bool
}
type AWSServiceConfig struct {
Logs *AWSServiceLogsConfig `json:"logs,omitempty"`
Metrics *AWSServiceMetricsConfig `json:"metrics,omitempty"`
}
type AWSServiceLogsConfig struct {
Enabled bool `json:"enabled"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"`
}
type AWSServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
}
// IsMetricsEnabled returns true if metrics collection is configured and enabled
func (a *AWSServiceConfig) IsMetricsEnabled() bool {
return a.Metrics != nil && a.Metrics.Enabled
}
// IsLogsEnabled returns true if logs collection is configured and enabled
func (a *AWSServiceConfig) IsLogsEnabled() bool {
return a.Logs != nil && a.Logs.Enabled
}
type AzureServiceConfig struct {
Logs []*AzureServiceLogsConfig `json:"logs,omitempty"`
Metrics []*AzureServiceMetricsConfig `json:"metrics,omitempty"`
}
// AzureServiceLogsConfig is Azure specific service config for logs
type AzureServiceLogsConfig struct {
Enabled bool `json:"enabled"`
Name string `json:"name"`
}
// AzureServiceMetricsConfig is Azure specific service config for metrics
type AzureServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
Name string `json:"name"`
}
// IsMetricsEnabled returns true if any metric is configured and enabled
func (a *AzureServiceConfig) IsMetricsEnabled() bool {
if a.Metrics == nil {
return false
}
for _, m := range a.Metrics {
if m.Enabled {
return true
}
}
return false
}
// IsLogsEnabled returns true if any log is configured and enabled
func (a *AzureServiceConfig) IsLogsEnabled() bool {
if a.Logs == nil {
return false
}
for _, l := range a.Logs {
if l.Enabled {
return true
}
}
return false
}
func (a *AWSServiceConfig) Validate(def *AWSDefinition) error {
if def.Id != S3Sync.String() && a.Logs != nil && a.Logs.S3Buckets != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "s3 buckets can only be added to service-type[%s]", S3Sync)
} else if def.Id == S3Sync.String() && a.Logs != nil && a.Logs.S3Buckets != nil {
for region := range a.Logs.S3Buckets {
if _, found := ValidAWSRegions[region]; !found {
return errors.NewInvalidInputf(CodeInvalidCloudRegion, "invalid cloud region: %s", region)
}
}
}
return nil
}
func (a *AzureServiceConfig) Validate(def *AzureDefinition) error {
logsMap := make(map[string]bool)
metricsMap := make(map[string]bool)
if def.Strategy != nil && def.Strategy.Logs != nil {
for _, log := range def.Strategy.Logs {
logsMap[log.Name] = true
}
}
if def.Strategy != nil && def.Strategy.Metrics != nil {
for _, metric := range def.Strategy.Metrics {
metricsMap[metric.Name] = true
}
}
for _, log := range a.Logs {
if _, found := logsMap[log.Name]; !found {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid log name: %s", log.Name)
}
}
for _, metric := range a.Metrics {
if _, found := metricsMap[metric.Name]; !found {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid metric name: %s", metric.Name)
}
}
return nil
}
// UpdatableServiceConfigRes is response for UpdateServiceConfig API
// TODO: find a better way to name this
type UpdatableServiceConfigRes struct {
ServiceId string `json:"id"`
Config any `json:"config"`
}
// UpdatableAccountConfigTyped is a generic struct for updating cloud integration account config used in UpdateAccountConfig API
type UpdatableAccountConfigTyped[AccountConfigT any] struct {
Config *AccountConfigT `json:"config"`
}
type (
UpdatableAWSAccountConfig = UpdatableAccountConfigTyped[AWSAccountConfig]
UpdatableAzureAccountConfig = UpdatableAccountConfigTyped[AzureAccountConfig]
)
// AWSAccountConfig is the configuration for AWS cloud integration account
type AWSAccountConfig struct {
EnabledRegions []string `json:"regions"`
}
// AzureAccountConfig is the configuration for Azure cloud integration account
type AzureAccountConfig struct {
DeploymentRegion string `json:"deployment_region,omitempty"`
EnabledResourceGroups []string `json:"resource_groups,omitempty"`
}
// GettableServices is a generic struct for listing services of a cloud integration account used in ListServices API
type GettableServices[ServiceSummaryT any] struct {
Services []ServiceSummaryT `json:"services"`
}
type (
GettableAWSServices = GettableServices[AWSServiceSummary]
GettableAzureServices = GettableServices[AzureServiceSummary]
)
// GetServiceDetailsReq is a req struct for getting service definition details
type GetServiceDetailsReq struct {
OrgID valuer.UUID
ServiceId string
CloudAccountID *string
}
// ServiceSummary is a generic struct for service summary used in ListServices API
type ServiceSummary[ServiceConfigT any] struct {
DefinitionMetadata
Config *ServiceConfigT `json:"config"`
}
type (
AWSServiceSummary = ServiceSummary[AWSServiceConfig]
AzureServiceSummary = ServiceSummary[AzureServiceConfig]
)
// GettableServiceDetails is a generic struct for service details used in GetServiceDetails API
type GettableServiceDetails[DefinitionT any, ServiceConfigT any] struct {
Definition DefinitionT `json:",inline"`
Config ServiceConfigT `json:"config"`
ConnectionStatus *ServiceConnectionStatus `json:"status,omitempty"`
}
type (
GettableAWSServiceDetails = GettableServiceDetails[AWSDefinition, *AWSServiceConfig]
GettableAzureServiceDetails = GettableServiceDetails[AzureDefinition, *AzureServiceConfig]
)
// ServiceConnectionStatus represents integration connection status for a particular service
// this struct helps to check ingested data and determines connection status by whether data was ingested or not.
// this is composite struct for both metrics and logs
type ServiceConnectionStatus struct {
Logs []*SignalConnectionStatus `json:"logs"`
Metrics []*SignalConnectionStatus `json:"metrics"`
}
// SignalConnectionStatus represents connection status for a particular signal type (logs or metrics) for a service
// this struct is used in API responses for clients to show relevant information about the connection status.
type SignalConnectionStatus struct {
CategoryID string `json:"category"`
CategoryDisplayName string `json:"category_display_name"`
LastReceivedTsMillis int64 `json:"last_received_ts_ms"` // epoch milliseconds
LastReceivedFrom string `json:"last_received_from"` // resource identifier
}
// GettableCloudIntegrationConnectionParams is response for connection params API
type GettableCloudIntegrationConnectionParams struct {
IngestionUrl string `json:"ingestion_url,omitempty"`
IngestionKey string `json:"ingestion_key,omitempty"`
SigNozAPIUrl string `json:"signoz_api_url,omitempty"`
SigNozAPIKey string `json:"signoz_api_key,omitempty"`
}
// GettableIngestionKey is a struct for ingestion key returned from gateway
type GettableIngestionKey struct {
Name string `json:"name"`
Value string `json:"value"`
// other attributes from gateway response not included here since they are not being used.
}
// GettableIngestionKeysSearch is a struct for response of ingestion keys search API on gateway
type GettableIngestionKeysSearch struct {
Status string `json:"status"`
Data []GettableIngestionKey `json:"data"`
Error string `json:"error"`
}
// GettableCreateIngestionKey is a struct for response of create ingestion key API on gateway
type GettableCreateIngestionKey struct {
Status string `json:"status"`
Data GettableIngestionKey `json:"data"`
Error string `json:"error"`
}
// GettableDeployment is response struct for deployment details fetched from Zeus
type GettableDeployment struct {
Name string `json:"name"`
ClusterInfo struct {
Region struct {
DNS string `json:"dns"`
} `json:"region"`
} `json:"cluster"`
}
// --------------------------------------------------------------------------
// Cloud integration uses the cloud_integration table
// and cloud_integrations_service table
// --------------------------------------------------------------------------
type CloudIntegration struct {
bun.BaseModel `bun:"table:cloud_integration"`
types.Identifiable
types.TimeAuditable
Provider string `json:"provider" bun:"provider,type:text,unique:provider_id"`
Config *AccountConfig `json:"config" bun:"config,type:text"`
AccountID *string `json:"account_id" bun:"account_id,type:text"`
LastAgentReport *AgentReport `json:"last_agent_report" bun:"last_agent_report,type:text"`
RemovedAt *time.Time `json:"removed_at" bun:"removed_at,type:timestamp,nullzero"`
OrgID string `bun:"org_id,type:text,unique:provider_id"`
}
func (a *CloudIntegration) Status() AccountStatus {
status := AccountStatus{}
if a.LastAgentReport != nil {
lastHeartbeat := a.LastAgentReport.TimestampMillis
status.Integration.LastHeartbeatTsMillis = &lastHeartbeat
}
return status
}
func (a *CloudIntegration) Account() Account {
ca := Account{Id: a.ID.StringValue(), Status: a.Status()}
if a.AccountID != nil {
ca.CloudAccountId = *a.AccountID
}
if a.Config != nil {
ca.Config = *a.Config
} else {
ca.Config = DefaultAccountConfig()
}
return ca
}
type Account struct {
Id string `json:"id"`
CloudAccountId string `json:"cloud_account_id"`
Config AccountConfig `json:"config"`
Status AccountStatus `json:"status"`
}
type AccountStatus struct {
Integration AccountIntegrationStatus `json:"integration"`
}
type AccountIntegrationStatus struct {
LastHeartbeatTsMillis *int64 `json:"last_heartbeat_ts_ms"`
}
func DefaultAccountConfig() AccountConfig {
return AccountConfig{
EnabledRegions: []string{},
}
}
type AccountConfig struct {
EnabledRegions []string `json:"regions"`
}
// For serializing from db
func (c *AccountConfig) Scan(src any) error {
var data []byte
switch v := src.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return errors.NewInternalf(errors.CodeInternal, "tried to scan from %T instead of string or bytes", src)
}
return json.Unmarshal(data, c)
}
// For serializing to db
func (c *AccountConfig) Value() (driver.Value, error) {
if c == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "cloud account config is nil")
}
serialized, err := json.Marshal(c)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize cloud account config to JSON")
}
// Return as string instead of []byte to ensure PostgreSQL stores as text, not bytea
return string(serialized), nil
}
type AgentReport struct {
TimestampMillis int64 `json:"timestamp_millis"`
Data map[string]any `json:"data"`
}
// For serializing from db
func (r *AgentReport) Scan(src any) error {
var data []byte
switch v := src.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return errors.NewInternalf(errors.CodeInternal, "tried to scan from %T instead of string or bytes", src)
}
return json.Unmarshal(data, r)
}
// For serializing to db
func (r *AgentReport) Value() (driver.Value, error) {
if r == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "agent report is nil")
}
serialized, err := json.Marshal(r)
if err != nil {
return nil, errors.WrapInternalf(
err, errors.CodeInternal, "couldn't serialize agent report to JSON",
)
}
// Return as string instead of []byte to ensure PostgreSQL stores as text, not bytea
return string(serialized), nil
}
type CloudIntegrationService struct {
bun.BaseModel `bun:"table:cloud_integration_service,alias:cis"`
types.Identifiable
types.TimeAuditable
Type string `bun:"type,type:text,notnull,unique:cloud_integration_id_type"`
Config CloudServiceConfig `bun:"config,type:text"`
CloudIntegrationID string `bun:"cloud_integration_id,type:text,notnull,unique:cloud_integration_id_type,references:cloud_integrations(id),on_delete:cascade"`
}
type CloudServiceLogsConfig struct {
Enabled bool `json:"enabled"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"`
}
type CloudServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
}
type CloudServiceConfig struct {
Logs *CloudServiceLogsConfig `json:"logs,omitempty"`
Metrics *CloudServiceMetricsConfig `json:"metrics,omitempty"`
}
// For serializing from db
func (c *CloudServiceConfig) Scan(src any) error {
var data []byte
switch src := src.(type) {
case []byte:
data = src
case string:
data = []byte(src)
default:
return errors.NewInternalf(errors.CodeInternal, "tried to scan from %T instead of string or bytes", src)
}
return json.Unmarshal(data, c)
}
// For serializing to db
func (c *CloudServiceConfig) Value() (driver.Value, error) {
if c == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "cloud service config is nil")
}
serialized, err := json.Marshal(c)
if err != nil {
return nil, errors.WrapInternalf(
err, errors.CodeInternal, "couldn't serialize cloud service config to JSON",
)
}
// Return as string instead of []byte to ensure PostgreSQL stores as text, not bytea
return string(serialized), nil
}

View File

@@ -1,103 +0,0 @@
package cloudintegrationtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
)
var (
CodeInvalidCloudRegion = errors.MustNewCode("invalid_cloud_region")
CodeMismatchCloudProvider = errors.MustNewCode("cloud_provider_mismatch")
)
// List of all valid cloud regions on Amazon Web Services
var ValidAWSRegions = map[string]bool{
"af-south-1": true, // Africa (Cape Town).
"ap-east-1": true, // Asia Pacific (Hong Kong).
"ap-northeast-1": true, // Asia Pacific (Tokyo).
"ap-northeast-2": true, // Asia Pacific (Seoul).
"ap-northeast-3": true, // Asia Pacific (Osaka).
"ap-south-1": true, // Asia Pacific (Mumbai).
"ap-south-2": true, // Asia Pacific (Hyderabad).
"ap-southeast-1": true, // Asia Pacific (Singapore).
"ap-southeast-2": true, // Asia Pacific (Sydney).
"ap-southeast-3": true, // Asia Pacific (Jakarta).
"ap-southeast-4": true, // Asia Pacific (Melbourne).
"ca-central-1": true, // Canada (Central).
"ca-west-1": true, // Canada West (Calgary).
"eu-central-1": true, // Europe (Frankfurt).
"eu-central-2": true, // Europe (Zurich).
"eu-north-1": true, // Europe (Stockholm).
"eu-south-1": true, // Europe (Milan).
"eu-south-2": true, // Europe (Spain).
"eu-west-1": true, // Europe (Ireland).
"eu-west-2": true, // Europe (London).
"eu-west-3": true, // Europe (Paris).
"il-central-1": true, // Israel (Tel Aviv).
"me-central-1": true, // Middle East (UAE).
"me-south-1": true, // Middle East (Bahrain).
"sa-east-1": true, // South America (Sao Paulo).
"us-east-1": true, // US East (N. Virginia).
"us-east-2": true, // US East (Ohio).
"us-west-1": true, // US West (N. California).
"us-west-2": true, // US West (Oregon).
}
// List of all valid cloud regions for Microsoft Azure
var ValidAzureRegions = map[string]bool{
"australiacentral": true, // Australia Central
"australiacentral2": true, // Australia Central 2
"australiaeast": true, // Australia East
"australiasoutheast": true, // Australia Southeast
"austriaeast": true, // Austria East
"belgiumcentral": true, // Belgium Central
"brazilsouth": true, // Brazil South
"brazilsoutheast": true, // Brazil Southeast
"canadacentral": true, // Canada Central
"canadaeast": true, // Canada East
"centralindia": true, // Central India
"centralus": true, // Central US
"chilecentral": true, // Chile Central
"denmarkeast": true, // Denmark East
"eastasia": true, // East Asia
"eastus": true, // East US
"eastus2": true, // East US 2
"francecentral": true, // France Central
"francesouth": true, // France South
"germanynorth": true, // Germany North
"germanywestcentral": true, // Germany West Central
"indonesiacentral": true, // Indonesia Central
"israelcentral": true, // Israel Central
"italynorth": true, // Italy North
"japaneast": true, // Japan East
"japanwest": true, // Japan West
"koreacentral": true, // Korea Central
"koreasouth": true, // Korea South
"malaysiawest": true, // Malaysia West
"mexicocentral": true, // Mexico Central
"newzealandnorth": true, // New Zealand North
"northcentralus": true, // North Central US
"northeurope": true, // North Europe
"norwayeast": true, // Norway East
"norwaywest": true, // Norway West
"polandcentral": true, // Poland Central
"qatarcentral": true, // Qatar Central
"southafricanorth": true, // South Africa North
"southafricawest": true, // South Africa West
"southcentralus": true, // South Central US
"southindia": true, // South India
"southeastasia": true, // Southeast Asia
"spaincentral": true, // Spain Central
"swedencentral": true, // Sweden Central
"switzerlandnorth": true, // Switzerland North
"switzerlandwest": true, // Switzerland West
"uaecentral": true, // UAE Central
"uaenorth": true, // UAE North
"uksouth": true, // UK South
"ukwest": true, // UK West
"westcentralus": true, // West Central US
"westeurope": true, // West Europe
"westindia": true, // West India
"westus": true, // West US
"westus2": true, // West US 2
"westus3": true, // West US 3
}

View File

@@ -1,267 +0,0 @@
package cloudintegrationtypes
import (
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
var S3Sync = valuer.NewString("s3sync")
// Generic interface for cloud service definition.
// This is implemented by AWSDefinition and AzureDefinition, which represent service definitions for AWS and Azure respectively.
// Generics work well so far because service definitions share a similar logic.
// We dont want to over-do generics as well, if the service definitions functionally diverge in the future consider breaking generics.
type Definition interface {
GetId() string
Validate() error
PopulateDashboardURLs(cloudProvider CloudProviderType, svcId string)
GetIngestionStatusCheck() *IngestionStatusCheck
GetAssets() Assets
}
// AWSDefinition represents AWS Service definition, which includes collection strategy, dashboards and meta info for integration
type AWSDefinition = ServiceDefinition[AWSCollectionStrategy]
// AzureDefinition represents Azure Service definition, which includes collection strategy, dashboards and meta info for integration
type AzureDefinition = ServiceDefinition[AzureCollectionStrategy]
// Making AWSDefinition and AzureDefinition satisfy Definition interface, so that they can be used in a generic way
var (
_ Definition = &AWSDefinition{}
_ Definition = &AzureDefinition{}
)
// ServiceDefinition represents generic struct for cloud service, regardless of the cloud provider.
// this struct must satify Definition interface.
// StrategyT is of either AWSCollectionStrategy or AzureCollectionStrategy, depending on the cloud provider.
type ServiceDefinition[StrategyT any] struct {
DefinitionMetadata
Overview string `json:"overview"` // markdown
Assets Assets `json:"assets"`
SupportedSignals SupportedSignals `json:"supported_signals"`
DataCollected DataCollected `json:"data_collected"`
IngestionStatusCheck *IngestionStatusCheck `json:"ingestion_status_check,omitempty"`
Strategy *StrategyT `json:"telemetry_collection_strategy"`
}
// Following methods are quite self explanatory, they are just to satisfy the Definition interface and provide some utility functions for service definitions.
func (def *ServiceDefinition[StrategyT]) GetId() string {
return def.Id
}
func (def *ServiceDefinition[StrategyT]) Validate() error {
seenDashboardIds := map[string]interface{}{}
if def.Strategy == nil {
return errors.NewInternalf(errors.CodeInternal, "telemetry_collection_strategy is required")
}
for _, dd := range def.Assets.Dashboards {
if _, seen := seenDashboardIds[dd.Id]; seen {
return errors.NewInternalf(errors.CodeInternal, "multiple dashboards found with id %s", dd.Id)
}
seenDashboardIds[dd.Id] = nil
}
return nil
}
func (def *ServiceDefinition[StrategyT]) PopulateDashboardURLs(cloudProvider CloudProviderType, svcId string) {
for i := range def.Assets.Dashboards {
dashboardId := def.Assets.Dashboards[i].Id
url := "/dashboard/" + GetCloudIntegrationDashboardID(cloudProvider, svcId, dashboardId)
def.Assets.Dashboards[i].Url = url
}
}
func (def *ServiceDefinition[StrategyT]) GetIngestionStatusCheck() *IngestionStatusCheck {
return def.IngestionStatusCheck
}
func (def *ServiceDefinition[StrategyT]) GetAssets() Assets {
return def.Assets
}
// DefinitionMetadata represents service definition metadata. This is useful for showing service overview
type DefinitionMetadata struct {
Id string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon"`
}
// IngestionStatusCheckCategory represents a category of ingestion status check. Applies for both metrics and logs.
// A category can be "Overview" of metrics or "Enhanced" Metrics for AWS, and "Transaction" or "Capacity" metrics for Azure.
// Each category can have multiple checks (AND logic), if all checks pass,
// then we can be sure that data is being ingested for that category of the signal
type IngestionStatusCheckCategory struct {
Category string `json:"category"`
DisplayName string `json:"display_name"`
Checks []*IngestionStatusCheckAttribute `json:"checks"`
}
// IngestionStatusCheckAttribute represents a check or condition for ingestion status.
// Key can be metric name or part of log message
type IngestionStatusCheckAttribute struct {
Key string `json:"key"` // OPTIONAL search key (metric name or log message)
Attributes []*IngestionStatusCheckAttributeFilter `json:"attributes"`
}
// IngestionStatusCheck represents combined checks for metrics and logs for a service
type IngestionStatusCheck struct {
Metrics []*IngestionStatusCheckCategory `json:"metrics"`
Logs []*IngestionStatusCheckCategory `json:"logs"`
}
// IngestionStatusCheckAttributeFilter represents filter for a check, which can be used to filter specific log messages or metrics with specific attributes.
// For example, we can use it to filter logs with specific log level or metrics with specific dimensions.
type IngestionStatusCheckAttributeFilter struct {
Name string `json:"name"`
Operator string `json:"operator"`
Value string `json:"value"` // OPTIONAL
}
// Assets represents the collection of dashboards
type Assets struct {
Dashboards []Dashboard `json:"dashboards"`
}
// SupportedSignals for cloud provider's service
type SupportedSignals struct {
Logs bool `json:"logs"`
Metrics bool `json:"metrics"`
}
// DataCollected is curated static list of metrics and logs, this is shown as part of service overview
type DataCollected struct {
Logs []CollectedLogAttribute `json:"logs"`
Metrics []CollectedMetric `json:"metrics"`
}
// CollectedLogAttribute represents a log attribute that is present in all log entries for a service,
// this is shown as part of service overview
type CollectedLogAttribute struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
}
// CollectedMetric represents a metric that is collected for a service, this is shown as part of service overview
type CollectedMetric struct {
Name string `json:"name"`
Type string `json:"type"`
Unit string `json:"unit"`
Description string `json:"description"`
}
// AWSCollectionStrategy represents signal collection strategy for AWS services.
// this is AWS specific.
type AWSCollectionStrategy struct {
Metrics *AWSMetricsStrategy `json:"aws_metrics,omitempty"`
Logs *AWSLogsStrategy `json:"aws_logs,omitempty"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"` // Only available in S3 Sync Service Type in AWS
}
// AzureCollectionStrategy represents signal collection strategy for Azure services.
// this is Azure specific.
type AzureCollectionStrategy struct {
Metrics []*AzureMetricsStrategy `json:"azure_metrics,omitempty"`
Logs []*AzureLogsStrategy `json:"azure_logs,omitempty"`
}
// AWSMetricsStrategy represents metrics collection strategy for AWS services.
// this is AWS specific.
type AWSMetricsStrategy struct {
// to be used as https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudwatch-metricstream.html#cfn-cloudwatch-metricstream-includefilters
StreamFilters []struct {
// json tags here are in the shape expected by AWS API as detailed at
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudwatch-metricstream-metricstreamfilter.html
Namespace string `json:"Namespace"`
MetricNames []string `json:"MetricNames,omitempty"`
} `json:"cloudwatch_metric_stream_filters"`
}
// AWSLogsStrategy represents logs collection strategy for AWS services.
// this is AWS specific.
type AWSLogsStrategy struct {
Subscriptions []struct {
// subscribe to all logs groups with specified prefix.
// eg: `/aws/rds/`
LogGroupNamePrefix string `json:"log_group_name_prefix"`
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html
// "" implies no filtering is required.
FilterPattern string `json:"filter_pattern"`
} `json:"cloudwatch_logs_subscriptions"`
}
// AzureMetricsStrategy represents metrics collection strategy for Azure services.
// this is Azure specific.
type AzureMetricsStrategy struct {
CategoryType string `json:"category_type"`
Name string `json:"name"`
}
// AzureLogsStrategy represents logs collection strategy for Azure services.
// this is Azure specific. Even though this is similar to AzureMetricsStrategy, keeping it separate for future flexibility and clarity.
type AzureLogsStrategy struct {
CategoryType string `json:"category_type"`
Name string `json:"name"`
}
// Dashboard represents a dashboard definition for cloud integration.
type Dashboard struct {
Id string `json:"id"`
Url string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
Definition *dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
// UTILS
// GetCloudIntegrationDashboardID returns the dashboard id for a cloud integration, given the cloud provider, service id, and dashboard id.
// This is used to generate unique dashboard ids for cloud integration, and also to parse the dashboard id to get the cloud provider and service id when needed.
func GetCloudIntegrationDashboardID(cloudProvider CloudProviderType, svcId, dashboardId string) string {
return fmt.Sprintf("cloud-integration--%s--%s--%s", cloudProvider, svcId, dashboardId)
}
// GetDashboardsFromAssets returns the list of dashboards for the cloud provider service from definition
func GetDashboardsFromAssets(
svcId string,
orgID valuer.UUID,
cloudProvider CloudProviderType,
createdAt time.Time,
assets Assets,
) []*dashboardtypes.Dashboard {
dashboards := make([]*dashboardtypes.Dashboard, 0)
for _, d := range assets.Dashboards {
if d.Definition == nil {
continue
}
author := fmt.Sprintf("%s-integration", cloudProvider)
dashboards = append(dashboards, &dashboardtypes.Dashboard{
ID: GetCloudIntegrationDashboardID(cloudProvider, svcId, d.Id),
Locked: true,
OrgID: orgID,
Data: *d.Definition,
TimeAuditable: types.TimeAuditable{
CreatedAt: createdAt,
UpdatedAt: createdAt,
},
UserAuditable: types.UserAuditable{
CreatedBy: author,
UpdatedBy: author,
},
})
}
return dashboards
}

View File

@@ -1,42 +0,0 @@
package cloudintegrationtypes
import (
"context"
"time"
)
type CloudIntegrationAccountStore interface {
ListConnected(ctx context.Context, orgId string, provider string) ([]CloudIntegration, error)
Get(ctx context.Context, orgId string, provider string, id string) (*CloudIntegration, error)
GetConnectedCloudAccount(ctx context.Context, orgId, provider string, accountID string) (*CloudIntegration, error)
// Insert an account or update it by (cloudProvider, id)
// for specified non-empty fields
Upsert(
ctx context.Context,
orgId string,
provider string,
id *string,
config []byte,
accountId *string,
agentReport *AgentReport,
removedAt *time.Time,
) (*CloudIntegration, error)
}
type CloudIntegrationServiceStore interface {
Get(ctx context.Context, orgID, cloudAccountId, serviceType string) ([]byte, error)
Upsert(
ctx context.Context,
orgID,
cloudProvider,
cloudAccountId,
serviceId string,
config []byte,
) ([]byte, error)
GetAllForAccount(ctx context.Context, orgID, cloudAccountId string) (map[string][]byte, error)
}

View File

@@ -11,22 +11,20 @@ import (
)
var (
ErrCodeAPIkeyInvalidInput = errors.MustNewCode("service_account_factor_api_key_invalid_input")
ErrCodeAPIKeyAlreadyExists = errors.MustNewCode("service_account_factor_api_key_already_exists")
ErrCodeAPIKeytNotFound = errors.MustNewCode("service_account_factor_api_key_not_found")
ErrCodeAPIKeyExpired = errors.MustNewCode("api_key_expired")
ErrCodeAPIkeyOlderLastObservedAt = errors.MustNewCode("api_key_older_last_observed_at")
ErrCodeServiceAccountFactorAPIkeyInvalidInput = errors.MustNewCode("service_account_factor_api_key_invalid_input")
ErrCodeServiceAccountFactorAPIKeyAlreadyExists = errors.MustNewCode("service_account_factor_api_key_already_exists")
ErrCodeServiceAccounFactorAPIKeytNotFound = errors.MustNewCode("service_account_factor_api_key_not_found")
)
type StorableFactorAPIKey struct {
bun.BaseModel `bun:"table:factor_api_key,alias:factor_api_key"`
bun.BaseModel `bun:"table:factor_api_key"`
types.Identifiable
types.TimeAuditable
Name string `bun:"name"`
Key string `bun:"key"`
ExpiresAt uint64 `bun:"expires_at"`
LastObservedAt time.Time `bun:"last_observed_at"`
LastUsed time.Time `bun:"last_used"`
ServiceAccountID string `bun:"service_account_id"`
}
@@ -35,9 +33,9 @@ type FactorAPIKey struct {
types.TimeAuditable
Name string `json:"name" requrired:"true"`
Key string `json:"key" required:"true"`
ExpiresAt uint64 `json:"expiresAt" required:"true"`
LastObservedAt time.Time `json:"lastObservedAt" required:"true"`
ServiceAccountID valuer.UUID `json:"serviceAccountId" required:"true"`
ExpiresAt uint64 `json:"expires_at" required:"true"`
LastUsed time.Time `json:"last_used" required:"true"`
ServiceAccountID valuer.UUID `json:"service_account_id" required:"true"`
}
type GettableFactorAPIKeyWithKey struct {
@@ -49,19 +47,19 @@ type GettableFactorAPIKey struct {
types.Identifiable
types.TimeAuditable
Name string `json:"name" requrired:"true"`
ExpiresAt uint64 `json:"expiresAt" required:"true"`
LastObservedAt time.Time `json:"lastObservedAt" required:"true"`
ServiceAccountID valuer.UUID `json:"serviceAccountId" required:"true"`
ExpiresAt uint64 `json:"expires_at" required:"true"`
LastUsed time.Time `json:"last_used" required:"true"`
ServiceAccountID valuer.UUID `json:"service_account_id" required:"true"`
}
type PostableFactorAPIKey struct {
Name string `json:"name" required:"true"`
ExpiresAt uint64 `json:"expiresAt" required:"true"`
ExpiresAt uint64 `json:"expires_at" required:"true"`
}
type UpdatableFactorAPIKey struct {
Name string `json:"name" required:"true"`
ExpiresAt uint64 `json:"expiresAt" required:"true"`
ExpiresAt uint64 `json:"expires_at" required:"true"`
}
func NewFactorAPIKeyFromStorable(storable *StorableFactorAPIKey) *FactorAPIKey {
@@ -71,7 +69,7 @@ func NewFactorAPIKeyFromStorable(storable *StorableFactorAPIKey) *FactorAPIKey {
Name: storable.Name,
Key: storable.Key,
ExpiresAt: storable.ExpiresAt,
LastObservedAt: storable.LastObservedAt,
LastUsed: storable.LastUsed,
ServiceAccountID: valuer.MustNewUUID(storable.ServiceAccountID),
}
}
@@ -93,7 +91,7 @@ func NewStorableFactorAPIKey(factorAPIKey *FactorAPIKey) *StorableFactorAPIKey {
Name: factorAPIKey.Name,
Key: factorAPIKey.Key,
ExpiresAt: factorAPIKey.ExpiresAt,
LastObservedAt: factorAPIKey.LastObservedAt,
LastUsed: factorAPIKey.LastUsed,
ServiceAccountID: factorAPIKey.ServiceAccountID.String(),
}
}
@@ -107,7 +105,7 @@ func NewGettableFactorAPIKeys(keys []*FactorAPIKey) []*GettableFactorAPIKey {
TimeAuditable: key.TimeAuditable,
Name: key.Name,
ExpiresAt: key.ExpiresAt,
LastObservedAt: key.LastObservedAt,
LastUsed: key.LastUsed,
ServiceAccountID: key.ServiceAccountID,
}
}
@@ -130,29 +128,6 @@ func (apiKey *FactorAPIKey) Update(name string, expiresAt uint64) {
apiKey.UpdatedAt = time.Now()
}
func (apiKey *FactorAPIKey) IsExpired() error {
if apiKey.ExpiresAt == 0 {
return nil
}
if time.Now().After(time.Unix(int64(apiKey.ExpiresAt), 0)) {
return errors.New(errors.TypeUnauthenticated, ErrCodeAPIKeyExpired, "api key has been expired")
}
return nil
}
func (apiKey *FactorAPIKey) UpdateLastObservedAt(lastObservedAt time.Time) error {
if lastObservedAt.Before(apiKey.LastObservedAt) {
return errors.New(errors.TypeInvalidInput, ErrCodeAPIkeyOlderLastObservedAt, "last observed at is before the current last observed at")
}
apiKey.LastObservedAt = lastObservedAt
apiKey.UpdatedAt = time.Now()
return nil
}
func (key *PostableFactorAPIKey) UnmarshalJSON(data []byte) error {
type Alias PostableFactorAPIKey
@@ -162,7 +137,7 @@ func (key *PostableFactorAPIKey) UnmarshalJSON(data []byte) error {
}
if temp.Name == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeAPIkeyInvalidInput, "name cannot be empty")
return errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountFactorAPIkeyInvalidInput, "name cannot be empty")
}
*key = PostableFactorAPIKey(temp)
@@ -178,7 +153,7 @@ func (key *UpdatableFactorAPIKey) UnmarshalJSON(data []byte) error {
}
if temp.Name == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeAPIkeyInvalidInput, "name cannot be empty")
return errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountFactorAPIkeyInvalidInput, "name cannot be empty")
}
*key = UpdatableFactorAPIKey(temp)

View File

@@ -4,7 +4,7 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"regexp"
"slices"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -15,11 +15,10 @@ import (
)
var (
ErrCodeServiceAccountInvalidInput = errors.MustNewCode("service_account_invalid_input")
ErrCodeServiceAccountAlreadyExists = errors.MustNewCode("service_account_already_exists")
ErrCodeServiceAccountNotFound = errors.MustNewCode("service_account_not_found")
ErrCodeServiceAccountRoleAlreadyExists = errors.MustNewCode("service_account_role_already_exists")
ErrCodeServiceAccountOperationUnsupported = errors.MustNewCode("service_account_operation_unsupported")
ErrCodeServiceAccountInvalidInput = errors.MustNewCode("service_account_invalid_input")
ErrCodeServiceAccountAlreadyExists = errors.MustNewCode("service_account_already_exists")
ErrCodeServiceAccountNotFound = errors.MustNewCode("service_account_not_found")
ErrCodeServiceAccountRoleAlreadyExists = errors.MustNewCode("service_account_role_already_exists")
)
var (
@@ -28,31 +27,25 @@ var (
ValidStatus = []valuer.String{StatusActive, StatusDisabled}
)
var (
serviceAccountNameRegex = regexp.MustCompile("^[a-z-]{1,50}$")
)
type StorableServiceAccount struct {
bun.BaseModel `bun:"table:service_account,alias:service_account"`
types.Identifiable
types.TimeAuditable
Name string `bun:"name"`
Email string `bun:"email"`
Status valuer.String `bun:"status"`
OrgID string `bun:"org_id"`
DeletedAt time.Time `bun:"deleted_at"`
Name string `bun:"name"`
Email string `bun:"email"`
Status valuer.String `bun:"status"`
OrgID string `bun:"org_id"`
}
type ServiceAccount struct {
types.Identifiable
types.TimeAuditable
Name string `json:"name" required:"true"`
Email valuer.Email `json:"email" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
Status valuer.String `json:"status" required:"true"`
OrgID valuer.UUID `json:"orgId" required:"true"`
DeletedAt time.Time `json:"deletedAt" required:"true"`
Name string `json:"name" required:"true"`
Email valuer.Email `json:"email" required:"true"`
Roles []string `json:"roles" required:"true" nullable:"false"`
Status valuer.String `json:"status" required:"true"`
OrgID valuer.UUID `json:"orgID" required:"true"`
}
type PostableServiceAccount struct {
@@ -80,12 +73,11 @@ func NewServiceAccount(name string, email valuer.Email, roles []string, status v
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: name,
Email: email,
Roles: roles,
Status: status,
OrgID: orgID,
DeletedAt: time.Time{},
Name: name,
Email: email,
Roles: roles,
Status: status,
OrgID: orgID,
}
}
@@ -98,7 +90,6 @@ func NewServiceAccountFromStorables(storableServiceAccount *StorableServiceAccou
Roles: roles,
Status: storableServiceAccount.Status,
OrgID: valuer.MustNewUUID(storableServiceAccount.OrgID),
DeletedAt: storableServiceAccount.DeletedAt,
}
}
@@ -135,46 +126,22 @@ func NewStorableServiceAccount(serviceAccount *ServiceAccount) *StorableServiceA
Email: serviceAccount.Email.String(),
Status: serviceAccount.Status,
OrgID: serviceAccount.OrgID.String(),
DeletedAt: serviceAccount.DeletedAt,
}
}
func (sa *ServiceAccount) Update(name string, email valuer.Email, roles []string) error {
if err := sa.ErrIfDisabled(); err != nil {
return err
}
func (sa *ServiceAccount) Update(name string, email valuer.Email, roles []string) {
sa.Name = name
sa.Email = email
sa.Roles = roles
sa.UpdatedAt = time.Now()
return nil
}
func (sa *ServiceAccount) UpdateStatus(status valuer.String) error {
if err := sa.ErrIfDisabled(); err != nil {
return err
}
func (sa *ServiceAccount) UpdateStatus(status valuer.String) {
sa.Status = status
sa.UpdatedAt = time.Now()
sa.DeletedAt = time.Now()
return nil
}
func (sa *ServiceAccount) ErrIfDisabled() error {
if sa.Status == StatusDisabled {
return errors.New(errors.TypeUnsupported, ErrCodeServiceAccountOperationUnsupported, "this operation is not supported for disabled service account")
}
return nil
}
func (sa *ServiceAccount) NewFactorAPIKey(name string, expiresAt uint64) (*FactorAPIKey, error) {
if err := sa.ErrIfDisabled(); err != nil {
return nil, err
}
key := make([]byte, 32)
_, err := rand.Read(key)
if err != nil {
@@ -194,7 +161,7 @@ func (sa *ServiceAccount) NewFactorAPIKey(name string, expiresAt uint64) (*Facto
Name: name,
Key: encodedKey,
ExpiresAt: expiresAt,
LastObservedAt: time.Now(),
LastUsed: time.Now(),
ServiceAccountID: sa.ID,
}, nil
}
@@ -237,8 +204,8 @@ func (sa *PostableServiceAccount) UnmarshalJSON(data []byte) error {
return err
}
if match := serviceAccountNameRegex.MatchString(temp.Name); !match {
return errors.Newf(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must conform to the regex: %s", serviceAccountNameRegex.String())
if temp.Name == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name cannot be empty")
}
if len(temp.Roles) == 0 {
@@ -257,8 +224,8 @@ func (sa *UpdatableServiceAccount) UnmarshalJSON(data []byte) error {
return err
}
if match := serviceAccountNameRegex.MatchString(temp.Name); !match {
return errors.Newf(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must conform to the regex: %s", serviceAccountNameRegex.String())
if temp.Name == "" {
return errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name cannot be empty")
}
if len(temp.Roles) == 0 {
@@ -277,8 +244,8 @@ func (sa *UpdatableServiceAccountStatus) UnmarshalJSON(data []byte) error {
return err
}
if temp.Status != StatusDisabled {
return errors.Newf(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "invalid status: %s, allowed status are: %v", temp.Status, StatusDisabled)
if !slices.Contains(ValidStatus, temp.Status) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "invalid status: %s, allowed status are: %v", temp.Status, ValidStatus)
}
*sa = UpdatableServiceAccountStatus(temp)

View File

@@ -10,7 +10,6 @@ type Store interface {
// Service Account
Create(context.Context, *StorableServiceAccount) error
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableServiceAccount, error)
GetActiveByOrgIDAndName(context.Context, valuer.UUID, string) (*StorableServiceAccount, error)
GetByID(context.Context, valuer.UUID) (*StorableServiceAccount, error)
List(context.Context, valuer.UUID) ([]*StorableServiceAccount, error)
Update(context.Context, valuer.UUID, *StorableServiceAccount) error