Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 9 additions & 15 deletions src/components/FlowDeleteModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
SpaceBetween,
TextContent,
} from "@cloudscape-design/components";
import useAlertsStore from "@/stores/useAlertsStore";
import { useOutcomeAlerts } from "@/stores/useAlertsStore";
import { useDelete } from "@/hooks/useFlows";

const FlowDeleteModal = ({
Expand All @@ -16,24 +16,18 @@ const FlowDeleteModal = ({
}) => {
const [isDeleting, setIsDeleting] = useState(false);
const { del } = useDelete();
const addAlertItems = useAlertsStore((state) => state.addAlertItems);
const delAlertItem = useAlertsStore((state) => state.delAlertItem);
const reportOutcomes = useOutcomeAlerts();

const deleteFlow = async () => {
setIsDeleting(true);
const promises = selectedItems.map((item) => del({ flowId: item.id }));
const id = crypto.randomUUID();
addAlertItems(
selectedItems.map((flow, n) => ({
type: "success",
dismissible: true,
dismissLabel: "Dismiss message",
content: `Flow ${flow.id} is being deleted. This will happen asynchronously`,
id: `${id}-${n}`,
onDismiss: () => delAlertItem(`${id}-${n}`),
}))
const results = await Promise.allSettled(
selectedItems.map((item) => del({ flowId: item.id }))
);
await Promise.all(promises);
reportOutcomes(results, selectedItems, {
success: (flow) =>
`Flow ${flow.id} is being deleted. This will happen asynchronously`,
failure: (flow, reason) => `Failed to delete flow ${flow.id}: ${reason}`,
});
setIsDeleting(false);
setModalVisible(false);
};
Expand Down
30 changes: 12 additions & 18 deletions src/components/FlowDeleteTimeRangeModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
Modal,
SpaceBetween,
} from "@cloudscape-design/components";
import useAlertsStore from "@/stores/useAlertsStore";
import { useOutcomeAlerts } from "@/stores/useAlertsStore";
import { useDeleteTimerange } from "@/hooks/useFlows";

const FlowDeleteTimeRangeModal = ({
Expand All @@ -16,28 +16,22 @@ const FlowDeleteTimeRangeModal = ({
selectedItems,
}) => {
const { delTimerange, isDeletingTimerange } = useDeleteTimerange();
const addAlertItems = useAlertsStore((state) => state.addAlertItems);
const delAlertItem = useAlertsStore((state) => state.delAlertItem);
const reportOutcomes = useOutcomeAlerts();
const [timerange, setTimerange] = useState("");

const deleteTimerange = async () => {
const promises = selectedItems.map((item) =>
delTimerange({ flowId: item.id, timerange })
const results = await Promise.allSettled(
selectedItems.map((item) => delTimerange({ flowId: item.id, timerange }))
);
const id = crypto.randomUUID();
addAlertItems(
selectedItems.map((flow, n) => ({
type: "success",
dismissible: true,
dismissLabel: "Dismiss message",
content: `Flow segments on flow ${flow.id} within the timerange ${timerange} are being deleted. This will happen asynchronously.`,
id: `${id}-${n}`,
onDismiss: () => delAlertItem(`${id}-${n}`),
}))
);
await Promise.all(promises);
const allSucceeded = reportOutcomes(results, selectedItems, {
success: (flow) =>
`Flow segments on flow ${flow.id} within the timerange ${timerange} are being deleted. This will happen asynchronously.`,
failure: (flow, reason) =>
`Failed to delete segments on flow ${flow.id} within the timerange ${timerange}: ${reason}`,
});
setModalVisible(false);
setTimerange("");
// Kept on failure so the timerange is still there to retry with.
if (allSucceeded) setTimerange("");
};

const handleDismiss = () => {
Expand Down
35 changes: 12 additions & 23 deletions src/components/WebhookDeleteModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Modal, TextContent } from "@cloudscape-design/components";
import CancelModalFooter from "@/components/CancelModalFooter";
import useAlertsStore from "@/stores/useAlertsStore";
import { useOutcomeAlerts } from "@/stores/useAlertsStore";
import { useDelete } from "@/hooks/useWebhooks";

const WebhookDeleteModal = ({
Expand All @@ -13,8 +13,7 @@ const WebhookDeleteModal = ({
const navigate = useNavigate();
const [isDeleting, setIsDeleting] = useState(false);
const { del } = useDelete();
const addAlertItems = useAlertsStore((state) => state.addAlertItems);
const delAlertItem = useAlertsStore((state) => state.delAlertItem);
const reportOutcomes = useOutcomeAlerts();

const handleDismiss = () => {
setIsDeleting(false);
Expand All @@ -24,26 +23,16 @@ const WebhookDeleteModal = ({

const deleteWebhooks = async () => {
setIsDeleting(true);
try {
await Promise.all(
selectedItems.map((item) => del({ webhookId: item.id }))
);
const id = crypto.randomUUID();
addAlertItems(
selectedItems.map((webhook, n) => ({
type: "success",
dismissible: true,
dismissLabel: "Dismiss message",
content: `Webhook ${webhook.id} is being deleted. This will happen asynchronously`,
id: `${id}-${n}`,
onDismiss: () => delAlertItem(`${id}-${n}`),
}))
);
} catch {
// Alert emitted by useApi
} finally {
handleDismiss();
}
const results = await Promise.allSettled(
selectedItems.map((item) => del({ webhookId: item.id }))
);
reportOutcomes(results, selectedItems, {
success: (webhook) =>
`Webhook ${webhook.id} is being deleted. This will happen asynchronously`,
failure: (webhook, reason) =>
`Failed to delete webhook ${webhook.id}: ${reason}`,
});
handleDismiss();
};

return (
Expand Down
12 changes: 9 additions & 3 deletions src/hooks/useFlows.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,15 @@ export const useDeleteTimerange = () => {
const { trigger, isMutating } = useSWRMutation(
api.endpoint ? [api.endpoint, "/flows"] : null,
([, path], { arg }) =>
api.del(`${path}/${arg.flowId}/segments?timerange=${arg.timerange}`).then(
(response) => response.data
)
// buildQuery rather than interpolation: a timerange is typed by the user
// and carries `[`, `:` and `)`, which have to be percent-encoded.
api
.del(
buildQuery(`${path}/${arg.flowId}/segments`, {
timerange: arg.timerange,
})
)
.then((response) => response.data)
);

return {
Expand Down
36 changes: 36 additions & 0 deletions src/stores/useAlertsStore.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,40 @@ const useAlertsStore = create((set) => ({
})),
}));

/**
* Report the outcome of a batch of requests, one alert per item.
*
* Alerts are raised only once the requests have settled: announcing success
* up front and then awaiting the promises told the user a delete had happened
* when it might still fail, and a rejection skipped the rest of the handler,
* leaving the button spinning with nothing on screen to explain it.
*
* Returns whether every request succeeded.
*/
export const useOutcomeAlerts = () => {
const addAlertItems = useAlertsStore((state) => state.addAlertItems);
const delAlertItem = useAlertsStore((state) => state.delAlertItem);

return (results, items, { success, failure }) => {
const batch = crypto.randomUUID();
addAlertItems(
results.map((result, n) => {
const id = `${batch}-${n}`;
const failed = result.status === "rejected";
const reason =
result.reason instanceof Error ? result.reason.message : "Unknown error";
return {
type: failed ? "error" : "success",
dismissible: true,
dismissLabel: "Dismiss message",
content: failed ? failure(items[n], reason) : success(items[n]),
id,
onDismiss: () => delAlertItem(id),
};
})
);
return results.every((result) => result.status === "fulfilled");
};
};

export default useAlertsStore;
21 changes: 17 additions & 4 deletions src/views/Embed/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,13 @@ const resolveVideoSourceId = async (source, api, canQueryCollections) => {
const children = Array.isArray(response.data) ? response.data : [];
const byId = new Map(children.map((child) => [child.id, child]));
return findVideoMember(collection, byId) ?? source.id;
} catch {
return source.id;
} catch (error) {
// Not `source.id`: that is a real answer for a source with no collection,
// and returning it here would pin the wrong flow's fps, duration and
// status onto the row and present them as the source's own. Null instead,
// so the caller leaves the source unresolved and looks again.
console.warn("[embed] could not resolve the video source for", source.id, error);
return null;
}
};

Expand Down Expand Up @@ -192,6 +197,7 @@ const useSourceFlowDetails = (displayedSources, liveSourceIds) => {
api,
canQueryCollections
);
if (!videoSourceId) return;
const listRes = await api.get(`/flows?source_id=${videoSourceId}&limit=1`);
const listed = listRes.data?.[0];
if (!listed) {
Expand Down Expand Up @@ -244,7 +250,12 @@ const useSourceFlowDetails = (displayedSources, liveSourceIds) => {
return next;
});
}
} catch { /* skip */ }
} catch (error) {
// Reported rather than swallowed: a request that fails on every
// pass is otherwise indistinguishable from one that succeeds, and
// an unknown query parameter went unnoticed for months that way.
console.warn("[embed] could not read the flow for source", source.id, error);
}
})
);
if (!cancelled && showLoading) setIsLoading(false);
Expand Down Expand Up @@ -303,7 +314,9 @@ const useSourceFlowDetails = (displayedSources, liveSourceIds) => {
next.set(sourceId, { ...existing, isGrowing, durationMs, hasSegments });
return next;
});
} catch { /* skip */ }
} catch (error) {
console.warn("[embed] could not poll flow", flowId, error);
}
})
);
};
Expand Down
Loading