mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 12:18:59 +00:00
Merge pull request #47 from AnsoCode/feature/app-store-categories
feat(app-store): category filter bar + custom registry settings
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).
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>('All');
|
||||
const [imgErrors, setImgErrors] = useState<Record<string, boolean>>({});
|
||||
const [portVars, setPortVars] = useState<Record<string, string>>({});
|
||||
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<string>();
|
||||
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 (
|
||||
<div className="flex flex-col h-full space-y-6">
|
||||
@@ -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); }}
|
||||
/>
|
||||
</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">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
@@ -233,7 +266,12 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
<CardContent className="pt-0 mt-auto">
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{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}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -513,6 +545,62 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
<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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user