diff --git a/ui/package-lock.json b/ui/package-lock.json index 517e8d2b5..c766e5e9b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index 6b5c4e11f..f9031d26b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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": { diff --git a/ui/src/Models/APITypes.ts b/ui/src/Models/APITypes.ts index 7c165ad98..993612dbd 100644 --- a/ui/src/Models/APITypes.ts +++ b/ui/src/Models/APITypes.ts @@ -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[]; +} diff --git a/ui/src/Stores/AlertStore.test.js b/ui/src/Stores/AlertStore.test.js index b114e9f1b..e14669136 100644 --- a/ui/src/Stores/AlertStore.test.js +++ b/ui/src/Stores/AlertStore.test.js @@ -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" ); }); diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.ts similarity index 70% rename from ui/src/Stores/AlertStore.js rename to ui/src/Stores/AlertStore.ts index f06d6c9df..7b6fa3b0c 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.ts @@ -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 = {}; + 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 diff --git a/ui/src/Stores/Settings.ts b/ui/src/Stores/Settings.ts index b67264932..f21c9ba81 100644 --- a/ui/src/Stores/Settings.ts +++ b/ui/src/Stores/Settings.ts @@ -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; });