From db46ae22ae584a90933b609a87f355bcb68421fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Skr=C3=B8vseth?= Date: Fri, 4 Oct 2024 17:03:47 +0200 Subject: [PATCH] Add send debug info user menu button --- frontend/src/components/app/app.tsx | 8 +- frontend/src/components/app/router.tsx | 160 ++++++++++-------- .../src/components/header/user-menu/debug.tsx | 158 +++++++++++++++++ .../components/header/user-menu/dropdown.tsx | 2 + server/src/plugins/debug/debug.ts | 47 +++++ server/src/plugins/debug/formatting.test.ts | 70 ++++++++ server/src/plugins/debug/formatting.ts | 64 +++++++ server/src/plugins/debug/types.ts | 61 +++++++ server/src/server.ts | 2 + server/src/slack.ts | 29 ++-- 10 files changed, 511 insertions(+), 90 deletions(-) create mode 100644 frontend/src/components/header/user-menu/debug.tsx create mode 100644 server/src/plugins/debug/debug.ts create mode 100644 server/src/plugins/debug/formatting.test.ts create mode 100644 server/src/plugins/debug/formatting.ts create mode 100644 server/src/plugins/debug/types.ts diff --git a/frontend/src/components/app/app.tsx b/frontend/src/components/app/app.tsx index fea542dcd..96548ef96 100644 --- a/frontend/src/components/app/app.tsx +++ b/frontend/src/components/app/app.tsx @@ -1,8 +1,5 @@ import { AppErrorBoundary } from '@app/components/app/error-boundary'; import { StaticDataLoader } from '@app/components/app/static-data-context'; -import { NavHeader } from '@app/components/header/header'; -import { Toasts } from '@app/components/toast/toasts'; -import { VersionCheckerStatus } from '@app/components/version-checker/version-checker-status'; import { reduxStore } from '@app/redux/configure-store'; import { StrictMode } from 'react'; import { Provider } from 'react-redux'; @@ -13,14 +10,11 @@ import { Router } from './router'; export const App = () => ( + - - - - diff --git a/frontend/src/components/app/router.tsx b/frontend/src/components/app/router.tsx index f31cef6f2..31ba69396 100644 --- a/frontend/src/components/app/router.tsx +++ b/frontend/src/components/app/router.tsx @@ -1,6 +1,9 @@ import { NotFoundPage } from '@app/components/app/not-found-page'; import { ProtectedRoute } from '@app/components/app/protected-route'; +import { NavHeader } from '@app/components/header/header'; import { ModalEnum } from '@app/components/svarbrev/row/row'; +import { Toasts } from '@app/components/toast/toasts'; +import { VersionCheckerStatus } from '@app/components/version-checker/version-checker-status'; import { AccessRightsPage } from '@app/pages/access-rights/access-rights'; import { AdminPage } from '@app/pages/admin/admin'; import { AnkebehandlingPage } from '@app/pages/ankebehandling/ankebehandling'; @@ -22,92 +25,103 @@ import { SvarbrevPage } from '@app/pages/svarbrev/svarbrev'; import { ToppteksterPage } from '@app/pages/topptekster/topptekster'; import { TrygderettsankebehandlingPage } from '@app/pages/trygderettsankebehandling/trygderettsankebehandling'; import { Role } from '@app/types/bruker'; -import { Route, Routes as Switch } from 'react-router-dom'; +import { Outlet, Route, Routes as Switch } from 'react-router-dom'; export const Router = () => ( - } /> - - }> - } /> - } /> - } /> - } /> - } /> - } /> - + }> + } /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + - }> - } /> - + }> + } /> + - }> - } /> - + }> + } /> + - }> - } - /> - } - /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - + }> + } + /> + } + /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + - }> - } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> - } /> - } /> - } /> - + } /> + } /> + } /> + - }> - - } /> - } /> - } /> + }> + + } /> + } /> + } /> + - - }> - } /> - + }> + } /> + - }> - } /> - + }> + } /> + - } /> + } /> - } /> + } /> + ); + +const AppWrapper = () => ( + <> + + + + + +); diff --git a/frontend/src/components/header/user-menu/debug.tsx b/frontend/src/components/header/user-menu/debug.tsx new file mode 100644 index 000000000..0e70433f2 --- /dev/null +++ b/frontend/src/components/header/user-menu/debug.tsx @@ -0,0 +1,158 @@ +import { toast } from '@app/components/toast/store'; +import { ENVIRONMENT } from '@app/environment'; +import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; +import { useSmartEditorActiveDocument } from '@app/hooks/settings/use-setting'; +import { useGetDocumentsQuery } from '@app/redux-api/oppgaver/queries/documents'; +import { useUtfall, useYtelserAll } from '@app/simple-api-state/use-kodeverk'; +import { user } from '@app/static-data/static-data'; +import type { INavEmployee } from '@app/types/bruker'; +import { SaksTypeEnum } from '@app/types/kodeverk'; +import { BugIcon } from '@navikt/aksel-icons'; +import { Button, Dropdown, Tooltip } from '@navikt/ds-react'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useCallback } from 'react'; +import { useParams } from 'react-router-dom'; + +export const DebugButton = () => { + const { oppgaveId } = useParams(); + + if (oppgaveId !== undefined) { + return ; + } + + return ; +}; + +export const SimpleDebug = () => { + const reporter = useReporter(); + + const onClick: React.MouseEventHandler = useCallback(async () => { + const body = JSON.stringify( + { + reporter: await reporter, + url: window.location.href, + version: ENVIRONMENT.version, + }, + null, + 2, + ); + + sendDebugInfo(body); + }, [reporter]); + + return ( + + Send teknisk informasjon + + ); +}; + +export const BehandlingDebug = () => { + const reporter = useReporter(); + const { data: oppgave } = useOppgave(); + const { data: documents } = useGetDocumentsQuery(oppgave?.id ?? skipToken); + const { value: selectedTab = null } = useSmartEditorActiveDocument(); + const { data: utfallList = [] } = useUtfall(); + + const onClick: React.MouseEventHandler = useCallback(async () => { + if (oppgave === undefined) { + console.error('No behandling loaded'); + return; + } + + const medunderskriver = oppgave.medunderskriver.employee; + const rol = oppgave.typeId !== SaksTypeEnum.ANKE_I_TRYGDERETTEN ? oppgave.rol : null; + + const body = JSON.stringify( + { + reporter: await reporter, + url: window.location.href, + version: ENVIRONMENT.version, + data: { + type: 'behandling', + behandlingId: oppgave.id, + utfall: utfallList.find((u) => u.id === oppgave.resultat.utfallId)?.navn ?? oppgave.resultat.utfallId, + ekstraUtfall: oppgave.resultat.extraUtfallIdSet.map((id) => utfallList.find((u) => u.id === id)?.navn ?? id), + medunderskriver: employeeToUser(medunderskriver), + muFlowState: oppgave.medunderskriver.flowState, + rol: employeeToUser(rol?.employee), + rolFlowState: rol?.flowState ?? null, + selectedTab, + documents: documents + ?.filter((d) => !d.isSmartDokument) + .map(({ id, tittel, type, templateId }) => ({ id, title: tittel, type, templateId })), + smartDocuments: documents + ?.filter((d) => d.isSmartDokument) + .map(({ id, tittel, type, templateId }) => ({ id, title: tittel, type, templateId })), + }, + }, + null, + 2, + ); + + sendDebugInfo(body); + }, [oppgave, documents, selectedTab, reporter, utfallList]); + + return ( + + Send teknisk informasjon + + ); +}; + +const sendDebugInfo = async (body: string) => { + try { + const res = await fetch('/debug', { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }); + + if (!res.ok) { + throw new Error(await res.text()); + } + + toast.success('Teknisk informasjon er sendt til Team Klage'); + } catch (error) { + console.error('Failed to send debug info to Team Klage', error instanceof Error ? error.message : error); + toast.error( + 'Klarte ikke sende teknisk informasjon til Team Klage. Teknisk informasjon er kopiert til utklippstavlen din.', + ); + navigator.clipboard.writeText(body); + } +}; + +const useReporter = async () => { + const { data: ytelser } = useYtelserAll(); + + if (ytelser === undefined) { + return user; + } + + const { tildelteYtelser, ...rest } = await user; + + return { + ...rest, + tildelteYtelser: tildelteYtelser.map((id) => { + const ytelse = ytelser.find((y) => y.id === id); + + if (ytelse === undefined) { + return `Unknown ytelse (\`${id}\`)`; + } + + return `${ytelse.navn} (${ytelse.id})`; + }), + }; +}; + +interface SendButtonProps { + onClick: () => void; + children?: React.ReactNode; +} + +const SendButton = ({ onClick, children }: SendButtonProps) => ( + + + +); + +const employeeToUser = (employee: INavEmployee | null = null) => + employee === null ? null : { name: employee.navn, navIdent: employee.navIdent }; diff --git a/frontend/src/components/header/user-menu/dropdown.tsx b/frontend/src/components/header/user-menu/dropdown.tsx index c8f2e5f0a..435df8288 100644 --- a/frontend/src/components/header/user-menu/dropdown.tsx +++ b/frontend/src/components/header/user-menu/dropdown.tsx @@ -1,3 +1,4 @@ +import { DebugButton } from '@app/components/header/user-menu/debug'; import { ENVIRONMENT } from '@app/environment'; import { pushEvent } from '@app/observability'; import { CogIcon, CogRotationIcon, LeaveIcon } from '@navikt/aksel-icons'; @@ -33,6 +34,7 @@ export const UserDropdown = (): JSX.Element | null => { > {null} + ); diff --git a/server/src/plugins/debug/debug.ts b/server/src/plugins/debug/debug.ts new file mode 100644 index 000000000..a66fbff06 --- /dev/null +++ b/server/src/plugins/debug/debug.ts @@ -0,0 +1,47 @@ +import { isDeployed } from '@app/config/env'; +import { formatMessage } from '@app/plugins/debug/formatting'; +import { BODY_TYPE } from '@app/plugins/debug/types'; +import { NAV_IDENT_PLUGIN_ID } from '@app/plugins/nav-ident'; +import { EmojiIcons, sendToSlack } from '@app/slack'; +import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import fastifyPlugin from 'fastify-plugin'; + +export const DEBUG_PLUGIN_ID = 'tab-id'; + +export const debugPlugin = fastifyPlugin( + async (app) => { + app.withTypeProvider().post( + '/debug', + { + bodyLimit: 10 * 1024 * 1024, + schema: { + tags: ['debug'], + body: BODY_TYPE, + produces: ['application/json'], + }, + }, + async (req, reply) => { + if (isDeployed && req.body.reporter.navIdent !== req.navIdent) { + return reply.status(403).send('Provided navIdent does not match the authenticated user.'); + } + + const { url, version, reporter, data } = req.body; + + const message = formatMessage(url, version, reporter, data); + + try { + const sent = await sendToSlack(message, EmojiIcons.Kabal); + + if (!sent) { + return reply.status(500).send('Failed to send debug info to Slack.'); + } + + return reply.status(200).send(); + } catch { + return reply.status(500).send(); + } + }, + ); + }, + { fastify: '5', name: DEBUG_PLUGIN_ID, dependencies: [NAV_IDENT_PLUGIN_ID] }, +); diff --git a/server/src/plugins/debug/formatting.test.ts b/server/src/plugins/debug/formatting.test.ts new file mode 100644 index 000000000..80df882e0 --- /dev/null +++ b/server/src/plugins/debug/formatting.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'bun:test'; +import { formatMessage, formatUser } from '@app/plugins/debug/formatting'; +import { DataType } from '@app/plugins/debug/types'; + +describe('debug formatting', () => { + it('should format user', () => { + expect.assertions(1); + const actual = formatUser({ navIdent: 'Z997766', name: 'Ola Nordmann' }); + const expected = 'Ola Nordmann `Z997766`'; + expect(actual).toBe(expected); + }); + + it('should format message', () => { + expect.assertions(1); + const actual = formatMessage( + 'https://kabal.intern.nav.no/ankebehandling/9f454262-f83a-44c2-ab2d-5b175e3654c6', + 'test-version', + { + navIdent: 'Z997766', + navn: 'Ola Nordmann', + roller: ['ROLE1', 'ROLE2'], + ansattEnhet: { id: 'enhet1', navn: 'Enhet 1', lovligeYtelser: ['ytelse1', 'ytelse2'] }, + enheter: [ + { id: 'enhet1', navn: 'Enhet 1', lovligeYtelser: ['ytelse1', 'ytelse2'] }, + { id: 'enhet2', navn: 'Enhet 2', lovligeYtelser: ['ytelse1', 'ytelse2'] }, + ], + tildelteYtelser: ['ytelse1', 'ytelse2'], + }, + { + type: DataType.BEHANDLING, + behandlingId: '9f454262-f83a-44c2-ab2d-5b175e3654c6', + medunderskriver: { navIdent: 'Z123456', name: 'Kari Nordmann' }, + rol: { navIdent: 'Z654321', name: 'Per Nordmann' }, + selectedTab: '9f454262-f83a-44c2-ab2d-5b175e3654c6', + smartDocuments: [ + { id: '9f454262-f83a-44c2-ab2d-5b175e3654c6', title: 'Ankevedtak', type: 'SMART', templateId: 'ankevedtak' }, + ], + documents: [ + { id: '9f454262-f83a-44c2-ab2d-5b175e3654c6', title: 'Ankevedtak', type: 'SMART', templateId: 'ankevedtak' }, + ], + utfall: 'MEDHOLD', + ekstraUtfall: ['utfall1', 'utfall2'], + muFlowState: 'NOT_SENT', + rolFlowState: 'SENT', + }, + ); + const expected = `\n*Debug-data fra Ola Nordmann* \`Z997766\` for \`https://kabal.intern.nav.no/ankebehandling/9f454262-f83a-44c2-ab2d-5b175e3654c6\` +Klientversjon: \`test-version\` + +*Bruker* +*Roller*: \`ROLE1\`, \`ROLE2\` +*Ansatt i enhet*: Enhet 1 (enhet1) +*Enheter*: Enhet 1 (enhet1), Enhet 2 (enhet2) +*Tildelte ytelser*: \`ytelse1\`, \`ytelse2\` + +*Behandling* +*ID*: \`9f454262-f83a-44c2-ab2d-5b175e3654c6\` +*Aktivt smartdokument*: \`9f454262-f83a-44c2-ab2d-5b175e3654c6\` +*Smartdokumenter (1)*: +- \`9f454262-f83a-44c2-ab2d-5b175e3654c6\` - type: \`SMART\` - templateId: \`ankevedtak\` - tittel: \`Ankevedtak\` +*Ikke-smartdokumenter (1)*: +- \`9f454262-f83a-44c2-ab2d-5b175e3654c6\` - type: \`SMART\` - templateId: \`ankevedtak\` - tittel: \`Ankevedtak\` + +*Utfall*: \`MEDHOLD\` +*Medunderskriver*: Kari Nordmann \`Z123456\` \`NOT_SENT\` +*ROL*: Per Nordmann \`Z654321\` \`SENT\``; + + expect(actual).toBe(expected); + }); +}); diff --git a/server/src/plugins/debug/formatting.ts b/server/src/plugins/debug/formatting.ts new file mode 100644 index 000000000..4a83b9d3e --- /dev/null +++ b/server/src/plugins/debug/formatting.ts @@ -0,0 +1,64 @@ +import { type BehandlingData, DataType, type Document, type Reporter, type User } from '@app/plugins/debug/types'; + +export const formatMessage = ( + url: string, + version: string, + { navn, navIdent, roller, ansattEnhet, enheter, tildelteYtelser }: Reporter, + data: BehandlingData | undefined | null, +) => { + const header = `*Debug-data fra ${navn}* \`${navIdent}\` for \`${url}\`\nKlientversjon: \`${version}\``; + const reporter = [ + '*Bruker*', + `*Roller*: ${roller.map((r) => `\`${r}\``).join(', ')}`, + `*Ansatt i enhet*: ${ansattEnhet.navn} (${ansattEnhet.id})`, + `*Enheter*: ${enheter.map((enhet) => `${enhet.navn} (${enhet.id})`).join(', ')}`, + `*Tildelte ytelser*: ${tildelteYtelser.map((y) => `\`${y}\``).join(', ')}`, + ].join('\n'); + + if (data === undefined || data === null) { + return `\n${header}\n\n${reporter}`; + } + + return `\n${header}\n\n${reporter}\n\n${formatData(data)}`; +}; + +const formatData = (data: BehandlingData) => { + switch (data.type) { + case DataType.BEHANDLING: + return formatBehandling(data); + } +}; + +const formatBehandling = (data: BehandlingData) => { + const { + behandlingId, + selectedTab, + smartDocuments, + documents, + medunderskriver, + rol, + muFlowState, + rolFlowState, + utfall, + } = data; + + return `*Behandling* +*ID*: \`${behandlingId}\` +*Aktivt smartdokument*: \`${selectedTab === undefined ? 'Nytt dokument' : selectedTab}\` +*Smartdokumenter (${smartDocuments.length})*:${smartDocuments.map((doc) => `\n- ${formatDocument(doc)}`).join('')} +*Ikke-smartdokumenter (${documents.length})*:${documents.map((doc) => `\n- ${formatDocument(doc)}`).join('')} + +*Utfall*: \`${utfall ?? 'Ingen'}\` +*Medunderskriver*: ${medunderskriver === null ? 'Ingen' : formatUser(medunderskriver)} \`${muFlowState}\` +*ROL*: ${rol === null ? 'Ingen' : formatUser(rol)} \`${rolFlowState}\``; +}; + +const formatDocument = ({ title, id, type, templateId }: Document) => { + if (templateId === null) { + return `\`${id}\` - type: \`${type}\` - tittel: \`${title}\``; + } + + return `\`${id}\` - type: \`${type}\` - templateId: \`${templateId}\` - tittel: \`${title}\``; +}; + +export const formatUser = ({ navIdent, name }: User) => `${name} \`${navIdent}\``; diff --git a/server/src/plugins/debug/types.ts b/server/src/plugins/debug/types.ts new file mode 100644 index 000000000..0a2c89f79 --- /dev/null +++ b/server/src/plugins/debug/types.ts @@ -0,0 +1,61 @@ +import { type Static, Type } from '@fastify/type-provider-typebox'; + +export enum DataType { + BEHANDLING = 'behandling', +} + +export const USER = Type.Object({ + name: Type.String(), + navIdent: Type.String(), +}); + +export type User = Static; + +export const DOCUMENT = Type.Object({ + id: Type.String(), + title: Type.String(), + type: Type.String(), + templateId: Type.Union([Type.String(), Type.Null()]), +}); + +export type Document = Static; + +export const BEHANDLING_DATA = Type.Object({ + type: Type.Literal(DataType.BEHANDLING), + behandlingId: Type.String(), + medunderskriver: Type.Union([USER, Type.Null()]), + rol: Type.Union([USER, Type.Null()]), + selectedTab: Type.Union([Type.String(), Type.Null()]), + smartDocuments: Type.Array(DOCUMENT), + documents: Type.Array(DOCUMENT), + utfall: Type.Union([Type.String(), Type.Null()]), + ekstraUtfall: Type.Array(Type.String()), + muFlowState: Type.String(), + rolFlowState: Type.String(), +}); + +export type BehandlingData = Static; + +const ENHET = Type.Object({ + id: Type.String(), + navn: Type.String(), + lovligeYtelser: Type.Array(Type.String()), +}); + +export const REPORTER = Type.Object({ + navIdent: Type.String(), + navn: Type.String(), + roller: Type.Array(Type.String()), + enheter: Type.Array(ENHET), + ansattEnhet: ENHET, + tildelteYtelser: Type.Array(Type.String()), +}); + +export type Reporter = Static; + +export const BODY_TYPE = Type.Object({ + url: Type.String(), + reporter: REPORTER, + version: Type.String(), + data: Type.Optional(Type.Union([BEHANDLING_DATA, Type.Null()])), +}); diff --git a/server/src/server.ts b/server/src/server.ts index 33f61fa6e..d4ccb3d14 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -9,6 +9,7 @@ import { accessTokenPlugin } from '@app/plugins/access-token'; import { apiProxyPlugin } from '@app/plugins/api-proxy'; import { clientVersionPlugin } from '@app/plugins/client-version'; import { crdtPlugin } from '@app/plugins/crdt/crdt'; +import { debugPlugin } from '@app/plugins/debug/debug'; import { documentPlugin } from '@app/plugins/document'; import { healthPlugin } from '@app/plugins/health'; import { httpLoggerPlugin } from '@app/plugins/http-logger'; @@ -63,6 +64,7 @@ fastify({ trustProxy: true, querystringParser, bodyLimit }) .register(serveIndexPlugin) .register(httpLoggerPlugin) .register(crdtPlugin) + .register(debugPlugin) // Start server. .listen({ host: '0.0.0.0', port: serverConfig.port }); diff --git a/server/src/slack.ts b/server/src/slack.ts index bc9f1e176..85ed32ee0 100644 --- a/server/src/slack.ts +++ b/server/src/slack.ts @@ -1,4 +1,4 @@ -import { ENVIRONMENT, isDeployed, isLocal } from '@app/config/env'; +import { ENVIRONMENT, isLocal } from '@app/config/env'; import { optionalEnvString, requiredEnvString } from '@app/config/env-var'; import { getLogger } from '@app/logger'; @@ -9,6 +9,7 @@ export enum EmojiIcons { LoadingDots = ':loading-dots:', Broken = ':broken:', Collision = ':collision:', + Kabal = ':kabal:', } const url = optionalEnvString('SLACK_URL'); @@ -16,23 +17,29 @@ const channel = '#klage-notifications'; const messagePrefix = `${requiredEnvString('NAIS_APP_NAME', 'kabal-frontend')} frontend NodeJS -`; const isConfigured = url !== undefined && url.length !== 0; -export const sendToSlack = async (message: string, icon_emoji: EmojiIcons) => { +export const sendToSlack = async (message: string, icon_emoji: EmojiIcons): Promise => { const text = `[${ENVIRONMENT}] ${messagePrefix} ${message}`; - if (!(isDeployed && isConfigured)) { - return; - } - - const body = JSON.stringify({ channel, text, icon_emoji }); - if (isLocal) { log.info({ msg: `Sending message to Slack: ${text}` }); - return; + return true; + } + + if (!isConfigured) { + return true; } + const body = JSON.stringify({ channel, text, icon_emoji }); + try { - await fetch(url, { method: 'POST', body }); + const res = await fetch(url, { method: 'POST', body }); + + if (!res.ok) { + throw new Error(`Slack responded with status code ${res.status}`); + } + + return true; } catch (error) { const msg = `Failed to send message to Slack. Message: '${text}'`; @@ -42,6 +49,8 @@ export const sendToSlack = async (message: string, icon_emoji: EmojiIcons) => { } log.error({ msg: scrubWebhookUrl(msg) }); + + return false; } };