rl-core-front 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/_services/api/schema.d.ts +238 -117
- package/src/_utils/filter.ts +15 -34
- package/src/components/app-shell.tsx +51 -1
- package/src/components/brand-header.tsx +23 -8
- package/src/components/brand-panel.tsx +41 -0
- package/src/components/index.ts +1 -0
- package/src/components/ui/button.tsx +5 -0
- package/src/components/ui/confirm-dialog.tsx +3 -1
- package/src/components/ui/data-table.tsx +13 -4
- package/src/components/ui/dialog.tsx +120 -21
- package/src/components/ui/filter-sheet.tsx +47 -25
- package/src/components/ui/index.ts +1 -1
- package/src/components/ui/segmented-control.tsx +74 -0
- package/src/contexts/brand-context.tsx +15 -0
- package/src/contexts/i18n-context.tsx +69 -4
- package/src/features/audit/audit-screen.tsx +13 -140
- package/src/features/audit/components/audit-diff.tsx +156 -0
- package/src/features/audit/components/index.ts +3 -0
- package/src/features/audit/components/record-audit-button.tsx +176 -0
- package/src/features/audit/enums/audit-data-action.enum.ts +20 -0
- package/src/features/audit/hooks/use-audit-trail.ts +9 -2
- package/src/features/audit/services/audit.service.ts +43 -1
- package/src/features/login/login-screen.tsx +86 -40
- package/src/features/logs/enums/error-type.enum.ts +21 -0
- package/src/features/logs/enums/request-outcome.enum.ts +14 -0
- package/src/features/logs/hooks/use-request-logs.ts +26 -3
- package/src/features/logs/logs-screen.tsx +2 -14
- package/src/features/logs/panels/index.ts +0 -1
- package/src/features/logs/panels/request-logs-panel.tsx +177 -25
- package/src/features/logs/services/logs.service.ts +13 -51
- package/src/features/notifications/components/notification-item.tsx +10 -8
- package/src/features/notifications/enums/notification-type.enum.ts +8 -0
- package/src/features/profile/profile-screen.tsx +2 -1
- package/src/features/rbac/panels/groups-panel.tsx +20 -7
- package/src/features/users/components/user-form-dialog.tsx +16 -7
- package/src/features/users/hooks/use-users.ts +9 -2
- package/src/hooks/use-filters.ts +25 -2
- package/src/hooks/use-list-query.ts +14 -1
- package/src/i18n/messages/en.ts +17 -5
- package/src/i18n/messages/pt.ts +18 -5
- package/src/index.ts +12 -1
- package/src/components/ui/filter-chips.tsx +0 -88
- package/src/features/logs/hooks/use-error-logs.ts +0 -65
- package/src/features/logs/panels/error-logs-panel.tsx +0 -183
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// O que aconteceu com o registro (espelha o AuditDataAction do backend:
|
|
2
|
+
// audit_data_changes.action).
|
|
3
|
+
export enum AuditDataAction {
|
|
4
|
+
CREATE = 0,
|
|
5
|
+
UPDATE = 1,
|
|
6
|
+
DELETE = 2,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Chave de i18n de cada valor.
|
|
11
|
+
*
|
|
12
|
+
* Aponta a chave e não o texto porque o core é bilíngue (§6): o rótulo mora em
|
|
13
|
+
* `i18n/messages`, e escrevê-lo aqui criaria uma segunda fonte do mesmo texto —
|
|
14
|
+
* que sairia em português também no inglês.
|
|
15
|
+
*/
|
|
16
|
+
export const AuditDataActionLabelKey: Record<AuditDataAction, string> = {
|
|
17
|
+
[AuditDataAction.CREATE]: "auditTrail.actionCreate",
|
|
18
|
+
[AuditDataAction.UPDATE]: "auditTrail.actionUpdate",
|
|
19
|
+
[AuditDataAction.DELETE]: "auditTrail.actionDelete",
|
|
20
|
+
};
|
|
@@ -48,9 +48,14 @@ export function useAuditTrail(): UseAuditTrailResult {
|
|
|
48
48
|
}
|
|
49
49
|
}, [run, list.query]);
|
|
50
50
|
|
|
51
|
+
// Não busca antes da URL ser lida: a query sairia sem o filtro, e essa
|
|
52
|
+
// resposta pode chegar depois da certa e pintar a tela com tudo.
|
|
51
53
|
useEffect(() => {
|
|
54
|
+
if (!list.ready) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
52
57
|
void fetch();
|
|
53
|
-
}, [fetch]);
|
|
58
|
+
}, [fetch, list.ready]);
|
|
54
59
|
|
|
55
60
|
return {
|
|
56
61
|
rows,
|
|
@@ -58,7 +63,9 @@ export function useAuditTrail(): UseAuditTrailResult {
|
|
|
58
63
|
page: list.page,
|
|
59
64
|
pageSize: list.pageSize,
|
|
60
65
|
sorting: list.sorting,
|
|
61
|
-
|
|
66
|
+
// Esperar o catálogo também é carregar: sem isso a tabela pisca
|
|
67
|
+
// "Nenhum registro" antes da primeira busca sair.
|
|
68
|
+
loading: loading || !list.ready,
|
|
62
69
|
setPage: list.setPage,
|
|
63
70
|
setPageSize: list.setPageSize,
|
|
64
71
|
setSorting: list.setSorting,
|
|
@@ -4,16 +4,44 @@ import { toListParams } from "#core/_services/api/list-query";
|
|
|
4
4
|
import type { components } from "#core/_services/api/schema";
|
|
5
5
|
|
|
6
6
|
/** Tipos derivados do `openapi.json` (§5) — não redigitar a forma da resposta. */
|
|
7
|
-
export type AuditAction = components["schemas"]["AuditDataChangeResponse"]["action"];
|
|
8
7
|
export type AuditUserRef = components["schemas"]["AuditUserRefResponse"];
|
|
9
8
|
export type AuditDataChange = components["schemas"]["AuditDataChangeResponse"];
|
|
9
|
+
export type RequestLog = components["schemas"]["RequestLogResponse"];
|
|
10
10
|
|
|
11
11
|
export interface Paginated<T> {
|
|
12
12
|
items: T[];
|
|
13
13
|
meta: components["schemas"]["PaginationMetaResponse"];
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Teto do histórico de um registro.
|
|
18
|
+
*
|
|
19
|
+
* O modal não pagina: quem precisa de mais que isso está fazendo investigação,
|
|
20
|
+
* e aí a tela de auditoria com filtro é o lugar certo.
|
|
21
|
+
*/
|
|
22
|
+
const RECORD_HISTORY_LIMIT = 100;
|
|
23
|
+
|
|
16
24
|
export const auditService = {
|
|
25
|
+
/**
|
|
26
|
+
* Histórico de um registro específico (`entity` + `entityId`).
|
|
27
|
+
*
|
|
28
|
+
* Usa o mesmo endpoint e o mesmo catálogo da listagem geral — a consulta cai
|
|
29
|
+
* no índice `(entity, entity_id, created_at)`, que existe exatamente por isso.
|
|
30
|
+
*/
|
|
31
|
+
listByRecord(entity: string, entityId: string) {
|
|
32
|
+
return api
|
|
33
|
+
.get<Paginated<AuditDataChange>>("/audit/data-changes", {
|
|
34
|
+
params: {
|
|
35
|
+
page: 1,
|
|
36
|
+
limit: RECORD_HISTORY_LIMIT,
|
|
37
|
+
sortBy: "createdAt",
|
|
38
|
+
sortDir: "DESC",
|
|
39
|
+
filter: JSON.stringify({ entity, entityId }),
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
.then((r) => r.data);
|
|
43
|
+
},
|
|
44
|
+
|
|
17
45
|
listDataChanges(query: ListQuery) {
|
|
18
46
|
return api
|
|
19
47
|
.get<Paginated<AuditDataChange>>("/audit/data-changes", {
|
|
@@ -21,4 +49,18 @@ export const auditService = {
|
|
|
21
49
|
})
|
|
22
50
|
.then((r) => r.data);
|
|
23
51
|
},
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Log de requisição: erro, requisição lenta e ação marcada com `@Auditable`.
|
|
55
|
+
*
|
|
56
|
+
* Uma rota só para o que antes eram `/audit/changes` e `/audit/errors` — a
|
|
57
|
+
* distinção agora é o filtro `outcome`.
|
|
58
|
+
*/
|
|
59
|
+
listRequests(query: ListQuery) {
|
|
60
|
+
return api
|
|
61
|
+
.get<Paginated<RequestLog>>("/audit/requests", {
|
|
62
|
+
params: toListParams(query),
|
|
63
|
+
})
|
|
64
|
+
.then((r) => r.data);
|
|
65
|
+
},
|
|
24
66
|
};
|
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
import type { JSX } from "react";
|
|
4
4
|
import { useEffect } from "react";
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import {
|
|
7
|
+
BrandHeader,
|
|
8
|
+
BrandPanel,
|
|
9
|
+
LanguageSelector,
|
|
10
|
+
ThemeToggle,
|
|
11
|
+
} from "#core/components";
|
|
12
|
+
import { Card, Separator } from "#core/components/ui";
|
|
13
|
+
import { useBrand, useI18n } from "#core/contexts";
|
|
9
14
|
import {
|
|
10
15
|
BackupCodesDialog,
|
|
11
16
|
LoginForm,
|
|
@@ -13,6 +18,7 @@ import {
|
|
|
13
18
|
TwoFactorVerify,
|
|
14
19
|
} from "#core/features/login/components";
|
|
15
20
|
import { useLoginFlow } from "#core/features/login/hooks/use-login-flow";
|
|
21
|
+
import { cn } from "#core/lib/utils";
|
|
16
22
|
|
|
17
23
|
export function LoginScreen(): JSX.Element {
|
|
18
24
|
const flow = useLoginFlow();
|
|
@@ -30,50 +36,90 @@ export function LoginScreen(): JSX.Element {
|
|
|
30
36
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
37
|
}, [flow.step]);
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<div className="absolute right-3 top-3 flex gap-1">
|
|
36
|
-
<LanguageSelector />
|
|
37
|
-
<ThemeToggle />
|
|
38
|
-
</div>
|
|
39
|
+
const { loginLayout } = useBrand();
|
|
40
|
+
const aoLado = loginLayout !== "top";
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
const conteudo = (
|
|
43
|
+
<>
|
|
44
|
+
{/* A marca vem antes do título do passo: é a primeira tela do sistema, e
|
|
45
|
+
quem chega aqui precisa reconhecer onde está antes de logar. Com a
|
|
46
|
+
marca ao lado, só o título — repeti-la aqui seria dizer duas vezes. */}
|
|
47
|
+
<BrandHeader title={titles[flow.step]} showBrand={!aoLado} />
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
{flow.error && (
|
|
50
|
+
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
51
|
+
{flow.error}
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
{flow.step === "credentials" && (
|
|
56
|
+
<LoginForm loading={flow.loading} onSubmit={flow.submitCredentials} />
|
|
57
|
+
)}
|
|
54
58
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
{flow.step === "setup" && flow.setup && (
|
|
60
|
+
<TwoFactorSetup
|
|
61
|
+
setup={flow.setup}
|
|
62
|
+
loading={flow.loading}
|
|
63
|
+
onConfirm={(code) => flow.submitCode(code, "totp")}
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{flow.step === "verify" && (
|
|
68
|
+
<TwoFactorVerify
|
|
69
|
+
loading={flow.loading}
|
|
70
|
+
onVerify={flow.submitCode}
|
|
71
|
+
onRequestEmail={flow.requestEmailCode}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
<BackupCodesDialog
|
|
76
|
+
open={flow.step === "backup"}
|
|
77
|
+
codes={flow.backupCodes}
|
|
78
|
+
onClose={flow.finishBackup}
|
|
79
|
+
/>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
83
|
+
const seletores = (
|
|
84
|
+
<div className="absolute right-3 top-3 z-10 flex gap-1">
|
|
85
|
+
<LanguageSelector />
|
|
86
|
+
<ThemeToggle />
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (aoLado) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="relative min-h-screen bg-background">
|
|
93
|
+
{seletores}
|
|
94
|
+
{/* Empilhado em tela estreita, lado a lado a partir do `lg`. No DOM a
|
|
95
|
+
marca vem primeiro — no celular é ela que deve abrir a tela, nos
|
|
96
|
+
dois casos. Quem troca os lados no desktop é o `order`, e o divisor
|
|
97
|
+
precisa do dele para não sobrar na ponta. */}
|
|
98
|
+
<div className="flex min-h-screen flex-col lg:flex-row">
|
|
99
|
+
<BrandPanel
|
|
100
|
+
className={loginLayout === "right" ? "lg:order-3" : "lg:order-1"}
|
|
101
|
+
/>
|
|
102
|
+
<Separator
|
|
103
|
+
orientation="horizontal"
|
|
104
|
+
className="lg:order-2 lg:h-auto lg:w-px lg:self-stretch"
|
|
68
105
|
/>
|
|
69
|
-
|
|
106
|
+
<div
|
|
107
|
+
className={cn(
|
|
108
|
+
"flex flex-1 items-center justify-center p-4",
|
|
109
|
+
loginLayout === "right" ? "lg:order-1" : "lg:order-3",
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
<Card className="w-full max-w-md p-8">{conteudo}</Card>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
70
118
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
/>
|
|
76
|
-
</Card>
|
|
119
|
+
return (
|
|
120
|
+
<div className="relative flex min-h-screen items-center justify-center bg-background p-4">
|
|
121
|
+
{seletores}
|
|
122
|
+
<Card className="w-full max-w-md p-8">{conteudo}</Card>
|
|
77
123
|
</div>
|
|
78
124
|
);
|
|
79
125
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Categoria do erro (espelha o ErrorType do backend: request_logs.error_type).
|
|
2
|
+
export enum ErrorType {
|
|
3
|
+
VALIDATION = 0,
|
|
4
|
+
AUTH = 1,
|
|
5
|
+
NOT_FOUND = 2,
|
|
6
|
+
RATE_LIMIT = 3,
|
|
7
|
+
DATABASE = 4,
|
|
8
|
+
INTEGRATION = 5,
|
|
9
|
+
INTERNAL = 6,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Chave de i18n de cada valor — o texto mora em `i18n/messages` (§6). */
|
|
13
|
+
export const ErrorTypeLabelKey: Record<ErrorType, string> = {
|
|
14
|
+
[ErrorType.VALIDATION]: "logs.errorValidation",
|
|
15
|
+
[ErrorType.AUTH]: "logs.errorAuth",
|
|
16
|
+
[ErrorType.NOT_FOUND]: "logs.errorNotFound",
|
|
17
|
+
[ErrorType.RATE_LIMIT]: "logs.errorRateLimit",
|
|
18
|
+
[ErrorType.DATABASE]: "logs.errorDatabase",
|
|
19
|
+
[ErrorType.INTEGRATION]: "logs.errorIntegration",
|
|
20
|
+
[ErrorType.INTERNAL]: "logs.errorInternal",
|
|
21
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Desfecho da requisição (espelha o RequestOutcome do backend:
|
|
2
|
+
// request_logs.outcome).
|
|
3
|
+
export enum RequestOutcome {
|
|
4
|
+
SUCCESS = 0,
|
|
5
|
+
ERROR = 1,
|
|
6
|
+
ABORTED = 2,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Chave de i18n de cada valor — o texto mora em `i18n/messages` (§6). */
|
|
10
|
+
export const RequestOutcomeLabelKey: Record<RequestOutcome, string> = {
|
|
11
|
+
[RequestOutcome.SUCCESS]: "logs.outcomeSuccess",
|
|
12
|
+
[RequestOutcome.ERROR]: "logs.outcomeError",
|
|
13
|
+
[RequestOutcome.ABORTED]: "logs.outcomeAborted",
|
|
14
|
+
};
|
|
@@ -9,11 +9,23 @@ import {
|
|
|
9
9
|
useState,
|
|
10
10
|
} from "react";
|
|
11
11
|
|
|
12
|
+
import type { FilterValues } from "#core/_utils/filter";
|
|
13
|
+
import { RequestOutcome } from "#core/features/logs/enums/request-outcome.enum";
|
|
12
14
|
import { logsService, RequestLog } from "#core/features/logs/services/logs.service";
|
|
13
15
|
import type { FiltersState } from "#core/hooks/use-filters";
|
|
14
16
|
import { useListQuery } from "#core/hooks/use-list-query";
|
|
15
17
|
import { useRequest } from "#core/hooks/use-request";
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* A tela abre no sucesso, não em tudo.
|
|
21
|
+
*
|
|
22
|
+
* Fora do módulo para não recriar o objeto a cada render: ele é dependência do
|
|
23
|
+
* efeito de hidratação, e um literal novo por render o refaria sem parar.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_FILTER: FilterValues = {
|
|
26
|
+
outcome: { values: [String(RequestOutcome.SUCCESS)] },
|
|
27
|
+
};
|
|
28
|
+
|
|
17
29
|
export interface UseRequestLogsResult {
|
|
18
30
|
rows: RequestLog[];
|
|
19
31
|
total: number;
|
|
@@ -32,7 +44,11 @@ export interface UseRequestLogsResult {
|
|
|
32
44
|
export function useRequestLogs(): UseRequestLogsResult {
|
|
33
45
|
const { run, loading } = useRequest();
|
|
34
46
|
|
|
35
|
-
const list = useListQuery(
|
|
47
|
+
const list = useListQuery(
|
|
48
|
+
"/audit/requests/filter-schema",
|
|
49
|
+
20,
|
|
50
|
+
DEFAULT_FILTER,
|
|
51
|
+
);
|
|
36
52
|
|
|
37
53
|
const [rows, setRows] = useState<RequestLog[]>([]);
|
|
38
54
|
const [total, setTotal] = useState(0);
|
|
@@ -45,9 +61,14 @@ export function useRequestLogs(): UseRequestLogsResult {
|
|
|
45
61
|
}
|
|
46
62
|
}, [run, list.query]);
|
|
47
63
|
|
|
64
|
+
// Não busca antes da URL ser lida: a query sairia sem o filtro, e essa
|
|
65
|
+
// resposta pode chegar depois da certa e pintar a tela com tudo.
|
|
48
66
|
useEffect(() => {
|
|
67
|
+
if (!list.ready) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
49
70
|
void fetch();
|
|
50
|
-
}, [fetch]);
|
|
71
|
+
}, [fetch, list.ready]);
|
|
51
72
|
|
|
52
73
|
return {
|
|
53
74
|
rows,
|
|
@@ -55,7 +76,9 @@ export function useRequestLogs(): UseRequestLogsResult {
|
|
|
55
76
|
page: list.page,
|
|
56
77
|
pageSize: list.pageSize,
|
|
57
78
|
sorting: list.sorting,
|
|
58
|
-
|
|
79
|
+
// Esperar o catálogo também é carregar: sem isso a tabela pisca
|
|
80
|
+
// "Nenhum registro" antes da primeira busca sair.
|
|
81
|
+
loading: loading || !list.ready,
|
|
59
82
|
setPage: list.setPage,
|
|
60
83
|
setPageSize: list.setPageSize,
|
|
61
84
|
setSorting: list.setSorting,
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import type { JSX } from "react";
|
|
4
4
|
|
|
5
|
-
import { Tabs, TabsContent, TabsList, TabsTrigger } from "#core/components/ui";
|
|
6
5
|
import { useI18n } from "#core/contexts";
|
|
7
|
-
import {
|
|
6
|
+
import { RequestLogsPanel } from "#core/features/logs/panels";
|
|
8
7
|
|
|
9
8
|
export function LogsScreen(): JSX.Element {
|
|
10
9
|
const { t } = useI18n();
|
|
@@ -14,18 +13,7 @@ export function LogsScreen(): JSX.Element {
|
|
|
14
13
|
<h1 className="mb-1 text-3xl font-bold">{t("logs.title")}</h1>
|
|
15
14
|
<p className="mb-4 text-sm text-muted-foreground">{t("logs.subtitle")}</p>
|
|
16
15
|
|
|
17
|
-
<
|
|
18
|
-
<TabsList>
|
|
19
|
-
<TabsTrigger value="requests">{t("logs.tabRequests")}</TabsTrigger>
|
|
20
|
-
<TabsTrigger value="errors">{t("logs.tabErrors")}</TabsTrigger>
|
|
21
|
-
</TabsList>
|
|
22
|
-
<TabsContent value="requests">
|
|
23
|
-
<RequestLogsPanel />
|
|
24
|
-
</TabsContent>
|
|
25
|
-
<TabsContent value="errors">
|
|
26
|
-
<ErrorLogsPanel />
|
|
27
|
-
</TabsContent>
|
|
28
|
-
</Tabs>
|
|
16
|
+
<RequestLogsPanel />
|
|
29
17
|
</div>
|
|
30
18
|
);
|
|
31
19
|
}
|
|
@@ -6,11 +6,29 @@ import type {
|
|
|
6
6
|
PaginationState,
|
|
7
7
|
SortingState,
|
|
8
8
|
} from "@tanstack/react-table";
|
|
9
|
+
import { Eye } from "lucide-react";
|
|
9
10
|
import type { JSX } from "react";
|
|
10
|
-
import { useMemo } from "react";
|
|
11
|
+
import { useMemo, useState } from "react";
|
|
11
12
|
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
Badge,
|
|
15
|
+
Button,
|
|
16
|
+
DataTable,
|
|
17
|
+
DataTableFeatures,
|
|
18
|
+
Dialog,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogHeader,
|
|
21
|
+
DialogTitle,
|
|
22
|
+
RowActions,
|
|
23
|
+
SegmentedControl,
|
|
24
|
+
SegmentedOption,
|
|
25
|
+
} from "#core/components/ui";
|
|
13
26
|
import { useI18n } from "#core/contexts";
|
|
27
|
+
import {
|
|
28
|
+
ErrorType,
|
|
29
|
+
ErrorTypeLabelKey,
|
|
30
|
+
} from "#core/features/logs/enums/error-type.enum";
|
|
31
|
+
import { RequestOutcomeLabelKey } from "#core/features/logs/enums/request-outcome.enum";
|
|
14
32
|
import { useRequestLogs } from "#core/features/logs/hooks/use-request-logs";
|
|
15
33
|
import { RequestLog } from "#core/features/logs/services/logs.service";
|
|
16
34
|
|
|
@@ -24,9 +42,46 @@ function statusVariant(
|
|
|
24
42
|
return "secondary";
|
|
25
43
|
}
|
|
26
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Nenhum segmento aceso.
|
|
47
|
+
*
|
|
48
|
+
* Não é opção do controle — some do filtro só quem limpar o desfecho pelo
|
|
49
|
+
* painel lateral, e aí nenhum segmento fica marcado.
|
|
50
|
+
*/
|
|
51
|
+
const NO_OUTCOME = "";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Log de requisição — um painel só.
|
|
55
|
+
*
|
|
56
|
+
* Eram dois (requisições e erros) enquanto eram duas tabelas. Hoje é uma linha
|
|
57
|
+
* por requisição, e quem separa sucesso de erro é o desfecho na barra.
|
|
58
|
+
*/
|
|
27
59
|
export function RequestLogsPanel(): JSX.Element {
|
|
28
60
|
const { t } = useI18n();
|
|
29
61
|
const logs = useRequestLogs();
|
|
62
|
+
const [detail, setDetail] = useState<RequestLog | null>(null);
|
|
63
|
+
|
|
64
|
+
// O atalho não é um segundo caminho de filtro (§11): ele escreve no mesmo
|
|
65
|
+
// estado do painel lateral, então o chip aparece, a URL guarda e o painel
|
|
66
|
+
// mostra o mesmo valor. É atalho para o campo do catálogo, não um filtro
|
|
67
|
+
// paralelo.
|
|
68
|
+
const outcome = logs.filters.values.outcome?.values?.[0] ?? NO_OUTCOME;
|
|
69
|
+
const setOutcome = (value: string): void => {
|
|
70
|
+
logs.filters.setValues({
|
|
71
|
+
...logs.filters.values,
|
|
72
|
+
outcome: { values: [value] },
|
|
73
|
+
});
|
|
74
|
+
logs.setPage(0);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const outcomeOptions = useMemo<SegmentedOption[]>(
|
|
78
|
+
() =>
|
|
79
|
+
Object.entries(RequestOutcomeLabelKey).map(([value, key]) => ({
|
|
80
|
+
value,
|
|
81
|
+
label: t(key),
|
|
82
|
+
})),
|
|
83
|
+
[t],
|
|
84
|
+
);
|
|
30
85
|
|
|
31
86
|
const columns = useMemo<ColumnDef<DataTableFeatures, RequestLog, unknown>[]>(
|
|
32
87
|
() => [
|
|
@@ -37,12 +92,17 @@ export function RequestLogsPanel(): JSX.Element {
|
|
|
37
92
|
cell: ({ getValue }) => new Date(String(getValue())).toLocaleString(),
|
|
38
93
|
},
|
|
39
94
|
{
|
|
40
|
-
|
|
95
|
+
id: "route",
|
|
41
96
|
header: t("logs.route"),
|
|
42
97
|
meta: { label: t("logs.route") },
|
|
43
98
|
enableSorting: false,
|
|
44
|
-
|
|
45
|
-
|
|
99
|
+
// A ação nomeada (`@Auditable("Login")`) diz mais que o caminho; sem
|
|
100
|
+
// ela, o caminho é o que identifica a requisição.
|
|
101
|
+
cell: ({ row }) => (
|
|
102
|
+
<span className="font-mono text-xs">
|
|
103
|
+
{row.original.action ??
|
|
104
|
+
`${row.original.method ?? ""} ${row.original.path ?? "—"}`}
|
|
105
|
+
</span>
|
|
46
106
|
),
|
|
47
107
|
},
|
|
48
108
|
{
|
|
@@ -54,6 +114,16 @@ export function RequestLogsPanel(): JSX.Element {
|
|
|
54
114
|
return <Badge variant={statusVariant(status)}>{status ?? "—"}</Badge>;
|
|
55
115
|
},
|
|
56
116
|
},
|
|
117
|
+
{
|
|
118
|
+
accessorKey: "errorType",
|
|
119
|
+
header: t("logs.type"),
|
|
120
|
+
meta: { label: t("logs.type") },
|
|
121
|
+
enableSorting: false,
|
|
122
|
+
cell: ({ getValue }) => {
|
|
123
|
+
const type = getValue() as ErrorType | null;
|
|
124
|
+
return type == null ? "—" : t(ErrorTypeLabelKey[type]);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
57
127
|
{
|
|
58
128
|
accessorKey: "durationMs",
|
|
59
129
|
header: t("logs.duration"),
|
|
@@ -71,16 +141,26 @@ export function RequestLogsPanel(): JSX.Element {
|
|
|
71
141
|
cell: ({ row }) => row.original.user?.name ?? t("logs.systemUser"),
|
|
72
142
|
},
|
|
73
143
|
{
|
|
74
|
-
|
|
75
|
-
header: t("
|
|
76
|
-
meta: {
|
|
144
|
+
id: "actions",
|
|
145
|
+
header: () => <div className="text-center">{t("common.actions")}</div>,
|
|
146
|
+
meta: { shrink: true },
|
|
147
|
+
enableHiding: false,
|
|
77
148
|
enableSorting: false,
|
|
78
|
-
cell: ({
|
|
79
|
-
<
|
|
149
|
+
cell: ({ row }) => (
|
|
150
|
+
<RowActions>
|
|
151
|
+
<Button
|
|
152
|
+
variant="ghost"
|
|
153
|
+
size="sm"
|
|
154
|
+
onClick={() => setDetail(row.original)}
|
|
155
|
+
>
|
|
156
|
+
<Eye className="h-4 w-4" />
|
|
157
|
+
{t("logs.viewDetails")}
|
|
158
|
+
</Button>
|
|
159
|
+
</RowActions>
|
|
80
160
|
),
|
|
81
161
|
},
|
|
82
162
|
],
|
|
83
|
-
|
|
163
|
+
|
|
84
164
|
[t],
|
|
85
165
|
);
|
|
86
166
|
|
|
@@ -105,20 +185,92 @@ export function RequestLogsPanel(): JSX.Element {
|
|
|
105
185
|
};
|
|
106
186
|
|
|
107
187
|
return (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
188
|
+
<>
|
|
189
|
+
<DataTable
|
|
190
|
+
columns={columns}
|
|
191
|
+
data={logs.rows}
|
|
192
|
+
getRowId={(r) => r.id}
|
|
193
|
+
storageKey="logs-requests"
|
|
194
|
+
loading={logs.loading}
|
|
195
|
+
manualPagination
|
|
196
|
+
rowCount={logs.total}
|
|
197
|
+
pagination={pagination}
|
|
198
|
+
onPaginationChange={onPaginationChange}
|
|
199
|
+
manualSorting
|
|
200
|
+
sorting={logs.sorting}
|
|
201
|
+
onSortingChange={onSortingChange}
|
|
121
202
|
filters={logs.filters}
|
|
122
|
-
|
|
203
|
+
// O desfecho é escolhido na barra: no painel e no chip seria a mesma
|
|
204
|
+
// coisa em três lugares. Continua no catálogo, que é quem serializa.
|
|
205
|
+
hiddenFilterFields={["outcome"]}
|
|
206
|
+
toolbar={
|
|
207
|
+
<SegmentedControl
|
|
208
|
+
// Altura dos botões `sm` de Filtros/Colunas, que dividem a linha.
|
|
209
|
+
className="h-9"
|
|
210
|
+
label={t("logs.outcome")}
|
|
211
|
+
options={outcomeOptions}
|
|
212
|
+
value={outcome}
|
|
213
|
+
onValueChange={setOutcome}
|
|
214
|
+
/>
|
|
215
|
+
}
|
|
216
|
+
/>
|
|
217
|
+
|
|
218
|
+
<Dialog open={!!detail} onOpenChange={(o) => !o && setDetail(null)}>
|
|
219
|
+
<DialogContent className="max-w-2xl">
|
|
220
|
+
<DialogHeader>
|
|
221
|
+
<DialogTitle>
|
|
222
|
+
{t("logs.detailsTitle")}
|
|
223
|
+
{detail?.statusCode != null && ` (${detail.statusCode})`}
|
|
224
|
+
</DialogTitle>
|
|
225
|
+
</DialogHeader>
|
|
226
|
+
{detail && (
|
|
227
|
+
<div className="flex min-w-0 flex-col gap-3 text-sm">
|
|
228
|
+
<div>
|
|
229
|
+
<span className="font-medium">{t("logs.route")}: </span>
|
|
230
|
+
<span className="font-mono text-xs">
|
|
231
|
+
{detail.method} {detail.path}
|
|
232
|
+
</span>
|
|
233
|
+
</div>
|
|
234
|
+
{detail.errorMessage && (
|
|
235
|
+
<div>
|
|
236
|
+
<span className="font-medium">{t("logs.message")}: </span>
|
|
237
|
+
{detail.errorMessage}
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
{detail.errorCode && (
|
|
241
|
+
<div>
|
|
242
|
+
<span className="font-medium">{t("logs.errorCode")}: </span>
|
|
243
|
+
<span className="font-mono text-xs">{detail.errorCode}</span>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
<div>
|
|
247
|
+
<span className="font-medium">{t("logs.user")}: </span>
|
|
248
|
+
{detail.user?.name ?? t("logs.systemUser")}
|
|
249
|
+
</div>
|
|
250
|
+
<div>
|
|
251
|
+
<span className="font-medium">{t("logs.requestId")}: </span>
|
|
252
|
+
<span className="font-mono text-xs">
|
|
253
|
+
{detail.requestId ?? "—"}
|
|
254
|
+
</span>
|
|
255
|
+
</div>
|
|
256
|
+
{detail.userAgent && (
|
|
257
|
+
<div className="min-w-0">
|
|
258
|
+
<span className="font-medium">{t("logs.userAgent")}: </span>
|
|
259
|
+
<span className="font-mono text-xs">{detail.userAgent}</span>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
{detail.errorStack && (
|
|
263
|
+
<div className="min-w-0">
|
|
264
|
+
<p className="mb-1 font-medium">{t("logs.stackTrace")}</p>
|
|
265
|
+
<pre className="max-h-64 min-w-0 max-w-full overflow-auto rounded-md bg-muted p-3 text-xs">
|
|
266
|
+
{detail.errorStack}
|
|
267
|
+
</pre>
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
</DialogContent>
|
|
273
|
+
</Dialog>
|
|
274
|
+
</>
|
|
123
275
|
);
|
|
124
276
|
}
|