Add multi-metric note selection to compliance detail panel

This commit is contained in:
jramos
2026-04-16 14:28:44 -06:00
parent e1b0236874
commit f141fa58a1
7 changed files with 684 additions and 57 deletions

View File

@@ -42,7 +42,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [noteText, setNoteText] = useState('');
const [noteMetric, setNoteMetric] = useState('');
const [selectedMetrics, setSelectedMetrics] = useState([]);
const [submitting, setSubmitting] = useState(false);
const [noteError, setNoteError] = useState(null);
@@ -55,9 +55,9 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
if (!res.ok) throw new Error(data.error || 'Failed to load device');
setDetail(data);
// Default note metric to first active failing metric
// Default selected metrics to first active failing metric
const firstActive = (data.metrics || []).find(m => m.status === 'active');
if (firstActive) setNoteMetric(firstActive.metric_id);
if (firstActive) setSelectedMetrics([firstActive.metric_id]);
} catch (err) {
setError(err.message);
} finally {
@@ -68,7 +68,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
useEffect(() => { fetchDetail(); }, [fetchDetail]);
const handleAddNote = async () => {
if (!noteText.trim() || !noteMetric) return;
if (!noteText.trim() || selectedMetrics.length === 0) return;
setSubmitting(true);
setNoteError(null);
try {
@@ -76,7 +76,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostname, metric_id: noteMetric, note: noteText.trim() }),
body: JSON.stringify({ hostname, metric_ids: selectedMetrics, note: noteText.trim() }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save note');
@@ -194,39 +194,115 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
{detail.notes.length === 0 && (
<div style={{ color: '#334155', fontSize: '0.75rem', fontStyle: 'italic', marginBottom: '0.75rem' }}>No notes yet</div>
)}
{detail.notes.map(n => (
<div key={n.id} style={{
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
border: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
<MetricChip metricId={n.metric_id} category={activeMetrics.find(m => m.metric_id === n.metric_id)?.category || ''} />
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>
{n.created_by && `${n.created_by} · `}{n.created_at?.slice(0, 10)}
</span>
{(() => {
// Build a lookup map for metric categories (active + resolved)
const metricMap = {};
(detail.metrics || []).forEach(m => { metricMap[m.metric_id] = m.category; });
// Group notes by group_id, preserving reverse chronological order
const grouped = [];
const seen = new Set();
// detail.notes is already sorted newest-first from the API
for (const n of detail.notes) {
const gid = n.group_id;
if (!gid) {
// Legacy note without group_id — render individually
grouped.push({ key: `note-${n.id}`, notes: [n], note: n.note, created_by: n.created_by, created_at: n.created_at });
} else if (!seen.has(gid)) {
seen.add(gid);
const group = detail.notes.filter(x => x.group_id === gid);
grouped.push({ key: `group-${gid}`, notes: group, note: group[0].note, created_by: group[0].created_by, created_at: group[0].created_at });
}
}
return grouped.map(g => (
<div key={g.key} style={{
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
border: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.3rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{g.notes.map(n => (
<MetricChip key={n.id} metricId={n.metric_id} category={metricMap[n.metric_id] || ''} />
))}
</div>
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace', flexShrink: 0, marginLeft: '0.5rem', whiteSpace: 'nowrap' }}>
{g.created_by && `${g.created_by} · `}{g.created_at?.slice(0, 10)}
</span>
</div>
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{g.note}</div>
</div>
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{n.note}</div>
</div>
))}
));
})()}
{/* Add note */}
<div style={{ marginTop: 'auto', paddingTop: '0.75rem', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
{activeMetrics.length > 1 && (
<select
value={noteMetric}
onChange={e => setNoteMetric(e.target.value)}
style={{
width: '100%', marginBottom: '0.5rem',
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
borderRadius: '0.25rem', color: '#CBD5E1',
padding: '0.4rem 0.5rem', fontSize: '0.75rem', fontFamily: 'monospace',
}}>
{activeMetrics.map(m => (
<option key={m.metric_id} value={m.metric_id}>{m.metric_id} {m.category}</option>
))}
</select>
)}
{activeMetrics.length > 1 && (() => {
const allSelected = activeMetrics.length > 0 && activeMetrics.every(m => selectedMetrics.includes(m.metric_id));
return (
<div style={{ marginBottom: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569' }}>
Metrics
</span>
<button
onClick={() => {
if (allSelected) {
setSelectedMetrics([activeMetrics[0].metric_id]);
} else {
setSelectedMetrics(activeMetrics.map(m => m.metric_id));
}
}}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: '0.68rem', fontFamily: 'monospace',
color: TEAL, padding: 0,
transition: 'opacity 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
{allSelected ? 'Deselect All' : 'Select All'}
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
{activeMetrics.map(m => {
const isSelected = selectedMetrics.includes(m.metric_id);
const color = categoryColor(m.category);
return (
<button
key={m.metric_id}
onClick={() => {
if (isSelected) {
if (selectedMetrics.length > 1) {
setSelectedMetrics(selectedMetrics.filter(id => id !== m.metric_id));
}
} else {
setSelectedMetrics([...selectedMetrics, m.metric_id]);
}
}}
style={{
display: 'inline-flex', alignItems: 'center',
padding: '0.25rem 0.5rem',
background: isSelected ? `${color}25` : `${color}08`,
border: `1px solid ${isSelected ? `${color}90` : `${color}30`}`,
borderRadius: '0.25rem',
color: isSelected ? color : `${color}90`,
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
cursor: (isSelected && selectedMetrics.length === 1) ? 'default' : 'pointer',
transition: 'all 0.15s',
opacity: (isSelected && selectedMetrics.length === 1) ? 0.85 : 1,
}}
>
{m.metric_id}
</button>
);
})}
</div>
</div>
);
})()}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<textarea
value={noteText}
@@ -244,13 +320,13 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAddNote(); }}
/>
<button onClick={handleAddNote} disabled={!noteText.trim() || submitting}
<button onClick={handleAddNote} disabled={!noteText.trim() || selectedMetrics.length === 0 || submitting}
style={{
padding: '0.5rem 0.625rem', flexShrink: 0,
background: noteText.trim() ? `${TEAL}20` : 'transparent',
border: `1px solid ${noteText.trim() ? TEAL : 'rgba(20,184,166,0.2)'}`,
borderRadius: '0.375rem', color: noteText.trim() ? TEAL : '#334155',
cursor: noteText.trim() ? 'pointer' : 'default', transition: 'all 0.15s',
background: (noteText.trim() && selectedMetrics.length > 0) ? `${TEAL}20` : 'transparent',
border: `1px solid ${(noteText.trim() && selectedMetrics.length > 0) ? TEAL : 'rgba(20,184,166,0.2)'}`,
borderRadius: '0.375rem', color: (noteText.trim() && selectedMetrics.length > 0) ? TEAL : '#334155',
cursor: (noteText.trim() && selectedMetrics.length > 0) ? 'pointer' : 'default', transition: 'all 0.15s',
}}>
{submitting
? <Loader style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />