From 34cad76d45fe7212bd61aa514f40e13217e2fd8e Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 15:19:32 -0400 Subject: [PATCH] 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. --- CHANGELOG.md | 3 + backend/src/index.ts | 5 + backend/src/services/TemplateService.ts | 162 +++++++++++++++++++++- frontend/src/components/AppStoreView.tsx | 54 ++++++-- frontend/src/components/SettingsModal.tsx | 94 ++++++++++++- 5 files changed, 306 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a217ee4a..81d594cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Added:** App Store category filter — LSIO templates are now grouped into categories (Automation, Downloaders, Media, Monitoring, Networking, Security, Development, Productivity, Utilities, Other) via a static lookup map in `TemplateService`. A horizontal pill bar below the search field lets users filter by category; clicking a category badge on a template card also activates the filter. Category badges highlight when their category is the active filter. App count updates reactively. +- **Added:** App Store registry settings — new "App Store" section in Settings lets users supply a custom Portainer v2 JSON template URL to override the default LinuxServer.io registry. "Save & Refresh" persists the URL and immediately busts the 24-hour template cache via `POST /api/templates/refresh-cache`. Portainer v2 registries pass their native `categories` field through unchanged. +- **Added:** `source` field on the `Template` interface — set to `'linuxserver'` for LSIO apps and `'custom'` for Portainer v2 registries, enabling future per-source filtering. - **Fixed:** LogViewer (container SSE log stream) returning 404 on remote nodes — the `?nodeId=` query param was forwarded by `remoteNodeProxy` to the remote server, where `nodeContextMiddleware` rejected it with `Node X not found` because the gateway's node IDs don't exist on the remote instance. Fixed by stripping `nodeId` from `proxyReq.path` in `onProxyReq`, mirroring the existing `x-node-id` header removal. - **Fixed:** Terminal logs and container stats WebSockets failing with "HTTP Authentication failed" on remote nodes — the WebSocket upgrade proxy was forwarding the browser's `cookie` header to the remote Sencho instance. The remote's `authMiddleware` picks `cookieToken` before `bearerToken`, and the cookie (signed with the local JWT secret) fails verification on the remote, returning 401. Fixed by deleting the `cookie` header before `wsProxyServer.ws()`, mirroring the `proxyReq.removeHeader('cookie')` already present in the HTTP proxy. Also strips the gateway's `nodeId` query param from the forwarded URL so the remote defaults cleanly to its own local node. - **Fixed:** `streamStats` Docker stats stream leaking after WebSocket client disconnect — the Docker daemon stream was never destroyed when the WS closed, causing orphaned streams to poll the daemon indefinitely. Fixed by adding a `ws.on('close')` handler that calls `stats.destroy()`. Also guards all `ws.send()` calls with a `readyState === OPEN` check to prevent silent errors on a closed socket. diff --git a/backend/src/index.ts b/backend/src/index.ts index af5ca4db..ca70b9af 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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; diff --git a/backend/src/services/TemplateService.ts b/backend/src/services/TemplateService.ts index fd23c86c..f69909fa 100644 --- a/backend/src/services/TemplateService.ts +++ b/backend/src/services/TemplateService.ts @@ -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 = { + // 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 { 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; diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index 0fc6e1b9..fbd304b5 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; @@ -31,6 +31,7 @@ export interface Template { docs_url?: string; architectures?: string[]; stars?: number; + source?: string; } interface AppStoreViewProps { @@ -48,6 +49,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { const [isDeploying, setIsDeploying] = useState(false); const [loading, setLoading] = useState(true); + const [selectedCategory, setSelectedCategory] = useState('All'); const [imgErrors, setImgErrors] = useState>({}); const [portVars, setPortVars] = useState>({}); const [isDescExpanded, setIsDescExpanded] = useState(false); @@ -180,11 +182,21 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { } }; - const filtered = templates.filter(t => - t.title.toLowerCase().includes(searchQuery.toLowerCase()) || - t.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - (t.categories && t.categories.join(' ').toLowerCase().includes(searchQuery.toLowerCase())) - ); + const categories = useMemo(() => { + const cats = new Set(); + templates.forEach(t => t.categories?.forEach(c => cats.add(c))); + return ['All', ...Array.from(cats).sort()]; + }, [templates]); + + const filtered = useMemo(() => templates.filter(t => { + const matchesCategory = selectedCategory === 'All' || t.categories?.includes(selectedCategory); + const q = searchQuery.toLowerCase(); + const matchesSearch = !q || + t.title.toLowerCase().includes(q) || + t.description?.toLowerCase().includes(q) || + (t.categories && t.categories.join(' ').toLowerCase().includes(q)); + return matchesCategory && matchesSearch; + }), [templates, selectedCategory, searchQuery]); return (
@@ -196,11 +208,32 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { placeholder="Search App Store..." className="pl-8" value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => { setSearchQuery(e.target.value); }} />
+ {!loading && categories.length > 1 && ( +
+
+ {categories.map(cat => ( + + ))} +
+ + {filtered.length} app{filtered.length !== 1 ? 's' : ''} + +
+ )} +
{loading ? (
@@ -233,7 +266,12 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
{t.categories.slice(0, 3).map(c => ( - + { e.stopPropagation(); setSelectedCategory(c); }} + > {c} ))} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 9f0bbd1e..3d0537be 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -12,7 +12,7 @@ import { Slider } from '@/components/ui/slider'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server } from 'lucide-react'; +import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server, Package, RefreshCw } from 'lucide-react'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; @@ -32,11 +32,11 @@ interface SettingsModalProps { export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) { const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; - const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes'>('account'); + const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore'>('account'); // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { - if (isRemote && (activeSection === 'account' || activeSection === 'notifications' || activeSection === 'appearance' || activeSection === 'nodes')) { + if (isRemote && (activeSection === 'account' || activeSection === 'notifications' || activeSection === 'appearance' || activeSection === 'nodes' || activeSection === 'appstore')) { setActiveSection('system'); } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps @@ -63,6 +63,8 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se }); const [isLoading, setIsLoading] = useState(false); + const [registryUrl, setRegistryUrl] = useState(''); + const [isSavingRegistry, setIsSavingRegistry] = useState(false); useEffect(() => { if (isOpen) { @@ -93,12 +95,32 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se if (res.ok) { const data = await res.json(); setSettings(prev => ({ ...prev, ...data })); + if (data.template_registry_url) { + setRegistryUrl(data.template_registry_url); + } } } catch (e) { console.error('Failed to fetch settings', e); } }; + const saveRegistrySettings = async () => { + setIsSavingRegistry(true); + try { + await apiFetch('/settings', { + method: 'POST', + body: JSON.stringify({ key: 'template_registry_url', value: registryUrl.trim() }) + }); + // Bust the template cache so the next App Store load uses the new URL + await apiFetch('/templates/refresh-cache', { method: 'POST' }); + toast.success('Registry saved. App Store will reload from the new source.'); + } catch (e) { + toast.error('Failed to save registry settings.'); + } finally { + setIsSavingRegistry(false); + } + }; + const handleAgentChange = (type: string, field: keyof Agent, value: any) => { setAgents(prev => ({ ...prev, @@ -297,6 +319,16 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se Nodes )} + {!isRemote && ( + + )}
@@ -513,6 +545,62 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se )} + {activeSection === 'appstore' && ( +
+
+

App Store Registry

+

Configure the template source used by the App Store.

+
+ +
+
+
+ +

+ LinuxServer.io — https://api.linuxserver.io/api/v1/images +

+

Used when no custom registry is set.

+
+
+ +
+
+ +

+ Provide a URL pointing to a Portainer v2 compatible template JSON file. Overrides the default registry. +

+
+ setRegistryUrl(e.target.value)} + /> +

+ Leave empty to use the default LinuxServer.io registry. +

+
+
+ +
+ + +
+
+ )} +