chore(ui): migrate AlertStore to typescript

This commit is contained in:
Łukasz Mierzwa
2020-07-17 11:58:41 +01:00
committed by Łukasz Mierzwa
parent 0e803c3b99
commit c5d399e3eb
6 changed files with 241 additions and 93 deletions
+13
View File
@@ -5025,6 +5025,14 @@
"@types/lodash": "*"
}
},
"@types/lodash.throttle": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.6.tgz",
"integrity": "sha512-/UIH96i/sIRYGC60NoY72jGkCJtFN5KVPhEMMMTjol65effe1gPn0tycJqV5tlSwMTzX8FqzB5yAj0rfGHTPNg==",
"requires": {
"@types/lodash": "*"
}
},
"@types/lodash.uniqueid": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@types/lodash.uniqueid/-/lodash.uniqueid-4.0.6.tgz",
@@ -5084,6 +5092,11 @@
"resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz",
"integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug=="
},
"@types/qs": {
"version": "6.9.3",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.3.tgz",
"integrity": "sha512-7s9EQWupR1fTc2pSMtXRQ9w9gLOcrJn+h7HOXw4evxyvVqMi4f+q7d2tnFe3ng3SNHjtK+0EzGMGFUQX4/AQRA=="
},
"@types/reach__router": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/reach__router/-/reach__router-1.3.5.tgz",
+4 -1
View File
@@ -15,8 +15,10 @@
"@types/bricks.js": "1.8.1",
"@types/lodash.debounce": "4.0.6",
"@types/lodash.merge": "4.6.6",
"@types/lodash.throttle": "4.1.6",
"@types/lodash.uniqueid": "4.0.6",
"@types/promise-retry": "1.1.3",
"@types/qs": "6.9.3",
"@types/react": "16.9.43",
"@types/react-dom": "16.9.8",
"@types/react-js-pagination": "3.0.3",
@@ -76,7 +78,8 @@
"collectCoverageFrom": [
"src/**/*.{js,ts,tsx}",
"!src/**/*.stories.{js,ts,tsx}",
"!src/react-app-env.d.ts"
"!src/react-app-env.d.ts",
"!src/Models/*.ts"
]
},
"devDependencies": {
+98
View File
@@ -96,3 +96,101 @@ export interface APIAlertmanagerUpstreamT {
version: string;
clusterMembers: string[];
}
export interface APIFilterT {
text: string;
name: string;
matcher: string;
value: string;
hits: number;
isValid: boolean;
}
export interface APIAlertsResponseUpstreamsCountersT {
total: number;
healthy: number;
failed: number;
}
export type APIAlertsResponseUpstreamsClusterMapT = {
[clusterName: string]: string[];
};
export type APIAlertsResponseSilenceMapT = {
[clusterName: string]: { [silenceID: string]: APISilenceT };
};
export interface APIAlertsResponseAuthenticationT {
enabled: boolean;
username: string;
}
export interface APILabelColorT {
brightness: number;
background: string;
}
export type APIAlertsResponseColorsT = {
[labelName: string]: { [labelValue: string]: APILabelColorT };
};
export interface APIAlertsResponseUpstreamsT {
counters: APIAlertsResponseUpstreamsCountersT;
instances: APIAlertmanagerUpstreamT[];
clusters: APIAlertsResponseUpstreamsClusterMapT;
}
export interface APILabelCounterValueT {
value: string;
raw: string;
hits: number;
precent: number;
offset: number;
}
export interface APILabelCounterT {
name: string;
values: APILabelCounterValueT[];
}
export interface APISettingsT {
staticColorLabels: string[];
annotationsDefaultHidden: boolean;
annotationsHidden: string[];
annotationsVisible: string[];
sorting: {
grid: {
order: string;
reverse: boolean;
label: string;
};
valueMapping: { [labelName: string]: { [labelValue: string]: number } };
};
silenceForm: {
strip: {
labels: string[];
};
};
alertAcknowledgement: {
enabled: boolean;
durationSeconds: number;
author: string;
commentPrefix: string;
};
}
export interface APIAlertsResponseT {
status: string;
error: string;
timestamp: string;
version: string;
upstreams: APIAlertsResponseUpstreamsT;
silences: APIAlertsResponseSilenceMapT;
grids: APIGridT[];
totalAlerts: number;
colors: APIAlertsResponseColorsT;
filters: APIFilterT[];
counters: APILabelCounterT[];
settings: APISettingsT;
authentication: APIAlertsResponseAuthenticationT;
receivers: string[];
}
+4 -4
View File
@@ -217,7 +217,7 @@ describe("AlertStore.filters", () => {
store.filters.addFilter("foo");
expect(historyMock).toHaveBeenLastCalledWith(
null,
null,
"",
"http://localhost/?q=foo"
);
});
@@ -228,7 +228,7 @@ describe("AlertStore.filters", () => {
store.filters.replaceFilter("foo", "bar");
expect(historyMock).toHaveBeenLastCalledWith(
null,
null,
"",
"http://localhost/?q=bar"
);
});
@@ -239,7 +239,7 @@ describe("AlertStore.filters", () => {
store.filters.addFilter("foo");
expect(historyMock).toHaveBeenLastCalledWith(
null,
null,
"",
"http://localhost/?q=foo"
);
});
@@ -256,7 +256,7 @@ describe("AlertStore.filters", () => {
expect(store.filters.values[1]).toMatchObject(NewUnappliedFilter("far"));
expect(historyMock).toHaveBeenLastCalledWith(
null,
null,
"",
"http://localhost/?q=baz&q=far"
);
});
@@ -5,18 +5,29 @@ import throttle from "lodash.throttle";
import qs from "qs";
import { FetchGet } from "Common/Fetch";
import {
APIAlertmanagerUpstreamT,
APILabelColorT,
APIAlertsResponseT,
APIAlertsResponseColorsT,
APILabelCounterT,
APIGridT,
APIAlertsResponseSilenceMapT,
APIAlertsResponseUpstreamsT,
APIAlertsResponseUpstreamsClusterMapT,
} from "Models/APITypes";
const QueryStringEncodeOptions = {
encodeValuesOnly: true, // don't encode q[]
indices: false, // go-gin doesn't support parsing q[0]=foo&q[1]=bar
};
function FormatAlertsQ(filters) {
function FormatAlertsQ(filters: string[]) {
return qs.stringify({ q: filters }, QueryStringEncodeOptions);
}
// generate URL for the UI with a set of filters
function FormatAPIFilterQuery(filters) {
function FormatAPIFilterQuery(filters: string[]) {
return qs.stringify(
Object.assign(DecodeLocationSearch(window.location.search).params, {
q: filters,
@@ -26,19 +37,30 @@ function FormatAPIFilterQuery(filters) {
}
// format URI for react UI -> Go backend requests
function FormatBackendURI(path) {
function FormatBackendURI(path: string) {
const uri = process.env.REACT_APP_BACKEND_URI || ".";
return `${uri}/${path}`;
}
// takes the '?foo=bar&foo=baz' part of http://example.com?foo=bar&foo=baz
// and decodes it into a dict with some extra metadata
function DecodeLocationSearch(searchString) {
interface QueryParamsT {
q: string[];
}
interface DecodeLocationSearchReturnT {
params: QueryParamsT;
defaultsUsed: boolean;
}
function DecodeLocationSearch(
searchString: string
): DecodeLocationSearchReturnT {
let defaultsUsed = true;
let params = { q: [] };
let params: QueryParamsT = { q: [] };
if (searchString !== "") {
const parsed = qs.parse(searchString.split("?")[1]);
const parsed = qs.parse(searchString.split("?")[1]) as {
[key: string]: string | string[];
};
params = Object.assign(params, parsed);
if (parsed.q !== undefined) {
@@ -49,8 +71,8 @@ function DecodeLocationSearch(searchString) {
// first filter out duplicates
// then filter out empty strings, so 'q=' doesn't end up [""] but rather []
params.q = parsed.q
.filter((v, i) => parsed.q.indexOf(v) === i)
.filter((v) => v !== "");
.filter((v: string, i: number) => parsed.q.indexOf(v) === i)
.filter((v: string) => v !== "");
} else {
params.q = [parsed.q];
}
@@ -60,12 +82,12 @@ function DecodeLocationSearch(searchString) {
return { params: params, defaultsUsed: defaultsUsed };
}
function UpdateLocationSearch(newParams) {
function UpdateLocationSearch(newParams: QueryParamsT) {
const baseURLWithoutSearch = window.location.href.split("?")[0];
const newSearch = FormatAPIFilterQuery(newParams.q);
window.history.pushState(
null,
null,
"",
`${baseURLWithoutSearch}?${newSearch || "q="}`
);
}
@@ -77,7 +99,17 @@ const AlertStoreStatuses = Object.freeze({
Failure: Symbol("failure"),
});
function NewUnappliedFilter(raw) {
export interface FilterT {
applied: boolean;
isValid: boolean;
raw: string;
hits: number;
name: string;
matcher: string;
value: string;
}
function NewUnappliedFilter(raw: string): FilterT {
return {
applied: false,
isValid: true,
@@ -92,20 +124,20 @@ function NewUnappliedFilter(raw) {
class AlertStore {
filters = observable(
{
values: [],
addFilter(raw) {
values: [] as FilterT[],
addFilter(raw: string) {
if (this.values.filter((f) => f.raw === raw).length === 0) {
this.values.push(NewUnappliedFilter(raw));
UpdateLocationSearch({ q: this.values.map((f) => f.raw) });
}
},
removeFilter(raw) {
removeFilter(raw: string) {
if (this.values.filter((f) => f.raw === raw).length > 0) {
this.values = this.values.filter((f) => f.raw !== raw);
UpdateLocationSearch({ q: this.values.map((f) => f.raw) });
}
},
replaceFilter(oldRaw, newRaw) {
replaceFilter(oldRaw: string, newRaw: string) {
const index = this.values.findIndex((e) => e.raw === oldRaw);
if (index >= 0) {
// first check if we would create a duplicated filter
@@ -119,15 +151,18 @@ class AlertStore {
}
}
},
setFilters(raws) {
setFilters(raws: string[]) {
this.values = raws.map((raw) => NewUnappliedFilter(raw));
UpdateLocationSearch({ q: this.values.map((f) => f.raw) });
},
setWithoutLocation(raws) {
const filtersByRaw = this.values.reduce(function (map, obj) {
map[toJS(obj.raw)] = toJS(obj);
return map;
}, {});
setWithoutLocation(raws: string[]) {
const filtersByRaw: { [key: string]: FilterT } = this.values.reduce(
function (map: { [key: string]: FilterT }, obj) {
map[toJS(obj.raw)] = toJS(obj);
return map;
},
{}
);
this.values = raws.map((raw) =>
filtersByRaw[raw] ? filtersByRaw[raw] : NewUnappliedFilter(raw)
);
@@ -151,28 +186,34 @@ class AlertStore {
data = observable(
{
colors: {},
counters: [],
grids: [],
silences: {},
upstreams: { instances: [], clusters: {} },
receivers: [],
get gridPadding() {
colors: {} as APIAlertsResponseColorsT,
counters: [] as APILabelCounterT[],
grids: [] as APIGridT[],
silences: {} as APIAlertsResponseSilenceMapT,
upstreams: {
counters: { total: 0, healthy: 0, failed: 0 },
instances: [],
clusters: {},
} as APIAlertsResponseUpstreamsT,
receivers: [] as string[],
get gridPadding(): number {
return this.grids.filter((g) => g.labelName !== "").length > 0 ? 5 : 0;
},
getAlertmanagerByName(name) {
getAlertmanagerByName(
name: string
): APIAlertmanagerUpstreamT | undefined {
return this.upstreams.instances.find((am) => am.name === name);
},
isReadOnlyAlertmanager(name) {
isReadOnlyAlertmanager(name: string): boolean {
return this.readOnlyAlertmanagers.map((am) => am.name).includes(name);
},
getClusterAlertmanagersWithoutReadOnly(clusterID) {
getClusterAlertmanagersWithoutReadOnly(clusterID: string): string[] {
return this.clustersWithoutReadOnly[clusterID] || [];
},
get readOnlyAlertmanagers() {
get readOnlyAlertmanagers(): APIAlertmanagerUpstreamT[] {
return this.upstreams.instances.filter((am) => am.readonly === true);
},
get readWriteAlertmanagers() {
get readWriteAlertmanagers(): APIAlertmanagerUpstreamT[] {
return this.upstreams.instances
.filter((am) => am.readonly === false)
.map((am) =>
@@ -183,8 +224,8 @@ class AlertStore {
})
);
},
get clustersWithoutReadOnly() {
const clusters = {};
get clustersWithoutReadOnly(): APIAlertsResponseUpstreamsClusterMapT {
const clusters: APIAlertsResponseUpstreamsClusterMapT = {};
for (const clusterID of Object.keys(this.upstreams.clusters)) {
const members = this.upstreams.clusters[clusterID].filter(
(member) => this.isReadOnlyAlertmanager(member) === false
@@ -195,7 +236,7 @@ class AlertStore {
}
return clusters;
},
getColorData(name, value) {
getColorData(name: string, value: string): APILabelColorT | undefined {
if (this.colors[name] !== undefined) {
return this.colors[name][value];
}
@@ -213,14 +254,14 @@ class AlertStore {
info = observable(
{
authentication: {
enabled: false,
enabled: false as boolean,
username: "",
},
totalAlerts: 0,
version: "unknown",
upgradeNeeded: false,
isRetrying: false,
reloadNeeded: false,
upgradeNeeded: false as boolean,
isRetrying: false as boolean,
reloadNeeded: false as boolean,
setIsRetrying() {
this.isRetrying = true;
},
@@ -242,25 +283,25 @@ class AlertStore {
settings = observable(
{
values: {
staticColorLabels: [],
annotationsDefaultHidden: false,
annotationsHidden: [],
annotationsVisible: [],
staticColorLabels: [] as string[],
annotationsDefaultHidden: false as boolean,
annotationsHidden: [] as string[],
annotationsVisible: [] as string[],
sorting: {
grid: {
order: "startsAt",
reverse: false,
reverse: false as boolean,
label: "alertname",
},
valueMapping: {},
},
silenceForm: {
strip: {
labels: [],
labels: [] as string[],
},
},
alertAcknowledgement: {
enabled: false,
enabled: false as boolean,
durationSeconds: 900,
author: "karma / author missing",
commentPrefix: "",
@@ -276,9 +317,9 @@ class AlertStore {
status = observable(
{
value: AlertStoreStatuses.Idle,
lastUpdateAt: 0,
error: null,
paused: false,
lastUpdateAt: 0 as number | Date,
error: null as null | string,
paused: false as boolean,
setIdle() {
this.value = AlertStoreStatuses.Idle;
this.error = null;
@@ -291,7 +332,7 @@ class AlertStore {
this.value = AlertStoreStatuses.Processing;
this.error = null;
},
setFailure(err) {
setFailure(err: string) {
this.value = AlertStoreStatuses.Failure;
this.error = err;
this.lastUpdateAt = new Date();
@@ -314,12 +355,18 @@ class AlertStore {
{ name: "Store status" }
);
constructor(initialFilters) {
constructor(initialFilters: null | string[]) {
if (initialFilters !== null) this.filters.setFilters(initialFilters);
}
fetch = action(
(gridLabel, gridSortReverse, sortOrder, sortLabel, sortReverse) => {
async (
gridLabel: string,
gridSortReverse: boolean,
sortOrder: string,
sortLabel: string,
sortReverse: string
) => {
this.status.setFetching();
const args = [
@@ -334,7 +381,7 @@ class AlertStore {
FormatBackendURI(`alerts.json?&${args.join("&")}&`) +
FormatAPIFilterQuery(this.filters.values.map((f) => f.raw));
return FetchGet(alertsURI, {}, this.info.setIsRetrying)
return await FetchGet(alertsURI, {}, this.info.setIsRetrying)
.then((result) => {
// we're sending requests with mode=cors so the response should also be type=cors
// after a few failures in the retry loop we will switch to no-cors
@@ -361,23 +408,23 @@ class AlertStore {
fetchWithThrottle = throttle(this.fetch, 300);
parseAPIResponse = action((result) => {
parseAPIResponse = action((result: APIAlertsResponseT) => {
if (result.error) {
this.handleFetchError(result.error);
return;
}
const queryFilters = [
...new Set(
const queryFilters = Array.from(
new Set(
this.filters.values
.map((f) => f.raw)
.slice()
.sort()
),
];
const responseFilters = [
...new Set(result.filters.map((m) => m.text).sort()),
];
)
);
const responseFilters = Array.from(
new Set(result.filters.map((m) => m.text).sort())
);
if (JSON.stringify(queryFilters) !== JSON.stringify(responseFilters)) {
console.info(
`Got response with filters '${responseFilters}' while expecting results for '${queryFilters}', ignoring`
@@ -402,17 +449,13 @@ class AlertStore {
);
}
let updates = {};
for (const key of [
"colors",
"counters",
"grids",
"silences",
"upstreams",
"receivers",
]) {
updates[key] = result[key];
}
let updates: Partial<APIAlertsResponseT> = {};
updates.colors = result.colors;
updates.counters = result.counters;
updates.grids = result.grids;
updates.silences = result.silences;
updates.upstreams = result.upstreams;
updates.receivers = result.receivers;
this.data = Object.assign(this.data, updates);
// before storing new version check if we need to reload
@@ -423,11 +466,9 @@ class AlertStore {
this.info.upgradeNeeded = true;
}
// update extra root level keys that are stored under 'info'
for (const key of ["totalAlerts", "version", "authentication"]) {
if (this.info[key] !== result[key]) {
this.info[key] = result[key];
}
}
this.info.totalAlerts = result.totalAlerts;
this.info.version = result.version;
this.info.authentication = result.authentication;
// settings exported via API
this.settings.values = result.settings;
@@ -435,7 +476,7 @@ class AlertStore {
this.status.setIdle();
});
handleFetchError = action((err) => {
handleFetchError = action((err: string) => {
this.status.setFailure(err);
// reset alert counter since we won't be rendering any alerts
+2 -9
View File
@@ -3,15 +3,8 @@ import { localStored } from "mobx-stored";
import { UIDefaults } from "Models/UI";
interface SavedFilter {
raw: string;
name: string;
matcher: string;
value: string;
}
interface SavedFiltersStorage {
filters: SavedFilter[];
filters: string[];
present: boolean;
}
class SavedFilters {
@@ -26,7 +19,7 @@ class SavedFilters {
}
);
save = action((newFilters: SavedFilter[]) => {
save = action((newFilters: string[]) => {
this.config.filters = newFilters;
this.config.present = true;
});