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
88 changes: 88 additions & 0 deletions src/composables/__tests__/useAiDiagnostics.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useDataApi } from '@/composables/axios';
import { useAiDiagnostics } from '@/composables/useAiDiagnostics';

vi.mock('@/composables/axios', () => ({
useDataApi: vi.fn(),
}));

function mockDataApi(partial: {
execute: ReturnType<typeof vi.fn>;
isLoading?: ReturnType<typeof ref<boolean>>;
error?: ReturnType<typeof ref<unknown>>;
}) {
return {
execute: partial.execute,
isLoading: partial.isLoading ?? ref(false),
error: partial.error ?? ref<unknown>(null),
} as unknown as ReturnType<typeof useDataApi>;
}

describe('useAiDiagnostics', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('does not start a pagination request while one is already loading', async () => {
const summaryRequest = vi.fn();
const runsRequest = vi.fn(async () => ({
data: {
value: {
data: [
{
id: 'run-1',
status: 'completed',
},
],
meta: {
nextCursor: 'cursor-2',
},
},
},
}));
const runsPageRequest = vi.fn();
const runDetailRequest = vi.fn();

vi.mocked(useDataApi)
.mockReturnValueOnce(
mockDataApi({
execute: summaryRequest,
isLoading: ref(false),
error: ref<unknown>(null),
}),
)
.mockReturnValueOnce(
mockDataApi({
execute: runsRequest,
isLoading: ref(false),
error: ref<unknown>(null),
}),
)
.mockReturnValueOnce(
mockDataApi({
execute: runsPageRequest,
isLoading: ref(true),
error: ref<unknown>(null),
}),
)
.mockReturnValueOnce(
mockDataApi({
execute: runDetailRequest,
isLoading: ref(false),
error: ref<unknown>(null),
}),
);

const diagnostics = useAiDiagnostics();
await diagnostics.refreshRuns();
diagnostics.paginationError.value = 'previous pagination error';

await diagnostics.loadMoreRuns();

expect(runsPageRequest).not.toHaveBeenCalled();
expect(diagnostics.paginationError.value).toBe('previous pagination error');
expect(diagnostics.runs.value).toHaveLength(1);
expect(diagnostics.nextCursor.value).toBe('cursor-2');
});
});
204 changes: 204 additions & 0 deletions src/composables/useAiDiagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { computed, onUnmounted, ref, shallowRef } from 'vue';
import { useDataApi } from '@/composables/axios';
import {
buildAiDiagnosticsRunDetailEndpoint,
buildAiDiagnosticsRunsEndpoint,
buildAiDiagnosticsSummaryEndpoint,
type AiDiagnosticsRun,
type AiDiagnosticsRunDetail,
type AiDiagnosticsRunsFilters,
type AiDiagnosticsRunsResponse,
type AiDiagnosticsSummary,
} from '@/views/admin/partials/ai-diagnostics';

const labelKeyStopPaths = ['data.scope.labelSets', 'data.events.metadata'];

function unavailableMessage(fallback: string, error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}

return fallback;
}

export function useAiDiagnostics() {
const summary = shallowRef<AiDiagnosticsSummary | null>(null);
const runs = shallowRef<AiDiagnosticsRun[]>([]);
const nextCursor = ref<string | null>(null);
const runsError = ref<string | null>(null);
const paginationError = ref<string | null>(null);
const summaryError = ref<string | null>(null);
const selectedRunDetail = shallowRef<AiDiagnosticsRunDetail | null>(null);
const runDetailError = ref<string | null>(null);
const pollTimer = ref<number>();

const {
execute: summaryRequest,
isLoading: summaryLoading,
error: summaryRequestError,
} = useDataApi<AiDiagnosticsSummary>(null, {}, { immediate: false });

const {
execute: runsRequest,
isLoading: runsLoading,
error: runsRequestError,
} = useDataApi<AiDiagnosticsRun[]>(null, {}, { immediate: false });

const { execute: runsPageRequest, isLoading: paginationLoading } = useDataApi<
AiDiagnosticsRun[]
>(null, {}, { immediate: false });

const { execute: runDetailRequest, isLoading: runDetailLoading } =
useDataApi<AiDiagnosticsRunDetail>(null, {}, { immediate: false });

async function refreshSummary() {
summaryError.value = null;

try {
const response = await summaryRequest(
buildAiDiagnosticsSummaryEndpoint(),
);
summary.value = response?.data.value?.data ?? null;
} catch (error) {
summary.value = null;
summaryError.value = unavailableMessage(
'AI diagnostics summary is unavailable.',
error,
);
}
}

async function refreshRuns(filters: AiDiagnosticsRunsFilters = {}) {
runsError.value = null;
paginationError.value = null;
nextCursor.value = null;

try {
const response = await runsRequest(
buildAiDiagnosticsRunsEndpoint(filters),
{ camelcaseStopPaths: labelKeyStopPaths },
);
const payload = response?.data.value as
| AiDiagnosticsRunsResponse
| undefined;

runs.value = payload?.data ?? [];
nextCursor.value = payload?.meta?.nextCursor ?? null;
} catch (error) {
runs.value = [];
runsError.value = unavailableMessage(
'AI diagnostics runs are unavailable.',
error,
);
}
}

async function loadMoreRuns(filters: AiDiagnosticsRunsFilters = {}) {
if (!nextCursor.value || paginationLoading.value) {
return;
}

paginationError.value = null;

try {
const response = await runsPageRequest(
buildAiDiagnosticsRunsEndpoint({
...filters,
cursor: nextCursor.value,
}),
{ camelcaseStopPaths: labelKeyStopPaths },
);
const payload = response?.data.value as
| AiDiagnosticsRunsResponse
| undefined;

runs.value = [...runs.value, ...(payload?.data ?? [])];
nextCursor.value = payload?.meta?.nextCursor ?? null;
} catch (error) {
paginationError.value = unavailableMessage(
'More AI diagnostics runs are unavailable.',
error,
);
}
}

async function fetchRunDetail(runId: string) {
runDetailError.value = null;
selectedRunDetail.value = null;

try {
const response = await runDetailRequest(
buildAiDiagnosticsRunDetailEndpoint(runId),
{ camelcaseStopPaths: labelKeyStopPaths },
);
selectedRunDetail.value = response?.data.value?.data ?? null;
} catch (error) {
runDetailError.value = unavailableMessage(
'AI diagnostics run details are unavailable.',
error,
);
}

return selectedRunDetail.value;
}

function stopPolling() {
if (!pollTimer.value) {
return;
}

window.clearInterval(pollTimer.value);
pollTimer.value = undefined;
}

function pollWhileActive(callback: () => Promise<void> | void) {
if (pollTimer.value) {
return;
}

pollTimer.value = window.setInterval(() => {
void callback();
}, 30000);
}

onUnmounted(stopPolling);

return {
summary,
runs,
nextCursor,
selectedRunDetail,
summaryLoading,
runsLoading,
paginationLoading,
runDetailLoading,
summaryError: computed(
() =>
summaryError.value ??
(summaryRequestError.value
? unavailableMessage(
'AI diagnostics summary is unavailable.',
summaryRequestError.value,
)
: null),
),
runsError: computed(
() =>
runsError.value ??
(runsRequestError.value
? unavailableMessage(
'AI diagnostics runs are unavailable.',
runsRequestError.value,
)
: null),
),
paginationError,
runDetailError,
refreshSummary,
refreshRuns,
loadMoreRuns,
fetchRunDetail,
pollWhileActive,
stopPolling,
};
}
29 changes: 28 additions & 1 deletion src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,38 @@ const authenticatedRoutes = [
{
path: '/admin/notifications',
name: 'admin-notifications',
component: () => import('../views/admin/NotificationsView.vue'),
redirect: { name: 'admin-diagnostics-notifications' },
meta: {
requiresAuth: true,
},
},
{
path: '/admin/diagnostics',
name: 'admin-diagnostics',
component: () => import('../views/admin/DiagnosticsView.vue'),
redirect: { name: 'admin-diagnostics-notifications' },
meta: {
requiresAuth: true,
},
children: [
{
path: 'notifications',
name: 'admin-diagnostics-notifications',
component: () => import('../views/admin/NotificationsView.vue'),
meta: {
requiresAuth: true,
},
},
{
path: 'ai-suggestions',
name: 'admin-diagnostics-ai-suggestions',
component: () => import('../views/admin/AiDiagnosticsView.vue'),
meta: {
requiresAuth: true,
},
},
],
},
{
path: '/admin/risks',
name: 'admin-risks',
Expand Down
6 changes: 3 additions & 3 deletions src/views/LeftSideNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,9 @@ const links = ref<Array<NavigationItem>>([
title: 'Risk Templates',
},
{
name: 'admin-notifications',
title: 'Notifications',
abbr: 'NTF',
name: 'admin-diagnostics',
title: 'Diagnostics',
abbr: 'DIAG',
},
{
name: 'admin-import',
Expand Down
Loading
Loading