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:
@@ -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));
|
||||
|
||||
@@ -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[]>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,7 +6,7 @@ import App from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename="/app">
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user