Skip to content

Commit 5e434e1

Browse files
committed
scan center command
1 parent 7f308b2 commit 5e434e1

3 files changed

Lines changed: 486 additions & 0 deletions

File tree

‎src/components/ScanCenter.js‎

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { Box, Text, useApp, useInput } from 'ink';
3+
import SelectList from './SelectList.js';
4+
import { validateConnection } from '../scans/connectionHandler.js';
5+
import { listRepos } from '../scans/listRepos.js';
6+
import { getScanHistory } from '../scans/getScanHistory.js';
7+
import { fetchScanResults } from '../scans/fetchScanResults.js';
8+
import { fetchAdvancedScanResults } from '../scans/fetchAdvancedScanResults.js';
9+
import { fetchDismissedAlerts } from '../scans/fetchDismissedAlerts.js';
10+
11+
const ce = React.createElement;
12+
13+
// ─── Constants ───────────────────────────────────────────────────────────────
14+
15+
const STEPS = {
16+
LOADING: 'loading',
17+
SELECT_CONNECTION: 'select-connection',
18+
SELECT_REPO: 'select-repo',
19+
SELECT_SCAN: 'select-scan',
20+
SELECT_RESULT_TYPE: 'select-result-type',
21+
SHOWING_RESULTS: 'showing-results',
22+
ERROR: 'error',
23+
};
24+
25+
const RESULT_TYPES = [
26+
{ label: 'Security Issues (SAST)', value: 'security_issues', kind: 'basic' },
27+
{ label: 'Anti-Patterns', value: 'anti_patterns', kind: 'basic' },
28+
{ label: 'Docstring Issues', value: 'docstring', kind: 'basic' },
29+
{ label: 'Complex Functions', value: 'complex_functions', kind: 'basic' },
30+
{ label: 'SCA — Dependencies', value: 'sca', kind: 'advanced' },
31+
{ label: 'SBOM', value: 'sbom', kind: 'advanced' },
32+
{ label: 'Secrets', value: 'secrets', kind: 'advanced' },
33+
{ label: 'IaC (Infrastructure)', value: 'iac', kind: 'advanced' },
34+
{ label: 'Dead Code', value: 'dead_code', kind: 'advanced' },
35+
{ label: 'Dismissed Alerts', value: 'dismissed_alerts', kind: 'dismissed' },
36+
{ label: 'Dismissed Secrets', value: 'dismissed_secrets', kind: 'dismissed' },
37+
];
38+
39+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
40+
const RESULTS_PAGE = 15;
41+
42+
// ─── Helpers ─────────────────────────────────────────────────────────────────
43+
44+
function severityColor(sev) {
45+
if (!sev) return 'gray';
46+
const s = sev.toLowerCase();
47+
if (s === 'critical' || s === 'high') return 'red';
48+
if (s === 'medium' || s === 'warning') return 'yellow';
49+
if (s === 'low') return 'cyan';
50+
return 'gray';
51+
}
52+
53+
function severityLabel(sev) {
54+
if (!sev) return 'INFO';
55+
return sev.toUpperCase().slice(0, 4);
56+
}
57+
58+
// ─── Spinner ─────────────────────────────────────────────────────────────────
59+
60+
function Spinner({ label }) {
61+
const [frame, setFrame] = useState(0);
62+
useEffect(() => {
63+
const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 100);
64+
return () => clearInterval(id);
65+
}, []);
66+
return ce(
67+
Box,
68+
null,
69+
ce(Text, { color: 'cyan' }, SPINNER_FRAMES[frame] + ' '),
70+
ce(Text, null, label)
71+
);
72+
}
73+
74+
// ─── Breadcrumb ──────────────────────────────────────────────────────────────
75+
76+
function Breadcrumb({ parts }) {
77+
return ce(
78+
Box,
79+
{ marginBottom: 1 },
80+
...parts.map((p, i) =>
81+
ce(Text, { key: i, color: i === parts.length - 1 ? 'cyan' : 'gray' },
82+
(i > 0 ? ' › ' : '') + p)
83+
)
84+
);
85+
}
86+
87+
// ─── Results view ────────────────────────────────────────────────────────────
88+
89+
function ResultsView({ issues, resultTypeLabel, breadcrumbParts, onBack }) {
90+
const [offset, setOffset] = useState(0);
91+
92+
useInput((input, key) => {
93+
if (input === 'b' || key.escape) { onBack(); return; }
94+
if (key.downArrow) setOffset((o) => Math.min(o + 1, Math.max(0, issues.length - RESULTS_PAGE)));
95+
if (key.upArrow) setOffset((o) => Math.max(0, o - 1));
96+
});
97+
98+
const visible = issues.slice(offset, offset + RESULTS_PAGE);
99+
100+
const rows = issues.length === 0
101+
? [ce(Box, { key: 'empty', marginBottom: 1 }, ce(Text, { color: 'green' }, '✓ No issues found'))]
102+
: [
103+
ce(Box, { key: 'counter', marginBottom: 1 },
104+
ce(Text, { color: 'gray' },
105+
`Showing ${offset + 1}–${Math.min(offset + RESULTS_PAGE, issues.length)} of ${issues.length} issues`)
106+
),
107+
...visible.map((issue, i) =>
108+
ce(
109+
Box,
110+
{ key: i, flexDirection: 'row', gap: 1 },
111+
ce(Text, { color: severityColor(issue.severity), bold: true },
112+
`[${severityLabel(issue.severity)}]`),
113+
ce(Text, { color: 'gray' },
114+
(issue.file_path || 'unknown') + (issue.line_number ? `:${issue.line_number}` : '')),
115+
ce(Text, null,
116+
issue.check_name || issue.issue_text || issue.message || '')
117+
)
118+
),
119+
];
120+
121+
return ce(
122+
Box,
123+
{ flexDirection: 'column', paddingX: 1 },
124+
ce(Breadcrumb, { parts: [...breadcrumbParts, resultTypeLabel] }),
125+
...rows,
126+
ce(
127+
Box,
128+
{ marginTop: 1, borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
129+
ce(Text, { color: 'gray' }, '[↑↓] scroll [b] back')
130+
)
131+
);
132+
}
133+
134+
// ─── Error view ──────────────────────────────────────────────────────────────
135+
136+
function ErrorView({ message, onBack, canGoBack }) {
137+
useInput((input, key) => {
138+
if (input === 'b' || key.escape || key.return) onBack();
139+
});
140+
return ce(
141+
Box,
142+
{ flexDirection: 'column', paddingX: 1, paddingY: 1 },
143+
ce(Text, { color: 'red', bold: true }, '✖ Error'),
144+
ce(Box, { marginTop: 1 },
145+
ce(Text, null, message)
146+
),
147+
ce(
148+
Box,
149+
{ marginTop: 1, borderStyle: 'single', borderColor: 'gray', paddingX: 1 },
150+
ce(Text, { color: 'gray' }, canGoBack ? '[b / Enter] go back' : '[b / Enter] exit')
151+
)
152+
);
153+
}
154+
155+
// ─── Main ScanCenter ─────────────────────────────────────────────────────────
156+
157+
export default function ScanCenter() {
158+
const { exit } = useApp();
159+
160+
const [step, setStep] = useState(STEPS.LOADING);
161+
const [loadingMsg, setLoadingMsg] = useState('Validating connection…');
162+
const [errorMsg, setErrorMsg] = useState('');
163+
const [errorBackStep, setErrorBackStep] = useState(null);
164+
165+
const [connections, setConnections] = useState([]);
166+
const [selectedConnection, setSelectedConnection] = useState(null);
167+
168+
const [repos, setRepos] = useState([]);
169+
const [selectedRepo, setSelectedRepo] = useState(null);
170+
171+
const [scanHistory, setScanHistory] = useState([]);
172+
const [selectedScan, setSelectedScan] = useState(null);
173+
174+
const [selectedResultType, setSelectedResultType] = useState(null);
175+
const [results, setResults] = useState(null);
176+
177+
const setError = (msg, backStep) => {
178+
setErrorMsg(msg);
179+
setErrorBackStep(backStep);
180+
setStep(STEPS.ERROR);
181+
};
182+
183+
// ── Step 1: validate connection on mount ──
184+
useEffect(() => {
185+
(async () => {
186+
const res = await validateConnection();
187+
if (!res.success) {
188+
setError(res.error || 'Failed to validate connection', null);
189+
return;
190+
}
191+
if (!res.connections || res.connections.length === 0) {
192+
setError('No connected organisations found. Please log in to CodeAnt first.', null);
193+
return;
194+
}
195+
setConnections(res.connections);
196+
setStep(STEPS.SELECT_CONNECTION);
197+
})();
198+
}, []);
199+
200+
// ── Step 2: connection selected → fetch repos ──
201+
const handleSelectConnection = async (item) => {
202+
setSelectedConnection(item.value);
203+
setStep(STEPS.LOADING);
204+
setLoadingMsg(`Fetching repos for ${item.value.organizationName}…`);
205+
const res = await listRepos(item.value.organizationName);
206+
if (!res.success) {
207+
setError(res.error || 'Failed to fetch repos', STEPS.SELECT_CONNECTION);
208+
return;
209+
}
210+
setRepos(res.repos || []);
211+
setStep(STEPS.SELECT_REPO);
212+
};
213+
214+
// ── Step 3: repo selected → fetch scan history ──
215+
const handleSelectRepo = async (item) => {
216+
setSelectedRepo(item.value);
217+
setStep(STEPS.LOADING);
218+
setLoadingMsg(`Loading scan history for ${item.value.full_name}…`);
219+
const res = await getScanHistory(item.value.full_name);
220+
if (!res.success) {
221+
setError(res.error || 'Failed to fetch scan history', STEPS.SELECT_REPO);
222+
return;
223+
}
224+
const history = res.scanHistory || [];
225+
process.stderr.write('SCAN_HISTORY_SAMPLE: ' + JSON.stringify(history.slice(0, 3), null, 2) + '\n');
226+
setScanHistory(history);
227+
setStep(STEPS.SELECT_SCAN);
228+
};
229+
230+
// ── Step 4: scan selected → show result type menu ──
231+
const handleSelectScan = (item) => {
232+
setSelectedScan(item.value);
233+
setStep(STEPS.SELECT_RESULT_TYPE);
234+
};
235+
236+
// ── Step 5: result type selected → fetch exactly one endpoint ──
237+
const handleSelectResultType = async (item) => {
238+
const rt = item.value;
239+
setSelectedResultType(rt);
240+
setStep(STEPS.LOADING);
241+
setLoadingMsg(`Fetching ${item.label}…`);
242+
243+
const repo = selectedRepo.full_name;
244+
const commitId = selectedScan.commitId;
245+
let res;
246+
247+
if (rt.kind === 'basic') {
248+
res = await fetchScanResults(repo, commitId, rt.value);
249+
} else if (rt.kind === 'advanced') {
250+
res = await fetchAdvancedScanResults(repo, commitId, rt.value);
251+
} else if (rt.value === 'dismissed_alerts') {
252+
const r = await fetchDismissedAlerts(repo, 'security');
253+
res = r.success ? { success: true, issues: r.dismissedAlerts } : r;
254+
} else if (rt.value === 'dismissed_secrets') {
255+
const r = await fetchDismissedAlerts(repo, 'secrets');
256+
res = r.success ? { success: true, issues: r.dismissedAlerts } : r;
257+
}
258+
259+
if (!res || !res.success) {
260+
setError((res && res.error) || 'Failed to fetch results', STEPS.SELECT_RESULT_TYPE);
261+
return;
262+
}
263+
264+
setResults(res);
265+
setStep(STEPS.SHOWING_RESULTS);
266+
};
267+
268+
// ── Back navigation ──
269+
const goBack = {
270+
[STEPS.SELECT_CONNECTION]: () => exit(),
271+
[STEPS.SELECT_REPO]: () => { setSelectedConnection(null); setStep(STEPS.SELECT_CONNECTION); },
272+
[STEPS.SELECT_SCAN]: () => { setSelectedRepo(null); setStep(STEPS.SELECT_REPO); },
273+
[STEPS.SELECT_RESULT_TYPE]:() => { setSelectedScan(null); setStep(STEPS.SELECT_SCAN); },
274+
[STEPS.SHOWING_RESULTS]: () => { setResults(null); setSelectedResultType(null); setStep(STEPS.SELECT_RESULT_TYPE); },
275+
};
276+
277+
const breadcrumbParts = [
278+
selectedConnection?.organizationName,
279+
selectedRepo?.name,
280+
selectedScan ? (selectedScan.commitId || '').slice(0, 8) : null,
281+
].filter(Boolean);
282+
283+
// ─── Render ───────────────────────────────────────────────────────────────
284+
285+
if (step === STEPS.LOADING) {
286+
return ce(Box, { paddingX: 1, paddingY: 1 }, ce(Spinner, { label: loadingMsg }));
287+
}
288+
289+
if (step === STEPS.ERROR) {
290+
const canGoBack = errorBackStep !== null;
291+
const onErrorBack = canGoBack
292+
? () => { setErrorMsg(''); setStep(errorBackStep); }
293+
: () => exit();
294+
return ce(ErrorView, { message: errorMsg, onBack: onErrorBack, canGoBack });
295+
}
296+
297+
if (step === STEPS.SELECT_CONNECTION) {
298+
const items = connections
299+
.filter((c) => c && c.organizationName)
300+
.map((c) => ({
301+
label: c.organizationName,
302+
sublabel: `${c.service} ${c.baseUrl}`,
303+
value: c,
304+
}));
305+
return ce(SelectList, {
306+
title: 'Select a connection',
307+
items,
308+
onSelect: handleSelectConnection,
309+
onBack: goBack[STEPS.SELECT_CONNECTION],
310+
});
311+
}
312+
313+
if (step === STEPS.SELECT_REPO) {
314+
const items = repos
315+
.filter((r) => r && (r.name || r.full_name))
316+
.map((r) => ({
317+
label: r.name || r.full_name,
318+
sublabel: r.pushed_at ? `Last push: ${new Date(r.pushed_at).toLocaleDateString()}` : undefined,
319+
value: r,
320+
}));
321+
return ce(
322+
Box,
323+
{ flexDirection: 'column' },
324+
ce(Box, { paddingX: 1 }, ce(Breadcrumb, { parts: [selectedConnection.organizationName] })),
325+
ce(SelectList, {
326+
title: 'Select a repository',
327+
items,
328+
onSelect: handleSelectRepo,
329+
onBack: goBack[STEPS.SELECT_REPO],
330+
emptyMessage: 'No repositories found for this organisation.',
331+
})
332+
);
333+
}
334+
335+
if (step === STEPS.SELECT_SCAN) {
336+
const items = scanHistory
337+
.map((s, i) => {
338+
if (!s || typeof s !== 'object') return null;
339+
const date = s.date || s.created_at || s.timestamp;
340+
const branch = s.branch || s.ref || '';
341+
const commitFull = s.commit_id || s.commitId || '';
342+
const commit = commitFull.slice(0, 8);
343+
return {
344+
label: (commit || `scan-${i + 1}`) + (branch ? ' ' + branch : ''),
345+
sublabel: [commitFull, date ? new Date(date).toLocaleString() : null].filter(Boolean).join(' '),
346+
value: { ...s, commitId: commitFull },
347+
};
348+
})
349+
.filter(Boolean);
350+
return ce(
351+
Box,
352+
{ flexDirection: 'column' },
353+
ce(Box, { paddingX: 1 },
354+
ce(Breadcrumb, { parts: [selectedConnection.organizationName, selectedRepo.name] })),
355+
ce(SelectList, {
356+
title: 'Select a scan',
357+
items,
358+
onSelect: handleSelectScan,
359+
onBack: goBack[STEPS.SELECT_SCAN],
360+
emptyMessage: 'No scan history found for this repository.',
361+
})
362+
);
363+
}
364+
365+
if (step === STEPS.SELECT_RESULT_TYPE) {
366+
const items = RESULT_TYPES.map((rt) => ({
367+
label: rt.label,
368+
sublabel: rt.kind === 'basic' ? 'standard analysis'
369+
: rt.kind === 'advanced' ? 'advanced analysis'
370+
: 'dismissed',
371+
value: rt,
372+
}));
373+
return ce(
374+
Box,
375+
{ flexDirection: 'column' },
376+
ce(Box, { paddingX: 1 }, ce(Breadcrumb, { parts: breadcrumbParts })),
377+
ce(SelectList, {
378+
title: 'Select result type',
379+
items,
380+
onSelect: handleSelectResultType,
381+
onBack: goBack[STEPS.SELECT_RESULT_TYPE],
382+
})
383+
);
384+
}
385+
386+
if (step === STEPS.SHOWING_RESULTS) {
387+
const issues = results?.issues || [];
388+
const rtObj = RESULT_TYPES.find((r) => r.value === selectedResultType?.value);
389+
return ce(ResultsView, {
390+
issues,
391+
resultTypeLabel: rtObj?.label || selectedResultType?.value || '',
392+
breadcrumbParts,
393+
onBack: goBack[STEPS.SHOWING_RESULTS],
394+
});
395+
}
396+
397+
return null;
398+
}

0 commit comments

Comments
 (0)