mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 14:45:41 +00:00
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:
@@ -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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [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:** 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:** 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.
|
- **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.
|
||||||
|
|||||||
@@ -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) => {
|
app.post('/api/templates/deploy', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { stackName, template, envVars } = req.body;
|
const { stackName, template, envVars } = req.body;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface Template {
|
|||||||
docs_url?: string;
|
docs_url?: string;
|
||||||
architectures?: string[];
|
architectures?: string[];
|
||||||
stars?: number;
|
stars?: number;
|
||||||
|
source?: string;
|
||||||
repository?: {
|
repository?: {
|
||||||
url: string;
|
url: string;
|
||||||
stackfile: string;
|
stackfile: string;
|
||||||
@@ -40,11 +41,165 @@ export interface TemplatesResponse {
|
|||||||
templates: Template[];
|
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 {
|
export class TemplateService {
|
||||||
private cachedTemplates: Template[] = [];
|
private cachedTemplates: Template[] = [];
|
||||||
private lastFetchTime: number = 0;
|
private lastFetchTime: number = 0;
|
||||||
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
private readonly CACHE_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
public clearCache(): void {
|
||||||
|
this.cachedTemplates = [];
|
||||||
|
this.lastFetchTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
public async getTemplates(): Promise<Template[]> {
|
public async getTemplates(): Promise<Template[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (this.cachedTemplates.length > 0 && now - this.lastFetchTime < this.CACHE_DURATION_MS) {
|
if (this.cachedTemplates.length > 0 && now - this.lastFetchTime < this.CACHE_DURATION_MS) {
|
||||||
@@ -73,6 +228,8 @@ export class TemplateService {
|
|||||||
docs_url: app.readme,
|
docs_url: app.readme,
|
||||||
architectures: app.arch,
|
architectures: app.arch,
|
||||||
stars: app.stars,
|
stars: app.stars,
|
||||||
|
categories: getCategoriesForApp(app.name),
|
||||||
|
source: 'linuxserver',
|
||||||
// Map configs if available, otherwise default to empty arrays
|
// 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'}`),
|
ports: (app.config?.ports || []).map((p: any) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`),
|
||||||
volumes: (app.config?.volumes || []).map((v: any) => {
|
volumes: (app.config?.volumes || []).map((v: any) => {
|
||||||
@@ -91,7 +248,10 @@ export class TemplateService {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Legacy Portainer v2 Format (Fallback for custom registries)
|
// 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;
|
this.lastFetchTime = now;
|
||||||
|
|||||||
@@ -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 { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -31,6 +31,7 @@ export interface Template {
|
|||||||
docs_url?: string;
|
docs_url?: string;
|
||||||
architectures?: string[];
|
architectures?: string[];
|
||||||
stars?: number;
|
stars?: number;
|
||||||
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppStoreViewProps {
|
interface AppStoreViewProps {
|
||||||
@@ -48,6 +49,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
const [isDeploying, setIsDeploying] = useState(false);
|
const [isDeploying, setIsDeploying] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('All');
|
||||||
const [imgErrors, setImgErrors] = useState<Record<string, boolean>>({});
|
const [imgErrors, setImgErrors] = useState<Record<string, boolean>>({});
|
||||||
const [portVars, setPortVars] = useState<Record<string, string>>({});
|
const [portVars, setPortVars] = useState<Record<string, string>>({});
|
||||||
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
||||||
@@ -180,11 +182,21 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filtered = templates.filter(t =>
|
const categories = useMemo(() => {
|
||||||
t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const cats = new Set<string>();
|
||||||
t.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
templates.forEach(t => t.categories?.forEach(c => cats.add(c)));
|
||||||
(t.categories && t.categories.join(' ').toLowerCase().includes(searchQuery.toLowerCase()))
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full space-y-6">
|
<div className="flex flex-col h-full space-y-6">
|
||||||
@@ -196,11 +208,32 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
placeholder="Search App Store..."
|
placeholder="Search App Store..."
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => { setSearchQuery(e.target.value); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!loading && categories.length > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex gap-1.5 overflow-x-auto pb-1 flex-1 scrollbar-none">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<Button
|
||||||
|
key={cat}
|
||||||
|
variant={selectedCategory === cat ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0 h-7 text-xs px-3 rounded-full"
|
||||||
|
onClick={() => setSelectedCategory(cat)}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
|
||||||
|
{filtered.length} app{filtered.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-48">
|
<div className="flex items-center justify-center h-48">
|
||||||
@@ -233,7 +266,12 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
<CardContent className="pt-0 mt-auto">
|
<CardContent className="pt-0 mt-auto">
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{t.categories.slice(0, 3).map(c => (
|
{t.categories.slice(0, 3).map(c => (
|
||||||
<Badge variant="secondary" key={c} className="text-[10px] px-1.5 py-0 pb-0.5">
|
<Badge
|
||||||
|
variant={selectedCategory === c ? 'default' : 'secondary'}
|
||||||
|
key={c}
|
||||||
|
className="text-[10px] px-1.5 py-0 pb-0.5 cursor-pointer"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setSelectedCategory(c); }}
|
||||||
|
>
|
||||||
{c}
|
{c}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Slider } from '@/components/ui/slider';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
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 { NodeManager } from './NodeManager';
|
||||||
import { useNodes } from '@/context/NodeContext';
|
import { useNodes } from '@/context/NodeContext';
|
||||||
|
|
||||||
@@ -32,11 +32,11 @@ interface SettingsModalProps {
|
|||||||
export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) {
|
export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) {
|
||||||
const { activeNode } = useNodes();
|
const { activeNode } = useNodes();
|
||||||
const isRemote = activeNode?.type === 'remote';
|
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
|
// When switching to a remote node, reset to a node-scoped section if on a global-only one
|
||||||
useEffect(() => {
|
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');
|
setActiveSection('system');
|
||||||
}
|
}
|
||||||
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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 [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [registryUrl, setRegistryUrl] = useState('');
|
||||||
|
const [isSavingRegistry, setIsSavingRegistry] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -93,12 +95,32 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSettings(prev => ({ ...prev, ...data }));
|
setSettings(prev => ({ ...prev, ...data }));
|
||||||
|
if (data.template_registry_url) {
|
||||||
|
setRegistryUrl(data.template_registry_url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch settings', 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) => {
|
const handleAgentChange = (type: string, field: keyof Agent, value: any) => {
|
||||||
setAgents(prev => ({
|
setAgents(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -297,6 +319,16 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
|||||||
Nodes
|
Nodes
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!isRemote && (
|
||||||
|
<Button
|
||||||
|
variant={activeSection === 'appstore' ? 'secondary' : 'ghost'}
|
||||||
|
className="w-full justify-start font-medium"
|
||||||
|
onClick={() => setActiveSection('appstore')}
|
||||||
|
>
|
||||||
|
<Package className="w-4 h-4 mr-2" />
|
||||||
|
App Store
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -513,6 +545,62 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
|||||||
<NodeManager />
|
<NodeManager />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'appstore' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight">App Store Registry</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Configure the template source used by the App Store.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-base">Default Registry</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
LinuxServer.io — <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Used when no custom registry is set.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 pt-4 border-t border-border">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-base">Custom Registry URL</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Provide a URL pointing to a <span className="font-medium">Portainer v2</span> compatible template JSON file. Overrides the default registry.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/templates.json"
|
||||||
|
value={registryUrl}
|
||||||
|
onChange={(e) => setRegistryUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leave empty to use the default LinuxServer.io registry.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRegistryUrl('')}
|
||||||
|
disabled={isSavingRegistry || registryUrl === ''}
|
||||||
|
>
|
||||||
|
Reset to Default
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveRegistrySettings} disabled={isSavingRegistry}>
|
||||||
|
{isSavingRegistry ? (
|
||||||
|
<><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
|
||||||
|
) : (
|
||||||
|
'Save & Refresh'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user