feat: remove legacy frontend, root routing, compliance ZIP with Chromium PDF, chat markdown styling

- Legacy frontend removed: React SPA served at / (no /app/ prefix)
- Compliance report ZIP: Chromium headless --print-to-pdf (identical to browser) + CSV findings
- Server-side generation state: .compliance_generating marker survives page navigation
- RAG enriched: Description + Rationale + Remediation + Audit extracted from CIS vector chunks
- Chat markdown: syntax highlighting (Catppuccin), styled headings/lists/tables/blockquotes/code
- Default model auto-selected on Chat Agent load
This commit is contained in:
nogueiraguh
2026-03-20 07:21:41 -03:00
parent 5028f632a6
commit 61fbb3861d
18 changed files with 471 additions and 200 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Agent - Infrastructure & Security Engineer</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/app/assets/index-BCYp2Z2l.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-D9WRGO1k.css">
<script type="module" crossorigin src="/assets/index-Dtsz7Biu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ca4AYldT.css">
</head>
<body>
<div id="root"></div>

View File

@@ -20,7 +20,7 @@ client.interceptors.response.use(
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('t');
window.location.href = '/app/login';
window.location.href = '/login';
}
const detail = err.response?.data?.detail || err.response?.data?.message || err.message || 'Erro';
return Promise.reject(new Error(detail));

View File

@@ -109,7 +109,12 @@ export const reportsApi = {
client.post(`/reports/${rid}/compliance-report/generate`) as unknown as Promise<{ status: string; has_rag: boolean }>,
complianceReportStatus: (rid: string) =>
client.get(`/reports/${rid}/compliance-report/status`) as unknown as Promise<{ ready: boolean }>,
client.get(`/reports/${rid}/compliance-report/status`) as unknown as Promise<{ ready: boolean; generating: boolean }>,
complianceReportZipUrl: (rid: string) => {
const token = localStorage.getItem('t') || '';
return `/api/reports/${rid}/compliance-report/download?token=${encodeURIComponent(token)}`;
},
files: (rid: string) =>
client.get(`/reports/${rid}/files`) as unknown as Promise<ReportFile[]>,

View File

@@ -409,23 +409,153 @@ nav a:hover { background: var(--bg3); color: var(--t1) !important; }
border-bottom: 1px solid var(--bg3);
}
/* Prevent text overflow on resize */
.prose pre, .chat-markdown pre {
overflow-x: auto;
max-width: 100%;
/* ── Chat Markdown Styling ───────────────────────────────────── */
.chat-markdown {
font-size: .82rem;
line-height: 1.7;
color: var(--t1);
}
.prose table, .chat-markdown table {
overflow-x: auto;
display: block;
max-width: 100%;
}
.prose code, .chat-markdown code {
word-break: break-all;
}
.prose p, .chat-markdown p {
.chat-markdown p {
margin: 0 0 .65em;
overflow-wrap: break-word;
word-break: break-word;
}
.chat-markdown p:last-child { margin-bottom: 0; }
/* Headings */
.chat-markdown h1, .chat-markdown h2, .chat-markdown h3,
.chat-markdown h4, .chat-markdown h5, .chat-markdown h6 {
font-weight: 700;
color: var(--t1);
margin: 1em 0 .4em;
line-height: 1.3;
}
.chat-markdown h1 { font-size: 1.15em; }
.chat-markdown h2 { font-size: 1.05em; }
.chat-markdown h3 { font-size: .95em; }
.chat-markdown h4, .chat-markdown h5, .chat-markdown h6 { font-size: .88em; }
.chat-markdown > h1:first-child,
.chat-markdown > h2:first-child,
.chat-markdown > h3:first-child { margin-top: 0; }
/* Lists */
.chat-markdown ul, .chat-markdown ol {
margin: .4em 0 .65em;
padding-left: 1.5em;
}
.chat-markdown ul { list-style: disc; }
.chat-markdown ol { list-style: decimal; }
.chat-markdown li { margin: .2em 0; }
.chat-markdown li > ul, .chat-markdown li > ol { margin: .15em 0; }
.chat-markdown li > p { margin: .15em 0; }
/* Inline code */
.chat-markdown code:not(pre code) {
background: color-mix(in srgb, var(--ac) 10%, transparent);
color: var(--ac);
padding: .12em .35em;
border-radius: 4px;
font-size: .88em;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
word-break: break-all;
}
/* Code blocks */
.chat-markdown pre {
background: #1e1e2e;
color: #cdd6f4;
border-radius: 8px;
padding: .85em 1em;
margin: .5em 0 .75em;
overflow-x: auto;
max-width: 100%;
font-size: .78rem;
line-height: 1.55;
border: 1px solid color-mix(in srgb, var(--bd) 50%, transparent);
}
.chat-markdown pre code {
background: none !important;
color: inherit !important;
padding: 0 !important;
border-radius: 0 !important;
font-size: inherit !important;
word-break: normal;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
}
/* Tables */
.chat-markdown table {
border-collapse: collapse;
margin: .5em 0 .75em;
font-size: .8rem;
display: block;
overflow-x: auto;
max-width: 100%;
}
.chat-markdown th, .chat-markdown td {
border: 1px solid var(--bd);
padding: .4em .7em;
text-align: left;
}
.chat-markdown th {
background: color-mix(in srgb, var(--ac) 8%, transparent);
font-weight: 700;
font-size: .78rem;
white-space: nowrap;
}
.chat-markdown tr:nth-child(even) {
background: color-mix(in srgb, var(--bd) 15%, transparent);
}
/* Blockquotes */
.chat-markdown blockquote {
border-left: 3px solid var(--ac);
margin: .5em 0 .75em;
padding: .4em .8em;
background: color-mix(in srgb, var(--ac) 5%, transparent);
color: var(--t2);
border-radius: 0 6px 6px 0;
}
.chat-markdown blockquote p { margin: 0; }
/* Horizontal rule */
.chat-markdown hr {
border: none;
border-top: 1px solid var(--bd);
margin: .8em 0;
}
/* Links */
.chat-markdown a {
color: var(--ac);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Strong / emphasis */
.chat-markdown strong { font-weight: 700; color: var(--t1); }
.chat-markdown em { font-style: italic; }
/* ── Syntax Highlighting (Catppuccin Mocha) ─────────────────── */
.hljs-keyword, .hljs-selector-tag { color: #cba6f7; }
.hljs-string, .hljs-attr { color: #a6e3a1; }
.hljs-number, .hljs-literal { color: #fab387; }
.hljs-comment { color: #6c7086; font-style: italic; }
.hljs-built_in, .hljs-builtin-name { color: #89dceb; }
.hljs-function .hljs-title, .hljs-title.function_ { color: #89b4fa; }
.hljs-type, .hljs-title.class_ { color: #f9e2af; }
.hljs-variable, .hljs-template-variable { color: #f38ba8; }
.hljs-meta { color: #fab387; }
.hljs-punctuation { color: #bac2de; }
.hljs-tag { color: #89b4fa; }
.hljs-name { color: #89b4fa; }
.hljs-attribute { color: #f9e2af; }
/* ── Prose overflow (legacy compat) ─────────────────────────── */
.prose pre { overflow-x: auto; max-width: 100%; }
.prose table { overflow-x: auto; display: block; max-width: 100%; }
.prose code { word-break: break-all; }
.prose p { overflow-wrap: break-word; word-break: break-word; }
/* Tooltip-like labels */
.field-label {

View File

@@ -6,7 +6,7 @@ import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename="/app">
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,

View File

@@ -5,6 +5,7 @@ import {
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import { useAppStore } from '@/stores/app';
import { useI18n } from '@/i18n';
import {
@@ -101,7 +102,12 @@ export default function ChatPage() {
const [messages, setMessages] = useState<UIMessage[]>([]);
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [model, setModel] = useState('');
const [model, setModel] = useState(() => {
// Auto-select first genai config as default model
const store = useAppStore.getState();
if (store.genaiCfg.length > 0) return `cfg:${store.genaiCfg[0].id}`;
return '';
});
const [ociConfig, setOciConfig] = useState('');
const [files, setFiles] = useState<AttachedFile[]>([]);
const [chatParams, setChatParams] = useState<ChatParams>({ ...DEFAULT_PARAMS });
@@ -145,6 +151,13 @@ export default function ChatPage() {
[mcpSvr],
);
// Auto-select default model when genaiCfg loads
useEffect(() => {
if (!model && genaiCfg.length > 0) {
setModel(`cfg:${genaiCfg[0].id}`);
}
}, [genaiCfg]); // eslint-disable-line react-hooks/exhaustive-deps
const hasRag = useMemo(
() => adbCfg.some((c) => c.is_active) && (genaiCfg.length > 0 || ociCfg.length > 0),
[adbCfg, genaiCfg, ociCfg],
@@ -789,7 +802,7 @@ export default function ChatPage() {
<div style={{ whiteSpace: 'pre-wrap' }}>{msg.content}</div>
) : (
<div className="chat-markdown prose prose-sm max-w-none [&_pre]:overflow-x-auto [&_table]:overflow-x-auto" style={{ overflowWrap: 'break-word', wordBreak: 'break-word' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>{msg.content}</ReactMarkdown>
</div>
)}
</div>

View File

@@ -5,6 +5,7 @@ import {
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import { useAppStore, type ModelInfo } from '@/stores/app';
import { terraformApi, type ChatSession } from '@/api/endpoints/terraform';
import { useI18n } from '@/i18n';
@@ -459,7 +460,7 @@ export default function PromptGeneratorPage() {
['--tw-prose-headings' as string]: 'var(--t1)',
['--tw-prose-code' as string]: 'var(--pp)',
}}>
<ReactMarkdown remarkPlugins={[remarkGfm]}
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}
components={{
pre: ({ children }) => (
<div className="relative group">

View File

@@ -577,7 +577,6 @@ export default function ReportsPage() {
const [showCompliance, setShowCompliance] = useState(false);
const [complianceReady, setComplianceReady] = useState(false);
const [complianceGenerating, setComplianceGenerating] = useState(false);
const compliancePollRef = useRef<ReturnType<typeof setInterval> | null>(null);
/* ── Error ── */
const [error, setError] = useState('');
@@ -625,12 +624,17 @@ export default function ReportsPage() {
if (running && !trackingId) {
setTrackingId(running.id);
}
// Auto-select most recent completed report if none selected
if (!selectedRid && data.length > 0) {
const completed = data.find((r) => r.status === 'completed');
if (completed) setSelectedRid(completed.id);
}
} catch (err) {
setError(err instanceof Error ? err.message : t('rpt.errorLoadReports'));
} finally {
setReportsLoading(false);
}
}, [trackingId]);
}, [trackingId, selectedRid]);
useEffect(() => { loadReports(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
@@ -672,14 +676,14 @@ export default function ReportsPage() {
Promise.all([
reportsApi.summary(selectedRid),
reportsApi.files(selectedRid),
reportsApi.complianceReportStatus(selectedRid).catch(() => ({ ready: false })),
reportsApi.complianceReportStatus(selectedRid).catch(() => ({ ready: false, generating: false } as { ready: boolean; generating: boolean })),
])
.then(([summaryData, filesData, compStatus]) => {
if (!cancelled) {
setKpi(summaryData);
setFiles(filesData);
setComplianceReady(compStatus.ready);
setComplianceGenerating(false);
setComplianceReady(!!compStatus.ready);
setComplianceGenerating(!!compStatus.generating);
}
})
.catch(() => {
@@ -695,43 +699,34 @@ export default function ReportsPage() {
return () => { cancelled = true; };
}, [selectedRid]);
/* ── Compliance report polling (useEffect-based, survives re-renders + tab throttle) ── */
useEffect(() => {
if (!complianceGenerating || !selectedRid) return;
let active = true;
const check = async () => {
/* ── Compliance report polling (uses same usePolling as CIS report) ── */
usePolling(
async () => {
if (!complianceGenerating || !selectedRid) return false;
try {
const s = await reportsApi.complianceReportStatus(selectedRid);
if (s.ready && active) {
if (s.ready) {
setComplianceReady(true);
setComplianceGenerating(false);
setShowCompliance(true);
return true; // stop polling
}
} catch { /* keep polling */ }
};
check();
const id = setInterval(check, 3000);
compliancePollRef.current = id;
const timeout = setTimeout(() => { clearInterval(id); if (active) setComplianceGenerating(false); }, 300000);
return () => { active = false; clearInterval(id); clearTimeout(timeout); compliancePollRef.current = null; };
}, [complianceGenerating, selectedRid]);
/* ── Re-check compliance status when section is expanded ── */
useEffect(() => {
if (!showCompliance || !selectedRid || complianceReady || complianceGenerating) return;
reportsApi.complianceReportStatus(selectedRid)
.then((s) => { if (s.ready) setComplianceReady(true); })
.catch(() => {});
}, [showCompliance, selectedRid, complianceReady, complianceGenerating]);
return false;
},
3000,
complianceGenerating && !!selectedRid,
);
const startComplianceGeneration = useCallback(async () => {
if (!selectedRid) return;
setComplianceGenerating(true);
if (!selectedRid || complianceGenerating) return;
setComplianceReady(false);
setComplianceGenerating(true);
try {
await reportsApi.generateComplianceReport(selectedRid);
} catch { /* ignore */ }
}, [selectedRid]);
// polling is already running via usePolling above
}, [selectedRid, complianceGenerating]);
/* ── Handlers ── */
const handleRun = async () => {
@@ -1337,7 +1332,14 @@ export default function ReportsPage() {
style={{ height: 700, background: '#fff' }}
title="LAD A-Team CIS Compliance Report"
/>
<div className="px-5 py-2 flex justify-end" style={{ borderTop: '1px solid var(--bd)' }}>
<div className="px-5 py-2 flex justify-end gap-2" style={{ borderTop: '1px solid var(--bd)' }}>
<a
href={reportsApi.complianceReportZipUrl(selectedRid)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.68rem] font-medium cursor-pointer transition-colors no-underline"
style={{ background: 'var(--ac)', color: '#fff' }}
>
<Download size={12} /> Download ZIP
</a>
<button
onClick={startComplianceGeneration}
disabled={complianceGenerating}

View File

@@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
base: '/app/',
base: '/',
plugins: [react()],
resolve: {
alias: {