feat(app-store): editorial hero, category rail, security scan signal per tile (#679)

* feat(app-store): editorial hero, category rail, security scan signal per tile

Rework the App Store view into an editorial layout with a 180px category
rail, a featured-template hero, and compact tiles that surface star counts
and vulnerability scan status at a glance. The deploy sheet splits into
Essentials (one-click deploy) and Advanced (full port/volume/env control)
tabs.

Backend enriches /api/templates with scan_status, scan_cve_count, and a
featured flag computed from the highest-star template with recorded stars.
Scan lookups use a single batched SQL query to avoid N+1 round-trips.

* fix(app-store): split firstSentence helper into its own module

The firstSentence helper lived alongside the TemplateLogo component, which
violates react-refresh/only-export-components — a file that exports a
component must not also export non-component values, or Fast Refresh cannot
establish an HMR boundary. Move the helper into appstore/util.ts and update
the two import sites.
This commit is contained in:
Anso
2026-04-18 12:55:50 -04:00
committed by GitHub
parent 5f6fdfcba8
commit ec7620675e
14 changed files with 645 additions and 302 deletions
+30 -1
View File
@@ -7258,7 +7258,36 @@ app.post('/api/system/networks', async (req: Request, res: Response) => {
app.get('/api/templates', authMiddleware, async (req: Request, res: Response) => {
try {
const templates = await templateService.getTemplates();
res.json(templates);
const imageRefs = templates.map(t => t.image).filter((i): i is string => !!i);
const scanSummary = DatabaseService.getInstance().getLatestScanSummaryByImageRefs(req.nodeId, imageRefs);
let featuredIndex = -1;
let featuredStars = 0;
templates.forEach((t, i) => {
const s = t.stars ?? 0;
if (s > featuredStars) {
featuredStars = s;
featuredIndex = i;
}
});
const enriched = templates.map((t, i) => {
const summary = t.image ? scanSummary.get(t.image) : undefined;
const scan_status: 'clean' | 'vulnerable' | 'unscanned' = summary
? (summary.total === 0 ? 'clean' : 'vulnerable')
: 'unscanned';
return {
...t,
scan_status,
scan_cve_count: summary?.total ?? 0,
scan_critical_count: summary?.critical ?? 0,
scan_high_count: summary?.high ?? 0,
featured: i === featuredIndex,
};
});
res.json(enriched);
} catch (error) {
console.error('[Templates] Failed to fetch:', error);
res.status(500).json({ error: 'Failed to fetch templates' });
+38
View File
@@ -2508,6 +2508,44 @@ export class DatabaseService {
);
}
public getLatestScanSummaryByImageRefs(
nodeId: number,
imageRefs: string[],
): Map<string, { total: number; critical: number; high: number; scannedAt: number }> {
const summary = new Map<string, { total: number; critical: number; high: number; scannedAt: number }>();
if (imageRefs.length === 0) return summary;
const placeholders = imageRefs.map(() => '?').join(',');
const rows = this.db
.prepare(
`SELECT image_ref, total_vulnerabilities, critical_count, high_count, scanned_at
FROM vulnerability_scans v1
WHERE node_id = ?
AND image_ref IN (${placeholders})
AND scanned_at = (
SELECT MAX(scanned_at) FROM vulnerability_scans v2
WHERE v2.node_id = v1.node_id AND v2.image_ref = v1.image_ref
)`,
)
.all(nodeId, ...imageRefs) as Array<{
image_ref: string;
total_vulnerabilities: number;
critical_count: number;
high_count: number;
scanned_at: number;
}>;
for (const row of rows) {
summary.set(row.image_ref, {
total: row.total_vulnerabilities,
critical: row.critical_count,
high: row.high_count,
scannedAt: row.scanned_at,
});
}
return summary;
}
public getLatestScanByDigest(digest: string, scannersUsed?: string): VulnerabilityScan | null {
if (!digest) return null;
if (scannersUsed) {