fix(sla): freeze misses after resolution

This commit is contained in:
Sony Mathew
2026-07-15 13:00:51 +05:30
parent 13db36609d
commit 92d5d5ecff
16 changed files with 598 additions and 66 deletions
@@ -1,6 +1,10 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import { useI18n } from 'vue-i18n';
import {
evaluateSLAStatus,
shouldRefreshSLAStatus,
} from 'dashboard/helper/slaHelper';
const props = defineProps({
conversation: {
@@ -12,6 +16,7 @@ const props = defineProps({
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const { t } = useI18n();
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
@@ -24,12 +29,15 @@ const slaEvents = computed(() => props.conversation?.slaEvents);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const hasSlaThreshold = computed(() => {
return slaStatus.value?.threshold && appliedSLA.value?.id;
return slaStatus.value?.type && appliedSLA.value?.id;
});
const slaStatusText = computed(() => {
return slaStatus.value?.type?.toUpperCase();
});
const slaValueText = computed(
() => slaStatus.value?.threshold || t('CONVERSATION.HEADER.SLA_STATUS.MISSED')
);
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
@@ -39,7 +47,24 @@ const updateSlaStatus = () => {
});
};
const clearTimer = () => {
if (timer.value) {
clearTimeout(timer.value);
timer.value = null;
}
};
const createTimer = () => {
clearTimer();
if (
!shouldRefreshSLAStatus({
appliedSla: appliedSLA.value,
chat: props.conversation,
})
) {
return;
}
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
@@ -52,12 +77,16 @@ onMounted(() => {
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
clearTimer();
});
watch(() => props.conversation, updateSlaStatus);
watch(
() => props.conversation,
() => {
updateSlaStatus();
createTimer();
}
);
// This expose is to provide context to the parent component, so that it can decided weather
// a new row has to be added to the conversation card or not
@@ -96,7 +125,7 @@ defineExpose({
class="text-sm truncate"
:class="isSlaMissed ? 'text-n-ruby-11' : 'text-n-slate-11'"
>
{{ `${slaStatusText}: ${slaStatus.threshold}` }}
{{ `${slaStatusText}: ${slaValueText}` }}
</span>
</div>
</template>
@@ -1,6 +1,10 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import { useI18n } from 'vue-i18n';
import {
evaluateSLAStatus,
shouldRefreshSLAStatus,
} from 'dashboard/helper/slaHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
@@ -15,6 +19,7 @@ const props = defineProps({
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const { t } = useI18n();
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
@@ -28,8 +33,18 @@ defineOptions({
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const hasSlaThreshold = computed(() => slaStatus.value?.type);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaLabel = computed(() => {
if (slaStatus.value?.threshold) return slaStatus.value.threshold;
const status = t('CONVERSATION.HEADER.SLA_STATUS.MISSED');
return {
FRT: t('CONVERSATION.HEADER.SLA_STATUS.FRT', { status }),
NRT: t('CONVERSATION.HEADER.SLA_STATUS.NRT', { status }),
RT: t('CONVERSATION.HEADER.SLA_STATUS.RT', { status }),
}[slaStatus.value.type];
});
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
@@ -39,7 +54,24 @@ const updateSlaStatus = () => {
});
};
const clearTimer = () => {
if (timer.value) {
clearTimeout(timer.value);
timer.value = null;
}
};
const createTimer = () => {
clearTimer();
if (
!shouldRefreshSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
})
) {
return;
}
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
@@ -52,12 +84,16 @@ onMounted(() => {
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
clearTimer();
});
watch(() => props.chat, updateSlaStatus);
watch(
() => props.chat,
() => {
updateSlaStatus();
createTimer();
}
);
defineExpose({
hasSlaThreshold,
@@ -70,11 +106,7 @@ defineExpose({
v-bind="$attrs"
class="relative flex items-center cursor-pointer min-w-fit group"
>
<Label
:label="slaStatus.threshold"
:color="isSlaMissed ? 'ruby' : 'amber'"
compact
>
<Label :label="slaLabel" :color="isSlaMissed ? 'ruby' : 'amber'" compact>
<template #icon>
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
</template>
@@ -1,7 +1,10 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import {
evaluateSLAStatus,
shouldRefreshSLAStatus,
} from 'dashboard/helper/slaHelper';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
@@ -32,7 +35,7 @@ const slaStatus = ref({
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const hasSlaThreshold = computed(() => slaStatus.value?.type);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const slaTextStyles = computed(() =>
isSlaMissed.value ? 'text-n-ruby-11' : 'text-n-amber-11'
@@ -40,12 +43,24 @@ const slaTextStyles = computed(() =>
const slaStatusText = computed(() => {
const upperCaseType = slaStatus.value?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = isSlaMissed.value ? 'MISSED' : 'DUE';
const status = isSlaMissed.value
? t('CONVERSATION.HEADER.SLA_STATUS.MISSED')
: t('CONVERSATION.HEADER.SLA_STATUS.DUE');
return t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
});
return {
FRT: t('CONVERSATION.HEADER.SLA_STATUS.FRT', { status }),
NRT: t('CONVERSATION.HEADER.SLA_STATUS.NRT', { status }),
RT: t('CONVERSATION.HEADER.SLA_STATUS.RT', { status }),
}[upperCaseType];
});
const showFullStatusText = computed(
() => props.showExtendedInfo && props.parentWidth > 650
);
const slaValueText = computed(
() =>
slaStatus.value?.threshold ||
(showFullStatusText.value ? '' : slaStatusText.value)
);
const showSlaPopoverCard = computed(
() => props.showExtendedInfo && slaEvents.value?.length > 0
@@ -65,7 +80,24 @@ const updateSlaStatus = () => {
});
};
const clearTimer = () => {
if (timer.value) {
clearTimeout(timer.value);
timer.value = null;
}
};
const createTimer = () => {
clearTimer();
if (
!shouldRefreshSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
})
) {
return;
}
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
@@ -76,6 +108,7 @@ watch(
() => props.chat,
() => {
updateSlaStatus();
createTimer();
}
);
@@ -91,9 +124,7 @@ onMounted(() => {
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
clearTimer();
});
</script>
@@ -118,7 +149,7 @@ onUnmounted(() => {
:class="slaTextStyles"
/>
<span
v-if="showExtendedInfo && parentWidth > 650"
v-if="showFullStatusText"
class="text-xs font-medium"
:class="slaTextStyles"
>
@@ -126,10 +157,11 @@ onUnmounted(() => {
</span>
</div>
<span
v-if="slaValueText"
class="text-xs font-medium"
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
>
{{ slaStatus.threshold }}
{{ slaValueText }}
</span>
</div>
<SLAPopoverCard
+63 -34
View File
@@ -47,6 +47,22 @@ const toUnixTimestamp = value => {
: Math.floor(parsedTimestamp / 1000);
};
const isSLACompleted = (sla, conversation) => {
const terminalStatuses = ['hit', 'missed'];
return Boolean(
sla.slaCompletedAt ||
terminalStatuses.includes(sla.slaStatus) ||
conversation.status === 'resolved'
);
};
export const shouldRefreshSLAStatus = ({ appliedSla, chat }) => {
if (!appliedSla || !chat) return false;
return !isSLACompleted(useCamelCase(appliedSla), useCamelCase(chat));
};
/**
* Evaluates SLA status using backend-computed due times
* @param {Object} params - Parameters object
@@ -66,6 +82,9 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
const conversation = useCamelCase(chat);
const events = useCamelCase(slaEvents || []);
const currentTime = Math.floor(Date.now() / 1000);
const completionTime = toUnixTimestamp(sla.slaCompletedAt);
const isCompleted = isSLACompleted(sla, conversation);
const evaluationTime = completionTime || (isCompleted ? null : currentTime);
const slaStatuses = [];
const dueAtByType = {
@@ -84,47 +103,51 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
slaStatuses.push({
type,
threshold: missedAt - currentTime,
threshold: evaluationTime ? missedAt - evaluationTime : null,
icon: 'flame',
isSlaMissed: true,
});
});
const firstReplyCreatedAt = toUnixTimestamp(conversation.firstReplyCreatedAt);
const shouldCheckFirstResponse =
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
if (!isCompleted) {
const firstReplyCreatedAt = toUnixTimestamp(
conversation.firstReplyCreatedAt
);
const shouldCheckFirstResponse =
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
// Check FRT - until first reply is made on time
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
const threshold = sla.slaFrtDueAt - currentTime;
slaStatuses.push({
type: 'FRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check FRT - until first reply is made on time
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
const threshold = sla.slaFrtDueAt - currentTime;
slaStatuses.push({
type: 'FRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check NRT - only if first reply made and waiting for response
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
const threshold = sla.slaNrtDueAt - currentTime;
slaStatuses.push({
type: 'NRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check NRT - only if first reply made and waiting for response
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
const threshold = sla.slaNrtDueAt - currentTime;
slaStatuses.push({
type: 'NRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check RT - only if conversation is unresolved
if (sla.slaRtDueAt && conversation.status !== 'resolved') {
const threshold = sla.slaRtDueAt - currentTime;
slaStatuses.push({
type: 'RT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
// Check RT - only if conversation is unresolved
if (sla.slaRtDueAt) {
const threshold = sla.slaRtDueAt - currentTime;
slaStatuses.push({
type: 'RT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
}
if (slaStatuses.length === 0) {
@@ -137,13 +160,19 @@ export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
return a.isSlaMissed ? -1 : 1;
}
if (a.threshold === null || b.threshold === null) {
if (a.threshold === b.threshold) return 0;
return a.threshold === null ? -1 : 1;
}
return Math.abs(a.threshold) - Math.abs(b.threshold);
});
const mostUrgent = slaStatuses[0];
return {
type: mostUrgent.type,
threshold: formatSLATime(mostUrgent.threshold),
threshold:
mostUrgent.threshold === null ? '' : formatSLATime(mostUrgent.threshold),
icon: mostUrgent.icon,
isSlaMissed: mostUrgent.isSlaMissed,
};
@@ -1,4 +1,4 @@
import { evaluateSLAStatus } from '../slaHelper';
import { evaluateSLAStatus, shouldRefreshSLAStatus } from '../slaHelper';
describe('#SLA Helpers', () => {
const currentTimestamp = 1700000000; // Fixed timestamp for testing
@@ -378,6 +378,109 @@ describe('#SLA Helpers', () => {
});
});
describe('completed SLA misses', () => {
it('freezes a recorded FRT miss at the SLA completion time', () => {
const appliedSla = {
sla_status: 'missed',
sla_completed_at: currentTimestamp - 3600,
sla_frt_due_at: currentTimestamp - 7200,
};
const chat = { status: 'resolved' };
const slaEvents = [
{ event_type: 'frt', created_at: currentTimestamp - 7000 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result).toMatchObject({
type: 'FRT',
threshold: '1h',
isSlaMissed: true,
});
});
it('freezes a recorded NRT miss at the SLA completion time', () => {
const appliedSla = {
sla_status: 'missed',
sla_completed_at: currentTimestamp - 3600,
};
const chat = { status: 'resolved' };
const slaEvents = [
{ event_type: 'nrt', created_at: currentTimestamp - 5400 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result).toMatchObject({
type: 'NRT',
threshold: '30m',
isSlaMissed: true,
});
});
it('freezes a recorded RT miss at the SLA completion time', () => {
const appliedSla = {
sla_status: 'missed',
sla_completed_at: currentTimestamp - 3600,
sla_rt_due_at: currentTimestamp - 7200,
};
const chat = { status: 'resolved' };
const slaEvents = [
{ event_type: 'rt', created_at: currentTimestamp - 7000 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result).toMatchObject({
type: 'RT',
threshold: '1h',
isSlaMissed: true,
});
});
it('returns a static miss for a legacy completed SLA without a timestamp', () => {
const appliedSla = {
sla_status: 'missed',
sla_rt_due_at: currentTimestamp - 7200,
};
const chat = { status: 'resolved' };
const slaEvents = [
{ event_type: 'rt', created_at: currentTimestamp - 7000 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result).toMatchObject({
type: 'RT',
threshold: '',
isSlaMissed: true,
});
});
});
describe('refresh scheduling', () => {
it('refreshes only active unresolved SLAs', () => {
expect(
shouldRefreshSLAStatus({
appliedSla: { sla_status: 'active' },
chat: { status: 'open' },
})
).toBe(true);
expect(
shouldRefreshSLAStatus({
appliedSla: { sla_status: 'active' },
chat: { status: 'resolved' },
})
).toBe(false);
expect(
shouldRefreshSLAStatus({
appliedSla: { sla_status: 'missed' },
chat: { status: 'open' },
})
).toBe(false);
});
});
describe('time formatting', () => {
it('formats time in days and hours', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours