feat(app-store): category filter bar and custom registry settings

- TemplateService: static LSIO_CATEGORY_MAP covers ~80 well-known apps
  across Automation, Downloaders, Media, Monitoring, Networking,
  Security, Development, Productivity, Utilities, and Other. Category
  lookup is O(1) and runs once at cache-fill time. Adds source field
  ('linuxserver' | 'custom') to Template for future per-source UX.
  Portainer v2 registries pass their native categories through unchanged.
  Adds clearCache() method wired to POST /api/templates/refresh-cache.

- AppStoreView: category pill bar (All + sorted dynamic categories)
  rendered between search and grid; active pill fills on click; clicking
  a category badge on a card also sets the active filter; app count
  updates reactively; filter composed with existing search query.

- SettingsModal: new App Store section (local-node only) exposes a
  custom Portainer v2 JSON URL input with Save & Refresh (saves setting
  then busts the 24h template cache) and Reset to Default.
This commit is contained in:
SaelixCode
2026-03-20 15:19:32 -04:00
parent 18314119b0
commit 34cad76d45
5 changed files with 306 additions and 12 deletions
+5
View File
@@ -1482,6 +1482,11 @@ app.get('/api/templates', async (req: Request, res: Response) => {
}
});
app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Response) => {
templateService.clearCache();
res.json({ success: true });
});
app.post('/api/templates/deploy', async (req: Request, res: Response) => {
try {
const { stackName, template, envVars } = req.body;
+161 -1
View File
@@ -29,6 +29,7 @@ export interface Template {
docs_url?: string;
architectures?: string[];
stars?: number;
source?: string;
repository?: {
url: string;
stackfile: string;
@@ -40,11 +41,165 @@ export interface TemplatesResponse {
templates: Template[];
}
// Static category map for LSIO apps (the LSIO API does not expose category metadata).
// Apps can belong to multiple categories. Unmapped apps fall back to ['Other'].
const LSIO_CATEGORY_MAP: Record<string, string[]> = {
// Media Servers
'plex': ['Media'],
'jellyfin': ['Media'],
'emby': ['Media'],
'navidrome': ['Media'],
'airsonic-advanced': ['Media'],
'airsonic': ['Media'],
'beets': ['Media'],
'calibre': ['Media', 'Books'],
'calibre-web': ['Media', 'Books'],
'kavita': ['Media', 'Books'],
'komga': ['Media', 'Books'],
'mylar3': ['Media', 'Books'],
'ubooquity': ['Media', 'Books'],
'lazylibrarian': ['Media', 'Books'],
'cops': ['Media', 'Books'],
'photoprism': ['Media', 'Productivity'],
'immich': ['Media', 'Productivity'],
'piwigo': ['Media'],
'lychee': ['Media'],
'davos': ['Media'],
'mstream': ['Media'],
'koel': ['Media'],
'grocy': ['Productivity'],
// *arr Automation suite
'sonarr': ['Automation', 'Media'],
'radarr': ['Automation', 'Media'],
'lidarr': ['Automation', 'Media'],
'readarr': ['Automation', 'Media'],
'bazarr': ['Automation', 'Media'],
'whisparr': ['Automation', 'Media'],
'prowlarr': ['Automation'],
'jackett': ['Automation'],
'nzbhydra2': ['Automation'],
'overseerr': ['Automation', 'Media'],
'ombi': ['Automation', 'Media'],
'requestrr': ['Automation'],
'tautulli': ['Monitoring', 'Media'],
'organizr': ['Automation'],
'recyclarr': ['Automation'],
'notifiarr': ['Automation'],
'unpackerr': ['Automation'],
// Dashboards / Homepages
'heimdall': ['Utilities'],
'homer': ['Utilities'],
'dasherr': ['Utilities'],
'flame': ['Utilities'],
'homarr': ['Utilities'],
'dashdot': ['Monitoring'],
// Downloaders
'qbittorrent': ['Downloaders'],
'transmission': ['Downloaders'],
'deluge': ['Downloaders'],
'sabnzbd': ['Downloaders'],
'nzbget': ['Downloaders'],
'aria2': ['Downloaders'],
'jdownloader-2': ['Downloaders'],
'pyload-ng': ['Downloaders'],
'rutorrent': ['Downloaders'],
'flood': ['Downloaders'],
'medusa': ['Automation', 'Downloaders'],
'sickchill': ['Automation', 'Downloaders'],
// Monitoring
'grafana': ['Monitoring'],
'netdata': ['Monitoring'],
'uptime-kuma': ['Monitoring'],
'statping-ng': ['Monitoring'],
'healthchecks': ['Monitoring'],
'smokeping': ['Monitoring'],
'librespeed': ['Monitoring'],
'speedtest-tracker': ['Monitoring'],
'scrutiny': ['Monitoring'],
'prometheus': ['Monitoring'],
'loki': ['Monitoring'],
'influxdb': ['Monitoring'],
// Networking / Reverse Proxy
'nginx': ['Networking'],
'swag': ['Networking'],
'letsencrypt': ['Networking'],
'ddclient': ['Networking'],
'duckdns': ['Networking'],
'wireguard': ['Networking', 'Security'],
'openvpn-as': ['Networking', 'Security'],
'netbootxyz': ['Networking'],
'pihole': ['Networking'],
'unbound': ['Networking'],
'adguardhome': ['Networking'],
'cloudflared': ['Networking'],
'haproxy': ['Networking'],
'traefik': ['Networking'],
'nginx-proxy-manager': ['Networking'],
'fail2ban': ['Networking', 'Security'],
// Security / Auth
'vaultwarden': ['Security'],
'authelia': ['Security'],
'lldap': ['Security'],
'endlessh': ['Security'],
'sshwifty': ['Security'],
// Development / CI
'gitea': ['Development'],
'code-server': ['Development'],
'drone': ['Development'],
'drone-runner-docker': ['Development'],
'registry': ['Development'],
'jenkins': ['Development'],
'gogs': ['Development'],
'woodpecker-ci': ['Development'],
'gitlab': ['Development'],
'fleet': ['Development'],
// Productivity / Self-hosted SaaS
'nextcloud': ['Productivity'],
'bookstack': ['Productivity', 'Documentation'],
'dokuwiki': ['Productivity', 'Documentation'],
'wikijs': ['Productivity', 'Documentation'],
'paperless-ngx': ['Productivity'],
'mealie': ['Productivity'],
'freshrss': ['Productivity'],
'miniflux': ['Productivity'],
'wallabag': ['Productivity'],
'trilium': ['Productivity'],
'hedgedoc': ['Productivity'],
'etherpad': ['Productivity'],
'monica': ['Productivity'],
'firefly-iii': ['Productivity'],
'shlink': ['Productivity'],
'yourls': ['Productivity'],
'stirling-pdf': ['Productivity'],
'syncthing': ['Productivity'],
'tandoor': ['Productivity'],
'linkwarden': ['Productivity'],
'vikunja': ['Productivity'],
// Utilities / Backup
'duplicati': ['Utilities'],
'restic': ['Utilities'],
'rsnapshot': ['Utilities'],
'mysql-workbench': ['Utilities'],
'sqlitebrowser': ['Utilities'],
'filezilla': ['Utilities'],
'rdesktop': ['Utilities'],
'webtop': ['Utilities'],
};
function getCategoriesForApp(name: string): string[] {
return LSIO_CATEGORY_MAP[name.toLowerCase()] ?? ['Other'];
}
export class TemplateService {
private cachedTemplates: Template[] = [];
private lastFetchTime: number = 0;
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
public clearCache(): void {
this.cachedTemplates = [];
this.lastFetchTime = 0;
}
public async getTemplates(): Promise<Template[]> {
const now = Date.now();
if (this.cachedTemplates.length > 0 && now - this.lastFetchTime < this.CACHE_DURATION_MS) {
@@ -73,6 +228,8 @@ export class TemplateService {
docs_url: app.readme,
architectures: app.arch,
stars: app.stars,
categories: getCategoriesForApp(app.name),
source: 'linuxserver',
// Map configs if available, otherwise default to empty arrays
ports: (app.config?.ports || []).map((p: any) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`),
volumes: (app.config?.volumes || []).map((v: any) => {
@@ -91,7 +248,10 @@ export class TemplateService {
});
} else {
// Legacy Portainer v2 Format (Fallback for custom registries)
this.cachedTemplates = (response.data.templates || []).filter((t: Template) => !!t.image && t.type === 1);
// The Portainer v2 spec includes a native `categories` field — pass it through.
this.cachedTemplates = (response.data.templates || [])
.filter((t: Template) => !!t.image && t.type === 1)
.map((t: Template) => ({ ...t, source: 'custom' }));
}
this.lastFetchTime = now;