snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
158
docs/site/src/components/SecurityUpdates/feed.ts
Normal file
158
docs/site/src/components/SecurityUpdates/feed.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import {parseAffectedLines, type VersionRange} from './versions';
|
||||
|
||||
export const FEED_URL = 'https://securityupdates.mattermost.com/security_updates.json';
|
||||
|
||||
export type TabId = 'server' | 'desktop' | 'mobile' | 'plugins';
|
||||
|
||||
export type SecurityUpdateRow = {
|
||||
issueId: string;
|
||||
cveId?: string;
|
||||
severity: string;
|
||||
affectedVersions: string[];
|
||||
affectedRanges: VersionRange[];
|
||||
releaseDate: string;
|
||||
fixVersions: string[];
|
||||
details: string;
|
||||
};
|
||||
|
||||
export type SecurityUpdateTab = {
|
||||
id: TabId;
|
||||
label: string;
|
||||
rows: SecurityUpdateRow[];
|
||||
};
|
||||
|
||||
export const TAB_ORDER: {id: TabId; label: string}[] = [
|
||||
{id: 'server', label: 'Server'},
|
||||
{id: 'desktop', label: 'Desktop'},
|
||||
{id: 'mobile', label: 'Mobile'},
|
||||
{id: 'plugins', label: 'Plugins'},
|
||||
];
|
||||
|
||||
const PLATFORM_MAP: Record<string, TabId> = {
|
||||
'Mattermost Server': 'server',
|
||||
'Mattermost Desktop App': 'desktop',
|
||||
'Mattermost Mobile Apps': 'mobile',
|
||||
'Mattermost Plugins': 'plugins',
|
||||
};
|
||||
|
||||
type FeedItem = {
|
||||
issue_id?: string;
|
||||
cve_id?: string;
|
||||
severity?: string;
|
||||
affected_versions?: unknown;
|
||||
fix_release_date?: string;
|
||||
fix_versions?: unknown;
|
||||
details?: string;
|
||||
platform?: string;
|
||||
};
|
||||
|
||||
function normalizeVersions(value: unknown): string[] {
|
||||
if (value == null || value === '') {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry) => normalizeVersions(entry)).map((entry) => entry.trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
return normalizeVersions(JSON.parse(trimmed));
|
||||
} catch {
|
||||
/* keep as plain string */
|
||||
}
|
||||
}
|
||||
return [trimmed];
|
||||
}
|
||||
return [String(value)];
|
||||
}
|
||||
|
||||
function normalizeSeverity(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || /^n\/?a$/i.test(trimmed)) {
|
||||
return 'N/A';
|
||||
}
|
||||
return trimmed.replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function parseIssueParts(issueId: string) {
|
||||
const parts = issueId.split('-');
|
||||
return {
|
||||
year: Number.parseInt(parts[1] || '', 10) || 0,
|
||||
num: Number.parseInt(parts[2] || '', 10) || 0,
|
||||
extra: Number.parseInt(parts[3] || '', 10) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toRow(item: FeedItem): SecurityUpdateRow {
|
||||
const cveId = String(item.cve_id || '').trim();
|
||||
const affectedVersions = normalizeVersions(item.affected_versions);
|
||||
const fixVersions = normalizeVersions(item.fix_versions);
|
||||
return {
|
||||
issueId: String(item.issue_id || '').trim(),
|
||||
...(cveId ? {cveId} : {}),
|
||||
severity: normalizeSeverity(String(item.severity || '')),
|
||||
affectedVersions,
|
||||
affectedRanges: parseAffectedLines(affectedVersions, fixVersions),
|
||||
releaseDate: String(item.fix_release_date || '').slice(0, 10),
|
||||
fixVersions,
|
||||
details: String(item.details || '').replace(/\s+/g, ' ').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function compareFeedItems(a: FeedItem, b: FeedItem): number {
|
||||
const dateA = Date.parse(String(a.fix_release_date || '').slice(0, 10)) || 0;
|
||||
const dateB = Date.parse(String(b.fix_release_date || '').slice(0, 10)) || 0;
|
||||
if (dateA !== dateB) {
|
||||
return dateB - dateA;
|
||||
}
|
||||
const pa = parseIssueParts(String(a.issue_id || ''));
|
||||
const pb = parseIssueParts(String(b.issue_id || ''));
|
||||
if (pa.year !== pb.year) {
|
||||
return pb.year - pa.year;
|
||||
}
|
||||
if (pa.num !== pb.num) {
|
||||
return pb.num - pa.num;
|
||||
}
|
||||
return pb.extra - pa.extra;
|
||||
}
|
||||
|
||||
export function normalizeFeed(feed: unknown): SecurityUpdateTab[] {
|
||||
if (!Array.isArray(feed)) {
|
||||
throw new Error('Unexpected feed shape: expected an array of advisories.');
|
||||
}
|
||||
|
||||
const tabs = Object.fromEntries(
|
||||
TAB_ORDER.map((tab) => [tab.id, {...tab, rows: [] as SecurityUpdateRow[]}]),
|
||||
) as Record<TabId, SecurityUpdateTab>;
|
||||
|
||||
[...(feed as FeedItem[])]
|
||||
.sort(compareFeedItems)
|
||||
.forEach((item) => {
|
||||
const tabId = PLATFORM_MAP[String(item.platform || '').trim()];
|
||||
if (!tabId) {
|
||||
return;
|
||||
}
|
||||
tabs[tabId].rows.push(toRow(item));
|
||||
});
|
||||
|
||||
return TAB_ORDER.map((tab) => tabs[tab.id]);
|
||||
}
|
||||
|
||||
export async function fetchSecurityUpdates(): Promise<{
|
||||
tabs: SecurityUpdateTab[];
|
||||
lastModified: string | null;
|
||||
}> {
|
||||
const response = await fetch(FEED_URL, {cache: 'no-store'});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Security updates feed returned ${response.status}.`);
|
||||
}
|
||||
const payload: unknown = await response.json();
|
||||
return {
|
||||
tabs: normalizeFeed(payload),
|
||||
lastModified: response.headers.get('Last-Modified'),
|
||||
};
|
||||
}
|
||||
608
docs/site/src/components/SecurityUpdates/index.tsx
Normal file
608
docs/site/src/components/SecurityUpdates/index.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import React, {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react';
|
||||
|
||||
import {
|
||||
fetchSecurityUpdates,
|
||||
TAB_ORDER,
|
||||
type SecurityUpdateRow,
|
||||
type SecurityUpdateTab,
|
||||
type TabId,
|
||||
} from './feed';
|
||||
import styles from './styles.module.css';
|
||||
import {labelToVersion, rowMatchesVersionRange, type Version} from './versions';
|
||||
|
||||
type SortKey = 'issueId' | 'severity' | 'affectedVersions' | 'releaseDate' | 'fixVersions' | 'details';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
const PAGE_SIZES = [10, 25, 50, 100];
|
||||
const SEVERITY_OPTIONS = ['Critical', 'High', 'Medium', 'Low', 'N/A'] as const;
|
||||
const SEVERITY_RANK: Record<string, number> = {
|
||||
Critical: 4,
|
||||
High: 3,
|
||||
Medium: 2,
|
||||
Low: 1,
|
||||
'N/A': 0,
|
||||
};
|
||||
|
||||
const VERSION_FILTER_DEBOUNCE_MS = 250;
|
||||
|
||||
const EMPTY_FILTERS = {
|
||||
issueId: '',
|
||||
severity: '',
|
||||
affectedVersion: '',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
fixVersions: '',
|
||||
search: '',
|
||||
};
|
||||
|
||||
function parseIssueSort(issueId: string): number[] {
|
||||
const parts = issueId.split('-');
|
||||
return [
|
||||
Number.parseInt(parts[1] || '', 10) || 0,
|
||||
Number.parseInt(parts[2] || '', 10) || 0,
|
||||
Number.parseInt(parts[3] || '', 10) || 0,
|
||||
];
|
||||
}
|
||||
|
||||
function firstVersionLabel(lines: string[]): string {
|
||||
return lines[0] || '';
|
||||
}
|
||||
|
||||
function sortRows(rows: SecurityUpdateRow[], key: SortKey, dir: SortDir): SecurityUpdateRow[] {
|
||||
const copy = [...rows];
|
||||
copy.sort((a, b) => {
|
||||
let result = 0;
|
||||
if (key === 'issueId') {
|
||||
const pa = parseIssueSort(a.issueId);
|
||||
const pb = parseIssueSort(b.issueId);
|
||||
result = pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2] || a.issueId.localeCompare(b.issueId);
|
||||
} else if (key === 'severity') {
|
||||
result = (SEVERITY_RANK[a.severity] ?? -1) - (SEVERITY_RANK[b.severity] ?? -1);
|
||||
} else if (key === 'releaseDate') {
|
||||
result = (a.releaseDate || '').localeCompare(b.releaseDate || '');
|
||||
} else if (key === 'affectedVersions') {
|
||||
result = firstVersionLabel(a.affectedVersions).localeCompare(firstVersionLabel(b.affectedVersions));
|
||||
} else if (key === 'fixVersions') {
|
||||
result = firstVersionLabel(a.fixVersions).localeCompare(firstVersionLabel(b.fixVersions));
|
||||
} else {
|
||||
result = a.details.localeCompare(b.details);
|
||||
}
|
||||
return dir === 'asc' ? result : -result;
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
function rowMatchesFilters(
|
||||
row: SecurityUpdateRow,
|
||||
filters: typeof EMPTY_FILTERS,
|
||||
minV: Version | null,
|
||||
maxV: Version | null,
|
||||
): boolean {
|
||||
const search = filters.search.trim().toLowerCase();
|
||||
if (search) {
|
||||
const haystack = [
|
||||
row.issueId,
|
||||
row.cveId,
|
||||
row.severity,
|
||||
row.releaseDate,
|
||||
row.details,
|
||||
...row.affectedVersions,
|
||||
...row.fixVersions,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
if (!haystack.includes(search)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.issueId.trim()) {
|
||||
const q = filters.issueId.trim().toLowerCase();
|
||||
const issueHay = `${row.issueId} ${row.cveId || ''}`.toLowerCase();
|
||||
if (!issueHay.includes(q)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.severity && row.severity !== filters.severity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.fixVersions.trim()) {
|
||||
const q = filters.fixVersions.trim().toLowerCase();
|
||||
if (!row.fixVersions.some((line) => line.toLowerCase().includes(q))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.dateFrom && row.releaseDate && row.releaseDate < filters.dateFrom) {
|
||||
return false;
|
||||
}
|
||||
if (filters.dateTo && row.releaseDate && row.releaseDate > filters.dateTo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!rowMatchesVersionRange(row.affectedRanges, minV, maxV)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildPagination(current: number, total: number): (number | '…')[] {
|
||||
if (total <= 7) {
|
||||
return Array.from({length: total}, (_, i) => i + 1);
|
||||
}
|
||||
const pages: (number | '…')[] = [1];
|
||||
const start = Math.max(2, current - 2);
|
||||
const end = Math.min(total - 1, current + 2);
|
||||
if (start > 2) {
|
||||
pages.push('…');
|
||||
}
|
||||
for (let page = start; page <= end; page += 1) {
|
||||
pages.push(page);
|
||||
}
|
||||
if (end < total - 1) {
|
||||
pages.push('…');
|
||||
}
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
function VersionList({lines}: {lines: string[]}) {
|
||||
if (lines.length === 0) {
|
||||
return <span className={styles.muted}>—</span>;
|
||||
}
|
||||
return (
|
||||
<ul className={styles.versionList}>
|
||||
{lines.map((line, index) => (
|
||||
<li key={`${line}-${index}`}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function rowKey(row: SecurityUpdateRow): string {
|
||||
return `${row.issueId}|${row.releaseDate}|${row.affectedVersions.join(';')}|${row.fixVersions.join(';')}`;
|
||||
}
|
||||
|
||||
export default function SecurityUpdates() {
|
||||
const formId = useId();
|
||||
const [status, setStatus] = useState<'loading' | 'error' | 'ready'>('loading');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tabs, setTabs] = useState<SecurityUpdateTab[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<TabId>('server');
|
||||
const [filters, setFilters] = useState(EMPTY_FILTERS);
|
||||
const [versionDraft, setVersionDraft] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('releaseDate');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const loadGeneration = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const generation = (loadGeneration.current += 1);
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchSecurityUpdates();
|
||||
if (generation !== loadGeneration.current) {
|
||||
return;
|
||||
}
|
||||
setTabs(result.tabs);
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
if (generation !== loadGeneration.current) {
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Unable to load security updates.');
|
||||
setStatus('error');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const activeRows = useMemo(
|
||||
() => tabs.find((tab) => tab.id === activeTab)?.rows ?? [],
|
||||
[tabs, activeTab],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setFilters((prev) => {
|
||||
if (prev.affectedVersion === versionDraft) {
|
||||
return prev;
|
||||
}
|
||||
return {...prev, affectedVersion: versionDraft};
|
||||
});
|
||||
}, VERSION_FILTER_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [versionDraft]);
|
||||
|
||||
const dateOrderError = useMemo(() => {
|
||||
if (filters.dateFrom && filters.dateTo && filters.dateFrom > filters.dateTo) {
|
||||
return 'Fix released to must be on or after fix released from.';
|
||||
}
|
||||
return null;
|
||||
}, [filters.dateFrom, filters.dateTo]);
|
||||
|
||||
const affectedVersionError = useMemo(() => {
|
||||
const trimmed = filters.affectedVersion.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (!labelToVersion(trimmed, 'min')) {
|
||||
return 'Enter a version such as 10.11 or 10.11.23.';
|
||||
}
|
||||
return null;
|
||||
}, [filters.affectedVersion]);
|
||||
|
||||
const filterError = affectedVersionError || dateOrderError;
|
||||
const filterErrorId = `${formId}-filter-error`;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (filterError) {
|
||||
return [];
|
||||
}
|
||||
const minV = labelToVersion(filters.affectedVersion, 'min');
|
||||
const maxV = labelToVersion(filters.affectedVersion, 'max');
|
||||
return sortRows(
|
||||
activeRows.filter((row) => rowMatchesFilters(row, filters, minV, maxV)),
|
||||
sortKey,
|
||||
sortDir,
|
||||
);
|
||||
}, [activeRows, filters, sortKey, sortDir, filterError]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [activeTab, filters, pageSize, sortKey, sortDir]);
|
||||
|
||||
const hasActiveFilters = useMemo(
|
||||
() =>
|
||||
Object.values(filters).some((value) => value.trim() !== '') || versionDraft.trim() !== '',
|
||||
[filters, versionDraft],
|
||||
);
|
||||
|
||||
const setFilter = (key: keyof typeof EMPTY_FILTERS, value: string) => {
|
||||
setFilters((prev) => ({...prev, [key]: value}));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters(EMPTY_FILTERS);
|
||||
setVersionDraft('');
|
||||
};
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
return;
|
||||
}
|
||||
setSortKey(key);
|
||||
setSortDir(key === 'releaseDate' || key === 'severity' || key === 'issueId' ? 'desc' : 'asc');
|
||||
};
|
||||
|
||||
const sortProps = (key: SortKey) => ({
|
||||
'aria-sort': sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none',
|
||||
}) as const;
|
||||
|
||||
const renderSortButton = (key: SortKey, label: string) => {
|
||||
const direction =
|
||||
sortKey === key ? (sortDir === 'asc' ? 'sorted ascending' : 'sorted descending') : 'not sorted';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortButton}
|
||||
onClick={() => toggleSort(key)}
|
||||
aria-label={`${label}, ${direction}`}
|
||||
>
|
||||
{label}
|
||||
<span className={styles.sortIcons} aria-hidden>
|
||||
<span className={sortKey === key && sortDir === 'asc' ? styles.sortActive : undefined}>▲</span>
|
||||
<span className={sortKey === key && sortDir === 'desc' ? styles.sortActive : undefined}>▼</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.tabs} role="tablist" aria-label="Product">
|
||||
{TAB_ORDER.map((tab) => {
|
||||
const selected = tab.id === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${formId}-tab-${tab.id}`}
|
||||
aria-selected={selected}
|
||||
aria-controls={`${formId}-panel`}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
className={selected ? `${styles.tab} ${styles.tabActive}` : styles.tab}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
onKeyDown={(event) => {
|
||||
const order = TAB_ORDER.map((item) => item.id);
|
||||
const index = order.indexOf(tab.id);
|
||||
let next = -1;
|
||||
if (event.key === 'ArrowRight') {
|
||||
next = (index + 1) % order.length;
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
next = (index - 1 + order.length) % order.length;
|
||||
} else if (event.key === 'Home') {
|
||||
next = 0;
|
||||
} else if (event.key === 'End') {
|
||||
next = order.length - 1;
|
||||
}
|
||||
if (next < 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setActiveTab(order[next]);
|
||||
document.getElementById(`${formId}-tab-${order[next]}`)?.focus();
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`${formId}-panel`}
|
||||
aria-labelledby={`${formId}-tab-${activeTab}`}
|
||||
>
|
||||
{status === 'loading' && (
|
||||
<div className={styles.status} role="status">
|
||||
Loading security updates…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className={styles.error} role="alert">
|
||||
<p>{error}</p>
|
||||
<button type="button" className={styles.primaryButton} onClick={() => void load()}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'ready' && (
|
||||
<>
|
||||
<form
|
||||
className={styles.filters}
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
aria-label="Filter security updates"
|
||||
>
|
||||
<label className={styles.searchField}>
|
||||
Search all columns
|
||||
<input
|
||||
type="search"
|
||||
value={filters.search}
|
||||
onChange={(event) => setFilter('search', event.target.value)}
|
||||
placeholder="Issue ID, CVE, version, or details"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Issue ID / CVE
|
||||
<input
|
||||
type="search"
|
||||
value={filters.issueId}
|
||||
onChange={(event) => setFilter('issueId', event.target.value)}
|
||||
placeholder="MMSA or CVE"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Severity
|
||||
<select
|
||||
value={filters.severity}
|
||||
onChange={(event) => setFilter('severity', event.target.value)}
|
||||
>
|
||||
<option value="">All severities</option>
|
||||
{SEVERITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Affected version
|
||||
<input
|
||||
value={versionDraft}
|
||||
onChange={(event) => setVersionDraft(event.target.value)}
|
||||
placeholder="e.g. 10.11.23"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(affectedVersionError)}
|
||||
aria-describedby={
|
||||
affectedVersionError
|
||||
? `${filterErrorId} ${formId}-affected-hint`
|
||||
: `${formId}-affected-hint`
|
||||
}
|
||||
/>
|
||||
<small id={`${formId}-affected-hint`} className={styles.fieldHint}>
|
||||
Enter the version you’re running to see affected advisories.
|
||||
</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Fix released from
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateFrom}
|
||||
onChange={(event) => setFilter('dateFrom', event.target.value)}
|
||||
aria-invalid={Boolean(dateOrderError)}
|
||||
aria-describedby={dateOrderError ? filterErrorId : undefined}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Fix released to
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateTo}
|
||||
onChange={(event) => setFilter('dateTo', event.target.value)}
|
||||
aria-invalid={Boolean(dateOrderError)}
|
||||
aria-describedby={dateOrderError ? filterErrorId : undefined}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Fix versions
|
||||
<input
|
||||
type="search"
|
||||
value={filters.fixVersions}
|
||||
onChange={(event) => setFilter('fixVersions', event.target.value)}
|
||||
placeholder="e.g. 10.11.23"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={styles.filterActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.resetButton}
|
||||
onClick={clearFilters}
|
||||
disabled={!hasActiveFilters}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
{filterError ? (
|
||||
<p className={styles.filterError} id={filterErrorId} role="alert">
|
||||
{filterError}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<label className={styles.pageSize}>
|
||||
Rows
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(event) => setPageSize(Number.parseInt(event.target.value, 10) || 25)}
|
||||
>
|
||||
{PAGE_SIZES.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className={styles.summary} role="status">
|
||||
{filteredRows.length === 0
|
||||
? 'No matching advisories'
|
||||
: `Showing ${(currentPage - 1) * pageSize + 1}–${(currentPage - 1) * pageSize + pageRows.length} of ${filteredRows.length}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table} aria-label={`${TAB_ORDER.find((tab) => tab.id === activeTab)?.label} security updates`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" {...sortProps('issueId')}>
|
||||
{renderSortButton('issueId', 'Issue ID')}
|
||||
</th>
|
||||
<th scope="col" {...sortProps('severity')}>
|
||||
{renderSortButton('severity', 'Severity')}
|
||||
</th>
|
||||
<th scope="col" {...sortProps('affectedVersions')}>
|
||||
{renderSortButton('affectedVersions', 'Affected versions')}
|
||||
</th>
|
||||
<th scope="col" {...sortProps('releaseDate')}>
|
||||
{renderSortButton('releaseDate', 'Release date')}
|
||||
</th>
|
||||
<th scope="col" {...sortProps('fixVersions')}>
|
||||
{renderSortButton('fixVersions', 'Fix versions')}
|
||||
</th>
|
||||
<th scope="col" {...sortProps('details')}>
|
||||
{renderSortButton('details', 'Details')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className={styles.empty}>
|
||||
No security updates match these filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pageRows.map((row) => (
|
||||
<tr key={rowKey(row)}>
|
||||
<td data-label="Issue ID">
|
||||
<span className={styles.issueId}>{row.issueId}</span>
|
||||
{row.cveId ? (
|
||||
<a
|
||||
className={styles.cve}
|
||||
href={`https://nvd.nist.gov/vuln/detail/${row.cveId}`}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{row.cveId}
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td data-label="Severity">
|
||||
<span
|
||||
className={`${styles.badge} ${
|
||||
styles[`badge${row.severity.replace(/[^A-Za-z]/g, '')}` as keyof typeof styles] ||
|
||||
styles.badgeNA
|
||||
}`}
|
||||
>
|
||||
{row.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="Affected versions">
|
||||
<VersionList lines={row.affectedVersions} />
|
||||
</td>
|
||||
<td data-label="Release date">{row.releaseDate || '—'}</td>
|
||||
<td data-label="Fix versions">
|
||||
<VersionList lines={row.fixVersions} />
|
||||
</td>
|
||||
<td data-label="Details">{row.details || '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className={styles.pagination} aria-label="Security updates pages">
|
||||
<ol>
|
||||
{buildPagination(currentPage, totalPages).map((item, index) => (
|
||||
<li key={`${item}-${index}`}>
|
||||
{item === '…' ? (
|
||||
<span className={styles.ellipsis}>…</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={item === currentPage ? styles.pageActive : undefined}
|
||||
aria-label={`Page ${item}`}
|
||||
aria-current={item === currentPage ? 'page' : undefined}
|
||||
onClick={() => setPage(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
458
docs/site/src/components/SecurityUpdates/styles.module.css
Normal file
458
docs/site/src/components/SecurityUpdates/styles.module.css
Normal file
@@ -0,0 +1,458 @@
|
||||
.root {
|
||||
margin: 1.5rem 0 2.5rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.55rem 0.95rem;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
background: var(--mm-bg-surface);
|
||||
color: var(--mm-text-primary);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--mm-focus-ring);
|
||||
outline-offset: 2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
background: var(--mm-color-denim);
|
||||
border-color: var(--mm-color-denim);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .tabActive {
|
||||
background: var(--mm-denim-500);
|
||||
border-color: var(--mm-denim-500);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 0.75rem 1rem;
|
||||
padding: 1rem 1.15rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
border-radius: 4px;
|
||||
background: var(--mm-bg-subtle);
|
||||
}
|
||||
|
||||
.filters label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
.filters input,
|
||||
.filters select,
|
||||
.pageSize select {
|
||||
width: 100%;
|
||||
min-height: 2.4rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
border-radius: 4px;
|
||||
background: var(--mm-bg-surface);
|
||||
color: var(--mm-text-primary);
|
||||
font: inherit;
|
||||
font-weight: 400;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .filters input,
|
||||
[data-theme='dark'] .filters select,
|
||||
[data-theme='dark'] .pageSize select {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.filters input:focus-visible,
|
||||
.filters select:focus-visible,
|
||||
.pageSize select:focus-visible,
|
||||
.sortButton:focus-visible,
|
||||
.primaryButton:focus-visible,
|
||||
.resetButton:focus-visible,
|
||||
.pagination button:focus-visible {
|
||||
outline: 2px solid var(--mm-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.searchField {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.filterActions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
flex: 1 1 16rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 400;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
.fieldHint {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
.fieldHint code {
|
||||
padding: 0.05rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: var(--mm-bg-surface);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.filterError {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: #8b1f1f;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .filterError {
|
||||
color: #f4a0ab;
|
||||
}
|
||||
|
||||
.primaryButton,
|
||||
.resetButton {
|
||||
padding: 0.5rem 0.95rem;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
border: none;
|
||||
background: var(--mm-color-denim);
|
||||
color: var(--mm-color-white);
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
background: var(--mm-denim-600);
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
background: transparent;
|
||||
color: var(--mm-text-primary);
|
||||
}
|
||||
|
||||
.resetButton:hover:not(:disabled) {
|
||||
background: var(--mm-bg-surface);
|
||||
}
|
||||
|
||||
.resetButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.pageSize {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pageSize select {
|
||||
width: auto;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 0;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
background: var(--mm-bg-surface);
|
||||
}
|
||||
|
||||
.tableWrap .table {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: table;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.7rem 0.75rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.45;
|
||||
border-bottom: 1px solid var(--mm-border-subtle);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: var(--mm-denim-50);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mm-text-secondary);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .table th {
|
||||
background: var(--mm-denim-800);
|
||||
}
|
||||
|
||||
.table tbody tr:nth-child(even) {
|
||||
background: var(--mm-bg-subtle);
|
||||
}
|
||||
|
||||
.table th:nth-child(1),
|
||||
.table td:nth-child(1) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.table th:nth-child(2),
|
||||
.table td:nth-child(2) {
|
||||
width: 9%;
|
||||
}
|
||||
|
||||
.table th:nth-child(3),
|
||||
.table td:nth-child(3),
|
||||
.table th:nth-child(5),
|
||||
.table td:nth-child(5) {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.table th:nth-child(4),
|
||||
.table td:nth-child(4) {
|
||||
width: 11%;
|
||||
}
|
||||
|
||||
.sortButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sortIcons {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.55rem;
|
||||
line-height: 0.7;
|
||||
}
|
||||
|
||||
.sortIcons span {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sortIcons .sortActive {
|
||||
opacity: 1;
|
||||
color: var(--mm-color-denim);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .sortIcons .sortActive {
|
||||
color: var(--mm-denim-200);
|
||||
}
|
||||
|
||||
.issueId {
|
||||
display: block;
|
||||
font-family: var(--mm-font-mono);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cve {
|
||||
display: inline-block;
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.versionList {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.versionList li + li {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.muted,
|
||||
.empty {
|
||||
color: var(--mm-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 1.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badgeCritical {
|
||||
background: #fbeded;
|
||||
color: #8b1f1f;
|
||||
}
|
||||
|
||||
.badgeHigh {
|
||||
background: #fdecef;
|
||||
color: #c8102e;
|
||||
}
|
||||
|
||||
.badgeMedium {
|
||||
background: var(--mm-marigold-300);
|
||||
color: var(--mm-color-black);
|
||||
border-color: var(--mm-marigold-500);
|
||||
}
|
||||
|
||||
.badgeLow {
|
||||
background: #eaf5ee;
|
||||
color: #1f5a39;
|
||||
}
|
||||
|
||||
.badgeNA {
|
||||
background: var(--mm-denim-100);
|
||||
color: var(--mm-denim-700);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badgeCritical {
|
||||
background: #4a1f1f;
|
||||
color: #e9a0a0;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badgeHigh {
|
||||
background: #4e0f1b;
|
||||
color: #f4a0ab;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badgeMedium {
|
||||
background: #5c4a14;
|
||||
color: var(--mm-marigold-100);
|
||||
border-color: var(--mm-marigold-700);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badgeLow {
|
||||
background: #14442c;
|
||||
color: #7fcc9e;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .badgeNA {
|
||||
background: var(--mm-denim-700);
|
||||
color: var(--mm-denim-200);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination ol {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pagination button,
|
||||
.ellipsis {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--mm-text-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pageActive {
|
||||
border-color: var(--mm-border-strong) !important;
|
||||
color: var(--mm-color-denim);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .pageActive {
|
||||
color: var(--mm-marigold-300);
|
||||
border-color: var(--mm-denim-300) !important;
|
||||
}
|
||||
|
||||
.status,
|
||||
.error {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--mm-border-subtle);
|
||||
background: var(--mm-bg-subtle);
|
||||
}
|
||||
|
||||
.error p {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 996px) {
|
||||
.filters {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
231
docs/site/src/components/SecurityUpdates/versions.ts
Normal file
231
docs/site/src/components/SecurityUpdates/versions.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
export type Version = [number, number, number];
|
||||
export type VersionRange = {min: Version; max: Version};
|
||||
|
||||
const INF = 999_999;
|
||||
const ZERO: Version = [0, 0, 0];
|
||||
const MAX: Version = [INF, INF, INF];
|
||||
|
||||
export function parseVersionToken(raw: string): Version | null {
|
||||
const cleaned = raw.trim().replace(/^v/i, '');
|
||||
const match = cleaned.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+|x))?/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const major = Number(match[1]);
|
||||
const minor = match[2] === undefined ? 0 : Number(match[2]);
|
||||
const patchToken = match[3];
|
||||
const patch =
|
||||
patchToken === undefined ? 0 : patchToken.toLowerCase() === 'x' ? 0 : Number(patchToken);
|
||||
return [major, minor, patch];
|
||||
}
|
||||
|
||||
export function compareVersions(a: Version, b: Version): number {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (a[i] !== b[i]) {
|
||||
return a[i] - b[i];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function exclusiveUpperBound(v: Version): Version {
|
||||
if (v[2] > 0) {
|
||||
return [v[0], v[1], v[2] - 1];
|
||||
}
|
||||
if (v[1] > 0) {
|
||||
return [v[0], v[1] - 1, INF];
|
||||
}
|
||||
if (v[0] > 0) {
|
||||
return [v[0] - 1, INF, INF];
|
||||
}
|
||||
return ZERO;
|
||||
}
|
||||
|
||||
function versionFromMatch(token: string, asUpperBound: boolean): Version | null {
|
||||
const parsed = parseVersionToken(token);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const cleaned = token.trim().replace(/^v/i, '');
|
||||
if (asUpperBound && /\.x$/i.test(cleaned)) {
|
||||
return [parsed[0], parsed[1], INF];
|
||||
}
|
||||
// "11" as a max means the whole 11 major line.
|
||||
if (asUpperBound && /^\d+$/.test(cleaned)) {
|
||||
return [parsed[0], INF, INF];
|
||||
}
|
||||
// "11.9" as a max means the whole 11.9 line.
|
||||
if (asUpperBound && /^\d+\.\d+$/.test(cleaned)) {
|
||||
return [parsed[0], parsed[1], INF];
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseOneLine(line: string): VersionRange[] {
|
||||
const text = line.trim();
|
||||
if (!text || /^(n\/a|na)$/i.test(text) || /^exclud/i.test(text)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (/^all$/i.test(text)) {
|
||||
return [{min: ZERO, max: MAX}];
|
||||
}
|
||||
|
||||
const toMatch = text.match(
|
||||
/v?(\d+(?:\.\d+){0,2}(?:\.x)?)\s+to\s+v?(\d+(?:\.\d+){0,2}(?:\.x)?)/i,
|
||||
);
|
||||
if (toMatch) {
|
||||
const min = versionFromMatch(toMatch[1], false);
|
||||
const max = versionFromMatch(toMatch[2], true);
|
||||
if (min && max) {
|
||||
return [{min, max}];
|
||||
}
|
||||
}
|
||||
|
||||
const earlier = text.match(/v?(\d+(?:\.\d+){0,2}(?:\.x)?)\s+and earlier/i);
|
||||
if (earlier) {
|
||||
const max = versionFromMatch(earlier[1], true);
|
||||
if (max) {
|
||||
return [{min: ZERO, max}];
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes('<=')) {
|
||||
const segments = text.split('<=');
|
||||
const left = (segments[0] || '').trim();
|
||||
const rightSegments = segments.slice(1).map((part) => part.trim()).filter(Boolean);
|
||||
if (rightSegments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Multiple "<=" operators: union of upper bounds (e.g. "<=7.1.9 <=7.8.4").
|
||||
if (rightSegments.length > 1) {
|
||||
return rightSegments
|
||||
.map((part) => versionFromMatch(part.split(/\s+/)[0] || '', true))
|
||||
.filter((max): max is Version => max !== null)
|
||||
.map((max) => ({min: ZERO, max}));
|
||||
}
|
||||
|
||||
const tokens = rightSegments[0].split(/\s+/).filter(Boolean);
|
||||
// Pre-2024 mashed line: "<=11.9 11.0.9 11.4.8 …" is a version list, not one open range.
|
||||
if (tokens.length > 1 && !left) {
|
||||
return tokens.flatMap((token) => {
|
||||
const min = versionFromMatch(token, false);
|
||||
const max = versionFromMatch(token, true);
|
||||
return min && max ? [{min, max}] : [];
|
||||
});
|
||||
}
|
||||
|
||||
const max = versionFromMatch(tokens[0] || '', true);
|
||||
if (!max) {
|
||||
return [];
|
||||
}
|
||||
const branch = left.match(/(\d+)\.(\d+)\.x/i);
|
||||
if (branch) {
|
||||
return [{min: [Number(branch[1]), Number(branch[2]), 0], max}];
|
||||
}
|
||||
const leftVer = parseVersionToken(left.replace(/^.*\s/, ''));
|
||||
if (leftVer) {
|
||||
return [{min: leftVer, max}];
|
||||
}
|
||||
return [{min: ZERO, max}];
|
||||
}
|
||||
|
||||
if (text.includes('>=')) {
|
||||
const min = versionFromMatch(text.split('>=')[1] || '', false);
|
||||
if (min) {
|
||||
return [{min, max: MAX}];
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes('<')) {
|
||||
const ranges: VersionRange[] = [];
|
||||
for (const match of text.matchAll(/<\s*v?(\d+(?:\.\d+){0,2}(?:\.x)?)/gi)) {
|
||||
// Exclusive bound: do not expand "7.9" to the whole 7.9 line first.
|
||||
const bound = versionFromMatch(match[1], false);
|
||||
if (bound) {
|
||||
ranges.push({min: ZERO, max: exclusiveUpperBound(bound)});
|
||||
}
|
||||
}
|
||||
if (ranges.length > 0) {
|
||||
return ranges;
|
||||
}
|
||||
}
|
||||
|
||||
const ranges: VersionRange[] = [];
|
||||
for (const match of text.matchAll(/v?(\d+\.\d+(?:\.\d+|\.x)?)/gi)) {
|
||||
const token = match[1];
|
||||
const version = parseVersionToken(token);
|
||||
if (version) {
|
||||
ranges.push({min: version, max: version});
|
||||
}
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function highestFixVersion(fixVersions: string[]): Version | null {
|
||||
let highest: Version | null = null;
|
||||
for (const line of fixVersions) {
|
||||
for (const match of line.matchAll(/v?(\d+\.\d+(?:\.\d+)?)/gi)) {
|
||||
const parsed = parseVersionToken(match[1]);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
if (!highest || compareVersions(parsed, highest) > 0) {
|
||||
highest = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return highest;
|
||||
}
|
||||
|
||||
function isUnbounded(range: VersionRange): boolean {
|
||||
return compareVersions(range.max, MAX) === 0;
|
||||
}
|
||||
|
||||
export function parseAffectedLines(lines: string[], fixVersions: string[] = []): VersionRange[] {
|
||||
const ranges = lines.flatMap(parseOneLine);
|
||||
const cap = highestFixVersion(fixVersions);
|
||||
if (!cap) {
|
||||
return ranges;
|
||||
}
|
||||
return ranges.map((range) => {
|
||||
if (!isUnbounded(range)) {
|
||||
return range;
|
||||
}
|
||||
return {
|
||||
min: range.min,
|
||||
max: compareVersions(cap, range.min) < 0 ? range.min : cap,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function rangesOverlap(a: VersionRange, b: VersionRange): boolean {
|
||||
return compareVersions(a.min, b.max) <= 0 && compareVersions(b.min, a.max) <= 0;
|
||||
}
|
||||
|
||||
export function rowMatchesVersionRange(
|
||||
ranges: VersionRange[],
|
||||
minFilter: Version | null,
|
||||
maxFilter: Version | null,
|
||||
): boolean {
|
||||
if (!minFilter && !maxFilter) {
|
||||
return true;
|
||||
}
|
||||
if (ranges.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const filter: VersionRange = {
|
||||
min: minFilter ?? ZERO,
|
||||
max: maxFilter ?? MAX,
|
||||
};
|
||||
return ranges.some((range) => rangesOverlap(range, filter));
|
||||
}
|
||||
|
||||
export function labelToVersion(label: string, bound: 'min' | 'max'): Version | null {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed || !/^v?\d+(?:\.\d+){0,2}(?:\.x)?$/i.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return versionFromMatch(trimmed, bound === 'max');
|
||||
}
|
||||
Reference in New Issue
Block a user