Compare commits

..

6 Commits

Author SHA1 Message Date
SagarRajput-7
9f3f8521a2 Merge branch 'main' into SIG-1709-custom-domain 2026-02-26 23:48:12 +05:30
SagarRajput-7
2a4bb4bf28 feat: updated home page data source info section 2026-02-26 23:47:21 +05:30
SagarRajput-7
2d1827bad1 feat: updated and added test cases 2026-02-26 23:31:56 +05:30
SagarRajput-7
6f292eff9e feat: cleanup and refactor 2026-02-26 23:11:10 +05:30
SagarRajput-7
70604ea7e1 Merge branch 'main' into SIG-1709-custom-domain 2026-02-26 16:49:16 +05:30
SagarRajput-7
084701daeb feat: redesign the custom domain and moved it to general settings 2026-02-26 16:45:35 +05:30
52 changed files with 1640 additions and 994 deletions

View File

@@ -26,7 +26,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/utils/times"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
"github.com/SigNoz/signoz/pkg/units"
"github.com/SigNoz/signoz/pkg/query-service/formatter"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
@@ -335,7 +335,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
prevState := r.State()
valueFormatter := units.FormatterFromUnit(r.Unit())
valueFormatter := formatter.FromUnit(r.Unit())
var res ruletypes.Vector
var err error

View File

@@ -165,11 +165,6 @@ export const MySettings = Loadable(
() => import(/* webpackChunkName: "All MySettings" */ 'pages/Settings'),
);
export const CustomDomainSettings = Loadable(
() =>
import(/* webpackChunkName: "Custom Domain Settings" */ 'pages/Settings'),
);
export const Logs = Loadable(
() => import(/* webpackChunkName: "Logs" */ 'pages/LogsModulePage'),
);

View File

@@ -38,7 +38,6 @@ const ROUTES = {
SETTINGS: '/settings',
MY_SETTINGS: '/settings/my-settings',
ORG_SETTINGS: '/settings/org-settings',
CUSTOM_DOMAIN_SETTINGS: '/settings/custom-domain-settings',
API_KEYS: '/settings/api-keys',
INGESTION_SETTINGS: '/settings/ingestion-settings',
SOMETHING_WENT_WRONG: '/something-went-wrong',

View File

@@ -0,0 +1,184 @@
import { useEffect, useState } from 'react';
import { Button } from '@signozhq/button';
import { Color } from '@signozhq/design-tokens';
import { Input } from '@signozhq/input';
import { Modal } from 'antd';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
interface CustomDomainEditModalProps {
isOpen: boolean;
onClose: () => void;
customDomainSubdomain?: string;
dnsSuffix: string;
isLoading: boolean;
updateDomainError: AxiosError<RenderErrorResponseDTO> | null;
onClearError: () => void;
onSubmit: (subdomain: string) => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function CustomDomainEditModal({
isOpen,
onClose,
customDomainSubdomain,
dnsSuffix,
isLoading,
updateDomainError,
onClearError,
onSubmit,
}: CustomDomainEditModalProps): JSX.Element {
const [value, setValue] = useState(customDomainSubdomain ?? '');
const [validationError, setValidationError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setValue(customDomainSubdomain ?? '');
}
}, [isOpen, customDomainSubdomain]);
const handleClose = (): void => {
setValidationError(null);
onClearError();
onClose();
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
setValue(e.target.value);
setValidationError(null);
onClearError();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
handleSubmit();
}
};
const handleSubmit = (): void => {
if (!value) {
setValidationError('This field is required');
return;
}
if (value.length < 3) {
setValidationError('Minimum 3 characters required');
return;
}
onSubmit(value);
};
const is409 = updateDomainError?.status === 409;
const apiErrorMessage = (updateDomainError?.response
?.data as RenderErrorResponseDTO)?.error?.message;
const isError = !!(validationError || (updateDomainError && !is409));
const errorMessage =
validationError ||
(is409
? apiErrorMessage ||
"You've already updated the custom domain once today. Please contact support."
: apiErrorMessage) ||
null;
const statusIcon = isLoading ? (
<Loader2 size={16} className="animate-spin edit-modal-status-icon" />
) : isError || is409 ? (
<AlertCircle size={16} color={Color.BG_CHERRY_500} />
) : (
<CheckCircle2 size={16} color={Color.BG_FOREST_500} />
);
return (
<Modal
className="edit-workspace-modal"
title="Edit Workspace Link"
open={isOpen}
onCancel={handleClose}
destroyOnClose
footer={null}
width={512}
>
<p className="edit-modal-description">
Enter your preferred subdomain to create a unique URL for your team. Need
help?{' '}
<a
href="https://signoz.io/support"
target="_blank"
rel="noreferrer"
className="edit-modal-link"
>
Contact support.
</a>
</p>
<div className="edit-modal-field">
<span
className={`edit-modal-label${
isError || is409 ? ' edit-modal-label--error' : ''
}`}
>
Workspace URL
</span>
<div
className={`edit-modal-input-wrapper${
isError ? ' edit-modal-input-wrapper--error' : ''
}`}
>
<div className="edit-modal-input-field">
{statusIcon}
<Input
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
autoFocus
/>
</div>
<div className="edit-modal-input-suffix">{dnsSuffix}</div>
</div>
<span
className={`edit-modal-helper${
isError || is409 ? ' edit-modal-helper--error' : ''
}`}
>
{isError || is409
? errorMessage
: "To help you easily explore SigNoz, we've selected a tenant sub domain name for you."}
</span>
</div>
<div className="edit-modal-note">
<span className="edit-modal-note-emoji">🚧</span>
<span className="edit-modal-note-text">
Note that your previous URL still remains accessible. Your access
credentials for the new URL remains the same.
</span>
</div>
<div className="edit-modal-footer">
{is409 ? (
<LaunchChatSupport
attributes={{ screen: 'Custom Domain Settings' }}
eventName="Custom Domain Settings: Facing Issues Updating Custom Domain"
message="Hi Team, I need help with updating custom domain"
buttonText="Contact Support"
/>
) : (
<Button
variant="solid"
size="md"
color="primary"
className="edit-modal-apply-btn"
onClick={handleSubmit}
disabled={isLoading}
loading={isLoading}
>
Apply Changes
</Button>
)}
</div>
</Modal>
);
}

View File

@@ -1,262 +1,496 @@
.custom-domain-settings-container {
margin-top: 24px;
.beacon {
position: relative;
display: inline-block;
width: 16px;
height: 16px;
flex-shrink: 0;
&::before {
content: '';
position: absolute;
inset: 1px;
border-radius: 50%;
background: rgba(78, 116, 248, 0.2);
}
&::after {
content: '';
position: absolute;
left: 5px;
top: 5px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
}
}
.custom-domain-card {
width: 100%;
max-width: 768px;
border-radius: 4px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
overflow: hidden;
&--loading {
padding: 12px;
}
.custom-domain-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 12px;
gap: 12px;
.custom-domain-edit-button {
border: 1px solid var(--l3-border);
background: var(--l3-background);
&:hover {
background: var(--l3-background-hover);
}
}
}
.custom-domain-card-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.custom-domain-card-name-row {
display: flex;
align-items: center;
gap: 10px;
}
.custom-domain-card-org-name {
color: var(--l1-foreground);
font-size: 13px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
.custom-domain-card-meta-row {
display: flex;
align-items: center;
gap: 20px;
padding-left: 26px;
}
.custom-domain-card-meta-timezone {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings: 'dlig' on, 'salt' on;
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
text-transform: uppercase;
svg {
flex-shrink: 0;
color: var(--l1-foreground);
}
}
.custom-domain-callout {
margin: 0 12px 12px;
font-size: 13px;
max-width: 742px;
--callout-background: var(--primary);
--callout-border-color: var(--callout-primary-border);
--callout-icon-color: var(--primary);
--callout-title-color: var(--callout-primary-title);
}
.custom-domain-card-divider {
height: 1px;
background: var(--l2-border);
margin: 0;
}
.custom-domain-card-bottom {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
}
.custom-domain-card-license {
color: var(--l1-foreground);
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
}
.custom-domain-plan-badge {
display: inline-flex;
align-items: center;
padding: 0 2px;
border-radius: 2px;
background: var(--l2-background);
color: var(--l2-foreground);
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
line-height: 20px;
}
}
.workspace-url-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--l1-foreground);
font-size: 12px;
line-height: 16px;
letter-spacing: -0.06px;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
flex-shrink: 0;
color: var(--l2-foreground);
}
}
.workspace-url-dropdown {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
padding: 8px 0;
min-width: 200px;
display: flex;
flex-direction: column;
justify-content: center;
}
.workspace-url-dropdown-header {
color: var(--l2-foreground);
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
padding: 0 12px 8px;
}
.workspace-url-dropdown-divider {
height: 1px;
background: var(--l1-border);
margin-bottom: 4px;
}
.workspace-url-dropdown-item {
display: flex;
align-items: center;
gap: 24px;
width: 100%;
justify-content: space-between;
gap: 8px;
padding: 5px 12px;
cursor: pointer;
text-decoration: none;
transition: background 0.15s ease;
.custom-domain-settings-content {
width: calc(100% - 30px);
max-width: 736px;
&:hover {
background: var(--l1-background-hover);
.title {
color: var(--bg-vanilla-100);
font-size: var(--font-size-lg);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 28px; /* 155.556% */
letter-spacing: -0.09px;
.workspace-url-dropdown-item-label {
text-decoration: underline;
}
.subtitle {
color: var(--bg-vanilla-400);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.workspace-url-dropdown-item-external {
opacity: 1;
}
}
.custom-domain-settings-card {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-400);
.ant-card-body {
padding: 12px;
display: flex;
flex-direction: column;
.custom-domain-settings-content-header {
color: var(--bg-vanilla-100);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.custom-domain-settings-content-body {
margin-top: 12px;
display: flex;
gap: 12px;
align-items: flex-end;
justify-content: space-between;
.custom-domain-url-edit-btn {
.periscope-btn {
border-radius: 2px;
border: 1px solid var(--Slate-200, #2c3140);
background: var(--Ink-200, #23262e);
}
}
}
.custom-domain-urls {
display: flex;
flex-direction: column;
flex: 1;
}
.custom-domain-url {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
line-height: 24px;
padding: 4px 0;
}
.custom-domain-update-status {
margin-top: 12px;
color: var(--bg-robin-400);
font-size: 13px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: 20px;
letter-spacing: -0.07px;
border-radius: 4px;
border: 1px solid rgba(78, 116, 248, 0.1);
background: rgba(78, 116, 248, 0.1);
}
}
&--active {
background: var(--l1-background-hover);
}
}
.custom-domain-settings-modal {
.workspace-url-dropdown-item-external {
color: var(--l2-foreground);
flex-shrink: 0;
opacity: 0.5;
transition: opacity 0.15s ease;
}
.workspace-url-dropdown-item-label {
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--l2-foreground);
.workspace-url-dropdown-item--active & {
color: var(--l1-foreground);
}
}
.workspace-url-dropdown-item-check {
color: var(--l1-foreground);
flex-shrink: 0;
}
.edit-workspace-modal {
.ant-modal-content {
border-radius: 4px;
border: 1px solid var(--bg-slate-500);
background: var(--bg-ink-400);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
padding: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
}
.ant-modal-header {
background: none;
border-bottom: 1px solid var(--bg-slate-500);
padding: 16px;
margin-bottom: 0;
.ant-modal-header {
background: none;
border-bottom: 1px solid var(--l1-border);
padding: 16px;
margin-bottom: 0;
.ant-modal-title {
color: var(--l1-foreground);
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
}
.ant-modal-close {
top: 14px;
inset-inline-end: 14px;
color: var(--l2-foreground);
}
.ant-modal-body {
display: flex;
flex-direction: column;
gap: 24px;
padding: 16px;
}
}
// Description
.edit-modal-description {
margin: 0;
color: var(--l1-foreground);
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
}
.edit-modal-link {
color: var(--primary);
&:hover {
text-decoration: underline;
}
}
// Input field group
.edit-modal-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.edit-modal-label {
color: var(--l2-foreground);
font-size: 13px;
font-weight: 500;
line-height: 20px;
&--error {
color: var(--destructive);
}
}
.edit-modal-input-wrapper {
display: flex;
align-items: stretch;
.edit-modal-input-field {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
height: 44px;
padding: 6px 12px;
background: var(--l1-background);
border: 1px solid var(--l1-border);
border-right: none;
border-radius: 2px 0 0 2px;
svg {
flex-shrink: 0;
}
.ant-modal-close-x {
font-size: 12px;
}
input {
flex: 1;
width: 100%;
height: auto;
background: transparent;
border: none;
border-radius: 0;
outline: none;
box-shadow: none;
color: var(--l1-foreground);
font-size: 13px;
line-height: 20px;
padding: 0;
.ant-modal-body {
padding: 12px 16px;
.custom-domain-settings-modal-body {
margin-bottom: 48px;
font-size: 13px;
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.custom-domain-settings-modal-error {
display: flex;
flex-direction: column;
gap: 24px;
.update-limit-reached-error {
display: flex;
padding: 20px 24px 24px 24px;
flex-direction: column;
align-items: center;
gap: 24px;
align-self: stretch;
border-radius: 4px;
border: 1px solid rgba(255, 205, 86, 0.2);
background: rgba(255, 205, 86, 0.1);
color: var(--bg-amber-400);
font-size: 13px;
font-style: normal;
line-height: 20px; /* 142.857% */
}
.ant-alert-message::first-letter {
text-transform: capitalize;
}
}
.custom-domain-settings-modal-footer {
padding: 16px 0;
margin-top: 0;
display: flex;
justify-content: flex-end;
.apply-changes-btn {
width: 100%;
}
.facing-issue-button {
width: 100%;
.periscope-btn {
width: 100%;
border-radius: 2px;
background: var(--bg-robin-500);
border: none;
color: var(--bg-vanilla-100);
line-height: 20px;
.ant-btn-icon {
display: none;
}
&:hover {
background: var(--bg-robin-500) !important;
border: none !important;
color: var(--bg-vanilla-100) !important;
line-height: 20px !important;
}
}
&:focus,
&:focus-visible {
outline: none;
box-shadow: none;
}
}
}
}
.lightMode {
.custom-domain-settings-container {
.custom-domain-settings-content {
.title {
color: var(--bg-ink-400);
}
.edit-modal-input-suffix {
display: flex;
align-items: center;
padding: 6px 12px;
background: var(--l2-background);
border: 1px solid var(--l1-border);
border-left: none;
border-radius: 0 2px 2px 0;
color: var(--l2-foreground);
font-size: 13px;
line-height: 20px;
white-space: nowrap;
}
.subtitle {
color: var(--bg-vanilla-400);
}
}
.edit-modal-helper {
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
.custom-domain-settings-card {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.ant-card-body {
.custom-domain-settings-content-header {
color: var(--bg-ink-100);
}
.custom-domain-update-status {
color: var(--bg-robin-400);
border: 1px solid rgba(78, 116, 248, 0.1);
background: rgba(78, 116, 248, 0.1);
}
.custom-domain-url-edit-btn {
.periscope-btn {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
box-shadow: none;
}
}
}
}
&--error {
color: var(--destructive);
}
}
.custom-domain-settings-modal {
.ant-modal-content {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
.edit-modal-status-icon {
color: var(--l2-foreground);
}
.ant-modal-header {
border-bottom: 1px solid var(--bg-vanilla-300);
.edit-modal-note {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px;
border-radius: 4px;
background: var(--l2-background);
}
.edit-modal-note-emoji {
font-size: 16px;
line-height: 20px;
flex-shrink: 0;
margin-top: 2px;
}
.edit-modal-note-text {
color: var(--l2-foreground);
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
}
.edit-modal-footer {
.facing-issue-button {
width: 100%;
.periscope-btn {
width: 100%;
border-radius: 2px;
background: var(--primary);
border: none;
color: var(--bg-vanilla-100);
font-size: 13px;
font-weight: 500;
line-height: 20px;
height: 36px;
.ant-btn-icon {
display: none;
}
.custom-domain-settings-modal-error {
.update-limit-reached-error {
border: 1px solid rgba(255, 205, 86, 0.2);
background: rgba(255, 205, 86, 0.1);
color: var(--bg-amber-500);
}
&:hover {
background: var(--primary) !important;
border: none !important;
color: var(--bg-vanilla-100) !important;
}
}
}
}
.edit-modal-apply-btn {
width: 100%;
}
.custom-domain-toast {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 16px;
height: 40px;
width: min(942px, calc(100vw - 32px));
border-radius: 4px;
background: var(--primary);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
color: var(--bg-base-white);
&-message {
font-size: 13px;
line-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
&-visit-btn {
color: inherit;
text-decoration: none;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
&:hover {
background: rgba(255, 255, 255, 0.25);
color: inherit;
}
}
&-dismiss-btn {
color: rgba(255, 255, 255, 0.7);
height: 24px;
width: 24px;
&:hover {
background: rgba(255, 255, 255, 0.15);
color: inherit;
}
}
}

View File

@@ -1,61 +1,88 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { useEffect, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Color } from '@signozhq/design-tokens';
import { Button } from '@signozhq/button';
import { Callout } from '@signozhq/callout';
import {
Alert,
Button,
Card,
Form,
Input,
Modal,
Skeleton,
Tag,
Typography,
} from 'antd';
Check,
ChevronDown,
Clock,
ExternalLink,
FilePenLine,
Link2,
SolidAlertCircle,
X,
} from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { Dropdown, Skeleton } from 'antd';
import {
RenderErrorResponseDTO,
ZeustypesHostDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useGetHosts, usePutHost } from 'api/generated/services/zeus';
import { AxiosError } from 'axios';
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
import { useNotifications } from 'hooks/useNotifications';
import { InfoIcon, Link2, Pencil } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import CustomDomainEditModal from './CustomDomainEditModal';
import './CustomDomainSettings.styles.scss';
interface CustomDomainSettingsProps {
subdomain: string;
function DomainUpdateToast({
toastId,
url,
}: {
toastId: string | number;
url: string;
}): JSX.Element {
const displayUrl = url?.split('://')[1] ?? url;
return (
<div className="custom-domain-toast">
<span className="custom-domain-toast-message">
Your workspace URL is being updated to <strong>{displayUrl}</strong>. This
may take a few minutes.
</span>
<div className="custom-domain-toast-actions">
<Button
variant="ghost"
size="xs"
className="custom-domain-toast-visit-btn"
suffixIcon={<ExternalLink size={12} />}
onClick={(): void => {
window.open(url, '_blank', 'noopener,noreferrer');
}}
>
Visit new URL
</Button>
<Button
variant="ghost"
size="icon"
className="custom-domain-toast-dismiss-btn"
onClick={(): void => {
toast.dismiss(toastId);
}}
aria-label="Dismiss"
prefixIcon={<X size={14} />}
/>
</div>
</div>
);
}
export default function CustomDomainSettings(): JSX.Element {
const { org } = useAppContext();
const { notifications } = useNotifications();
const { org, activeLicense } = useAppContext();
const { timezone } = useTimezone();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isPollingEnabled, setIsPollingEnabled] = useState(false);
const [hosts, setHosts] = useState<ZeustypesHostDTO[] | null>(null);
const [updateDomainError, setUpdateDomainError] = useState<AxiosError | null>(
null,
);
const [, setCopyUrl] = useCopyToClipboard();
const [
customDomainDetails,
setCustomDomainDetails,
] = useState<CustomDomainSettingsProps | null>();
updateDomainError,
setUpdateDomainError,
] = useState<AxiosError<RenderErrorResponseDTO> | null>(null);
const [editForm] = Form.useForm();
const handleModalClose = (): void => {
setIsEditModalOpen(false);
editForm.resetFields();
setUpdateDomainError(null);
};
const [customDomainSubdomain, setCustomDomainSubdomain] = useState<
string | undefined
>();
const {
data: hostsData,
@@ -69,9 +96,7 @@ export default function CustomDomainSettings(): JSX.Element {
isLoading: isLoadingUpdateCustomDomain,
} = usePutHost<AxiosError<RenderErrorResponseDTO>>();
const stripProtocol = (url: string): string => {
return url?.split('://')[1] ?? url;
};
const stripProtocol = (url: string): string => url?.split('://')[1] ?? url;
const dnsSuffix = useMemo(() => {
const defaultHost = hosts?.find((h) => h.is_default);
@@ -80,6 +105,11 @@ export default function CustomDomainSettings(): JSX.Element {
: '';
}, [hosts]);
const activeHost = useMemo(
() => hosts?.find((h) => !h.is_default) ?? hosts?.find((h) => h.is_default),
[hosts],
);
useEffect(() => {
if (isFetchingHosts || !hostsData) {
return;
@@ -87,22 +117,14 @@ export default function CustomDomainSettings(): JSX.Element {
if (hostsData.status === 'success') {
setHosts(hostsData.data.hosts ?? null);
const activeCustomDomain = hostsData.data.hosts?.find(
(host) => !host.is_default,
);
if (activeCustomDomain) {
setCustomDomainDetails({
subdomain: activeCustomDomain?.name || '',
});
const customHost = hostsData.data.hosts?.find((h) => !h.is_default);
if (customHost) {
setCustomDomainSubdomain(customHost.name || '');
}
}
if (hostsData.data.state !== 'HEALTHY' && isPollingEnabled) {
setTimeout(() => {
refetchHosts();
}, 3000);
setTimeout(() => refetchHosts(), 3000);
}
if (hostsData.data.state === 'HEALTHY') {
@@ -110,206 +132,174 @@ export default function CustomDomainSettings(): JSX.Element {
}
}, [hostsData, refetchHosts, isPollingEnabled, isFetchingHosts]);
const onUpdateCustomDomainSettings = (): void => {
editForm
.validateFields()
.then((values) => {
if (values.subdomain) {
updateSubDomain(
{ data: { name: values.subdomain } },
{
onSuccess: () => {
setIsPollingEnabled(true);
refetchHosts();
setIsEditModalOpen(false);
},
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
setUpdateDomainError(error as AxiosError);
setIsPollingEnabled(false);
},
},
const handleSubmit = (subdomain: string): void => {
updateSubDomain(
{ data: { name: subdomain } },
{
onSuccess: () => {
setIsPollingEnabled(true);
refetchHosts();
setIsEditModalOpen(false);
setCustomDomainSubdomain(subdomain);
const newUrl = `https://${subdomain}.${dnsSuffix}`;
toast.custom(
(toastId) => <DomainUpdateToast toastId={toastId} url={newUrl} />,
{ duration: 5000, position: 'bottom-right' }, // this 5 sec is as per design
);
},
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
setUpdateDomainError(error as AxiosError<RenderErrorResponseDTO>);
setIsPollingEnabled(false);
},
},
);
};
setCustomDomainDetails({
subdomain: values.subdomain,
});
const sortedHosts = useMemo(
() =>
[...(hosts ?? [])].sort((a, b) => {
if (a.name === activeHost?.name) {
return -1;
}
})
.catch((errorInfo) => {
console.error('error info', errorInfo);
});
};
if (b.name === activeHost?.name) {
return 1;
}
if (a.is_default && !b.is_default) {
return 1;
}
if (!a.is_default && b.is_default) {
return -1;
}
return 0;
}),
[hosts, activeHost],
);
const onCopyUrlHandler = (url: string): void => {
setCopyUrl(stripProtocol(url));
notifications.success({
message: 'Copied to clipboard',
});
};
const planName = activeLicense?.plan?.name;
if (isLoadingHosts || isFetchingHosts) {
return (
<div className="custom-domain-card custom-domain-card--loading">
<Skeleton
active
title={{ width: '40%' }}
paragraph={{ rows: 1, width: '60%' }}
/>
</div>
);
}
return (
<div className="custom-domain-settings-container">
<div className="custom-domain-settings-content">
<header>
<Typography.Title className="title">
Custom Domain Settings
</Typography.Title>
<Typography.Text className="subtitle">
Personalize your workspace domain effortlessly.
</Typography.Text>
</header>
</div>
<div className="custom-domain-settings-content">
{!isLoadingHosts && (
<Card className="custom-domain-settings-card">
<div className="custom-domain-settings-content-header">
Team {org?.[0]?.displayName} Information
<>
<div className="custom-domain-card">
<div className="custom-domain-card-top">
<div className="custom-domain-card-info">
<div className="custom-domain-card-name-row">
<span className="beacon" />
<span className="custom-domain-card-org-name">
{org?.[0]?.displayName ? org?.[0]?.displayName : customDomainSubdomain}
</span>
</div>
<div className="custom-domain-settings-content-body">
<div className="custom-domain-urls">
{hosts?.map((host) => (
<div
className="custom-domain-url"
key={host.name}
onClick={(): void => onCopyUrlHandler(host.url || '')}
>
<Link2 size={12} /> {stripProtocol(host.url || '')}
{host.is_default && <Tag color={Color.BG_ROBIN_500}>Default</Tag>}
<div className="custom-domain-card-meta-row">
<Dropdown
trigger={['click']}
dropdownRender={(): JSX.Element => (
<div className="workspace-url-dropdown">
<span className="workspace-url-dropdown-header">
All Workspace URLs
</span>
<div className="workspace-url-dropdown-divider" />
{sortedHosts.map((host) => {
const isActive = host.name === activeHost?.name;
return (
<a
key={host.name}
href={host.url}
target="_blank"
rel="noopener noreferrer"
className={`workspace-url-dropdown-item${
isActive ? ' workspace-url-dropdown-item--active' : ''
}`}
>
<span className="workspace-url-dropdown-item-label">
{stripProtocol(host.url ?? '')}
</span>
{isActive ? (
<Check size={14} className="workspace-url-dropdown-item-check" />
) : (
<ExternalLink
size={12}
className="workspace-url-dropdown-item-external"
/>
)}
</a>
);
})}
</div>
))}
</div>
<div className="custom-domain-url-edit-btn">
<Button
className="periscope-btn"
disabled={isLoadingHosts || isFetchingHosts || isPollingEnabled}
type="default"
icon={<Pencil size={10} />}
onClick={(): void => setIsEditModalOpen(true)}
)}
>
<button
type="button"
className="workspace-url-trigger"
disabled={isFetchingHosts}
>
Customize teams URL
</Button>
</div>
<Link2 size={12} />
<span>{stripProtocol(activeHost?.url ?? '')}</span>
<ChevronDown size={12} />
</button>
</Dropdown>
<span className="custom-domain-card-meta-timezone">
<Clock size={11} />
{timezone.offset}
</span>
</div>
</div>
{isPollingEnabled && (
<Alert
className="custom-domain-update-status"
message={`Updating your URL to ⎯ ${customDomainDetails?.subdomain}.${dnsSuffix}. This may take a few mins.`}
type="info"
icon={<InfoIcon size={12} />}
/>
)}
</Card>
<Button
variant="solid"
size="sm"
className="custom-domain-edit-button"
prefixIcon={<FilePenLine size={12} />}
disabled={isFetchingHosts || isPollingEnabled}
onClick={(): void => setIsEditModalOpen(true)}
>
Edit workspace link
</Button>
</div>
{isPollingEnabled && (
<Callout
type="info"
showIcon
className="custom-domain-callout"
size="small"
icon={<SolidAlertCircle size={13} color="primary" />}
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
/>
)}
{isLoadingHosts && (
<Card className="custom-domain-settings-card">
<Skeleton
className="custom-domain-settings-skeleton"
active
paragraph={{ rows: 2 }}
/>
</Card>
)}
<div className="custom-domain-card-divider" />
<div className="custom-domain-card-bottom">
<span className="beacon" />
<span className="custom-domain-card-license">
{planName && <code className="custom-domain-plan-badge">{planName}</code>}{' '}
license is currently active
</span>
</div>
</div>
{/* Update Custom Domain Modal */}
<Modal
className="custom-domain-settings-modal"
title="Customize your teams URL"
open={isEditModalOpen}
key="edit-custom-domain-settings-modal"
afterClose={handleModalClose}
// closable
onCancel={handleModalClose}
destroyOnClose
footer={null}
>
<Form
name="edit-custom-domain-settings-form"
key={customDomainDetails?.subdomain}
form={editForm}
layout="vertical"
autoComplete="off"
initialValues={{
subdomain: customDomainDetails?.subdomain,
}}
>
{updateDomainError?.status !== 409 && (
<>
<div className="custom-domain-settings-modal-body">
Enter your preferred subdomain to create a unique URL for your team.
Need help? Contact support.
</div>
<Form.Item
name="subdomain"
label="Teams URL subdomain"
rules={[{ required: true }, { type: 'string', min: 3 }]}
>
<Input
addonBefore={updateDomainError && <InfoIcon size={12} color="red" />}
placeholder="Enter Domain"
onChange={(): void => setUpdateDomainError(null)}
addonAfter={dnsSuffix}
autoFocus
/>
</Form.Item>
</>
)}
{updateDomainError && (
<div className="custom-domain-settings-modal-error">
{updateDomainError.status === 409 ? (
<Alert
message={
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
?.message ||
'Youve already updated the custom domain once today. To make further changes, please contact our support team for assistance.'
}
type="warning"
className="update-limit-reached-error"
/>
) : (
<Typography.Text type="danger">
{
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
?.message
}
</Typography.Text>
)}
</div>
)}
{updateDomainError?.status !== 409 && (
<div className="custom-domain-settings-modal-footer">
<Button
className="periscope-btn primary apply-changes-btn"
onClick={onUpdateCustomDomainSettings}
loading={isLoadingUpdateCustomDomain}
>
Apply Changes
</Button>
</div>
)}
{updateDomainError?.status === 409 && (
<div className="custom-domain-settings-modal-footer">
<LaunchChatSupport
attributes={{
screen: 'Custom Domain Settings',
}}
eventName="Custom Domain Settings: Facing Issues Updating Custom Domain"
message="Hi Team, I need help with updating custom domain"
buttonText="Contact Support"
/>
</div>
)}
</Form>
</Modal>
</div>
<CustomDomainEditModal
isOpen={isEditModalOpen}
onClose={(): void => setIsEditModalOpen(false)}
customDomainSubdomain={customDomainSubdomain}
dnsSuffix={dnsSuffix}
isLoading={isLoadingUpdateCustomDomain}
updateDomainError={updateDomainError}
onClearError={(): void => setUpdateDomainError(null)}
onSubmit={handleSubmit}
/>
</>
);
}

View File

@@ -4,6 +4,14 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CustomDomainSettings from '../CustomDomainSettings';
const mockToastCustom = jest.fn();
jest.mock('@signozhq/sonner', () => ({
toast: {
custom: (...args: unknown[]): unknown => mockToastCustom(...args),
dismiss: jest.fn(),
},
}));
const ZEUS_HOSTS_ENDPOINT = '*/api/v2/zeus/hosts';
const mockHostsResponse: GetHosts200 = {
@@ -28,9 +36,12 @@ const mockHostsResponse: GetHosts200 = {
};
describe('CustomDomainSettings', () => {
afterEach(() => server.resetHandlers());
afterEach(() => {
server.resetHandlers();
mockToastCustom.mockClear();
});
it('renders host URLs with protocol stripped and marks the default host', async () => {
it('renders active host URL in the trigger button', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
@@ -39,12 +50,11 @@ describe('CustomDomainSettings', () => {
render(<CustomDomainSettings />);
await screen.findByText(/accepted-starfish\.test\.cloud/i);
// The active host is the non-default one (custom-host)
await screen.findByText(/custom-host\.test\.cloud/i);
expect(screen.getByText('Default')).toBeInTheDocument();
});
it('opens edit modal with DNS suffix derived from the default host', async () => {
it('opens edit modal when clicking the edit button', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
@@ -54,14 +64,14 @@ describe('CustomDomainSettings', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/accepted-starfish\.test\.cloud/i);
await screen.findByText(/custom-host\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /customize team[']s url/i }),
screen.getByRole('button', { name: /edit workspace link/i }),
);
expect(
screen.getByRole('dialog', { name: /customize your team[']s url/i }),
screen.getByRole('dialog', { name: /edit workspace link/i }),
).toBeInTheDocument();
// DNS suffix is the part of the default host URL after the name prefix
expect(screen.getByText('test.cloud')).toBeInTheDocument();
@@ -83,12 +93,13 @@ describe('CustomDomainSettings', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/accepted-starfish\.test\.cloud/i);
await screen.findByText(/custom-host\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /customize team[']s url/i }),
screen.getByRole('button', { name: /edit workspace link/i }),
);
const input = screen.getByPlaceholderText(/enter domain/i);
// The input is inside the modal — find it by its role
const input = screen.getByRole('textbox');
await user.clear(input);
await user.type(input, 'myteam');
await user.click(screen.getByRole('button', { name: /apply changes/i }));
@@ -114,15 +125,111 @@ describe('CustomDomainSettings', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/accepted-starfish\.test\.cloud/i);
await screen.findByText(/custom-host\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /customize team[']s url/i }),
screen.getByRole('button', { name: /edit workspace link/i }),
);
await user.type(screen.getByPlaceholderText(/enter domain/i), 'myteam');
const input = screen.getByRole('textbox');
await user.clear(input);
await user.type(input, 'myteam');
await user.click(screen.getByRole('button', { name: /apply changes/i }));
expect(
await screen.findByRole('button', { name: /contact support/i }),
).toBeInTheDocument();
});
it('shows validation error when subdomain is less than 3 characters', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/custom-host\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /edit workspace link/i }),
);
const input = screen.getByRole('textbox');
await user.clear(input);
await user.type(input, 'ab');
await user.click(screen.getByRole('button', { name: /apply changes/i }));
expect(
screen.getByText(/minimum 3 characters required/i),
).toBeInTheDocument();
});
it('shows all workspace URLs as links in the dropdown', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/custom-host\.test\.cloud/i);
// Open the URL dropdown
await user.click(
screen.getByRole('button', { name: /custom-host\.test\.cloud/i }),
);
// Both host URLs should appear as links in the dropdown
const links = await screen.findAllByRole('link');
const hostLinks = links.filter(
(link) =>
link.getAttribute('href')?.includes('test.cloud') &&
link.getAttribute('target') === '_blank',
);
expect(hostLinks).toHaveLength(2);
// Verify the URLs
const hrefs = hostLinks.map((link) => link.getAttribute('href'));
expect(hrefs).toContain('https://accepted-starfish.test.cloud');
expect(hrefs).toContain('https://custom-host.test.cloud');
});
it('calls toast.custom with new URL after successful domain update', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({})),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CustomDomainSettings />);
await screen.findByText(/custom-host\.test\.cloud/i);
await user.click(
screen.getByRole('button', { name: /edit workspace link/i }),
);
const input = screen.getByRole('textbox');
await user.clear(input);
await user.type(input, 'myteam');
await user.click(screen.getByRole('button', { name: /apply changes/i }));
// Verify toast.custom was called
await waitFor(() => {
expect(mockToastCustom).toHaveBeenCalledTimes(1);
});
// Render the toast element to verify its content
const toastRenderer = mockToastCustom.mock.calls[0][0] as (
id: string,
) => JSX.Element;
const { container } = render(toastRenderer('test-id'));
expect(container).toHaveTextContent(/myteam\.test\.cloud/i);
});
});

View File

@@ -0,0 +1,153 @@
.general-settings-page {
max-width: 768px;
margin: 0 auto;
padding: 32px 0 64px;
display: flex;
flex-direction: column;
gap: 24px;
}
.general-settings-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.general-settings-title {
font-size: 16px;
font-weight: 500;
line-height: 32px;
letter-spacing: -0.08px;
color: var(--l1-foreground);
}
.general-settings-subtitle {
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--l2-foreground);
}
.retention-controls-container {
border: 1px solid var(--l2-border);
border-radius: 4px;
overflow: hidden;
}
.retention-controls-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 50px;
background: var(--l2-background);
border-bottom: 1px solid var(--l2-border);
}
.retention-controls-header-label {
font-size: 12px;
font-weight: 500;
line-height: 22px;
letter-spacing: 0.48px;
text-transform: uppercase;
color: var(--l2-foreground);
}
.retention-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 52px;
background: var(--l2-background);
& + & {
border-top: 1px solid var(--l1-border);
}
}
.retention-row-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
svg {
color: var(--l2-foreground);
flex-shrink: 0;
}
}
.retention-row-controls {
display: flex;
align-items: center;
gap: 8px;
}
.retention-input-group {
display: flex;
align-items: flex-start;
gap: -1px;
// todo: https://github.com/SigNoz/components/issues/116
input[type='number'] {
display: flex;
width: 120px;
height: 32px;
align-items: center;
gap: 4px;
border-radius: 2px 0 0 2px;
border: 1px solid var(--l2-border);
background: transparent;
color: var(--l2-foreground);
text-align: left;
-moz-appearance: textfield;
appearance: textfield;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 400;
line-height: 16px;
box-shadow: none;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&:disabled {
opacity: 0.8;
cursor: not-allowed;
}
}
.ant-select {
.ant-select-selector {
display: flex;
height: 32px;
padding: 6px 6px 6px 8px;
align-items: center;
gap: 4px;
border: 1px solid var(--l2-border);
background: var(--l2-background);
width: 80px;
}
}
}
.retention-error-text {
font-size: 12px;
color: var(--accent-amber);
font-style: italic;
}
.retention-modal-description {
margin: 0;
color: var(--l1-foreground);
font-size: 14px;
line-height: 22px;
}

View File

@@ -4,16 +4,20 @@ import { useTranslation } from 'react-i18next';
import { UseQueryResult } from 'react-query';
import { useInterval } from 'react-use';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Card, Col, Divider, Modal, Row, Spin, Typography } from 'antd';
import { Button } from '@signozhq/button';
import { Compass, ScrollText } from '@signozhq/icons';
import { Modal, Spin } from 'antd';
import setRetentionApi from 'api/settings/setRetention';
import setRetentionApiV2 from 'api/settings/setRetentionV2';
import TextToolTip from 'components/TextToolTip';
import CustomDomainSettings from 'container/CustomDomainSettings';
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { StatusCodes } from 'http-status-codes';
import find from 'lodash-es/find';
import { BarChart2 } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import {
ErrorResponse,
@@ -32,13 +36,17 @@ import {
PayloadPropsMetrics as GetRetentionPeriodMetricsPayload,
PayloadPropsTraces as GetRetentionPeriodTracesPayload,
} from 'types/api/settings/getRetention';
import { USER_ROLES } from 'types/roles';
import Retention from './Retention';
import StatusMessage from './StatusMessage';
import { ActionItemsContainer, ErrorText, ErrorTextContainer } from './styles';
import './GeneralSettings.styles.scss';
type NumberOrNull = number | null;
// eslint-disable-next-line sonarjs/cognitive-complexity
function GeneralSettings({
metricsTtlValuesPayload,
tracesTtlValuesPayload,
@@ -456,12 +464,20 @@ function GeneralSettings({
onModalToggleHandler(type);
};
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const {
isCloudUser: isCloudUserVal,
isEnterpriseSelfHostedUser,
} = useGetTenantLicense();
const isAdmin = user.role === USER_ROLES.ADMIN;
const showCustomDomainSettings =
(isCloudUserVal || isEnterpriseSelfHostedUser) && isAdmin;
const renderConfig = [
{
name: 'Metrics',
type: 'metrics',
icon: <BarChart2 size={14} />,
retentionFields: [
{
name: t('total_retention_period'),
@@ -504,6 +520,7 @@ function GeneralSettings({
{
name: 'Traces',
type: 'traces',
icon: <Compass size={14} />,
retentionFields: [
{
name: t('total_retention_period'),
@@ -544,6 +561,7 @@ function GeneralSettings({
{
name: 'Logs',
type: 'logs',
icon: <ScrollText size={14} />,
retentionFields: [
{
name: t('total_retention_period'),
@@ -588,69 +606,66 @@ function GeneralSettings({
) {
return (
<Fragment key={category.name}>
<Col xs={22} xl={11} key={category.name} style={{ margin: '0.5rem' }}>
<Card style={{ height: '100%' }}>
<Typography.Title style={{ margin: 0 }} level={3}>
{category.name}
</Typography.Title>
<Divider
style={{
margin: '0.5rem 0',
padding: 0,
opacity: 0.5,
marginBottom: '1rem',
}}
/>
{category.retentionFields.map((retentionField) => (
<div className="retention-row">
<span className="retention-row-label">
{category.icon}
{category.name}
</span>
<div className="retention-row-controls">
{category.retentionFields.map((field) => (
<Retention
key={field.name}
type={category.type as TTTLType}
key={retentionField.name}
text={retentionField.name}
retentionValue={retentionField.value}
setRetentionValue={retentionField.setValue}
hide={!!retentionField.hide}
isS3Field={'isS3Field' in retentionField && retentionField.isS3Field}
text={field.name}
retentionValue={field.value}
setRetentionValue={field.setValue}
hide={!!field.hide}
isS3Field={'isS3Field' in field && !!field.isS3Field}
compact
/>
))}
{!isCloudUserVal && (
<>
<ActionItemsContainer>
<Button
type="primary"
onClick={category.save.modalOpen}
disabled={category.save.isDisabled}
>
{category.save.saveButtonText}
</Button>
{category.statusComponent}
</ActionItemsContainer>
<Modal
title={t('retention_confirmation')}
focusTriggerAfterClose
forceRender
destroyOnClose
closable
onCancel={(): void =>
onModalToggleHandler(category.name.toLowerCase() as TTTLType)
}
onOk={(): Promise<void> =>
onOkHandler(category.name.toLowerCase() as TTTLType)
}
centered
open={category.save.modal}
confirmLoading={category.save.apiLoading}
>
<Typography>
{t('retention_confirmation_description', {
name: category.name.toLowerCase(),
})}
</Typography>
</Modal>
</>
<Button
variant="solid"
size="sm"
color="primary"
onClick={category.save.modalOpen}
disabled={category.save.isDisabled}
>
{category.save.saveButtonText}
</Button>
)}
</Card>
</Col>
</div>
</div>
{!isCloudUserVal && (
<ActionItemsContainer>{category.statusComponent}</ActionItemsContainer>
)}
{!isCloudUserVal && (
<Modal
title={t('retention_confirmation')}
focusTriggerAfterClose
forceRender
destroyOnClose
closable
onCancel={(): void =>
onModalToggleHandler(category.name.toLowerCase() as TTTLType)
}
onOk={(): Promise<void> =>
onOkHandler(category.name.toLowerCase() as TTTLType)
}
centered
open={category.save.modal}
confirmLoading={category.save.apiLoading}
>
<p className="retention-modal-description">
{t('retention_confirmation_description', {
name: category.name.toLowerCase(),
})}
</p>
</Modal>
)}
</Fragment>
);
}
@@ -658,9 +673,24 @@ function GeneralSettings({
});
return (
<>
{Element}
<Col xs={24} md={22} xl={20} xxl={18} style={{ margin: 'auto' }}>
<div className="general-settings-page">
<div className="general-settings-header">
<span className="general-settings-title">General</span>
<span className="general-settings-subtitle">
Manage your workspace settings.
</span>
</div>
{showCustomDomainSettings && <CustomDomainSettings />}
<div className="retention-controls-container">
<div className="retention-controls-header">
<span className="retention-controls-header-label">Retention Controls</span>
</div>
{renderConfig}
</div>
{(!isCloudUserVal || errorText) && (
<ErrorTextContainer>
{!isCloudUserVal && (
<TextToolTip
@@ -672,12 +702,10 @@ function GeneralSettings({
)}
{errorText && <ErrorText>{errorText}</ErrorText>}
</ErrorTextContainer>
)}
<Row justify="start">{renderConfig}</Row>
{isCloudUserVal && <GeneralSettingsCloud />}
</Col>
</>
{isCloudUserVal && <GeneralSettingsCloud />}
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
useRef,
useState,
} from 'react';
import { Input as SignozInput } from '@signozhq/input';
import { Col, Row, Select } from 'antd';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { find } from 'lodash-es';
@@ -34,6 +35,7 @@ function Retention({
text,
hide,
isS3Field = false,
compact = false,
}: RetentionProps): JSX.Element | null {
// Filter available units based on type and field
const availableUnits = useMemo(
@@ -126,6 +128,27 @@ function Retention({
return null;
}
if (compact) {
return (
<div className="retention-input-group">
<SignozInput
type="number"
min={0}
value={selectedValue && selectedValue >= 0 ? selectedValue : ''}
disabled={isCloudUserVal}
onChange={(e): void => onChangeHandler(e, setSelectedValue)}
/>
<Select
value={selectedTimeUnit}
onChange={currentSelectedOption}
disabled={isCloudUserVal}
>
{menuItems}
</Select>
</div>
);
}
return (
<RetentionContainer>
<Row justify="space-between">
@@ -162,9 +185,11 @@ interface RetentionProps {
setRetentionValue: Dispatch<SetStateAction<number | null>>;
hide: boolean;
isS3Field?: boolean;
compact?: boolean;
}
Retention.defaultProps = {
isS3Field: false,
compact: false,
};
export default Retention;

View File

@@ -35,8 +35,12 @@ jest.mock('hooks/useComponentPermission', () => ({
}));
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: (): { isCloudUser: boolean } => ({
useGetTenantLicense: (): {
isCloudUser: boolean;
isEnterpriseSelfHostedUser: boolean;
} => ({
isCloudUser: false,
isEnterpriseSelfHostedUser: false,
}),
}));
@@ -93,10 +97,12 @@ const mockDisksWithoutS3: IDiskType[] = [
},
];
describe('GeneralSettings - S3 Logs Retention', () => {
const BUTTON_SELECTOR = 'button[type="button"]';
const PRIMARY_BUTTON_CLASS = 'ant-btn-primary';
const getLogsRow = (): HTMLElement => {
const logsLabel = screen.getByText('Logs');
return logsLabel.closest('.retention-row') as HTMLElement;
};
describe('GeneralSettings - S3 Logs Retention', () => {
beforeEach(() => {
jest.clearAllMocks();
(setRetentionApiV2 as jest.Mock).mockResolvedValue({
@@ -121,22 +127,20 @@ describe('GeneralSettings - S3 Logs Retention', () => {
/>,
);
// Find the Logs card
const logsCard = screen.getByText('Logs').closest('.ant-card');
expect(logsCard).toBeInTheDocument();
const logsRow = getLogsRow();
expect(logsRow).toBeInTheDocument();
// Find all inputs in the Logs card - there should be 2 (total retention + S3)
// eslint-disable-next-line sonarjs/no-duplicate-string
const inputs = logsCard?.querySelectorAll('input[type="text"]');
// Find all inputs in the Logs row - there should be 2 (total retention + S3)
const inputs = logsRow.querySelectorAll('input[type="number"]');
expect(inputs).toHaveLength(2);
// The second input is the S3 retention field
const s3Input = inputs?.[1] as HTMLInputElement;
const s3Input = inputs[1] as HTMLInputElement;
// Find the S3 dropdown (next sibling of the S3 input)
const s3Dropdown = s3Input?.nextElementSibling?.querySelector(
'.ant-select-selector',
) as HTMLElement;
const s3Dropdown = s3Input
?.closest('.retention-row-controls')
?.querySelectorAll('.ant-select-selector')[1] as HTMLElement;
expect(s3Dropdown).toBeInTheDocument();
// Click the S3 dropdown to open it
@@ -144,7 +148,6 @@ describe('GeneralSettings - S3 Logs Retention', () => {
// Wait for dropdown options to appear and verify only "Days" is available
await waitFor(() => {
// eslint-disable-next-line sonarjs/no-duplicate-string
const dropdownOptions = document.querySelectorAll('.ant-select-item');
expect(dropdownOptions).toHaveLength(1);
expect(dropdownOptions[0]).toHaveTextContent('Days');
@@ -157,16 +160,13 @@ describe('GeneralSettings - S3 Logs Retention', () => {
await user.clear(s3Input);
await user.type(s3Input, '5');
// Find the save button in the Logs card
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
// The primary button should be the save button
const saveButton = Array.from(buttons || []).find((btn) =>
btn.className.includes(PRIMARY_BUTTON_CLASS),
// Find the save button in the Logs row
const saveButton = logsRow.querySelector(
'button:not([disabled])',
) as HTMLButtonElement;
expect(saveButton).toBeInTheDocument();
// Wait for button to be enabled (it should enable after value changes)
// Wait for button to be enabled
await waitFor(() => {
expect(saveButton).not.toBeDisabled();
});
@@ -207,8 +207,8 @@ describe('GeneralSettings - S3 Logs Retention', () => {
);
// Verify S3 field is visible
const logsCard = screen.getByText('Logs').closest('.ant-card');
const inputs = logsCard?.querySelectorAll('input[type="text"]');
const logsRow = getLogsRow();
const inputs = logsRow.querySelectorAll('input[type="number"]');
expect(inputs).toHaveLength(2); // Total + S3
});
});
@@ -229,19 +229,18 @@ describe('GeneralSettings - S3 Logs Retention', () => {
/>,
);
// Find the Logs card
const logsCard = screen.getByText('Logs').closest('.ant-card');
expect(logsCard).toBeInTheDocument();
const logsRow = getLogsRow();
expect(logsRow).toBeInTheDocument();
// Only 1 input should be visible (total retention, no S3)
const inputs = logsCard?.querySelectorAll('input[type="text"]');
const inputs = logsRow.querySelectorAll('input[type="number"]');
expect(inputs).toHaveLength(1);
// Change total retention value
const totalInput = inputs?.[0] as HTMLInputElement;
const totalInput = inputs[0] as HTMLInputElement;
// First, change the dropdown to Days (it defaults to Months)
const totalDropdown = totalInput?.nextElementSibling?.querySelector(
const totalDropdown = logsRow.querySelector(
'.ant-select-selector',
) as HTMLElement;
await user.click(totalDropdown);
@@ -265,14 +264,12 @@ describe('GeneralSettings - S3 Logs Retention', () => {
await user.type(totalInput, '60');
// Find the save button
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
const saveButton = Array.from(buttons || []).find((btn) =>
btn.className.includes(PRIMARY_BUTTON_CLASS),
const saveButton = logsRow.querySelector(
'button:not([disabled])',
) as HTMLButtonElement;
expect(saveButton).toBeInTheDocument();
// Wait for button to be enabled (ensures all state updates have settled)
// Wait for button to be enabled
await waitFor(() => {
expect(saveButton).not.toBeDisabled();
});
@@ -314,22 +311,21 @@ describe('GeneralSettings - S3 Logs Retention', () => {
/>,
);
// Find the Logs card
const logsCard = screen.getByText('Logs').closest('.ant-card');
const inputs = logsCard?.querySelectorAll('input[type="text"]');
const logsRow = getLogsRow();
const inputs = logsRow.querySelectorAll('input[type="number"]');
// Total retention: 720 hours = 30 days = 1 month (displays as 1 Month)
const totalInput = inputs?.[0] as HTMLInputElement;
// Total retention: 30 days = 1 month (displays as 1 Month)
const totalInput = inputs[0] as HTMLInputElement;
expect(totalInput.value).toBe('1');
// S3 retention: 24 day
const s3Input = inputs?.[1] as HTMLInputElement;
// S3 retention: 24 days
const s3Input = inputs[1] as HTMLInputElement;
expect(s3Input.value).toBe('24');
// Verify dropdowns: total shows Months, S3 shows Days
const dropdowns = logsCard?.querySelectorAll('.ant-select-selection-item');
expect(dropdowns?.[0]).toHaveTextContent('Months');
expect(dropdowns?.[1]).toHaveTextContent('Days');
const dropdowns = logsRow.querySelectorAll('.ant-select-selection-item');
expect(dropdowns[0]).toHaveTextContent('Months');
expect(dropdowns[1]).toHaveTextContent('Days');
});
});
@@ -349,24 +345,22 @@ describe('GeneralSettings - S3 Logs Retention', () => {
/>,
);
// Find the Logs card
const logsCard = screen.getByText('Logs').closest('.ant-card');
expect(logsCard).toBeInTheDocument();
const logsRow = getLogsRow();
expect(logsRow).toBeInTheDocument();
// Find the save button
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
const saveButton = Array.from(buttons || []).find((btn) =>
btn.className.includes(PRIMARY_BUTTON_CLASS),
// Find the save button by accessible name within the Logs row
const allSaveButtons = screen.getAllByRole('button', { name: /save/i });
const saveButton = allSaveButtons.find((btn) =>
logsRow.contains(btn),
) as HTMLButtonElement;
expect(saveButton).toBeInTheDocument();
// Verify save button is disabled on initial load (no changes, S3 disabled with -1)
// Verify save button is disabled on initial load
expect(saveButton).toBeDisabled();
// Find the total retention input
const inputs = logsCard?.querySelectorAll('input[type="text"]');
const totalInput = inputs?.[0] as HTMLInputElement;
const inputs = logsRow.querySelectorAll('input[type="number"]');
const totalInput = inputs[0] as HTMLInputElement;
// Change total retention value to trigger button enable
await user.clear(totalInput);

View File

@@ -1,5 +1,5 @@
.general-settings-container {
margin: 16px 8px;
margin: 16px 0px;
.ant-card-body {
display: flex;

View File

@@ -1,6 +1,6 @@
/* eslint-disable sonarjs/no-identical-functions */
import { useEffect, useState } from 'react';
import { Button, Skeleton, Tag, Typography } from 'antd';
import { useMemo } from 'react';
import { Button } from '@signozhq/button';
import { Skeleton } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetHosts } from 'api/generated/services/zeus';
import ROUTES from 'constants/routes';
@@ -30,45 +30,44 @@ function DataSourceInfo({
query: { enabled: isEnabled || false },
});
const [url, setUrl] = useState<string>('');
const activeHost = useMemo(
() =>
hostsData?.data?.hosts?.find((h) => !h.is_default) ??
hostsData?.data?.hosts?.find((h) => h.is_default),
[hostsData],
);
useEffect(() => {
if (hostsData) {
const defaultHost = hostsData?.data.hosts?.find((h) => h.is_default);
if (defaultHost?.url) {
const url = defaultHost?.url?.split('://')[1] ?? '';
setUrl(url);
}
}
}, [hostsData]);
const url = useMemo(() => activeHost?.url?.split('://')[1] ?? '', [
activeHost,
]);
const renderNotSendingData = (): JSX.Element => (
<>
<Typography className="welcome-title">
<h2 className="welcome-title">
Hello there, Welcome to your SigNoz workspace
</Typography>
</h2>
<Typography className="welcome-description">
<p className="welcome-description">
Youre not sending any data yet. <br />
SigNoz is so much better with your data start by sending your telemetry
data to SigNoz.
</Typography>
</p>
<Card className="welcome-card">
<Card.Content>
<div className="workspace-ready-container">
<div className="workspace-ready-header">
<Typography className="workspace-ready-title">
<span className="workspace-ready-title">
<img src="/Icons/hurray.svg" alt="hurray" />
Your workspace is ready
</Typography>
</span>
<Button
type="primary"
variant="solid"
color="primary"
size="sm"
className="periscope-btn primary"
icon={<img src="/Icons/container-plus.svg" alt="plus" />}
role="button"
tabIndex={0}
prefixIcon={<img src="/Icons/container-plus.svg" alt="plus" />}
onClick={(): void => {
logEvent('Homepage: Connect dataSource clicked', {});
@@ -85,24 +84,6 @@ function DataSourceInfo({
);
}
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
logEvent('Homepage: Connect dataSource clicked', {});
if (
activeLicense &&
activeLicense.platform === LicensePlatform.CLOUD
) {
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
} else {
window?.open(
DOCS_LINKS.ADD_DATA_SOURCE,
'_blank',
'noopener noreferrer',
);
}
}
}}
>
Connect Data Source
</Button>
@@ -113,13 +94,7 @@ function DataSourceInfo({
<div className="workspace-url">
<Link2 size={12} />
<Typography className="workspace-url-text">
{url}
<Tag color="default" className="workspace-url-tag">
default
</Tag>
</Typography>
<span className="workspace-url-text">{url}</span>
</div>
</div>
)}
@@ -131,9 +106,9 @@ function DataSourceInfo({
const renderDataReceived = (): JSX.Element => (
<>
<Typography className="welcome-title">
<h2 className="welcome-title">
Hello there, Welcome to your SigNoz workspace
</Typography>
</h2>
{!isError && hostsData && (
<Card className="welcome-card">
@@ -143,13 +118,7 @@ function DataSourceInfo({
<div className="workspace-url">
<Link2 size={12} />
<Typography className="workspace-url-text">
{url}
<Tag color="default" className="workspace-url-tag">
default
</Tag>
</Typography>
<span className="workspace-url-text">{url}</span>
</div>
</div>
</div>

View File

@@ -30,7 +30,7 @@ const mockHostsResponse: GetHosts200 = {
describe('DataSourceInfo', () => {
afterEach(() => server.resetHandlers());
it('renders the default workspace URL with protocol stripped', async () => {
it('renders the active workspace URL with protocol stripped', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
@@ -39,7 +39,7 @@ describe('DataSourceInfo', () => {
render(<DataSourceInfo dataSentToSigNoz={false} isLoading={false} />);
await screen.findByText(/accepted-starfish\.test\.cloud/i);
await screen.findByText(/custom-host\.test\.cloud/i);
});
it('does not render workspace URL when GET /zeus/hosts fails', async () => {
@@ -55,7 +55,7 @@ describe('DataSourceInfo', () => {
expect(screen.queryByText(/signoz\.cloud/i)).not.toBeInTheDocument();
});
it('renders workspace URL in the data-received view when telemetry is flowing', async () => {
it('renders active workspace URL in the data-received view when telemetry is flowing', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
@@ -64,6 +64,6 @@ describe('DataSourceInfo', () => {
render(<DataSourceInfo dataSentToSigNoz={true} isLoading={false} />);
await screen.findByText(/accepted-starfish\.test\.cloud/i);
await screen.findByText(/custom-host\.test\.cloud/i);
});
});

View File

@@ -296,19 +296,11 @@
flex-direction: row;
align-items: center;
gap: 8px;
.workspace-url-tag {
font-size: 10px;
font-weight: 400;
line-height: 18px; /* 150% */
letter-spacing: 0.12px;
border-radius: 3px;
border: 1px solid var(--Slate-400, #1d212d);
background: var(--Ink-400, #121317);
color: var(--Vanilla-400, #c0c1c3);
}
font-size: 11px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.12px;
color: var(--foreground);
}
}

View File

@@ -13,7 +13,6 @@ import {
DraftingCompass,
FileKey2,
Github,
Globe,
HardDrive,
Home,
Key,
@@ -327,13 +326,7 @@ export const settingsMenuItems: SidebarItem[] = [
isEnabled: false,
itemKey: 'members-sso',
},
{
key: ROUTES.CUSTOM_DOMAIN_SETTINGS,
label: 'Custom Domain',
icon: <Globe size={16} />,
isEnabled: false,
itemKey: 'custom-domain',
},
{
key: ROUTES.INTEGRATIONS,
label: 'Integrations',

View File

@@ -19,7 +19,6 @@ const breadcrumbNameMap: Record<string, string> = {
[ROUTES.ORG_SETTINGS]: 'Organization Settings',
[ROUTES.INGESTION_SETTINGS]: 'Ingestion Settings',
[ROUTES.MY_SETTINGS]: 'My Settings',
[ROUTES.CUSTOM_DOMAIN_SETTINGS]: 'Custom Domain Settings',
[ROUTES.ERROR_DETAIL]: 'Exceptions',
[ROUTES.LIST_ALL_ALERT]: 'Alerts',
[ROUTES.ALL_DASHBOARD]: 'Dashboard',

View File

@@ -154,7 +154,6 @@ export const routesToSkip = [
ROUTES.ALL_DASHBOARD,
ROUTES.ORG_SETTINGS,
ROUTES.INGESTION_SETTINGS,
ROUTES.CUSTOM_DOMAIN_SETTINGS,
ROUTES.API_KEYS,
ROUTES.ERROR_DETAIL,
ROUTES.LOGS_PIPELINES,

View File

@@ -79,7 +79,6 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.CUSTOM_DOMAIN_SETTINGS ||
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS ||

View File

@@ -5,7 +5,6 @@ import APIKeys from 'container/APIKeys/APIKeys';
import BillingContainer from 'container/BillingContainer/BillingContainer';
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import CustomDomainSettings from 'container/CustomDomainSettings';
import GeneralSettings from 'container/GeneralSettings';
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
import IngestionSettings from 'container/IngestionSettings/IngestionSettings';
@@ -20,7 +19,6 @@ import {
Building,
Cpu,
CreditCard,
Globe,
Keyboard,
KeySquare,
Pencil,
@@ -124,19 +122,6 @@ export const apiKeys = (t: TFunction): RouteTabProps['routes'] => [
},
];
export const customDomainSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: CustomDomainSettings,
name: (
<div className="periscope-tab">
<Globe size={16} /> {t('routes:custom_domain_settings').toString()}
</div>
),
route: ROUTES.CUSTOM_DOMAIN_SETTINGS,
key: ROUTES.CUSTOM_DOMAIN_SETTINGS,
},
];
export const billingSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: BillingContainer,

View File

@@ -7,7 +7,6 @@ import {
apiKeys,
billingSettings,
createAlertChannels,
customDomainSettings,
editAlertChannels,
generalSettings,
ingestionSettings,
@@ -64,7 +63,7 @@ export const getRoutes = (
}
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {
settings.push(...customDomainSettings(t), ...billingSettings(t));
settings.push(...billingSettings(t));
}
if (isAdmin) {

View File

@@ -106,7 +106,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
TRACES_FUNNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACES_FUNNELS_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
API_KEYS: ['ADMIN'],
CUSTOM_DOMAIN_SETTINGS: ['ADMIN'],
LOGS_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
OLD_LOGS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
SHORTCUTS: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -1,4 +1,4 @@
package units
package converter
// boolConverter is a Converter implementation for bool
type boolConverter struct{}

View File

@@ -1,4 +1,4 @@
package units
package converter
// Unit represents a unit of measurement
type Unit string
@@ -40,8 +40,8 @@ var (
NoneConverter = &noneConverter{}
)
// ConverterFromUnit returns a converter for the given unit
func ConverterFromUnit(u Unit) Converter {
// FromUnit returns a converter for the given unit
func FromUnit(u Unit) Converter {
switch u {
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min", "w", "wk":
return DurationConverter

View File

@@ -1,4 +1,4 @@
package units
package converter
const (
// base 10 (SI prefixes)

View File

@@ -1,4 +1,4 @@
package units
package converter
const (
// base 10 (SI prefixes)

View File

@@ -1,4 +1,4 @@
package units
package converter
import (
"testing"

View File

@@ -1,4 +1,4 @@
package units
package converter
import (
"testing"

View File

@@ -1,4 +1,4 @@
package units
package converter
// percentConverter is a converter for percent unit
type percentConverter struct{}

View File

@@ -1,4 +1,4 @@
package units
package converter
import (
"testing"

View File

@@ -1,4 +1,4 @@
package units
package converter
// throughputConverter is an implementation of Converter that converts throughput
type throughputConverter struct {

View File

@@ -1,4 +1,4 @@
package units
package converter
type Duration float64

View File

@@ -1,4 +1,4 @@
package units
package converter
import (
"testing"

View File

@@ -1,4 +1,4 @@
package units
package formatter
import "fmt"

View File

@@ -0,0 +1,86 @@
package formatter
import (
"fmt"
"github.com/SigNoz/signoz/pkg/query-service/converter"
"github.com/dustin/go-humanize"
)
type dataFormatter struct {
}
func NewDataFormatter() Formatter {
return &dataFormatter{}
}
func (*dataFormatter) Name() string {
return "data"
}
func (f *dataFormatter) Format(value float64, unit string) string {
switch unit {
case "bytes", "By":
return humanize.IBytes(uint64(value))
case "decbytes":
return humanize.Bytes(uint64(value))
case "bits", "bit":
// humanize.IBytes/Bytes doesn't support bits
// and returns 0 B for values less than a byte
if value < 8 {
return fmt.Sprintf("%v b", value)
}
return humanize.IBytes(uint64(value / 8))
case "decbits":
if value < 8 {
return fmt.Sprintf("%v b", value)
}
return humanize.Bytes(uint64(value / 8))
case "kbytes", "KiBy":
return humanize.IBytes(uint64(value * converter.Kibibit))
case "Kibit":
return humanize.IBytes(uint64(value * converter.Kibibit / 8))
case "decKbytes", "deckbytes", "kBy":
return humanize.Bytes(uint64(value * converter.Kilobit))
case "kbit":
return humanize.Bytes(uint64(value * converter.Kilobit / 8))
case "mbytes", "MiBy":
return humanize.IBytes(uint64(value * converter.Mebibit))
case "Mibit":
return humanize.IBytes(uint64(value * converter.Mebibit / 8))
case "decMbytes", "decmbytes", "MBy":
return humanize.Bytes(uint64(value * converter.Megabit))
case "Mbit":
return humanize.Bytes(uint64(value * converter.Megabit / 8))
case "gbytes", "GiBy":
return humanize.IBytes(uint64(value * converter.Gibibit))
case "Gibit":
return humanize.IBytes(uint64(value * converter.Gibibit / 8))
case "decGbytes", "decgbytes", "GBy":
return humanize.Bytes(uint64(value * converter.Gigabit))
case "Gbit":
return humanize.Bytes(uint64(value * converter.Gigabit / 8))
case "tbytes", "TiBy":
return humanize.IBytes(uint64(value * converter.Tebibit))
case "Tibit":
return humanize.IBytes(uint64(value * converter.Tebibit / 8))
case "decTbytes", "dectbytes", "TBy":
return humanize.Bytes(uint64(value * converter.Terabit))
case "Tbit":
return humanize.Bytes(uint64(value * converter.Terabit / 8))
case "pbytes", "PiBy":
return humanize.IBytes(uint64(value * converter.Pebibit))
case "Pbit":
return humanize.Bytes(uint64(value * converter.Petabit / 8))
case "decPbytes", "decpbytes", "PBy":
return humanize.Bytes(uint64(value * converter.Petabit))
case "EiBy":
return humanize.IBytes(uint64(value * converter.Exbibit))
case "Ebit":
return humanize.Bytes(uint64(value * converter.Exabit / 8))
case "EBy":
return humanize.Bytes(uint64(value * converter.Exabit))
}
// When unit is not matched, return the value as it is.
return fmt.Sprintf("%v", value)
}

View File

@@ -0,0 +1,91 @@
package formatter
import (
"fmt"
"github.com/SigNoz/signoz/pkg/query-service/converter"
"github.com/dustin/go-humanize"
)
type dataRateFormatter struct {
}
func NewDataRateFormatter() Formatter {
return &dataRateFormatter{}
}
func (*dataRateFormatter) Name() string {
return "data_rate"
}
func (f *dataRateFormatter) Format(value float64, unit string) string {
switch unit {
case "binBps":
return humanize.IBytes(uint64(value)) + "/s"
case "Bps", "By/s":
return humanize.Bytes(uint64(value)) + "/s"
case "binbps":
// humanize.IBytes/Bytes doesn't support bits
// and returns 0 B for values less than a byte
if value < 8 {
return fmt.Sprintf("%v b/s", value)
}
return humanize.IBytes(uint64(value/8)) + "/s"
case "bps", "bit/s":
if value < 8 {
return fmt.Sprintf("%v b/s", value)
}
return humanize.Bytes(uint64(value/8)) + "/s"
case "KiBs", "KiBy/s":
return humanize.IBytes(uint64(value*converter.KibibitPerSecond)) + "/s"
case "Kibits", "Kibit/s":
return humanize.IBytes(uint64(value*converter.KibibitPerSecond/8)) + "/s"
case "KBs", "kBy/s":
return humanize.IBytes(uint64(value*converter.KilobitPerSecond)) + "/s"
case "Kbits", "kbit/s":
return humanize.Bytes(uint64(value*converter.KilobitPerSecond/8)) + "/s"
case "MiBs", "MiBy/s":
return humanize.IBytes(uint64(value*converter.MebibitPerSecond)) + "/s"
case "Mibits", "Mibit/s":
return humanize.IBytes(uint64(value*converter.MebibitPerSecond/8)) + "/s"
case "MBs", "MBy/s":
return humanize.IBytes(uint64(value*converter.MegabitPerSecond)) + "/s"
case "Mbits", "Mbit/s":
return humanize.Bytes(uint64(value*converter.MegabitPerSecond/8)) + "/s"
case "GiBs", "GiBy/s":
return humanize.IBytes(uint64(value*converter.GibibitPerSecond)) + "/s"
case "Gibits", "Gibit/s":
return humanize.IBytes(uint64(value*converter.GibibitPerSecond/8)) + "/s"
case "GBs", "GBy/s":
return humanize.IBytes(uint64(value*converter.GigabitPerSecond)) + "/s"
case "Gbits", "Gbit/s":
return humanize.Bytes(uint64(value*converter.GigabitPerSecond/8)) + "/s"
case "TiBs", "TiBy/s":
return humanize.IBytes(uint64(value*converter.TebibitPerSecond)) + "/s"
case "Tibits", "Tibit/s":
return humanize.IBytes(uint64(value*converter.TebibitPerSecond/8)) + "/s"
case "TBs", "TBy/s":
return humanize.IBytes(uint64(value*converter.TerabitPerSecond)) + "/s"
case "Tbits", "Tbit/s":
return humanize.Bytes(uint64(value*converter.TerabitPerSecond/8)) + "/s"
case "PiBs", "PiBy/s":
return humanize.IBytes(uint64(value*converter.PebibitPerSecond)) + "/s"
case "Pibits", "Pibit/s":
return humanize.IBytes(uint64(value*converter.PebibitPerSecond/8)) + "/s"
case "PBs", "PBy/s":
return humanize.IBytes(uint64(value*converter.PetabitPerSecond)) + "/s"
case "Pbits", "Pbit/s":
return humanize.Bytes(uint64(value*converter.PetabitPerSecond/8)) + "/s"
// Exa units
case "EBy/s":
return humanize.Bytes(uint64(value*converter.ExabitPerSecond)) + "/s"
case "Ebit/s":
return humanize.Bytes(uint64(value*converter.ExabitPerSecond/8)) + "/s"
case "EiBy/s":
return humanize.IBytes(uint64(value*converter.ExbibitPerSecond)) + "/s"
case "Eibit/s":
return humanize.IBytes(uint64(value*converter.ExbibitPerSecond/8)) + "/s"
}
// When unit is not matched, return the value as it is.
return fmt.Sprintf("%v", value)
}

View File

@@ -1,4 +1,4 @@
package units
package formatter
import (
"testing"

View File

@@ -1,4 +1,4 @@
package units
package formatter
import (
"testing"
@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestFormatterData(t *testing.T) {
func TestData(t *testing.T) {
dataFormatter := NewDataFormatter()
assert.Equal(t, "1 B", dataFormatter.Format(1, "bytes"))

View File

@@ -1,4 +1,4 @@
package units
package formatter
type Formatter interface {
Format(value float64, unit string) string
@@ -16,7 +16,7 @@ var (
ThroughputFormatter = NewThroughputFormatter()
)
func FormatterFromUnit(u string) Formatter {
func FromUnit(u string) Formatter {
switch u {
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min", "w", "wk":
return DurationFormatter

View File

@@ -1,4 +1,4 @@
package units
package formatter
import "fmt"

View File

@@ -1,4 +1,4 @@
package units
package formatter
import "fmt"

View File

@@ -1,4 +1,4 @@
package units
package formatter
import (
"fmt"
@@ -14,36 +14,36 @@ type IntervalsInSecondsType map[Interval]int
type Interval string
const (
IntervalYear Interval = "year"
IntervalMonth Interval = "month"
IntervalWeek Interval = "week"
IntervalDay Interval = "day"
IntervalHour Interval = "hour"
IntervalMinute Interval = "minute"
IntervalSecond Interval = "second"
IntervalMillisecond Interval = "millisecond"
Year Interval = "year"
Month Interval = "month"
Week Interval = "week"
Day Interval = "day"
Hour Interval = "hour"
Minute Interval = "minute"
Second Interval = "second"
Millisecond Interval = "millisecond"
)
var Units = []Interval{
IntervalYear,
IntervalMonth,
IntervalWeek,
IntervalDay,
IntervalHour,
IntervalMinute,
IntervalSecond,
IntervalMillisecond,
Year,
Month,
Week,
Day,
Hour,
Minute,
Second,
Millisecond,
}
var IntervalsInSeconds = IntervalsInSecondsType{
IntervalYear: 31536000,
IntervalMonth: 2592000,
IntervalWeek: 604800,
IntervalDay: 86400,
IntervalHour: 3600,
IntervalMinute: 60,
IntervalSecond: 1,
IntervalMillisecond: 1,
Year: 31536000,
Month: 2592000,
Week: 604800,
Day: 86400,
Hour: 3600,
Minute: 60,
Second: 1,
Millisecond: 1,
}
type DecimalCount *int
@@ -78,8 +78,9 @@ func toFixed(value float64, decimals DecimalCount) string {
}
decimalPos := strings.Index(formatted, ".")
precision := 0
if decimalPos != -1 {
precision := len(formatted) - decimalPos - 1
precision = len(formatted) - decimalPos - 1
if precision < *decimals {
return formatted + strings.Repeat("0", *decimals-precision)
}
@@ -88,8 +89,8 @@ func toFixed(value float64, decimals DecimalCount) string {
return formatted
}
func toFixedScaled(value float64, scaleFormat string) string {
return toFixed(value, nil) + scaleFormat
func toFixedScaled(value float64, decimals DecimalCount, scaleFormat string) string {
return toFixed(value, decimals) + scaleFormat
}
func getDecimalsForValue(value float64) int {

View File

@@ -1,4 +1,4 @@
package units
package formatter
import (
"testing"

View File

@@ -1,4 +1,4 @@
package units
package formatter
import "fmt"
@@ -13,35 +13,35 @@ func (*throughputFormatter) Name() string {
return "throughput"
}
func simpleCountUnit(value float64, symbol string) string {
func simpleCountUnit(value float64, decimals *int, symbol string) string {
units := []string{"", "K", "M", "B", "T"}
scaler := scaledUnits(1000, units, 0)
return scaler(value, nil) + " " + symbol
return scaler(value, decimals) + " " + symbol
}
func (f *throughputFormatter) Format(value float64, unit string) string {
switch unit {
case "cps", "{count}/s":
return simpleCountUnit(value, "c/s")
return simpleCountUnit(value, nil, "c/s")
case "ops", "{ops}/s":
return simpleCountUnit(value, "op/s")
return simpleCountUnit(value, nil, "op/s")
case "reqps", "{req}/s":
return simpleCountUnit(value, "req/s")
return simpleCountUnit(value, nil, "req/s")
case "rps", "{read}/s":
return simpleCountUnit(value, "r/s")
return simpleCountUnit(value, nil, "r/s")
case "wps", "{write}/s":
return simpleCountUnit(value, "w/s")
return simpleCountUnit(value, nil, "w/s")
case "iops", "{iops}/s":
return simpleCountUnit(value, "iops")
return simpleCountUnit(value, nil, "iops")
case "cpm", "{count}/min":
return simpleCountUnit(value, "c/m")
return simpleCountUnit(value, nil, "c/m")
case "opm", "{ops}/min":
return simpleCountUnit(value, "op/m")
return simpleCountUnit(value, nil, "op/m")
case "rpm", "{read}/min":
return simpleCountUnit(value, "r/m")
return simpleCountUnit(value, nil, "r/m")
case "wpm", "{write}/min":
return simpleCountUnit(value, "w/m")
return simpleCountUnit(value, nil, "w/m")
}
// When unit is not matched, return the value as it is.
return fmt.Sprintf("%v", value)

View File

@@ -1,4 +1,4 @@
package units
package formatter
import (
"testing"

View File

@@ -1,4 +1,4 @@
package units
package formatter
import (
"fmt"
@@ -46,17 +46,17 @@ func toNanoSeconds(value float64) string {
if absValue < 1000 {
return toFixed(value, nil) + " ns"
} else if absValue < 1000000 { // 2000 ns is better represented as 2 µs
return toFixedScaled(value/1000, " µs")
return toFixedScaled(value/1000, nil, " µs")
} else if absValue < 1000000000 { // 2000000 ns is better represented as 2 ms
return toFixedScaled(value/1000000, " ms")
return toFixedScaled(value/1000000, nil, " ms")
} else if absValue < 60000000000 {
return toFixedScaled(value/1000000000, " s")
return toFixedScaled(value/1000000000, nil, " s")
} else if absValue < 3600000000000 {
return toFixedScaled(value/60000000000, " min")
return toFixedScaled(value/60000000000, nil, " min")
} else if absValue < 86400000000000 {
return toFixedScaled(value/3600000000000, " hour")
return toFixedScaled(value/3600000000000, nil, " hour")
} else {
return toFixedScaled(value/86400000000000, " day")
return toFixedScaled(value/86400000000000, nil, " day")
}
}
@@ -66,9 +66,9 @@ func toMicroSeconds(value float64) string {
if absValue < 1000 {
return toFixed(value, nil) + " µs"
} else if absValue < 1000000 { // 2000 µs is better represented as 2 ms
return toFixedScaled(value/1000, " ms")
return toFixedScaled(value/1000, nil, " ms")
} else {
return toFixedScaled(value/1000000, " s")
return toFixedScaled(value/1000000, nil, " s")
}
}
@@ -80,16 +80,16 @@ func toMilliSeconds(value float64) string {
if absValue < 1000 {
return toFixed(value, nil) + " ms"
} else if absValue < 60000 {
return toFixedScaled(value/1000, " s")
return toFixedScaled(value/1000, nil, " s")
} else if absValue < 3600000 {
return toFixedScaled(value/60000, " min")
return toFixedScaled(value/60000, nil, " min")
} else if absValue < 86400000 { // 172800000 ms is better represented as 2 day
return toFixedScaled(value/3600000, " hour")
return toFixedScaled(value/3600000, nil, " hour")
} else if absValue < 31536000000 {
return toFixedScaled(value/86400000, " day")
return toFixedScaled(value/86400000, nil, " day")
}
return toFixedScaled(value/31536000000, " year")
return toFixedScaled(value/31536000000, nil, " year")
}
// toSeconds returns a easy to read string representation of the given value in seconds
@@ -97,24 +97,24 @@ func toSeconds(value float64) string {
absValue := math.Abs(value)
if absValue < 0.000001 {
return toFixedScaled(value*1e9, " ns")
return toFixedScaled(value*1e9, nil, " ns")
} else if absValue < 0.001 {
return toFixedScaled(value*1e6, " µs")
return toFixedScaled(value*1e6, nil, " µs")
} else if absValue < 1 {
return toFixedScaled(value*1e3, " ms")
return toFixedScaled(value*1e3, nil, " ms")
} else if absValue < 60 {
return toFixed(value, nil) + " s"
} else if absValue < 3600 {
return toFixedScaled(value/60, " min")
return toFixedScaled(value/60, nil, " min")
} else if absValue < 86400 { // 56000 s is better represented as 15.56 hour
return toFixedScaled(value/3600, " hour")
return toFixedScaled(value/3600, nil, " hour")
} else if absValue < 604800 {
return toFixedScaled(value/86400, " day")
return toFixedScaled(value/86400, nil, " day")
} else if absValue < 31536000 {
return toFixedScaled(value/604800, " week")
return toFixedScaled(value/604800, nil, " week")
}
return toFixedScaled(value/3.15569e7, " year")
return toFixedScaled(value/3.15569e7, nil, " year")
}
// toMinutes returns a easy to read string representation of the given value in minutes
@@ -124,13 +124,13 @@ func toMinutes(value float64) string {
if absValue < 60 {
return toFixed(value, nil) + " min"
} else if absValue < 1440 {
return toFixedScaled(value/60, " hour")
return toFixedScaled(value/60, nil, " hour")
} else if absValue < 10080 {
return toFixedScaled(value/1440, " day")
return toFixedScaled(value/1440, nil, " day")
} else if absValue < 604800 {
return toFixedScaled(value/10080, " week")
return toFixedScaled(value/10080, nil, " week")
} else {
return toFixedScaled(value/5.25948e5, " year")
return toFixedScaled(value/5.25948e5, nil, " year")
}
}
@@ -142,11 +142,11 @@ func toHours(value float64) string {
if absValue < 24 {
return toFixed(value, nil) + " hour"
} else if absValue < 168 {
return toFixedScaled(value/24, " day")
return toFixedScaled(value/24, nil, " day")
} else if absValue < 8760 {
return toFixedScaled(value/168, " week")
return toFixedScaled(value/168, nil, " week")
} else {
return toFixedScaled(value/8760, " year")
return toFixedScaled(value/8760, nil, " year")
}
}
@@ -157,9 +157,9 @@ func toDays(value float64) string {
if absValue < 7 {
return toFixed(value, nil) + " day"
} else if absValue < 365 {
return toFixedScaled(value/7, " week")
return toFixedScaled(value/7, nil, " week")
} else {
return toFixedScaled(value/365, " year")
return toFixedScaled(value/365, nil, " year")
}
}
@@ -170,6 +170,6 @@ func toWeeks(value float64) string {
if absValue < 52 {
return toFixed(value, nil) + " week"
} else {
return toFixedScaled(value/52, " year")
return toFixedScaled(value/52, nil, " year")
}
}

View File

@@ -1,4 +1,4 @@
package units
package formatter
import (
"testing"

View File

@@ -9,7 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/units"
"github.com/SigNoz/signoz/pkg/query-service/formatter"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
@@ -185,7 +185,7 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
prevState := r.State()
valueFormatter := units.FormatterFromUnit(r.Unit())
valueFormatter := formatter.FromUnit(r.Unit())
// prepare query, run query get data and filter the data based on the threshold
results, err := r.buildAndRunQuery(ctx, ts)

View File

@@ -33,7 +33,7 @@ import (
logsv3 "github.com/SigNoz/signoz/pkg/query-service/app/logs/v3"
tracesV4 "github.com/SigNoz/signoz/pkg/query-service/app/traces/v4"
"github.com/SigNoz/signoz/pkg/units"
"github.com/SigNoz/signoz/pkg/query-service/formatter"
querierV5 "github.com/SigNoz/signoz/pkg/querier"
@@ -571,7 +571,7 @@ func (r *ThresholdRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUI
func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
prevState := r.State()
valueFormatter := units.FormatterFromUnit(r.Unit())
valueFormatter := formatter.FromUnit(r.Unit())
var res ruletypes.Vector
var err error

View File

@@ -7,7 +7,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/units"
"github.com/SigNoz/signoz/pkg/query-service/converter"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -201,12 +201,12 @@ func sortThresholds(thresholds []BasicRuleThreshold) {
// convertToRuleUnit converts the given value from the target unit to the rule unit
func (b BasicRuleThreshold) convertToRuleUnit(val float64, ruleUnit string) float64 {
unitConverter := units.ConverterFromUnit(units.Unit(b.TargetUnit))
unitConverter := converter.FromUnit(converter.Unit(b.TargetUnit))
// convert the target value to the y-axis unit
value := unitConverter.Convert(units.Value{
value := unitConverter.Convert(converter.Value{
F: val,
U: units.Unit(b.TargetUnit),
}, units.Unit(ruleUnit))
U: converter.Unit(b.TargetUnit),
}, converter.Unit(ruleUnit))
return value.F
}

View File

@@ -1,85 +0,0 @@
package units
import (
"fmt"
"github.com/dustin/go-humanize"
)
type dataFormatter struct {
}
func NewDataFormatter() Formatter {
return &dataFormatter{}
}
func (*dataFormatter) Name() string {
return "data"
}
func (f *dataFormatter) Format(value float64, unit string) string {
switch unit {
case "bytes", "By":
return humanize.IBytes(uint64(value))
case "decbytes":
return humanize.Bytes(uint64(value))
case "bits", "bit":
// humanize.IBytes/Bytes doesn't support bits
// and returns 0 B for values less than a byte
if value < 8 {
return fmt.Sprintf("%v b", value)
}
return humanize.IBytes(uint64(value / 8))
case "decbits":
if value < 8 {
return fmt.Sprintf("%v b", value)
}
return humanize.Bytes(uint64(value / 8))
case "kbytes", "KiBy":
return humanize.IBytes(uint64(value * Kibibit))
case "Kibit":
return humanize.IBytes(uint64(value * Kibibit / 8))
case "decKbytes", "deckbytes", "kBy":
return humanize.Bytes(uint64(value * Kilobit))
case "kbit":
return humanize.Bytes(uint64(value * Kilobit / 8))
case "mbytes", "MiBy":
return humanize.IBytes(uint64(value * Mebibit))
case "Mibit":
return humanize.IBytes(uint64(value * Mebibit / 8))
case "decMbytes", "decmbytes", "MBy":
return humanize.Bytes(uint64(value * Megabit))
case "Mbit":
return humanize.Bytes(uint64(value * Megabit / 8))
case "gbytes", "GiBy":
return humanize.IBytes(uint64(value * Gibibit))
case "Gibit":
return humanize.IBytes(uint64(value * Gibibit / 8))
case "decGbytes", "decgbytes", "GBy":
return humanize.Bytes(uint64(value * Gigabit))
case "Gbit":
return humanize.Bytes(uint64(value * Gigabit / 8))
case "tbytes", "TiBy":
return humanize.IBytes(uint64(value * Tebibit))
case "Tibit":
return humanize.IBytes(uint64(value * Tebibit / 8))
case "decTbytes", "dectbytes", "TBy":
return humanize.Bytes(uint64(value * Terabit))
case "Tbit":
return humanize.Bytes(uint64(value * Terabit / 8))
case "pbytes", "PiBy":
return humanize.IBytes(uint64(value * Pebibit))
case "Pbit":
return humanize.Bytes(uint64(value * Petabit / 8))
case "decPbytes", "decpbytes", "PBy":
return humanize.Bytes(uint64(value * Petabit))
case "EiBy":
return humanize.IBytes(uint64(value * Exbibit))
case "Ebit":
return humanize.Bytes(uint64(value * Exabit / 8))
case "EBy":
return humanize.Bytes(uint64(value * Exabit))
}
// When unit is not matched, return the value as it is.
return fmt.Sprintf("%v", value)
}

View File

@@ -1,90 +0,0 @@
package units
import (
"fmt"
"github.com/dustin/go-humanize"
)
type dataRateFormatter struct {
}
func NewDataRateFormatter() Formatter {
return &dataRateFormatter{}
}
func (*dataRateFormatter) Name() string {
return "data_rate"
}
func (f *dataRateFormatter) Format(value float64, unit string) string {
switch unit {
case "binBps":
return humanize.IBytes(uint64(value)) + "/s"
case "Bps", "By/s":
return humanize.Bytes(uint64(value)) + "/s"
case "binbps":
// humanize.IBytes/Bytes doesn't support bits
// and returns 0 B for values less than a byte
if value < 8 {
return fmt.Sprintf("%v b/s", value)
}
return humanize.IBytes(uint64(value/8)) + "/s"
case "bps", "bit/s":
if value < 8 {
return fmt.Sprintf("%v b/s", value)
}
return humanize.Bytes(uint64(value/8)) + "/s"
case "KiBs", "KiBy/s":
return humanize.IBytes(uint64(value*KibibitPerSecond)) + "/s"
case "Kibits", "Kibit/s":
return humanize.IBytes(uint64(value*KibibitPerSecond/8)) + "/s"
case "KBs", "kBy/s":
return humanize.IBytes(uint64(value*KilobitPerSecond)) + "/s"
case "Kbits", "kbit/s":
return humanize.Bytes(uint64(value*KilobitPerSecond/8)) + "/s"
case "MiBs", "MiBy/s":
return humanize.IBytes(uint64(value*MebibitPerSecond)) + "/s"
case "Mibits", "Mibit/s":
return humanize.IBytes(uint64(value*MebibitPerSecond/8)) + "/s"
case "MBs", "MBy/s":
return humanize.IBytes(uint64(value*MegabitPerSecond)) + "/s"
case "Mbits", "Mbit/s":
return humanize.Bytes(uint64(value*MegabitPerSecond/8)) + "/s"
case "GiBs", "GiBy/s":
return humanize.IBytes(uint64(value*GibibitPerSecond)) + "/s"
case "Gibits", "Gibit/s":
return humanize.IBytes(uint64(value*GibibitPerSecond/8)) + "/s"
case "GBs", "GBy/s":
return humanize.IBytes(uint64(value*GigabitPerSecond)) + "/s"
case "Gbits", "Gbit/s":
return humanize.Bytes(uint64(value*GigabitPerSecond/8)) + "/s"
case "TiBs", "TiBy/s":
return humanize.IBytes(uint64(value*TebibitPerSecond)) + "/s"
case "Tibits", "Tibit/s":
return humanize.IBytes(uint64(value*TebibitPerSecond/8)) + "/s"
case "TBs", "TBy/s":
return humanize.IBytes(uint64(value*TerabitPerSecond)) + "/s"
case "Tbits", "Tbit/s":
return humanize.Bytes(uint64(value*TerabitPerSecond/8)) + "/s"
case "PiBs", "PiBy/s":
return humanize.IBytes(uint64(value*PebibitPerSecond)) + "/s"
case "Pibits", "Pibit/s":
return humanize.IBytes(uint64(value*PebibitPerSecond/8)) + "/s"
case "PBs", "PBy/s":
return humanize.IBytes(uint64(value*PetabitPerSecond)) + "/s"
case "Pbits", "Pbit/s":
return humanize.Bytes(uint64(value*PetabitPerSecond/8)) + "/s"
// Exa units
case "EBy/s":
return humanize.Bytes(uint64(value*ExabitPerSecond)) + "/s"
case "Ebit/s":
return humanize.Bytes(uint64(value*ExabitPerSecond/8)) + "/s"
case "EiBy/s":
return humanize.IBytes(uint64(value*ExbibitPerSecond)) + "/s"
case "Eibit/s":
return humanize.IBytes(uint64(value*ExbibitPerSecond/8)) + "/s"
}
// When unit is not matched, return the value as it is.
return fmt.Sprintf("%v", value)
}