diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b608d34..a5cba386 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:** Container stats WebSocket flooding React with up to 20+ `setState` calls per second in `EditorLayout` — each container's `onmessage` handler called `setContainerStats` independently, causing React to schedule a separate reconciliation pass per container per second. Replaced with a ref-buffer + 1.5 s flush interval pattern: incoming stats are written to `pendingStatsRef` (no re-render cost), snapshotted and cleared before a single batched `setContainerStats` call every 1.5 s. A separate `rawBytesRef` (never cleared) tracks raw rx/tx bytes for accurate rate calculation, avoiding the stale-closure bug that would have produced 0 B/s net I/O after every flush cycle. Buffer is also cleared on cleanup so stale entries from the previous stack don't flash in on stack switch. - **Fixed:** Browser Out of Memory crash in `GlobalObservabilityView` (Logs view) — the component rendered all log entries (up to 1,859+) as real DOM nodes with no virtualization, causing ~9,600 DOM nodes and ~25 MB of GC pressure every 5-second poll cycle. On RAM-constrained hosts (host system was at 97% usage) the browser renderer process OOMed within minutes. Fixed by capping DOM rendering to the last 300 entries (`MAX_DISPLAY_ROWS`), reducing the in-memory SSE log cap from 10,000 to 2,000 entries (`MAX_LOG_ENTRIES`), switching auto-scroll from `behavior: 'smooth'` (stacked layout animations) to `behavior: 'instant'`, and reducing the backend polling response limit from 2,000 to 500 lines. Also replaced `key={idx}` (array index) with a monotonic `_id` counter stamped at ingestion time so the slice window shifting no longer forces O(n) DOM mutations per new log line — React now only reconciles the one entry that actually changed. - **Fixed:** `HomeDashboard` create-stack error handling — HTTP error responses were thrown as hardcoded strings, discarding the server's actual error message (e.g. "Stack already exists", "Invalid stack name"). Now reads the JSON error body before throwing, and uses the defensive `error?.message || error?.error || fallback` toast pattern. diff --git a/backend/src/index.ts b/backend/src/index.ts index cdd69b67..afc8383c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1484,6 +1484,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. +

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