diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d94c415f..9f1c7aa5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -107,7 +107,9 @@ export class APIClient { // empty or non-JSON error body — fall through to status-based message } const errorMessage = - (typeof errorData.error === "object" ? (errorData.error as any)?.message : errorData.error) || + (typeof errorData.error === "object" + ? (errorData.error as any)?.message + : errorData.error) || errorData.message || `HTTP ${response.status}: ${response.statusText}`; const errorDetails = @@ -125,7 +127,8 @@ export class APIClient { const errorMessage = (typeof data.error === "object" ? data.error?.message : data.error) || "API request failed"; - const errorDetails = (typeof data.error === "object" ? (data.error as any)?.details : "") || ""; + const errorDetails = + (typeof data.error === "object" ? (data.error as any)?.details : "") || ""; throw new APIError(response.status, errorMessage, errorDetails); } @@ -174,7 +177,9 @@ export class APIClient { try { const errorData = await response.json(); const errorMessage = - (typeof errorData.error === "object" ? (errorData.error as any)?.message : errorData.error) || + (typeof errorData.error === "object" + ? (errorData.error as any)?.message + : errorData.error) || errorData.message || `HTTP ${response.status}: ${response.statusText}`; const errorDetails = @@ -203,7 +208,8 @@ export class APIClient { const errorMessage = (typeof data.error === "object" ? data.error?.message : data.error) || "API request failed"; - const errorDetails = (typeof data.error === "object" ? (data.error as any)?.details : "") || ""; + const errorDetails = + (typeof data.error === "object" ? (data.error as any)?.details : "") || ""; throw new APIError(response.status, errorMessage, errorDetails); } @@ -526,11 +532,15 @@ export class APIClient { } async getProviderHistoricalStats(days = 30, interval = "daily") { - return this.request(`/system/provider-stats?days=${days}&interval=${interval}`); + return this.request( + `/system/provider-stats?days=${days}&interval=${interval}`, + ); } async getProviderSpeedHistory(days = 30) { - return this.request(`/system/provider-speed-history?days=${days}`); + return this.request( + `/system/provider-speed-history?days=${days}`, + ); } async directHealthCheck(id: number) { diff --git a/frontend/src/components/config/NetworkConfigSection.tsx b/frontend/src/components/config/NetworkConfigSection.tsx new file mode 100644 index 00000000..2c6e675e --- /dev/null +++ b/frontend/src/components/config/NetworkConfigSection.tsx @@ -0,0 +1,111 @@ +import { Globe } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { ConfigResponse, NetworkConfig } from "../../types/config"; + +interface NetworkConfigSectionProps { + config: ConfigResponse; + onUpdate?: (section: string, data: NetworkConfig) => Promise; + isReadOnly?: boolean; + isUpdating?: boolean; +} + +const emptyNetwork: NetworkConfig = { + http_proxy: "", + https_proxy: "", + no_proxy: "", +}; + +export function NetworkConfigSection({ + config, + onUpdate, + isReadOnly, + isUpdating, +}: NetworkConfigSectionProps) { + const baseline = config.network ?? emptyNetwork; + const [data, setData] = useState(baseline); + const [hasChanges, setHasChanges] = useState(false); + + useEffect(() => { + setData(config.network ?? emptyNetwork); + setHasChanges(false); + }, [config.network]); + + const handleChange = (field: keyof NetworkConfig, value: string) => { + const next: NetworkConfig = { ...data, [field]: value }; + setData(next); + setHasChanges(JSON.stringify(next) !== JSON.stringify(baseline)); + }; + + const handleSave = async () => { + if (!onUpdate || !hasChanges) return; + await onUpdate("network", data); + setHasChanges(false); + }; + + return ( +
+
+
+ +
+
+ Applied to every outbound HTTP request used for indexer search, NZB grabbing, Arrs + (Radarr/Sonarr/Lidarr/Readarr/Whisparr), SABnzbd fallback, and the NZBLNK resolver. + Internal endpoints (RC server, self-loopback) are not affected. Leave fields blank to + connect directly. Changes take effect on the next external request — restart AltMount if + you want long-lived clients to pick up the new proxy immediately. +
+
+ +
+ HTTP Proxy + handleChange("http_proxy", e.target.value)} + /> +

Used for plain HTTP outbound requests.

+
+ +
+ HTTPS Proxy + handleChange("https_proxy", e.target.value)} + /> +

Used for HTTPS outbound requests. May be the same as HTTP Proxy.

+
+ +
+ No Proxy + handleChange("no_proxy", e.target.value)} + /> +

Comma-separated hosts, IPs, or CIDRs that bypass the proxy.

+
+ + +
+ ); +} diff --git a/frontend/src/components/config/RCloneConfigSection.tsx b/frontend/src/components/config/RCloneConfigSection.tsx index a9a0231c..9adfbe9b 100644 --- a/frontend/src/components/config/RCloneConfigSection.tsx +++ b/frontend/src/components/config/RCloneConfigSection.tsx @@ -1,13 +1,4 @@ -import { KeyValueEditor } from "../ui/KeyValueEditor"; -import { - Eye, - EyeOff, - HardDrive, - Play, - Save, - Square, - TestTube, -} from "lucide-react"; +import { Eye, EyeOff, HardDrive, Play, Save, Square, TestTube } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useConfirm } from "../../contexts/ModalContext"; import { useToast } from "../../contexts/ToastContext"; @@ -17,6 +8,7 @@ import type { RCloneMountFormData, RCloneRCFormData, } from "../../types/config"; +import { KeyValueEditor } from "../ui/KeyValueEditor"; interface RCloneConfigSectionProps { config: ConfigResponse; @@ -92,9 +84,7 @@ export function RCloneConfigSection({ }); // Separate state for mount path since it's a root-level config - const [mountPath, setMountPath] = useState( - config.mount_path || "/mnt/remotes/altmount", - ); + const [mountPath, setMountPath] = useState(config.mount_path || "/mnt/remotes/altmount"); const [mountStatus, setMountStatus] = useState(null); const [hasChanges, setHasChanges] = useState(false); @@ -210,9 +200,7 @@ export function RCloneConfigSection({ rc_pass: "", rc_options: config.rclone.rc_options, }; - setHasChanges( - JSON.stringify(newFormData) !== JSON.stringify(initialFormData), - ); + setHasChanges(JSON.stringify(newFormData) !== JSON.stringify(initialFormData)); }; const handleMountInputChange = ( @@ -265,9 +253,7 @@ export function RCloneConfigSection({ use_mmap: config.rclone.use_mmap || false, links: config.rclone.links || false, }; - setHasMountChanges( - JSON.stringify(newMountFormData) !== JSON.stringify(initialMountFormData), - ); + setHasMountChanges(JSON.stringify(newMountFormData) !== JSON.stringify(initialMountFormData)); }; const handleMountPathChange = (value: string) => { @@ -423,9 +409,7 @@ export function RCloneConfigSection({ return (
-

- RClone Filesystem -

+

RClone Filesystem

Manage the virtual mount and Remote Control (RC) interface.

@@ -492,9 +476,7 @@ export function RCloneConfigSection({ className="select" value={mountFormData.log_level} disabled={isReadOnly} - onChange={(e) => - handleMountInputChange("log_level", e.target.value) - } + onChange={(e) => handleMountInputChange("log_level", e.target.value)} > @@ -514,10 +496,7 @@ export function RCloneConfigSection({ value={mountFormData.uid} disabled={isReadOnly} onChange={(e) => - handleMountInputChange( - "uid", - Number.parseInt(e.target.value, 10) || 1000, - ) + handleMountInputChange("uid", Number.parseInt(e.target.value, 10) || 1000) } placeholder="1000" /> @@ -532,10 +511,7 @@ export function RCloneConfigSection({ value={mountFormData.gid} disabled={isReadOnly} onChange={(e) => - handleMountInputChange( - "gid", - Number.parseInt(e.target.value, 10) || 1000, - ) + handleMountInputChange("gid", Number.parseInt(e.target.value, 10) || 1000) } placeholder="1000" /> @@ -549,9 +525,7 @@ export function RCloneConfigSection({ className="input" value={mountFormData.umask} disabled={isReadOnly} - onChange={(e) => - handleMountInputChange("umask", e.target.value) - } + onChange={(e) => handleMountInputChange("umask", e.target.value)} placeholder="002" />

File permission mask.

@@ -559,9 +533,7 @@ export function RCloneConfigSection({
-
- Security & Flags -
+
Security & Flags
Allow Other @@ -572,9 +544,7 @@ export function RCloneConfigSection({ className="checkbox" checked={mountFormData.allow_other} disabled={isReadOnly} - onChange={(e) => - handleMountInputChange("allow_other", e.target.checked) - } + onChange={(e) => handleMountInputChange("allow_other", e.target.checked)} />
@@ -589,10 +559,7 @@ export function RCloneConfigSection({ checked={mountFormData.allow_non_empty} disabled={isReadOnly} onChange={(e) => - handleMountInputChange( - "allow_non_empty", - e.target.checked, - ) + handleMountInputChange("allow_non_empty", e.target.checked) } /> @@ -607,9 +574,7 @@ export function RCloneConfigSection({ className="checkbox" checked={mountFormData.read_only} disabled={isReadOnly} - onChange={(e) => - handleMountInputChange("read_only", e.target.checked) - } + onChange={(e) => handleMountInputChange("read_only", e.target.checked)} /> @@ -617,9 +582,7 @@ export function RCloneConfigSection({
-
- VFS Cache Settings -
+
VFS Cache Settings
Cache Mode @@ -627,18 +590,14 @@ export function RCloneConfigSection({ className="select" value={mountFormData.vfs_cache_mode} disabled={isReadOnly} - onChange={(e) => - handleMountInputChange("vfs_cache_mode", e.target.value) - } + onChange={(e) => handleMountInputChange("vfs_cache_mode", e.target.value)} > -

- Determines how much data RClone caches locally. -

+

Determines how much data RClone caches locally.

@@ -648,9 +607,7 @@ export function RCloneConfigSection({ className="input" value={mountFormData.cache_dir} disabled={isReadOnly} - onChange={(e) => - handleMountInputChange("cache_dir", e.target.value) - } + onChange={(e) => handleMountInputChange("cache_dir", e.target.value)} placeholder="/config/cache" />

@@ -667,17 +624,10 @@ export function RCloneConfigSection({ className="input" value={mountFormData.vfs_cache_max_size} disabled={isReadOnly} - onChange={(e) => - handleMountInputChange( - "vfs_cache_max_size", - e.target.value, - ) - } + onChange={(e) => handleMountInputChange("vfs_cache_max_size", e.target.value)} placeholder="50G" /> -

- Maximum cache size (e.g., 50G, 1T). -

+

Maximum cache size (e.g., 50G, 1T).

@@ -687,32 +637,23 @@ export function RCloneConfigSection({ className="input" value={mountFormData.vfs_cache_max_age} disabled={isReadOnly} - onChange={(e) => - handleMountInputChange("vfs_cache_max_age", e.target.value) - } + onChange={(e) => handleMountInputChange("vfs_cache_max_age", e.target.value)} placeholder="504h" /> -

- Maximum cache age (e.g., 504h, 7d). -

+

Maximum cache age (e.g., 504h, 7d).

- - Cache Poll Interval - + Cache Poll Interval - handleMountInputChange( - "vfs_cache_poll_interval", - e.target.value, - ) + handleMountInputChange("vfs_cache_poll_interval", e.target.value) } placeholder="1m" /> @@ -728,22 +669,16 @@ export function RCloneConfigSection({ className="input" value={mountFormData.vfs_read_ahead} disabled={isReadOnly} - onChange={(e) => - handleMountInputChange("vfs_read_ahead", e.target.value) - } + onChange={(e) => handleMountInputChange("vfs_read_ahead", e.target.value)} placeholder="128M" /> -

- Read ahead size (e.g., 128M, 256M). -

+

Read ahead size (e.g., 128M, 256M).

-
- Performance Settings -
+
Performance Settings
Read Chunk Size @@ -757,52 +692,37 @@ export function RCloneConfigSection({ } placeholder="32M" /> -

- Initial read chunk size (e.g., 32M, 64M). -

+

Initial read chunk size (e.g., 32M, 64M).

- - Read Chunk Size Limit - + Read Chunk Size Limit - handleMountInputChange( - "vfs_read_chunk_size_limit", - e.target.value, - ) + handleMountInputChange("vfs_read_chunk_size_limit", e.target.value) } placeholder="2G" /> -

- Maximum read chunk size (e.g., 2G, 4G). -

+

Maximum read chunk size (e.g., 2G, 4G).

- - Directory Cache Time - + Directory Cache Time - handleMountInputChange("dir_cache_time", e.target.value) - } + onChange={(e) => handleMountInputChange("dir_cache_time", e.target.value)} placeholder="10m" /> -

- Directory cache time (e.g., 10m, 1h). -

+

Directory cache time (e.g., 10m, 1h).

@@ -821,32 +741,24 @@ export function RCloneConfigSection({ min="1" max="32" /> -

- Number of parallel transfers (1-32). -

+

Number of parallel transfers (1-32).

-
- Advanced Flags -
+
Advanced Flags
Async Read
@@ -860,9 +772,7 @@ export function RCloneConfigSection({ className="checkbox" checked={mountFormData.no_mod_time} disabled={isReadOnly} - onChange={(e) => - handleMountInputChange("no_mod_time", e.target.checked) - } + onChange={(e) => handleMountInputChange("no_mod_time", e.target.checked)} /> @@ -871,9 +781,7 @@ export function RCloneConfigSection({ {/* Custom Mount Options */}
-
- Custom Mount Options -
+
Custom Mount Options

Arbitrary flags to pass to the rclone mount command. (e.g.,{" "} no-modtime: true) @@ -881,9 +789,7 @@ export function RCloneConfigSection({ - handleMountInputChange("mount_options", val) - } + onChange={(val) => handleMountInputChange("mount_options", val)} keyPlaceholder="Flag (e.g. no-modtime)" valuePlaceholder="Value (e.g. true)" /> @@ -893,22 +799,16 @@ export function RCloneConfigSection({

{mountStatus && ( -
+
{mountStatus.mounted ? "Mounted" : "Not Mounted"}
{mountStatus.mounted && mountStatus.mount_point && ( -
- Mount point: {mountStatus.mount_point} -
- )} - {mountStatus.error && ( -
{mountStatus.error}
+
Mount point: {mountStatus.mount_point}
)} + {mountStatus.error &&
{mountStatus.error}
}
{mountStatus.mounted ? ( @@ -951,9 +851,7 @@ export function RCloneConfigSection({ className={`btn btn-primary px-10 ${hasMountChanges || hasMountPathChanges ? "shadow-lg shadow-primary/20" : "btn-ghost"}`} onClick={handleSaveMount} disabled={ - (!hasMountChanges && !hasMountPathChanges) || - isUpdating || - isMountLoading + (!hasMountChanges && !hasMountPathChanges) || isUpdating || isMountLoading } > {isUpdating ? ( @@ -985,18 +883,14 @@ export function RCloneConfigSection({ Enable connection for cache refresh notifications {mountFormData.mount_enabled && ( - - Managed by mount - + Managed by mount )} handleRCEnabledChange(e.target.checked)} /> @@ -1029,10 +923,7 @@ export function RCloneConfigSection({ value={formData.rc_port} disabled={isReadOnly || mountFormData.mount_enabled} onChange={(e) => - handleInputChange( - "rc_port", - Number.parseInt(e.target.value, 10) || 5572, - ) + handleInputChange("rc_port", Number.parseInt(e.target.value, 10) || 5572) } /> @@ -1059,9 +950,7 @@ export function RCloneConfigSection({ value={formData.rc_pass} disabled={isReadOnly || mountFormData.mount_enabled} onChange={(e) => handleInputChange("rc_pass", e.target.value)} - placeholder={ - config.rclone.rc_pass_set ? "********" : "admin" - } + placeholder={config.rclone.rc_pass_set ? "********" : "admin"} />
diff --git a/frontend/src/components/config/SABnzbdConfigSection.tsx b/frontend/src/components/config/SABnzbdConfigSection.tsx index 45ae0723..765e3fe0 100644 --- a/frontend/src/components/config/SABnzbdConfigSection.tsx +++ b/frontend/src/components/config/SABnzbdConfigSection.tsx @@ -246,7 +246,9 @@ export function SABnzbdConfigSection({
- History Retention (minutes) + + History Retention (minutes) + - handleInputChange("allow_symlinks_on_windows", e.target.checked) - } + onChange={(e) => handleInputChange("allow_symlinks_on_windows", e.target.checked)} />
diff --git a/frontend/src/components/system/ActivityHub.tsx b/frontend/src/components/system/ActivityHub.tsx index f4daeecb..34a9b694 100644 --- a/frontend/src/components/system/ActivityHub.tsx +++ b/frontend/src/components/system/ActivityHub.tsx @@ -1,7 +1,7 @@ import { + CheckCircle2, ChevronDown, ChevronUp, - CheckCircle2, Download, FileVideo, History, @@ -190,7 +190,7 @@ export function ActivityHub() {
{!isStalled && ( @@ -371,7 +371,7 @@ export function ActivityHub() { return (
- + {formatRelativeTime(item.completed_at)} {isExpanded ? ( @@ -420,7 +420,9 @@ export function ActivityHub() { Dest:
-
{item.virtual_path}
+
+ {item.virtual_path} +
{item.library_path && ( <> @@ -428,7 +430,7 @@ export function ActivityHub() { Library:
-
+
{item.library_path}
@@ -445,7 +447,7 @@ export function ActivityHub() { {item.metadata && ( <> -
+
Technical Details
{(() => { @@ -470,14 +472,14 @@ export function ActivityHub() { Encryption:
-
+
{meta.encryption}
)} ); - } catch (e) { + } catch (_e) { return null; } })()} @@ -502,4 +504,3 @@ export function ActivityHub() {
); } - diff --git a/frontend/src/pages/ConfigurationPage.tsx b/frontend/src/pages/ConfigurationPage.tsx index 37baeca8..294903a5 100644 --- a/frontend/src/pages/ConfigurationPage.tsx +++ b/frontend/src/pages/ConfigurationPage.tsx @@ -24,6 +24,7 @@ import { ComingSoonSection } from "../components/config/ComingSoonSection"; import { HealthConfigSection } from "../components/config/HealthConfigSection"; import { MetadataConfigSection } from "../components/config/MetadataConfigSection"; import { MountConfigSection } from "../components/config/MountConfigSection"; +import { NetworkConfigSection } from "../components/config/NetworkConfigSection"; import { NzblnkConfigSection } from "../components/config/NzblnkConfigSection"; import { ProvidersConfigSection } from "../components/config/ProvidersConfigSection"; import { SABnzbdConfigSection } from "../components/config/SABnzbdConfigSection"; @@ -53,6 +54,7 @@ import type { ImportConfig, LogFormData, MetadataConfig, + NetworkConfig, NzblnkConfig, ProviderConfig, SABnzbdConfig, @@ -99,7 +101,7 @@ const SECTION_GROUPS = [ }, { title: "System", - sections: ["auth", "system"], + sections: ["auth", "network", "system"], }, ]; @@ -275,6 +277,11 @@ export function ConfigurationPage() { section: "nzblnk", config: { nzblnk: data as unknown as NzblnkConfig }, }); + } else if (section === "network") { + await updateConfigSection.mutateAsync({ + section: "network", + config: { network: data as unknown as NetworkConfig }, + }); } else if (section === "log") { const logData = data as unknown as LogFormData & { profiler_enabled?: boolean }; const { profiler_enabled, ...logConfig } = logData; @@ -567,6 +574,13 @@ export function ConfigurationPage() { isUpdating={updateConfigSection.isPending} /> )} + {activeSection === "network" && ( + + )} {![ "webdav", "auth", @@ -581,6 +595,7 @@ export function ConfigurationPage() { "health", "stremio", "nzblnk", + "network", ].includes(activeSection) && ( { +const CustomTooltip = ({ + active, + payload, + label, +}: { + active?: boolean; + payload?: { value: number; dataKey: string; stroke: string }[]; + label?: string; +}) => { if (!active || !payload) return null; const sortedPayload = [...payload].sort((a, b) => b.value - a.value); @@ -19,7 +37,9 @@ const CustomTooltip = ({ active, payload, label }: { active?: boolean, payload?:

{label}

{sortedPayload.map((p) => (
- {p.dataKey}: + + {p.dataKey}: + {formatBytes(p.value)}
))} @@ -33,7 +53,7 @@ const CustomTooltip = ({ active, payload, label }: { active?: boolean, payload?: export function ProviderChart() { const [days, setDays] = useState(30); - const [interval, setInterval] = useState("daily"); + const [interval, setInterval] = useState("daily"); const [activeProviders, setActiveProviders] = useState>({}); const { data: response, isLoading } = useProviderHistoricalStats(days, interval); @@ -50,15 +70,17 @@ export function ProviderChart() { const dateObj = new Date(stat.timestamp); const timeKey = dateObj.toISOString(); const timeLabel = dateObj.toLocaleString(undefined, { - month: 'short', day: 'numeric' + month: "short", + day: "numeric", }); - const normalizedID = stat.provider_id.split(':')[0]; + const normalizedID = stat.provider_id.split(":")[0]; if (!groupedByTime[timeKey]) groupedByTime[timeKey] = { name: timeLabel }; const currentVal = groupedByTime[timeKey][normalizedID]; - groupedByTime[timeKey][normalizedID] = ((typeof currentVal === 'number' ? currentVal : 0) + stat.bytes_downloaded); + groupedByTime[timeKey][normalizedID] = + (typeof currentVal === "number" ? currentVal : 0) + stat.bytes_downloaded; pTotals[normalizedID] = (pTotals[normalizedID] || 0) + stat.bytes_downloaded; total += stat.bytes_downloaded; @@ -66,7 +88,12 @@ export function ProviderChart() { const sortedProviders = Object.keys(pTotals).sort((a, b) => pTotals[b] - pTotals[a]); - return { chartData: Object.values(groupedByTime), providers: sortedProviders, totalUsage: total, providerTotals: pTotals }; + return { + chartData: Object.values(groupedByTime), + providers: sortedProviders, + totalUsage: total, + providerTotals: pTotals, + }; }, [response]); // Initialize active providers when providers load @@ -80,19 +107,26 @@ export function ProviderChart() { } }, [providers, activeProviders]); - if (isLoading) return
; + if (isLoading) + return ( +
+ +
+ ); const toggleProvider = (provider: string) => { - setActiveProviders(prev => ({ + setActiveProviders((prev) => ({ ...prev, - [provider]: !prev[provider] + [provider]: !prev[provider], })); }; - const pieData = providers.map(p => ({ - name: p, - value: providerTotals[p] - })).filter(d => activeProviders[d.name]); + const pieData = providers + .map((p) => ({ + name: p, + value: providerTotals[p], + })) + .filter((d) => activeProviders[d.name]); return (
@@ -100,22 +134,24 @@ export function ProviderChart() {

Data Usage Trends

-

Total: {formatBytes(totalUsage)} in the last {days} days

+

+ Total: {formatBytes(totalUsage)} in the last {days} days +

- + value={interval} + onChange={(e) => setInterval(e.target.value)} + > + + + + + Days: - setDays(Number(e.target.value))} @@ -130,30 +166,40 @@ export function ProviderChart() { {providers.map((p, i) => ( - - + + ))} - + } /> - toggleProvider(e.dataKey as string)} - wrapperStyle={{ cursor: 'pointer', fontSize: '12px' }} + toggleProvider(e.dataKey as string)} + wrapperStyle={{ cursor: "pointer", fontSize: "12px" }} {...({ payload: providers.map((p, i) => ({ value: p, - type: 'rect', + type: "rect", id: p, color: COLORS[i % COLORS.length], dataKey: p, - inactive: !activeProviders[p] - })) + inactive: !activeProviders[p], + })), } as any)} formatter={(value, entry: any) => ( - + {value} )} @@ -161,17 +207,19 @@ export function ProviderChart() { {[...providers].reverse().map((p) => { const i = providers.indexOf(p); const color = COLORS[i % COLORS.length]; - return activeProviders[p] && ( - + return ( + activeProviders[p] && ( + + ) ); })} @@ -189,12 +237,20 @@ export function ProviderChart() { dataKey="value" > {pieData.map((entry, index) => ( - + ))} - formatBytes(value)} - contentStyle={{ borderRadius: '8px', border: '1px solid hsl(var(--bc) / 0.2)', backgroundColor: 'hsl(var(--b1))', fontSize: '12px' }} + contentStyle={{ + borderRadius: "8px", + border: "1px solid hsl(var(--bc) / 0.2)", + backgroundColor: "hsl(var(--b1))", + fontSize: "12px", + }} /> @@ -203,4 +259,4 @@ export function ProviderChart() {
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderHealth.tsx b/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderHealth.tsx index 98e77552..8b40e94d 100644 --- a/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderHealth.tsx +++ b/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderHealth.tsx @@ -1,83 +1,137 @@ +import { + Activity, + ActivitySquare, + AlertTriangle, + ArrowDown, + ArrowUp, + ArrowUpDown, + CheckCircle2, + Gauge, + Info, + RefreshCw, + Wifi, + WifiOff, + XCircle, +} from "lucide-react"; import { useState } from "react"; -import { Activity, AlertTriangle, Wifi, WifiOff, ActivitySquare, ArrowUpDown, ArrowUp, ArrowDown, CheckCircle2, XCircle, Info, Gauge, RefreshCw } from "lucide-react"; -import { usePoolMetrics, useProviderSpeedHistory, useTestProviderSpeed } from "../../../../hooks/useApi"; +import { Line, LineChart, ResponsiveContainer, YAxis } from "recharts"; +import { useToast } from "../../../../contexts/ToastContext"; +import { + usePoolMetrics, + useProviderSpeedHistory, + useTestProviderSpeed, +} from "../../../../hooks/useApi"; import { formatBytes, formatRelativeTime } from "../../../../lib/utils"; +import type { ProviderSpeedTestHistoryStat, ProviderStatus } from "../../../../types/api"; import { ProviderChart } from "./ProviderChart"; import { ProviderQuota } from "./ProviderQuota"; import { ProviderSpeedChart } from "./ProviderSpeedChart"; -import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts"; -import type { ProviderSpeedTestHistoryStat, ProviderStatus } from "../../../../types/api"; -import { useToast } from "../../../../contexts/ToastContext"; -type SortField = 'host' | 'state' | 'used_connections' | 'missing_count' | 'current_speed_bytes_per_sec' | 'last_speed_test_mbps' | 'ping_ms' | 'error_count' | 'health_score'; -type SortDirection = 'asc' | 'desc'; +type SortField = + | "host" + | "state" + | "used_connections" + | "missing_count" + | "current_speed_bytes_per_sec" + | "last_speed_test_mbps" + | "ping_ms" + | "error_count" + | "health_score"; +type SortDirection = "asc" | "desc"; -const SortIcon = ({ field, sortField, sortDirection }: { field: SortField, sortField: SortField, sortDirection: SortDirection }) => { +const SortIcon = ({ + field, + sortField, + sortDirection, +}: { + field: SortField; + sortField: SortField; + sortDirection: SortDirection; +}) => { if (sortField !== field) return ; - return sortDirection === 'asc' ? : ; + return sortDirection === "asc" ? ( + + ) : ( + + ); }; const calculateHealthScore = (provider: ProviderStatus) => { - let score = 100; - - // State penalty - if (provider.state !== 'connected' && provider.state !== 'active') { - return 0; // If disconnected, health is 0 - } - - // Ping penalty - if (provider.ping_ms > 1000) score -= 40; - else if (provider.ping_ms > 500) score -= 25; - else if (provider.ping_ms > 200) score -= 10; - else if (provider.ping_ms > 100) score -= 5; - - // Error penalty - score -= Math.min(30, provider.error_count * 5); - - // Missing count penalty (warning indicator) - if (provider.missing_warning) { - score -= 20; - } - if (provider.missing_count > 5000) score -= 15; - else if (provider.missing_count > 1000) score -= 10; - - return Math.max(0, score); + let score = 100; + + // State penalty + if (provider.state !== "connected" && provider.state !== "active") { + return 0; // If disconnected, health is 0 + } + + // Ping penalty + if (provider.ping_ms > 1000) score -= 40; + else if (provider.ping_ms > 500) score -= 25; + else if (provider.ping_ms > 200) score -= 10; + else if (provider.ping_ms > 100) score -= 5; + + // Error penalty + score -= Math.min(30, provider.error_count * 5); + + // Missing count penalty (warning indicator) + if (provider.missing_warning) { + score -= 20; + } + if (provider.missing_count > 5000) score -= 15; + else if (provider.missing_count > 1000) score -= 10; + + return Math.max(0, score); }; const HealthIndicator = ({ score }: { score: number }) => { - let colorClass = "text-success"; - let icon = ; - - if (score < 50) { - colorClass = "text-error"; - icon = ; - } else if (score < 85) { - colorClass = "text-warning"; - icon = ; - } - - return ( -
- {icon} - {score}% -
- ); + let colorClass = "text-success"; + let icon = ; + + if (score < 50) { + colorClass = "text-error"; + icon = ; + } else if (score < 85) { + colorClass = "text-warning"; + icon = ; + } + + return ( +
+ {icon} + {score}% +
+ ); }; // Sparkline component for speed history -const SpeedHistorySparkline = ({ providerId, historyData }: { providerId: string, historyData: ProviderSpeedTestHistoryStat[] }) => { - const providerHistory = historyData?.filter(h => h.provider_id === providerId) || []; +const SpeedHistorySparkline = ({ + providerId, + historyData, +}: { + providerId: string; + historyData: ProviderSpeedTestHistoryStat[]; +}) => { + const providerHistory = historyData?.filter((h) => h.provider_id === providerId) || []; // sort by created_at asc - const sortedHistory = [...providerHistory].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); - + const sortedHistory = [...providerHistory].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), + ); + if (sortedHistory.length < 2) return ; return (
- - + +
@@ -90,8 +144,8 @@ export function ProviderHealth() { const testSpeed = useTestProviderSpeed(); const { showToast } = useToast(); - const [sortField, setSortField] = useState('host'); - const [sortDirection, setSortDirection] = useState('asc'); + const [sortField, setSortField] = useState("host"); + const [sortDirection, setSortDirection] = useState("asc"); const [testingId, setTestingId] = useState(null); if (isLoading) { @@ -126,17 +180,27 @@ export function ProviderHealth() { return sum; }, 0); - const connectionPercent = totalMaxConnections > 0 ? Math.round((totalUsedConnections / totalMaxConnections) * 100) : 0; + const connectionPercent = + totalMaxConnections > 0 ? Math.round((totalUsedConnections / totalMaxConnections) * 100) : 0; - const maxedProviders = data.providers.filter(p => p.quota_bytes && p.quota_bytes > 0 && p.quota_used && p.quota_used >= p.quota_bytes); - const nearMaxProviders = data.providers.filter(p => p.quota_bytes && p.quota_bytes > 0 && p.quota_used && p.quota_used >= p.quota_bytes * 0.85 && p.quota_used < p.quota_bytes); + const maxedProviders = data.providers.filter( + (p) => p.quota_bytes && p.quota_bytes > 0 && p.quota_used && p.quota_used >= p.quota_bytes, + ); + const nearMaxProviders = data.providers.filter( + (p) => + p.quota_bytes && + p.quota_bytes > 0 && + p.quota_used && + p.quota_used >= p.quota_bytes * 0.85 && + p.quota_used < p.quota_bytes, + ); const handleSort = (field: SortField) => { if (sortField === field) { - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); } else { setSortField(field); - setSortDirection('desc'); // Default to desc for most metrics + setSortDirection("desc"); // Default to desc for most metrics } }; @@ -160,25 +224,27 @@ export function ProviderHealth() { } }; - const sortedProviders = [...data.providers].map(p => ({ ...p, health_score: calculateHealthScore(p) })).sort((a, b) => { - const aRaw = a[sortField as keyof typeof a]; - const bRaw = b[sortField as keyof typeof b]; + const sortedProviders = [...data.providers] + .map((p) => ({ ...p, health_score: calculateHealthScore(p) })) + .sort((a, b) => { + const aRaw = a[sortField as keyof typeof a]; + const bRaw = b[sortField as keyof typeof b]; - let aValue: string | number = 0; - let bValue: string | number = 0; + let aValue: string | number = 0; + let bValue: string | number = 0; - if (sortField === 'host' || sortField === 'state') { - aValue = aRaw?.toString().toLowerCase() || ''; - bValue = bRaw?.toString().toLowerCase() || ''; - } else { - aValue = Number(aRaw) || 0; - bValue = Number(bRaw) || 0; - } + if (sortField === "host" || sortField === "state") { + aValue = aRaw?.toString().toLowerCase() || ""; + bValue = bRaw?.toString().toLowerCase() || ""; + } else { + aValue = Number(aRaw) || 0; + bValue = Number(bRaw) || 0; + } - if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1; - if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1; - return 0; - }); + if (aValue < bValue) return sortDirection === "asc" ? -1 : 1; + if (aValue > bValue) return sortDirection === "asc" ? 1 : -1; + return 0; + }); return (
@@ -188,10 +254,10 @@ export function ProviderHealth() {

Quota Exceeded

- {maxedProviders.length === 1 + {maxedProviders.length === 1 ? `${maxedProviders[0].host} has reached its data limit. Downloads from this provider are paused.` - : `${maxedProviders.length} providers have reached their data limits. Downloads from these providers are paused.`} - {" "}You can reset the quota manually below. + : `${maxedProviders.length} providers have reached their data limits. Downloads from these providers are paused.`}{" "} + You can reset the quota manually below.
@@ -202,7 +268,7 @@ export function ProviderHealth() {

Quota Warning

- {nearMaxProviders.length === 1 + {nearMaxProviders.length === 1 ? `${nearMaxProviders[0].host} is approaching its data limit.` : `${nearMaxProviders.length} providers are approaching their data limits.`}
@@ -247,9 +313,15 @@ export function ProviderHealth() {
-
{connectionPercent}% @@ -275,41 +347,123 @@ export function ProviderHealth() {

Provider Status

-
- - Real-time stats updated every 5s -
+
+ + Real-time stats updated every 5s +
- - - - - - - - - @@ -323,7 +477,7 @@ export function ProviderHealth() { @@ -398,9 +556,9 @@ export function ProviderHealth() { )} {speedHistoryResponse?.history && ( - )} @@ -410,7 +568,7 @@ export function ProviderHealth() {
handleSort('host')}> -
Provider Host
+
handleSort("host")} + > +
+ Provider Host{" "} + +
handleSort('health_score')}> -
Health
+
handleSort("health_score")} + > +
+ Health{" "} + +
handleSort('state')}> -
State
+
handleSort("state")} + > +
+ State{" "} + +
handleSort('used_connections')}> -
Connections
+
handleSort("used_connections")} + > +
+ Connections{" "} + +
handleSort('ping_ms')}> -
Ping
+
handleSort("ping_ms")} + > +
+ Ping{" "} + +
handleSort('error_count')}> -
Errors
+
handleSort("error_count")} + > +
+ Errors{" "} + +
handleSort('missing_count')}> -
Missing
+
handleSort("missing_count")} + > +
+ Missing{" "} + +
handleSort('current_speed_bytes_per_sec')}> -
Current Speed
+
handleSort("current_speed_bytes_per_sec")} + > +
+ Current Speed{" "} + +
handleSort('last_speed_test_mbps')}> -
Top Speed
+
handleSort("last_speed_test_mbps")} + > +
+ Top Speed{" "} + +
Actions
- +
@@ -353,12 +507,16 @@ export function ProviderHealth() {
- 200 ? 'text-warning' : provider.ping_ms > 500 ? 'text-error' : ''}`}> - {provider.ping_ms > 0 ? `${provider.ping_ms}ms` : '-'} + 200 ? "text-warning" : provider.ping_ms > 500 ? "text-error" : ""}`} + > + {provider.ping_ms > 0 ? `${provider.ping_ms}ms` : "-"} - 0 ? 'text-error' : ''}`}> + 0 ? "text-error" : ""}`} + > {provider.error_count}
-
- +
{providersWithQuota.map((provider: ProviderStatus) => { const used = provider.quota_used || 0; const total = provider.quota_bytes || 0; const percentage = total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0; - + const isWarning = percentage >= 80 && percentage < 95; const isError = percentage >= 95; - + let progressClass = "progress-primary"; if (isError) progressClass = "progress-error"; else if (isWarning) progressClass = "progress-warning"; @@ -68,7 +70,9 @@ export function ProviderQuota() { disabled={resettingId === provider.id} title="Reset Quota" > - + )}
@@ -85,12 +89,14 @@ export function ProviderQuota() {
- - + {percentage}%
diff --git a/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderSpeedChart.tsx b/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderSpeedChart.tsx index 91167d00..7e041226 100644 --- a/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderSpeedChart.tsx +++ b/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderSpeedChart.tsx @@ -1,12 +1,22 @@ +import { Activity } from "lucide-react"; import { useMemo, useState } from "react"; -import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend, PieChart, Pie, Cell } from "recharts"; -import { useProviderSpeedHistory, usePoolMetrics } from "../../../../hooks/useApi"; +import { + Area, + AreaChart, + CartesianGrid, + Cell, + Legend, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; import { LoadingSpinner } from "../../../../components/ui/LoadingSpinner"; -import { Activity } from "lucide-react"; +import { usePoolMetrics, useProviderSpeedHistory } from "../../../../hooks/useApi"; -const COLORS = [ - "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4" -]; +const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4"]; const CustomTooltip = ({ active, payload, label }: any) => { if (!active || !payload) return null; @@ -19,7 +29,9 @@ const CustomTooltip = ({ active, payload, label }: any) => {

{label}

{sortedPayload.map((p) => (
- {p.dataKey}: + + {p.dataKey}: + {p.value.toFixed(2)} Mbps
))} @@ -35,37 +47,40 @@ export function ProviderSpeedChart() { const [days, setDays] = useState(7); const [activeProviders, setActiveProviders] = useState>({}); const { data: historyResponse, isLoading: historyLoading } = useProviderSpeedHistory(days); - const { data: poolData } = usePoolMetrics(); + const { data: poolData } = usePoolMetrics(); const { chartData, providers, providerMaxes } = useMemo(() => { if (!historyResponse?.history) return { chartData: [], providers: [], providerMaxes: {} }; const grouped: Record = {}; - const maxes: Record = {}; - - historyResponse.history.forEach(stat => { + const maxes: Record = {}; + + historyResponse.history.forEach((stat) => { const date = new Date(stat.created_at); - const timestamp = date.toLocaleString(undefined, { - month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' - }); - - if (!grouped[timestamp]) { - grouped[timestamp] = { name: timestamp }; - } - - const provider = poolData?.providers.find(p => p.id === stat.provider_id); - const label = provider ? provider.host : stat.provider_id; - - grouped[timestamp][label] = stat.speed_mbps; - maxes[label] = Math.max(maxes[label] || 0, stat.speed_mbps); + const timestamp = date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + + if (!grouped[timestamp]) { + grouped[timestamp] = { name: timestamp }; + } + + const provider = poolData?.providers.find((p) => p.id === stat.provider_id); + const label = provider ? provider.host : stat.provider_id; + + grouped[timestamp][label] = stat.speed_mbps; + maxes[label] = Math.max(maxes[label] || 0, stat.speed_mbps); }); - const sortedProviders = Object.keys(maxes).sort((a, b) => maxes[b] - maxes[a]); + const sortedProviders = Object.keys(maxes).sort((a, b) => maxes[b] - maxes[a]); return { chartData: Object.values(grouped), providers: sortedProviders, providerMaxes: maxes }; }, [historyResponse, poolData]); - // Initialize active providers when providers load + // Initialize active providers when providers load useMemo(() => { if (providers.length > 0 && Object.keys(activeProviders).length === 0) { const initialActive: Record = {}; @@ -76,19 +91,26 @@ export function ProviderSpeedChart() { } }, [providers, activeProviders]); - if (historyLoading) return
; + if (historyLoading) + return ( +
+ +
+ ); - const toggleProvider = (provider: string) => { - setActiveProviders(prev => ({ + const toggleProvider = (provider: string) => { + setActiveProviders((prev) => ({ ...prev, - [provider]: !prev[provider] + [provider]: !prev[provider], })); }; - const pieData = providers.map(p => ({ - name: p, - value: providerMaxes[p] - })).filter(d => activeProviders[d.name]); + const pieData = providers + .map((p) => ({ + name: p, + value: providerMaxes[p], + })) + .filter((d) => activeProviders[d.name]); return (
@@ -99,12 +121,14 @@ export function ProviderSpeedChart() { Speed Performance History -

Top speed (Mbps) per provider over time (stacked)

+

+ Top speed (Mbps) per provider over time (stacked) +

-
+
Days: - setDays(Number(e.target.value))} @@ -118,9 +142,16 @@ export function ProviderSpeedChart() { {providers.map((p, i) => ( - - - + + + ))} @@ -128,47 +159,56 @@ export function ProviderSpeedChart() { } /> - toggleProvider(e.dataKey as string)} - wrapperStyle={{ cursor: 'pointer', fontSize: '12px' }} - {...({ - payload: providers.map((p, i) => ({ - value: p, - type: 'rect', - id: p, - color: COLORS[i % COLORS.length], - dataKey: p, - inactive: !activeProviders[p] - })) - } as any)} - formatter={(value, entry: any) => ( - - {value} - - )} - /> + toggleProvider(e.dataKey as string)} + wrapperStyle={{ cursor: "pointer", fontSize: "12px" }} + {...({ + payload: providers.map((p, i) => ({ + value: p, + type: "rect", + id: p, + color: COLORS[i % COLORS.length], + dataKey: p, + inactive: !activeProviders[p], + })), + } as any)} + formatter={(value, entry: any) => ( + + {value} + + )} + /> {[...providers].reverse().map((p) => { - const i = providers.indexOf(p); - const color = COLORS[i % COLORS.length]; - return activeProviders[p] && ( - - ); - })} + const i = providers.indexOf(p); + const color = COLORS[i % COLORS.length]; + return ( + activeProviders[p] && ( + + ) + ); + })}
-
- Peak Performance +
+ + Peak Performance + {pieData.map((entry, index) => ( - + ))} - `${value.toFixed(2)} Mbps`} - contentStyle={{ borderRadius: '8px', border: '1px solid hsl(var(--bc) / 0.2)', backgroundColor: 'hsl(var(--b1))', fontSize: '12px' }} + contentStyle={{ + borderRadius: "8px", + border: "1px solid hsl(var(--bc) / 0.2)", + backgroundColor: "hsl(var(--b1))", + fontSize: "12px", + }} /> diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index f1512fc4..5aef9dc2 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -22,6 +22,7 @@ export interface ConfigResponse { stremio: StremioConfig; providers: ProviderConfig[]; nzblnk: NzblnkConfig; + network: NetworkConfig; mount_path: string; mount_type: MountType; api_key?: string; @@ -48,6 +49,16 @@ export interface AuthConfig { login_required: boolean; } +// Network proxy configuration for outbound HTTP (indexers, arrs, SABnzbd, NZBLNK). +// Empty strings disable proxying for that scheme. Mirrors standard +// HTTP_PROXY/HTTPS_PROXY/NO_PROXY env-var semantics. Internal endpoints +// (RC server, self-loopback) are not affected. +export interface NetworkConfig { + http_proxy: string; + https_proxy: string; + no_proxy: string; +} + // Database configuration export interface DatabaseConfig { type: string; @@ -321,6 +332,7 @@ export interface ConfigUpdateRequest { stremio?: Partial; providers?: ProviderUpdateRequest[]; nzblnk?: NzblnkConfig; + network?: NetworkConfig; mount_path?: string; mount_type?: MountType; profiler_enabled?: boolean; @@ -512,6 +524,7 @@ export type ConfigSection = | "arrs" | "stremio" | "nzblnk" + | "network" | "system"; // Form data interfaces for UI components @@ -946,6 +959,13 @@ export const CONFIG_SECTIONS: Record