Skip to content
Open
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
4 changes: 2 additions & 2 deletions src/Frontend/src/components/audit/AuditList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const { messages, totalCount, sortBy, messageFilterString, selectedEndpointName,
const route = useRoute();
const router = useRouter();
const autoRefreshValue = ref<number | null>(null);
const { refreshNow, isRefreshing, updateInterval, start, stop } = useFetchWithAutoRefresh("audit-list", store.refresh, 3000);
const { refreshNow, isRefreshing, updateInterval, isActive, start, stop } = useFetchWithAutoRefresh("audit-list", store.refresh, 0);
const firstLoad = ref(true);

onBeforeMount(() => {
Expand Down Expand Up @@ -82,7 +82,7 @@ watch(autoRefreshValue, (newValue) => {
updateInterval(newValue || 0);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that you modified updateInterval to do the pause and resume as part of the internals, I don't think we need the if/else statement at all here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

without the if statement, there would be nothing to call start when going from 0 to >0. This has made me realise, however, that the updateInterval will potentially resume before start is ever called

if (newValue === null || newValue === 0) {
stop();
} else {
} else if (!isActive.value) {
start();
}
});
Expand Down
254 changes: 32 additions & 222 deletions src/Frontend/src/components/failedmessages/DeletedMessageGroups.vue
Original file line number Diff line number Diff line change
@@ -1,194 +1,54 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useShowToast } from "../../composables/toast";
import createMessageGroupClient from "./messageGroupClient";
import { useCookies } from "vue3-cookies";
import NoData from "../NoData.vue";
import TimeSince from "../TimeSince.vue";
import LicenseNotExpired from "../../components/LicenseNotExpired.vue";
import ServiceControlAvailable from "../ServiceControlAvailable.vue";
import ConfirmDialog from "../ConfirmDialog.vue";
import routeLinks from "@/router/routeLinks";
import FailureGroupView from "@/resources/FailureGroupView";
import { TYPE } from "vue-toastification";
import MetadataItem from "@/components/MetadataItem.vue";
import ActionButton from "@/components/ActionButton.vue";
import { faArrowRotateRight, faEnvelope } from "@fortawesome/free-solid-svg-icons";
import { faClock } from "@fortawesome/free-regular-svg-icons";
import { useServiceControlStore } from "@/stores/ServiceControlStore";

const statusesForRestoreOperation = ["restorestarted", "restoreprogressing", "restorefinalizing", "restorecompleted"] as const;
type RestoreOperationStatus = (typeof statusesForRestoreOperation)[number];
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const otherStatuses = ["none", "working"] as const;
type Status = RestoreOperationStatus | (typeof otherStatuses)[number];
interface WorkflowState {
status: Status;
total?: number;
failed?: boolean;
message?: string;
}

interface ExtendedFailureGroupView extends FailureGroupView {
index: number;
need_user_acknowledgement?: boolean;
workflow_state: WorkflowState;
operation_remaining_count?: number;
hover2?: boolean;
hover3?: boolean;
operation_start_time?: string;
last_operation_completion_time?: string;
}
import { useDeletedMessageGroupsStore, statusesForRestoreOperation, ExtendedFailureGroupView, Status } from "@/stores/DeletedMessageGroupsStore";
import { useStoreAutoRefresh } from "@/composables/useAutoRefresh";
import { storeToRefs } from "pinia";
import LoadingSpinner from "../LoadingSpinner.vue";

let pollingFaster = false;
const archiveGroups = ref<ExtendedFailureGroupView[]>([]);
const undismissedRestoreGroups = ref<ExtendedFailureGroupView[]>([]);
const loadingData = ref(true);
const initialLoadComplete = ref(false);
const emit = defineEmits<{
InitialLoadComplete: [];
}>();
let refreshInterval: number | undefined = undefined;
const route = useRoute();
const { autoRefresh, isRefreshing, updateInterval } = useStoreAutoRefresh("deletedMessageGroups", useDeletedMessageGroupsStore, 5000);
const { store } = autoRefresh();
const { archiveGroups, classifiers, selectedClassifier } = storeToRefs(store);
const router = useRouter();
const showRestoreGroupModal = ref(false);
const selectedGroup = ref<ExtendedFailureGroupView>();

const serviceControlStore = useServiceControlStore();
const messageGroupClient = createMessageGroupClient();

const groupRestoreSuccessful = ref<boolean | null>(null);
const selectedClassifier = ref<string | null>(null);
const classifiers = ref<string[]>([]);

async function getGroupingClassifiers() {
const [, data] = await serviceControlStore.fetchTypedFromServiceControl<string[]>("recoverability/classifiers");
classifiers.value = data;
}

function saveDefaultGroupingClassifier(classifier: string) {
const cookies = useCookies().cookies;
cookies.set("archived_groups_classification", classifier);
}

async function classifierChanged(classifier: string) {
saveDefaultGroupingClassifier(classifier);

store.setGrouping(classifier);
selectedClassifier.value = classifier;
archiveGroups.value = [];
await loadArchivedMessageGroups(classifier);
}

async function getArchiveGroups(classifier: string) {
//get all deleted message groups
const [, result] = await serviceControlStore.fetchTypedFromServiceControl<FailureGroupView[]>(`errors/groups/${classifier}`);

if (result.length === 0 && undismissedRestoreGroups.value.length > 0) {
undismissedRestoreGroups.value.forEach((deletedGroup) => {
deletedGroup.need_user_acknowledgement = true;
deletedGroup.workflow_state.status = "restorecompleted";
});
}

undismissedRestoreGroups.value.forEach((deletedGroup) => {
if (!result.find((group) => group.id === deletedGroup.id)) {
deletedGroup.need_user_acknowledgement = true;
deletedGroup.workflow_state.status = "restorecompleted";
}
});

// need a map in some ui state for controlling animations
const mappedResults = result
.filter((group) => !undismissedRestoreGroups.value.find((deletedGroup) => deletedGroup.id === group.id))
.map(initializeGroupState)
.concat(undismissedRestoreGroups.value);

let maxIndex = archiveGroups.value.reduce((currentMax, currentGroup) => Math.max(currentMax, currentGroup.index), 0);

mappedResults.forEach((serverGroup) => {
const previousGroup = archiveGroups.value.find((oldGroup) => oldGroup.id === serverGroup.id);

if (previousGroup) {
serverGroup.index = previousGroup.index;
} else {
serverGroup.index = ++maxIndex;
}
});

archiveGroups.value = mappedResults.sort((group1, group2) => {
return group1.index - group2.index;
});
}

function initializeGroupState(group: FailureGroupView): ExtendedFailureGroupView {
return {
index: 0,
workflow_state: createWorkflowState("none"),
...group,
};
}

function loadDefaultGroupingClassifier() {
const cookies = useCookies().cookies;
const cookieGrouping = cookies.get("archived_groups_classification");

if (cookieGrouping) {
return cookieGrouping;
}

return null;
}

async function loadArchivedMessageGroups(groupBy: string | null = null) {
loadingData.value = true;
if (!initialLoadComplete.value || !groupBy) {
groupBy = loadDefaultGroupingClassifier();
}

await getArchiveGroups(groupBy ?? (route.query.deletedGroupBy as string));
loadingData.value = false;
initialLoadComplete.value = true;

emit("InitialLoadComplete");
}

//create workflow state
function createWorkflowState(optionalStatus?: Status, optionalTotal?: number, optionalFailed?: boolean): WorkflowState {
if (optionalTotal && optionalTotal <= 1) {
optionalTotal = optionalTotal * 100;
}

return {
status: optionalStatus ?? "working",
total: optionalTotal ?? 0,
failed: optionalFailed ?? false,
};
await store.refresh();
}

//Restore operation
function showRestoreGroupDialog(group: ExtendedFailureGroupView) {
groupRestoreSuccessful.value = null;
selectedGroup.value = group;
showRestoreGroupModal.value = true;
}

async function restoreGroup() {
const group = selectedGroup.value;
if (group) {
// We're starting a restore, poll more frequently
changeRefreshInterval(1000);
undismissedRestoreGroups.value.push(group);

group.workflow_state = { status: "restorestarted", message: "Restore request initiated..." };
group.operation_start_time = new Date().toUTCString();

const result = await messageGroupClient.restoreGroup(group.id);
if (messageGroupClient.isError(result)) {
groupRestoreSuccessful.value = false;
useShowToast(TYPE.ERROR, "Error", `Failed to restore the group: ${result.message}`);
const { result, errorMessage } = await store.restoreGroup(group);
if (!result) {
useShowToast(TYPE.ERROR, "Error", `Failed to restore the group: ${errorMessage}`);
} else {
groupRestoreSuccessful.value = true;
// We're starting a restore, poll more frequently
pollingFaster = true;
updateInterval(1000);
useShowToast(TYPE.INFO, "Info", "Group restore started...");
}
}
Expand All @@ -211,20 +71,6 @@ function getClassesForRestoreOperation(stepStatus: Status, currentStatus: Status
return getClasses(stepStatus, currentStatus, statusesForRestoreOperation);
}

const acknowledgeGroup = function (dismissedGroup: FailureGroupView) {
undismissedRestoreGroups.value.splice(
undismissedRestoreGroups.value.findIndex((group) => {
return group.id === dismissedGroup.id;
}),
1
);

archiveGroups.value.splice(
archiveGroups.value.findIndex((group) => group.id === dismissedGroup.id),
1
);
};

function isBeingRestored(status: Status) {
return (statusesForRestoreOperation as readonly Status[]).includes(status);
}
Expand All @@ -237,44 +83,17 @@ function isRestoreInProgress() {
return archiveGroups.value.some((group) => group.workflow_state.status !== "none" && group.workflow_state.status !== "restorecompleted");
}

function changeRefreshInterval(milliseconds: number) {
if (refreshInterval) {
clearInterval(refreshInterval);
}

refreshInterval = window.setInterval(() => {
// If we're currently polling at 5 seconds and there is a restore in progress, then change the polling interval to poll every 1 second
if (!pollingFaster && isRestoreInProgress()) {
changeRefreshInterval(1000);
pollingFaster = true;
} else if (pollingFaster && !isRestoreInProgress()) {
// if we're currently polling every 1 second and all restores are done, change polling frequency back to every 5 seconds
changeRefreshInterval(5000);
pollingFaster = false;
}

loadArchivedMessageGroups();
}, milliseconds);
}

onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
watch(isRefreshing, () => {
// If we're currently polling at 5 seconds and there is a restore in progress, then change the polling interval to poll every 1 second
if (!pollingFaster && isRestoreInProgress()) {
pollingFaster = true;
updateInterval(1000);
} else if (pollingFaster && !isRestoreInProgress()) {
// if we're currently polling every 1 second and all restores are done, change polling frequency back to every 5 seconds
pollingFaster = false;
updateInterval(5000);
}
});
Comment on lines +86 to 96
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something feels off here. Why are we updating the interval on the isRefreshing watch?
The interval is already updated when we call restoreGroup.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I decided to rewrite this, see #2717


onMounted(async () => {
await getGroupingClassifiers();
let savedClassifier = loadDefaultGroupingClassifier();
if (!savedClassifier) {
savedClassifier = classifiers.value[0];
}

selectedClassifier.value = savedClassifier;
await loadArchivedMessageGroups();

changeRefreshInterval(5000);
});
</script>

<template>
Expand Down Expand Up @@ -307,31 +126,24 @@ onMounted(async () => {
<div>
<div class="row">
<div class="col-sm-12">
<no-data v-if="archiveGroups.length === 0 && !loadingData" title="message groups" message="There are currently no grouped message failures"></no-data>
<no-data v-if="archiveGroups.length === 0 && !isRefreshing" title="message groups" message="There are currently no grouped message failures"></no-data>
<LoadingSpinner v-if="archiveGroups.length === 0 && isRefreshing" />
</div>
</div>

<div class="row">
<div class="col-sm-12 no-mobile-side-padding">
<div v-if="archiveGroups.length > 0">
<div
:class="`row box box-group wf-${group.workflow_state.status} repeat-modify deleted-message-group`"
v-for="(group, index) in archiveGroups"
:key="index"
:disabled="group.count == 0"
@mouseenter="group.hover2 = true"
@mouseleave="group.hover2 = false"
@click.prevent="navigateToGroup(group.id)"
>
<div :class="`row box box-group wf-${group.workflow_state.status} repeat-modify deleted-message-group`" v-for="group in archiveGroups" :key="group.id" :disabled="group.count == 0" @click.prevent="navigateToGroup(group.id)">
<div class="col-sm-12 no-mobile-side-padding">
<div class="row">
<div class="col-sm-12 no-side-padding">
<div class="row box-header">
<div class="col-sm-12 no-side-padding">
<p class="lead break" v-bind:class="{ 'msg-type-hover': group.hover2, 'msg-type-hover-off': group.hover3 }">{{ group.title }}</p>
<p class="lead break">{{ group.title }}</p>
<p class="metadata" v-if="!isBeingRestored(group.workflow_state.status)">
<MetadataItem :icon="faEnvelope">
{{ group.count }} message<span v-if="group.count > 1">s</span>
<span>{{ group.count }} message<template v-if="group.count > 1">s</template></span>
<span v-if="group.operation_remaining_count"> (currently restoring {{ group.operation_remaining_count }} </span>
</MetadataItem>

Expand All @@ -357,8 +169,6 @@ onMounted(async () => {
size="sm"
:icon="faArrowRotateRight"
:disabled="group.count === 0 || isBeingRestored(group.workflow_state.status)"
@mouseenter="group.hover3 = true"
@mouseleave="group.hover3 = false"
v-if="archiveGroups.length > 0"
@click.stop="showRestoreGroupDialog(group)"
>
Expand All @@ -378,7 +188,7 @@ onMounted(async () => {
</li>
<li v-if="group.workflow_state.status === 'restorecompleted'">
<div class="retry-completed bulk-retry-progress-status">Restore request completed</div>
<button type="button" class="btn btn-default btn-primary btn-xs btn-retry-dismiss" v-if="group.need_user_acknowledgement == true" @click.stop="acknowledgeGroup(group)">Dismiss</button>
<button type="button" class="btn btn-default btn-primary btn-xs btn-retry-dismiss" v-if="group.need_user_acknowledgement == true" @click.stop="store.acknowledgeGroup(group)">Dismiss</button>
</li>
</ul>
<div class="op-metadata">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ export interface ErrorResponse {
message: string;
}

class MessageGroupClient {
export class MessageGroupClient {
serviceControlStore: ServiceControlStore;
constructor() {
constructor(store?: ServiceControlStore) {
//this module is only called from within view setup or other pinia stores, so this call is lifecycle safe
this.serviceControlStore = useServiceControlStore();
this.serviceControlStore = store ?? useServiceControlStore();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given useServiceControlStore() is a singleton, do we need to pass it in?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not a singleton, but pinia ensures that the same store is injected for each call. This seems safer, given we don't control where in the lifecycle this class is constructed

}

public async getExceptionGroups(classifier: string = "") {
Expand Down
Loading