Compare commits

..

39 Commits

Author SHA1 Message Date
swapnil-signoz
0369842f3d refactor: clean up 2026-03-17 23:40:14 +05:30
swapnil-signoz
59cd96562a Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-17 23:10:54 +05:30
swapnil-signoz
cc4475cab7 refactor: updating store methods 2026-03-17 23:10:15 +05:30
swapnil-signoz
ac8c648420 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-17 21:09:47 +05:30
swapnil-signoz
bede6be4b8 feat: adding method for service id creation 2026-03-17 21:09:26 +05:30
swapnil-signoz
dd3d60e6df Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-17 20:49:31 +05:30
swapnil-signoz
538ab686d2 refactor: using serviceID type 2026-03-17 20:49:17 +05:30
swapnil-signoz
936a325cb9 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-17 17:25:58 +05:30
swapnil-signoz
c6cdcd0143 refactor: renaming service type to service id 2026-03-17 17:25:29 +05:30
swapnil-signoz
cd9211d718 refactor: clean up types 2026-03-17 17:04:27 +05:30
swapnil-signoz
0601c28782 feat: adding integration test 2026-03-17 11:02:46 +05:30
swapnil-signoz
580610dbfa Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-16 23:02:19 +05:30
swapnil-signoz
2d2aa02a81 refactor: split upsert store method 2026-03-16 18:27:42 +05:30
swapnil-signoz
dd9723ad13 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-16 17:42:03 +05:30
swapnil-signoz
3651469416 Merge branch 'main' of https://github.com/SigNoz/signoz into refactor/cloud-integration-types 2026-03-16 17:41:52 +05:30
swapnil-signoz
febce75734 refactor: update Dashboard struct comments and remove unused fields 2026-03-16 17:41:28 +05:30
swapnil-signoz
e1616f3487 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-16 17:36:15 +05:30
swapnil-signoz
4b94287ac7 refactor: add comments for backward compatibility in PostableAgentCheckInRequest 2026-03-16 15:48:20 +05:30
swapnil-signoz
1575c7c54c refactor: streamlining types 2026-03-16 15:39:32 +05:30
swapnil-signoz
8def3f835b refactor: adding comments and removed wrong code 2026-03-16 11:10:53 +05:30
swapnil-signoz
11ed15f4c5 feat: implement cloud integration store 2026-03-14 17:05:02 +05:30
swapnil-signoz
f47877cca9 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-14 17:01:51 +05:30
swapnil-signoz
bb2b9215ba fix: correct GetService signature and remove shadowed Data field 2026-03-14 16:59:07 +05:30
swapnil-signoz
3111904223 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-14 16:36:35 +05:30
swapnil-signoz
003e2c30d8 Merge branch 'main' into refactor/cloud-integration-types 2026-03-14 16:25:35 +05:30
swapnil-signoz
00fe516d10 refactor: update cloud integration types and module interface 2026-03-14 16:25:16 +05:30
swapnil-signoz
0305f4f7db refactor: using struct for map 2026-03-13 16:09:26 +05:30
swapnil-signoz
c60019a6dc Merge branch 'main' into refactor/cloud-integration-types 2026-03-12 23:41:22 +05:30
swapnil-signoz
acde2a37fa feat: adding updated types for cloud integration 2026-03-12 23:40:44 +05:30
swapnil-signoz
945241a52a Merge branch 'main' into refactor/cloud-integration-types 2026-03-12 19:45:50 +05:30
swapnil-signoz
e967f80c86 Merge branch 'main' into refactor/cloud-integration-types 2026-03-02 16:39:42 +05:30
swapnil-signoz
a09dc325de Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-02 16:39:20 +05:30
swapnil-signoz
379b4f7fc4 refactor: removing interface check 2026-03-02 14:50:37 +05:30
swapnil-signoz
5e536ae077 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-02 14:49:35 +05:30
swapnil-signoz
234585e642 Merge branch 'main' into refactor/cloud-integration-types 2026-03-02 14:49:19 +05:30
swapnil-signoz
2cc14f1ad4 Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-02 14:49:00 +05:30
swapnil-signoz
dc4ed4d239 feat: adding sql store implementation 2026-03-02 14:44:56 +05:30
swapnil-signoz
7281c36873 refactor: store interfaces to use local types and error 2026-03-02 13:27:46 +05:30
swapnil-signoz
40288776e8 feat: adding cloud integration type for refactor 2026-02-28 16:59:14 +05:30
72 changed files with 2126 additions and 3944 deletions

View File

@@ -217,7 +217,8 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
}),
otelmux.WithPublicEndpoint(),
))
r.Use(middleware.NewIdentN(s.signoz.IdentNResolver, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAuthN([]string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Tokenizer, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAPIKey(s.signoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.signoz.Instrumentation.Logger(), s.signoz.Sharder).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
s.config.APIServer.Timeout.ExcludedRoutes,
s.config.APIServer.Timeout.Default,

View File

@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -33,9 +32,9 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
fmter: fmter,
settings: settings,
operator: sqlschema.NewOperator(fmter, sqlschema.OperatorSupport{
SCreateAndDropConstraint: true,
SAlterTableAddAndDropColumnIfNotExistsAndExists: true,
SAlterTableAlterColumnSetAndDrop: true,
DropConstraint: true,
ColumnIfNotExistsExists: true,
AlterColumnSetNotNull: true,
}),
}, nil
}
@@ -73,9 +72,8 @@ WHERE
if err != nil {
return nil, nil, err
}
if len(columns) == 0 {
return nil, nil, provider.sqlstore.WrapNotFoundErrf(sql.ErrNoRows, errors.CodeNotFound, "table (%s) not found", tableName)
return nil, nil, sql.ErrNoRows
}
sqlschemaColumns := make([]*sqlschema.Column, 0)
@@ -222,9 +220,7 @@ SELECT
ci.relname AS index_name,
i.indisunique AS unique,
i.indisprimary AS primary,
a.attname AS column_name,
array_position(i.indkey, a.attnum) AS column_position,
pg_get_expr(i.indpred, i.indrelid) AS predicate
a.attname AS column_name
FROM
pg_index i
LEFT JOIN pg_class ct ON ct.oid = i.indrelid
@@ -235,10 +231,9 @@ WHERE
a.attnum = ANY(i.indkey)
AND con.oid IS NULL
AND ct.relkind = 'r'
AND ct.relname = ?
ORDER BY index_name, column_position`, string(name))
AND ct.relname = ?`, string(name))
if err != nil {
return nil, provider.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "no indices for table (%s) found", name)
return nil, err
}
defer func() {
@@ -247,12 +242,7 @@ ORDER BY index_name, column_position`, string(name))
}
}()
type indexEntry struct {
columns []sqlschema.ColumnName
predicate *string
}
uniqueIndicesMap := make(map[string]*indexEntry)
uniqueIndicesMap := make(map[string]*sqlschema.UniqueIndex)
for rows.Next() {
var (
tableName string
@@ -260,53 +250,27 @@ ORDER BY index_name, column_position`, string(name))
unique bool
primary bool
columnName string
// starts from 0 and is unused in this function, this is to ensure that the column names are in the correct order
columnPosition int
predicate *string
)
if err := rows.Scan(&tableName, &indexName, &unique, &primary, &columnName, &columnPosition, &predicate); err != nil {
if err := rows.Scan(&tableName, &indexName, &unique, &primary, &columnName); err != nil {
return nil, err
}
if unique {
if _, ok := uniqueIndicesMap[indexName]; !ok {
uniqueIndicesMap[indexName] = &indexEntry{
columns: []sqlschema.ColumnName{sqlschema.ColumnName(columnName)},
predicate: predicate,
uniqueIndicesMap[indexName] = &sqlschema.UniqueIndex{
TableName: name,
ColumnNames: []sqlschema.ColumnName{sqlschema.ColumnName(columnName)},
}
} else {
uniqueIndicesMap[indexName].columns = append(uniqueIndicesMap[indexName].columns, sqlschema.ColumnName(columnName))
uniqueIndicesMap[indexName].ColumnNames = append(uniqueIndicesMap[indexName].ColumnNames, sqlschema.ColumnName(columnName))
}
}
}
indices := make([]sqlschema.Index, 0)
for indexName, entry := range uniqueIndicesMap {
if entry.predicate != nil {
index := &sqlschema.PartialUniqueIndex{
TableName: name,
ColumnNames: entry.columns,
Where: *entry.predicate,
}
if index.Name() == indexName {
indices = append(indices, index)
} else {
indices = append(indices, index.Named(indexName))
}
} else {
index := &sqlschema.UniqueIndex{
TableName: name,
ColumnNames: entry.columns,
}
if index.Name() == indexName {
indices = append(indices, index)
} else {
indices = append(indices, index.Named(indexName))
}
}
for _, index := range uniqueIndicesMap {
indices = append(indices, index)
}
return indices, nil

View File

@@ -5,7 +5,6 @@
padding-bottom: 48px;
.section-heading {
font-family: 'Space Mono';
color: var(--bg-vanilla-400);
font-size: 13px;
font-style: normal;
@@ -26,10 +25,6 @@
letter-spacing: 0.48px;
}
.panel-type-select {
width: 100%;
}
.header {
display: flex;
padding: 14px 14px 14px 12px;
@@ -58,6 +53,50 @@
gap: 8px;
}
.name-description {
padding: 0 0 4px 0;
.name-input {
display: flex;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
flex: 1 0 0;
align-self: stretch;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
.description-input {
border-style: unset;
.ant-input {
display: flex;
height: 80px;
padding: 6px 6px 6px 8px;
align-items: flex-start;
gap: 4px;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
}
}
.panel-config {
display: flex;
flex-direction: column;
@@ -191,6 +230,87 @@
flex-direction: row;
justify-content: space-between;
}
.bucket-config {
.bucket-size-label {
margin-top: 8px;
}
.bucket-input {
display: flex;
width: 100%;
height: 32px;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
.ant-input {
background: var(--bg-ink-300);
}
}
.combine-hist {
display: flex;
justify-content: space-between;
margin-top: 8px;
.label {
color: var(--bg-vanilla-400);
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
}
}
}
.alerts {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
min-height: 44px;
border-top: 1px solid var(--bg-slate-500);
cursor: pointer;
.left-section {
display: flex;
align-items: center;
gap: 8px;
.bell-icon {
color: var(--bg-vanilla-400);
}
.alerts-text {
color: var(--bg-vanilla-400);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.14px;
}
}
.plus-icon {
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);
}
}
@@ -225,6 +345,26 @@
}
}
.name-description {
.typography {
color: var(--bg-ink-400);
}
.name-input {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
color: var(--bg-ink-300);
}
.description-input {
.ant-input {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
color: var(--bg-ink-300);
}
}
}
.panel-config {
.panel-type-select {
.ant-select-selector {
@@ -262,6 +402,21 @@
}
}
.bucket-config {
.label {
color: var(--bg-ink-400);
}
.bucket-input {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.ant-input {
background: var(--bg-vanilla-300);
}
}
}
.panel-time-text {
color: var(--bg-ink-400);
}
@@ -295,6 +450,31 @@
}
}
}
.alerts {
border-top: 1px solid var(--bg-vanilla-300);
.left-section {
.bell-icon {
color: var(--bg-ink-300);
}
.alerts-text {
color: var(--bg-ink-300);
}
}
.plus-icon {
color: var(--bg-ink-300);
}
}
.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,50 +0,0 @@
.alerts-section {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
min-height: 44px;
border-top: 1px solid var(--bg-slate-500);
cursor: pointer;
.alerts-section__left {
display: flex;
align-items: center;
gap: 8px;
.alerts-section__bell-icon {
color: var(--bg-vanilla-400);
}
.alerts-section__text {
color: var(--bg-vanilla-400);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.14px;
}
}
.alerts-section__plus-icon {
color: var(--bg-vanilla-400);
}
}
.lightMode {
.alerts-section {
border-top: 1px solid var(--bg-vanilla-300);
.alerts-section__left {
.alerts-section__bell-icon {
color: var(--bg-ink-300);
}
.alerts-section__text {
color: var(--bg-ink-300);
}
}
.alerts-section__plus-icon {
color: var(--bg-ink-300);
}
}
}

View File

@@ -1,23 +0,0 @@
import { Typography } from 'antd';
import { ConciergeBell, Plus, SquareArrowOutUpRight } from 'lucide-react';
import './AlertsSection.styles.scss';
interface AlertsSectionProps {
onCreateAlertsHandler: () => void;
}
export default function AlertsSection({
onCreateAlertsHandler,
}: AlertsSectionProps): JSX.Element {
return (
<section className="alerts-section" onClick={onCreateAlertsHandler}>
<div className="alerts-section__left">
<ConciergeBell size={14} className="alerts-section__bell-icon" />
<Typography.Text className="alerts-section__text">Alerts</Typography.Text>
<SquareArrowOutUpRight size={10} className="info-icon" />
</div>
<Plus size={14} className="alerts-section__plus-icon" />
</section>
);
}

View File

@@ -1,98 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { InputNumber, Select, Typography } from 'antd';
import { Axis3D, LineChart, Spline } from 'lucide-react';
import SettingsSection from '../../components/SettingsSection/SettingsSection';
enum LogScale {
LINEAR = 'linear',
LOGARITHMIC = 'logarithmic',
}
const { Option } = Select;
interface AxesSectionProps {
allowSoftMinMax: boolean;
allowLogScale: boolean;
softMin: number | null;
softMax: number | null;
setSoftMin: Dispatch<SetStateAction<number | null>>;
setSoftMax: Dispatch<SetStateAction<number | null>>;
isLogScale: boolean;
setIsLogScale: Dispatch<SetStateAction<boolean>>;
}
export default function AxesSection({
allowSoftMinMax,
allowLogScale,
softMin,
softMax,
setSoftMin,
setSoftMax,
isLogScale,
setIsLogScale,
}: AxesSectionProps): JSX.Element {
const softMinHandler = (value: number | null): void => {
setSoftMin(value);
};
const softMaxHandler = (value: number | null): void => {
setSoftMax(value);
};
return (
<SettingsSection title="Axes" icon={<Axis3D size={14} />}>
{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="section-heading">Y Axis Scale</Typography.Text>
<Select
onChange={(value): void => setIsLogScale(value === LogScale.LOGARITHMIC)}
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
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>
);
}

View File

@@ -1,71 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { Switch, Typography } from 'antd';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import { Paintbrush } from 'lucide-react';
import { FillModeSelector } from '../../components/FillModeSelector/FillModeSelector';
import { LineInterpolationSelector } from '../../components/LineInterpolationSelector/LineInterpolationSelector';
import { LineStyleSelector } from '../../components/LineStyleSelector/LineStyleSelector';
import SettingsSection from '../../components/SettingsSection/SettingsSection';
interface ChartAppearanceSectionProps {
fillMode: FillMode;
setFillMode: Dispatch<SetStateAction<FillMode>>;
lineStyle: LineStyle;
setLineStyle: Dispatch<SetStateAction<LineStyle>>;
lineInterpolation: LineInterpolation;
setLineInterpolation: Dispatch<SetStateAction<LineInterpolation>>;
showPoints: boolean;
setShowPoints: Dispatch<SetStateAction<boolean>>;
allowFillMode: boolean;
allowLineStyle: boolean;
allowLineInterpolation: boolean;
allowShowPoints: boolean;
}
export default function ChartAppearanceSection({
fillMode,
setFillMode,
lineStyle,
setLineStyle,
lineInterpolation,
setLineInterpolation,
showPoints,
setShowPoints,
allowFillMode,
allowLineStyle,
allowLineInterpolation,
allowShowPoints,
}: ChartAppearanceSectionProps): JSX.Element {
return (
<SettingsSection title="Chart Appearance" icon={<Paintbrush size={14} />}>
{allowFillMode && (
<FillModeSelector value={fillMode} onChange={setFillMode} />
)}
{allowLineStyle && (
<LineStyleSelector value={lineStyle} onChange={setLineStyle} />
)}
{allowLineInterpolation && (
<LineInterpolationSelector
value={lineInterpolation}
onChange={setLineInterpolation}
/>
)}
{allowShowPoints && (
<section className="show-points toggle-card">
<div className="toggle-card-text-container">
<Typography.Text className="section-heading">Show points</Typography.Text>
<Typography.Text className="toggle-card-description">
Display individual data points on the chart
</Typography.Text>
</div>
<Switch size="small" checked={showPoints} onChange={setShowPoints} />
</section>
)}
</SettingsSection>
);
}

View File

@@ -1,10 +0,0 @@
.context-links-section {
padding: 12px 12px 16px 12px;
border-bottom: 1px solid var(--bg-slate-500);
}
.lightMode {
.context-links-section {
border-bottom: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -1,36 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { Link as LinkIcon } from 'lucide-react';
import { ContextLinksData, Widgets } from 'types/api/dashboard/getAll';
import SettingsSection from '../../components/SettingsSection/SettingsSection';
import ContextLinks from '../../ContextLinks';
import './ContextLinksSection.styles.scss';
interface ContextLinksSectionProps {
contextLinks: ContextLinksData;
setContextLinks: Dispatch<SetStateAction<ContextLinksData>>;
selectedWidget?: Widgets;
}
export default function ContextLinksSection({
contextLinks,
setContextLinks,
selectedWidget,
}: ContextLinksSectionProps): JSX.Element {
return (
<SettingsSection
title="Context Links"
icon={<LinkIcon size={14} />}
defaultOpen={!!contextLinks.linksData.length}
>
<div className="context-links-section">
<ContextLinks
contextLinks={contextLinks}
setContextLinks={setContextLinks}
selectedWidget={selectedWidget}
/>
</div>
</SettingsSection>
);
}

View File

@@ -1,84 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { Select, Typography } from 'antd';
import { PrecisionOption } from 'components/Graph/types';
import { PanelDisplay } from 'constants/queryBuilder';
import { SlidersHorizontal } from 'lucide-react';
import { ColumnUnit } from 'types/api/dashboard/getAll';
import { ColumnUnitSelector } from '../../ColumnUnitSelector/ColumnUnitSelector';
import SettingsSection from '../../components/SettingsSection/SettingsSection';
import DashboardYAxisUnitSelectorWrapper from '../../DashboardYAxisUnitSelectorWrapper';
interface FormattingUnitsSectionProps {
selectedPanelDisplay: PanelDisplay | '';
yAxisUnit: string;
setYAxisUnit: Dispatch<SetStateAction<string>>;
isNewDashboard: boolean;
decimalPrecision: PrecisionOption;
setDecimalPrecision: Dispatch<SetStateAction<PrecisionOption>>;
columnUnits: ColumnUnit;
setColumnUnits: Dispatch<SetStateAction<ColumnUnit>>;
allowYAxisUnit: boolean;
allowDecimalPrecision: boolean;
allowPanelColumnPreference: boolean;
decimapPrecisionOptions: { label: string; value: PrecisionOption }[];
}
export default function FormattingUnitsSection({
selectedPanelDisplay,
yAxisUnit,
setYAxisUnit,
isNewDashboard,
decimalPrecision,
setDecimalPrecision,
columnUnits,
setColumnUnits,
allowYAxisUnit,
allowDecimalPrecision,
allowPanelColumnPreference,
decimapPrecisionOptions,
}: FormattingUnitsSectionProps): JSX.Element {
return (
<SettingsSection
title="Formatting & Units"
icon={<SlidersHorizontal size={14} />}
>
{allowYAxisUnit && (
<DashboardYAxisUnitSelectorWrapper
onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={
selectedPanelDisplay === PanelDisplay.VALUE ||
selectedPanelDisplay === PanelDisplay.PIE
? 'Unit'
: 'Y Axis Unit'
}
shouldUpdateYAxisUnit={isNewDashboard}
/>
)}
{allowDecimalPrecision && (
<section className="decimal-precision-selector control-container">
<Typography.Text className="section-heading">
Decimal Precision
</Typography.Text>
<Select
options={decimapPrecisionOptions}
value={decimalPrecision}
className="panel-type-select"
defaultValue={decimapPrecisionOptions[0]?.value}
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
/>
</section>
)}
{allowPanelColumnPreference && (
<ColumnUnitSelector
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
/>
)}
</SettingsSection>
);
}

View File

@@ -1,64 +0,0 @@
.general-settings__name-description {
padding: 0 0 4px 0;
.general-settings__name-input {
display: flex;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
flex: 1 0 0;
align-self: stretch;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
.general-settings__description-input {
border-style: unset;
.ant-input {
display: flex;
height: 80px;
padding: 6px 6px 6px 8px;
align-items: flex-start;
gap: 4px;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
}
}
.lightMode {
.general-settings__name-description {
border-top: 1px solid var(--bg-vanilla-300);
border-bottom: 1px solid var(--bg-vanilla-300);
.general-settings__name-input {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
color: var(--bg-ink-300);
}
.general-settings__description-input {
.ant-input {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
color: var(--bg-ink-300);
}
}
}
}

View File

@@ -1,156 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import type { InputRef } from 'antd';
import { AutoComplete, Input, Typography } from 'antd';
import { popupContainer } from 'utils/selectPopupContainer';
import SettingsSection from '../../components/SettingsSection/SettingsSection';
import './GeneralSettingsSection.styles.scss';
const { TextArea } = Input;
interface VariableOption {
value: string;
label: string;
}
interface GeneralSettingsSectionProps {
title: string;
setTitle: Dispatch<SetStateAction<string>>;
description: string;
setDescription: Dispatch<SetStateAction<string>>;
dashboardVariables: Record<string, { name?: string }>;
}
export default function GeneralSettingsSection({
title,
setTitle,
description,
setDescription,
dashboardVariables,
}: GeneralSettingsSectionProps): JSX.Element {
const [inputValue, setInputValue] = useState(title);
const [autoCompleteOpen, setAutoCompleteOpen] = useState(false);
const [cursorPos, setCursorPos] = useState(0);
const inputRef = useRef<InputRef>(null);
const onChangeHandler = (
setFunc: Dispatch<SetStateAction<string>>,
value: string,
): void => {
setFunc(value);
};
const dashboardVariableOptions = useMemo<VariableOption[]>(() => {
return Object.entries(dashboardVariables).map(([, value]) => ({
value: value.name || '',
label: value.name || '',
}));
}, [dashboardVariables]);
const updateCursorAndDropdown = useCallback(
(value: string, pos: number): void => {
setCursorPos(pos);
const lastDollar = value.lastIndexOf('$', pos - 1);
setAutoCompleteOpen(lastDollar !== -1 && pos >= lastDollar + 1);
},
[],
);
const onInputChange = useCallback(
(value: string): void => {
setInputValue(value);
onChangeHandler(setTitle, value);
setTimeout(() => {
const pos = inputRef.current?.input?.selectionStart ?? 0;
updateCursorAndDropdown(value, pos);
}, 0);
},
[setTitle, updateCursorAndDropdown],
);
const onSelect = useCallback(
(selectedValue: string): void => {
const pos = cursorPos;
const value = inputValue;
const lastDollar = value.lastIndexOf('$', pos - 1);
const textBeforeDollar = value.substring(0, lastDollar);
const textAfterDollar = value.substring(lastDollar + 1);
const match = textAfterDollar.match(/^([a-zA-Z0-9_.]*)/);
const rest = textAfterDollar.substring(match ? match[1].length : 0);
const newValue = `${textBeforeDollar}$${selectedValue}${rest}`;
setInputValue(newValue);
onChangeHandler(setTitle, newValue);
setAutoCompleteOpen(false);
setTimeout(() => {
const newCursor = `${textBeforeDollar}$${selectedValue}`.length;
inputRef.current?.input?.setSelectionRange(newCursor, newCursor);
setCursorPos(newCursor);
}, 0);
},
[cursorPos, inputValue, setTitle],
);
const filterOption = useCallback(
(currentInputValue: string, option?: VariableOption): boolean => {
const pos = cursorPos;
const value = currentInputValue;
const lastDollar = value.lastIndexOf('$', pos - 1);
if (lastDollar === -1) {
return false;
}
const afterDollar = value.substring(lastDollar + 1, pos).toLowerCase();
return option?.value.toLowerCase().startsWith(afterDollar) || false;
},
[cursorPos],
);
const handleInputCursor = useCallback((): void => {
const pos = inputRef.current?.input?.selectionStart ?? 0;
updateCursorAndDropdown(inputValue, pos);
}, [inputValue, updateCursorAndDropdown]);
return (
<SettingsSection title="General" defaultOpen icon={null}>
<section className="general-settings__name-description control-container">
<Typography.Text className="section-heading">Name</Typography.Text>
<AutoComplete
options={dashboardVariableOptions}
value={inputValue}
onChange={onInputChange}
onSelect={onSelect}
filterOption={filterOption}
getPopupContainer={popupContainer}
placeholder="Enter the panel name here..."
open={autoCompleteOpen}
>
<Input
rootClassName="general-settings__name-input"
ref={inputRef}
onSelect={handleInputCursor}
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/>
</AutoComplete>
<Typography.Text className="section-heading">Description</Typography.Text>
<TextArea
placeholder="Enter the panel description here..."
bordered
allowClear
value={description}
onChange={(event): void =>
onChangeHandler(setDescription, event.target.value)
}
rootClassName="general-settings__description-input"
/>
</section>
</SettingsSection>
);
}

View File

@@ -1,55 +0,0 @@
.histogram-settings__bucket-config {
.histogram-settings__bucket-size-label {
margin-top: 8px;
}
.histogram-settings__bucket-input {
display: flex;
width: 100%;
height: 32px;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
align-self: stretch;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
.ant-input {
background: var(--bg-ink-300);
}
}
.histogram-settings__combine-hist {
display: flex;
justify-content: space-between;
margin-top: 8px;
.histogram-settings__merge-label {
color: var(--bg-vanilla-400);
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 138.462% */
letter-spacing: 0.52px;
text-transform: uppercase;
}
}
}
.lightMode {
.histogram-settings__bucket-config {
.histogram-settings__merge-label {
color: var(--bg-ink-400);
}
.histogram-settings__bucket-input {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.ant-input {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -1,71 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { InputNumber, Switch, Typography } from 'antd';
import SettingsSection from '../../components/SettingsSection/SettingsSection';
import './HistogramBucketsSection.styles.scss';
interface HistogramBucketsSectionProps {
bucketCount: number;
setBucketCount: Dispatch<SetStateAction<number>>;
bucketWidth: number;
setBucketWidth: Dispatch<SetStateAction<number>>;
combineHistogram: boolean;
setCombineHistogram: Dispatch<SetStateAction<boolean>>;
}
export default function HistogramBucketsSection({
bucketCount,
setBucketCount,
bucketWidth,
setBucketWidth,
combineHistogram,
setCombineHistogram,
}: HistogramBucketsSectionProps): JSX.Element {
return (
<SettingsSection title="Histogram / Buckets">
<section className="histogram-settings__bucket-config control-container">
<Typography.Text className="section-heading">
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="section-heading histogram-settings__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="histogram-settings__bucket-input"
onChange={(val): void => {
setBucketWidth(val || 0);
}}
/>
<section className="histogram-settings__combine-hist">
<Typography.Text className="section-heading">
<span className="histogram-settings__merge-label">
Merge all series into one
</span>
</Typography.Text>
<Switch
checked={combineHistogram}
size="small"
onChange={(checked): void => setCombineHistogram(checked)}
/>
</section>
</section>
</SettingsSection>
);
}

View File

@@ -1,72 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import type { UseQueryResult } from 'react-query';
import { Select, Typography } from 'antd';
import { Layers } from 'lucide-react';
import { SuccessResponse } from 'types/api';
import { LegendPosition } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import SettingsSection from '../../components/SettingsSection/SettingsSection';
import LegendColors from '../../LegendColors/LegendColors';
const { Option } = Select;
interface LegendSectionProps {
allowLegendPosition: boolean;
allowLegendColors: boolean;
legendPosition: LegendPosition;
setLegendPosition: Dispatch<SetStateAction<LegendPosition>>;
customLegendColors: Record<string, string>;
setCustomLegendColors: Dispatch<SetStateAction<Record<string, string>>>;
queryResponse?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
}
export default function LegendSection({
allowLegendPosition,
allowLegendColors,
legendPosition,
setLegendPosition,
customLegendColors,
setCustomLegendColors,
queryResponse,
}: LegendSectionProps): JSX.Element {
return (
<SettingsSection title="Legend" icon={<Layers size={14} />}>
{allowLegendPosition && (
<section className="legend-position control-container">
<Typography.Text className="section-heading">Position</Typography.Text>
<Select
onChange={(value: LegendPosition): void => setLegendPosition(value)}
value={legendPosition}
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>
);
}

View File

@@ -1,10 +0,0 @@
.thresholds-section {
padding: 12px 12px 16px 12px;
border-top: 1px solid var(--bg-slate-500);
}
.lightMode {
.thresholds-section {
border-top: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -1,42 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Antenna } from 'lucide-react';
import { ColumnUnit } from 'types/api/dashboard/getAll';
import SettingsSection from '../../components/SettingsSection/SettingsSection';
import ThresholdSelector from '../../Threshold/ThresholdSelector';
import { ThresholdProps } from '../../Threshold/types';
import './ThresholdsSection.styles.scss';
interface ThresholdsSectionProps {
thresholds: ThresholdProps[];
setThresholds: Dispatch<SetStateAction<ThresholdProps[]>>;
yAxisUnit: string;
selectedGraph: PANEL_TYPES;
columnUnits: ColumnUnit;
}
export default function ThresholdsSection({
thresholds,
setThresholds,
yAxisUnit,
selectedGraph,
columnUnits,
}: ThresholdsSectionProps): JSX.Element {
return (
<SettingsSection
title="Thresholds"
icon={<Antenna size={14} />}
defaultOpen={!!thresholds.length}
>
<ThresholdSelector
thresholds={thresholds}
setThresholds={setThresholds}
yAxisUnit={yAxisUnit}
selectedGraph={selectedGraph}
columnUnits={columnUnits}
/>
</SettingsSection>
);
}

View File

@@ -1,130 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { Select, Switch, Typography } from 'antd';
import TimePreference from 'components/TimePreferenceDropDown';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
ItemsProps,
PanelTypesWithData,
} from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { LayoutDashboard } from 'lucide-react';
import { DataSource } from 'types/common/queryBuilder';
import SettingsSection from '../../components/SettingsSection/SettingsSection';
import { timePreferance } from '../../timeItems';
const { Option } = Select;
interface VisualizationSettingsSectionProps {
selectedGraph: PANEL_TYPES;
setGraphHandler: (type: PANEL_TYPES) => void;
selectedTime: timePreferance;
setSelectedTime: Dispatch<SetStateAction<timePreferance>>;
stackedBarChart: boolean;
setStackedBarChart: Dispatch<SetStateAction<boolean>>;
isFillSpans: boolean;
setIsFillSpans: Dispatch<SetStateAction<boolean>>;
allowPanelTimePreference: boolean;
allowStackingBarChart: boolean;
allowFillSpans: boolean;
}
export default function VisualizationSettingsSection({
selectedGraph,
setGraphHandler,
selectedTime,
setSelectedTime,
stackedBarChart,
setStackedBarChart,
isFillSpans,
setIsFillSpans,
allowPanelTimePreference,
allowStackingBarChart,
allowFillSpans,
}: VisualizationSettingsSectionProps): JSX.Element {
const { currentQuery } = useQueryBuilder();
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(PanelTypesWithData);
useEffect(() => {
const queryContainsMetricsDataSource = currentQuery.builder.queryData.some(
(query) => query.dataSource === DataSource.METRICS,
);
if (queryContainsMetricsDataSource) {
setGraphTypes((prev) =>
prev.filter((graph) => graph.name !== PANEL_TYPES.LIST),
);
} else {
setGraphTypes(PanelTypesWithData);
}
}, [currentQuery]);
return (
<SettingsSection
title="Visualization"
defaultOpen
icon={<LayoutDashboard size={14} />}
>
<section className="panel-type control-container">
<Typography.Text className="section-heading">Panel Type</Typography.Text>
<Select
onChange={setGraphHandler}
value={selectedGraph}
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>
</section>
{allowPanelTimePreference && (
<section className="panel-time-preference control-container">
<Typography.Text className="section-heading">
Panel Time Preference
</Typography.Text>
<TimePreference
{...{
selectedTime,
setSelectedTime,
}}
/>
</section>
)}
{allowStackingBarChart && (
<section className="stack-chart control-container">
<Typography.Text className="section-heading">Stack series</Typography.Text>
<Switch
checked={stackedBarChart}
size="small"
onChange={(checked): void => setStackedBarChart(checked)}
/>
</section>
)}
{allowFillSpans && (
<section className="fill-gaps toggle-card">
<div className="toggle-card-text-container">
<Typography className="section-heading">Fill gaps</Typography>
<Typography.Text className="toggle-card-description">
Fill gaps in data with 0 for continuity
</Typography.Text>
</div>
<Switch
checked={isFillSpans}
size="small"
onChange={(checked): void => setIsFillSpans(checked)}
/>
</section>
)}
</SettingsSection>
);
}

View File

@@ -189,7 +189,7 @@ describe('RightContainer - Alerts Section', () => {
const alertsSection = screen.getByText('Alerts').closest('section');
expect(alertsSection).toBeInTheDocument();
expect(alertsSection).toHaveClass('alerts-section');
expect(alertsSection).toHaveClass('alerts');
});
it('renders alerts section with correct text and SquareArrowOutUpRight icon', () => {

View File

@@ -1,21 +0,0 @@
.fill-mode-selector {
.fill-mode-icon {
width: 24px;
height: 24px;
}
.fill-mode-label {
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
color: var(--bg-vanilla-400);
}
}
.lightMode {
.fill-mode-selector {
.fill-mode-label {
color: var(--bg-ink-400);
}
}
}

View File

@@ -1,94 +0,0 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { FillMode } from 'lib/uPlotV2/config/types';
import './FillModeSelector.styles.scss';
interface FillModeSelectorProps {
value: FillMode;
onChange: (value: FillMode) => void;
}
export function FillModeSelector({
value,
onChange,
}: FillModeSelectorProps): JSX.Element {
return (
<section className="fill-mode-selector control-container">
<Typography.Text className="section-heading">Fill mode</Typography.Text>
<ToggleGroup
type="single"
value={value}
variant="outline"
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as FillMode);
}
}}
>
<ToggleGroupItem value={FillMode.None} aria-label="None" title="None">
<svg
className="fill-mode-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="8" y="16" width="32" height="16" stroke="#888" fill="none" />
</svg>
<Typography.Text className="section-heading-small">None</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem value={FillMode.Solid} aria-label="Solid" title="Solid">
<svg
className="fill-mode-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="8" y="16" width="32" height="16" fill="#888" />
</svg>
<Typography.Text className="section-heading-small">Solid</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem
value={FillMode.Gradient}
aria-label="Gradient"
title="Gradient"
>
<svg
className="fill-mode-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<defs>
<linearGradient id="fill-gradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#888" stopOpacity="0.2" />
<stop offset="100%" stopColor="#888" stopOpacity="0.8" />
</linearGradient>
</defs>
<rect
x="8"
y="16"
width="32"
height="16"
fill="url(#fill-gradient)"
stroke="#888"
/>
</svg>
<Typography.Text className="section-heading-small">
Gradient
</Typography.Text>
</ToggleGroupItem>
</ToggleGroup>
</section>
);
}

View File

@@ -1,21 +0,0 @@
.line-interpolation-selector {
.line-interpolation-icon {
width: 24px;
height: 24px;
}
.line-interpolation-label {
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
color: var(--bg-vanilla-400);
}
}
.lightMode {
.line-interpolation-selector {
.line-interpolation-label {
color: var(--bg-ink-400);
}
}
}

View File

@@ -1,110 +0,0 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { LineInterpolation } from 'lib/uPlotV2/config/types';
import './LineInterpolationSelector.styles.scss';
interface LineInterpolationSelectorProps {
value: LineInterpolation;
onChange: (value: LineInterpolation) => void;
}
export function LineInterpolationSelector({
value,
onChange,
}: LineInterpolationSelectorProps): JSX.Element {
return (
<section className="line-interpolation-selector control-container">
<Typography.Text className="section-heading">
Line interpolation
</Typography.Text>
<ToggleGroup
type="single"
value={value}
variant="outline"
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as LineInterpolation);
}
}}
>
<ToggleGroupItem
value={LineInterpolation.Linear}
aria-label="Linear"
title="Linear"
>
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 L24 16 L40 32" stroke="#888" />
</svg>
</ToggleGroupItem>
<ToggleGroupItem value={LineInterpolation.Spline} aria-label="Spline">
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 C16 8, 32 8, 40 32" />
</svg>
</ToggleGroupItem>
<ToggleGroupItem
value={LineInterpolation.StepAfter}
aria-label="Step After"
>
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 V16 H24 V32 H40" />
</svg>
</ToggleGroupItem>
<ToggleGroupItem
value={LineInterpolation.StepBefore}
aria-label="Step Before"
>
<svg
className="line-interpolation-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="32" r="3" fill="#888" />
<circle cx="24" cy="16" r="3" fill="#888" />
<circle cx="40" cy="32" r="3" fill="#888" />
<path d="M8 32 H24 V16 H40 V32" />
</svg>
</ToggleGroupItem>
</ToggleGroup>
</section>
);
}

View File

@@ -1,21 +0,0 @@
.line-style-selector {
.line-style-icon {
width: 24px;
height: 24px;
}
.line-style-label {
text-transform: uppercase;
font-size: 12px;
font-weight: 500;
color: var(--bg-vanilla-400);
}
}
.lightMode {
.line-style-selector {
.line-style-label {
color: var(--bg-ink-400);
}
}
}

View File

@@ -1,66 +0,0 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { LineStyle } from 'lib/uPlotV2/config/types';
import './LineStyleSelector.styles.scss';
interface LineStyleSelectorProps {
value: LineStyle;
onChange: (value: LineStyle) => void;
}
export function LineStyleSelector({
value,
onChange,
}: LineStyleSelectorProps): JSX.Element {
return (
<section className="line-style-selector control-container">
<Typography.Text className="section-heading">Line style</Typography.Text>
<ToggleGroup
type="single"
value={value}
variant="outline"
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as LineStyle);
}
}}
>
<ToggleGroupItem value={LineStyle.Solid} aria-label="Solid" title="Solid">
<svg
className="line-style-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 24 L40 24" />
</svg>
<Typography.Text className="section-heading-small">Solid</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem
value={LineStyle.Dashed}
aria-label="Dashed"
title="Dashed"
>
<svg
className="line-style-icon"
viewBox="0 0 48 48"
fill="none"
stroke="#888"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="6 4"
>
<path d="M8 24 L40 24" />
</svg>
<Typography.Text className="section-heading-small">Dashed</Typography.Text>
</ToggleGroupItem>
</ToggleGroup>
</section>
);
}

View File

@@ -1,16 +1,52 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { UseQueryResult } from 'react-query';
import { Typography } from 'antd';
import type { InputRef } from 'antd';
import {
AutoComplete,
Input,
InputNumber,
Select,
Switch,
Typography,
} from 'antd';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import TimePreference from 'components/TimePreferenceDropDown';
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
import { PanelTypesWithData } from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems';
import {
ItemsProps,
PanelTypesWithData,
} from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
FillMode,
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import {
Antenna,
Axis3D,
ConciergeBell,
Layers,
LayoutDashboard,
LineChart,
Link,
Paintbrush,
Pencil,
Plus,
SlidersHorizontal,
Spline,
SquareArrowOutUpRight,
} from 'lucide-react';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
@@ -19,7 +55,11 @@ import {
Widgets,
} from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
import SettingsSection from './components/SettingsSection/SettingsSection';
import {
panelTypeVsBucketConfig,
panelTypeVsColumnUnitPreferences,
@@ -40,20 +80,32 @@ import {
panelTypeVsThreshold,
panelTypeVsYAxisUnit,
} from './constants';
import AlertsSection from './SettingSections/AlertsSection/AlertsSection';
import AxesSection from './SettingSections/AxesSection/AxesSection';
import ChartAppearanceSection from './SettingSections/ChartAppearanceSection/ChartAppearanceSection';
import ContextLinksSection from './SettingSections/ContextLinksSection/ContextLinksSection';
import FormattingUnitsSection from './SettingSections/FormattingUnitsSection/FormattingUnitsSection';
import GeneralSettingsSection from './SettingSections/GeneralSettingsSection/GeneralSettingsSection';
import HistogramBucketsSection from './SettingSections/HistogramBucketsSection/HistogramBucketsSection';
import LegendSection from './SettingSections/LegendSection/LegendSection';
import ThresholdsSection from './SettingSections/ThresholdsSection/ThresholdsSection';
import VisualizationSettingsSection from './SettingSections/VisualizationSettingsSection/VisualizationSettingsSection';
import ContextLinks from './ContextLinks';
import DashboardYAxisUnitSelectorWrapper from './DashboardYAxisUnitSelectorWrapper';
import { FillModeSelector } from './FillModeSelector';
import LegendColors from './LegendColors/LegendColors';
import { LineInterpolationSelector } from './LineInterpolationSelector';
import { LineStyleSelector } from './LineStyleSelector';
import ThresholdSelector from './Threshold/ThresholdSelector';
import { ThresholdProps } from './Threshold/types';
import { timePreferance } from './timeItems';
import './RightContainer.styles.scss';
const { TextArea } = Input;
const { Option } = Select;
enum LogScale {
LINEAR = 'linear',
LOGARITHMIC = 'logarithmic',
}
interface VariableOption {
value: string;
label: string;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function RightContainer({
description,
setDescription,
@@ -107,10 +159,20 @@ function RightContainer({
isNewDashboard,
}: RightContainerProps): JSX.Element {
const { dashboardVariables } = useDashboardVariables();
const [inputValue, setInputValue] = useState(title);
const [autoCompleteOpen, setAutoCompleteOpen] = useState(false);
const [cursorPos, setCursorPos] = useState(0);
const inputRef = useRef<InputRef>(null);
const selectedPanelDisplay = PanelTypesWithData.find(
(e) => e.name === selectedGraph,
)?.display as PanelDisplay;
const onChangeHandler = useCallback(
(setFunc: Dispatch<SetStateAction<string>>, value: string) => {
setFunc(value);
},
[],
);
const selectedGraphType =
PanelTypesWithData.find((e) => e.name === selectedGraph)?.display || '';
const onCreateAlertsHandler = useCreateAlerts(selectedWidget, 'panelView');
@@ -139,15 +201,16 @@ function RightContainer({
const allowFillMode = panelTypeVsFillMode[selectedGraph];
const allowShowPoints = panelTypeVsShowPoints[selectedGraph];
const decimapPrecisionOptions = useMemo(
() => [
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
],
[],
);
const { currentQuery } = useQueryBuilder();
const [graphTypes, setGraphTypes] = useState<ItemsProps[]>(PanelTypesWithData);
const dashboardVariableOptions = useMemo<VariableOption[]>(() => {
return Object.entries(dashboardVariables).map(([, value]) => ({
value: value.name || '',
label: value.name || '',
}));
}, [dashboardVariables]);
const isAxisSectionVisible = useMemo(() => allowSoftMinMax || allowLogScale, [
allowSoftMinMax,
@@ -180,6 +243,96 @@ function RightContainer({
[allowFillMode, allowLineStyle, allowLineInterpolation, allowShowPoints],
);
const updateCursorAndDropdown = (value: string, pos: number): void => {
setCursorPos(pos);
const lastDollar = value.lastIndexOf('$', pos - 1);
setAutoCompleteOpen(lastDollar !== -1 && pos >= lastDollar + 1);
};
const onInputChange = (value: string): void => {
setInputValue(value);
onChangeHandler(setTitle, value);
setTimeout(() => {
const pos = inputRef.current?.input?.selectionStart ?? 0;
updateCursorAndDropdown(value, pos);
}, 0);
};
const decimapPrecisionOptions = useMemo(() => {
return [
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
];
}, []);
const handleInputCursor = (): void => {
const pos = inputRef.current?.input?.selectionStart ?? 0;
updateCursorAndDropdown(inputValue, pos);
};
const onSelect = (selectedValue: string): void => {
const pos = cursorPos;
const value = inputValue;
const lastDollar = value.lastIndexOf('$', pos - 1);
const textBeforeDollar = value.substring(0, lastDollar);
const textAfterDollar = value.substring(lastDollar + 1);
const match = textAfterDollar.match(/^([a-zA-Z0-9_.]*)/);
const rest = textAfterDollar.substring(match ? match[1].length : 0);
const newValue = `${textBeforeDollar}$${selectedValue}${rest}`;
setInputValue(newValue);
onChangeHandler(setTitle, newValue);
setAutoCompleteOpen(false);
setTimeout(() => {
const newCursor = `${textBeforeDollar}$${selectedValue}`.length;
inputRef.current?.input?.setSelectionRange(newCursor, newCursor);
setCursorPos(newCursor);
}, 0);
};
const filterOption = (
inputValue: string,
option?: VariableOption,
): boolean => {
const pos = cursorPos;
const value = inputValue;
const lastDollar = value.lastIndexOf('$', pos - 1);
if (lastDollar === -1) {
return false;
}
const afterDollar = value.substring(lastDollar + 1, pos).toLowerCase();
return option?.value.toLowerCase().startsWith(afterDollar) || false;
};
useEffect(() => {
const queryContainsMetricsDataSource = currentQuery.builder.queryData.some(
(query) => query.dataSource === DataSource.METRICS,
);
if (queryContainsMetricsDataSource) {
setGraphTypes((prev) =>
prev.filter((graph) => graph.name !== PANEL_TYPES.LIST),
);
} else {
setGraphTypes(PanelTypesWithData);
}
}, [currentQuery]);
const softMinHandler = useCallback(
(value: number | null) => {
setSoftMin(value);
},
[setSoftMin],
);
const softMaxHandler = useCallback(
(value: number | null) => {
setSoftMax(value);
},
[setSoftMax],
);
return (
<div className="right-container">
<section className="header">
@@ -187,120 +340,372 @@ function RightContainer({
<Typography.Text className="header-text">Panel Settings</Typography.Text>
</section>
<GeneralSettingsSection
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
dashboardVariables={dashboardVariables}
/>
<SettingsSection title="General" defaultOpen icon={<Pencil size={14} />}>
<section className="name-description control-container">
<Typography.Text className="section-heading">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="section-heading">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>
</SettingsSection>
<section className="panel-config">
<VisualizationSettingsSection
selectedGraph={selectedGraph}
setGraphHandler={setGraphHandler}
selectedTime={selectedTime}
setSelectedTime={setSelectedTime}
stackedBarChart={stackedBarChart}
setStackedBarChart={setStackedBarChart}
isFillSpans={isFillSpans}
setIsFillSpans={setIsFillSpans}
allowPanelTimePreference={allowPanelTimePreference}
allowStackingBarChart={allowStackingBarChart}
allowFillSpans={allowFillSpans}
/>
<SettingsSection
title="Visualization"
defaultOpen
icon={<LayoutDashboard size={14} />}
>
<section className="panel-type control-container">
<Typography.Text className="section-heading">Panel Type</Typography.Text>
<Select
onChange={setGraphHandler}
value={selectedGraph}
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>
</section>
{allowPanelTimePreference && (
<section className="panel-time-preference control-container">
<Typography.Text className="section-heading">
Panel Time Preference
</Typography.Text>
<TimePreference
{...{
selectedTime,
setSelectedTime,
}}
/>
</section>
)}
{allowStackingBarChart && (
<section className="stack-chart control-container">
<Typography.Text className="section-heading">
Stack series
</Typography.Text>
<Switch
checked={stackedBarChart}
size="small"
onChange={(checked): void => setStackedBarChart(checked)}
/>
</section>
)}
{allowFillSpans && (
<section className="fill-gaps toggle-card">
<div className="toggle-card-text-container">
<Typography className="section-heading">Fill gaps</Typography>
<Typography.Text className="toggle-card-description">
Fill gaps in data with 0 for continuity
</Typography.Text>
</div>
<Switch
checked={isFillSpans}
size="small"
onChange={(checked): void => setIsFillSpans(checked)}
/>
</section>
)}
</SettingsSection>
{isFormattingSectionVisible && (
<FormattingUnitsSection
selectedPanelDisplay={selectedPanelDisplay}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
isNewDashboard={isNewDashboard}
decimalPrecision={decimalPrecision}
setDecimalPrecision={setDecimalPrecision}
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
allowYAxisUnit={allowYAxisUnit}
allowDecimalPrecision={allowDecimalPrecision}
allowPanelColumnPreference={allowPanelColumnPreference}
decimapPrecisionOptions={decimapPrecisionOptions}
/>
<SettingsSection
title="Formatting & Units"
icon={<SlidersHorizontal 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={decimapPrecisionOptions}
value={decimalPrecision}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={PrecisionOptionsEnum.TWO}
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
/>
</section>
)}
{allowPanelColumnPreference && (
<ColumnUnitSelector
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
/>
)}
</SettingsSection>
)}
{isChartAppearanceSectionVisible && (
<ChartAppearanceSection
fillMode={fillMode}
setFillMode={setFillMode}
lineStyle={lineStyle}
setLineStyle={setLineStyle}
lineInterpolation={lineInterpolation}
setLineInterpolation={setLineInterpolation}
showPoints={showPoints}
setShowPoints={setShowPoints}
allowFillMode={allowFillMode}
allowLineStyle={allowLineStyle}
allowLineInterpolation={allowLineInterpolation}
allowShowPoints={allowShowPoints}
/>
<SettingsSection title="Chart Appearance" icon={<Paintbrush size={14} />}>
{allowFillMode && (
<FillModeSelector value={fillMode} onChange={setFillMode} />
)}
{allowLineStyle && (
<LineStyleSelector value={lineStyle} onChange={setLineStyle} />
)}
{allowLineInterpolation && (
<LineInterpolationSelector
value={lineInterpolation}
onChange={setLineInterpolation}
/>
)}
{allowShowPoints && (
<section className="show-points toggle-card">
<div className="toggle-card-text-container">
<Typography.Text className="section-heading">
Show points
</Typography.Text>
<Typography.Text className="toggle-card-description">
Display individual data points on the chart
</Typography.Text>
</div>
<Switch size="small" checked={showPoints} onChange={setShowPoints} />
</section>
)}
</SettingsSection>
)}
{isAxisSectionVisible && (
<AxesSection
allowSoftMinMax={allowSoftMinMax}
allowLogScale={allowLogScale}
softMin={softMin}
softMax={softMax}
setSoftMin={setSoftMin}
setSoftMax={setSoftMax}
isLogScale={isLogScale}
setIsLogScale={setIsLogScale}
/>
<SettingsSection title="Axes" icon={<Axis3D size={14} />}>
{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="section-heading">
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>
)}
{isLegendSectionVisible && (
<LegendSection
allowLegendPosition={allowLegendPosition}
allowLegendColors={allowLegendColors}
legendPosition={legendPosition}
setLegendPosition={setLegendPosition}
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
<SettingsSection title="Legend" icon={<Layers size={14} />}>
{allowLegendPosition && (
<section className="legend-position control-container">
<Typography.Text className="section-heading">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 && (
<HistogramBucketsSection
bucketCount={bucketCount}
setBucketCount={setBucketCount}
bucketWidth={bucketWidth}
setBucketWidth={setBucketWidth}
combineHistogram={combineHistogram}
setCombineHistogram={setCombineHistogram}
/>
<SettingsSection title="Histogram / Buckets">
<section className="bucket-config control-container">
<Typography.Text className="section-heading">
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="section-heading 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="section-heading">
Merge all series into one
</Typography.Text>
<Switch
checked={combineHistogram}
size="small"
onChange={(checked): void => setCombineHistogram(checked)}
/>
</section>
</section>
</SettingsSection>
)}
</section>
{allowCreateAlerts && (
<AlertsSection onCreateAlertsHandler={onCreateAlertsHandler} />
<section className="alerts" onClick={onCreateAlertsHandler}>
<div className="left-section">
<ConciergeBell size={14} className="bell-icon" />
<Typography.Text className="alerts-text">Alerts</Typography.Text>
<SquareArrowOutUpRight size={10} className="info-icon" />
</div>
<Plus size={14} className="plus-icon" />
</section>
)}
{allowContextLinks && (
<ContextLinksSection
contextLinks={contextLinks}
setContextLinks={setContextLinks}
selectedWidget={selectedWidget}
/>
<SettingsSection
title="Context Links"
icon={<Link size={14} />}
defaultOpen={!!contextLinks.linksData.length}
>
<ContextLinks
contextLinks={contextLinks}
setContextLinks={setContextLinks}
selectedWidget={selectedWidget}
/>
</SettingsSection>
)}
{allowThreshold && (
<ThresholdsSection
thresholds={thresholds}
setThresholds={setThresholds}
yAxisUnit={yAxisUnit}
selectedGraph={selectedGraph}
columnUnits={columnUnits}
/>
<SettingsSection
title="Thresholds"
icon={<Antenna size={14} />}
defaultOpen={!!thresholds.length}
>
<ThresholdSelector
thresholds={thresholds}
setThresholds={setThresholds}
yAxisUnit={yAxisUnit}
selectedGraph={selectedGraph}
columnUnits={columnUnits}
/>
</SettingsSection>
)}
</div>
);

View File

@@ -23,7 +23,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/gorilla/mux"
)
@@ -238,13 +238,13 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
func newSecuritySchemes(role types.Role) []handler.OpenAPISecurityScheme {
return []handler.OpenAPISecurityScheme{
{Name: authtypes.IdentNProviderAPIkey.StringValue(), Scopes: []string{role.String()}},
{Name: authtypes.IdentNProviderTokenizer.StringValue(), Scopes: []string{role.String()}},
{Name: ctxtypes.AuthTypeAPIKey.StringValue(), Scopes: []string{role.String()}},
{Name: ctxtypes.AuthTypeTokenizer.StringValue(), Scopes: []string{role.String()}},
}
}
func newAnonymousSecuritySchemes(scopes []string) []handler.OpenAPISecurityScheme {
return []handler.OpenAPISecurityScheme{
{Name: authtypes.IdentNProviderAnonymous.StringValue(), Scopes: scopes},
{Name: ctxtypes.AuthTypeAnonymous.StringValue(), Scopes: scopes},
}
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/gorilla/mux"
)
@@ -72,7 +73,7 @@ func (provider *provider) addSessionRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: ctxtypes.AuthTypeTokenizer.StringValue()}},
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -5,7 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/gorilla/mux"
)
@@ -208,7 +208,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: ctxtypes.AuthTypeTokenizer.StringValue()}},
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -30,5 +30,5 @@ func (a *AuthN) Authenticate(ctx context.Context, email string, password string,
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
}
return authtypes.NewIdentity(user.ID, orgID, user.Email, user.Role, authtypes.IdentNProviderTokenizer), nil
return authtypes.NewIdentity(user.ID, orgID, user.Email, user.Role), nil
}

View File

@@ -0,0 +1,143 @@
package middleware
import (
"context"
"log/slog"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/sync/singleflight"
)
const (
apiKeyCrossOrgMessage string = "::API-KEY-CROSS-ORG::"
)
type APIKey struct {
store sqlstore.SQLStore
uuid *authtypes.UUID
headers []string
logger *slog.Logger
sharder sharder.Sharder
sfGroup *singleflight.Group
}
func NewAPIKey(store sqlstore.SQLStore, headers []string, logger *slog.Logger, sharder sharder.Sharder) *APIKey {
return &APIKey{
store: store,
uuid: authtypes.NewUUID(),
headers: headers,
logger: logger,
sharder: sharder,
sfGroup: &singleflight.Group{},
}
}
func (a *APIKey) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var values []string
var apiKeyToken string
var apiKey types.StorableAPIKey
for _, header := range a.headers {
values = append(values, r.Header.Get(header))
}
ctx, err := a.uuid.ContextFromRequest(r.Context(), values...)
if err != nil {
next.ServeHTTP(w, r)
return
}
apiKeyToken, ok := authtypes.UUIDFromContext(ctx)
if !ok {
next.ServeHTTP(w, r)
return
}
err = a.
store.
BunDB().
NewSelect().
Model(&apiKey).
Where("token = ?", apiKeyToken).
Scan(r.Context())
if err != nil {
next.ServeHTTP(w, r)
return
}
// allow the APIKey if expires_at is not set
if apiKey.ExpiresAt.Before(time.Now()) && !apiKey.ExpiresAt.Equal(types.NEVER_EXPIRES) {
next.ServeHTTP(w, r)
return
}
// get user from db
user := types.User{}
err = a.store.BunDB().NewSelect().Model(&user).Where("id = ?", apiKey.UserID).Scan(r.Context())
if err != nil {
next.ServeHTTP(w, r)
return
}
jwt := authtypes.Claims{
UserID: user.ID.String(),
Role: apiKey.Role,
Email: user.Email.String(),
OrgID: user.OrgID.String(),
}
ctx = authtypes.NewContextWithClaims(ctx, jwt)
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
next.ServeHTTP(w, r)
return
}
if err := a.sharder.IsMyOwnedKey(r.Context(), types.NewOrganizationKey(valuer.MustNewUUID(claims.OrgID))); err != nil {
a.logger.ErrorContext(r.Context(), apiKeyCrossOrgMessage, "claims", claims, "error", err)
next.ServeHTTP(w, r)
return
}
ctx = ctxtypes.SetAuthType(ctx, ctxtypes.AuthTypeAPIKey)
comment := ctxtypes.CommentFromContext(ctx)
comment.Set("auth_type", ctxtypes.AuthTypeAPIKey.StringValue())
comment.Set("user_id", claims.UserID)
comment.Set("org_id", claims.OrgID)
r = r.WithContext(ctxtypes.NewContextWithComment(ctx, comment))
next.ServeHTTP(w, r)
lastUsedCtx := context.WithoutCancel(r.Context())
_, _, _ = a.sfGroup.Do(apiKey.ID.StringValue(), func() (any, error) {
apiKey.LastUsed = time.Now()
_, err = a.
store.
BunDB().
NewUpdate().
Model(&apiKey).
Column("last_used").
Where("token = ?", apiKeyToken).
Where("revoked = false").
Exec(lastUsedCtx)
if err != nil {
a.logger.ErrorContext(lastUsedCtx, "failed to update last used of api key", "error", err)
}
return true, nil
})
})
}

View File

@@ -0,0 +1,150 @@
package middleware
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/tokenizer"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"golang.org/x/sync/singleflight"
)
const (
authCrossOrgMessage string = "::AUTH-CROSS-ORG::"
)
type AuthN struct {
tokenizer tokenizer.Tokenizer
headers []string
sharder sharder.Sharder
logger *slog.Logger
sfGroup *singleflight.Group
}
func NewAuthN(headers []string, sharder sharder.Sharder, tokenizer tokenizer.Tokenizer, logger *slog.Logger) *AuthN {
return &AuthN{
headers: headers,
sharder: sharder,
tokenizer: tokenizer,
logger: logger,
sfGroup: &singleflight.Group{},
}
}
func (a *AuthN) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var values []string
for _, header := range a.headers {
values = append(values, r.Header.Get(header))
}
ctx, err := a.contextFromRequest(r.Context(), values...)
if err != nil {
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
return
}
r = r.WithContext(ctx)
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
next.ServeHTTP(w, r)
return
}
if err := a.sharder.IsMyOwnedKey(r.Context(), types.NewOrganizationKey(valuer.MustNewUUID(claims.OrgID))); err != nil {
a.logger.ErrorContext(r.Context(), authCrossOrgMessage, "claims", claims, "error", err)
next.ServeHTTP(w, r)
return
}
ctx = ctxtypes.SetAuthType(ctx, ctxtypes.AuthTypeTokenizer)
comment := ctxtypes.CommentFromContext(ctx)
comment.Set("auth_type", ctxtypes.AuthTypeTokenizer.StringValue())
comment.Set("tokenizer_provider", a.tokenizer.Config().Provider)
comment.Set("user_id", claims.UserID)
comment.Set("org_id", claims.OrgID)
r = r.WithContext(ctxtypes.NewContextWithComment(ctx, comment))
next.ServeHTTP(w, r)
accessToken, err := authtypes.AccessTokenFromContext(r.Context())
if err != nil {
next.ServeHTTP(w, r)
return
}
lastObservedAtCtx := context.WithoutCancel(r.Context())
_, _, _ = a.sfGroup.Do(accessToken, func() (any, error) {
if err := a.tokenizer.SetLastObservedAt(lastObservedAtCtx, accessToken, time.Now()); err != nil {
a.logger.ErrorContext(lastObservedAtCtx, "failed to set last observed at", "error", err)
return false, err
}
return true, nil
})
})
}
func (a *AuthN) contextFromRequest(ctx context.Context, values ...string) (context.Context, error) {
ctx, err := a.contextFromAccessToken(ctx, values...)
if err != nil {
return ctx, err
}
accessToken, err := authtypes.AccessTokenFromContext(ctx)
if err != nil {
return ctx, err
}
authenticatedUser, err := a.tokenizer.GetIdentity(ctx, accessToken)
if err != nil {
return ctx, err
}
return authtypes.NewContextWithClaims(ctx, authenticatedUser.ToClaims()), nil
}
func (a *AuthN) contextFromAccessToken(ctx context.Context, values ...string) (context.Context, error) {
var value string
for _, v := range values {
if v != "" {
value = v
break
}
}
if value == "" {
return ctx, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "missing authorization header")
}
// parse from
bearerToken, ok := parseBearerAuth(value)
if !ok {
// this will take care that if the value is not of type bearer token, directly use it
bearerToken = value
}
return authtypes.NewContextWithAccessToken(ctx, bearerToken), nil
}
func parseBearerAuth(auth string) (string, bool) {
const prefix = "Bearer "
// Case insensitive prefix match
if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
return "", false
}
return auth[len(prefix):], true
}

View File

@@ -44,7 +44,7 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && (authtype == authtypes.IdentNProviderAPIkey.StringValue()) {
if ok && authtype == ctxtypes.AuthTypeAPIKey.StringValue() {
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
@@ -96,7 +96,7 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && (authtype == authtypes.IdentNProviderAPIkey.StringValue()) {
if ok && authtype == ctxtypes.AuthTypeAPIKey.StringValue() {
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
@@ -147,7 +147,7 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && (authtype == authtypes.IdentNProviderAPIkey.StringValue()) {
if ok && authtype == ctxtypes.AuthTypeAPIKey.StringValue() {
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)

View File

@@ -1,75 +0,0 @@
package middleware
import (
"context"
"log/slog"
"net/http"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
identityCrossOrgMessage string = "::IDENTITY-CROSS-ORG::"
)
type IdentN struct {
resolver identn.IdentNResolver
sharder sharder.Sharder
logger *slog.Logger
}
func NewIdentN(resolver identn.IdentNResolver, sharder sharder.Sharder, logger *slog.Logger) *IdentN {
return &IdentN{
resolver: resolver,
sharder: sharder,
logger: logger,
}
}
func (m *IdentN) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idn := m.resolver.GetIdentN(r)
if idn == nil {
next.ServeHTTP(w, r)
return
}
if pre, ok := idn.(identn.IdentNWithPreHook); ok {
r = pre.Pre(r)
}
identity, err := idn.GetIdentity(r)
if err != nil {
next.ServeHTTP(w, r)
return
}
ctx := r.Context()
claims := identity.ToClaims()
if err := m.sharder.IsMyOwnedKey(ctx, types.NewOrganizationKey(valuer.MustNewUUID(claims.OrgID))); err != nil {
m.logger.ErrorContext(ctx, identityCrossOrgMessage, "claims", claims, "error", err)
next.ServeHTTP(w, r)
return
}
ctx = authtypes.NewContextWithClaims(ctx, claims)
comment := ctxtypes.CommentFromContext(ctx)
comment.Set("identn_provider", claims.IdentNProvider)
comment.Set("user_id", claims.UserID)
comment.Set("org_id", claims.OrgID)
ctx = ctxtypes.NewContextWithComment(ctx, comment)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
if hook, ok := idn.(identn.IdentNWithPostHook); ok {
hook.Post(context.WithoutCancel(r.Context()), r, claims)
}
})
}

View File

@@ -1,131 +0,0 @@
package apikeyidentn
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"golang.org/x/sync/singleflight"
)
// todo: will move this in types layer with service account integration
type apiKeyTokenKey struct{}
type resolver struct {
store sqlstore.SQLStore
headers []string
settings factory.ScopedProviderSettings
sfGroup *singleflight.Group
}
func New(providerSettings factory.ProviderSettings, store sqlstore.SQLStore, headers []string) identn.IdentN {
return &resolver{
store: store,
headers: headers,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/apikeyidentn"),
sfGroup: &singleflight.Group{},
}
}
func (r *resolver) Name() authtypes.IdentNProvider {
return authtypes.IdentNProviderAPIkey
}
func (r *resolver) Test(req *http.Request) bool {
for _, header := range r.headers {
if req.Header.Get(header) != "" {
return true
}
}
return false
}
func (r *resolver) Pre(req *http.Request) *http.Request {
token := r.extractToken(req)
if token == "" {
return req
}
ctx := context.WithValue(req.Context(), apiKeyTokenKey{}, token)
return req.WithContext(ctx)
}
func (r *resolver) GetIdentity(req *http.Request) (*authtypes.Identity, error) {
ctx := req.Context()
apiKeyToken, ok := ctx.Value(apiKeyTokenKey{}).(string)
if !ok || apiKeyToken == "" {
return nil, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "missing api key")
}
var apiKey types.StorableAPIKey
err := r.store.
BunDB().
NewSelect().
Model(&apiKey).
Where("token = ?", apiKeyToken).
Scan(ctx)
if err != nil {
return nil, err
}
if apiKey.ExpiresAt.Before(time.Now()) && !apiKey.ExpiresAt.Equal(types.NEVER_EXPIRES) {
return nil, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "api key has expired")
}
var user types.User
err = r.store.
BunDB().
NewSelect().
Model(&user).
Where("id = ?", apiKey.UserID).
Scan(ctx)
if err != nil {
return nil, err
}
identity := authtypes.Identity{
UserID: user.ID,
Role: apiKey.Role,
Email: user.Email,
OrgID: user.OrgID,
}
return &identity, nil
}
func (r *resolver) Post(ctx context.Context, _ *http.Request, _ authtypes.Claims) {
apiKeyToken, ok := ctx.Value(apiKeyTokenKey{}).(string)
if !ok || apiKeyToken == "" {
return
}
_, _, _ = r.sfGroup.Do(apiKeyToken, func() (any, error) {
_, err := r.store.
BunDB().
NewUpdate().
Model(new(types.StorableAPIKey)).
Set("last_used = ?", time.Now()).
Where("token = ?", apiKeyToken).
Where("revoked = false").
Exec(ctx)
if err != nil {
r.settings.Logger().ErrorContext(ctx, "failed to update last used of api key", "error", err)
}
return true, nil
})
}
func (r *resolver) extractToken(req *http.Request) string {
for _, header := range r.headers {
if v := req.Header.Get(header); v != "" {
return v
}
}
return ""
}

View File

@@ -1,43 +0,0 @@
package identn
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
type IdentNResolver interface {
// GetIdentN returns the first IdentN whose Test() returns true for the request.
// Returns nil if no resolver matched.
GetIdentN(r *http.Request) IdentN
}
type IdentN interface {
// Test checks if this identN can handle the request.
// This should be a cheap check (e.g., header presence) with no I/O.
Test(r *http.Request) bool
// GetIdentity returns the resolved identity.
// Only called when Test() returns true.
GetIdentity(r *http.Request) (*authtypes.Identity, error)
Name() authtypes.IdentNProvider
}
// IdentNWithPreHook is optionally implemented by resolvers that need to
// enrich the request before authentication (e.g., storing the access token
// in context so downstream handlers can use it even on auth failure).
type IdentNWithPreHook interface {
IdentN
Pre(r *http.Request) *http.Request
}
// IdentNWithPostHook is optionally implemented by resolvers that need
// post-response side-effects (e.g., updating last_observed_at).
type IdentNWithPostHook interface {
IdentN
Post(ctx context.Context, r *http.Request, claims authtypes.Claims)
}

View File

@@ -1,31 +0,0 @@
package identn
import (
"net/http"
"github.com/SigNoz/signoz/pkg/factory"
)
type identNResolver struct {
identNs []IdentN
settings factory.ScopedProviderSettings
}
func NewIdentNResolver(providerSettings factory.ProviderSettings, identNs ...IdentN) IdentNResolver {
return &identNResolver{
identNs: identNs,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn"),
}
}
// GetIdentN returns the first IdentN whose Test() returns true.
// Returns nil if no resolver matched.
func (c *identNResolver) GetIdentN(r *http.Request) IdentN {
for _, idn := range c.identNs {
if idn.Test(r) {
c.settings.Logger().DebugContext(r.Context(), "identN matched", "provider", idn.Name())
return idn
}
}
return nil
}

View File

@@ -1,103 +0,0 @@
package tokenizeridentn
import (
"context"
"net/http"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/tokenizer"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"golang.org/x/sync/singleflight"
)
type resolver struct {
tokenizer tokenizer.Tokenizer
headers []string
settings factory.ScopedProviderSettings
sfGroup *singleflight.Group
}
func New(providerSettings factory.ProviderSettings, tokenizer tokenizer.Tokenizer, headers []string) identn.IdentN {
return &resolver{
tokenizer: tokenizer,
headers: headers,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/tokenizeridentn"),
sfGroup: &singleflight.Group{},
}
}
func (r *resolver) Name() authtypes.IdentNProvider {
return authtypes.IdentNProviderTokenizer
}
func (r *resolver) Test(req *http.Request) bool {
for _, header := range r.headers {
if req.Header.Get(header) != "" {
return true
}
}
return false
}
func (r *resolver) Pre(req *http.Request) *http.Request {
accessToken := r.extractToken(req)
if accessToken == "" {
return req
}
ctx := authtypes.NewContextWithAccessToken(req.Context(), accessToken)
return req.WithContext(ctx)
}
func (r *resolver) GetIdentity(req *http.Request) (*authtypes.Identity, error) {
ctx := req.Context()
accessToken, err := authtypes.AccessTokenFromContext(ctx)
if err != nil {
return nil, err
}
return r.tokenizer.GetIdentity(ctx, accessToken)
}
func (r *resolver) Post(ctx context.Context, _ *http.Request, _ authtypes.Claims) {
accessToken, err := authtypes.AccessTokenFromContext(ctx)
if err != nil {
return
}
_, _, _ = r.sfGroup.Do(accessToken, func() (any, error) {
if err := r.tokenizer.SetLastObservedAt(ctx, accessToken, time.Now()); err != nil {
r.settings.Logger().ErrorContext(ctx, "failed to set last observed at", "error", err)
return false, err
}
return true, nil
})
}
func (r *resolver) extractToken(req *http.Request) string {
var value string
for _, header := range r.headers {
if v := req.Header.Get(header); v != "" {
value = v
break
}
}
accessToken, ok := r.parseBearerAuth(value)
if !ok {
return value
}
return accessToken
}
func (r *resolver) parseBearerAuth(auth string) (string, bool) {
const prefix = "Bearer "
if len(auth) < len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
return "", false
}
return auth[len(prefix):], true
}

View File

@@ -0,0 +1,65 @@
package cloudintegration
import (
"context"
"net/http"
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
CreateAccount(ctx context.Context, account *citypes.Account) error
// GetAccount returns cloud integration account
GetAccount(ctx context.Context, orgID, accountID valuer.UUID) (*citypes.Account, error)
// GetAccounts lists accounts where agent is connected
GetAccounts(ctx context.Context, orgID valuer.UUID) ([]*citypes.Account, error)
// UpdateAccount updates the cloud integration account for a specific organization.
UpdateAccount(ctx context.Context, account *citypes.Account) error
// DisconnectAccount soft deletes/removes a cloud integration account.
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID) error
// GetConnectionArtifact returns cloud provider specific connection information,
// client side handles how this information is shown
GetConnectionArtifact(ctx context.Context, account *citypes.Account, req *citypes.ConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
// GetServicesMetadata returns list of services metadata for a cloud provider attached with the integrationID.
// This just returns a summary of the service and not the whole service definition
GetServicesMetadata(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID) ([]*citypes.ServiceMetadata, error)
// GetService returns service definition details for a serviceID. This returns config and
// other details required to show in service details page on web client.
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID string) (*citypes.Service, error)
// UpdateService updates cloud integration service
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService) error
// AgentCheckIn is called by agent to heartbeat and get latest config in response.
AgentCheckIn(ctx context.Context, orgID valuer.UUID, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
// GetDashboardByID returns dashboard JSON for a given dashboard id.
// this only returns the dashboard when the service (embedded in dashboard id) is enabled
// in the org for any cloud integration account
GetDashboardByID(ctx context.Context, orgID valuer.UUID, id string) (*dashboardtypes.Dashboard, error)
// GetAllDashboards returns list of dashboards across all connected cloud integration accounts
// for enabled services in the org. This list gets added to dashboard list page
GetAllDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error)
}
type Handler interface {
GetConnectionArtifact(http.ResponseWriter, *http.Request)
GetAccounts(http.ResponseWriter, *http.Request)
GetAccount(http.ResponseWriter, *http.Request)
UpdateAccount(http.ResponseWriter, *http.Request)
DisconnectAccount(http.ResponseWriter, *http.Request)
GetServicesMetadata(http.ResponseWriter, *http.Request)
GetService(http.ResponseWriter, *http.Request)
UpdateService(http.ResponseWriter, *http.Request)
AgentCheckIn(http.ResponseWriter, *http.Request)
}

View File

@@ -0,0 +1,134 @@
package implcloudintegration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type store struct {
store sqlstore.SQLStore
}
func NewStore(sqlStore sqlstore.SQLStore) cloudintegrationtypes.Store {
return &store{store: sqlStore}
}
func (s *store) GetAccountByID(ctx context.Context, orgID, id valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := s.store.BunDBCtx(ctx).NewSelect().Model(account).
Where("id = ?", id).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Scan(ctx)
if err != nil {
return nil, s.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "cloud integration account with id %s not found", id)
}
return account, nil
}
func (s *store) CreateAccount(ctx context.Context, account *cloudintegrationtypes.StorableCloudIntegration) (*cloudintegrationtypes.StorableCloudIntegration, error) {
_, err := s.store.BunDBCtx(ctx).NewInsert().Model(account).Exec(ctx)
if err != nil {
return nil, s.store.WrapAlreadyExistsErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyExists, "cloud integration account with id %s already exists", account.ID)
}
return account, nil
}
func (s *store) UpdateAccount(ctx context.Context, account *cloudintegrationtypes.StorableCloudIntegration) error {
_, err := s.store.BunDBCtx(ctx).
NewUpdate().
Model(account).
WherePK().
Where("org_id = ?", account.OrgID).
Where("provider = ?", account.Provider).
Exec(ctx)
return err
}
func (s *store) RemoveAccount(ctx context.Context, orgID, id valuer.UUID, provider cloudintegrationtypes.CloudProviderType) error {
_, err := s.store.BunDBCtx(ctx).NewUpdate().Model(new(cloudintegrationtypes.StorableCloudIntegration)).
Set("removed_at = ?", time.Now()).
Where("id = ?", id).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Exec(ctx)
return err
}
func (s *store) GetConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.StorableCloudIntegration, error) {
var accounts []*cloudintegrationtypes.StorableCloudIntegration
err := s.store.BunDBCtx(ctx).NewSelect().Model(&accounts).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Where("removed_at IS NULL").
Where("account_id IS NOT NULL").
Where("last_agent_report IS NOT NULL").
Order("created_at ASC").
Scan(ctx)
if err != nil {
return nil, err
}
return accounts, nil
}
func (s *store) GetConnectedAccount(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, providerAccountID string) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := s.store.BunDBCtx(ctx).NewSelect().Model(account).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Where("account_id = ?", providerAccountID).
Where("last_agent_report IS NOT NULL").
Where("removed_at IS NULL").
Scan(ctx)
if err != nil {
return nil, s.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "connected account with provider account id %s not found", providerAccountID)
}
return account, nil
}
func (s *store) GetServiceByServiceID(ctx context.Context, cloudIntegrationID valuer.UUID, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.StorableCloudIntegrationService, error) {
service := new(cloudintegrationtypes.StorableCloudIntegrationService)
err := s.store.BunDBCtx(ctx).NewSelect().Model(service).
Where("cloud_integration_id = ?", cloudIntegrationID).
Where("type = ?", serviceID).
Scan(ctx)
if err != nil {
return nil, s.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "cloud integration service with id %s not found", serviceID)
}
return service, nil
}
func (s *store) CreateService(ctx context.Context, service *cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.StorableCloudIntegrationService, error) {
_, err := s.store.BunDBCtx(ctx).NewInsert().Model(service).Exec(ctx)
if err != nil {
return nil, s.store.WrapAlreadyExistsErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationServiceAlreadyExists, "cloud integration service with id %s already exists for integration account", service.Type)
}
return service, nil
}
func (s *store) UpdateService(ctx context.Context, service *cloudintegrationtypes.StorableCloudIntegrationService) error {
_, err := s.store.BunDBCtx(ctx).NewUpdate().Model(service).
WherePK().
Where("cloud_integration_id = ?", service.CloudIntegrationID).
Where("type = ?", service.Type).
Exec(ctx)
return err
}
func (s *store) GetServices(ctx context.Context, cloudIntegrationID valuer.UUID) ([]*cloudintegrationtypes.StorableCloudIntegrationService, error) {
var services []*cloudintegrationtypes.StorableCloudIntegrationService
err := s.store.BunDBCtx(ctx).NewSelect().Model(&services).
Where("cloud_integration_id = ?", cloudIntegrationID).
Scan(ctx)
if err != nil {
return nil, err
}
return services, nil
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
@@ -108,7 +109,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
diff := 0
// Allow multiple deletions for API key requests; enforce for others
if claims.IdentNProvider == authtypes.IdentNProviderTokenizer.StringValue() {
if authType, ok := ctxtypes.AuthTypeFromContext(ctx); ok && authType == ctxtypes.AuthTypeTokenizer {
diff = 1
}

View File

@@ -158,7 +158,7 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
return "", errors.WithAdditionalf(err, "root user can only authenticate via password")
}
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewIdentity(user.ID, user.OrgID, user.Email, user.Role, authtypes.IdentNProviderTokenizer), map[string]string{})
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewIdentity(user.ID, user.OrgID, user.Email, user.Role), map[string]string{})
if err != nil {
return "", err
}

View File

@@ -196,12 +196,13 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
}),
otelmux.WithPublicEndpoint(),
))
r.Use(middleware.NewIdentN(s.signoz.IdentNResolver, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAuthN([]string{"Authorization", "Sec-WebSocket-Protocol"}, s.signoz.Sharder, s.signoz.Tokenizer, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
s.config.APIServer.Timeout.ExcludedRoutes,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewAPIKey(s.signoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.signoz.Instrumentation.Logger(), s.signoz.Sharder).Wrap)
r.Use(middleware.NewLogging(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewComment().Wrap)

View File

@@ -26,7 +26,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/swaggest/jsonschema-go"
"github.com/swaggest/openapi-go"
@@ -82,8 +82,8 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
reflector.SpecSchema().SetTitle("SigNoz")
reflector.SpecSchema().SetDescription("OpenTelemetry-Native Logs, Metrics and Traces in a single pane")
reflector.SpecSchema().SetAPIKeySecurity(authtypes.IdentNProviderAPIkey.StringValue(), "SigNoz-Api-Key", openapi.InHeader, "API Keys")
reflector.SpecSchema().SetHTTPBearerTokenSecurity(authtypes.IdentNProviderTokenizer.StringValue(), "Tokenizer", "Tokens generated by the tokenizer")
reflector.SpecSchema().SetAPIKeySecurity(ctxtypes.AuthTypeAPIKey.StringValue(), "SigNoz-Api-Key", openapi.InHeader, "API Keys")
reflector.SpecSchema().SetHTTPBearerTokenSecurity(ctxtypes.AuthTypeTokenizer.StringValue(), "Tokenizer", "Tokens generated by the tokenizer")
collector := handler.NewOpenAPICollector(reflector)

View File

@@ -172,7 +172,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateRulesV4ToV5Factory(sqlstore, telemetryStore),
sqlmigration.NewAddStatusUserFactory(sqlstore, sqlschema),
sqlmigration.NewDeprecateUserInviteFactory(sqlstore, sqlschema),
sqlmigration.NewFixCloudIntegrationUniqueIndexFactory(sqlstore, sqlschema),
)
}

View File

@@ -16,9 +16,6 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/identn/apikeyidentn"
"github.com/SigNoz/signoz/pkg/identn/tokenizeridentn"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
@@ -68,7 +65,6 @@ type SigNoz struct {
Sharder sharder.Sharder
StatsReporter statsreporter.StatsReporter
Tokenizer pkgtokenizer.Tokenizer
IdentNResolver identn.IdentNResolver
Authz authz.AuthZ
Modules Modules
Handlers Handlers
@@ -394,11 +390,6 @@ func New(
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter)
// Initialize identN resolver
tokenizeridentN := tokenizeridentn.New(providerSettings, tokenizer, []string{"Authorization", "Sec-WebSocket-Protocol"})
apikeyIdentN := apikeyidentn.New(providerSettings, sqlstore, []string{"SIGNOZ-API-KEY"})
identNResolver := identn.NewIdentNResolver(providerSettings, tokenizeridentN, apikeyIdentN)
userService := impluser.NewService(providerSettings, impluser.NewStore(sqlstore, providerSettings), modules.User, orgGetter, authz, config.User.Root)
// Initialize the querier handler via callback (allows EE to decorate with anomaly detection)
@@ -477,7 +468,6 @@ func New(
Emailing: emailing,
Sharder: sharder,
Tokenizer: tokenizer,
IdentNResolver: identNResolver,
Authz: authz,
Modules: modules,
Handlers: handlers,

View File

@@ -1,252 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type fixCloudIntegrationUniqueIndex struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewFixCloudIntegrationUniqueIndexFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("fix_cloud_integration_index"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &fixCloudIntegrationUniqueIndex{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
},
)
}
func (migration *fixCloudIntegrationUniqueIndex) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
type cloudIntegrationRow struct {
bun.BaseModel `bun:"table:cloud_integration"`
ID string `bun:"id"`
AccountID string `bun:"account_id"`
Provider string `bun:"provider"`
OrgID string `bun:"org_id"`
Config string `bun:"config"`
UpdatedAt time.Time `bun:"updated_at"`
}
type cloudIntegrationAccountConfig struct {
Regions []string `json:"regions"`
}
// duplicateGroup holds the keeper (first element) and losers (rest) for a duplicate (account_id, provider, org_id) group.
type duplicateGroup struct {
keeper *cloudIntegrationRow
losers []*cloudIntegrationRow
}
func (migration *fixCloudIntegrationUniqueIndex) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
// Step 1: Drop the wrong index on (id, provider, org_id)
dropSqls := migration.sqlschema.Operator().DropIndex(
(&sqlschema.UniqueIndex{
TableName: "cloud_integration",
ColumnNames: []sqlschema.ColumnName{"id", "provider", "org_id"},
}).Named("unique_cloud_integration"),
)
for _, sql := range dropSqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
// Step 2: Fetch all active rows with non-null account_id, ordered for grouping
var activeRows []*cloudIntegrationRow
err = tx.NewSelect().
Model(&activeRows).
Where("removed_at IS NULL").
Where("account_id IS NOT NULL").
OrderExpr("account_id, provider, org_id, updated_at DESC").
Scan(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
// Group by (account_id, provider, org_id)
groups := groupCloudIntegrationRows(activeRows)
now := time.Now()
var loserIDs []string
for _, group := range groups {
if len(group.losers) == 0 {
continue
}
// Step 3: Merge config from losers into keeper
if err = mergeCloudIntegrationConfigs(ctx, tx, group); err != nil {
return err
}
// Step 4: Reassign orphaned cloud_integration_service rows
for _, loser := range group.losers {
// Delete services from loser that would conflict with keeper's services (same type)
_, err = tx.NewDelete().
TableExpr("cloud_integration_service").
Where("cloud_integration_id = ?", loser.ID).
Where("type IN (?)",
tx.NewSelect().
TableExpr("cloud_integration_service").
Column("type").
Where("cloud_integration_id = ?", group.keeper.ID),
).
Exec(ctx)
if err != nil {
return err
}
// Reassign remaining services to the keeper
_, err = tx.ExecContext(ctx,
"UPDATE cloud_integration_service SET cloud_integration_id = ? WHERE cloud_integration_id = ?",
group.keeper.ID, loser.ID,
)
if err != nil {
return err
}
loserIDs = append(loserIDs, loser.ID)
}
}
// Step 5: Soft-delete all loser rows
if len(loserIDs) > 0 {
_, err = tx.NewUpdate().
TableExpr("cloud_integration").
Set("removed_at = ?", now).
Set("updated_at = ?", now).
Where("id IN (?)", bun.In(loserIDs)).
Exec(ctx)
if err != nil {
return err
}
}
// Step 6: Create correct partial unique index on (account_id, provider, org_id) WHERE removed_at IS NULL
createSqls := migration.sqlschema.Operator().CreateIndex(
&sqlschema.PartialUniqueIndex{
TableName: "cloud_integration",
ColumnNames: []sqlschema.ColumnName{"account_id", "provider", "org_id"},
Where: "removed_at IS NULL",
},
)
for _, sql := range createSqls {
if _, err = tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *fixCloudIntegrationUniqueIndex) Down(ctx context.Context, db *bun.DB) error {
return nil
}
// groupCloudIntegrationRows groups rows by (account_id, provider, org_id).
// Rows must be pre-sorted by account_id, provider, org_id, updated_at DESC
// so the first row in each group is the keeper (most recently updated).
func groupCloudIntegrationRows(rows []*cloudIntegrationRow) []duplicateGroup {
if len(rows) == 0 {
return nil
}
var groups []duplicateGroup
var current duplicateGroup
current.keeper = rows[0]
for i := 1; i < len(rows); i++ {
row := rows[i]
if row.AccountID == current.keeper.AccountID &&
row.Provider == current.keeper.Provider &&
row.OrgID == current.keeper.OrgID {
current.losers = append(current.losers, row)
} else {
groups = append(groups, current)
current = duplicateGroup{keeper: row}
}
}
groups = append(groups, current)
return groups
}
// mergeCloudIntegrationConfigs unions the EnabledRegions from all rows in the group into the keeper's config and updates
func mergeCloudIntegrationConfigs(ctx context.Context, tx bun.Tx, group duplicateGroup) error {
regionSet := make(map[string]struct{})
// Parse keeper's config
parseRegions(group.keeper.Config, regionSet)
// Parse each loser's config
for _, loser := range group.losers {
parseRegions(loser.Config, regionSet)
}
// Build merged config
mergedRegions := make([]string, 0, len(regionSet))
for region := range regionSet {
mergedRegions = append(mergedRegions, region)
}
merged := cloudIntegrationAccountConfig{Regions: mergedRegions}
mergedJSON, err := json.Marshal(merged)
if err != nil {
return err
}
// Update keeper's config
_, err = tx.NewUpdate().
TableExpr("cloud_integration").
Set("config = ?", string(mergedJSON)).
Where("id = ?", group.keeper.ID).
Exec(ctx)
return err
}
// parseRegions unmarshals a config JSON string and adds its regions to the set.
func parseRegions(configJSON string, regionSet map[string]struct{}) {
if configJSON == "" {
return
}
var config cloudIntegrationAccountConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return
}
for _, region := range config.Regions {
regionSet[region] = struct{}{}
}
}

View File

@@ -41,13 +41,6 @@ type Column struct {
Default string
}
func (column *Column) Equals(other *Column) bool {
return column.Name == other.Name &&
column.DataType == other.DataType &&
column.Nullable == other.Nullable &&
column.Default == other.Default
}
func (column *Column) ToDefinitionSQL(fmter SQLFormatter) []byte {
sql := []byte{}
@@ -136,53 +129,3 @@ func (column *Column) ToSetNotNullSQL(fmter SQLFormatter, tableName TableName) [
return sql
}
func (column *Column) ToDropNotNullSQL(fmter SQLFormatter, tableName TableName) []byte {
sql := []byte{}
sql = append(sql, "ALTER TABLE "...)
sql = fmter.AppendIdent(sql, string(tableName))
sql = append(sql, " ALTER COLUMN "...)
sql = fmter.AppendIdent(sql, string(column.Name))
sql = append(sql, " DROP NOT NULL"...)
return sql
}
func (column *Column) ToSetDefaultSQL(fmter SQLFormatter, tableName TableName) []byte {
sql := []byte{}
sql = append(sql, "ALTER TABLE "...)
sql = fmter.AppendIdent(sql, string(tableName))
sql = append(sql, " ALTER COLUMN "...)
sql = fmter.AppendIdent(sql, string(column.Name))
sql = append(sql, " SET DEFAULT "...)
sql = append(sql, column.Default...)
return sql
}
func (column *Column) ToDropDefaultSQL(fmter SQLFormatter, tableName TableName) []byte {
sql := []byte{}
sql = append(sql, "ALTER TABLE "...)
sql = fmter.AppendIdent(sql, string(tableName))
sql = append(sql, " ALTER COLUMN "...)
sql = fmter.AppendIdent(sql, string(column.Name))
sql = append(sql, " DROP DEFAULT"...)
return sql
}
func (column *Column) ToSetDataTypeSQL(fmter SQLFormatter, tableName TableName) []byte {
sql := []byte{}
sql = append(sql, "ALTER TABLE "...)
sql = fmter.AppendIdent(sql, string(tableName))
sql = append(sql, " ALTER COLUMN "...)
sql = fmter.AppendIdent(sql, string(column.Name))
sql = append(sql, " SET DATA TYPE "...)
sql = append(sql, fmter.SQLDataTypeOf(column.DataType)...)
return sql
}

View File

@@ -49,9 +49,6 @@ type Constraint interface {
// The SQL representation of the constraint.
ToDropSQL(fmter SQLFormatter, tableName TableName) []byte
// The SQL representation of the constraint.
ToCreateSQL(fmter SQLFormatter, tableName TableName) []byte
}
type PrimaryKeyConstraint struct {
@@ -142,27 +139,6 @@ func (constraint *PrimaryKeyConstraint) ToDropSQL(fmter SQLFormatter, tableName
return sql
}
func (constraint *PrimaryKeyConstraint) ToCreateSQL(fmter SQLFormatter, tableName TableName) []byte {
sql := []byte{}
sql = append(sql, "ALTER TABLE "...)
sql = fmter.AppendIdent(sql, string(tableName))
sql = append(sql, " ADD CONSTRAINT "...)
sql = fmter.AppendIdent(sql, constraint.Name(tableName))
sql = append(sql, " PRIMARY KEY ("...)
for i, column := range constraint.ColumnNames {
if i > 0 {
sql = append(sql, ", "...)
}
sql = fmter.AppendIdent(sql, string(column))
}
sql = append(sql, ")"...)
return sql
}
type ForeignKeyConstraint struct {
ReferencingColumnName ColumnName
ReferencedTableName TableName
@@ -244,24 +220,6 @@ func (constraint *ForeignKeyConstraint) ToDropSQL(fmter SQLFormatter, tableName
return sql
}
func (constraint *ForeignKeyConstraint) ToCreateSQL(fmter SQLFormatter, tableName TableName) []byte {
sql := []byte{}
sql = append(sql, "ALTER TABLE "...)
sql = fmter.AppendIdent(sql, string(tableName))
sql = append(sql, " ADD CONSTRAINT "...)
sql = fmter.AppendIdent(sql, constraint.Name(tableName))
sql = append(sql, " FOREIGN KEY ("...)
sql = fmter.AppendIdent(sql, string(constraint.ReferencingColumnName))
sql = append(sql, ") REFERENCES "...)
sql = fmter.AppendIdent(sql, string(constraint.ReferencedTableName))
sql = append(sql, " ("...)
sql = fmter.AppendIdent(sql, string(constraint.ReferencedColumnName))
sql = append(sql, ")"...)
return sql
}
// Do not use this constraint type. Instead create an index with the `UniqueIndex` type.
// The main difference between a Unique Index and a Unique Constraint is mostly semantic, with a constraint focusing more on data integrity, while an index focuses on performance.
// We choose to create unique indices because of sqlite. Dropping a unique index is directly supported whilst dropping a unique constraint requires a recreation of the table with the constraint removed.
@@ -365,7 +323,3 @@ func (constraint *UniqueConstraint) ToDropSQL(fmter SQLFormatter, tableName Tabl
return sql
}
func (constraint *UniqueConstraint) ToCreateSQL(fmter SQLFormatter, tableName TableName) []byte {
return []byte{}
}

View File

@@ -1,16 +1,14 @@
package sqlschema
import (
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
IndexTypeUnique = IndexType{s: valuer.NewString("uq")}
IndexTypeIndex = IndexType{s: valuer.NewString("ix")}
IndexTypePartialUnique = IndexType{s: valuer.NewString("puq")}
IndexTypeUnique = IndexType{s: valuer.NewString("uq")}
IndexTypeIndex = IndexType{s: valuer.NewString("ix")}
)
type IndexType struct{ s valuer.String }
@@ -23,25 +21,18 @@ type Index interface {
// The name of the index.
// - Indexes are named as `ix_<table_name>_<column_names>`. The column names are separated by underscores.
// - Unique constraints are named as `uq_<table_name>_<column_names>`. The column names are separated by underscores.
// - Partial unique indexes are named as `puq_<table_name>_<column_names>_<predicate_hash>`.
// The name is autogenerated and should not be set by the user.
Name() string
// Add name to the index. This is typically used to override the autogenerated name because the database might have a different name.
Named(name string) Index
// Returns true if the index is named. A named index is not autogenerated
IsNamed() bool
// The type of the index.
Type() IndexType
// The columns that the index is applied to.
Columns() []ColumnName
// Equals returns true if the index is equal to the other index.
Equals(other Index) bool
// The SQL representation of the index.
ToCreateSQL(fmter SQLFormatter) []byte
@@ -85,10 +76,6 @@ func (index *UniqueIndex) Named(name string) Index {
}
}
func (index *UniqueIndex) IsNamed() bool {
return index.name != ""
}
func (*UniqueIndex) Type() IndexType {
return IndexTypeUnique
}
@@ -97,14 +84,6 @@ func (index *UniqueIndex) Columns() []ColumnName {
return index.ColumnNames
}
func (index *UniqueIndex) Equals(other Index) bool {
if other.Type() != IndexTypeUnique {
return false
}
return index.Name() == other.Name() && slices.Equal(index.Columns(), other.Columns())
}
func (index *UniqueIndex) ToCreateSQL(fmter SQLFormatter) []byte {
sql := []byte{}
@@ -135,101 +114,3 @@ func (index *UniqueIndex) ToDropSQL(fmter SQLFormatter) []byte {
return sql
}
type PartialUniqueIndex struct {
TableName TableName
ColumnNames []ColumnName
Where string
name string
}
func (index *PartialUniqueIndex) Name() string {
if index.name != "" {
return index.name
}
var b strings.Builder
b.WriteString(IndexTypePartialUnique.String())
b.WriteString("_")
b.WriteString(string(index.TableName))
b.WriteString("_")
for i, column := range index.ColumnNames {
if i > 0 {
b.WriteString("_")
}
b.WriteString(string(column))
}
b.WriteString("_")
b.WriteString((&whereNormalizer{input: index.Where}).hash())
return b.String()
}
func (index *PartialUniqueIndex) Named(name string) Index {
copyOfColumnNames := make([]ColumnName, len(index.ColumnNames))
copy(copyOfColumnNames, index.ColumnNames)
return &PartialUniqueIndex{
TableName: index.TableName,
ColumnNames: copyOfColumnNames,
Where: index.Where,
name: name,
}
}
func (index *PartialUniqueIndex) IsNamed() bool {
return index.name != ""
}
func (*PartialUniqueIndex) Type() IndexType {
return IndexTypePartialUnique
}
func (index *PartialUniqueIndex) Columns() []ColumnName {
return index.ColumnNames
}
func (index *PartialUniqueIndex) Equals(other Index) bool {
if other.Type() != IndexTypePartialUnique {
return false
}
otherPartial, ok := other.(*PartialUniqueIndex)
if !ok {
return false
}
return index.Name() == other.Name() && slices.Equal(index.Columns(), other.Columns()) && (&whereNormalizer{input: index.Where}).normalize() == (&whereNormalizer{input: otherPartial.Where}).normalize()
}
func (index *PartialUniqueIndex) ToCreateSQL(fmter SQLFormatter) []byte {
sql := []byte{}
sql = append(sql, "CREATE UNIQUE INDEX IF NOT EXISTS "...)
sql = fmter.AppendIdent(sql, index.Name())
sql = append(sql, " ON "...)
sql = fmter.AppendIdent(sql, string(index.TableName))
sql = append(sql, " ("...)
for i, column := range index.ColumnNames {
if i > 0 {
sql = append(sql, ", "...)
}
sql = fmter.AppendIdent(sql, string(column))
}
sql = append(sql, ") WHERE "...)
sql = append(sql, index.Where...)
return sql
}
func (index *PartialUniqueIndex) ToDropSQL(fmter SQLFormatter) []byte {
sql := []byte{}
sql = append(sql, "DROP INDEX IF EXISTS "...)
sql = fmter.AppendIdent(sql, index.Name())
return sql
}

View File

@@ -38,110 +38,6 @@ func TestIndexToCreateSQL(t *testing.T) {
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "my_index" ON "users" ("id", "name", "email")`,
},
{
name: "PartialUnique_1Column",
index: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `"deleted_at" IS NULL`,
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "puq_users_email_94610c77" ON "users" ("email") WHERE "deleted_at" IS NULL`,
},
{
name: "PartialUnique_2Columns",
index: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"org_id", "email"},
Where: `"deleted_at" IS NULL`,
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "puq_users_org_id_email_94610c77" ON "users" ("org_id", "email") WHERE "deleted_at" IS NULL`,
},
{
name: "PartialUnique_Named",
index: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `"deleted_at" IS NULL`,
name: "my_partial_index",
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "my_partial_index" ON "users" ("email") WHERE "deleted_at" IS NULL`,
},
{
name: "PartialUnique_WhereWithParentheses",
index: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `("deleted_at" IS NULL)`,
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "puq_users_email_94610c77" ON "users" ("email") WHERE ("deleted_at" IS NULL)`,
},
{
name: "PartialUnique_WhereWithQuotedIdentifier",
index: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `"order" IS NULL`,
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "puq_users_email_14c5f5f2" ON "users" ("email") WHERE "order" IS NULL`,
},
{
name: "PartialUnique_WhereWithQuotedLiteral",
index: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `status = 'somewhere'`,
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "puq_users_email_9817c709" ON "users" ("email") WHERE status = 'somewhere'`,
},
{
name: "PartialUnique_WhereWith2Columns",
index: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email", "status"},
Where: `email = 'test@example.com' AND status = 'active'`,
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "puq_users_email_status_e70e78c3" ON "users" ("email", "status") WHERE email = 'test@example.com' AND status = 'active'`,
},
// postgres docs example
{
name: "PartialUnique_WhereWithPostgresDocsExample_1",
index: &PartialUniqueIndex{
TableName: "access_log",
ColumnNames: []ColumnName{"client_ip"},
Where: `NOT (client_ip > inet '192.168.100.0' AND client_ip < inet '192.168.100.255')`,
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "puq_access_log_client_ip_5a596410" ON "access_log" ("client_ip") WHERE NOT (client_ip > inet '192.168.100.0' AND client_ip < inet '192.168.100.255')`,
},
// postgres docs example
{
name: "PartialUnique_WhereWithPostgresDocsExample_2",
index: &PartialUniqueIndex{
TableName: "orders",
ColumnNames: []ColumnName{"order_nr"},
Where: `billed is not true`,
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "puq_orders_order_nr_6d31bb0e" ON "orders" ("order_nr") WHERE billed is not true`,
},
// sqlite docs example
{
name: "PartialUnique_WhereWithSqliteDocsExample_1",
index: &PartialUniqueIndex{
TableName: "person",
ColumnNames: []ColumnName{"team_id"},
Where: `is_team_leader`,
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "puq_person_team_id_c8604a29" ON "person" ("team_id") WHERE is_team_leader`,
},
// sqlite docs example
{
name: "PartialUnique_WhereWithSqliteDocsExample_2",
index: &PartialUniqueIndex{
TableName: "purchaseorder",
ColumnNames: []ColumnName{"parent_po"},
Where: `parent_po IS NOT NULL`,
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "puq_purchaseorder_parent_po_dbe2929d" ON "purchaseorder" ("parent_po") WHERE parent_po IS NOT NULL`,
},
}
for _, testCase := range testCases {
@@ -153,109 +49,3 @@ func TestIndexToCreateSQL(t *testing.T) {
})
}
}
func TestIndexEquals(t *testing.T) {
testCases := []struct {
name string
a Index
b Index
equals bool
}{
{
name: "PartialUnique_Same",
a: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `"deleted_at" IS NULL`,
},
b: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `"deleted_at" IS NULL`,
},
equals: true,
},
{
name: "PartialUnique_NormalizedPostgresWhere",
a: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `"deleted_at" IS NULL`,
},
b: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `(deleted_at IS NULL)`,
},
equals: true,
},
{
name: "PartialUnique_DifferentWhere",
a: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `"deleted_at" IS NULL`,
},
b: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `"active" = true`,
},
equals: false,
},
{
name: "PartialUnique_NotEqual_Unique",
a: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `"deleted_at" IS NULL`,
},
b: &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
},
equals: false,
},
{
name: "Unique_NotEqual_PartialUnique",
a: &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
},
b: &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `"deleted_at" IS NULL`,
},
equals: false,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.equals, testCase.a.Equals(testCase.b))
})
}
}
func TestPartialUniqueIndexName(t *testing.T) {
a := &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `"deleted_at" IS NULL`,
}
b := &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `(deleted_at IS NULL)`,
}
c := &PartialUniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Where: `"active" = true`,
}
assert.Equal(t, "puq_users_email_94610c77", a.Name())
assert.Equal(t, a.Name(), b.Name())
assert.NotEqual(t, a.Name(), c.Name())
}

View File

@@ -1,162 +0,0 @@
package sqlschema
import (
"fmt"
"hash/fnv"
"strings"
)
type whereNormalizer struct {
input string
}
func (n *whereNormalizer) hash() string {
hasher := fnv.New32a()
_, _ = hasher.Write([]byte(n.normalize()))
return fmt.Sprintf("%08x", hasher.Sum32())
}
func (n *whereNormalizer) normalize() string {
where := strings.TrimSpace(n.input)
where = n.stripOuterParentheses(where)
var output strings.Builder
output.Grow(len(where))
for i := 0; i < len(where); i++ {
switch where[i] {
case ' ', '\t', '\n', '\r':
if output.Len() > 0 {
last := output.String()[output.Len()-1]
if last != ' ' {
output.WriteByte(' ')
}
}
case '\'':
end := n.consumeSingleQuotedLiteral(where, i, &output)
i = end
case '"':
token, end := n.consumeDoubleQuotedToken(where, i)
output.WriteString(token)
i = end
default:
output.WriteByte(where[i])
}
}
return strings.TrimSpace(output.String())
}
func (n *whereNormalizer) stripOuterParentheses(s string) string {
for {
s = strings.TrimSpace(s)
if len(s) < 2 || s[0] != '(' || s[len(s)-1] != ')' || !n.hasWrappingParentheses(s) {
return s
}
s = s[1 : len(s)-1]
}
}
func (n *whereNormalizer) hasWrappingParentheses(s string) bool {
depth := 0
inSingleQuotedLiteral := false
inDoubleQuotedToken := false
for i := 0; i < len(s); i++ {
switch s[i] {
case '\'':
if inDoubleQuotedToken {
continue
}
if inSingleQuotedLiteral && i+1 < len(s) && s[i+1] == '\'' {
i++
continue
}
inSingleQuotedLiteral = !inSingleQuotedLiteral
case '"':
if inSingleQuotedLiteral {
continue
}
if inDoubleQuotedToken && i+1 < len(s) && s[i+1] == '"' {
i++
continue
}
inDoubleQuotedToken = !inDoubleQuotedToken
case '(':
if inSingleQuotedLiteral || inDoubleQuotedToken {
continue
}
depth++
case ')':
if inSingleQuotedLiteral || inDoubleQuotedToken {
continue
}
depth--
if depth == 0 && i != len(s)-1 {
return false
}
}
}
return depth == 0
}
func (n *whereNormalizer) consumeSingleQuotedLiteral(s string, start int, output *strings.Builder) int {
output.WriteByte(s[start])
for i := start + 1; i < len(s); i++ {
output.WriteByte(s[i])
if s[i] == '\'' {
if i+1 < len(s) && s[i+1] == '\'' {
i++
output.WriteByte(s[i])
continue
}
return i
}
}
return len(s) - 1
}
func (n *whereNormalizer) consumeDoubleQuotedToken(s string, start int) (string, int) {
var ident strings.Builder
for i := start + 1; i < len(s); i++ {
if s[i] == '"' {
if i+1 < len(s) && s[i+1] == '"' {
ident.WriteByte('"')
i++
continue
}
if n.isSimpleUnquotedIdentifier(ident.String()) {
return ident.String(), i
}
return s[start : i+1], i
}
ident.WriteByte(s[i])
}
return s[start:], len(s) - 1
}
func (n *whereNormalizer) isSimpleUnquotedIdentifier(s string) bool {
if s == "" || strings.ToLower(s) != s {
return false
}
for i := 0; i < len(s); i++ {
ch := s[i]
if (ch >= 'a' && ch <= 'z') || ch == '_' {
continue
}
if i > 0 && ch >= '0' && ch <= '9' {
continue
}
return false
}
return true
}

View File

@@ -1,57 +0,0 @@
package sqlschema
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestWhereNormalizerNormalize(t *testing.T) {
testCases := []struct {
name string
input string
output string
}{
{
name: "BooleanComparison",
input: `"active" = true`,
output: `active = true`,
},
{
name: "QuotedStringLiteralPreserved",
input: `status = 'somewhere'`,
output: `status = 'somewhere'`,
},
{
name: "EscapedStringLiteralPreserved",
input: `status = 'it''s active'`,
output: `status = 'it''s active'`,
},
{
name: "OuterParenthesesRemoved",
input: `(("deleted_at" IS NULL))`,
output: `deleted_at IS NULL`,
},
{
name: "InnerParenthesesPreserved",
input: `("deleted_at" IS NULL OR ("active" = true AND "status" = 'open'))`,
output: `deleted_at IS NULL OR (active = true AND status = 'open')`,
},
{
name: "MultipleClausesWhitespaceCollapsed",
input: " ( \"deleted_at\" IS NULL \n AND\t\"active\" = true AND status = 'open' ) ",
output: `deleted_at IS NULL AND active = true AND status = 'open'`,
},
{
name: "ComplexBooleanClauses",
input: `NOT ("deleted_at" IS NOT NULL AND ("active" = false OR "status" = 'archived'))`,
output: `NOT (deleted_at IS NOT NULL AND (active = false OR status = 'archived'))`,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.output, (&whereNormalizer{input: testCase.input}).normalize())
})
}
}

View File

@@ -1,21 +1,11 @@
package sqlschema
import (
"reflect"
"slices"
)
var _ SQLOperator = (*Operator)(nil)
type OperatorSupport struct {
// Support for creating and dropping constraints.
SCreateAndDropConstraint bool
// Support for `IF EXISTS` and `IF NOT EXISTS` in `ALTER TABLE ADD COLUMN` and `ALTER TABLE DROP COLUMN`.
SAlterTableAddAndDropColumnIfNotExistsAndExists bool
// Support for altering columns such as `ALTER TABLE ALTER COLUMN SET NOT NULL`.
SAlterTableAlterColumnSetAndDrop bool
DropConstraint bool
ColumnIfNotExistsExists bool
AlterColumnSetNotNull bool
}
type Operator struct {
@@ -35,115 +25,9 @@ func (operator *Operator) CreateTable(table *Table) [][]byte {
}
func (operator *Operator) RenameTable(table *Table, newName TableName) [][]byte {
sql := table.ToRenameSQL(operator.fmter, newName)
sqls := [][]byte{table.ToRenameSQL(operator.fmter, newName)}
table.Name = newName
return [][]byte{sql}
}
func (operator *Operator) AlterTable(oldTable *Table, oldTableUniqueConstraints []*UniqueConstraint, newTable *Table) [][]byte {
// The following has to be done in order:
// - Drop constraints
// - Drop columns (some columns might be part of constraints therefore this depends on Step 1)
// - Add columns, then modify columns
// - Rename table
// - Add constraints (some constraints might be part of columns therefore this depends on Step 3, constraint names also depend on table name which is changed in Step 4)
// - Create unique indices from unique constraints for the new table
sql := [][]byte{}
// Drop primary key constraint if it is in the old table but not in the new table.
if oldTable.PrimaryKeyConstraint != nil && newTable.PrimaryKeyConstraint == nil {
sql = append(sql, operator.DropConstraint(oldTable, oldTableUniqueConstraints, oldTable.PrimaryKeyConstraint)...)
}
// Drop primary key constraint if it is in the old table and the new table but they are different.
if oldTable.PrimaryKeyConstraint != nil && newTable.PrimaryKeyConstraint != nil && !oldTable.PrimaryKeyConstraint.Equals(newTable.PrimaryKeyConstraint) {
sql = append(sql, operator.DropConstraint(oldTable, oldTableUniqueConstraints, oldTable.PrimaryKeyConstraint)...)
}
// Drop foreign key constraints that are in the old table but not in the new table.
for _, fkConstraint := range oldTable.ForeignKeyConstraints {
if index := operator.findForeignKeyConstraint(newTable, fkConstraint); index == -1 {
sql = append(sql, operator.DropConstraint(oldTable, oldTableUniqueConstraints, fkConstraint)...)
}
}
// Drop all unique constraints.
for _, uniqueConstraint := range oldTableUniqueConstraints {
sql = append(sql, operator.DropConstraint(oldTable, oldTableUniqueConstraints, uniqueConstraint)...)
}
// Reduce number of drops for engines with no SCreateAndDropConstraint.
if !operator.support.SCreateAndDropConstraint && len(sql) > 0 {
// Do not send the unique constraints to recreate table. We will change them to indexes at the end.
sql = operator.RecreateTable(oldTable, nil)
}
// Drop columns that are in the old table but not in the new table.
for _, column := range oldTable.Columns {
if index := operator.findColumnByName(newTable, column.Name); index == -1 {
sql = append(sql, operator.DropColumn(oldTable, column)...)
}
}
// Add columns that are in the new table but not in the old table.
for _, column := range newTable.Columns {
if index := operator.findColumnByName(oldTable, column.Name); index == -1 {
sql = append(sql, operator.AddColumn(oldTable, oldTableUniqueConstraints, column, nil)...)
}
}
// Modify columns that are in the new table and in the old table
alterColumnSQLs := [][]byte{}
for _, column := range newTable.Columns {
alterColumnSQLs = append(alterColumnSQLs, operator.AlterColumn(oldTable, oldTableUniqueConstraints, column)...)
}
// Reduce number of drops for engines with no SAlterTableAlterColumnSetAndDrop.
if !operator.support.SAlterTableAlterColumnSetAndDrop && len(alterColumnSQLs) > 0 {
// Do not send the unique constraints to recreate table. We will change them to indexes at the end.
sql = append(sql, operator.RecreateTable(oldTable, nil)...)
}
if operator.support.SAlterTableAlterColumnSetAndDrop && len(alterColumnSQLs) > 0 {
sql = append(sql, alterColumnSQLs...)
}
// Check if the name has changed
if oldTable.Name != newTable.Name {
sql = append(sql, operator.RenameTable(oldTable, newTable.Name)...)
}
// If the old table does not have a primary key constraint and the new table does, we need to create it.
if oldTable.PrimaryKeyConstraint == nil {
if newTable.PrimaryKeyConstraint != nil {
sql = append(sql, operator.CreateConstraint(oldTable, oldTableUniqueConstraints, newTable.PrimaryKeyConstraint)...)
}
}
if oldTable.PrimaryKeyConstraint != nil {
if !oldTable.PrimaryKeyConstraint.Equals(newTable.PrimaryKeyConstraint) {
sql = append(sql, operator.CreateConstraint(oldTable, oldTableUniqueConstraints, newTable.PrimaryKeyConstraint)...)
}
}
// Create foreign key constraints that are in the new table but not in the old table.
for _, fkConstraint := range newTable.ForeignKeyConstraints {
if index := operator.findForeignKeyConstraint(oldTable, fkConstraint); index == -1 {
sql = append(sql, operator.CreateConstraint(oldTable, oldTableUniqueConstraints, fkConstraint)...)
}
}
// Create indices for the new table.
for _, uniqueConstraint := range oldTableUniqueConstraints {
sql = append(sql, uniqueConstraint.ToIndex(oldTable.Name).ToCreateSQL(operator.fmter))
}
// Remove duplicate SQLs.
return slices.CompactFunc(sql, func(a, b []byte) bool {
return string(a) == string(b)
})
return sqls
}
func (operator *Operator) RecreateTable(table *Table, uniqueConstraints []*UniqueConstraint) [][]byte {
@@ -180,23 +64,16 @@ func (operator *Operator) AddColumn(table *Table, uniqueConstraints []*UniqueCon
table.Columns = append(table.Columns, column)
sqls := [][]byte{
column.ToAddSQL(operator.fmter, table.Name, operator.support.SAlterTableAddAndDropColumnIfNotExistsAndExists),
column.ToAddSQL(operator.fmter, table.Name, operator.support.ColumnIfNotExistsExists),
}
// If the value is not nil, always try to update the column.
if val != nil {
sqls = append(sqls, column.ToUpdateSQL(operator.fmter, table.Name, val))
}
// If the column is not nullable and does not have a default value and no value is provided, we need to set something.
// So we set it to the zero value of the column's data type.
if !column.Nullable && column.Default == "" && val == nil {
sqls = append(sqls, column.ToUpdateSQL(operator.fmter, table.Name, column.DataType.z))
}
// If the column is not nullable, we need to set it to not null.
if !column.Nullable {
if operator.support.SAlterTableAlterColumnSetAndDrop {
if val == nil {
val = column.DataType.z
}
sqls = append(sqls, column.ToUpdateSQL(operator.fmter, table.Name, val))
if operator.support.AlterColumnSetNotNull {
sqls = append(sqls, column.ToSetNotNullSQL(operator.fmter, table.Name))
} else {
sqls = append(sqls, operator.RecreateTable(table, uniqueConstraints)...)
@@ -206,62 +83,6 @@ func (operator *Operator) AddColumn(table *Table, uniqueConstraints []*UniqueCon
return sqls
}
func (operator *Operator) AlterColumn(table *Table, uniqueConstraints []*UniqueConstraint, column *Column) [][]byte {
index := operator.findColumnByName(table, column.Name)
// If the column does not exist, we do not need to alter it.
if index == -1 {
return [][]byte{}
}
oldColumn := table.Columns[index]
// If the column is the same, we do not need to alter it.
if oldColumn.Equals(column) {
return [][]byte{}
}
sqls := [][]byte{}
var recreateTable bool
if oldColumn.DataType != column.DataType {
if operator.support.SAlterTableAlterColumnSetAndDrop {
sqls = append(sqls, column.ToSetDataTypeSQL(operator.fmter, table.Name))
} else {
recreateTable = true
}
}
if oldColumn.Nullable != column.Nullable {
if operator.support.SAlterTableAlterColumnSetAndDrop {
if column.Nullable {
sqls = append(sqls, column.ToDropNotNullSQL(operator.fmter, table.Name))
} else {
sqls = append(sqls, column.ToSetNotNullSQL(operator.fmter, table.Name))
}
} else {
recreateTable = true
}
}
if oldColumn.Default != column.Default {
if operator.support.SAlterTableAlterColumnSetAndDrop {
if column.Default != "" {
sqls = append(sqls, column.ToSetDefaultSQL(operator.fmter, table.Name))
} else {
sqls = append(sqls, column.ToDropDefaultSQL(operator.fmter, table.Name))
}
} else {
recreateTable = true
}
}
table.Columns[index] = column
if recreateTable {
sqls = append(sqls, operator.RecreateTable(table, uniqueConstraints)...)
}
return sqls
}
func (operator *Operator) DropColumn(table *Table, column *Column) [][]byte {
index := operator.findColumnByName(table, column.Name)
// If the column does not exist, we do not need to drop it.
@@ -271,48 +92,7 @@ func (operator *Operator) DropColumn(table *Table, column *Column) [][]byte {
table.Columns = append(table.Columns[:index], table.Columns[index+1:]...)
return [][]byte{column.ToDropSQL(operator.fmter, table.Name, operator.support.SAlterTableAddAndDropColumnIfNotExistsAndExists)}
}
func (operator *Operator) CreateConstraint(table *Table, uniqueConstraints []*UniqueConstraint, constraint Constraint) [][]byte {
if constraint == nil {
return [][]byte{}
}
if reflect.ValueOf(constraint).IsNil() {
return [][]byte{}
}
if constraint.Type() == ConstraintTypeForeignKey {
// Constraint already exists as foreign key constraint.
if table.ForeignKeyConstraints != nil {
for _, fkConstraint := range table.ForeignKeyConstraints {
if constraint.Equals(fkConstraint) {
return [][]byte{}
}
}
}
table.ForeignKeyConstraints = append(table.ForeignKeyConstraints, constraint.(*ForeignKeyConstraint))
}
sqls := [][]byte{}
if constraint.Type() == ConstraintTypePrimaryKey {
if operator.support.SCreateAndDropConstraint {
if table.PrimaryKeyConstraint != nil {
// TODO(grandwizard28): this is a hack to drop the primary key constraint.
// We need to find a better way to do this.
sqls = append(sqls, table.PrimaryKeyConstraint.ToDropSQL(operator.fmter, table.Name))
}
}
table.PrimaryKeyConstraint = constraint.(*PrimaryKeyConstraint)
}
if operator.support.SCreateAndDropConstraint {
return append(sqls, constraint.ToCreateSQL(operator.fmter, table.Name))
}
return operator.RecreateTable(table, uniqueConstraints)
return [][]byte{column.ToDropSQL(operator.fmter, table.Name, operator.support.ColumnIfNotExistsExists)}
}
func (operator *Operator) DropConstraint(table *Table, uniqueConstraints []*UniqueConstraint, constraint Constraint) [][]byte {
@@ -325,48 +105,20 @@ func (operator *Operator) DropConstraint(table *Table, uniqueConstraints []*Uniq
return [][]byte{}
}
if operator.support.SCreateAndDropConstraint {
if operator.support.DropConstraint {
return [][]byte{uniqueConstraints[uniqueConstraintIndex].ToDropSQL(operator.fmter, table.Name)}
}
var copyOfUniqueConstraints []*UniqueConstraint
copyOfUniqueConstraints = append(copyOfUniqueConstraints, uniqueConstraints[:uniqueConstraintIndex]...)
copyOfUniqueConstraints = append(copyOfUniqueConstraints, uniqueConstraints[uniqueConstraintIndex+1:]...)
return operator.RecreateTable(table, copyOfUniqueConstraints)
return operator.RecreateTable(table, append(uniqueConstraints[:uniqueConstraintIndex], uniqueConstraints[uniqueConstraintIndex+1:]...))
}
if operator.support.SCreateAndDropConstraint {
if operator.support.DropConstraint {
return [][]byte{toDropConstraint.ToDropSQL(operator.fmter, table.Name)}
}
return operator.RecreateTable(table, uniqueConstraints)
}
func (operator *Operator) DiffIndices(oldIndices []Index, newIndices []Index) [][]byte {
sqls := [][]byte{}
for i, oldIndex := range oldIndices {
if index := operator.findIndex(newIndices, oldIndex); index == -1 {
sqls = append(sqls, oldIndex.ToDropSQL(operator.fmter))
continue
}
if oldIndex.IsNamed() {
sqls = append(sqls, oldIndex.ToDropSQL(operator.fmter))
sqls = append(sqls, newIndices[i].ToCreateSQL(operator.fmter))
}
}
for _, newIndex := range newIndices {
if index := operator.findIndex(oldIndices, newIndex); index == -1 {
sqls = append(sqls, newIndex.ToCreateSQL(operator.fmter))
}
}
return sqls
}
func (*Operator) findColumnByName(table *Table, columnName ColumnName) int {
for i, column := range table.Columns {
if column.Name == columnName {
@@ -390,27 +142,3 @@ func (*Operator) findUniqueConstraint(uniqueConstraints []*UniqueConstraint, con
return -1
}
func (*Operator) findForeignKeyConstraint(table *Table, constraint Constraint) int {
if constraint.Type() != ConstraintTypeForeignKey {
return -1
}
for i, fkConstraint := range table.ForeignKeyConstraints {
if fkConstraint.Equals(constraint) {
return i
}
}
return -1
}
func (*Operator) findIndex(indices []Index, index Index) int {
for i, inputIndex := range indices {
if index.Equals(inputIndex) {
return i
}
}
return -1
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ package sqlitesqlschema
import (
"context"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
@@ -34,9 +33,9 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
settings: settings,
sqlstore: sqlstore,
operator: sqlschema.NewOperator(fmter, sqlschema.OperatorSupport{
SCreateAndDropConstraint: false,
SAlterTableAddAndDropColumnIfNotExistsAndExists: false,
SAlterTableAlterColumnSetAndDrop: false,
DropConstraint: false,
ColumnIfNotExistsExists: false,
AlterColumnSetNotNull: false,
}),
}, nil
}
@@ -57,7 +56,7 @@ func (provider *provider) GetTable(ctx context.Context, tableName sqlschema.Tabl
BunDB().
NewRaw("SELECT sql FROM sqlite_master WHERE type IN (?) AND tbl_name = ? AND sql IS NOT NULL", bun.In([]string{"table"}), string(tableName)).
Scan(ctx, &sql); err != nil {
return nil, nil, provider.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "table (%s) not found", tableName)
return nil, nil, err
}
table, uniqueConstraints, err := parseCreateTable(sql, provider.fmter)
@@ -74,7 +73,7 @@ func (provider *provider) GetIndices(ctx context.Context, tableName sqlschema.Ta
BunDB().
QueryContext(ctx, "SELECT * FROM PRAGMA_index_list(?)", string(tableName))
if err != nil {
return nil, provider.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "no indices for table (%s) found", tableName)
return nil, err
}
defer func() {
@@ -115,39 +114,11 @@ func (provider *provider) GetIndices(ctx context.Context, tableName sqlschema.Ta
return nil, err
}
if unique && partial {
var indexSQL string
if err := provider.
sqlstore.
BunDB().
NewRaw("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", name).
Scan(ctx, &indexSQL); err != nil {
return nil, err
}
where := extractWhereClause(indexSQL)
index := &sqlschema.PartialUniqueIndex{
if unique {
indices = append(indices, (&sqlschema.UniqueIndex{
TableName: tableName,
ColumnNames: columns,
Where: where,
}
if index.Name() == name {
indices = append(indices, index)
} else {
indices = append(indices, index.Named(name))
}
} else if unique {
index := &sqlschema.UniqueIndex{
TableName: tableName,
ColumnNames: columns,
}
if index.Name() == name {
indices = append(indices, index)
} else {
indices = append(indices, index.Named(name))
}
}).Named(name).(*sqlschema.UniqueIndex))
}
}
@@ -171,73 +142,3 @@ func (provider *provider) ToggleFKEnforcement(ctx context.Context, db bun.IDB, o
return errors.NewInternalf(errors.CodeInternal, "foreign_keys(actual: %s, expected: %s), maybe a transaction is in progress?", strconv.FormatBool(val), strconv.FormatBool(on))
}
func extractWhereClause(sql string) string {
lastWhere := -1
inSingleQuotedLiteral := false
inDoubleQuotedIdentifier := false
inBacktickQuotedIdentifier := false
inBracketQuotedIdentifier := false
for i := 0; i < len(sql); i++ {
switch sql[i] {
case '\'':
if inDoubleQuotedIdentifier || inBacktickQuotedIdentifier || inBracketQuotedIdentifier {
continue
}
if inSingleQuotedLiteral && i+1 < len(sql) && sql[i+1] == '\'' {
i++
continue
}
inSingleQuotedLiteral = !inSingleQuotedLiteral
case '"':
if inSingleQuotedLiteral || inBacktickQuotedIdentifier || inBracketQuotedIdentifier {
continue
}
if inDoubleQuotedIdentifier && i+1 < len(sql) && sql[i+1] == '"' {
i++
continue
}
inDoubleQuotedIdentifier = !inDoubleQuotedIdentifier
case '`':
if inSingleQuotedLiteral || inDoubleQuotedIdentifier || inBracketQuotedIdentifier {
continue
}
inBacktickQuotedIdentifier = !inBacktickQuotedIdentifier
case '[':
if inSingleQuotedLiteral || inDoubleQuotedIdentifier || inBacktickQuotedIdentifier || inBracketQuotedIdentifier {
continue
}
inBracketQuotedIdentifier = true
case ']':
if inBracketQuotedIdentifier {
inBracketQuotedIdentifier = false
}
}
if inSingleQuotedLiteral || inDoubleQuotedIdentifier || inBacktickQuotedIdentifier || inBracketQuotedIdentifier {
continue
}
if strings.EqualFold(sql[i:min(i+5, len(sql))], "WHERE") &&
(i == 0 || !isSQLiteIdentifierChar(sql[i-1])) &&
(i+5 == len(sql) || !isSQLiteIdentifierChar(sql[i+5])) {
lastWhere = i
i += 4
}
}
if lastWhere == -1 {
return ""
}
return strings.TrimSpace(sql[lastWhere+len("WHERE"):])
}
func isSQLiteIdentifierChar(ch byte) bool {
return (ch >= 'a' && ch <= 'z') ||
(ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') ||
ch == '_'
}

View File

@@ -1,52 +0,0 @@
package sqlitesqlschema
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestExtractWhereClause(t *testing.T) {
testCases := []struct {
name string
sql string
where string
}{
{
name: "UppercaseWhere",
sql: `CREATE UNIQUE INDEX "idx" ON "users" ("email") WHERE "deleted_at" IS NULL`,
where: `"deleted_at" IS NULL`,
},
{
name: "LowercaseWhere",
sql: `CREATE UNIQUE INDEX "idx" ON "users" ("email") where "deleted_at" IS NULL`,
where: `"deleted_at" IS NULL`,
},
{
name: "NewlineBeforeWhere",
sql: "CREATE UNIQUE INDEX \"idx\" ON \"users\" (\"email\")\nWHERE \"deleted_at\" IS NULL",
where: `"deleted_at" IS NULL`,
},
{
name: "ExtraWhitespace",
sql: "CREATE UNIQUE INDEX \"idx\" ON \"users\" (\"email\") \n \t where \"deleted_at\" IS NULL ",
where: `"deleted_at" IS NULL`,
},
{
name: "WhereInStringLiteral",
sql: `CREATE UNIQUE INDEX "idx" ON "users" ("email") WHERE status = 'somewhere'`,
where: `status = 'somewhere'`,
},
{
name: "BooleanLiteral",
sql: `CREATE UNIQUE INDEX "idx" ON "users" ("email") WHERE active = true`,
where: `active = true`,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.where, extractWhereClause(testCase.sql))
})
}
}

View File

@@ -34,10 +34,7 @@ type SQLOperator interface {
// Returns a list of SQL statements to rename a table.
RenameTable(*Table, TableName) [][]byte
// Returns a list of SQL statements to alter the input table to the new table. It converts all unique constraints to unique indices and drops the unique constraints.
AlterTable(*Table, []*UniqueConstraint, *Table) [][]byte
// Returns a list of SQL statements to recreate a table. In recreating the table, it converts all unqiue constraints to indices and copies data from the old table.
// Returns a list of SQL statements to recreate a table.
RecreateTable(*Table, []*UniqueConstraint) [][]byte
// Returns a list of SQL statements to create an index.
@@ -50,21 +47,11 @@ type SQLOperator interface {
// If the column is not nullable, the column is added with the input value, then the column is made non-nullable.
AddColumn(*Table, []*UniqueConstraint, *Column, any) [][]byte
// Returns a list of SQL statements to alter a column.
AlterColumn(*Table, []*UniqueConstraint, *Column) [][]byte
// Returns a list of SQL statements to drop a column from a table.
DropColumn(*Table, *Column) [][]byte
// Returns a list of SQL statements to create a constraint on a table.
CreateConstraint(*Table, []*UniqueConstraint, Constraint) [][]byte
// Returns a list of SQL statements to drop a constraint from a table.
DropConstraint(*Table, []*UniqueConstraint, Constraint) [][]byte
// Returns a list of SQL statements to get the difference between the input indices and the output indices. If the input indices are named,
// they are dropped and recreated with autogenerated names.
DiffIndices([]Index, []Index) [][]byte
}
type SQLFormatter interface {

View File

@@ -125,7 +125,7 @@ func (provider *provider) GetIdentity(ctx context.Context, accessToken string) (
return nil, errors.Newf(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "claim role mismatch")
}
return authtypes.NewIdentity(valuer.MustNewUUID(claims.UserID), valuer.MustNewUUID(claims.OrgID), valuer.MustNewEmail(claims.Email), claims.Role, authtypes.IdentNProviderTokenizer), nil
return authtypes.NewIdentity(valuer.MustNewUUID(claims.UserID), valuer.MustNewUUID(claims.OrgID), valuer.MustNewEmail(claims.Email), claims.Role), nil
}
func (provider *provider) DeleteToken(ctx context.Context, accessToken string) error {

View File

@@ -47,7 +47,7 @@ func (store *store) GetIdentityByUserID(ctx context.Context, userID valuer.UUID)
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with id: %s does not exist", userID)
}
return authtypes.NewIdentity(userID, user.OrgID, user.Email, types.Role(user.Role), authtypes.IdentNProviderTokenizer), nil
return authtypes.NewIdentity(userID, user.OrgID, user.Email, types.Role(user.Role)), nil
}
func (store *store) GetByAccessToken(ctx context.Context, accessToken string) (*authtypes.StorableToken, error) {

View File

@@ -25,11 +25,10 @@ var (
type AuthNProvider struct{ valuer.String }
type Identity struct {
UserID valuer.UUID `json:"userId"`
OrgID valuer.UUID `json:"orgId"`
IdenNProvider IdentNProvider `json:"identNProvider"`
Email valuer.Email `json:"email"`
Role types.Role `json:"role"`
UserID valuer.UUID `json:"userId"`
OrgID valuer.UUID `json:"orgId"`
Email valuer.Email `json:"email"`
Role types.Role `json:"role"`
}
type CallbackIdentity struct {
@@ -79,13 +78,12 @@ func NewStateFromString(state string) (State, error) {
}, nil
}
func NewIdentity(userID valuer.UUID, orgID valuer.UUID, email valuer.Email, role types.Role, identNProvider IdentNProvider) *Identity {
func NewIdentity(userID valuer.UUID, orgID valuer.UUID, email valuer.Email, role types.Role) *Identity {
return &Identity{
UserID: userID,
OrgID: orgID,
Email: email,
Role: role,
IdenNProvider: identNProvider,
UserID: userID,
OrgID: orgID,
Email: email,
Role: role,
}
}
@@ -118,11 +116,10 @@ func (typ *Identity) UnmarshalBinary(data []byte) error {
func (typ *Identity) ToClaims() Claims {
return Claims{
UserID: typ.UserID.String(),
Email: typ.Email.String(),
Role: typ.Role,
OrgID: typ.OrgID.String(),
IdentNProvider: typ.IdenNProvider.StringValue(),
UserID: typ.UserID.String(),
Email: typ.Email.String(),
Role: typ.Role,
OrgID: typ.OrgID.String(),
}
}

View File

@@ -13,11 +13,10 @@ type claimsKey struct{}
type accessTokenKey struct{}
type Claims struct {
UserID string
Email string
Role types.Role
OrgID string
IdentNProvider string
UserID string
Email string
Role types.Role
OrgID string
}
// NewContextWithClaims attaches individual claims to the context.
@@ -54,7 +53,6 @@ func (c *Claims) LogValue() slog.Value {
slog.String("email", c.Email),
slog.String("role", c.Role.String()),
slog.String("org_id", c.OrgID),
slog.String("identn_provider", c.IdentNProvider),
)
}

View File

@@ -1,11 +0,0 @@
package authtypes
import "github.com/SigNoz/signoz/pkg/valuer"
var (
IdentNProviderTokenizer = IdentNProvider{valuer.NewString("tokenizer")}
IdentNProviderAPIkey = IdentNProvider{valuer.NewString("api_key")}
IdentNProviderAnonymous = IdentNProvider{valuer.NewString("anonymous")}
)
type IdentNProvider struct{ valuer.String }

View File

@@ -0,0 +1,41 @@
package authtypes
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
)
type uuidKey struct{}
type UUID struct {
}
func NewUUID() *UUID {
return &UUID{}
}
func (u *UUID) ContextFromRequest(ctx context.Context, values ...string) (context.Context, error) {
var value string
for _, v := range values {
if v != "" {
value = v
break
}
}
if value == "" {
return ctx, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "missing Authorization header")
}
return NewContextWithUUID(ctx, value), nil
}
func NewContextWithUUID(ctx context.Context, uuid string) context.Context {
return context.WithValue(ctx, uuidKey{}, uuid)
}
func UUIDFromContext(ctx context.Context) (string, bool) {
uuid, ok := ctx.Value(uuidKey{}).(string)
return uuid, ok
}

View File

@@ -0,0 +1,45 @@
package cloudintegrationtypes
import (
"time"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
type (
Account struct {
types.Identifiable
types.TimeAuditable
ProviderAccountId *string `json:"providerAccountID,omitempty"`
Provider CloudProviderType `json:"provider"`
RemovedAt *time.Time `json:"removedAt,omitempty"`
AgentReport *AgentReport `json:"agentReport,omitempty"`
OrgID valuer.UUID `json:"orgID"`
Config *AccountConfig `json:"config,omitempty"`
}
GettableConnectedAccounts struct {
Accounts []*Account `json:"accounts"`
}
GettableAccount = Account
UpdatableAccount struct {
Config *AccountConfig `json:"config"`
}
)
// AgentReport represents heartbeats sent by the agent.
type AgentReport struct {
TimestampMillis int64 `json:"timestampMillis"`
Data map[string]any `json:"data"`
}
type AccountConfig struct {
AWS *AWSAccountConfig `json:"aws,omitempty"`
}
type AWSAccountConfig struct {
Regions []string `json:"regions"`
}

View File

@@ -0,0 +1,85 @@
package cloudintegrationtypes
import (
"database/sql/driver"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/uptrace/bun"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
ErrCodeCloudIntegrationNotFound = errors.MustNewCode("cloud_integration_not_found")
ErrCodeCloudIntegrationAlreadyExists = errors.MustNewCode("cloud_integration_already_exists")
ErrCodeCloudIntegrationServiceAlreadyExists = errors.MustNewCode("cloud_integration_service_already_exists")
)
// StorableCloudIntegration represents a cloud integration stored in the database.
// This is also referred as "Account" in the context of cloud integrations.
type StorableCloudIntegration struct {
bun.BaseModel `bun:"table:cloud_integration"`
types.Identifiable
types.TimeAuditable
Provider CloudProviderType `json:"provider" bun:"provider,type:text"`
// Config is provider specific data in JSON string format
Config string `json:"config" bun:"config,type:text"`
AccountID *string `json:"accountID" bun:"account_id,type:text"`
LastAgentReport *StorableAgentReport `json:"lastAgentReport" bun:"last_agent_report,type:text"`
RemovedAt *time.Time `json:"removedAt" bun:"removed_at,type:timestamp,nullzero"`
OrgID valuer.UUID `json:"orgID" bun:"org_id,type:text"`
}
// StorableAgentReport represents the last heartbeat and arbitrary data sent by the agent
// as of now there is no use case for Data field, but keeping it for backwards compatibility with older structure.
type StorableAgentReport struct {
TimestampMillis int64 `json:"timestamp_millis"`
Data map[string]any `json:"data"`
}
// StorableCloudIntegrationService is to store service config for a cloud integration, which is a cloud provider specific configuration.
type StorableCloudIntegrationService struct {
bun.BaseModel `bun:"table:cloud_integration_service,alias:cis"`
types.Identifiable
types.TimeAuditable
// Keeping Type field name as is, but it is a service id
Type ServiceID `bun:"type,type:text,notnull,unique:cloud_integration_id_type"`
// Config is cloud provider's service specific data in JSON string format
Config string `bun:"config,type:text"`
CloudIntegrationID valuer.UUID `bun:"cloud_integration_id,type:text,notnull,unique:cloud_integration_id_type,on_delete:cascade"`
}
// Scan scans value from DB.
func (r *StorableAgentReport) 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)
}
// Value creates value to be stored in DB.
func (r *StorableAgentReport) 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 bytes
return string(serialized), nil
}

View File

@@ -0,0 +1,41 @@
package cloudintegrationtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
// CloudProviderType type alias.
type CloudProviderType struct{ valuer.String }
var (
// cloud providers.
CloudProviderTypeAWS = CloudProviderType{valuer.NewString("aws")}
CloudProviderTypeAzure = CloudProviderType{valuer.NewString("azure")}
// errors.
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("invalid_cloud_provider")
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,
}
// NewCloudProvider returns a new CloudProviderType from a string.
// It validates the input and returns an error if the input is not valid cloud provider.
func NewCloudProvider(provider string) (CloudProviderType, error) {
switch provider {
case CloudProviderTypeAWS.StringValue():
return CloudProviderTypeAWS, nil
case CloudProviderTypeAzure.StringValue():
return CloudProviderTypeAzure, nil
default:
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
}
}

View File

@@ -0,0 +1,97 @@
package cloudintegrationtypes
import "github.com/SigNoz/signoz/pkg/types/integrationtypes"
// request for creating connection artifact.
type (
PostableConnectionArtifact = ConnectionArtifactRequest
ConnectionArtifactRequest struct {
Aws *AWSConnectionArtifactRequest `json:"aws"`
}
AWSConnectionArtifactRequest struct {
DeploymentRegion string `json:"deploymentRegion"`
Regions []string `json:"regions"`
}
)
type (
ConnectionArtifact struct {
Aws *AWSConnectionArtifact `json:"aws"`
}
AWSConnectionArtifact struct {
ConnectionUrl string `json:"connectionURL"`
}
GettableConnectionArtifact = ConnectionArtifact
)
type (
AccountStatus struct {
Id string `json:"id"`
ProviderAccountId *string `json:"providerAccountID,omitempty"`
Status integrationtypes.AccountStatus `json:"status"`
}
GettableAccountStatus = AccountStatus
)
type (
AgentCheckInRequest struct {
// older backward compatible fields are mapped to new fields
// CloudIntegrationId string `json:"cloudIntegrationId"`
// AccountId string `json:"accountId"`
// New fields
ProviderAccountId string `json:"providerAccountId"`
CloudAccountId string `json:"cloudAccountId"`
Data map[string]any `json:"data,omitempty"`
}
PostableAgentCheckInRequest struct {
AgentCheckInRequest
// following are backward compatible fields for older running agents
// which gets mapped to new fields in AgentCheckInRequest
CloudIntegrationId string `json:"cloud_integration_id"`
CloudAccountId string `json:"cloud_account_id"`
}
GettableAgentCheckInResponse struct {
AgentCheckInResponse
// For backward compatibility
CloudIntegrationId string `json:"cloud_integration_id"`
AccountId string `json:"account_id"`
}
AgentCheckInResponse struct {
// Older fields for backward compatibility are mapped to new fields below
// CloudIntegrationId string `json:"cloud_integration_id"`
// AccountId string `json:"account_id"`
// New fields
ProviderAccountId string `json:"providerAccountId"`
CloudAccountId string `json:"cloudAccountId"`
// IntegrationConfig populates data related to integration that is required for an agent
// to start collecting telemetry data
// keeping JSON key snake_case for backward compatibility
IntegrationConfig *IntegrationConfig `json:"integration_config,omitempty"`
}
IntegrationConfig struct {
EnabledRegions []string `json:"enabledRegions"` // backward compatible
Telemetry *AWSCollectionStrategy `json:"telemetry,omitempty"` // backward compatible
// new fields
AWS *AWSIntegrationConfig `json:"aws,omitempty"`
}
AWSIntegrationConfig struct {
EnabledRegions []string `json:"enabledRegions"`
Telemetry *AWSCollectionStrategy `json:"telemetry,omitempty"`
}
)

View File

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

View File

@@ -0,0 +1,250 @@
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")
// ErrCodeInvalidServiceID is the error code for invalid service id.
ErrCodeInvalidServiceID = errors.MustNewCode("invalid_service_id")
)
type (
ServiceID struct{ valuer.String }
CloudIntegrationService struct {
types.Identifiable
types.TimeAuditable
Type ServiceID `json:"type"`
Config *ServiceConfig `json:"config"`
CloudIntegrationID valuer.UUID `json:"cloudIntegrationID"`
}
// ServiceMetadata helps to quickly list available services and whether it is enabled or not.
// As getting complete service definition is a heavy operation and the response is also large,
// initial integration page load can be very slow.
ServiceMetadata struct {
ServiceDefinitionMetadata
// if the service is enabled for the account
Enabled bool `json:"enabled"`
}
GettableServicesMetadata struct {
Services []*ServiceMetadata `json:"services"`
}
Service struct {
ServiceDefinition
ServiceConfig *ServiceConfig `json:"serviceConfig"`
}
GettableService = Service
UpdatableService struct {
Config *ServiceConfig `json:"config"`
}
)
type ServiceConfig struct {
AWS *AWSServiceConfig `json:"aws,omitempty"`
}
type AWSServiceConfig struct {
Logs *AWSServiceLogsConfig `json:"logs"`
Metrics *AWSServiceMetricsConfig `json:"metrics"`
}
// AWSServiceLogsConfig is AWS specific logs config for a service
// NOTE: the JSON keys are snake case for backward compatibility with existing agents.
type AWSServiceLogsConfig struct {
Enabled bool `json:"enabled"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"`
}
type AWSServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
}
// ServiceDefinitionMetadata represents service definition metadata. This is useful for showing service tab in frontend.
type ServiceDefinitionMetadata struct {
Id string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon"`
}
type ServiceDefinition struct {
ServiceDefinitionMetadata
Overview string `json:"overview"` // markdown
Assets Assets `json:"assets"`
SupportedSignals SupportedSignals `json:"supported_signals"`
DataCollected DataCollected `json:"dataCollected"`
Strategy *CollectionStrategy `json:"telemetryCollectionStrategy"`
}
// CollectionStrategy is cloud provider specific configuration for signal collection,
// this is used by agent to understand the nitty-gritty for collecting telemetry for the cloud provider.
type CollectionStrategy struct {
AWS *AWSCollectionStrategy `json:"aws,omitempty"`
}
// 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.
// NOTE: this structure is still using snake case, for backward compatibility,
// with existing agents.
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
}
// AWSMetricsStrategy represents metrics collection strategy for AWS services.
// this is AWS specific.
// NOTE: this structure is still using snake case, for backward compatibility,
// with existing agents.
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.
// NOTE: this structure is still using snake case, for backward compatibility,
// with existing agents.
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"`
}
// Dashboard represents a dashboard definition for cloud integration.
// This is used to show available pre-made dashboards for a service,
// hence has additional fields like id, title and description
type Dashboard struct {
Id string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
// SupportedServices is the map of supported services for each cloud provider.
var SupportedServices = map[CloudProviderType][]ServiceID{
CloudProviderTypeAWS: {
{valuer.NewString("alb")},
{valuer.NewString("api-gateway")},
{valuer.NewString("dynamodb")},
{valuer.NewString("ec2")},
{valuer.NewString("ecs")},
{valuer.NewString("eks")},
{valuer.NewString("elasticache")},
{valuer.NewString("lambda")},
{valuer.NewString("msk")},
{valuer.NewString("rds")},
{valuer.NewString("s3sync")},
{valuer.NewString("sns")},
{valuer.NewString("sqs")},
},
}
// NewServiceID returns a new ServiceID from a string, validated against the supported services for the given cloud provider.
func NewServiceID(provider CloudProviderType, service string) (ServiceID, error) {
services, ok := SupportedServices[provider]
if !ok {
return ServiceID{}, errors.NewInvalidInputf(ErrCodeInvalidServiceID, "no services defined for cloud provider: %s", provider)
}
for _, s := range services {
if s.StringValue() == service {
return s, nil
}
}
return ServiceID{}, errors.NewInvalidInputf(ErrCodeInvalidServiceID, "invalid service id %q for cloud provider %s", service, provider)
}
// 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 {
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

@@ -0,0 +1,41 @@
package cloudintegrationtypes
import (
"context"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Store interface {
// GetAccountByID returns a cloud integration account by id
GetAccountByID(ctx context.Context, orgID, id valuer.UUID, provider CloudProviderType) (*StorableCloudIntegration, error)
// CreateAccount creates a new cloud integration account
CreateAccount(ctx context.Context, account *StorableCloudIntegration) (*StorableCloudIntegration, error)
// UpdateAccount updates an existing cloud integration account
UpdateAccount(ctx context.Context, account *StorableCloudIntegration) error
// RemoveAccount marks a cloud integration account as removed by setting the RemovedAt field
RemoveAccount(ctx context.Context, orgID, id valuer.UUID, provider CloudProviderType) error
// GetConnectedAccounts returns all the cloud integration accounts for the org and cloud provider
GetConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider CloudProviderType) ([]*StorableCloudIntegration, error)
// GetConnectedAccount for a given provider
GetConnectedAccount(ctx context.Context, orgID valuer.UUID, provider CloudProviderType, providerAccountID string) (*StorableCloudIntegration, error)
// cloud_integration_service related methods
// GetServiceByServiceID returns the cloud integration service for the given cloud integration id and service id
GetServiceByServiceID(ctx context.Context, cloudIntegrationID valuer.UUID, serviceID ServiceID) (*StorableCloudIntegrationService, error)
// CreateService creates a new cloud integration service
CreateService(ctx context.Context, service *StorableCloudIntegrationService) (*StorableCloudIntegrationService, error)
// UpdateService updates an existing cloud integration service
UpdateService(ctx context.Context, service *StorableCloudIntegrationService) error
// GetServices returns all the cloud integration services for the given cloud integration id
GetServices(ctx context.Context, cloudIntegrationID valuer.UUID) ([]*StorableCloudIntegrationService, error)
}

View File

@@ -0,0 +1,31 @@
package ctxtypes
import (
"context"
"github.com/SigNoz/signoz/pkg/valuer"
)
type AuthType struct {
valuer.String
}
var (
AuthTypeTokenizer = AuthType{valuer.NewString("tokenizer")}
AuthTypeAPIKey = AuthType{valuer.NewString("api_key")}
AuthTypeInternal = AuthType{valuer.NewString("internal")}
AuthTypeAnonymous = AuthType{valuer.NewString("anonymous")}
)
type authTypeKey struct{}
// SetAuthType stores the auth type (e.g., AuthTypeJWT, AuthTypeAPIKey, AuthTypeInternal) in context.
func SetAuthType(ctx context.Context, authType AuthType) context.Context {
return context.WithValue(ctx, authTypeKey{}, authType)
}
// AuthTypeFromContext retrieves the auth type from context if set.
func AuthTypeFromContext(ctx context.Context) (AuthType, bool) {
v, ok := ctx.Value(authTypeKey{}).(AuthType)
return v, ok
}