mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: add mobile row layout to Security History tab (#1608)
Replace the desktop 8-column table with card-like mobile rows on phones, following the ImagesTab pattern. Each row shows a 44px checkbox for compare selection, a severity dot, truncated image ref with trigger and timestamp meta, count tags (total vulns + fixable, at most 2), and a chevron. Secret/config-only scans use the FINDINGS dot and suppress the clean tag, matching the getSeverityKey classification from severityStyles. Extract a shared HistoryStateMessage component to deduplicate the loading/error/empty state blocks between the mobile and desktop branches.
This commit is contained in:
@@ -9,10 +9,12 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { formatShortDigest } from '@/lib/formatDigest';
|
||||
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
|
||||
import { SeverityChip } from '../VulnerabilityScanSheet';
|
||||
import { ScanComparisonSheet } from '../ScanComparisonSheet';
|
||||
import { HistoryScanRow } from './SecurityMobile';
|
||||
import type { VulnerabilityScan, ScanDetailTab, VulnSeverity } from '@/types/security';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
@@ -59,6 +61,29 @@ interface HistoryTabProps {
|
||||
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
|
||||
}
|
||||
|
||||
/** Shared loading/error/empty state messages for the mobile and desktop branches. */
|
||||
function HistoryStateMessage({ loading, error, search, isEmpty }: {
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
search: string;
|
||||
isEmpty: boolean;
|
||||
}) {
|
||||
if (loading) {
|
||||
return <div className="py-12 text-center text-sm text-muted-foreground">Loading scan history...</div>;
|
||||
}
|
||||
if (error) {
|
||||
return <div className="py-12 text-center text-sm text-muted-foreground">Couldn't load scan history. Try again.</div>;
|
||||
}
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{search ? 'No scans match your search.' : 'No completed scans yet. Scan an image from the Images tab.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Inline scan-history table: search, sortable columns, two-scan compare, and
|
||||
* server-paginated completed scans. Replaces the former history sheet. */
|
||||
export function HistoryTab({ onInspect }: HistoryTabProps) {
|
||||
@@ -76,6 +101,7 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
|
||||
const [compareIds, setCompareIds] = useState<[number, number] | null>(null);
|
||||
const [sortKey, setSortKey] = useState<SortKey>('scanned_at');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
@@ -191,6 +217,20 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
{isMobile ? (
|
||||
<div className="px-4">
|
||||
{!loading && !error && sorted.map((scan) => (
|
||||
<HistoryScanRow
|
||||
key={scan.id}
|
||||
scan={scan}
|
||||
selected={selected.includes(scan.id)}
|
||||
onToggle={() => toggleSelect(scan.id)}
|
||||
onInspect={() => onInspect(scan.id, 'vulns')}
|
||||
/>
|
||||
))}
|
||||
<HistoryStateMessage loading={loading} error={error} search={search} isEmpty={sorted.length === 0} />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea block className="max-h-[60vh]">
|
||||
<Table className="max-md:min-w-[720px]">
|
||||
<TableHeader>
|
||||
@@ -239,14 +279,9 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{loading && <div className="py-12 text-center text-sm text-muted-foreground">Loading scan history...</div>}
|
||||
{!loading && error && <div className="py-12 text-center text-sm text-muted-foreground">Couldn't load scan history. Try again.</div>}
|
||||
{!loading && !error && sorted.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
{search ? 'No scans match your search.' : 'No completed scans yet. Scan an image from the Images tab.'}
|
||||
</div>
|
||||
)}
|
||||
<HistoryStateMessage loading={loading} error={error} search={search} isEmpty={sorted.length === 0} />
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
|
||||
@@ -6,10 +6,11 @@ import type { ReactNode } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Kicker, MobileSubTabs, MobileChipRow } from '@/components/mobile/mobile-ui';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { getSeverityKey, SEVERITY_DOT_CLASSES, type ImageFilterValue } from '@/lib/severityStyles';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { ScanSummary, SecurityOverview, ScanDetailTab } from '@/types/security';
|
||||
import type { ScanSummary, SecurityOverview, ScanDetailTab, VulnerabilityScan } from '@/types/security';
|
||||
|
||||
export interface SecurityMobileTab {
|
||||
value: SecurityTab;
|
||||
@@ -150,6 +151,61 @@ export function ImageScanRow({ summary, onInspect }: {
|
||||
);
|
||||
}
|
||||
|
||||
/** One scan-history row in the mobile History list: checkbox (44px touch target
|
||||
* for compare selection), severity dot, truncated mono ref over a freshness meta
|
||||
* line, trailing count tags (total vulns + fixable, max 2), and a chevron. */
|
||||
export function HistoryScanRow({ scan, selected, onToggle, onInspect }: {
|
||||
scan: VulnerabilityScan;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
onInspect: () => void;
|
||||
}) {
|
||||
// VulnerabilityScan has the same highest_severity / secret_count /
|
||||
// misconfig_count fields as ScanSummary but a different shape, so the
|
||||
// classification is inlined rather than cast through getSeverityKey.
|
||||
const hasNonVuln = scan.secret_count > 0 || scan.misconfig_count > 0;
|
||||
const severityKey = scan.highest_severity ?? (hasNonVuln ? 'FINDINGS' : 'CLEAN');
|
||||
|
||||
return (
|
||||
<div className="flex min-h-11 items-center gap-[11px] border-b border-hairline py-[11px] last:border-b-0">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={onToggle}
|
||||
aria-label="Select scan to compare"
|
||||
className="h-11 w-11"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInspect}
|
||||
className="flex min-h-11 flex-1 items-center gap-[11px] text-left min-w-0"
|
||||
>
|
||||
<span
|
||||
className={cn('h-[7px] w-[7px] shrink-0 rounded-full', SEVERITY_DOT_CLASSES[severityKey])}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-mono text-[13px] text-stat-value">
|
||||
{scan.image_ref}
|
||||
</span>
|
||||
<span className="mt-px block font-mono text-[10px] text-stat-icon">
|
||||
{scan.triggered_by} · scanned {formatTimeAgo(scan.scanned_at)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{scan.total_vulnerabilities > 0 && (
|
||||
<CountTag tone="destructive">{scan.total_vulnerabilities}</CountTag>
|
||||
)}
|
||||
{scan.fixable_count > 0 && (
|
||||
<CountTag tone="success">{scan.fixable_count} fixable</CountTag>
|
||||
)}
|
||||
{severityKey === 'CLEAN' && <CountTag tone="success">clean</CountTag>}
|
||||
</span>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-stat-icon" strokeWidth={1.5} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ImageFilterChip {
|
||||
value: ImageFilterValue;
|
||||
label: string;
|
||||
|
||||
@@ -66,6 +66,19 @@ function listResponse(items: VulnerabilityScan[], total?: number): Response {
|
||||
return { ok: true, status: 200, json: async () => ({ items, total: total ?? items.length }) } as unknown as Response;
|
||||
}
|
||||
|
||||
function installMatchMedia(matches: boolean) {
|
||||
window.matchMedia = vi.fn().mockReturnValue({
|
||||
matches,
|
||||
media: '',
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
compareProps.length = 0;
|
||||
@@ -162,3 +175,85 @@ describe('HistoryTab', () => {
|
||||
expect(screen.queryByText(/No completed scans yet/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HistoryTab (mobile)', () => {
|
||||
const original = window.matchMedia;
|
||||
afterEach(() => { window.matchMedia = original; vi.clearAllMocks(); });
|
||||
|
||||
it('renders mobile rows instead of a table', async () => {
|
||||
installMatchMedia(true);
|
||||
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
|
||||
// No table element, no column headers.
|
||||
expect(document.querySelector('table')).toBeNull();
|
||||
expect(screen.queryByText('Trigger')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the scan sheet from a mobile row tap', async () => {
|
||||
installMatchMedia(true);
|
||||
const onInspect = vi.fn();
|
||||
mockedFetch.mockResolvedValue(listResponse([scan({ id: 42, image_ref: 'nginx:1' })]));
|
||||
render(<HistoryTab onInspect={onInspect} />);
|
||||
await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument());
|
||||
// Click the row's content button (the inspect target), not the checkbox.
|
||||
const rowBtn = screen.getByRole('button', { name: /nginx:1/ });
|
||||
await userEvent.click(rowBtn);
|
||||
expect(onInspect).toHaveBeenCalledWith(42, 'vulns');
|
||||
});
|
||||
|
||||
it('selects two scans via mobile checkboxes and opens compare', async () => {
|
||||
installMatchMedia(true);
|
||||
const older = scan({ id: 10, image_ref: 'a:1', scanned_at: 1000 });
|
||||
const newer = scan({ id: 20, image_ref: 'b:1', scanned_at: 2000 });
|
||||
mockedFetch.mockResolvedValue(listResponse([newer, older]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('a:1')).toBeInTheDocument());
|
||||
const checks = screen.getAllByLabelText('Select scan to compare');
|
||||
await userEvent.click(checks[0]);
|
||||
await userEvent.click(checks[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Compare/ }));
|
||||
const last = compareProps[compareProps.length - 1];
|
||||
expect(last.baselineScanId).toBe(10);
|
||||
expect(last.currentScanId).toBe(20);
|
||||
});
|
||||
|
||||
it('does not show clean for a secret-only scan', async () => {
|
||||
installMatchMedia(true);
|
||||
mockedFetch.mockResolvedValue(listResponse([scan({
|
||||
image_ref: 'secret-img:1',
|
||||
total_vulnerabilities: 0,
|
||||
secret_count: 2,
|
||||
misconfig_count: 0,
|
||||
highest_severity: null,
|
||||
})]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('secret-img:1')).toBeInTheDocument());
|
||||
expect(screen.queryByText('clean')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows clean tag for a truly clean scan', async () => {
|
||||
installMatchMedia(true);
|
||||
mockedFetch.mockResolvedValue(listResponse([scan({
|
||||
image_ref: 'clean-img:1',
|
||||
total_vulnerabilities: 0,
|
||||
fixable_count: 0,
|
||||
secret_count: 0,
|
||||
misconfig_count: 0,
|
||||
highest_severity: null,
|
||||
})]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('clean-img:1')).toBeInTheDocument());
|
||||
expect(screen.getByText('clean')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders checkbox with 44px touch target', async () => {
|
||||
installMatchMedia(true);
|
||||
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'nginx:1' })]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('nginx:1')).toBeInTheDocument());
|
||||
const checkbox = screen.getByLabelText('Select scan to compare');
|
||||
expect(checkbox.className).toContain('h-11');
|
||||
expect(checkbox.className).toContain('w-11');
|
||||
});
|
||||
});
|
||||
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
- generic [ref=f1e3]:
|
||||
- generic [ref=f1e4]:
|
||||
- generic [ref=f1e7]:
|
||||
- generic [ref=f1e8]: Sencho
|
||||
- generic [ref=f1e9]: v0.94.1
|
||||
- button "Switch node" [ref=f1e11]:
|
||||
- generic [ref=f1e14] [cursor=pointer]:
|
||||
- generic [ref=f1e15]: Node · LOCAL
|
||||
- generic [ref=f1e16]: Local
|
||||
- generic [ref=f1e20]:
|
||||
- button "Create Stack" [ref=f1e22]
|
||||
- button [ref=f1e23]
|
||||
- button [ref=f1e24]
|
||||
- generic [ref=f1e25]:
|
||||
- combobox [expanded] [ref=f1e32]
|
||||
- generic [ref=f1e33]:
|
||||
- generic [ref=f1e34]:
|
||||
- button "All 1" [pressed] [ref=f1e35]:
|
||||
- text: All
|
||||
- generic [ref=f1e36]: "1"
|
||||
- button "Up 1" [ref=f1e37]:
|
||||
- text: Up
|
||||
- generic [ref=f1e38]: "1"
|
||||
- button "Down 0" [ref=f1e39]:
|
||||
- text: Down
|
||||
- generic [ref=f1e40]: "0"
|
||||
- button "Updates 0" [ref=f1e41]:
|
||||
- text: Updates
|
||||
- generic [ref=f1e42]: "0"
|
||||
- button "Hide filters" [ref=f1e43]
|
||||
- listbox "Suggestions" [ref=f1e49]:
|
||||
- generic [ref=f1e51]:
|
||||
- button "UNLABELED 1" [expanded] [ref=f1e52]:
|
||||
- generic [ref=f1e53]: UNLABELED
|
||||
- generic [ref=f1e54]: "1"
|
||||
- option [selected] [ref=f1e59]:
|
||||
- button "UP bookstack" [ref=f1e60] [cursor=pointer]:
|
||||
- generic [ref=f1e61]: UP
|
||||
- generic [ref=f1e62]: bookstack
|
||||
- button [ref=f1e65]
|
||||
- button "Live · no stack changes in 1h LIVE · OPEN ACTIVITY →" [ref=f1e66] [cursor=pointer]:
|
||||
- generic [ref=f1e67]: Live · no stack changes in 1h
|
||||
- generic [ref=f1e70]: LIVE · OPEN ACTIVITY →
|
||||
- generic [ref=f1e71]:
|
||||
- generic [ref=f1e72]:
|
||||
- navigation "Primary" [ref=f1e73]:
|
||||
- button "Home" [ref=f1e74]
|
||||
- button "Fleet" [ref=f1e79]
|
||||
- button "Resources" [ref=f1e88]
|
||||
- button "Security" [ref=f1e92]
|
||||
- button "App Store" [ref=f1e97]
|
||||
- button "Logs" [ref=f1e103]
|
||||
- button "Update" [ref=f1e107]
|
||||
- button "Schedules" [ref=f1e114]
|
||||
- generic [ref=f1e119]:
|
||||
- button "Open search (Ctrl+K)" [ref=f1e120]
|
||||
- button "Theme" [ref=f1e124]
|
||||
- button "Notifications" [ref=f1e125]
|
||||
- button "Profile" [ref=f1e126]: BO
|
||||
- generic [ref=f1e128]:
|
||||
- generic [ref=f1e132]:
|
||||
- generic [ref=f1e133]:
|
||||
- generic [ref=f1e134]:
|
||||
- generic [ref=f1e135]: Action needed
|
||||
- generic [ref=f1e137]:
|
||||
- generic [ref=f1e138]: "2 actions: fixable findings, publicly exposed affected images ·"
|
||||
- generic [ref=f1e139]: 10 images scanned · scanner ready
|
||||
- button "Update affected images →" [ref=f1e144]
|
||||
- generic [ref=f1e145]:
|
||||
- generic [ref=f1e146]:
|
||||
- generic [ref=f1e147]: CRITICAL
|
||||
- generic [ref=f1e148]: "0"
|
||||
- generic [ref=f1e149]:
|
||||
- generic [ref=f1e150]: HIGH
|
||||
- generic [ref=f1e151]: "31"
|
||||
- generic [ref=f1e152]:
|
||||
- generic [ref=f1e153]: LAST SCAN
|
||||
- generic [ref=f1e154]: 23h ago
|
||||
- generic [ref=f1e155]:
|
||||
- tablist [ref=f1e157]:
|
||||
- tab "Overview" [ref=f1e159]
|
||||
- tab "Images" [active] [selected] [ref=f1e166]
|
||||
- tab "Compose risks" [ref=f1e178]
|
||||
- tab "Secrets" [ref=f1e182]
|
||||
- tab "Policies" [ref=f1e188]
|
||||
- tab "Suppressions" [ref=f1e193]
|
||||
- tab "History" [ref=f1e200]
|
||||
- tab "Scanner setup" [ref=f1e206]
|
||||
- tabpanel "Images" [ref=f1e314]:
|
||||
- generic [ref=f1e315]:
|
||||
- generic [ref=f1e316]:
|
||||
- textbox "Search images..." [ref=f1e321]
|
||||
- combobox [ref=f1e323]:
|
||||
- generic [ref=f1e324]: All severities
|
||||
- table [ref=f1e333]:
|
||||
- rowgroup [ref=f1e334]:
|
||||
- row [ref=f1e335]:
|
||||
- columnheader [ref=f1e336] [cursor=pointer]:
|
||||
- button "Image" [ref=f1e337]
|
||||
- columnheader [ref=f1e338] [cursor=pointer]:
|
||||
- button "Findings" [ref=f1e339]
|
||||
- columnheader [ref=f1e340] [cursor=pointer]:
|
||||
- button "Last scan" [ref=f1e341]
|
||||
- columnheader [ref=f1e344] [cursor=pointer]:
|
||||
- button "Severity" [ref=f1e345]
|
||||
- columnheader "Actions" [ref=f1e346]
|
||||
- rowgroup [ref=f1e347]:
|
||||
- row [ref=f1e348]:
|
||||
- cell [ref=f1e349]:
|
||||
- button "lscr.io/linuxserver/bookstack:latest" [ref=f1e350]
|
||||
- cell [ref=f1e351]:
|
||||
- button "6H14 fixable" [ref=f1e352]:
|
||||
- generic [ref=f1e353]: 6H
|
||||
- generic [ref=f1e354]: 14 fixable
|
||||
- cell "23h ago" [ref=f1e355]
|
||||
- cell [ref=f1e356]:
|
||||
- button "HIGH" [ref=f1e357] [cursor=pointer]
|
||||
- cell [ref=f1e359]:
|
||||
- button "Scan lscr.io/linuxserver/bookstack:latest" [ref=f1e360]
|
||||
- row [ref=f1e361]:
|
||||
- cell [ref=f1e362]:
|
||||
- button "lscr.io/linuxserver/bookstack@sha256:13dc508fb46aa7d2b726b6c872e4903892bda68391ac948a5603d3ca60f0937e" [ref=f1e363]
|
||||
- cell [ref=f1e364]:
|
||||
- button "6H14 fixable" [ref=f1e365]:
|
||||
- generic [ref=f1e366]: 6H
|
||||
- generic [ref=f1e367]: 14 fixable
|
||||
- cell "23h ago" [ref=f1e368]
|
||||
- cell [ref=f1e369]:
|
||||
- button "HIGH" [ref=f1e370] [cursor=pointer]
|
||||
- cell [ref=f1e372]:
|
||||
- button "Scan lscr.io/linuxserver/bookstack@sha256:13dc508fb46aa7d2b726b6c872e4903892bda68391ac948a5603d3ca60f0937e" [ref=f1e373]
|
||||
- row [ref=f1e374]:
|
||||
- cell [ref=f1e375]:
|
||||
- button "lscr.io/linuxserver/bazarr:latest" [ref=f1e376]
|
||||
- cell [ref=f1e377]:
|
||||
- button "2H1 fixable" [ref=f1e378]:
|
||||
- generic [ref=f1e379]: 2H
|
||||
- generic [ref=f1e380]: 1 fixable
|
||||
- cell "23h ago" [ref=f1e381]
|
||||
- cell [ref=f1e382]:
|
||||
- button "HIGH" [ref=f1e383] [cursor=pointer]
|
||||
- cell [ref=f1e385]:
|
||||
- button "Scan lscr.io/linuxserver/bazarr:latest" [ref=f1e386]
|
||||
- row [ref=f1e387]:
|
||||
- cell [ref=f1e388]:
|
||||
- button "lscr.io/linuxserver/jackett:latest" [ref=f1e389]
|
||||
- cell [ref=f1e390]:
|
||||
- button "1H1 fixable" [ref=f1e391]:
|
||||
- generic [ref=f1e392]: 1H
|
||||
- generic [ref=f1e393]: 1 fixable
|
||||
- cell "23h ago" [ref=f1e394]
|
||||
- cell [ref=f1e395]:
|
||||
- button "HIGH" [ref=f1e396] [cursor=pointer]
|
||||
- cell [ref=f1e398]:
|
||||
- button "Scan lscr.io/linuxserver/jackett:latest" [ref=f1e399]
|
||||
- row [ref=f1e400]:
|
||||
- cell [ref=f1e401]:
|
||||
- button "saelix/sencho:pr-1603" [ref=f1e402]
|
||||
- cell [ref=f1e403]:
|
||||
- button "3H4 fixable" [ref=f1e404]:
|
||||
- generic [ref=f1e405]: 3H
|
||||
- generic [ref=f1e406]: 4 fixable
|
||||
- cell "23h ago" [ref=f1e407]
|
||||
- cell [ref=f1e408]:
|
||||
- button "HIGH" [ref=f1e409] [cursor=pointer]
|
||||
- cell [ref=f1e411]:
|
||||
- button "Scan saelix/sencho:pr-1603" [ref=f1e412]
|
||||
- row [ref=f1e413]:
|
||||
- cell [ref=f1e414]:
|
||||
- button "lscr.io/linuxserver/prowlarr:latest" [ref=f1e415]
|
||||
- cell [ref=f1e416]:
|
||||
- button "1H1 fixable" [ref=f1e417]:
|
||||
- generic [ref=f1e418]: 1H
|
||||
- generic [ref=f1e419]: 1 fixable
|
||||
- cell "23h ago" [ref=f1e420]
|
||||
- cell [ref=f1e421]:
|
||||
- button "HIGH" [ref=f1e422] [cursor=pointer]
|
||||
- cell [ref=f1e424]:
|
||||
- button "Scan lscr.io/linuxserver/prowlarr:latest" [ref=f1e425]
|
||||
- row [ref=f1e426]:
|
||||
- cell [ref=f1e427]:
|
||||
- button "saelix/sencho:pr-1600" [ref=f1e428]
|
||||
- cell [ref=f1e429]:
|
||||
- button "3H4 fixable" [ref=f1e430]:
|
||||
- generic [ref=f1e431]: 3H
|
||||
- generic [ref=f1e432]: 4 fixable
|
||||
- cell "23h ago" [ref=f1e433]
|
||||
- cell [ref=f1e434]:
|
||||
- button "HIGH" [ref=f1e435] [cursor=pointer]
|
||||
- cell [ref=f1e437]:
|
||||
- button "Scan saelix/sencho:pr-1600" [ref=f1e438]
|
||||
- row [ref=f1e439]:
|
||||
- cell [ref=f1e440]:
|
||||
- button "saelix/sencho:pr-1609" [ref=f1e441]
|
||||
- cell [ref=f1e442]:
|
||||
- button "3H4 fixable" [ref=f1e443]:
|
||||
- generic [ref=f1e444]: 3H
|
||||
- generic [ref=f1e445]: 4 fixable
|
||||
- cell "23h ago" [ref=f1e446]
|
||||
- cell [ref=f1e447]:
|
||||
- button "HIGH" [ref=f1e448] [cursor=pointer]
|
||||
- cell [ref=f1e450]:
|
||||
- button "Scan saelix/sencho:pr-1609" [ref=f1e451]
|
||||
- row [ref=f1e452]:
|
||||
- cell [ref=f1e453]:
|
||||
- button "saelix/sencho:pr-1608" [ref=f1e454]
|
||||
- cell [ref=f1e455]:
|
||||
- button "3H4 fixable" [ref=f1e456]:
|
||||
- generic [ref=f1e457]: 3H
|
||||
- generic [ref=f1e458]: 4 fixable
|
||||
- cell "23h ago" [ref=f1e459]
|
||||
- cell [ref=f1e460]:
|
||||
- button "HIGH" [ref=f1e461] [cursor=pointer]
|
||||
- cell [ref=f1e463]:
|
||||
- button "Scan saelix/sencho:pr-1608" [ref=f1e464]
|
||||
- row [ref=f1e465]:
|
||||
- cell [ref=f1e466]:
|
||||
- button "saelix/sencho:pr-1610" [ref=f1e467]
|
||||
- cell [ref=f1e468]:
|
||||
- button "3H4 fixable" [ref=f1e469]:
|
||||
- generic [ref=f1e470]: 3H
|
||||
- generic [ref=f1e471]: 4 fixable
|
||||
- cell "23h ago" [ref=f1e472]
|
||||
- cell [ref=f1e473]:
|
||||
- button "HIGH" [ref=f1e474] [cursor=pointer]
|
||||
- cell [ref=f1e476]:
|
||||
- button "Scan saelix/sencho:pr-1610" [ref=f1e477]
|
||||
Reference in New Issue
Block a user