mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Merge pull request #1308 from sv-dispatcharr:feat/disable-public-ip
feat: blur public IP display, add IP lookup toggle with env var override, make lookup non-blocking
This commit is contained in:
commit
ac2eb2e3ad
14 changed files with 260 additions and 95 deletions
|
|
@ -9,11 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- **Public IP display in the sidebar is now blurred by default and reveals on click.** Prevents accidental exposure in screenshots and screen shares. A new toggle in Settings > System Settings lets users disable IP and geolocation fetching entirely. A `DISPATCHARR_ENABLE_IP_LOOKUP` environment variable provides a container-level override; when set to `false` the toggle is hidden from the UI and cannot be changed. (Closes #1302) — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Configurable per-page count and sticky pagination footer in Plugin Browse.** The pagination controls now live in a fixed footer bar at the bottom of the page. A page-size selector (9 / 18 / 27 / 36) sits alongside the pagination widget and an item range readout (`X to Y of Z`). The selected page size is persisted in `localStorage` so it survives page navigations. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **VOD basic sync now stores additional metadata from the provider's stream list.** When a provider includes `director`, `cast`, `release_date`, or `trailer`/`youtube_trailer` in its `get_vod_streams` response, Dispatcharr now captures and stores those fields in `custom_properties` during the initial sync pass. Previously, this data was discarded and only populated if an advanced per-movie refresh was triggered. - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
|
||||
### Changed
|
||||
|
||||
- **IP lookup result delivered via WebSocket push.** When the background lookup completes, an `ip_lookup_complete` event is pushed to all connected clients so the sidebar IP field populates without polling. A `Skeleton` placeholder is shown while the lookup is in progress.
|
||||
- **`get_host_and_port` and `build_absolute_uri_with_port` moved from `apps/output/views.py` to `core/utils.py`.** Both helpers have no dependencies on anything in `apps/output` and are now used in `apps/channels/serializers.py` as well. Moving them to `core/utils` eliminates the need for a local import inside `LogoSerializer` and makes them available to the rest of the codebase without circular-import risk. `LogoSerializer.get_cache_url()` was also updated to use `build_absolute_uri_with_port` instead of `request.build_absolute_uri()`, so logo cache URLs now correctly include non-standard ports (fixing port-stripping for logo URLs behind reverse proxies, matching the existing fix applied to M3U and EPG URLs).
|
||||
- **`debian_install.sh` switched from Gunicorn to uWSGI with gevent workers.** The Debian/LXC bare-metal installer now deploys Dispatcharr under the same uWSGI + gevent stack used by the Docker image, eliminating a class of compatibility differences between the two deployment paths. The installer writes a `uwsgi-debian.ini` next to the app and manages uWSGI via a systemd service. The nginx site config now uses `uwsgi_pass` + `include uwsgi_params` instead of `proxy_pass`, which correctly populates `SERVER_PORT` in the WSGI environ so M3U playlist URLs include the configured port number (fixing the port-stripping bug from #1267 for bare-metal installs). Python 3.13 is now provisioned through uv's managed runtime so the install works on Debian 12 and Ubuntu 24.04 LTS regardless of the system Python version.
|
||||
- **`get_vod_streams` XC API response was missing metadata fields available from basic sync.** When a provider includes `director`, `cast`, `release_date`, `plot`, `genre`, or `year` in its `get_vod_streams` response, Dispatcharr stores those fields during the basic sync pass but was not including them in its own `get_vod_streams` XC output. The endpoint now outputs all six fields. The `trailer` key in the response also mapped to the wrong internal key (`trailer` instead of `youtube_trailer`) following the storage key rename, so the trailer field was always empty until an advanced refresh ran. Both issues are corrected. Users with existing libraries should trigger a VOD provider refresh to populate the missing fields. (Fixes #1228)
|
||||
|
|
@ -34,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Fixed
|
||||
|
||||
- **IP lookup no longer blocks the settings page load.** The `environment` endpoint previously made up to three sequential HTTP calls (ipify, ipapi.co, ip-api.com) with 5s timeouts each, blocking the page for up to 15s if any were unreachable. The lookups now run in a background thread on first request and results are cached in Redis for 1 hour, so the endpoint returns immediately on every call. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Authenticated users were not identified in VOD connection cards and stream events when streaming via the web player.** The VOD proxy now accepts a JWT via a `?token=` query parameter so browser `<video>` elements (which cannot send `Authorization` headers) can authenticate. The token is read on the initial request, the resolved user ID is stored in Redis, then retrieved on the actual streaming request after the session redirect. Previously the redirect discarded auth context and all JWT-authenticated VOD sessions were tracked as anonymous. (Fixes #1224)
|
||||
- **Deleting the last playlist (or any playlist) crashed the entire UI.** `removePlaylists()` in the playlists store filtered the `playlists` array but never removed the corresponding entries from the `profiles` map. After deletion, components reading `profiles[deletedId]` received `undefined` and crashed with `undefined is not an object (evaluating 'P[R].name')`, replacing the entire page with an error screen. The profiles map is now cleaned up atomically in the same state update as the playlists array. (Fixes #1269) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Switching providers in the VOD or Series detail modal had no effect.** The `provider-info` endpoints for movies and series always fetched data from the highest-priority provider, ignoring the `relation_id` the frontend sent when the user selected a different provider from the dropdown. The endpoints now accept an optional `relation_id` query parameter and fetch from that specific relation. The VOD modal also now shows the "Loading additional details..." indicator while the provider switch is in flight, matching the existing behaviour in the Series modal. (Fixes #1285) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
# core/api_views.py
|
||||
|
||||
import json
|
||||
import ipaddress
|
||||
import logging
|
||||
from django.conf import settings as django_settings
|
||||
|
|
@ -8,11 +7,9 @@ from django.db import models
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.decorators import api_view, permission_classes, action
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from .models import (
|
||||
UserAgent,
|
||||
StreamProfile,
|
||||
|
|
@ -32,8 +29,10 @@ from .serializers import (
|
|||
)
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import requests
|
||||
import os
|
||||
from django.core.cache import cache
|
||||
from core.tasks import rehash_streams
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
|
|
@ -287,6 +286,73 @@ class ProxySettingsViewSet(viewsets.ViewSet):
|
|||
|
||||
|
||||
|
||||
_IP_CACHE_KEY = "dispatcharr:ip_lookup_result"
|
||||
_IP_CACHE_TTL = 3600 # 1 hour
|
||||
_IP_LOCK_KEY = "dispatcharr:ip_lookup_lock"
|
||||
|
||||
|
||||
def _perform_ip_lookup():
|
||||
"""Run IP and geolocation lookups in a background thread and cache the result."""
|
||||
public_ip = None
|
||||
local_ip = None
|
||||
country_code = None
|
||||
country_name = None
|
||||
city = None
|
||||
|
||||
try:
|
||||
r = requests.get("https://api64.ipify.org?format=json", timeout=5)
|
||||
r.raise_for_status()
|
||||
public_ip = r.json().get("ip")
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("203.0.113.1", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
finally:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
ipaddress.ip_address(public_ip)
|
||||
except (ValueError, TypeError):
|
||||
public_ip = None
|
||||
|
||||
if public_ip:
|
||||
try:
|
||||
r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5)
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("country_code")
|
||||
country_name = geo.get("country_name")
|
||||
city = geo.get("city")
|
||||
else:
|
||||
r = requests.get("http://ip-api.com/json/", timeout=5)
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("countryCode")
|
||||
country_name = geo.get("country")
|
||||
city = geo.get("city")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during geo lookup: {e}")
|
||||
|
||||
result = {
|
||||
"public_ip": public_ip,
|
||||
"local_ip": local_ip,
|
||||
"country_code": country_code,
|
||||
"country_name": country_name,
|
||||
"city": city,
|
||||
}
|
||||
cache.set(_IP_CACHE_KEY, result, _IP_CACHE_TTL)
|
||||
cache.delete(_IP_LOCK_KEY)
|
||||
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update("updates", "update", {"type": "ip_lookup_complete", **result})
|
||||
|
||||
|
||||
@extend_schema(
|
||||
description="Endpoint for environment details",
|
||||
)
|
||||
|
|
@ -297,55 +363,27 @@ def environment(request):
|
|||
local_ip = None
|
||||
country_code = None
|
||||
country_name = None
|
||||
city = None
|
||||
ip_lookup_pending = False
|
||||
|
||||
# 1) Get the public IP from ipify.org API
|
||||
try:
|
||||
r = requests.get("https://api64.ipify.org?format=json", timeout=5)
|
||||
r.raise_for_status()
|
||||
public_ip = r.json().get("ip")
|
||||
except requests.RequestException as e:
|
||||
public_ip = f"Error: {e}"
|
||||
ip_lookup_env_disabled = not getattr(django_settings, "ENABLE_IP_LOOKUP", True)
|
||||
ip_lookup_db_enabled = CoreSettings.get_system_settings().get("enable_ip_lookup", True)
|
||||
ip_lookup_enabled = not ip_lookup_env_disabled and ip_lookup_db_enabled
|
||||
|
||||
# 2) Get the local IP by connecting to a public DNS server
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
# connect to a "public" address so the OS can determine our local interface
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception as e:
|
||||
local_ip = f"Error: {e}"
|
||||
if ip_lookup_enabled:
|
||||
cached = cache.get(_IP_CACHE_KEY)
|
||||
if cached is not None:
|
||||
public_ip = cached.get("public_ip")
|
||||
local_ip = cached.get("local_ip")
|
||||
country_code = cached.get("country_code")
|
||||
country_name = cached.get("country_name")
|
||||
city = cached.get("city")
|
||||
else:
|
||||
if cache.add(_IP_LOCK_KEY, True, 30):
|
||||
threading.Thread(target=_perform_ip_lookup, daemon=True).start()
|
||||
ip_lookup_pending = True
|
||||
|
||||
# 3) Get geolocation data from ipapi.co or ip-api.com
|
||||
if public_ip and "Error" not in public_ip:
|
||||
try:
|
||||
# Attempt to get geo information from ipapi.co first
|
||||
r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5)
|
||||
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("country_code") # e.g. "US"
|
||||
country_name = geo.get("country_name") # e.g. "United States"
|
||||
|
||||
else:
|
||||
# If ipapi.co fails, fallback to ip-api.com
|
||||
# only supports http requests for free tier
|
||||
r = requests.get("http://ip-api.com/json/", timeout=5)
|
||||
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("countryCode") # e.g. "US"
|
||||
country_name = geo.get("country") # e.g. "United States"
|
||||
|
||||
else:
|
||||
raise Exception("Geo lookup failed with both services")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during geo lookup: {e}")
|
||||
country_code = None
|
||||
country_name = None
|
||||
|
||||
# 4) Get environment mode and TLS status from settings
|
||||
# Get environment mode and TLS status from settings
|
||||
postgres_ssl = getattr(django_settings, "POSTGRES_SSL", False)
|
||||
|
||||
return Response(
|
||||
|
|
@ -355,6 +393,10 @@ def environment(request):
|
|||
"local_ip": local_ip,
|
||||
"country_code": country_code,
|
||||
"country_name": country_name,
|
||||
"city": city,
|
||||
"ip_lookup_enabled": ip_lookup_enabled,
|
||||
"ip_lookup_env_disabled": ip_lookup_env_disabled,
|
||||
"ip_lookup_pending": ip_lookup_pending,
|
||||
"env_mode": os.getenv("DISPATCHARR_ENV", "aio"),
|
||||
"redis_tls": {
|
||||
"enabled": getattr(django_settings, "REDIS_SSL", False),
|
||||
|
|
|
|||
|
|
@ -407,6 +407,7 @@ class CoreSettings(models.Model):
|
|||
"max_system_events": 100,
|
||||
"preferred_region": None,
|
||||
"auto_import_mapped_files": True,
|
||||
"enable_ip_lookup": True,
|
||||
})
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ if REDIS_SSL:
|
|||
else:
|
||||
print("Redis TLS: disabled")
|
||||
|
||||
ENABLE_IP_LOOKUP = os.environ.get("DISPATCHARR_ENABLE_IP_LOOKUP", "true").lower() == "true"
|
||||
|
||||
# Set DEBUG to True for development, False for production
|
||||
if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true":
|
||||
DEBUG = True
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export POSTGRES_DIR=/data/db
|
|||
variables=(
|
||||
PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE
|
||||
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
|
||||
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
|
||||
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL DISPATCHARR_ENABLE_IP_LOOKUP
|
||||
REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT
|
||||
DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
|
||||
CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY
|
||||
|
|
|
|||
|
|
@ -965,6 +965,12 @@ export const WebsocketProvider = ({ children }) => {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'ip_lookup_complete': {
|
||||
const { type: _t, ...ipData } = parsedEvent.data;
|
||||
useSettingsStore.getState().setEnvironmentFields(ipData);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(
|
||||
`Unknown websocket event type: ${parsedEvent.data?.type}`
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useRef, useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { copyToClipboard } from '../utils';
|
||||
import {
|
||||
|
|
@ -18,10 +18,10 @@ import {
|
|||
Box,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
AppShellNavbar,
|
||||
ScrollArea,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import logo from '../images/logo.png';
|
||||
|
|
@ -161,10 +161,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
const getNavOrder = useAuthStore((s) => s.getNavOrder);
|
||||
const getHiddenNav = useAuthStore((s) => s.getHiddenNav);
|
||||
|
||||
const publicIPRef = useRef(null);
|
||||
|
||||
const [userFormOpen, setUserFormOpen] = useState(false);
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [ipRevealed, setIpRevealed] = useState(false);
|
||||
|
||||
const closeUserForm = () => setUserFormOpen(false);
|
||||
|
||||
|
|
@ -289,35 +288,106 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
}}
|
||||
>
|
||||
{isAuthenticated && (
|
||||
<Stack gap="sm">
|
||||
{!collapsed && (
|
||||
<TextInput
|
||||
label="Public IP"
|
||||
ref={publicIPRef}
|
||||
value={environment.public_ip}
|
||||
readOnly={true}
|
||||
leftSection={
|
||||
environment.country_code && (
|
||||
<img
|
||||
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
|
||||
alt={environment.country_name || environment.country_code}
|
||||
title={
|
||||
environment.country_name || environment.country_code
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray.9"
|
||||
onClick={copyPublicIP}
|
||||
<Stack gap="sm" style={{ width: '100%' }}>
|
||||
{!collapsed &&
|
||||
environment.ip_lookup_enabled !== false &&
|
||||
environment.ip_lookup_pending && (
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Public IP
|
||||
</Text>
|
||||
<Skeleton height={36} radius="sm" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!collapsed &&
|
||||
environment.ip_lookup_enabled !== false &&
|
||||
!environment.ip_lookup_pending &&
|
||||
environment.public_ip &&
|
||||
!environment.public_ip.startsWith('Error') && (
|
||||
<Box
|
||||
onClick={() => setIpRevealed((v) => !v)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Public IP
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
height: '36px',
|
||||
paddingLeft: '10px',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<Copy />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{environment.country_code && (
|
||||
<img
|
||||
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
|
||||
alt={
|
||||
environment.country_name || environment.country_code
|
||||
}
|
||||
title={[
|
||||
environment.country_name || environment.country_code,
|
||||
environment.city,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Box style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const ip = environment.public_ip;
|
||||
const isIPv6 = ip.includes(':');
|
||||
const sep = isIPv6 ? ':' : '.';
|
||||
const parts = ip.split(sep);
|
||||
const splitAt = isIPv6 ? 4 : 2;
|
||||
const visible =
|
||||
parts.slice(0, splitAt).join(sep) + sep;
|
||||
const hidden = parts.slice(splitAt).join(sep);
|
||||
return (
|
||||
<>
|
||||
{visible}
|
||||
<span
|
||||
style={{
|
||||
filter: ipRevealed ? 'none' : 'blur(5px)',
|
||||
transition: 'filter 0.15s',
|
||||
userSelect: ipRevealed ? 'text' : 'none',
|
||||
}}
|
||||
>
|
||||
{hidden}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
</Box>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray.9"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyPublicIP();
|
||||
}}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Copy />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!collapsed && authUser && (
|
||||
<Group
|
||||
|
|
@ -336,7 +406,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
</Group>
|
||||
)}
|
||||
{collapsed && (
|
||||
<Group gap="xs">
|
||||
<Group justify="center" style={{ width: '100%' }}>
|
||||
<Avatar src="" radius="xl" />
|
||||
</Group>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -96,14 +96,6 @@ vi.mock('@mantine/core', async () => {
|
|||
</Component>
|
||||
);
|
||||
},
|
||||
TextInput: ({ value, onChange, leftSection, rightSection, label }) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
{leftSection}
|
||||
<input value={value} onChange={onChange} />
|
||||
{rightSection}
|
||||
</div>
|
||||
),
|
||||
ActionIcon: ({ children, onClick, ...props }) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
|
|
@ -122,6 +114,7 @@ vi.mock('@mantine/core', async () => {
|
|||
</nav>
|
||||
),
|
||||
ScrollArea: ({ children }) => <div>{children}</div>,
|
||||
Skeleton: ({ height }) => <div data-testid="skeleton" style={{ height }} />,
|
||||
Tooltip: ({ children }) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
|
|
@ -290,8 +283,7 @@ describe('Sidebar', () => {
|
|||
it('should render public IP with country flag', () => {
|
||||
renderSidebar();
|
||||
|
||||
const ipInput = screen.getByDisplayValue('192.168.1.1');
|
||||
expect(ipInput).toBeInTheDocument();
|
||||
expect(screen.getByText('Public IP')).toBeInTheDocument();
|
||||
|
||||
const flag = screen.getByAltText('United States');
|
||||
expect(flag).toHaveAttribute('src', 'https://flagcdn.com/16x12/us.png');
|
||||
|
|
@ -492,7 +484,7 @@ describe('Sidebar', () => {
|
|||
});
|
||||
|
||||
renderSidebar();
|
||||
expect(screen.getByDisplayValue('192.168.1.1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Public IP')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('img', { name: /flag/i })
|
||||
).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ const SystemSettingsForm = React.memo(({ active }) => {
|
|||
const settings = useSettingsStore((s) => s.settings);
|
||||
const isModular =
|
||||
useSettingsStore((s) => s.environment.env_mode) === 'modular';
|
||||
const ipLookupEnvDisabled = useSettingsStore(
|
||||
(s) => s.environment.ip_lookup_env_disabled
|
||||
);
|
||||
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
|
|
@ -108,6 +111,23 @@ const SystemSettingsForm = React.memo(({ active }) => {
|
|||
id="auto_import_mapped_files"
|
||||
/>
|
||||
</Group>
|
||||
{!ipLookupEnvDisabled && (
|
||||
<Group justify="space-between" pt={5}>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
Enable IP Lookup
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Fetch and display the instance's public IP and country flag in the
|
||||
sidebar.
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
{...form.getInputProps('enable_ip_lookup', { type: 'checkbox' })}
|
||||
id="enable_ip_lookup"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
{isModular && (
|
||||
<>
|
||||
<Divider my="md" label="Connection Security" labelPosition="left" />
|
||||
|
|
|
|||
|
|
@ -87,7 +87,11 @@ describe('useSettingsStore', () => {
|
|||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
city: '',
|
||||
env_mode: 'aio',
|
||||
ip_lookup_enabled: true,
|
||||
ip_lookup_env_disabled: false,
|
||||
ip_lookup_pending: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ const useSettingsStore = create((set, get) => ({
|
|||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
city: '',
|
||||
env_mode: 'aio',
|
||||
ip_lookup_enabled: true,
|
||||
ip_lookup_env_disabled: false,
|
||||
ip_lookup_pending: false,
|
||||
},
|
||||
version: {
|
||||
version: '',
|
||||
|
|
@ -40,7 +44,11 @@ const useSettingsStore = create((set, get) => ({
|
|||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
city: '',
|
||||
env_mode: 'aio',
|
||||
ip_lookup_enabled: true,
|
||||
ip_lookup_env_disabled: false,
|
||||
ip_lookup_pending: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -78,6 +86,15 @@ const useSettingsStore = create((set, get) => ({
|
|||
}
|
||||
},
|
||||
|
||||
setEnvironmentFields: (fields) =>
|
||||
set((state) => ({
|
||||
environment: {
|
||||
...state.environment,
|
||||
...fields,
|
||||
ip_lookup_pending: false,
|
||||
},
|
||||
})),
|
||||
|
||||
updateSetting: (setting) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, [setting.key]: setting },
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ export const getSystemSettingsFormInitialValues = () => {
|
|||
max_system_events: 100,
|
||||
preferred_region: '',
|
||||
auto_import_mapped_files: true,
|
||||
enable_ip_lookup: true,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ describe('SystemSettingsFormUtils', () => {
|
|||
max_system_events: 100,
|
||||
preferred_region: '',
|
||||
auto_import_mapped_files: true,
|
||||
enable_ip_lookup: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
|
|||
'max_system_events',
|
||||
'preferred_region',
|
||||
'auto_import_mapped_files',
|
||||
'enable_ip_lookup',
|
||||
];
|
||||
|
||||
for (const formKey in changedSettings) {
|
||||
|
|
@ -133,6 +134,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
|
|||
'comskip_enabled',
|
||||
'schedule_enabled',
|
||||
'auto_import_mapped_files',
|
||||
'enable_ip_lookup',
|
||||
];
|
||||
if (booleanFields.includes(formKey) && value != null) {
|
||||
value = typeof value === 'boolean' ? value : Boolean(value);
|
||||
|
|
@ -358,6 +360,10 @@ export const parseSettings = (settings) => {
|
|||
typeof systemSettings.auto_import_mapped_files === 'boolean'
|
||||
? systemSettings.auto_import_mapped_files
|
||||
: true;
|
||||
parsed.enable_ip_lookup =
|
||||
typeof systemSettings.enable_ip_lookup === 'boolean'
|
||||
? systemSettings.enable_ip_lookup
|
||||
: true;
|
||||
}
|
||||
|
||||
// Proxy and network access are already grouped objects
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue