fix(ui): drop useLocalStore from AlertAck

This commit is contained in:
Łukasz Mierzwa
2020-06-08 16:32:46 +01:00
committed by Łukasz Mierzwa
parent f063038c30
commit be882121cd
3 changed files with 323 additions and 254 deletions

View File

@@ -1,8 +1,8 @@
import React from "react";
import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import { toJS } from "mobx";
import { useObserver, useLocalStore } from "mobx-react";
import { useObserver } from "mobx-react";
import moment from "moment";
@@ -19,131 +19,19 @@ import {
MatchersFromGroup,
GenerateAlertmanagerSilenceData,
} from "Stores/SilenceFormStore";
import { FetchPost } from "Common/Fetch";
import { useFetchAny } from "Hooks/useFetchAny";
import { TooltipWrapper } from "Components/TooltipWrapper";
const SubmitState = Object.freeze({
Idle: "Idle",
InProgress: "InProgress",
Done: "Done",
Failed: "Failed",
});
const newPendingSilence = (
group,
members,
durationSeconds,
author,
commentPrefix
) => ({
payload: GenerateAlertmanagerSilenceData(
moment.utc(),
moment.utc().add(durationSeconds, "seconds"),
MatchersFromGroup(group, [], group.alerts, true),
author,
`${
commentPrefix ? commentPrefix + " " : ""
}This alert was acknowledged using karma on ${moment.utc().toString()}`
),
membersToTry: members,
submitState: SubmitState.Idle,
submitResult: null,
isDone: false,
isFailed: false,
error: null,
});
const AlertAck = ({ alertStore, silenceFormStore, group }) => {
const submitState = useLocalStore(() => ({
silencesByCluster: {},
reset() {
this.silencesByCluster = {};
},
pushSilence(cluster, silence) {
this.silencesByCluster[cluster] = silence;
},
markDone(cluster) {
this.silencesByCluster[cluster].isDone = true;
},
markFailed(cluster, err) {
this.silencesByCluster[cluster].isDone = true;
this.silencesByCluster[cluster].isFailed = true;
this.silencesByCluster[cluster].error = err;
},
get isIdle() {
return Object.keys(this.silencesByCluster).length === 0;
},
get isInprogress() {
return (
Object.values(this.silencesByCluster).filter(
(pendingSilence) => pendingSilence.isDone === false
).length > 0
);
},
get isDone() {
return (
Object.values(this.silencesByCluster).filter(
(pendingSilence) => pendingSilence.isDone === true
).length > 0
);
},
get isFailed() {
return (
Object.values(this.silencesByCluster).filter(
(pendingSilence) => pendingSilence.isFailed === true
).length > 0
);
},
get errorMessages() {
return Object.values(this.silencesByCluster)
.filter((pendingSilence) => pendingSilence.error !== null)
.map((s) => s.error);
},
}));
const [clusters, setClusters] = useState([]);
const [upstreams, setUpstreams] = useState([]);
const [currentCluster, setCurrentCluster] = useState(0);
const [isAcking, setIsAcking] = useState(false);
const maybeTryAgainAfterError = (cluster, err) => {
if (submitState.silencesByCluster[cluster].membersToTry.length) {
handleAlertmanagerRequest(cluster);
} else {
submitState.markFailed(cluster, err);
}
};
const handleAlertmanagerRequest = (cluster) => {
const member = submitState.silencesByCluster[cluster].membersToTry.pop();
const am = alertStore.data.getAlertmanagerByName(member);
if (am === undefined) {
const err = `Alertmanager instance "${member} not found`;
console.error(err);
maybeTryAgainAfterError(cluster, err);
return;
}
FetchPost(`${am.uri}/api/v2/silences`, {
body: JSON.stringify(submitState.silencesByCluster[cluster].payload),
credentials: am.corsCredentials,
headers: {
"Content-Type": "application/json",
...am.headers,
},
})
.then((result) => {
if (result.ok) {
return result.json().then((r) => submitState.markDone(cluster));
} else {
result.text().then((text) => maybeTryAgainAfterError(cluster, text));
}
})
.catch((err) => {
maybeTryAgainAfterError(cluster, err);
});
};
const { response, error, inProgress, reset } = useFetchAny(upstreams);
const onACK = () => {
if (submitState.isInprogress || submitState.isDone) {
return;
}
setIsAcking(true);
let author =
silenceFormStore.data.author !== ""
@@ -166,47 +54,103 @@ const AlertAck = ({ alertStore, silenceFormStore, group }) => {
alertmanagers.some((m) => clusterMembers.includes(m))
);
submitState.reset();
let c = [];
for (const [clusterName, clusterMembers] of clusters) {
const pendingSilence = newPendingSilence(
toJS(group),
toJS(clusterMembers),
toJS(alertStore.settings.values.alertAcknowledgement.durationSeconds),
author,
toJS(alertStore.settings.values.alertAcknowledgement.commentPrefix)
const durationSeconds = toJS(
alertStore.settings.values.alertAcknowledgement.durationSeconds
);
submitState.pushSilence(clusterName, pendingSilence);
handleAlertmanagerRequest(clusterName);
const commentPrefix = toJS(
alertStore.settings.values.alertAcknowledgement.commentPrefix
);
c.push({
payload: GenerateAlertmanagerSilenceData(
moment.utc(),
moment.utc().add(durationSeconds, "seconds"),
MatchersFromGroup(group, [], group.alerts, true),
author,
`${
commentPrefix ? commentPrefix + " " : ""
}This alert was acknowledged using karma on ${moment
.utc()
.toString()}`
),
clusterName: clusterName,
members: clusterMembers,
});
}
setClusters(c);
};
useEffect(() => {
if (upstreams.length && !inProgress && (error || response)) {
if (clusters.length > currentCluster + 1) {
setCurrentCluster(currentCluster + 1);
} else {
setIsAcking(false);
}
}
}, [clusters, upstreams, currentCluster, inProgress, error, response]);
useEffect(() => {
if (clusters.length) {
reset();
const cluster = clusters[currentCluster];
let u = [];
cluster.members.forEach((amName) => {
const am = alertStore.data.getAlertmanagerByName(amName);
if (am !== undefined) {
u.push({
uri: `${am.uri}/api/v2/silences`,
options: {
method: "POST",
body: JSON.stringify(cluster.payload),
credentials: am.corsCredentials,
headers: {
"Content-Type": "application/json",
...am.headers,
},
},
});
} else {
console.error(`Alertmanager "${amName}" not found`);
}
});
setUpstreams(u);
}
}, [alertStore.data, clusters, currentCluster, reset]);
return useObserver(() =>
alertStore.settings.values.alertAcknowledgement.enabled === false ? null : (
<TooltipWrapper
title={
submitState.isFailed
? submitState.errorMessages[0]
!isAcking && error
? error
: "Acknowledge this alert with a short lived silence"
}
>
<span
className={`badge badge-pill components-label components-label-with-hover px-2 ${
submitState.isFailed
!isAcking && error
? "badge-warning"
: submitState.isDone
: !isAcking && response
? "badge-success"
: "badge-secondary"
}`}
onClick={onACK}
onClick={() => {
if (!isAcking && !(response || error)) {
setIsAcking(true);
onACK();
}
}}
>
{submitState.isIdle ? (
<FontAwesomeIcon icon={faCheck} fixedWidth />
) : submitState.isInprogress ? (
<FontAwesomeIcon icon={faSpinner} fixedWidth spin />
) : submitState.isFailed ? (
{!isAcking && error ? (
<FontAwesomeIcon icon={faExclamationCircle} fixedWidth />
) : (
) : !isAcking && response ? (
<FontAwesomeIcon icon={faCheckCircle} fixedWidth />
) : isAcking ? (
<FontAwesomeIcon icon={faSpinner} fixedWidth spin />
) : (
<FontAwesomeIcon icon={faCheck} fixedWidth />
)}
</span>
</TooltipWrapper>

View File

@@ -1,4 +1,5 @@
import React from "react";
import { act } from "react-dom/test-utils";
import { mount } from "enzyme";
@@ -89,7 +90,9 @@ const MountAndClick = async () => {
const tree = MountedAlertAck();
const button = tree.find("span.badge");
button.simulate("click");
await fetchMock.flush(true);
await act(async () => {
await fetchMock.flush(true);
});
};
describe("<AlertAck />", () => {
@@ -117,7 +120,9 @@ describe("<AlertAck />", () => {
const tree = MountedAlertAck();
const button = tree.find("span.badge");
button.simulate("click");
await fetchMock.flush(true);
await act(async () => {
await fetchMock.flush(true);
});
expect(toDiffableHtml(tree.html())).toMatch(/fa-exclamation-circle/);
});
@@ -125,22 +130,95 @@ describe("<AlertAck />", () => {
const tree = MountedAlertAck();
const button = tree.find("span.badge");
button.simulate("click");
await fetchMock.flush(true);
await act(async () => {
await fetchMock.flush(true);
});
expect(toDiffableHtml(tree.html())).toMatch(/fa-check-circle/);
});
it("sends a request on click", () => {
MountAndClick();
it("sends a POST request on click", async () => {
await MountAndClick();
expect(fetchMock.calls()).toHaveLength(1);
expect(fetchMock.lastCall()[1]).toMatchObject({
method: "POST",
headers: { "Content-Type": "application/json" },
});
});
it("doesn't send any request on click when already in progress", async () => {
const tree = MountedAlertAck();
const button = tree.find("span.badge");
button.simulate("click");
button.simulate("click");
await fetchMock.flush(true);
expect(fetchMock.calls()).toHaveLength(1);
it("sends a POST request to every cluster", async () => {
alertStore.data.upstreams = {
clusters: { c1: ["m1", "m2"], c2: ["m3", "m4"] },
instances: ["m1", "m2", "m3", "m4"].map((a) => ({
name: a,
uri: `http://${a}.example.com`,
publicURI: `http://${a}.example.com`,
readonly: false,
headers: { "X-Cluster": a === "m1" || a === "2" ? "c1" : "c2" },
corsCredentials: a === "m1" || a === "2" ? "same-site" : "include",
error: "",
version: "0.17.0",
cluster: a === "m1" || a === "2" ? "c1" : "c2",
clusterMembers: a === "m1" || a === "2" ? ["m1", "m2"] : ["m3", "m4"],
})),
};
group.alertmanagerCount = {
m1: 1,
m2: 1,
m3: 1,
m4: 1,
};
await MountAndClick();
expect(fetchMock.calls()).toHaveLength(2);
expect(fetchMock.calls()[0][0]).toBe(
"http://m1.example.com/api/v2/silences"
);
expect(fetchMock.calls()[0][1]).toMatchObject({
method: "POST",
credentials: "same-site",
headers: { "X-Cluster": "c1" },
});
expect(fetchMock.calls()[1][0]).toBe(
"http://m3.example.com/api/v2/silences"
);
expect(fetchMock.calls()[1][1]).toMatchObject({
method: "POST",
credentials: "include",
headers: { "X-Cluster": "c2" },
});
});
it("skips readonly alertmanagers", async () => {
alertStore.data.upstreams = {
clusters: { c1: ["m1", "m2"], c2: ["m3", "m4"] },
instances: ["m1", "m2", "m3", "m4"].map((a) => ({
name: a,
uri: `http://${a}.example.com`,
publicURI: `http://${a}.example.com`,
readonly: a === "m1" || a === "m3" ? true : false,
headers: { "X-Cluster": a === "m1" || a === "2" ? "c1" : "c2" },
corsCredentials: a === "m1" || a === "m2" ? "same-site" : "include",
error: "",
version: "0.17.0",
cluster: a === "m1" || a === "2" ? "c1" : "c2",
clusterMembers: a === "m1" || a === "2" ? ["m1", "m2"] : ["m3", "m4"],
})),
};
group.alertmanagerCount = {
m1: 1,
m2: 1,
m3: 1,
m4: 1,
};
await MountAndClick();
expect(fetchMock.calls()).toHaveLength(2);
expect(fetchMock.calls()[0][0]).toBe(
"http://m2.example.com/api/v2/silences"
);
expect(fetchMock.calls()[1][0]).toBe(
"http://m4.example.com/api/v2/silences"
);
});
it("doesn't send any request on click when already done", async () => {
@@ -148,19 +226,16 @@ describe("<AlertAck />", () => {
const button = tree.find("span.badge");
button.simulate("click");
await fetchMock.flush(true);
await act(async () => {
await fetchMock.flush(true);
});
expect(fetchMock.calls()).toHaveLength(1);
button.simulate("click");
expect(fetchMock.calls()).toHaveLength(1);
});
it("sends POST requests", () => {
MountAndClick();
expect(fetchMock.calls()[0][1].method).toBe("POST");
});
it("sends correct payload", () => {
it("sends correct payload", async () => {
fetchMock.any(
{
headers: { "Content-Type": "application/json" },
@@ -172,7 +247,7 @@ describe("<AlertAck />", () => {
);
silenceFormStore.data.author = "karma/ui";
MountAndClick();
await MountAndClick();
expect(JSON.parse(fetchMock.calls()[0][1].body)).toEqual({
comment:
"PREFIX This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000",
@@ -186,11 +261,11 @@ describe("<AlertAck />", () => {
});
});
it("uses settings when generating payload", () => {
it("uses settings when generating payload", async () => {
alertStore.settings.values.alertAcknowledgement.durationSeconds = 237;
alertStore.settings.values.alertAcknowledgement.author = "me";
alertStore.settings.values.alertAcknowledgement.commentPrefix = "";
MountAndClick();
await MountAndClick();
expect(JSON.parse(fetchMock.calls()[0][1].body)).toEqual({
comment:
"This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000",
@@ -204,13 +279,13 @@ describe("<AlertAck />", () => {
});
});
it("uses author from authentication info when auth is enabled", () => {
it("uses author from authentication info when auth is enabled", async () => {
alertStore.info.authentication.enabled = true;
alertStore.info.authentication.username = "auth@example.com";
alertStore.settings.values.alertAcknowledgement.durationSeconds = 222;
alertStore.settings.values.alertAcknowledgement.author = "me";
alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:";
MountAndClick();
await MountAndClick();
expect(JSON.parse(fetchMock.calls()[0][1].body)).toEqual({
comment:
"FOO: This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000",
@@ -224,14 +299,14 @@ describe("<AlertAck />", () => {
});
});
it("uses author from silenceFormStore if authentication is disabled", () => {
it("uses author from silenceFormStore if authentication is disabled", async () => {
alertStore.info.authentication.enabled = false;
alertStore.info.authentication.username = "wrong";
alertStore.settings.values.alertAcknowledgement.durationSeconds = 222;
alertStore.settings.values.alertAcknowledgement.author = "me";
alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:";
silenceFormStore.data.author = "bob@example.com";
MountAndClick();
await MountAndClick();
expect(JSON.parse(fetchMock.calls()[0][1].body)).toEqual({
comment:
"FOO: This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000",
@@ -245,12 +320,12 @@ describe("<AlertAck />", () => {
});
});
it("uses default author as fallback", () => {
it("uses default author as fallback", async () => {
alertStore.settings.values.alertAcknowledgement.durationSeconds = 222;
alertStore.settings.values.alertAcknowledgement.author = "me";
alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:";
silenceFormStore.data.author = "";
MountAndClick();
await MountAndClick();
expect(JSON.parse(fetchMock.calls()[0][1].body)).toEqual({
comment:
"FOO: This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000",
@@ -264,118 +339,136 @@ describe("<AlertAck />", () => {
});
});
it("sends POST request to /api/v2/silences", () => {
MountAndClick();
it("sends POST request to /api/v2/silences", async () => {
await MountAndClick();
const uri = fetchMock.calls()[0][0];
expect(uri).toBe("http://localhost/api/v2/silences");
});
it("will retry on another cluster member after 500 response", async () => {
fetchMock.reset();
fetchMock.mock("http://am2.example.com/api/v2/silences", {
fetchMock.mock("http://m1.example.com/api/v2/silences", {
status: 500,
body: "error message",
});
fetchMock.mock("http://am1.example.com/api/v2/silences", {
fetchMock.mock("http://m2.example.com/api/v2/silences", {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ silenceID: "123" }),
});
fetchMock.mock("http://m3.example.com/api/v2/silences", {
status: 500,
body: "error message",
});
fetchMock.mock("http://m4.example.com/api/v2/silences", {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ silenceID: "456" }),
});
alertStore.data.upstreams = {
clusters: { default: ["default", "fallback"] },
instances: [
{
name: "default",
uri: "http://am1.example.com",
publicURI: "http://am1.example.com",
readonly: false,
headers: {},
corsCredentials: "include",
error: "",
version: "0.17.0",
cluster: "default",
clusterMembers: ["default", "fallback"],
},
{
name: "fallback",
uri: "http://am2.example.com",
publicURI: "http://am2.example.com",
readonly: false,
headers: {},
corsCredentials: "include",
error: "",
version: "0.17.0",
cluster: "default",
clusterMembers: ["default", "fallback"],
},
],
clusters: { c1: ["m1", "m2"], c2: ["m3", "m4"] },
instances: ["m1", "m2", "m3", "m4"].map((a) => ({
name: a,
uri: `http://${a}.example.com`,
publicURI: `http://${a}.example.com`,
readonly: false,
headers: { "X-Cluster": a === "m1" || a === "2" ? "c1" : "c2" },
corsCredentials: a === "m1" || a === "m2" ? "same-site" : "include",
error: "",
version: "0.17.0",
cluster: a === "m1" || a === "2" ? "c1" : "c2",
clusterMembers: a === "m1" || a === "2" ? ["m1", "m2"] : ["m3", "m4"],
})),
};
group.alertmanagerCount = {
m1: 1,
m2: 1,
m3: 1,
m4: 1,
};
const tree = MountedAlertAck();
const button = tree.find("span.badge");
button.simulate("click");
await fetchMock.flush(true);
await fetchMock.flush(true);
await act(async () => {
await fetchMock.flush(true);
});
expect(fetchMock.calls()).toHaveLength(4);
expect(fetchMock.calls()[0][0]).toBe(
"http://am2.example.com/api/v2/silences"
"http://m1.example.com/api/v2/silences"
);
expect(fetchMock.calls()[1][0]).toBe(
"http://am1.example.com/api/v2/silences"
"http://m2.example.com/api/v2/silences"
);
expect(fetchMock.calls()[2][0]).toBe(
"http://m3.example.com/api/v2/silences"
);
expect(fetchMock.calls()[3][0]).toBe(
"http://m4.example.com/api/v2/silences"
);
});
it("will retry on another cluster member after fetch failure", async () => {
fetchMock.reset();
fetchMock.mock("http://am2.example.com/api/v2/silences", {
fetchMock.mock("http://m1.example.com/api/v2/silences", {
throws: new TypeError("failed to fetch"),
});
fetchMock.mock("http://am1.example.com/api/v2/silences", {
fetchMock.mock("http://m2.example.com/api/v2/silences", {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ silenceID: "123" }),
});
fetchMock.mock("http://m3.example.com/api/v2/silences", {
throws: new TypeError("failed to fetch"),
});
fetchMock.mock("http://m4.example.com/api/v2/silences", {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ silenceID: "123" }),
});
alertStore.data.upstreams = {
clusters: { default: ["default", "fallback"] },
instances: [
{
name: "default",
uri: "http://am1.example.com",
publicURI: "http://am1.example.com",
readonly: false,
headers: {},
corsCredentials: "include",
error: "",
version: "0.17.0",
cluster: "default",
clusterMembers: ["default", "fallback"],
},
{
name: "fallback",
uri: "http://am2.example.com",
publicURI: "http://am2.example.com",
readonly: false,
headers: {},
corsCredentials: "include",
error: "",
version: "0.17.0",
cluster: "default",
clusterMembers: ["default", "fallback"],
},
],
clusters: { c1: ["m1", "m2"], c2: ["m3", "m4"] },
instances: ["m1", "m2", "m3", "m4"].map((a) => ({
name: a,
uri: `http://${a}.example.com`,
publicURI: `http://${a}.example.com`,
readonly: false,
headers: { "X-Cluster": a === "m1" || a === "2" ? "c1" : "c2" },
corsCredentials: a === "m1" || a === "m2" ? "same-site" : "include",
error: "",
version: "0.17.0",
cluster: a === "m1" || a === "2" ? "c1" : "c2",
clusterMembers: a === "m1" || a === "2" ? ["m1", "m2"] : ["m3", "m4"],
})),
};
group.alertmanagerCount = {
m1: 1,
m2: 1,
m3: 1,
m4: 1,
};
const tree = MountedAlertAck();
const button = tree.find("span.badge");
button.simulate("click");
await fetchMock.flush(true);
await fetchMock.flush(true);
await act(async () => {
await fetchMock.flush(true);
});
await act(async () => {
await fetchMock.flush(true);
});
expect(fetchMock.calls()).toHaveLength(4);
expect(fetchMock.calls()[0][0]).toBe(
"http://am2.example.com/api/v2/silences"
"http://m1.example.com/api/v2/silences"
);
expect(fetchMock.calls()[1][0]).toBe(
"http://am1.example.com/api/v2/silences"
"http://m2.example.com/api/v2/silences"
);
expect(fetchMock.calls()[2][0]).toBe(
"http://m3.example.com/api/v2/silences"
);
expect(fetchMock.calls()[3][0]).toBe(
"http://m4.example.com/api/v2/silences"
);
});
it("will log an error if Alertmanager instance is missing from instances and try the next one", () => {
it("will log an error if Alertmanager instance is missing from instances and try the next one", async () => {
const consoleSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
@@ -400,6 +493,10 @@ describe("<AlertAck />", () => {
const tree = MountedAlertAck();
const button = tree.find("span.badge");
button.simulate("click");
await act(async () => {
await fetchMock.flush(true);
});
expect(fetchMock.calls()).toHaveLength(1);
expect(fetchMock.calls()[0][0]).toBe(
"http://am1.example.com/api/v2/silences"
);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import merge from "lodash.merge";
@@ -6,10 +6,22 @@ import { CommonOptions } from "Common/Fetch";
const useFetchAny = (upstreams) => {
const [index, setIndex] = useState(0);
const [response, setResponse] = useState(null);
const [error, setError] = useState(null);
const [inProgress, setInProgress] = useState(true);
const [responseURI, setResponseURI] = useState(null);
const [response, setResponse] = useState({
response: null,
error: null,
responseURI: null,
inProgress: false,
});
const reset = useCallback(() => {
setIndex(0);
setResponse({
response: null,
error: null,
responseURI: null,
inProgress: false,
});
}, []);
useEffect(() => {
// https://dev.to/pallymore/clean-up-async-requests-in-useeffect-hooks-90h
@@ -18,8 +30,13 @@ const useFetchAny = (upstreams) => {
const fetchData = async () => {
const { uri, options } = upstreams[index];
setResponse({
response: null,
error: null,
responseURI: null,
inProgress: true,
});
try {
setInProgress(true);
const res = await fetch(
uri,
merge({}, { method: "GET" }, CommonOptions, options)
@@ -35,15 +52,22 @@ const useFetchAny = (upstreams) => {
}
if (res.ok) {
setResponse(body);
setResponseURI(uri);
setInProgress(false);
setResponse({
response: body,
error: null,
responseURI: uri,
inProgress: false,
});
} else {
if (upstreams.length > index + 1) {
setIndex(index + 1);
} else {
setError(body);
setInProgress(false);
setResponse({
response: null,
error: body,
responseURI: null,
inProgress: false,
});
}
}
}
@@ -52,8 +76,12 @@ const useFetchAny = (upstreams) => {
if (upstreams.length > index + 1) {
setIndex(index + 1);
} else {
setError(error.message);
setInProgress(false);
setResponse({
response: null,
error: error.message,
responseURI: null,
inProgress: false,
});
}
}
}
@@ -62,15 +90,15 @@ const useFetchAny = (upstreams) => {
if (upstreams.length > 0) {
fetchData();
} else {
setInProgress(false);
reset();
}
return () => {
isCancelled = true;
};
}, [upstreams, index]);
}, [upstreams, index, reset]);
return { response, error, inProgress, responseURI };
return { ...response, reset };
};
export { useFetchAny };