feat: show release note in alerts dashboards and services pages (#1840)

* feat: show release note in alerts dashboards and services pages

* fix: made code changes as per review and changed message in release note

* fix: solved build pipeline issue

* fix: solved lint issue

Co-authored-by: mindhash <mindhash@mindhashs-MacBook-Pro.local>
Co-authored-by: Pranay Prateek <pranay@signoz.io>
Co-authored-by: Ankit Nayan <ankit@signoz.io>
This commit is contained in:
Amol Umbark 2022-12-09 20:16:09 +05:30 committed by GitHub
parent 4d02603aed
commit 0abae1c09c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 422 additions and 17 deletions

View File

@ -57,6 +57,7 @@ const afterLogin = async (
profilePictureURL: payload.profilePictureURL,
userId: payload.id,
orgId: payload.orgId,
userFlags: payload.flags,
},
});

View File

@ -0,0 +1,26 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/setFlags';
const setFlags = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.patch(`/user/${props.userId}/flags`, {
...props.flags,
});
return {
statusCode: 200,
error: null,
message: response.data?.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default setFlags;

View File

@ -41,6 +41,7 @@ export const Logout = (): void => {
orgName: '',
profilePictureURL: '',
userId: '',
userFlags: {},
},
});

View File

@ -0,0 +1,27 @@
import React from 'react';
import { StyledAlert } from './styles';
interface MessageTipProps {
show?: boolean;
message: React.ReactNode | string;
action: React.ReactNode | undefined;
}
function MessageTip({
show,
message,
action,
}: MessageTipProps): JSX.Element | null {
if (!show) return null;
return (
<StyledAlert showIcon description={message} type="info" action={action} />
);
}
MessageTip.defaultProps = {
show: false,
};
export default MessageTip;

View File

@ -0,0 +1,6 @@
import { Alert } from 'antd';
import styled from 'styled-components';
export const StyledAlert = styled(Alert)`
align-items: center;
`;

View File

@ -0,0 +1,4 @@
export default interface ReleaseNoteProps {
path?: string;
release?: string;
}

View File

@ -0,0 +1,73 @@
import { Button, Space } from 'antd';
import setFlags from 'api/user/setFlags';
import MessageTip from 'components/MessageTip';
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { UPDATE_USER_FLAG } from 'types/actions/app';
import { UserFlags } from 'types/api/user/setFlags';
import AppReducer from 'types/reducer/app';
import ReleaseNoteProps from '../ReleaseNoteProps';
export default function ReleaseNote0120({
release,
}: ReleaseNoteProps): JSX.Element | null {
const { user } = useSelector<AppState, AppReducer>((state) => state.app);
const dispatch = useDispatch<Dispatch<AppActions>>();
const handleDontShow = useCallback(async (): Promise<void> => {
const flags: UserFlags = { ReleaseNote0120Hide: 'Y' };
try {
dispatch({
type: UPDATE_USER_FLAG,
payload: {
flags,
},
});
if (!user) {
// no user is set, so escape the routine
return;
}
const response = await setFlags({ userId: user?.userId, flags });
if (response.statusCode !== 200) {
console.log('failed to complete do not show status', response.error);
}
} catch (e) {
// here we do not nothing as the cost of error is minor,
// the user can switch the do no show option again in the further.
console.log('unexpected error: failed to complete do not show status', e);
}
}, [dispatch, user]);
return (
<MessageTip
show
message={
<div>
You are using {release} of SigNoz. We have introduced distributed setup in
v0.12.0 release. If you use or plan to use clickhouse queries in dashboard
or alerts, you might want to read about querying the new distributed tables{' '}
<a
href="https://signoz.io/docs/operate/migration/upgrade-0.12/#querying-distributed-tables"
target="_blank"
rel="noreferrer"
>
here
</a>
</div>
}
action={
<Space>
<Button onClick={handleDontShow}>Do not show again</Button>
</Space>
}
/>
);
}

View File

@ -0,0 +1,66 @@
import ReleaseNoteProps from 'components/ReleaseNote/ReleaseNoteProps';
import ReleaseNote0120 from 'components/ReleaseNote/Releases/ReleaseNote0120';
import ROUTES from 'constants/routes';
import React from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { UserFlags } from 'types/api/user/setFlags';
import AppReducer from 'types/reducer/app';
interface ComponentMapType {
match: (
path: string | undefined,
version: string,
userFlags: UserFlags | null,
) => boolean;
component: ({ path, release }: ReleaseNoteProps) => JSX.Element | null;
}
const allComponentMap: ComponentMapType[] = [
{
match: (
path: string | undefined,
version: string,
userFlags: UserFlags | null,
): boolean => {
if (!path) {
return false;
}
const allowedPaths = [
ROUTES.LIST_ALL_ALERT,
ROUTES.APPLICATION,
ROUTES.ALL_DASHBOARD,
];
return (
userFlags?.ReleaseNote0120Hide !== 'Y' &&
allowedPaths.includes(path) &&
version.startsWith('v0.12')
);
},
component: ReleaseNote0120,
},
];
// ReleaseNote prints release specific warnings and notes that
// user needs to be aware of before using the upgraded version.
function ReleaseNote({ path }: ReleaseNoteProps): JSX.Element | null {
const { userFlags, currentVersion } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const c = allComponentMap.find((item) => {
return item.match(path, currentVersion, userFlags);
});
if (!c) {
return null;
}
return <c.component path={path} release={currentVersion} />;
}
ReleaseNote.defaultProps = {
path: '',
};
export default ReleaseNote;

View File

@ -1,14 +1,17 @@
import { notification } from 'antd';
import { notification, Space } from 'antd';
import getAll from 'api/alerts/getAll';
import ReleaseNote from 'components/ReleaseNote';
import Spinner from 'components/Spinner';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { useLocation } from 'react-router-dom';
import ListAlert from './ListAlert';
function ListAlertRules(): JSX.Element {
const { t } = useTranslation('common');
const location = useLocation();
const { data, isError, isLoading, refetch, status } = useQuery('allAlerts', {
queryFn: getAll,
cacheTime: 0,
@ -45,12 +48,15 @@ function ListAlertRules(): JSX.Element {
}
return (
<ListAlert
{...{
allAlertRules: data.payload,
refetch,
}}
/>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<ReleaseNote path={location.pathname} />
<ListAlert
{...{
allAlertRules: data.payload,
refetch,
}}
/>
</Space>
);
}

View File

@ -12,7 +12,7 @@ import AppReducer from 'types/reducer/app';
import { NameInput } from '../styles';
function UpdateName(): JSX.Element {
const { user, role, org } = useSelector<AppState, AppReducer>(
const { user, role, org, userFlags } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
const { t } = useTranslation();
@ -47,6 +47,7 @@ function UpdateName(): JSX.Element {
ROLE: role || 'ADMIN',
orgId: org[0].id,
orgName: org[0].name,
userFlags: userFlags || {},
},
});
} else {

View File

@ -1,17 +1,26 @@
import { Space } from 'antd';
import ReleaseNote from 'components/ReleaseNote';
import ListOfAllDashboard from 'container/ListOfDashboard';
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { bindActionCreators } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { GetAllDashboards } from 'store/actions';
import AppActions from 'types/actions';
function Dashboard({ getAllDashboards }: DashboardProps): JSX.Element {
const location = useLocation();
useEffect(() => {
getAllDashboards();
}, [getAllDashboards]);
return <ListOfAllDashboard />;
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<ReleaseNote path={location.pathname} />
<ListOfAllDashboard />
</Space>
);
}
interface DispatchProps {

View File

@ -1,5 +1,6 @@
import { notification } from 'antd';
import { notification, Space } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get';
import ReleaseNote from 'components/ReleaseNote';
import Spinner from 'components/Spinner';
import { SKIP_ONBOARDING } from 'constants/onboarding';
import ResourceAttributesFilter from 'container/MetricsApplication/ResourceAttributesFilter';
@ -7,6 +8,7 @@ import MetricTable from 'container/MetricsTable';
import { convertRawQueriesToTraceSelectedTags } from 'lib/resourceAttributes';
import React, { useEffect, useMemo } from 'react';
import { connect, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { bindActionCreators, Dispatch } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { GetService, GetServiceProps } from 'store/actions/metrics';
@ -21,6 +23,7 @@ function Metrics({ getService }: MetricsProps): JSX.Element {
AppState,
GlobalReducer
>((state) => state.globalTime);
const location = useLocation();
const {
services,
resourceAttributeQueries,
@ -86,10 +89,12 @@ function Metrics({ getService }: MetricsProps): JSX.Element {
}
return (
<>
<Space direction="vertical" style={{ width: '100%' }}>
<ReleaseNote path={location.pathname} />
<ResourceAttributesFilter />
<MetricTable />
</>
</Space>
);
}

View File

@ -18,6 +18,7 @@ import {
UPDATE_ORG_NAME,
UPDATE_USER,
UPDATE_USER_ACCESS_REFRESH_ACCESS_TOKEN,
UPDATE_USER_FLAG,
UPDATE_USER_IS_FETCH,
UPDATE_USER_ORG_ROLE,
} from 'types/actions/app';
@ -58,6 +59,7 @@ const InitialValue: InitialValueTypes = {
org: null,
role: null,
configs: {},
userFlags: {},
};
const appReducer = (
@ -153,6 +155,7 @@ const appReducer = (
ROLE,
orgId,
orgName,
userFlags,
} = action.payload;
const orgIndex = org.findIndex((e) => e.id === orgId);
@ -179,6 +182,7 @@ const appReducer = (
},
org: [...updatedOrg],
role: ROLE,
userFlags,
};
}
@ -219,6 +223,14 @@ const appReducer = (
};
}
case UPDATE_USER_FLAG: {
console.log('herei n update user flag');
return {
...state,
userFlags: { ...state.userFlags, ...action.payload.flags },
};
}
default:
return state;
}

View File

@ -3,6 +3,7 @@ import {
Organization,
PayloadProps as OrgPayload,
} from 'types/api/user/getOrganization';
import { UserFlags } from 'types/api/user/setFlags';
import AppReducer, { User } from 'types/reducer/app';
import { ROLES } from 'types/roles';
@ -24,6 +25,7 @@ export const UPDATE_ORG_NAME = 'UPDATE_ORG_NAME';
export const UPDATE_ORG = 'UPDATE_ORG';
export const UPDATE_FEATURE_FLAGS = 'UPDATE_FEATURE_FLAGS';
export const UPDATE_CONFIGS = 'UPDATE_CONFIGS';
export const UPDATE_USER_FLAG = 'UPDATE_USER_FLAG';
export interface SwitchDarkMode {
type: typeof SWITCH_DARK_MODE;
@ -92,6 +94,7 @@ export interface UpdateUser {
orgName: Organization['name'];
ROLE: ROLES;
orgId: Organization['id'];
userFlags: UserFlags;
};
}
@ -110,6 +113,13 @@ export interface UpdateOrgName {
};
}
export interface UpdateUserFlag {
type: typeof UPDATE_USER_FLAG;
payload: {
flags: UserFlags | null;
};
}
export interface UpdateOrg {
type: typeof UPDATE_ORG;
payload: {
@ -137,4 +147,5 @@ export type AppAction =
| UpdateOrgName
| UpdateOrg
| UpdateFeatureFlags
| UpdateConfigs;
| UpdateConfigs
| UpdateUserFlag;

View File

@ -1,3 +1,4 @@
import { UserFlags } from 'types/api/user/setFlags';
import { User } from 'types/reducer/app';
import { ROLES } from 'types/roles';
@ -15,4 +16,5 @@ export interface PayloadProps {
profilePictureURL: string;
organization: string;
role: ROLES;
flags: UserFlags;
}

View File

@ -0,0 +1,12 @@
import { User } from 'types/reducer/app';
export interface UserFlags {
ReleaseNote0120Hide?: string;
}
export type PayloadProps = UserFlags;
export interface Props {
userId: User['userId'];
flags: UserFlags;
}

View File

@ -2,6 +2,7 @@ import { PayloadProps as ConfigPayload } from 'types/api/dynamicConfigs/getDynam
import { PayloadProps as FeatureFlagPayload } from 'types/api/features/getFeaturesFlags';
import { PayloadProps as OrgPayload } from 'types/api/user/getOrganization';
import { PayloadProps as UserPayload } from 'types/api/user/getUser';
import { UserFlags } from 'types/api/user/setFlags';
import { ROLES } from 'types/roles';
export interface User {
@ -28,4 +29,5 @@ export default interface AppReducer {
org: OrgPayload | null;
featureFlags: null | FeatureFlagPayload;
configs: ConfigPayload;
userFlags: null | UserFlags;
}

View File

@ -392,6 +392,8 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router) {
router.HandleFunc("/api/v1/user/{id}", SelfAccess(aH.editUser)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/user/{id}", AdminAccess(aH.deleteUser)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/user/{id}/flags", SelfAccess(aH.patchUserFlag)).Methods(http.MethodPatch)
router.HandleFunc("/api/v1/rbac/role/{id}", SelfAccess(aH.getRole)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rbac/role/{id}", AdminAccess(aH.editRole)).Methods(http.MethodPut)
@ -1854,6 +1856,37 @@ func (aH *APIHandler) deleteUser(w http.ResponseWriter, r *http.Request) {
aH.WriteJSON(w, r, map[string]string{"data": "user deleted successfully"})
}
// addUserFlag patches a user flags with the changes
func (aH *APIHandler) patchUserFlag(w http.ResponseWriter, r *http.Request) {
// read user id from path var
userId := mux.Vars(r)["id"]
// read input into user flag
defer r.Body.Close()
b, err := ioutil.ReadAll(r.Body)
if err != nil {
zap.S().Errorf("failed read user flags from http request for userId ", userId, "with error: ", err)
RespondError(w, model.BadRequestStr("received user flags in invalid format"), nil)
return
}
flags := make(map[string]string, 0)
err = json.Unmarshal(b, &flags)
if err != nil {
zap.S().Errorf("failed parsing user flags for userId ", userId, "with error: ", err)
RespondError(w, model.BadRequestStr("received user flags in invalid format"), nil)
return
}
newflags, apiError := dao.DB().UpdateUserFlags(r.Context(), userId, flags)
if !apiError.IsNil() {
RespondError(w, apiError, nil)
return
}
aH.Respond(w, newflags)
}
func (aH *APIHandler) getRole(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]

View File

@ -41,6 +41,8 @@ type Mutations interface {
EditUser(ctx context.Context, update *model.User) (*model.User, *model.ApiError)
DeleteUser(ctx context.Context, id string) *model.ApiError
UpdateUserFlags(ctx context.Context, userId string, flags map[string]string) (model.UserFlag, *model.ApiError)
CreateGroup(ctx context.Context, group *model.Group) (*model.Group, *model.ApiError)
DeleteGroup(ctx context.Context, id string) *model.ApiError

View File

@ -68,6 +68,11 @@ func InitDB(dataSourceName string) (*ModelDaoSqlite, error) {
token TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS user_flags (
user_id TEXT PRIMARY KEY,
flags TEXT,
FOREIGN KEY(user_id) REFERENCES users(id)
);
`
_, err = db.Exec(table_schema)

View File

@ -2,6 +2,7 @@ package sqlite
import (
"context"
"encoding/json"
"fmt"
"time"
@ -271,7 +272,10 @@ func (mds *ModelDaoSqlite) GetUser(ctx context.Context,
u.org_id,
u.group_id,
g.name as role,
o.name as organization
o.name as organization,
COALESCE((select uf.flags
from user_flags uf
where u.id = uf.user_id), '') as flags
from users u, groups g, organizations o
where
g.id=u.group_id and
@ -291,6 +295,7 @@ func (mds *ModelDaoSqlite) GetUser(ctx context.Context,
if len(users) == 0 {
return nil, nil
}
return &users[0], nil
}
@ -531,3 +536,53 @@ func (mds *ModelDaoSqlite) GetResetPasswordEntry(ctx context.Context,
}
return &entries[0], nil
}
// CreateUserFlags inserts user specific flags
func (mds *ModelDaoSqlite) UpdateUserFlags(ctx context.Context, userId string, flags map[string]string) (model.UserFlag, *model.ApiError) {
if len(flags) == 0 {
// nothing to do as flags are empty. In this method, we only append the flags
// but not set them to empty
return flags, nil
}
// fetch existing flags
userPayload, apiError := mds.GetUser(ctx, userId)
if apiError != nil {
return nil, apiError
}
if userPayload.Flags != nil {
for k, v := range userPayload.Flags {
if _, ok := flags[k]; !ok {
// insert only missing keys as we want to retain the
// flags in the db that are not part of this request
flags[k] = v
}
}
}
// append existing flags with new ones
// write the updated flags
flagsBytes, err := json.Marshal(flags)
if err != nil {
return nil, model.InternalError(err)
}
if len(userPayload.Flags) == 0 {
q := `INSERT INTO user_flags (user_id, flags) VALUES (?, ?);`
if _, err := mds.db.ExecContext(ctx, q, userId, string(flagsBytes)); err != nil {
return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err}
}
} else {
q := `UPDATE user_flags SET flags = ? WHERE user_id = ?;`
if _, err := mds.db.ExecContext(ctx, q, userId, string(flagsBytes)); err != nil {
return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err}
}
}
return flags, nil
}

View File

@ -1,5 +1,11 @@
package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
)
type Organization struct {
Id string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
@ -30,10 +36,42 @@ type User struct {
GroupId string `json:"groupId,omitempty" db:"group_id"`
}
type UserFlag map[string]string
func (uf UserFlag) Value() (driver.Value, error) {
f := make(map[string]string, 0)
for k, v := range uf {
f[k] = v
}
return json.Marshal(f)
}
func (uf *UserFlag) Scan(value interface{}) error {
fmt.Println(" value:", value)
if value == "" {
return nil
}
b, ok := value.(string)
if !ok {
return fmt.Errorf("type assertion to []byte failed while scanning user flag")
}
f := make(map[string]string, 0)
if err := json.Unmarshal([]byte(b), &f); err != nil {
return err
}
*uf = make(UserFlag, len(f))
for k, v := range f {
(*uf)[k] = v
}
return nil
}
type UserPayload struct {
User
Role string `json:"role"`
Organization string `json:"organization"`
Role string `json:"role"`
Organization string `json:"organization"`
Flags UserFlag `json:"flags"`
}
type Group struct {

View File

@ -72,6 +72,14 @@ func BadRequest(err error) *ApiError {
}
}
// BadRequestStr returns a ApiError object of bad request
func BadRequestStr(s string) *ApiError {
return &ApiError{
Typ: ErrorBadData,
Err: fmt.Errorf(s),
}
}
// InternalError returns a ApiError object of internal type
func InternalError(err error) *ApiError {
return &ApiError{