Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions application/database/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from application.utils import redis
from application.defs import cre_defs
from application.utils import file
from application.utils import wayfinder_metadata
from application.utils.gap_analysis import (
get_path_score,
make_resources_key,
Expand Down Expand Up @@ -1798,6 +1799,67 @@ def standards(self) -> List[str]:
)
return list(set([s[0] for s in standards]))

def wayfinder_resources(self) -> List[Dict[str, Any]]:
rows = (
self.session.query(
Node.name,
Node.ntype,
func.count(Node.id).label("entry_count"),
func.min(Node.link).label("sample_hyperlink"),
)
.group_by(Node.name, Node.ntype)
.order_by(func.lower(Node.ntype), func.lower(Node.name))
.all()
)

aggregated: Dict[Tuple[str, str], Dict[str, Any]] = {}
for row in rows:
name, ntype, entry_count, sample_hyperlink = row
canonical_name = wayfinder_metadata.canonical_resource_name(
name=str(name), ntype=str(ntype)
)
if not canonical_name:
continue

key = (str(ntype), canonical_name)
if key not in aggregated:
aggregated[key] = {
"doctype": str(ntype),
"name": canonical_name,
"entry_count": 0,
"hyperlink": "",
"aliases": set(),
}

entry = aggregated[key]
entry["entry_count"] += int(entry_count or 0)
entry["aliases"].add(str(name))
if sample_hyperlink and not entry["hyperlink"]:
entry["hyperlink"] = str(sample_hyperlink)

resources: List[Dict[str, Any]] = []
for (ntype, canonical_name), entry in sorted(
aggregated.items(), key=lambda x: (x[0][0].lower(), x[0][1].lower())
):
metadata = wayfinder_metadata.get_wayfinder_metadata(
name=canonical_name, ntype=ntype
)
resource_id = f"{ntype.lower()}:{canonical_name}"
aliases = sorted(list(entry["aliases"]), key=lambda x: x.lower())

resources.append(
{
"id": resource_id,
"name": canonical_name,
"doctype": ntype,
"entry_count": int(entry["entry_count"]),
"hyperlink": entry["hyperlink"],
"aliases": aliases,
"metadata": metadata,
}
)
return resources

def text_search(self, text: str) -> List[Optional[cre_defs.Document]]:
"""Given a piece of text, tries to find the best match
for the text in the database.
Expand Down
1 change: 1 addition & 0 deletions application/frontend/src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@ export const GRAPH = '/graph';
export const BROWSEROOT = '/root_cres';
export const GAP_ANALYSIS = '/map_analysis';
export const EXPLORER = '/explorer';
export const WAYFINDER = '/wayfinder';

export const GA_STRONG_UPPER_LIMIT = 2; // remember to change this in the Python code too
255 changes: 255 additions & 0 deletions application/frontend/src/pages/Wayfinder/Wayfinder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import './wayfinder.scss';

import axios from 'axios';
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Dropdown, Input } from 'semantic-ui-react';

import { LoadingAndErrorIndicator } from '../../components/LoadingAndErrorIndicator';
import { useEnvironment } from '../../hooks';
import { WayfinderFacet, WayfinderResource, WayfinderResponse } from '../../types';

const toArray = (
value: string | number | boolean | (string | number | boolean)[] | undefined
): string[] => {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value.map((entry) => String(entry));
}
return [String(value)];
};

const asOptions = (facets: WayfinderFacet[] = []) =>
facets.map((facet) => ({
key: facet.value,
text: `${facet.value} (${facet.count})`,
value: facet.value,
}));

export const Wayfinder = () => {
const { apiUrl } = useEnvironment();

const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [response, setResponse] = useState<WayfinderResponse | null>(null);

const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [selectedSdlc, setSelectedSdlc] = useState<string[]>([]);
const [selectedOrgs, setSelectedOrgs] = useState<string[]>([]);
const [selectedLicenses, setSelectedLicenses] = useState<string[]>([]);
const [selectedDoctypes, setSelectedDoctypes] = useState<string[]>([]);

useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedQuery(query.trim());
}, 250);
return () => window.clearTimeout(timeoutId);
}, [query]);

useEffect(() => {
const load = async () => {
setLoading(true);
setError(null);
try {
// Use URLSearchParams so Flask receives repeated keys (sdlc=x&sdlc=y), not sdlc[]=x.
const params = new URLSearchParams();
if (debouncedQuery) params.append('q', debouncedQuery);
selectedSdlc.forEach((value) => params.append('sdlc', value));
selectedOrgs.forEach((value) => params.append('supporting_org', value));
selectedLicenses.forEach((value) => params.append('license', value));
selectedDoctypes.forEach((value) => params.append('doctype', value));

const res = await axios.get<WayfinderResponse>(`${apiUrl}/wayfinder`, {
params,
});
setResponse(res.data);
} catch (err) {
console.error(err);
setError('Could not load wayfinder data');
} finally {
setLoading(false);
}
};

load();
}, [apiUrl, debouncedQuery, selectedDoctypes, selectedLicenses, selectedOrgs, selectedSdlc]);

const stats = response?.stats;
const groups = response?.grouped_by_sdlc || [];
const hasNoData = !loading && !error && (stats?.total_resources || 0) === 0;
const hasNoMatches = !loading && !error && !hasNoData && (stats?.filtered_resources || 0) === 0;
const visibleGroups = useMemo(() => groups.filter((group) => group.resources.length > 0), [groups]);

const sdlcOptions = useMemo(() => asOptions(response?.facets?.sdlc), [response?.facets?.sdlc]);
const orgOptions = useMemo(
() => asOptions(response?.facets?.supporting_orgs),
[response?.facets?.supporting_orgs]
);
const licenseOptions = useMemo(
() => asOptions(response?.facets?.licenses),
[response?.facets?.licenses]
);
const doctypeOptions = useMemo(
() => asOptions(response?.facets?.doctypes),
[response?.facets?.doctypes]
);

const clearFilters = () => {
setQuery('');
setSelectedSdlc([]);
setSelectedOrgs([]);
setSelectedLicenses([]);
setSelectedDoctypes([]);
};

const renderResourceCard = (resource: WayfinderResource) => (
<article className="wayfinder-card" key={`${resource.id}-${resource.name}`}>
<div className="wayfinder-card__header">
<h3>{resource.name}</h3>
<span className="wayfinder-card__doctype">{resource.doctype}</span>
</div>

<div className="wayfinder-card__meta">
<div>
<strong>Supporting org:</strong> {resource.metadata.supporting_orgs.join(', ')}
</div>
<div>
<strong>License:</strong> {resource.metadata.licenses.join(', ')}
</div>
</div>

<div className="wayfinder-card__footer">
<span>{resource.entry_count} mapped entries</span>
{resource.hyperlink ? (
<a href={resource.hyperlink} target="_blank" rel="noopener noreferrer">
Open source
</a>
) : (
<span className="wayfinder-card__muted">No direct link</span>
)}
</div>
</article>
);

return (
<main id="wayfinder-content">
<section className="wayfinder-intro">
<h1>Product Security Wayfinder</h1>
<p>
Explore standards and tools known to OpenCRE by SDLC stage, then narrow the view using metadata
facets.
</p>
<div className="wayfinder-stats">
<div>
<span>Total resources</span>
<strong>{stats?.total_resources || 0}</strong>
</div>
<div>
<span>Filtered resources</span>
<strong>{stats?.filtered_resources || 0}</strong>
</div>
<div>
<span>Mapped entries</span>
<strong>{stats?.filtered_entries || 0}</strong>
</div>
</div>
</section>

<section className="wayfinder-filters">
<Input
fluid
placeholder="Search resource name or keyword..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div className="wayfinder-filter-grid">
<Dropdown
fluid
multiple
search
selection
placeholder="SDLC phases"
options={sdlcOptions}
value={selectedSdlc}
onChange={(_, data) => setSelectedSdlc(toArray(data.value))}
/>
<Dropdown
fluid
multiple
search
selection
placeholder="Supporting organizations"
options={orgOptions}
value={selectedOrgs}
onChange={(_, data) => setSelectedOrgs(toArray(data.value))}
/>
<Dropdown
fluid
multiple
search
selection
placeholder="Licenses"
options={licenseOptions}
value={selectedLicenses}
onChange={(_, data) => setSelectedLicenses(toArray(data.value))}
/>
<Dropdown
fluid
multiple
search
selection
placeholder="Resource type"
options={doctypeOptions}
value={selectedDoctypes}
onChange={(_, data) => setSelectedDoctypes(toArray(data.value))}
/>
</div>
<Button basic size="small" onClick={clearFilters}>
Clear filters
</Button>
</section>

<LoadingAndErrorIndicator loading={loading} error={error} />

{hasNoData && (
<section className="wayfinder-empty">
<h2>No resources available yet</h2>
<p>
Wayfinder is active, but your local dataset has no imported non-CRE resources. Import/sync data and
refresh this page to populate lanes and facets.
</p>
</section>
)}

{hasNoMatches && (
<section className="wayfinder-empty">
<h2>No resources match the current filters</h2>
<p>Try clearing one or more filters to broaden the Wayfinder results.</p>
</section>
)}

{!loading && !error && !hasNoData && !hasNoMatches && (
<section className="wayfinder-lanes">
{visibleGroups.map((group) => (
<section className="wayfinder-lane" key={group.phase}>
<div className="wayfinder-lane__header">
<h2>{group.phase}</h2>
<span>{group.resources.length} resources</span>
</div>

<div className="wayfinder-lane__cards">
{group.resources.length > 0 ? (
group.resources.map((resource) => renderResourceCard(resource))
) : (
<div className="wayfinder-lane__empty">No resources for this lane with current filters.</div>
)}
</div>
</section>
))}
</section>
)}
</main>
);
};
Loading