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

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}