mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 21:03:56 +08:00
🎁 Sync 2026-10-18 22:41:16
This commit is contained in:
1 parent
3eca35a513
commit
3159b4f938
88 files changed
+5597
-1051
No files matched your search
+6
-8
@@ -6,12 +6,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
|
|
||||||
PKG_NAME:=dns2socks
|
PKG_NAME:=dns2socks
|
||||||
PKG_VERSION:=2.1
|
PKG_VERSION:=2.1
|
||||||
PKG_RELEASE:=1
|
PKG_RELEASE:=2
|
||||||
|
|
||||||
PKG_SOURCE:=SourceCode.zip
|
|
||||||
PKG_SOURCE_URL:=https://github.com/Openwrt-Passwall/openwrt-passwall-packages/releases/download/dns2socks
|
|
||||||
PKG_SOURCE_DATE:=2020-02-18
|
|
||||||
PKG_HASH:=skip
|
|
||||||
|
|
||||||
PKG_MAINTAINER:=ghostmaker
|
PKG_MAINTAINER:=ghostmaker
|
||||||
PKG_LICENSE:=BSD-3-Clause
|
PKG_LICENSE:=BSD-3-Clause
|
||||||
@@ -19,8 +14,6 @@ PKG_LICENSE_FILE:=LICENSE
|
|||||||
|
|
||||||
include $(INCLUDE_DIR)/package.mk
|
include $(INCLUDE_DIR)/package.mk
|
||||||
|
|
||||||
UNZIP_CMD:=unzip -q -d $(PKG_BUILD_DIR) $(DL_DIR)/$(PKG_SOURCE)
|
|
||||||
|
|
||||||
define Package/dns2socks
|
define Package/dns2socks
|
||||||
SECTION:=net
|
SECTION:=net
|
||||||
CATEGORY:=Network
|
CATEGORY:=Network
|
||||||
@@ -35,6 +28,11 @@ define Package/dns2socks/description
|
|||||||
a SOCKS tunnel like Tor or a HTTP proxy.
|
a SOCKS tunnel like Tor or a HTTP proxy.
|
||||||
endef
|
endef
|
||||||
|
|
||||||
|
define Build/Prepare
|
||||||
|
mkdir -p $(PKG_BUILD_DIR)
|
||||||
|
$(CP) ./src/. $(PKG_BUILD_DIR)/
|
||||||
|
endef
|
||||||
|
|
||||||
define Build/Compile
|
define Build/Compile
|
||||||
$(TARGET_CC) \
|
$(TARGET_CC) \
|
||||||
$(TARGET_CFLAGS) \
|
$(TARGET_CFLAGS) \
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Express 14 for Windows Desktop
|
||||||
|
VisualStudioVersion = 14.0.23107.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "DNS2SOCKS", "DNS2SOCKS\DNS2SOCKS.vcxproj", "{F19D3415-2E79-45EF-9893-4D9E2010B976}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Win32 = Debug|Win32
|
||||||
|
Release|Win32 = Release|Win32
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{F19D3415-2E79-45EF-9893-4D9E2010B976}.Debug|Win32.ActiveCfg = Debug|Win32
|
||||||
|
{F19D3415-2E79-45EF-9893-4D9E2010B976}.Debug|Win32.Build.0 = Debug|Win32
|
||||||
|
{F19D3415-2E79-45EF-9893-4D9E2010B976}.Release|Win32.ActiveCfg = Release|Win32
|
||||||
|
{F19D3415-2E79-45EF-9893-4D9E2010B976}.Release|Win32.Build.0 = Release|Win32
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<requestedExecutionLevel level="asInvoker"/>
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
<asmv3:application>
|
||||||
|
<asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
|
||||||
|
<dpiAware>true</dpiAware>
|
||||||
|
</asmv3:windowsSettings>
|
||||||
|
</asmv3:application>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
|
||||||
|
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||||
|
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||||
|
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#include <winresrc.h>
|
||||||
|
|
||||||
|
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
|
||||||
|
#pragma code_page(1252)
|
||||||
|
|
||||||
|
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "DNS2SOCKS.manifest"
|
||||||
|
|
||||||
|
VS_VERSION_INFO VERSIONINFO
|
||||||
|
FILEVERSION 2,1,0,0
|
||||||
|
PRODUCTVERSION 2,1,0,0
|
||||||
|
FILEFLAGSMASK 0x3FL
|
||||||
|
#ifdef _DEBUG
|
||||||
|
FILEFLAGS 0x1L
|
||||||
|
#else
|
||||||
|
FILEFLAGS 0x0L
|
||||||
|
#endif
|
||||||
|
FILEOS 0x00040004L
|
||||||
|
FILETYPE 0x1L
|
||||||
|
FILESUBTYPE 0x0L
|
||||||
|
BEGIN
|
||||||
|
BLOCK "StringFileInfo"
|
||||||
|
BEGIN
|
||||||
|
BLOCK "040904B0"
|
||||||
|
BEGIN
|
||||||
|
VALUE "FileDescription", "Tunnels DNS requests via SOCKS or HTTP"
|
||||||
|
VALUE "FileVersion", "2.1.0.0"
|
||||||
|
VALUE "OriginalFilename", "DNS2SOCKS.exe"
|
||||||
|
VALUE "ProductVersion", "2.1.0.0"
|
||||||
|
END
|
||||||
|
END
|
||||||
|
BLOCK "VarFileInfo"
|
||||||
|
BEGIN
|
||||||
|
VALUE "Translation", 0x0409, 1200
|
||||||
|
END
|
||||||
|
END
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project DefaultTargets="Build" ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<ItemGroup Label="ProjectConfigurations">
|
||||||
|
<ProjectConfiguration Include="Debug|Win32">
|
||||||
|
<Configuration>Debug</Configuration>
|
||||||
|
<Platform>Win32</Platform>
|
||||||
|
</ProjectConfiguration>
|
||||||
|
<ProjectConfiguration Include="Release|Win32">
|
||||||
|
<Configuration>Release</Configuration>
|
||||||
|
<Platform>Win32</Platform>
|
||||||
|
</ProjectConfiguration>
|
||||||
|
</ItemGroup>
|
||||||
|
<PropertyGroup Label="Globals">
|
||||||
|
<ProjectGuid>{F19D3415-2E79-45EF-9893-4D9E2010B976}</ProjectGuid>
|
||||||
|
<Keyword>Win32Proj</Keyword>
|
||||||
|
<RootNamespace>DNS2SOCKS</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
|
||||||
|
<ConfigurationType>Application</ConfigurationType>
|
||||||
|
<UseDebugLibraries>true</UseDebugLibraries>
|
||||||
|
<CharacterSet>NotSet</CharacterSet>
|
||||||
|
<PlatformToolset>v140</PlatformToolset>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
|
||||||
|
<ConfigurationType>Application</ConfigurationType>
|
||||||
|
<UseDebugLibraries>false</UseDebugLibraries>
|
||||||
|
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||||
|
<CharacterSet>NotSet</CharacterSet>
|
||||||
|
<PlatformToolset>v140</PlatformToolset>
|
||||||
|
</PropertyGroup>
|
||||||
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||||
|
<ImportGroup Label="ExtensionSettings">
|
||||||
|
</ImportGroup>
|
||||||
|
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||||
|
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||||
|
</ImportGroup>
|
||||||
|
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||||
|
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||||
|
</ImportGroup>
|
||||||
|
<PropertyGroup Label="UserMacros" />
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||||
|
<LinkIncremental>true</LinkIncremental>
|
||||||
|
<GenerateManifest>false</GenerateManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||||
|
<LinkIncremental>false</LinkIncremental>
|
||||||
|
<GenerateManifest>false</GenerateManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||||
|
<ClCompile>
|
||||||
|
<PrecompiledHeader>Use</PrecompiledHeader>
|
||||||
|
<WarningLevel>Level4</WarningLevel>
|
||||||
|
<Optimization>Disabled</Optimization>
|
||||||
|
<PreprocessorDefinitions>WIN32;_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||||
|
<StringPooling>true</StringPooling>
|
||||||
|
<ExceptionHandling>false</ExceptionHandling>
|
||||||
|
</ClCompile>
|
||||||
|
<Link>
|
||||||
|
<SubSystem>Windows</SubSystem>
|
||||||
|
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||||
|
<AdditionalDependencies>ws2_32.lib</AdditionalDependencies>
|
||||||
|
</Link>
|
||||||
|
</ItemDefinitionGroup>
|
||||||
|
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||||
|
<ClCompile>
|
||||||
|
<WarningLevel>Level4</WarningLevel>
|
||||||
|
<PrecompiledHeader>Use</PrecompiledHeader>
|
||||||
|
<Optimization>MaxSpeed</Optimization>
|
||||||
|
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||||
|
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||||
|
<PreprocessorDefinitions>WIN32;NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||||
|
<StringPooling>true</StringPooling>
|
||||||
|
<ExceptionHandling>false</ExceptionHandling>
|
||||||
|
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
|
||||||
|
</ClCompile>
|
||||||
|
<Link>
|
||||||
|
<SubSystem>Windows</SubSystem>
|
||||||
|
<GenerateDebugInformation>false</GenerateDebugInformation>
|
||||||
|
<EnableCOMDATFolding>true</EnableCOMDATFolding>
|
||||||
|
<OptimizeReferences>true</OptimizeReferences>
|
||||||
|
<AdditionalDependencies>ws2_32.lib</AdditionalDependencies>
|
||||||
|
</Link>
|
||||||
|
</ItemDefinitionGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ClInclude Include="stdafx.h" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ClCompile Include="DNS2SOCKS.c" />
|
||||||
|
<ClCompile Include="stdafx.c">
|
||||||
|
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">Create</PrecompiledHeader>
|
||||||
|
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">Create</PrecompiledHeader>
|
||||||
|
</ClCompile>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ResourceCompile Include="DNS2SOCKS.rc" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||||
|
<ImportGroup Label="ExtensionTargets">
|
||||||
|
</ImportGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<ItemGroup>
|
||||||
|
<Filter Include="Source Files">
|
||||||
|
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
|
||||||
|
<Extensions>cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
|
||||||
|
</Filter>
|
||||||
|
<Filter Include="Header Files">
|
||||||
|
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
|
||||||
|
<Extensions>h;hpp;hxx;hm;inl;inc;xsd</Extensions>
|
||||||
|
</Filter>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ClInclude Include="stdafx.h">
|
||||||
|
<Filter>Header Files</Filter>
|
||||||
|
</ClInclude>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ClCompile Include="DNS2SOCKS.c">
|
||||||
|
<Filter>Source Files</Filter>
|
||||||
|
</ClCompile>
|
||||||
|
<ClCompile Include="stdafx.c">
|
||||||
|
<Filter>Source Files</Filter>
|
||||||
|
</ClCompile>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ResourceCompile Include="DNS2SOCKS.rc">
|
||||||
|
<Filter>Source Files</Filter>
|
||||||
|
</ResourceCompile>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#include "stdafx.h"
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#ifndef _STDAFX_H
|
||||||
|
#define _STDAFX_H
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
|
||||||
|
//Windows specific includes
|
||||||
|
#define WINVER 0x501
|
||||||
|
#define _CRT_SECURE_NO_WARNINGS
|
||||||
|
#define _WINSOCK_DEPRECATED_NO_WARNINGS
|
||||||
|
#include <WinSock2.h>
|
||||||
|
#include <WS2tcpip.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#else //#ifdef _WIN32
|
||||||
|
|
||||||
|
//other OS includes and some tricks to fake Win32 API
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <stdarg.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <time.h>
|
||||||
|
typedef int SOCKET;
|
||||||
|
#define SOCKET_ERROR (-1)
|
||||||
|
#define closesocket(hSock) close(hSock)
|
||||||
|
#define WSAGetLastError() errno
|
||||||
|
#define CRITICAL_SECTION pthread_mutex_t
|
||||||
|
#define InitializeCriticalSection(criticalSection) pthread_mutex_init(criticalSection, NULL)
|
||||||
|
#define EnterCriticalSection(criticalSection) pthread_mutex_lock(criticalSection)
|
||||||
|
#define LeaveCriticalSection(criticalSection) pthread_mutex_unlock(criticalSection)
|
||||||
|
|
||||||
|
#endif //#ifdef _WIN32
|
||||||
|
|
||||||
|
#endif //#ifndef _STDAFX_H
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
Description for DNS2SOCKS
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
DNS2SOCKS is a command line utility running to forward DNS requests to a
|
||||||
|
DNS server via a SOCKS or HTTP tunnel.
|
||||||
|
|
||||||
|
I know that this is no new idea, but let me explain why I've coded this:
|
||||||
|
Windows supports using a SOCKS server for Internet connections, but it
|
||||||
|
only uses the SOCKS server for the content and not the DNS requests. I
|
||||||
|
found several articles in the Internet referring this issue. There seem
|
||||||
|
to be tools that do exactly the same thing as DNS2SOCKS, but they either
|
||||||
|
need a scripting interpreter or are not available for downloading anymore.
|
||||||
|
|
||||||
|
So I've coded an own tool. It's very(!) simple and doesn't use any
|
||||||
|
sophisticated technology.
|
||||||
|
|
||||||
|
To use it, just configure your OS to use the DNS server on the local
|
||||||
|
IP address 127.0.0.1 (IPv4) and/or ::1 (IPv6). On Windows: open the
|
||||||
|
properties of your network adapter. For IPv4 open the properties of
|
||||||
|
"Internet Protocol Version 4 (TCP/IPv4)", select "Use the following DNS
|
||||||
|
server addresses" and enter "127.0.0.1" for the "Preferred DNS server".
|
||||||
|
For IPv6 open the properties of "Internet Protocol Version 6
|
||||||
|
(TCP/IPv6)", select "Use the following DNS server addresses" and enter
|
||||||
|
"::1" for the "Preferred DNS server".
|
||||||
|
|
||||||
|
After that run your SOCKS server (must support SOCKS protocol version 5,
|
||||||
|
for example Tor) or HTTP proxy and start DNS2SOCKS using the correct
|
||||||
|
command line switches (see below). Now all DNS requests of your OS
|
||||||
|
(triggered by any application) run through DNS2SOCKS and your SOCKS
|
||||||
|
server or HTTP proxy.
|
||||||
|
|
||||||
|
You can additionally configure Windows to use your SOCKS server for
|
||||||
|
Internet connections (for the content, not DNS). To do this open the
|
||||||
|
"Internet Options" of the control panel, select the tab "Connections" and
|
||||||
|
click on "LAN settings". Check "Use a proxy server for your LAN..." and
|
||||||
|
click on "Advanced". Enter your SOCKS server address and port in the field
|
||||||
|
"Socks". Now Internet Explorer and other tools using these settings get
|
||||||
|
web pages via your SOCKS server. This works with most browsers. However,
|
||||||
|
you should rely on IPv4 for Tor here as (most?) Tor exit servers currently
|
||||||
|
don't support IPv6 (see below).
|
||||||
|
The settings are similar for using a HTTP proxy.
|
||||||
|
|
||||||
|
The command line call for DNS2SOCKS has the following format:
|
||||||
|
|
||||||
|
DNS2SOCKS [/?] [/t] [/d] [/q] [l[a]:FilePath] [/u:User /p:Password]
|
||||||
|
[/e[f]:IP/Bits]
|
||||||
|
[Socks5ServIP[:Port]] [DNSServIPorName[:Port]] [ListenIP[:Port]]
|
||||||
|
|
||||||
|
/? or any invalid parameter outputs the usage text
|
||||||
|
/t to use a HTTP proxy instead of a SOCKS server
|
||||||
|
(here: Socks5ServIP = HttpProxyIP, no support for /u and /p)
|
||||||
|
/d disables the cache
|
||||||
|
/q disables the text output to the console
|
||||||
|
/l:FilePath creates a new log file "FilePath"
|
||||||
|
/la:FilePath creates a new log file "FilePath" or appends to the file if
|
||||||
|
it already exists
|
||||||
|
/u:User user name if your SOCKS server uses user/password
|
||||||
|
authentication
|
||||||
|
/p:Password password if your SOCKS server uses user/password
|
||||||
|
authentication
|
||||||
|
/e:IP/Bits to enable and specify EDNS client subnet in DNS queries
|
||||||
|
/ef:IP/Bits same as above but also replaces EDNS client subnet if it
|
||||||
|
exists
|
||||||
|
|
||||||
|
The default values for the addresses and ports are (in case you don't
|
||||||
|
specify the command line arguments):
|
||||||
|
Default Socks5ServerIP:Port = 127.0.0.1:9050
|
||||||
|
Default DNSServerIPorName:Port = 46.182.19.48
|
||||||
|
Default ListenIP:Port = 127.0.0.1:53
|
||||||
|
|
||||||
|
So the SOCKS server runs locally on the TCP port 9050 (Tor's default port;
|
||||||
|
attention: for Tor Browser Bundle you must change it to 9150). The used
|
||||||
|
DNS server is 46.182.19.48 (Digitalcourage e.V.). The DNS server must
|
||||||
|
support TCP on port 53 as Tor doesn't support UDP via SOCKS. DNS2SOCKS
|
||||||
|
listens on the UDP port 53 of 127.0.0.1 (only locally) - change this to
|
||||||
|
0.0.0.0 for listening on all available local IPv4 addresses.
|
||||||
|
|
||||||
|
You can launch DNS2SOCKS several times with different settings, for
|
||||||
|
example to listen on IPv6 addresses additionally. To specify an IPv6
|
||||||
|
address, use the typical format like 1234:5678::1234. To add the port
|
||||||
|
number please embed the IP address in square brackets and add the port
|
||||||
|
number separated by the colon, e.g. [::1]:1024
|
||||||
|
|
||||||
|
Hint: In the default configuration Tor only listens on 127.0.0.1 for
|
||||||
|
incoming requests. You can change this in Tor's configuration file using
|
||||||
|
the following line
|
||||||
|
SocksListenAddress 192.168.1.1
|
||||||
|
In this example it listens on 192.168.1.1
|
||||||
|
Currently Tor doesn't support IPv6 addresses for listening.
|
||||||
|
|
||||||
|
Please note that Tor/Vidalia will output warnings that your application
|
||||||
|
doesn't resolve host names via Tor. This is not true, but Tor can't know
|
||||||
|
this as Tor doesn't recognize the tunneled DNS requests. DNS2SOCKS
|
||||||
|
directly uses the IP address of the DNS server while using SOCKS and also
|
||||||
|
your application will do this as it gets the IP address from DNS2SOCKS.
|
||||||
|
Tor expects getting the host names instead of IP addresses and thus
|
||||||
|
outputs these warnings.
|
||||||
|
|
||||||
|
However, instead of an IP address you can also specify the DNS server's
|
||||||
|
name instead of its IP address, e.g.
|
||||||
|
DNS2SOCKS 127.0.0.1 dns2.digitalcourage.de ::1
|
||||||
|
Specifying an IPv6 address for the DNS server is also supported by
|
||||||
|
DNS2SOCKS, but it's not recommended to do this as your current Tor exit
|
||||||
|
server would need to support IPv6, which it typically doesn't. So it's
|
||||||
|
better to specify the DNS server name as the exit server can choose IPv4
|
||||||
|
or IPv6 automatically this way. Directly specifying an IPv4 address might
|
||||||
|
be a bit faster; currently all Tor exit servers should support this.
|
||||||
|
|
||||||
|
Use the /e parameter to specify/overwrite the EDNS client subnet in your
|
||||||
|
DNS queries. This is a hint for the DNS server to reply with IP addresses
|
||||||
|
close to the specified subnet. It can speed things up. However, please
|
||||||
|
be aware of the privacy risk. There are three possible variants DNS2SOCKS
|
||||||
|
handles concerning the DNS requests if /e... is enabled:
|
||||||
|
1. There is no OPT pseudo-RR -> DNS2SOCKS adds one with the EDNS client
|
||||||
|
subnet option. It sets the DNSSEC flag to 0 as the original request
|
||||||
|
didn't have it.
|
||||||
|
2. There is an OPT pseudo-RR but no EDNS client subnet in it -> DNS2SOCKS
|
||||||
|
only adds the EDNS client subnet option but doesn't change the rest.
|
||||||
|
3. There is an OPT pseudo-RR including the EDNS client subnet. Now it
|
||||||
|
depends what you specify:
|
||||||
|
/e:... leaves it untouched.
|
||||||
|
/ef:... overwrites the original EDNS client subnet (f=force).
|
||||||
|
|
||||||
|
As DNS requests running through the SOCKS/HTTP tunnel are very slow, the
|
||||||
|
calling application might time out before it gets the answer - in this
|
||||||
|
case just try it again (press "reload" in the browser).
|
||||||
|
|
||||||
|
The output of DNS2SOCKS is very simple. On each new request it outputs
|
||||||
|
the requested name prefixed by the current number of entries in the
|
||||||
|
cache (just an increasing number in case the cache is disabled) and a time
|
||||||
|
stamp. DNS2SOCKS caches DNS requests, so the next time it can serve the
|
||||||
|
answer faster. The cache is a very simple list. There is no sophisticated
|
||||||
|
hash algorithm or something like that for the cache and DNS2SOCKS doesn't
|
||||||
|
really interpret the DNS requests and answers - it just forwards them.
|
||||||
|
|
||||||
|
DNS2SOCKS runs as long as you don't manually stop it.
|
||||||
|
You can also run several instances of DNS2SOCKS at the same time when
|
||||||
|
using different local ports or IPv4 and IPv6 at the same time, e.g. use a
|
||||||
|
batch file and Window's Start command to do this.
|
||||||
|
|
||||||
|
If you think that DNS2SOCKS is not the right tool for you, but you want
|
||||||
|
to route all network traffic of a specific Windows application through a
|
||||||
|
SOCKS tunnel, you might want to try my tool InjectSOCKS.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Now about some technical details:
|
||||||
|
DNS2SOCKS listens on the local UDP and TCP port you specify. In case it
|
||||||
|
gets a request it first searches the cache for an identical request.
|
||||||
|
In case of a cache miss or expiration of the entry, the tool creates a new
|
||||||
|
thread for resolving the request. The new thread opens a TCP connection to
|
||||||
|
the SOCKS server and forwards the DNS request. This time the DNS request
|
||||||
|
always runs on TCP as Tor currently doesn't support UDP via SOCKS. So the
|
||||||
|
DNS server must support TCP. When the thread finally gets the answer, it
|
||||||
|
forwards it via UDP or TCP to the requesting client and stores it in the
|
||||||
|
cache. DNS2SOCKS supports user/password authentication (method 0x02) for
|
||||||
|
SOCKS.
|
||||||
|
|
||||||
|
I've tried to comment the source code as good as possible and you can
|
||||||
|
compile it using Visual C++ 2015 Express Edition (or any other edition).
|
||||||
|
I've also tested it on Knoppix and Damn Small Linux and compiled it via
|
||||||
|
gcc -pthread -Wall -O2 -o DNS2SOCKS DNS2SOCKS.c
|
||||||
|
It should also run on other *nix variants; maybe with tiny modifications.
|
||||||
|
|
||||||
|
Have fun using this software!
|
||||||
|
|
||||||
|
ghostmaker
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
License (3-clause BSD License)
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
Copyright (c) 2012, ghostmaker
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are
|
||||||
|
met:
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
* Neither the name of ghostmaker nor the names of its contributors may
|
||||||
|
be used to endorse or promote products derived from this software
|
||||||
|
without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
||||||
|
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL ghostmaker BE LIABLE FOR ANY
|
||||||
|
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||||
|
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||||
|
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGE.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Changes of version 2.1 (released on 2020-02-18)
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
- Added command line parameter /e... to set/overwrite the EDNS client
|
||||||
|
subnet in DNS requests
|
||||||
|
- Changed default DNS server to 46.182.19.48
|
||||||
|
- Usage of the MSG_NOSIGNAL flag
|
||||||
|
- No modification of pseudo TTL field of OPT pseudo-RR anymore when
|
||||||
|
updating TTL in answer from cache
|
||||||
|
- Updated to Visual C++ 2015 Express Edition for compiling
|
||||||
|
- SHA256 for DNS2SOCKS.exe:
|
||||||
|
f4bb9471f73648a9f4f30cf7e9200eef3280499fb5c9e64a0234c9a4398c9edf
|
||||||
|
|
||||||
|
|
||||||
|
Changes of version 2.0 (released on 2015-06-28)
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
- Added command line parameter /t to use a HTTP proxy instead of a SOCKS5
|
||||||
|
server (HTTP CONNECT tunneling)
|
||||||
|
- SHA256 for DNS2SOCKS.exe:
|
||||||
|
ba841370b0d79b776438eb22a9131e91250025b55db4200db8f66a060cf6047d
|
||||||
|
|
||||||
|
|
||||||
|
Changes of version 1.9 (released on 2015-06-18)
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
- Added fallback to old IPv4 parsing via "inet_addr" in case "getaddrinfo"
|
||||||
|
fails (workaround for erroneous "getaddrinfo" implementation)
|
||||||
|
- SHA256 for DNS2SOCKS.exe:
|
||||||
|
5612bf263136852c39d929691b63a103c166f78bbd606d7947ce1d813c28c560
|
||||||
|
|
||||||
|
|
||||||
|
Changes of version 1.8 (released on 2015-01-01)
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
- Support for IPv6
|
||||||
|
- Support for DNS server name instead of IP address
|
||||||
|
- Added BSD license text to ReadMe.txt
|
||||||
|
- Minor tweaks
|
||||||
|
- SHA256 for DNS2SOCKS.exe:
|
||||||
|
d654659b031ede16224fa37f585c735112a689f46497afc3fca6ccd4cde70b93
|
||||||
|
|
||||||
|
|
||||||
|
Changes of version 1.7 (released on 2013-11-14)
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
- Support for SOCKS5 user/password authentication (method 0x02)
|
||||||
|
- SHA256 for DNS2SOCKS.exe:
|
||||||
|
63c0fd8f9dcd3f3546691892999d868ce74601778111dcf08cdfeb140818d28e
|
||||||
|
|
||||||
|
|
||||||
|
Changes of version 1.6 (released on 2013-10-21)
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
- Cache function respects the "time to live" field of DNS answers
|
||||||
|
- Command line parameter /d to disable the cache
|
||||||
|
- SHA256 for DNS2SOCKS.exe:
|
||||||
|
45a20efacb8fed901299712ae2ee6147ac453d69c349b0766024cb5d3634a0b5
|
||||||
|
|
||||||
|
|
||||||
|
Changes of version 1.5 (released on 2013-10-06)
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
- Removed several compiler warnings that appeared when compiling for
|
||||||
|
some platforms (tested on Knoppix V7.2.0 64 bit)
|
||||||
|
- Additional output of system error text in case of system call failure
|
||||||
|
- Added Windows application manifest "supportedOS" entry for Windows 8.1
|
||||||
|
- Bugfix: Output of settings at startup was missing
|
||||||
|
- SHA256 for DNS2SOCKS.exe:
|
||||||
|
8e987da2a57e5be942c21c76e28e185279a527ed67a468dab5757d38dc1e37ea
|
||||||
|
|
||||||
|
|
||||||
|
Changes of version 1.4 (released on 2013-01-19)
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
- Command line parameter /l:filePath or /la:filePath to create a log file
|
||||||
|
for the output (/la for appending)
|
||||||
|
- Output has a time stamp for each line
|
||||||
|
- SHA256 for DNS2SOCKS.exe:
|
||||||
|
cae8aab900897b2c03bd8f3d415de91df596479bca2f83b107455ba68de28793
|
||||||
|
|
||||||
|
|
||||||
|
Changes of version 1.3 (released on 2012-11-03)
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
- DNS requests/responses via TCP additionally to UDP (still always via
|
||||||
|
TCP for transportation via SOCKS)
|
||||||
|
- UDP requests/responses can now have the maximum possible size for UDP
|
||||||
|
- You can specify the local port number to use for UDP and TCP
|
||||||
|
- You can specify the remote port number to use for contacting the DNS
|
||||||
|
server
|
||||||
|
- You can compile the sources on Unix/Linux now and use it like on
|
||||||
|
Windows. You only need DNS2SOCKS.c and stdafx.h for that and compile
|
||||||
|
it with a command like
|
||||||
|
gcc -pthread -Wall -O2 -o DNS2SOCKS DNS2SOCKS.c
|
||||||
|
(tested on Damn Small Linux; use sudo to run it as otherwise it's
|
||||||
|
impossible to bind port 53; please be aware that the connect time-
|
||||||
|
out on Linux is typically several minutes while it is just some
|
||||||
|
seconds on Windows, so in case connecting to the SOCKS server doesn't
|
||||||
|
work, it takes very long until you see an error message)
|
||||||
|
- On Windows DNS2SOCKS is no console application anymore, but it creates
|
||||||
|
its own new console window at start-up; this way you can start it with
|
||||||
|
the new /q option to suppress any output and it has no window in this
|
||||||
|
case - use the Task Manager to terminate it
|
||||||
|
- SHA1 for DNS2SOCKS.exe: 596015cba187a9184c5b480b8666c0d4b37d9552
|
||||||
|
|
||||||
|
|
||||||
|
Changes of version 1.2 (released on 2012-01-13)
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
You can configure the listen address via the command line now. The default
|
||||||
|
is 127.0.0.1 instead of 0.0.0.0 of version 1.1. This way the Windows
|
||||||
|
firewall doesn't show up.
|
||||||
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
|
|
||||||
PKG_NAME:=luci-app-diskman
|
PKG_NAME:=luci-app-diskman
|
||||||
PKG_VERSION:=0.2.13
|
PKG_VERSION:=0.2.13
|
||||||
PKG_RELEASE:=3
|
PKG_RELEASE:=4
|
||||||
|
|
||||||
PKG_MAINTAINER:=lisaac <lisaac.cn@gmail.com>
|
PKG_MAINTAINER:=lisaac <lisaac.cn@gmail.com>
|
||||||
PKG_LICENSE:=AGPL-3.0
|
PKG_LICENSE:=AGPL-3.0
|
||||||
@@ -10,7 +10,8 @@ PKG_LICENSE:=AGPL-3.0
|
|||||||
LUCI_TITLE:=Disk Manager interface for LuCI
|
LUCI_TITLE:=Disk Manager interface for LuCI
|
||||||
LUCI_DEPENDS:= \
|
LUCI_DEPENDS:= \
|
||||||
+luci-compat +blkid +e2fsprogs +parted +smartmontools +dosfstools \
|
+luci-compat +blkid +e2fsprogs +parted +smartmontools +dosfstools \
|
||||||
+kmod-fs-msdos +kmod-fs-ntfs3 +kmod-fs-exfat +exfat-mkfs \
|
+kmod-fs-msdos +kmod-fs-ntfs3 \
|
||||||
|
+LINUX_5_4:kmod-fs-exfat-oot +!LINUX_5_4:kmod-fs-exfat +exfat-mkfs \
|
||||||
+exfat-fsck +kmod-nls-cp932 +kmod-nls-cp936 +kmod-nls-cp950 \
|
+exfat-fsck +kmod-nls-cp932 +kmod-nls-cp936 +kmod-nls-cp950 \
|
||||||
+PACKAGE_$(PKG_NAME)_INCLUDE_btrfs_progs:btrfs-progs \
|
+PACKAGE_$(PKG_NAME)_INCLUDE_btrfs_progs:btrfs-progs \
|
||||||
+PACKAGE_$(PKG_NAME)_INCLUDE_lsblk:lsblk \
|
+PACKAGE_$(PKG_NAME)_INCLUDE_lsblk:lsblk \
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
|
|
||||||
PKG_NAME:=luci-app-easymesh
|
PKG_NAME:=luci-app-easymesh
|
||||||
PKG_VERSION:=2.4.1
|
PKG_VERSION:=2.4.1
|
||||||
PKG_RELEASE:=6
|
PKG_RELEASE:=7
|
||||||
|
|
||||||
PKG_LICENSE:=GPL-2.0
|
PKG_LICENSE:=GPL-2.0
|
||||||
PKG_MAINTAINER:=Arafat Rahman Zami Mondol <zamimondol@gmail.com>
|
PKG_MAINTAINER:=Arafat Rahman Zami Mondol <zamimondol@gmail.com>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script type="text/javascript">
|
||||||
|
//<![CDATA[
|
||||||
|
(function() {
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 1. Keep the two wired-port dropdowns in sync
|
||||||
|
// =====================================================
|
||||||
|
function syncWiredPorts() {
|
||||||
|
var top = document.querySelector('select[name*="preferred_wired_port"]');
|
||||||
|
var bottom = document.querySelector('select[name*="wired_if"]');
|
||||||
|
if (!top || !bottom) return;
|
||||||
|
|
||||||
|
if (!top._easymeshSynced) {
|
||||||
|
top.value = bottom.value;
|
||||||
|
top.addEventListener('change', function() {
|
||||||
|
bottom.value = this.value;
|
||||||
|
});
|
||||||
|
top._easymeshSynced = true;
|
||||||
|
}
|
||||||
|
if (!bottom._easymeshSynced) {
|
||||||
|
bottom.addEventListener('change', function() {
|
||||||
|
top.value = this.value;
|
||||||
|
});
|
||||||
|
bottom._easymeshSynced = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 2. Confirm dialog before applying any preset
|
||||||
|
// =====================================================
|
||||||
|
function attachPresetConfirm() {
|
||||||
|
var buttons = document.querySelectorAll('input[type="submit"]');
|
||||||
|
for (var i = 0; i < buttons.length; i++) {
|
||||||
|
var btn = buttons[i];
|
||||||
|
if (!btn.name || btn.name.indexOf('._preset_') === -1) continue;
|
||||||
|
if (btn._easymeshConfirmAttached) continue;
|
||||||
|
btn._easymeshConfirmAttached = true;
|
||||||
|
|
||||||
|
btn.addEventListener('click', function(ev) {
|
||||||
|
var msg =
|
||||||
|
'Apply preset:\n\n ' + this.value + '\n\n' +
|
||||||
|
'A backup of the current config will be saved first.\n\n' +
|
||||||
|
'CONFIRM: Are you on the correct router?\n' +
|
||||||
|
' \u2022 Server preset \u2192 must be applied on the MAIN router\n' +
|
||||||
|
' \u2022 Client preset \u2192 must be applied on the EXTENDER\n\n' +
|
||||||
|
'Click OK to continue, Cancel to abort.';
|
||||||
|
if (!window.confirm(msg)) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (ev.stopImmediatePropagation) ev.stopImmediatePropagation();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachAll() {
|
||||||
|
syncWiredPorts();
|
||||||
|
attachPresetConfirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
setInterval(attachAll, 500);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setInterval(attachAll, 500);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
//]]>
|
||||||
|
</script>
|
||||||
@@ -1,95 +1,420 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr "Content-Type: text/plain; charset=UTF-8\n"
|
msgstr ""
|
||||||
|
"Project-Id-Version: luci-app-easymesh\n"
|
||||||
|
"POT-Creation-Date: \n"
|
||||||
|
"PO-Revision-Date: \n"
|
||||||
|
"Last-Translator: \n"
|
||||||
|
"Language-Team: \n"
|
||||||
|
"Language: zh_CN\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
|
|
||||||
msgid "easymesh"
|
msgid "EasyMesh"
|
||||||
msgstr "easymesh"
|
msgstr "EasyMesh"
|
||||||
|
|
||||||
msgid "Active node"
|
msgid "Configure a Batman-adv mesh network."
|
||||||
msgstr "活动节点"
|
msgstr "配置基于 Batman-adv 的 Mesh 网络。"
|
||||||
|
|
||||||
msgid "IF"
|
msgid "Active mesh links"
|
||||||
msgstr "IF"
|
msgstr "活动 Mesh 链路"
|
||||||
|
|
||||||
msgid "Neighbor"
|
msgid "stale"
|
||||||
msgstr "节点邻居设备"
|
msgstr "已失效"
|
||||||
|
|
||||||
msgid "lastseen"
|
msgid "Interface"
|
||||||
msgstr "上次连接延时"
|
msgstr "接口"
|
||||||
|
|
||||||
msgid "EASY MESH"
|
msgid "Neighbor MAC"
|
||||||
msgstr "简单MESH"
|
msgstr "邻居 MAC"
|
||||||
|
|
||||||
msgid "Settings"
|
msgid "Last seen"
|
||||||
msgstr "设置"
|
msgstr "最后在线"
|
||||||
|
|
||||||
msgid "General Settings"
|
msgid "Latency"
|
||||||
msgstr "基本设置"
|
msgstr "延迟"
|
||||||
|
|
||||||
msgid "Enable"
|
msgid "RX rate"
|
||||||
msgstr "启用"
|
msgstr "接收速率"
|
||||||
|
|
||||||
msgid "role"
|
msgid "TX rate"
|
||||||
msgstr "角色"
|
msgstr "发送速率"
|
||||||
|
|
||||||
msgid "off"
|
msgid "Priority weight"
|
||||||
|
msgstr "优先级权重"
|
||||||
|
|
||||||
|
msgid "Wired backhaul details"
|
||||||
|
msgstr "有线回传详情"
|
||||||
|
|
||||||
|
msgid "State"
|
||||||
|
msgstr "状态"
|
||||||
|
|
||||||
|
msgid "Cable"
|
||||||
|
msgstr "网线连接"
|
||||||
|
|
||||||
|
msgid "Link speed"
|
||||||
|
msgstr "链路速率"
|
||||||
|
|
||||||
|
msgid "In LAN bridge"
|
||||||
|
msgstr "在 LAN 桥中"
|
||||||
|
|
||||||
|
msgid "Wired backhaul port"
|
||||||
|
msgstr "有线回传端口"
|
||||||
|
|
||||||
|
msgid "Convenience selector. Mirrors 'Wired backhaul interface' below. Presets that use wired backhaul will remove this port from the LAN bridge. Pick a port NOT used by your uplink or modem. Leave empty to disable wired backhaul entirely."
|
||||||
|
msgstr "快捷选择器。与下方的“有线回传接口”同步。使用有线回传的预设会将此端口从 LAN 桥接中移除。请选择未被上行链路或光猫占用的端口。留空以完全禁用有线回传。"
|
||||||
|
|
||||||
|
msgid "Preferred wired port"
|
||||||
|
msgstr "首选有线端口"
|
||||||
|
|
||||||
|
msgid "(none — wired presets will skip)"
|
||||||
|
msgstr "(无 — 有线预设将跳过)"
|
||||||
|
|
||||||
|
msgid "Boot-time reliability"
|
||||||
|
msgstr "开机可靠性保障"
|
||||||
|
|
||||||
|
msgid "The init script applies link priorities at boot and 90s later. On some hardware, mesh interfaces appear even later, leaving priority weight at 0. Enable cron fallback to reapply priority every minute. Safe (idempotent) but runs in the background. When disabled, the cron line is commented out in /etc/crontabs/root."
|
||||||
|
msgstr "初始化脚本会在开机时及 90 秒后应用链路优先级。在部分硬件上,Mesh 接口可能出现得更晚,导致优先级权重保持为 0。启用计划任务(Cron)后备可在后台每分钟重新应用优先级。该操作安全且具备幂等性。禁用后,/etc/crontabs/root 中的对应计划任务将被注释。"
|
||||||
|
|
||||||
|
msgid "Enable cron fallback"
|
||||||
|
msgstr "启用计划任务后备"
|
||||||
|
|
||||||
|
msgid "Link priority weights"
|
||||||
|
msgstr "链路优先级权重"
|
||||||
|
|
||||||
|
msgid "Batman-adv throughput_override values in Kbit/s. Higher = preferred. Set to 0 to let batman auto-decide per link. Defaults: wired=1000000, 6G=900000, 5G=600000, 2.4G=30000. Changes apply on next Save & Apply (or 'Reapply EasyMesh Settings')."
|
||||||
|
msgstr "Batman-adv throughput_override(吞吐量重写)数值,单位为 Kbit/s。数值越高越优先。设为 0 则由 Batman 自动判定链路速率。默认值:有线=1000000,6G=900000,5G=600000,2.4G=30000。更改将在下次“保存并应用”(或“重新应用 EasyMesh 设置”)后生效。"
|
||||||
|
|
||||||
|
msgid "Wired (Kbit/s)"
|
||||||
|
msgstr "有线 (Kbit/s)"
|
||||||
|
|
||||||
|
msgid "6 GHz wireless (Kbit/s)"
|
||||||
|
msgstr "6 GHz 无线 (Kbit/s)"
|
||||||
|
|
||||||
|
msgid "5 GHz wireless (Kbit/s)"
|
||||||
|
msgstr "5 GHz 无线 (Kbit/s)"
|
||||||
|
|
||||||
|
msgid "2.4 GHz wireless (Kbit/s)"
|
||||||
|
msgstr "2.4 GHz 无线 (Kbit/s)"
|
||||||
|
|
||||||
|
msgid "Mesh VLANs"
|
||||||
|
msgstr "Mesh VLAN"
|
||||||
|
|
||||||
|
msgid "Extend guest / IoT / hotspot networks across the mesh. Enter a comma-separated list of VLAN IDs (e.g. 10,20,30). The init script creates bat0.<vid> as an 8021q device for each ID. If a bridge named br-<vid>, br-lan<vid>, br-vlan<vid>, br-guest<vid>, or br-iot<vid> already exists, it is auto-added to that bridge. Otherwise, add the bat0.<vid> device manually in Network → Interfaces → Devices. Requires 802.1Q-capable switches on the physical wired backhaul."
|
||||||
|
msgstr "通过 Mesh 网络扩展访客 / 物联网 (IoT) / 热点网络。请输入以英文逗号分隔的 VLAN ID 列表(例如:10,20,30)。初始化脚本会为每个 ID 创建对应的 bat0.<vid> 802.1q 设备。如果已存在名为 br-<vid>、br-lan<vid>、br-vlan<vid>、br-guest<vid> 或 br-iot<vid> 的桥接接口,将自动将其加入该桥接中。否则,请手动前往“网络 → 接口 → 设备”中添加 bat0.<vid> 设备。物理有线回传需要交换机支持 802.1Q。"
|
||||||
|
|
||||||
|
msgid "VLAN IDs (comma-separated)"
|
||||||
|
msgstr "VLAN ID(逗号分隔)"
|
||||||
|
|
||||||
|
msgid "Example: 10,20,30 — Leave empty to disable"
|
||||||
|
msgstr "示例:10,20,30 — 留空以禁用"
|
||||||
|
|
||||||
|
msgid "Quick Setup"
|
||||||
|
msgstr "快捷预设"
|
||||||
|
|
||||||
|
msgid "Click a preset. A confirmation dialog will appear. Current config is backed up automatically before applying. Grouped by role: SERVER (has internet) → CLIENT (uses mesh) → NODE (relay only)."
|
||||||
|
msgstr "点击预设方案。应用前会弹出确认对话框并自动备份当前配置。按节点角色分组:服务端 SERVER(提供网络)→ 客户端 CLIENT(接入 Mesh)→ 节点 NODE(纯中继)。"
|
||||||
|
|
||||||
|
msgid "1. Server — 5 GHz wireless only"
|
||||||
|
msgstr "1. 服务端 — 仅 5 GHz 无线"
|
||||||
|
|
||||||
|
msgid "[SERVER] Gateway. Extenders join via 5 GHz mesh only."
|
||||||
|
msgstr "[主路由/服务端] 网关节点。扩展节点仅通过 5 GHz Mesh 接入。"
|
||||||
|
|
||||||
|
msgid "2. Server — 2.4 GHz wireless only"
|
||||||
|
msgstr "2. 服务端 — 仅 2.4 GHz 无线"
|
||||||
|
|
||||||
|
msgid "[SERVER] Extenders join via 2.4 GHz (longer range)."
|
||||||
|
msgstr "[主路由/服务端] 扩展节点通过 2.4 GHz 接入(覆盖范围更广)。"
|
||||||
|
|
||||||
|
msgid "3. Server — Dual-band wireless only"
|
||||||
|
msgstr "3. 服务端 — 仅双频无线"
|
||||||
|
|
||||||
|
msgid "[SERVER] Both radios carry backhaul."
|
||||||
|
msgstr "[主路由/服务端] 双频射频同时承载回传。"
|
||||||
|
|
||||||
|
msgid "4. Server — Wired backhaul only"
|
||||||
|
msgstr "4. 服务端 — 仅有线回传"
|
||||||
|
|
||||||
|
msgid "[SERVER] No wireless mesh — extenders connect by cable."
|
||||||
|
msgstr "[主路由/服务端] 无无线 Mesh — 扩展节点通过网线连接。"
|
||||||
|
|
||||||
|
msgid "5. Server — 5 GHz + Wired"
|
||||||
|
msgstr "5. 服务端 — 5 GHz + 有线"
|
||||||
|
|
||||||
|
msgid "[SERVER] 5 GHz mesh primary, Ethernet parallel."
|
||||||
|
msgstr "[主路由/服务端] 5 GHz Mesh 为主,以太网并行传输。"
|
||||||
|
|
||||||
|
msgid "6. Server — 2.4 GHz + Wired"
|
||||||
|
msgstr "6. 服务端 — 2.4 GHz + 有线"
|
||||||
|
|
||||||
|
msgid "[SERVER] 2.4 GHz mesh plus Ethernet."
|
||||||
|
msgstr "[主路由/服务端] 2.4 GHz Mesh 配合以太网回传。"
|
||||||
|
|
||||||
|
msgid "7. Server — Dual-band + Wired"
|
||||||
|
msgstr "7. 服务端 — 双频 + 有线"
|
||||||
|
|
||||||
|
msgid "[SERVER] Maximum redundancy. Both radios + Ethernet."
|
||||||
|
msgstr "[主路由/服务端] 最大冗余。双频射频 + 以太网同时回传。"
|
||||||
|
|
||||||
|
msgid "8. Client — 5 GHz wireless only"
|
||||||
|
msgstr "8. 客户端 — 仅 5 GHz 无线"
|
||||||
|
|
||||||
|
msgid "[CLIENT] Extender via 5 GHz. No cable."
|
||||||
|
msgstr "[子路由/客户端] 通过 5 GHz 扩展,无网线。"
|
||||||
|
|
||||||
|
msgid "9. Client — 2.4 GHz wireless only"
|
||||||
|
msgstr "9. 客户端 — 仅 2.4 GHz 无线"
|
||||||
|
|
||||||
|
msgid "[CLIENT] Extender via 2.4 GHz."
|
||||||
|
msgstr "[子路由/客户端] 通过 2.4 GHz 扩展。"
|
||||||
|
|
||||||
|
msgid "10. Client — Dual-band wireless only"
|
||||||
|
msgstr "10. 客户端 — 仅双频无线"
|
||||||
|
|
||||||
|
msgid "[CLIENT] Extender via both radios."
|
||||||
|
msgstr "[子路由/客户端] 同时通过两个射频频段扩展。"
|
||||||
|
|
||||||
|
msgid "11. Client — Wired backhaul only"
|
||||||
|
msgstr "11. 客户端 — 仅有线回传"
|
||||||
|
|
||||||
|
msgid "[CLIENT] Cable only, no wireless mesh."
|
||||||
|
msgstr "[子路由/客户端] 仅网线连接,无无线 Mesh。"
|
||||||
|
|
||||||
|
msgid "12. Client — 5 GHz + Wired fallback"
|
||||||
|
msgstr "12. 客户端 — 5 GHz + 有线备用"
|
||||||
|
|
||||||
|
msgid "[CLIENT] 5 GHz primary, Ethernet fallback."
|
||||||
|
msgstr "[子路由/客户端] 5 GHz 为主,以太网备用。"
|
||||||
|
|
||||||
|
msgid "13. Client — 2.4 GHz + Wired fallback"
|
||||||
|
msgstr "13. 客户端 — 2.4 GHz + 有线备用"
|
||||||
|
|
||||||
|
msgid "[CLIENT] 2.4 GHz primary, Ethernet fallback."
|
||||||
|
msgstr "[子路由/客户端] 2.4 GHz 为主,以太网备用。"
|
||||||
|
|
||||||
|
msgid "14. Client — Dual-band + Wired fallback"
|
||||||
|
msgstr "14. 客户端 — 双频 + 有线备用"
|
||||||
|
|
||||||
|
msgid "[CLIENT] Both radios + Ethernet. Maximum redundancy."
|
||||||
|
msgstr "[子路由/客户端] 双频射频 + 以太网,提供最大冗余与稳定性。"
|
||||||
|
|
||||||
|
msgid "15. Node — 5 GHz relay"
|
||||||
|
msgstr "15. 纯中继节点 — 5 GHz 中继"
|
||||||
|
|
||||||
|
msgid "[NODE] Relay only, 5 GHz. No client SSID."
|
||||||
|
msgstr "[中继节点] 仅作为 5 GHz 中继,不发射终端接入 SSID。"
|
||||||
|
|
||||||
|
msgid "16. Node — Dual-band relay"
|
||||||
|
msgstr "16. 纯中继节点 — 双频中继"
|
||||||
|
|
||||||
|
msgid "[NODE] Relay only, both radios."
|
||||||
|
msgstr "[中继节点] 双频中继,不发射终端接入 SSID。"
|
||||||
|
|
||||||
|
msgid "17. Node — Wired relay only"
|
||||||
|
msgstr "17. 纯中继节点 — 仅有线中继"
|
||||||
|
|
||||||
|
msgid "[NODE] Relay via cable only. No wireless."
|
||||||
|
msgstr "[中继节点] 仅通过网线中继,无无线回传。"
|
||||||
|
|
||||||
|
msgid "18. Detach wired backhaul"
|
||||||
|
msgstr "18. 解绑有线回传"
|
||||||
|
|
||||||
|
msgid "Removes Ethernet backhaul, restores port to bridge."
|
||||||
|
msgstr "移除以太网回传,并将端口恢复加入网络桥接。"
|
||||||
|
|
||||||
|
msgid "19. Disable mesh"
|
||||||
|
msgstr "19. 禁用 Mesh"
|
||||||
|
|
||||||
|
msgid "Turns off mesh. Radios stay enabled."
|
||||||
|
msgstr "关闭 Mesh 网络功能,无线射频保持开启。"
|
||||||
|
|
||||||
|
msgid "Mesh settings"
|
||||||
|
msgstr "Mesh 设置"
|
||||||
|
|
||||||
|
msgid "Enable EasyMesh"
|
||||||
|
msgstr "启用 EasyMesh"
|
||||||
|
|
||||||
|
msgid "Mesh role"
|
||||||
|
msgstr "Mesh 角色"
|
||||||
|
|
||||||
|
msgid "Off"
|
||||||
msgstr "关闭"
|
msgstr "关闭"
|
||||||
|
|
||||||
msgid "host MESH"
|
msgid "Server (gateway - has internet)"
|
||||||
msgstr "主MESH"
|
msgstr "服务端 (主网关 - 连接外网)"
|
||||||
|
|
||||||
msgid "son MESH"
|
msgid "Client (uses mesh internet)"
|
||||||
msgstr "子MESH"
|
msgstr "客户端 (通过 Mesh 上网)"
|
||||||
|
|
||||||
msgid "MESH Radio device"
|
msgid "Node (relay only)"
|
||||||
msgstr "MESH无线设备"
|
msgstr "节点 (纯中继)"
|
||||||
|
|
||||||
msgid "The radio device which MESH use"
|
msgid "Mesh mode"
|
||||||
msgstr "使用MESH组网的无线设备"
|
msgstr "Mesh 模式"
|
||||||
|
|
||||||
msgid "AUTO"
|
msgid "Combined wireless band + wired backhaul. Wired variants use the port selected above."
|
||||||
msgstr "自动"
|
msgstr "无线频段与有线回传组合。包含有线的选项将使用上方选择的端口。"
|
||||||
|
|
||||||
msgid "Enable or disable EASY MESH"
|
msgid "5 GHz wireless only"
|
||||||
msgstr "启用或禁用简单MESH"
|
msgstr "仅 5 GHz 无线"
|
||||||
|
|
||||||
msgid "Encryption"
|
msgid "2.4 GHz wireless only"
|
||||||
msgstr "加密"
|
msgstr "仅 2.4 GHz 无线"
|
||||||
|
|
||||||
msgid "Key"
|
msgid "Dual-band wireless only"
|
||||||
msgstr "密码"
|
msgstr "仅双频无线"
|
||||||
|
|
||||||
msgid "inter-access point protocol"
|
msgid "5 GHz wireless + Wired backhaul"
|
||||||
msgstr "接入点内部协议"
|
msgstr "5 GHz 无线 + 有线回传"
|
||||||
|
|
||||||
msgid "Wireless Access Points (APs) running on different vendors can communicate with each other"
|
msgid "2.4 GHz wireless + Wired backhaul"
|
||||||
msgstr "运行在不同供应商的无线访问点(AP)可以相互交流"
|
msgstr "2.4 GHz 无线 + 有线回传"
|
||||||
|
|
||||||
msgid "Threshold for an good RSSI"
|
msgid "Dual-band wireless + Wired backhaul"
|
||||||
msgstr "快速漫游接入值"
|
msgstr "双频无线 + 有线回传"
|
||||||
|
|
||||||
msgid "Threshold for an bad RSSI"
|
msgid "Wired only (no wireless mesh)"
|
||||||
msgstr "快速漫游踢出值"
|
msgstr "仅有线 (无无线 Mesh)"
|
||||||
|
|
||||||
msgid "Threshold for a good RSSI"
|
msgid "Custom radio"
|
||||||
msgstr "优良RSSI信号阈值"
|
msgstr "自定义射频设备"
|
||||||
|
|
||||||
msgid "Threshold for a bad RSSI"
|
msgid "Custom mesh radio"
|
||||||
msgstr "不良RSSI信号阈值"
|
msgstr "自定义 Mesh 射频"
|
||||||
|
|
||||||
msgid "AP MODE Enable"
|
msgid "Mesh ID"
|
||||||
msgstr "启用AP模式"
|
msgstr "Mesh 网络名称 (Mesh ID)"
|
||||||
|
|
||||||
msgid "Enable or disable AP MODE"
|
msgid "Must match on all nodes. Ignored in wired-only mode."
|
||||||
msgstr "启用或禁用AP模式"
|
msgstr "所有节点必须保持一致。仅有线模式下忽略此项。"
|
||||||
|
|
||||||
msgid "IPv4-Address"
|
msgid "Require password (WPA3-SAE)"
|
||||||
msgstr "IPv4 地址"
|
msgstr "需要密码 (WPA3-SAE)"
|
||||||
|
|
||||||
msgid "IPv4 netmask"
|
msgid "Mesh password"
|
||||||
msgstr "IPv4 子网掩码"
|
msgstr "Mesh 密码"
|
||||||
|
|
||||||
msgid "IPv4 gateway"
|
msgid "Must match on all nodes. Only used when 'Require password' is enabled."
|
||||||
msgstr "IPv4 网关"
|
msgstr "所有节点必须保持一致。仅在启用“需要密码”时生效。"
|
||||||
|
|
||||||
msgid "Use custom DNS servers"
|
msgid "Enable 802.11k/v/r fast roaming"
|
||||||
msgstr "使用自定义的 DNS 服务器"
|
msgstr "启用 802.11k/v/r 快速漫游"
|
||||||
|
|
||||||
|
msgid "Mobility domain"
|
||||||
|
msgstr "移动域 (Mobility Domain)"
|
||||||
|
|
||||||
|
msgid "Client WiFi — 2.4 GHz"
|
||||||
|
msgstr "终端接入 WiFi — 2.4 GHz"
|
||||||
|
|
||||||
|
msgid "Longer range, lower speed. Use a different SSID from 5 GHz."
|
||||||
|
msgstr "覆盖距离更远,速率较低。建议使用与 5 GHz 不同的 SSID。"
|
||||||
|
|
||||||
|
msgid "Broadcast 2.4 GHz"
|
||||||
|
msgstr "广播 2.4 GHz"
|
||||||
|
|
||||||
|
msgid "2.4 GHz SSID"
|
||||||
|
msgstr "2.4 GHz SSID"
|
||||||
|
|
||||||
|
msgid "2.4 GHz security"
|
||||||
|
msgstr "2.4 GHz 安全模式"
|
||||||
|
|
||||||
|
msgid "None (open)"
|
||||||
|
msgstr "无 (开放网络)"
|
||||||
|
|
||||||
|
msgid "WPA2-PSK"
|
||||||
|
msgstr "WPA2-PSK"
|
||||||
|
|
||||||
|
msgid "WPA3-SAE"
|
||||||
|
msgstr "WPA3-SAE"
|
||||||
|
|
||||||
|
msgid "WPA2/WPA3 transition"
|
||||||
|
msgstr "WPA2/WPA3 混合模式"
|
||||||
|
|
||||||
|
msgid "2.4 GHz password"
|
||||||
|
msgstr "2.4 GHz 密码"
|
||||||
|
|
||||||
|
msgid "Client WiFi — 5 GHz"
|
||||||
|
msgstr "终端接入 WiFi — 5 GHz"
|
||||||
|
|
||||||
|
msgid "Shorter range, much faster. Use a different SSID from 2.4 GHz."
|
||||||
|
msgstr "速率更高,覆盖距离较近。建议使用与 2.4 GHz 不同的 SSID。"
|
||||||
|
|
||||||
|
msgid "Broadcast 5 GHz"
|
||||||
|
msgstr "广播 5 GHz"
|
||||||
|
|
||||||
|
msgid "5 GHz SSID"
|
||||||
|
msgstr "5 GHz SSID"
|
||||||
|
|
||||||
|
msgid "5 GHz security"
|
||||||
|
msgstr "5 GHz 安全模式"
|
||||||
|
|
||||||
|
msgid "5 GHz password"
|
||||||
|
msgstr "5 GHz 密码"
|
||||||
|
|
||||||
|
msgid "Client WiFi — shared"
|
||||||
|
msgstr "终端接入 WiFi — 公共设置"
|
||||||
|
|
||||||
|
msgid "Attach client WiFi to network"
|
||||||
|
msgstr "将终端 WiFi 绑定至网络接口"
|
||||||
|
|
||||||
|
msgid "AP Mode (Dumb AP)"
|
||||||
|
msgstr "AP 模式 (胖/瘦 AP 旁路模式)"
|
||||||
|
|
||||||
|
msgid "Enable AP Mode (Dumb AP)"
|
||||||
|
msgstr "启用 AP 模式 (Dumb AP)"
|
||||||
|
|
||||||
|
msgid "IP mode"
|
||||||
|
msgstr "IP 地址获取方式"
|
||||||
|
|
||||||
|
msgid "DHCP (from Server)"
|
||||||
|
msgstr "DHCP (从服务端自动获取)"
|
||||||
|
|
||||||
|
msgid "Static IP"
|
||||||
|
msgstr "静态 IP"
|
||||||
|
|
||||||
|
msgid "IP address"
|
||||||
|
msgstr "IP 地址"
|
||||||
|
|
||||||
|
msgid "Netmask"
|
||||||
|
msgstr "子网掩码"
|
||||||
|
|
||||||
|
msgid "Gateway"
|
||||||
|
msgstr "网关"
|
||||||
|
|
||||||
|
msgid "DNS server"
|
||||||
|
msgstr "DNS 服务器"
|
||||||
|
|
||||||
|
msgid "Wired backhaul"
|
||||||
|
msgstr "有线回传"
|
||||||
|
|
||||||
|
msgid "Optional. Adds a physical Ethernet port as a batman hardif. Required on BOTH sides for a wired link. The port is auto-removed from the LAN bridge on apply."
|
||||||
|
msgstr "可选功能。将指定的物理以太网端口添加为 Batman 底层接口 (hardif)。建立有线链路时两端节点均需配置。应用时该端口将自动从 LAN 桥接中移除。"
|
||||||
|
|
||||||
|
msgid "Wired backhaul interface"
|
||||||
|
msgstr "有线回传接口"
|
||||||
|
|
||||||
|
msgid "Disabled"
|
||||||
|
msgstr "已禁用"
|
||||||
|
|
||||||
|
msgid "Safety and actions"
|
||||||
|
msgstr "安全与操作"
|
||||||
|
|
||||||
|
msgid "Safe apply (60-second rollback)"
|
||||||
|
msgstr "安全应用 (60 秒无响应自动回滚)"
|
||||||
|
|
||||||
|
msgid "Reapply EasyMesh Settings"
|
||||||
|
msgstr "重新应用 EasyMesh 设置"
|
||||||
|
|
||||||
|
msgid "Restore from last backup"
|
||||||
|
msgstr "从上一次备份恢复"
|
||||||
|
|
||||||
|
msgid "Pair with another router"
|
||||||
|
msgstr "与其他路由器配对"
|
||||||
|
|
||||||
|
msgid "Enter pairing mode (2 minutes)"
|
||||||
|
msgstr "进入配对模式 (2 分钟)"
|
||||||
|
|
||||||
|
msgid "WPS-style pairing. Press the WPS button on both routers within 2 minutes. Auto-reverts on timeout."
|
||||||
|
msgstr "类似 WPS 风格的一键配对。请在 2 分钟内在两台路由器上均触发配对。超时未配对成功将自动恢复原状。"
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=luci-app-open-box
|
PKG_NAME:=luci-app-open-box
|
||||||
PKG_VERSION:=0.1.338
|
PKG_VERSION:=0.1.339
|
||||||
PKG_RELEASE:=35
|
PKG_RELEASE:=36
|
||||||
|
|
||||||
PKG_MAINTAINER:=liandu2024
|
PKG_MAINTAINER:=liandu2024
|
||||||
PKG_LICENSE:=GPL-3.0-or-later
|
PKG_LICENSE:=GPL-3.0-or-later
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
|
|
||||||
PKG_NAME:=luci-app-passwall
|
PKG_NAME:=luci-app-passwall
|
||||||
PKG_VERSION:=26.10.16
|
PKG_VERSION:=26.10.16
|
||||||
PKG_RELEASE:=337
|
PKG_RELEASE:=338
|
||||||
PKG_PO_VERSION:=$(PKG_VERSION)
|
PKG_PO_VERSION:=$(PKG_VERSION)
|
||||||
|
|
||||||
PKG_CONFIG_DEPENDS:= \
|
PKG_CONFIG_DEPENDS:= \
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ function get_redir_log()
|
|||||||
local path = api.TMP_PATH .. "/acl/" .. id
|
local path = api.TMP_PATH .. "/acl/" .. id
|
||||||
|
|
||||||
local function alert(msg)
|
local function alert(msg)
|
||||||
|
http.prepare_content("text/html; charset=utf-8")
|
||||||
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate(msg)))
|
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate(msg)))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -273,9 +274,8 @@ function get_redir_log()
|
|||||||
end
|
end
|
||||||
|
|
||||||
if fs.access(path .. "/" .. name .. ".log") then
|
if fs.access(path .. "/" .. name .. ".log") then
|
||||||
local content = luci.sys.exec("tail -n 5000 ".. path .. "/" .. name .. ".log")
|
http.prepare_content("text/plain; charset=utf-8")
|
||||||
content = content:gsub("\n", "<br />")
|
http.write(luci.sys.exec("tail -n 5000 " .. util.shellquote(path)))
|
||||||
http.write(content)
|
|
||||||
else
|
else
|
||||||
alert("Not enabled log")
|
alert("Not enabled log")
|
||||||
end
|
end
|
||||||
@@ -285,10 +285,10 @@ function get_socks_log()
|
|||||||
local name = http.formvalue("name")
|
local name = http.formvalue("name")
|
||||||
local path = api.TMP_PATH .. "/" .. name .. ".log"
|
local path = api.TMP_PATH .. "/" .. name .. ".log"
|
||||||
if fs.access(path) then
|
if fs.access(path) then
|
||||||
local content = luci.sys.exec("tail -n 5000 ".. path)
|
http.prepare_content("text/plain; charset=utf-8")
|
||||||
content = content:gsub("\n", "<br />")
|
http.write(luci.sys.exec("tail -n 5000 " .. util.shellquote(path)))
|
||||||
http.write(content)
|
|
||||||
else
|
else
|
||||||
|
http.prepare_content("text/html; charset=utf-8")
|
||||||
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate("Not enabled log")))
|
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate("Not enabled log")))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -308,21 +308,22 @@ function get_chinadns_log()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if fs.access(path) then
|
if fs.access(path) then
|
||||||
local content = luci.sys.exec("tail -n 5000 ".. path)
|
http.prepare_content("text/plain; charset=utf-8")
|
||||||
content = content:gsub("\n", "<br />")
|
http.write(luci.sys.exec("tail -n 5000 " .. util.shellquote(path)))
|
||||||
http.write(content)
|
|
||||||
else
|
else
|
||||||
|
http.prepare_content("text/html; charset=utf-8")
|
||||||
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate("Not enabled log")))
|
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate("Not enabled log")))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function get_smartdns_log()
|
function get_smartdns_log()
|
||||||
local path = api.TMP_PATH .. "/acl/default/smartdns.log"
|
local path = api.TMP_PATH .. "/acl/default/smartdns.log"
|
||||||
http.prepare_content("text/plain; charset=utf-8")
|
|
||||||
if fs.access(path) then
|
if fs.access(path) then
|
||||||
|
http.prepare_content("text/plain; charset=utf-8")
|
||||||
http.write(luci.sys.exec("tail -n 5000 " .. util.shellquote(path)))
|
http.write(luci.sys.exec("tail -n 5000 " .. util.shellquote(path)))
|
||||||
else
|
else
|
||||||
http.write(i18n.translate("Not enabled log"))
|
http.prepare_content("text/html; charset=utf-8")
|
||||||
|
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate("Not enabled log")))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -270,10 +270,11 @@ end
|
|||||||
-- 计算字符串 MD5
|
-- 计算字符串 MD5
|
||||||
function md5_string(str)
|
function md5_string(str)
|
||||||
if type(str) ~= "string" then return "" end
|
if type(str) ~= "string" then return "" end
|
||||||
local quoted = "'" .. str:gsub("'", "'\\''") .. "'"
|
local path = trim(sys.exec("mktemp /tmp/md5.XXXXXX") or "")
|
||||||
local out = sys.exec("printf '%s' " .. quoted .. " | md5sum")
|
if path == "" then return "" end
|
||||||
if not out then return "" end
|
local md5 = fs.writefile(path, str) == #str and md5_file(path) or ""
|
||||||
return out:sub(1, 32)
|
fs.unlink(path)
|
||||||
|
return md5
|
||||||
end
|
end
|
||||||
|
|
||||||
--提取URL中的域名和端口(no ip)
|
--提取URL中的域名和端口(no ip)
|
||||||
|
|||||||
@@ -196,10 +196,9 @@ local sid = "@global[0]"
|
|||||||
}
|
}
|
||||||
requestAnimationFrame(waitForDnsSelect);
|
requestAnimationFrame(waitForDnsSelect);
|
||||||
};
|
};
|
||||||
waitForDnsSelect();
|
|
||||||
|
|
||||||
const initDnsSelect = (dns_select) => {
|
const initDnsSelect = (dns_select) => {
|
||||||
if (dns_select.value === "chinadns-ng") {
|
if (dns_select.value === "chinadns-ng" || dns_select.value === "smartdns") {
|
||||||
addLogLink(dns_select);
|
addLogLink(dns_select);
|
||||||
}
|
}
|
||||||
if (dns_select._dnsLogBinded) return;
|
if (dns_select._dnsLogBinded) return;
|
||||||
@@ -210,7 +209,7 @@ local sid = "@global[0]"
|
|||||||
if (existingLogLink) {
|
if (existingLogLink) {
|
||||||
existingLogLink.remove();
|
existingLogLink.remove();
|
||||||
}
|
}
|
||||||
if (dns_select.value === "chinadns-ng") {
|
if (dns_select.value === "chinadns-ng" || dns_select.value === "smartdns") {
|
||||||
addLogLink(dns_select);
|
addLogLink(dns_select);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -225,9 +224,12 @@ local sid = "@global[0]"
|
|||||||
logLink.href = "#";
|
logLink.href = "#";
|
||||||
logLink.className = "dns-log-link";
|
logLink.className = "dns-log-link";
|
||||||
logLink.style.marginLeft = "10px";
|
logLink.style.marginLeft = "10px";
|
||||||
logLink.setAttribute("onclick", "window.open('" + '<%=api.url("get_chinadns_log")%>' + "?flag=default', '_blank')");
|
const logUrl = select.value === "smartdns" ? '<%=api.url("get_smartdns_log")%>' : '<%=api.url("get_chinadns_log")%>?flag=default';
|
||||||
|
logLink.setAttribute("onclick", "window.open('" + logUrl + "', '_blank')");
|
||||||
select.insertAdjacentElement("afterend", logLink);
|
select.insertAdjacentElement("afterend", logLink);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
waitForDnsSelect();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -196,10 +196,6 @@ if not is_file_nonzero(file_vpslist) then
|
|||||||
api.uci_foreach_c("nodes", function(t)
|
api.uci_foreach_c("nodes", function(t)
|
||||||
process_address(t.address)
|
process_address(t.address)
|
||||||
process_address(t.download_address)
|
process_address(t.download_address)
|
||||||
local dns, _ = api.get_domain_port_from_url(t.domain_resolver_dns or t.domain_resolver_dns_https or "")
|
|
||||||
if dns and dns ~= "" then
|
|
||||||
process_address(dns)
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
f_out:close()
|
f_out:close()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ function add_rule(var)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function get_dns_config_key()
|
local function get_dns_config_key()
|
||||||
local address_md5 = api.md5_string(sys.exec([[uci show passwall | grep -E '\.(address|download_address|domain_resolver_dns|domain_resolver_dns_https)=' | cut -d "'" -f 2 | sort -u]]))
|
local address_md5 = api.md5_string(sys.exec([[uci show passwall | grep -E '\.(address|download_address)=' | cut -d "'" -f 2 | sort -u]]))
|
||||||
local new_rules = sys.exec([[
|
local new_rules = sys.exec([[
|
||||||
for f in \
|
for f in \
|
||||||
/usr/share/passwall/rules/chnlist \
|
/usr/share/passwall/rules/chnlist \
|
||||||
@@ -462,10 +462,6 @@ function add_rule(var)
|
|||||||
api.uci_foreach_c("nodes", function(t)
|
api.uci_foreach_c("nodes", function(t)
|
||||||
process_address(t.address)
|
process_address(t.address)
|
||||||
process_address(t.download_address)
|
process_address(t.download_address)
|
||||||
local dns, _ = api.get_domain_port_from_url(t.domain_resolver_dns or t.domain_resolver_dns_https or "")
|
|
||||||
if dns and dns ~= "" then
|
|
||||||
process_address(dns)
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
log(string.format(" - 节点列表中的域名(vpslist):%s", fwd_dns or "默认"))
|
log(string.format(" - 节点列表中的域名(vpslist):%s", fwd_dns or "默认"))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -401,10 +401,6 @@ if not is_file_nonzero(file_vpslist) then
|
|||||||
api.uci_foreach_c("nodes", function(t)
|
api.uci_foreach_c("nodes", function(t)
|
||||||
process_address(t.address)
|
process_address(t.address)
|
||||||
process_address(t.download_address)
|
process_address(t.download_address)
|
||||||
local dns, _ = api.get_domain_port_from_url(t.domain_resolver_dns or t.domain_resolver_dns_https or "")
|
|
||||||
if dns and dns ~= "" then
|
|
||||||
process_address(dns)
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
f_out:close()
|
f_out:close()
|
||||||
end
|
end
|
||||||
@@ -418,6 +414,8 @@ if is_file_nonzero(file_vpslist) then
|
|||||||
"#6:" .. setflag .. "psw_vps6"
|
"#6:" .. setflag .. "psw_vps6"
|
||||||
}
|
}
|
||||||
local domain_rules_str = string.format('domain-rules /domain-set:%s/ -nameserver direct', domain_set_name) .. DIRECT_RULE_OPTIONS
|
local domain_rules_str = string.format('domain-rules /domain-set:%s/ -nameserver direct', domain_set_name) .. DIRECT_RULE_OPTIONS
|
||||||
|
-- 节点域名最多缓存 30 秒,到期后重新查询,避免继续返回过期的节点 IP。
|
||||||
|
domain_rules_str = domain_rules_str .. " -rr-ttl-max 30 -no-serve-expired"
|
||||||
domain_rules_str = domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",")
|
domain_rules_str = domain_rules_str .. " " .. set_type .. " " .. table.concat(sets, ",")
|
||||||
table.insert(tmp_lines, domain_rules_str)
|
table.insert(tmp_lines, domain_rules_str)
|
||||||
insert_array_after(config_lines, tmp_lines, "#--9")
|
insert_array_after(config_lines, tmp_lines, "#--9")
|
||||||
|
|||||||
@@ -767,7 +767,7 @@ filter_haproxy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filter_vpsip() {
|
filter_vpsip() {
|
||||||
local vps_addrs=$(uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Ev "$EXCLUDE_VPSIP")
|
local vps_addrs=$(uci show $CONFIG | grep -E "(\.address=|\.download_address=)" | cut -d "'" -f 2 | grep -Ev "$EXCLUDE_VPSIP")
|
||||||
local ipv4_addrs=$(echo "$vps_addrs" | grep -Eo "$IPv4_REGEX")
|
local ipv4_addrs=$(echo "$vps_addrs" | grep -Eo "$IPv4_REGEX")
|
||||||
[ -n "$ipv4_addrs" ] && {
|
[ -n "$ipv4_addrs" ] && {
|
||||||
echo "$ipv4_addrs" | sed "s/^/add $IPSET_VPS /" | awk '1; END{print "COMMIT"}' | ipset -! -R
|
echo "$ipv4_addrs" | sed "s/^/add $IPSET_VPS /" | awk '1; END{print "COMMIT"}' | ipset -! -R
|
||||||
|
|||||||
@@ -816,7 +816,7 @@ filter_vps_addr() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filter_vpsip() {
|
filter_vpsip() {
|
||||||
local vps_addrs=$(uci show $CONFIG | grep -E "(\.address=|\.download_address=|\.domain_resolver_dns=|\.domain_resolver_dns_https=)" | cut -d "'" -f 2 | grep -Ev "$EXCLUDE_VPSIP")
|
local vps_addrs=$(uci show $CONFIG | grep -E "(\.address=|\.download_address=)" | cut -d "'" -f 2 | grep -Ev "$EXCLUDE_VPSIP")
|
||||||
local ipv4_addrs=$(echo "$vps_addrs" | grep -Eo "$IPv4_REGEX")
|
local ipv4_addrs=$(echo "$vps_addrs" | grep -Eo "$IPv4_REGEX")
|
||||||
[ -n "$ipv4_addrs" ] && {
|
[ -n "$ipv4_addrs" ] && {
|
||||||
echo "$ipv4_addrs" | insert_nftset $NFTSET_VPS
|
echo "$ipv4_addrs" | insert_nftset $NFTSET_VPS
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ LOCK_FILE=${LOCK_PATH}/${CONFIG}_tasks.lock
|
|||||||
CFG_UPDATE_INT=0
|
CFG_UPDATE_INT=0
|
||||||
|
|
||||||
exec 99>"$LOCK_FILE"
|
exec 99>"$LOCK_FILE"
|
||||||
flock -n 99
|
flock -n 99 || exit 0
|
||||||
if [ "$?" != 0 ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
while true
|
while true
|
||||||
do
|
do
|
||||||
@@ -23,7 +20,7 @@ do
|
|||||||
restart_interval_mode=$(expr "$restart_interval_mode" \* 60)
|
restart_interval_mode=$(expr "$restart_interval_mode" \* 60)
|
||||||
if [ -n "$restart_week_mode" ]; then
|
if [ -n "$restart_week_mode" ]; then
|
||||||
[ "$restart_week_mode" = "8" ] && {
|
[ "$restart_week_mode" = "8" ] && {
|
||||||
[ "$(expr "$CFG_UPDATE_INT" % "$restart_interval_mode")" -eq 0 ] && { /etc/init.d/$CONFIG restart cron > /dev/null 2>&1 & }
|
[ "$(expr "$CFG_UPDATE_INT" % "$restart_interval_mode")" -eq 0 ] && { /etc/init.d/$CONFIG restart cron > /dev/null 2>&1 99>&- & }
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -32,7 +29,7 @@ do
|
|||||||
rules_update_interval_mode=$(expr "$rules_update_interval_mode" \* 60)
|
rules_update_interval_mode=$(expr "$rules_update_interval_mode" \* 60)
|
||||||
if [ -n "$rules_update_week_mode" ]; then
|
if [ -n "$rules_update_week_mode" ]; then
|
||||||
[ "$rules_update_week_mode" = "8" ] && {
|
[ "$rules_update_week_mode" = "8" ] && {
|
||||||
[ "$(expr "$CFG_UPDATE_INT" % "$rules_update_interval_mode")" -eq 0 ] && { lua $APP_PATH/rule_update.lua log all cron > /dev/null 2>&1 & }
|
[ "$(expr "$CFG_UPDATE_INT" % "$rules_update_interval_mode")" -eq 0 ] && { lua "$APP_PATH/rule_update.lua" log all cron > /dev/null 2>&1 99>&- & }
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -53,9 +50,9 @@ do
|
|||||||
sub_update_week_mode=${name%_*}
|
sub_update_week_mode=${name%_*}
|
||||||
sub_update_interval_mode=${name#*_}
|
sub_update_interval_mode=${name#*_}
|
||||||
sub_update_interval_mode=$(expr "$sub_update_interval_mode" \* 60)
|
sub_update_interval_mode=$(expr "$sub_update_interval_mode" \* 60)
|
||||||
cfgids=$(echo -n $(cat ${TMP_SUB_PATH}/${name}) | sed 's# #,#g')
|
cfgids=$(tr '\n' ',' < "${TMP_SUB_PATH}/${name}" | sed 's/,$//')
|
||||||
[ "$sub_update_week_mode" = "8" ] && {
|
[ "$sub_update_week_mode" = "8" ] && {
|
||||||
[ "$(expr "$CFG_UPDATE_INT" % "$sub_update_interval_mode")" -eq 0 ] && { lua $APP_PATH/subscribe.lua start $cfgids cron > /dev/null 2>&1 & }
|
[ "$(expr "$CFG_UPDATE_INT" % "$sub_update_interval_mode")" -eq 0 ] && { lua "$APP_PATH/subscribe.lua" start "$cfgids" cron > /dev/null 2>&1 99>&- & }
|
||||||
}
|
}
|
||||||
done
|
done
|
||||||
rm -rf $TMP_SUB_PATH
|
rm -rf $TMP_SUB_PATH
|
||||||
@@ -65,6 +62,6 @@ do
|
|||||||
|
|
||||||
CFG_UPDATE_INT=$(expr "$CFG_UPDATE_INT" + 10)
|
CFG_UPDATE_INT=$(expr "$CFG_UPDATE_INT" + 10)
|
||||||
|
|
||||||
sleep 600
|
sleep 600 99>&-
|
||||||
|
|
||||||
done 2>/dev/null
|
done 2>/dev/null
|
||||||
@@ -1,5 +1,184 @@
|
|||||||
# 변경 기록
|
# 변경 기록
|
||||||
|
|
||||||
|
## [0.2.30-r1] - 2026-10-18
|
||||||
|
|
||||||
|
### 개선
|
||||||
|
- 전체적인 화면 레이아웃 다듬었습니다.
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- 추천 사용자 규칙 해제가 안되던 문제를 수정하였습니다.
|
||||||
|
|
||||||
|
## [0.2.29-r14] - 2026-10-18
|
||||||
|
|
||||||
|
### 개선
|
||||||
|
- 펌웨어 업데이트 화면에서 새 펌웨어에 포함된 관리 소프트웨어 버전을 상단에서 바로 확인할 수 있습니다.
|
||||||
|
- 관리 소프트웨어 자동 업데이트 설정 카드의 불필요한 안내 문구를 줄여 화면을 더 간단하게 정리했습니다.
|
||||||
|
- SafeShield 상단 보호 데이터와 통계 수집 아이콘 크기를 아래 보호 구성 카드와 같은 기준으로 맞췄습니다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
- 펌웨어 카드에 포함된 관리 소프트웨어 버전이 상단에 표시되는지 확인하는 테스트를 보강했습니다.
|
||||||
|
- SafeShield 상단 아이콘 크기와 배경이 보호 구성 카드와 같은 규칙을 사용하는지 확인하는 테스트를 보강했습니다.
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.29-r13] - 2026-10-18
|
||||||
|
|
||||||
|
### 개선
|
||||||
|
- IPTV 동작 상태 카드에 간단한 안내 문구를 추가했습니다.
|
||||||
|
- 연결된 기기의 검색창과 필터를 같은 줄에 배치해 화면 공간을 절약했습니다.
|
||||||
|
- SafeShield 보호 데이터 카드의 비중을 줄이고, 통계 수집 스위치를 보호 활동 제목 옆으로 옮겼습니다.
|
||||||
|
- 원격 상태 보고의 멤버십 표시와 스위치를 제목 옆으로 옮기고, 개인정보 보호 안내를 바로 아래에 배치했습니다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
- 네 화면의 위치와 기존 조작 기능을 확인하는 레이아웃 테스트를 추가했습니다.
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.29-r12] - 2026-10-18
|
||||||
|
|
||||||
|
### 개선
|
||||||
|
|
||||||
|
- 최근 활동 화면 상단에서 Cloud 활동 기록을 바로 켜거나 끌 수 있습니다.
|
||||||
|
- 동기화 상태와 마지막 동기화 시간을 쉽게 확인하고, 자세한 정보는 펼쳐서 볼 수 있습니다.
|
||||||
|
- 최근 활동을 처음에는 20건만 보여주고, 필요한 경우 이전 기록을 20건씩 추가로 볼 수 있습니다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
|
||||||
|
- Cloud 설정이 활동 목록 위에 있는지, 접기·펼치기와 활동 더 보기가 동작하는지 확인합니다.
|
||||||
|
- 계정 연결 안내, 전송 오류, 로컬 활동 기록 유지 안내가 사라지지 않았는지 확인합니다.
|
||||||
|
|
||||||
|
## [0.2.29-r11] - 2026-10-18
|
||||||
|
|
||||||
|
### 개선
|
||||||
|
|
||||||
|
- 메뉴별 제목과 안내 문구를 카드 밖에 배치해 화면 구성을 통일했습니다.
|
||||||
|
- SafeShield 통계, 최근 활동, 네트워크, 연결 기기, 사용자 규칙 등 주요 정보를 더 쉽게 구분할 수 있습니다.
|
||||||
|
- 보호 전환, 갱신, 저장 등의 조작 버튼은 관련 카드에 남겨두어 사용하기 편하도록 했습니다.
|
||||||
|
- 모바일에서도 같은 화면 구조를 사용하고 카드 안의 불필요한 겹침을 줄였습니다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
|
||||||
|
- 메뉴별 제목 배치와 기존 조작 버튼, 설정 저장 안내가 유지되는지 확인합니다.
|
||||||
|
|
||||||
|
## [0.2.29-r10] - 2026-10-18
|
||||||
|
|
||||||
|
### 개선
|
||||||
|
|
||||||
|
- 추천 규칙이 공유기에 적용되면 웹사이트에서도 결과를 더 빨리 확인할 수 있습니다.
|
||||||
|
- 적용 확인 결과를 주기적으로 전달하되 불필요한 반복 전송은 줄입니다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
|
||||||
|
- 허용 목록 확인 결과의 전송, 최신 규칙 비교 및 중복 보고 방지를 검증합니다.
|
||||||
|
|
||||||
|
## [0.2.29-r9] - 2026-10-18
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
|
||||||
|
- SafeShield 차단 목록을 갱신하는 중 추천 규칙 동기화가 잘못 실패로 표시되던 문제를 수정했습니다.
|
||||||
|
- 추천 규칙을 적용하거나 해제할 때 진행 안내와 실패 안내가 겹쳐 보이던 문제를 수정했습니다.
|
||||||
|
|
||||||
|
### 개선
|
||||||
|
|
||||||
|
- 추천 규칙을 가져오는 중, SafeShield 갱신 중, 허용 목록 적용·확인 중인 상태를 구분해 보여줍니다.
|
||||||
|
- SafeShield 갱신과 겹치는 규칙 변경은 기존 규칙을 유지하고 갱신이 끝나면 자동으로 재시도합니다.
|
||||||
|
- 서버 연결이 일시적으로 지연되어도 이미 확인한 허용 규칙을 유지하고 자동으로 다시 확인합니다.
|
||||||
|
- 화면을 새로고침하지 않아도 처리 진행 상태와 허용 목록을 자동으로 갱신합니다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
|
||||||
|
- SafeShield 갱신 중 규칙 변경 보류, 재시도, 기존 사용자 규칙 보존 및 상태 표시를 검증합니다.
|
||||||
|
|
||||||
|
## [0.2.29-r8] - 2026-10-17
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
|
||||||
|
- 추천 규칙을 해제하거나 다시 적용하는 중 일시적으로 실패하면 도메인이 직접 추가한 규칙으로 잘못 분류될 수 있던 문제를 수정했습니다.
|
||||||
|
- 적용 확인이 완료되지 않은 추천 규칙은 전체 내용을 다시 동기화하도록 개선했습니다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
|
||||||
|
- 갱신 실패 후 재할당, 기존 직접 허용 규칙 보존, 동기화 확인서가 사라진 경우의 복구를 검증합니다.
|
||||||
|
|
||||||
|
## [0.2.29-r7] - 2026-10-17
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
|
||||||
|
- 추천 사용자 규칙을 공유기에서도 확인 후 적용 해제할 수 있습니다.
|
||||||
|
|
||||||
|
### 개선
|
||||||
|
|
||||||
|
- 여러 추천 규칙이 같은 도메인을 사용해도 다른 규칙과 직접 추가한 허용 규칙은 유지합니다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
|
||||||
|
- 해제 확인, 기기 인증, 공유기 동기화와 데모 접근 차단을 확인합니다.
|
||||||
|
|
||||||
|
## [0.2.29-r6] - 2026-10-17
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
|
||||||
|
- 사용자 규칙의 추천 규칙 제목이 다크모드에서 밝게 표시되던 문제를 수정했습니다.
|
||||||
|
- 다크모드에서 자동 완성된 관리자 비밀번호 입력칸의 배경색을 자연스럽게 표시합니다.
|
||||||
|
|
||||||
|
### 개선
|
||||||
|
|
||||||
|
- 설정의 세 탭을 동일한 콘텐츠 폭과 카드 디자인으로 정리했습니다.
|
||||||
|
- 진단, 원격 상태 보고, 개인정보 보호를 각각 보기 쉬운 카드로 분리했습니다.
|
||||||
|
- 시간 설정과 예약 재부팅을 넓은 화면에서 나란히 표시합니다.
|
||||||
|
- 모바일에서는 설정 카드를 세로로 배치하여 읽기 편하게 유지합니다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
|
||||||
|
- 다크모드 추천 규칙, 비밀번호 자동 완성, 설정 탭의 카드 배치를 점검합니다.
|
||||||
|
|
||||||
|
## [0.2.29-r5] - 2026-10-17
|
||||||
|
|
||||||
|
### 개선
|
||||||
|
|
||||||
|
- 사용자 규칙에서 추천 허용 도메인을 중복 표시하지 않고, 허용 목록 안에서 직접 추가한 규칙과 추천 규칙을 구분합니다.
|
||||||
|
- 추천 규칙이 많아지면 목록을 접어두고 필요할 때 펼쳐볼 수 있으며, 검색 결과는 자동으로 보여줍니다.
|
||||||
|
- 추천 규칙 동기화 상태와 최근 확인 시간을 더 간결하게 표시합니다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
|
||||||
|
- 기존 사용자 규칙과 추천 규칙의 구분, 삭제 권한, 접기·펼치기 및 데모 격리를 검증합니다.
|
||||||
|
|
||||||
|
## [0.2.29-r4] - 2026-10-17
|
||||||
|
|
||||||
|
### 개선
|
||||||
|
|
||||||
|
- 추천 사용자 규칙을 약 30초 간격으로 확인하여 공유기에 더 빠르게 반영합니다.
|
||||||
|
- 규칙이 변경되지 않으면 목록을 다시 내려받거나 SafeShield에 적용하지 않습니다.
|
||||||
|
- 기존 계정·라이선스 동기화는 5분 주기를 유지하고, 통신 오류 시 재시도 간격을 늘립니다.
|
||||||
|
- 사용자 규칙의 '지금 동기화' 버튼도 가벼운 추천 규칙 동기화를 사용합니다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
|
||||||
|
- 인증된 변경 확인, 적용·해제, 실패 후 재시도 및 데모 격리 동작을 검증합니다.
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.29-r3] - 2026-10-17
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
|
||||||
|
- 사용자 규칙 화면에서 추천 규칙을 바로 동기화할 수 있습니다.
|
||||||
|
- 추천 규칙으로 확인된 허용 도메인과 최근 동기화 상태를 표시합니다.
|
||||||
|
|
||||||
|
### 개선
|
||||||
|
|
||||||
|
- 동기화 대기, 허용 목록 확인, 동기화 실패를 구분합니다.
|
||||||
|
- SafeShield 차단 목록 갱신과 추천 규칙 동기화의 차이를 안내합니다.
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
|
||||||
|
- 수동 동기화 버튼, 인증 권한 및 데모 격리 상태를 확인합니다.
|
||||||
|
|
||||||
|
|
||||||
|
## [0.2.29-r2] - 2026-10-17
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
|
||||||
|
- 추천 사용자 규칙을 적용하지 못하는 문제를 수정하였습니다.
|
||||||
|
|
||||||
## [0.2.29-r1] - 2026-10-17
|
## [0.2.29-r1] - 2026-10-17
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
include $(TOPDIR)/rules.mk
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
PKG_NAME:=luci-app-smartsafehub
|
PKG_NAME:=luci-app-smartsafehub
|
||||||
PKG_VERSION:=0.2.29
|
PKG_VERSION:=0.2.30
|
||||||
PKG_RELEASE:=9
|
PKG_RELEASE:=10
|
||||||
|
|
||||||
PKG_MAINTAINER:=Beomjun Kang <kals323@gmail.com>
|
PKG_MAINTAINER:=Beomjun Kang <kals323@gmail.com>
|
||||||
PKG_LICENSE:=GPL-3.0-or-later
|
PKG_LICENSE:=GPL-3.0-or-later
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
||||||
@layer properties{:host,*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-outline-style:solid;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:Line truncated
|
@layer properties{:host,*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-outline-style:solid;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:Line truncated
|
||||||
/*$vite$:1*/
|
/*$vite$:1*/
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
var e,t,n,r,i,a,o,s,c,l,u={},d=[],f=/^m(i|n|o|s|text|space)$/,p=Array.isArray,m=d.slice,h=Object.assign;function g(e){e&&e.parentNode&&e.remove()}function _(n,r,i,a,o){var s={type:n,props:r,key:i,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o||++t,__i:-1,__u:0};return!o&&e.vnode&&e.vnode(s),s}function v(e){return e.children}function y(e,t){this.props=e,this.context=t,this.__g=0}function b(e,t){if(t==null)return e.__?b(e.__,e.__i+1):null;for(var n;t<e.__k.length;t++)if((n=e.__k[t])&&n.__e)return n.__e;return typeof e.type!=`function`||e.props.__P?null:b(e)}function x(e){if((e=e.__)&&e.__c&&!e.props.__P)return e.__e=null,e.__k.some(function(t){return t&&(e.__e=t.__e)}),x(e)}function S(t){(8&t.__g||!(t.__g|=8)||!n.push(t)||i++)&&r==e.debounceRendering||((r=e.debounceRendering)||queueMicrotask)(C)}function C(){var t,r,o,s,c,l,u,d,f;try{for(r=1;n.length;)n.length>r&&n.sort(a),t=n.shift(),r=n.length,8&t.__g&&(s=void 0,c=void 0,l=(c=(o=t).__v).__e,u=[],d=[],(f=o.__P)&&((s=h({constructor:void 0},c)).__v=c.__v+1,e.vnode&&e.vnode(s),M(f,s,c,o.__n,f.namespaceURI,32&c.__u?[l]:null,u,l||b(c),32&c.__u,d),s.__v=c.__v,s.__.__k[s.__i]=s,te(u,s,d),c.__=c.__e=null,s.__e!=l&&x(s)))}finally{n.length=i=0}}function w(e,t,n,r,i,a,o,s,c,l,f){var p,m,h,g,_,v,y=r.__k||d,b=t.length;for(c=T(n,t,y,c,b),p=0;p<b;p++)(h=n.__k[p])!=null&&(m=~h.__i&&y[h.__i]||u,h.__i=p,v=M(e,h,m,i,a,o,s,c,l,f),g=h.__e,(m.ref!=h.ref||8&m.__u)&&(m.ref&&re(m.ref,null,h,m),h.ref&&f.push(h.ref,h.__c||g,h)),_=_||g,4&h.__u?(c=E(h,c,e,!m.__v),m.__e&&(m.__e=null)):typeof h.type==`function`&&v!==void 0?c=v:g&&(c=g.nextSibling),h.__u&=-7);return n.__e=_,c}function T(e,t,n,r,i){var a,o,s,c,l,u,d,f,m,h,g=n.length,y=g,x=0,S=!1,C=e.__k=Array(i);for(a=0;a<i;a++)(o=t[a])!=null&&typeof o!=`boolean`&&typeof o!=`function`?(typeof o!=`object`||o.constructor==String?o=C[a]=_(null,o):p(o)?o=C[a]=_(v,{children:o}):o.constructor===void 0&&o.__b?o=C[a]=_(o.type,o.props,o.key,o.ref,o.__v):C[a]=o,c=a+x,o.__=e,o.__b=e.__b+1,s=null,~(l=o.__i=O(o,n,c,y))&&(y--,(s=n[l])&&(s.__u|=2)),s&&s.__v?(o.__u|=2,l==c-1?x--:l==c+1?x++:l!=c&&(l>c?x--:x++,S=!0)):(~l||(i>g?x--:i<g&&x++),typeof o.type!=`function`&&(o.__u|=4))):C[a]=null;if(S){for(u=[],d=[],a=0;a<i;a++)if((o=C[a])&&2&o.__u){for(f=0,m=u.length;f<m;)u[h=f+m>>1]<o.__i?f=h+1:m=h;u[f]=o.__i,d[a]=f+1}for(x=u.length;a--;)d[a]&&(d[a]==x?x--:C[a].__u|=4)}if(y)for(a=0;a<g;a++)!(s=n[a])||2&s.__u||(s.__e==r&&(r=b(s)),ie(s,s));return r}function E(e,t,n,r){var i,a,o;if(typeof e.type==`function`){if(e.props.__P)return t;if(i=e.__k)for(a=0;a<i.length;a++)i[a]&&(i[a].__=e,t=E(i[a],t,n,!1));return t}for(t&&!t.parentNode&&(t=b(e))&&!t.parentNode&&(t=null),o=t;o&&o.nodeType==8;)o=o.nextSibling;for(e.__e!=o&&(!r&&n.moveBefore&&e.__e.parentNode?n.moveBefore(e.__e,t):n.insertBefore(e.__e,t||null)),t=e.__e;(t=t&&t.nextSibling)&&t.nodeType==8;);return t}function D(e,t){return t=t||[],e!=null&&typeof e!=`boolean`&&(p(e)?e.some(function(e){D(e,t)}):t.push(e)),t}function O(e,t,n,r){var i,a,o,s=e.key,c=e.type,l=t[n],u=l&&!(2&l.__u);if(l===null&&s==null||u&&s==l.key&&c==l.type)return n;if(r>+!!u){for(i=n-1,a=n+1;i>=0||a<t.length;)if((l=t[o=i>=0?i--:a++])&&!(2&l.__u)&&s==l.key&&c==l.type)return o}return-1}function k(e,t,n){n??(n=``),t[0]==`-`?e.setProperty(t,n):e[t]=n}function A(e,t,n,r,i){var a,o;n:if(t==`style`){if(typeof n==`string`)e.style.cssText=n;else{if(typeof r==`string`&&(e.style.cssText=r=``),r)for(t in r)n&&t in n||k(e.style,t,``);if(n)for(t in n)r&&n[t]==r[t]||k(e.style,t,n[t])}}else if(t[0]==`o`&&t[1]==`n`)(e.__e||(e.__e={}))[t]=n,n&&r||(o=l[t]||(l[t]=j(t)),(e.__a||(e.__a={}))[t]=c,a=t!=(t=t.replace(s,`$1`)),(t=t.slice(2))[0]<`a`&&(t=t.toLowerCase()),n?e.addEventListener(t,o,a):e.removeEventListener(t,o,a));else{if(i==`http://www.w3.org/2000/svg`)t=t.replace(/xlink(H|:h)/,`h`).replace(/sName$/,`s`);else if(t!=`width`&&t!=`height`&&t!=`href`&&t!=`list`&&t!=`form`&&t!=`tabIndex`&&t!=`download`&&t!=`rowSpan`&&t!=`colSpan`&&t!=`role`&&t!=`popover`&&t in e)try{e[t]=n??``;break n}catch{}typeof n==`function`||(n==null||!1===n&&t[4]!=`-`?e.removeAttribute(t):e.setAttribute(t,t==`popover`&&n==1?``:n))}}function j(t){return function(n){if(this.__e){var r=this.__e[t];if(n[o]==null)n[o]=c++;else if(n[o]<this.__a[t])return;return r(e.event?e.event(n):n)}}}function M(t,n,r,i,a,o,s,c,l,u){var f,m,_,x,S,C,T,E,D,O,k,A,j,M,te,re,oe,se,ce,le,N=n.type;if(n.constructor!==void 0)return null;if(128&r.__u&&(l=32&r.__u,f=r.__c.__z)){if(n.__u|=l,m=o=[],f.nodeType==8)for(_=1,x=f.nextSibling;x;x=x.nextSibling){if(x.nodeType==8){if(x.data.startsWith(`$s`))_++;else if(x.data.startsWith(`/$s`)&&!--_)break}o.push(x)}else o.push(f);c=o[0]}(f=e.__b)&&f(n);n:if(typeof N==`function`){S=s.length;try{if(O=n.props,k=(f=N.prototype)&&f.render,A=(f=N.contextType)&&i[f.__c],j=f?A?A.props.value:f.__:i,r.__c?2&(C=n.__c=r.__c).__g&&(C.__g|=1):(k?n.__c=C=new N(O,j):(n.__c=C=new y(O,j),C.constructor=N,C.render=ae),A&&A.sub(C),C.state||(C.state={}),C.__n=i,C.__g|=8,C.__h=[],C._Line truncated
|
var e,t,n,r,i,a,o,s,c,l,u={},d=[],f=/^m(i|n|o|s|text|space)$/,p=Array.isArray,m=d.slice,h=Object.assign;function g(e){e&&e.parentNode&&e.remove()}function _(n,r,i,a,o){var s={type:n,props:r,key:i,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o||++t,__i:-1,__u:0};return!o&&e.vnode&&e.vnode(s),s}function v(e){return e.children}function y(e,t){this.props=e,this.context=t,this.__g=0}function b(e,t){if(t==null)return e.__?b(e.__,e.__i+1):null;for(var n;t<e.__k.length;t++)if((n=e.__k[t])&&n.__e)return n.__e;return typeof e.type!=`function`||e.props.__P?null:b(e)}function x(e){if((e=e.__)&&e.__c&&!e.props.__P)return e.__e=null,e.__k.some(function(t){return t&&(e.__e=t.__e)}),x(e)}function S(t){(8&t.__g||!(t.__g|=8)||!n.push(t)||i++)&&r==e.debounceRendering||((r=e.debounceRendering)||queueMicrotask)(C)}function C(){var t,r,o,s,c,l,u,d,f;try{for(r=1;n.length;)n.length>r&&n.sort(a),t=n.shift(),r=n.length,8&t.__g&&(s=void 0,c=void 0,l=(c=(o=t).__v).__e,u=[],d=[],(f=o.__P)&&((s=h({constructor:void 0},c)).__v=c.__v+1,e.vnode&&e.vnode(s),M(f,s,c,o.__n,f.namespaceURI,32&c.__u?[l]:null,u,l||b(c),32&c.__u,d),s.__v=c.__v,s.__.__k[s.__i]=s,te(u,s,d),c.__=c.__e=null,s.__e!=l&&x(s)))}finally{n.length=i=0}}function w(e,t,n,r,i,a,o,s,c,l,f){var p,m,h,g,_,v,y=r.__k||d,b=t.length;for(c=T(n,t,y,c,b),p=0;p<b;p++)(h=n.__k[p])!=null&&(m=~h.__i&&y[h.__i]||u,h.__i=p,v=M(e,h,m,i,a,o,s,c,l,f),g=h.__e,(m.ref!=h.ref||8&m.__u)&&(m.ref&&re(m.ref,null,h,m),h.ref&&f.push(h.ref,h.__c||g,h)),_=_||g,4&h.__u?(c=E(h,c,e,!m.__v),m.__e&&(m.__e=null)):typeof h.type==`function`&&v!==void 0?c=v:g&&(c=g.nextSibling),h.__u&=-7);return n.__e=_,c}function T(e,t,n,r,i){var a,o,s,c,l,u,d,f,m,h,g=n.length,y=g,x=0,S=!1,C=e.__k=Array(i);for(a=0;a<i;a++)(o=t[a])!=null&&typeof o!=`boolean`&&typeof o!=`function`?(typeof o!=`object`||o.constructor==String?o=C[a]=_(null,o):p(o)?o=C[a]=_(v,{children:o}):o.constructor===void 0&&o.__b?o=C[a]=_(o.type,o.props,o.key,o.ref,o.__v):C[a]=o,c=a+x,o.__=e,o.__b=e.__b+1,s=null,~(l=o.__i=O(o,n,c,y))&&(y--,(s=n[l])&&(s.__u|=2)),s&&s.__v?(o.__u|=2,l==c-1?x--:l==c+1?x++:l!=c&&(l>c?x--:x++,S=!0)):(~l||(i>g?x--:i<g&&x++),typeof o.type!=`function`&&(o.__u|=4))):C[a]=null;if(S){for(u=[],d=[],a=0;a<i;a++)if((o=C[a])&&2&o.__u){for(f=0,m=u.length;f<m;)u[h=f+m>>1]<o.__i?f=h+1:m=h;u[f]=o.__i,d[a]=f+1}for(x=u.length;a--;)d[a]&&(d[a]==x?x--:C[a].__u|=4)}if(y)for(a=0;a<g;a++)!(s=n[a])||2&s.__u||(s.__e==r&&(r=b(s)),ie(s,s));return r}function E(e,t,n,r){var i,a,o;if(typeof e.type==`function`){if(e.props.__P)return t;if(i=e.__k)for(a=0;a<i.length;a++)i[a]&&(i[a].__=e,t=E(i[a],t,n,!1));return t}for(t&&!t.parentNode&&(t=b(e))&&!t.parentNode&&(t=null),o=t;o&&o.nodeType==8;)o=o.nextSibling;for(e.__e!=o&&(!r&&n.moveBefore&&e.__e.parentNode?n.moveBefore(e.__e,t):n.insertBefore(e.__e,t||null)),t=e.__e;(t=t&&t.nextSibling)&&t.nodeType==8;);return t}function D(e,t){return t=t||[],e!=null&&typeof e!=`boolean`&&(p(e)?e.some(function(e){D(e,t)}):t.push(e)),t}function O(e,t,n,r){var i,a,o,s=e.key,c=e.type,l=t[n],u=l&&!(2&l.__u);if(l===null&&s==null||u&&s==l.key&&c==l.type)return n;if(r>+!!u){for(i=n-1,a=n+1;i>=0||a<t.length;)if((l=t[o=i>=0?i--:a++])&&!(2&l.__u)&&s==l.key&&c==l.type)return o}return-1}function k(e,t,n){n??(n=``),t[0]==`-`?e.setProperty(t,n):e[t]=n}function A(e,t,n,r,i){var a,o;n:if(t==`style`){if(typeof n==`string`)e.style.cssText=n;else{if(typeof r==`string`&&(e.style.cssText=r=``),r)for(t in r)n&&t in n||k(e.style,t,``);if(n)for(t in n)r&&n[t]==r[t]||k(e.style,t,n[t])}}else if(t[0]==`o`&&t[1]==`n`)(e.__e||(e.__e={}))[t]=n,n&&r||(o=l[t]||(l[t]=j(t)),(e.__a||(e.__a={}))[t]=c,a=t!=(t=t.replace(s,`$1`)),(t=t.slice(2))[0]<`a`&&(t=t.toLowerCase()),n?e.addEventListener(t,o,a):e.removeEventListener(t,o,a));else{if(i==`http://www.w3.org/2000/svg`)t=t.replace(/xlink(H|:h)/,`h`).replace(/sName$/,`s`);else if(t!=`width`&&t!=`height`&&t!=`href`&&t!=`list`&&t!=`form`&&t!=`tabIndex`&&t!=`download`&&t!=`rowSpan`&&t!=`colSpan`&&t!=`role`&&t!=`popover`&&t in e)try{e[t]=n??``;break n}catch{}typeof n==`function`||(n==null||!1===n&&t[4]!=`-`?e.removeAttribute(t):e.setAttribute(t,t==`popover`&&n==1?``:n))}}function j(t){return function(n){if(this.__e){var r=this.__e[t];if(n[o]==null)n[o]=c++;else if(n[o]<this.__a[t])return;return r(e.event?e.event(n):n)}}}function M(t,n,r,i,a,o,s,c,l,u){var f,m,_,x,S,C,T,E,D,O,k,A,j,M,te,re,oe,se,ce,le,N=n.type;if(n.constructor!==void 0)return null;if(128&r.__u&&(l=32&r.__u,f=r.__c.__z)){if(n.__u|=l,m=o=[],f.nodeType==8)for(_=1,x=f.nextSibling;x;x=x.nextSibling){if(x.nodeType==8){if(x.data.startsWith(`$s`))_++;else if(x.data.startsWith(`/$s`)&&!--_)break}o.push(x)}else o.push(f);c=o[0]}(f=e.__b)&&f(n);n:if(typeof N==`function`){S=s.length;try{if(O=n.props,k=(f=N.prototype)&&f.render,A=(f=N.contextType)&&i[f.__c],j=f?A?A.props.value:f.__:i,r.__c?2&(C=n.__c=r.__c).__g&&(C.__g|=1):(k?n.__c=C=new N(O,j):(n.__c=C=new y(O,j),C.constructor=N,C.render=ae),A&&A.sub(C),C.state||(C.state={}),C.__n=i,C.__g|=8,C.__h=[],C._Line truncated
|
||||||
`),l]),i&&!t?L(Rn,{}):n&&!t?L(zn,{message:n,onRetry:c}):t?L(v,{children:[L(`section`,{class:`rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5 sm:p-6`,children:L(`div`,{class:`flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between`,children:[L(`div`,{class:`flex min-w-0 items-start gap-3 sm:gap-4`,children:[L(`span`,{class:`grid size-12 shrink-0 place-items-center rounded-2xl bg-teal-50 text-teal-700`,children:L(pt,{class:`size-7`})}),L(`div`,{class:`min-w-0`,children:[L(`p`,{class:`m-0 text-xs font-extrabold uppercase tracking-[0.16em] text-teal-700`,children:`Local overrides`}),L(`h2`,{class:`mt-2 mb-0 break-words text-xl font-black tracking-tight text-slate-950 sm:text-2xl`,children:`사용자 도메인 규칙`}),L(`p`,{class:`mt-2 mb-0 max-w-3xl text-sm leading-6 text-slate-600`,children:`허용 목록은 오탐을 해제하고, 차단 목록은 원하는 도메인을 SafeShield 보호 목록에 추가합니다.`})]})]}),L(`dl`,{class:`m-0 grid grid-cols-3 gap-2 sm:min-w-80`,children:[L(`div`,{class:`rounded-xl bg-emerald-50 px-3 py-3 text-center`,children:[L(`dt`,{class:`text-[0.68rem] font-extrabold uppercase tracking-wide text-emerald-700`,children:`허용`}),L(`dd`,{class:`mt-1 mb-0 ml-0 text-lg font-black text-emerald-950`,children:t.counts.allow})]}),L(`div`,{class:`rounded-xl bg-red-50 px-3 py-3 text-center`,children:[L(`dt`,{class:`text-[0.68rem] font-extrabold uppercase tracking-wide text-red-700`,children:`차단`}),L(`dd`,{class:`mt-1 mb-0 ml-0 text-lg font-black text-red-950`,children:t.counts.block})]}),L(`div`,{class:`rounded-xl bg-slate-100 px-3 py-3 text-center`,children:[L(`dt`,{class:`text-[0.68rem] font-extrabold uppercase tracking-wide text-slate-600`,children:`전체`}),L(`dd`,{class:`mt-1 mb-0 ml-0 text-lg font-black text-slate-950`,children:t.counts.total})]})]})]})}),L(`section`,{class:`mt-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5 sm:p-5`,"aria-label":`추천 사용자 규칙`,children:L(`div`,{class:`flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center`,children:[L(`div`,{class:`min-w-0`,children:[L(`div`,{class:`flex flex-wrap items-center gap-2`,children:[L(`span`,{class:`grid size-8 place-items-center rounded-lg bg-teal-50 text-teal-700`,children:L(B,{class:`size-4`})}),L(`h2`,{class:`m-0 text-base font-extrabold text-slate-950`,children:`추천 사용자 규칙`}),f&&u.length>0?L(`span`,{class:`rounded-full bg-teal-50 px-2.5 py-1 text-xs font-bold text-teal-800`,children:[`추천으로 추가 `,u.length,`개`]}):null]}),L(`div`,{class:`mt-2 flex flex-wrap items-center gap-2`,"aria-label":`추천 규칙 분류`,children:[L(`span`,{class:`inline-flex items-center rounded-full bg-teal-50 px-2.5 py-1 text-xs font-bold text-teal-800`,children:`공식 추천`}),L(`span`,{class:`inline-flex items-center rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-700`,children:`커뮤니티`})]}),L(`p`,{class:`mt-2 mb-0 text-sm leading-6 text-slate-600`,children:`SmartSafeHub의 공식 추천과 관리자가 검토한 커뮤니티 허용 규칙을 확인하고, 필요한 규칙을 적용해 보세요.`}),L(`p`,{class:`mt-1 mb-0 text-xs leading-5 text-slate-500`,children:`공유기 선택과 적용 관리는 SmartSafeHub 웹사이트에서 진행합니다. 적용 결과는 다음 기기 동기화 후 반영되며, 연결된 모든 기기에 영향을 줍니다.`})]}),L(`a`,{class:`inline-flex min-h-10 w-full shrink-0 items-center justify-center rounded-xl border border-teal-700 bg-teal-700 px-4 py-2.5 text-sm font-extrabold text-white transition hover:bg-teal-800 sm:w-auto`,href:`https://www.smartsafehub.com/dashboard/recommended-rules/`,target:`_blank`,rel:`noopener noreferrer`,children:`추천 규칙 보기 ↗`})]})}),t.applyLocalOverrides?null:L(`section`,{class:`mt-4 flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 p-4`,role:`alert`,children:[L(V,{class:`mt-0.5 size-5 shrink-0 text-amber-700`}),L(`div`,{children:[L(`p`,{class:`m-0 text-sm font-extrabold text-amber-950`,children:`로컬 규칙 적용이 꺼져 있습니다`}),L(`p`,{class:`mt-1 mb-0 text-sm leading-6 text-amber-800`,children:`규칙은 저장되지만 SafeShield의 apply_local_overrides 설정을 켜기 전까지 DNS에 반영되지 않습니다.`})]})]}),t.safeshieldEnabled?null:L(`section`,{class:`mt-4 flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-100 p-4`,role:`status`,children:[L(Ue,{class:`mt-0.5 size-5 shrink-0 text-slate-600`}),L(`div`,{children:[L(`p`,{class:`m-0 text-sm font-extrabold text-slate-950`,children:`SafeShield가 비활성화되어 있습니다`}),L(`p`,{class:`mt-1 mb-0 text-sm leading-6 text-slate-600`,children:`규칙을 미리 저장할 수 있으며, SafeShield를 켜고 차단 목록을 갱신하면 적용됩니다.`})]})]}),r?L(`section`,{"aria-live":`polite`,class:`mt-4 flex flex-col items-start gap-3 rounded-2xl border border-teal-200
Line truncated
|
`),l,g,x,re]),I(()=>{if(!C)return;let e=window.setTimeout(()=>E(!0),6e4);return()=>window.clearTimeout(e)},[C]);async function ie(){if(!(!u||g||x)){_(!0),ee(null),j(null);try{let e=await u();re(e),j(e.syncStatus===`confirmed`?`서버에서 규칙을 가져와 SafeShield 허용 목록을 확인했습니다.`:`추천 규칙 상태를 확인 중입니다. 진행 결과는 자동으로 갱신됩니다.`),c()}catch{ee(`추천 규칙을 동기화하지 못했습니다. 서버 연결 및 기기 등록 상태를 확인해 주세요.`)}finally{_(!1)}}}async function ae(){if(!y||x||!d)return;let e=y.id;D.current=e,S(!0),w(!0),E(!1),ee(null),j(null);try{let t=await d(e);re(t),b(null),D.current!==null&&j(`적용 해제 요청을 처리했습니다. 공유기의 허용 목록을 확인하고 있습니다.`),k.current()}catch{b(null),ee(`적용 해제 결과를 아직 확인하지 못했습니다. 공유기의 상태를 자동으로 다시 확인합니다.`);try{let e=await l?.();e&&re(e)}catch{}}finally{S(!1)}}let oe=f?.syncStage,se=f?.syncStatus===`pending`,ce=f?.syncStatus===`delayed`,le=f?.syncStatus===`failed`,N=oe===`server_sync`?`서버 동기화 중`:oe===`safeshield_refreshing`?`SafeShield 갱신 중`:oe===`applying`?`추천 규칙 적용 중`:oe===`verifying`?`허용 목록 확인 중`:ce?`재확인 지연`:le?`동기화 실패`:se?`동기화 대기`:`허용 목록 확인`,ue=oe===`safeshield_refreshing`&&se?`SafeShield가 보호 목록을 갱신하고 있습니다. 갱신이 끝나면 추천 규칙을 자동으로 다시 확인합니다.`:oe===`applying`&&se?`추천 규칙을 SafeShield 허용 목록에 반영하고 있습니다.`:oe===`verifying`&&se?`SafeShield 허용 목록에서 추천 규칙 반영 결과를 확인하고 있습니다.`:oe===`server_sync`&&se?`SmartSafeHub 서버에서 최신 추천 규칙을 확인하고 있습니다.`:ce?`서버 응답이 지연되고 있습니다. 이미 확인한 허용 규칙은 유지되며 자동으로 다시 시도합니다.`:le?f?.syncReason===`auth_rejected`?`SmartSafeHub 기기 인증이 거부되었습니다. 공유기 계정 연결 상태를 확인해 주세요.`:f?.syncReason===`api_rejected`?`서버가 추천 규칙 요청을 거부했습니다. Hub API 배포 상태를 확인해 주세요.`:f?.syncReason===`invalid_response`?`서버 응답을 확인할 수 없습니다. 규칙은 유지되며 다음 동기화에서 다시 확인합니다.`:`추천 규칙 반영을 완료하지 못했습니다. 다음 자동 동기화에서 다시 시도합니다.`:se?`추천 규칙의 적용 결과를 기다리고 있습니다.`:null,de=C&&D.current!==null?{tone:`amber`,label:T?`적용 해제 처리 지연`:`적용 해제 확인 중`,description:T?`적용 해제 확인이 지연되고 있습니다. SafeShield 갱신 중이라면 완료 후 자동으로 재확인합니다.`:ue??`적용 해제 상태를 확인하고 있습니다. 확인되면 목록과 상태가 자동 갱신됩니다.`}:se||ce||le?{tone:le?`red`:`amber`,label:N,description:ue??``}:M?{tone:`red`,label:`상태 확인 필요`,description:M}:f?.syncWarning?{tone:`amber`,label:`서버 재확인 지연`,description:`이미 확인한 허용 규칙은 유지되며 자동으로 다시 시도합니다.`}:A?{tone:`green`,label:`허용 목록 확인`,description:A}:null;return i&&!t?L(zn,{}):n&&!t?L(Bn,{message:n,onRetry:c}):t?L(v,{children:[y?L(`div`,{class:`fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4`,role:`presentation`,onClick:()=>{x||b(null)},children:L(`section`,{"aria-labelledby":`recommended-revoke-title`,"aria-modal":`true`,class:`w-full max-w-md rounded-2xl border border-slate-200 bg-white p-5 shadow-xl`,onClick:e=>e.stopPropagation(),role:`dialog`,children:[L(`h2`,{class:`m-0 text-lg font-extrabold text-slate-950`,id:`recommended-revoke-title`,children:`추천 규칙 적용 해제`}),L(`p`,{class:`mt-3 text-sm text-slate-700`,children:[L(`strong`,{children:y.title}),` 규칙을 이 공유기에서 해제할까요?`]}),L(`p`,{class:`mt-2 text-xs leading-5 text-slate-600`,children:[`포함된 도메인 `,y.domains.length,`개를 다시 확인합니다. 직접 추가한 허용 규칙과 다른 추천 규칙에 필요한 도메인은 유지됩니다.`]}),L(`div`,{class:`mt-5 flex justify-end gap-2`,children:[L(`button`,{class:`rounded-lg border border-slate-200 px-4 py-2 text-sm font-bold text-slate-700 disabled:cursor-wait disabled:opacity-50`,disabled:x,onClick:()=>b(null),type:`button`,children:`취소`}),L(`button`,{class:`inline-flex min-w-24 items-center justify-center gap-2 rounded-lg bg-red-700 px-4 py-2 text-sm font-bold text-white transition hover:bg-red-800 disabled:cursor-progress disabled:bg-red-700 disabled:opacity-80`,"aria-busy":x,disabled:x,onClick:()=>void ae(),type:`button`,children:[x?L(`svg`,{class:`size-4 animate-spin`,"aria-hidden":`true`,viewBox:`0 0 24 24`,fill:`none`,children:[L(`circle`,{cx:`12`,cy:`12`,r:`9`,stroke:`currentColor`,"stroke-width":`3`,opacity:
Line truncated
|
||||||
`)>-1?e.split(`
|
`)>-1?e.split(`
|
||||||
`):e}function Ef(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function Df(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=gs(t.bodyFont),l=gs(t.titleFont),u=gs(t.footerFont),d=a.length,f=i.length,p=r.length,m=hs(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,J(e.title,y),n.font=c.string,J(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,J(r,e=>{J(e.before,y),J(e.lines,y),J(e.after,y)}),v=0,n.font=u.string,J(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function Of(e,t){let{y:n,height:r}=t;return n<r/2?`top`:n>e.height-r/2?`bottom`:`center`}function kf(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function Af(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),kf(l,e,t,n)&&(l=`center`),l}function jf(e,t,n){let r=n.yAlign||t.yAlign||Of(e,n);return{xAlign:n.xAlign||t.xAlign||Af(e,t,n,r),yAlign:r}}function Mf(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function Nf(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:r-=t===`bottom`?i+n:i/2,r}function Pf(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=ms(o),m=Mf(t,s),h=Nf(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:io(m,0,r.width-t.width),y:io(h,0,r.height-t.height)}}function Ff(e,t,n){let r=hs(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function If(e){return wf([],Tf(e))}function Lf(e,t,n){return ys(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function Rf(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var zf={beforeTitle:ma,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex<r)return n[t.dataIndex]}return``},afterTitle:ma,beforeBody:ma,beforeLabel:ma,label(e){if(this&&this.options&&this.options.mode===`dataset`)return e.label+`: `+e.formattedValue||e.formattedValue;let t=e.dataset.label||``;t&&(t+=`: `);let n=e.formattedValue;return U(n)||(t+=n),t},labelColor(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{borderColor:t.borderColor,backgroundColor:t.backgroundColor,borderWidth:t.borderWidth,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{pointStyle:t.pointStyle,rotation:t.rotation}},afterLabel:ma,afterBody:ma,beforeFooter:ma,footer:ma,afterFooter:ma};function Bf(e,t,n,r){let i=e[t].call(n,r);return i===void 0?zf[t].call(n,r):i}var Vf=class extends Mu{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){let e=this._cachedAnimations;if(e)return e;let t=this.chart,n=this.options.setContext(this.getContext()),r=n.enabled&&t.options.animation&&n.animations,i=new Hc(this.chart,r);return r._cacheable&&(this._cachedAnimations=Object.freeze(i)),i}getContext(){return this.$context||(this.$context=Lf(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,t){let{callbacks:n}=t,r=Bf(n,`beforeTitle`,this,e),i=Bf(n,`title`,this,e),a=Bf(n,`afterTitle`,this,e),o=[];return o=wf(o,Tf(r)),o=wf(o,Tf(i)),o=wf(o,Tf(a)),o}getBeforeBody(e,t){return If(Bf(t.callbacks,`beforeBody`,this,e))}getBody(e,t){let{callbacks:n}=t,r=[];return J(e
Line truncated
|
`):e}function Df(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function Of(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=_s(t.bodyFont),l=_s(t.titleFont),u=_s(t.footerFont),d=a.length,f=i.length,p=r.length,m=gs(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,J(e.title,y),n.font=c.string,J(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,J(r,e=>{J(e.before,y),J(e.lines,y),J(e.after,y)}),v=0,n.font=u.string,J(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function kf(e,t){let{y:n,height:r}=t;return n<r/2?`top`:n>e.height-r/2?`bottom`:`center`}function Af(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function jf(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),Af(l,e,t,n)&&(l=`center`),l}function Mf(e,t,n){let r=n.yAlign||t.yAlign||kf(e,n);return{xAlign:n.xAlign||t.xAlign||jf(e,t,n,r),yAlign:r}}function Nf(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function Pf(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:r-=t===`bottom`?i+n:i/2,r}function Ff(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=hs(o),m=Nf(t,s),h=Pf(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:ao(m,0,r.width-t.width),y:ao(h,0,r.height-t.height)}}function If(e,t,n){let r=gs(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function Lf(e){return Tf([],Ef(e))}function Rf(e,t,n){return bs(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function zf(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var Bf={beforeTitle:ha,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex<r)return n[t.dataIndex]}return``},afterTitle:ha,beforeBody:ha,beforeLabel:ha,label(e){if(this&&this.options&&this.options.mode===`dataset`)return e.label+`: `+e.formattedValue||e.formattedValue;let t=e.dataset.label||``;t&&(t+=`: `);let n=e.formattedValue;return U(n)||(t+=n),t},labelColor(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{borderColor:t.borderColor,backgroundColor:t.backgroundColor,borderWidth:t.borderWidth,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{pointStyle:t.pointStyle,rotation:t.rotation}},afterLabel:ha,afterBody:ha,beforeFooter:ha,footer:ha,afterFooter:ha};function Vf(e,t,n,r){let i=e[t].call(n,r);return i===void 0?Bf[t].call(n,r):i}var Hf=class extends Nu{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){let e=this._cachedAnimations;if(e)return e;let t=this.chart,n=this.options.setContext(this.getContext()),r=n.enabled&&t.options.animation&&n.animations,i=new Uc(this.chart,r);return r._cacheable&&(this._cachedAnimations=Object.freeze(i)),i}getContext(){return this.$context||(this.$context=Rf(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,t){let{callbacks:n}=t,r=Vf(n,`beforeTitle`,this,e),i=Vf(n,`title`,this,e),a=Vf(n,`afterTitle`,this,e),o=[];return o=Tf(o,Ef(r)),o=Tf(o,Ef(i)),o=Tf(o,Ef(a)),o}getBeforeBody(e,t){return Lf(Vf(t.callbacks,`beforeBody`,this,e))}getBody(e,t){let{callbacks:n}=t,r=[];return J(e
Line truncated
|
||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "luci-app-smartsafehub-frontend",
|
"name": "luci-app-smartsafehub-frontend",
|
||||||
"version": "0.2.29",
|
"version": "0.2.30",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "luci-app-smartsafehub-frontend",
|
"name": "luci-app-smartsafehub-frontend",
|
||||||
"version": "0.2.29",
|
"version": "0.2.30",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"preact": "11.0.1"
|
"preact": "11.0.1"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "luci-app-smartsafehub-frontend",
|
"name": "luci-app-smartsafehub-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.29",
|
"version": "0.2.30",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0"
|
"node": ">=24.0"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import type {
|
|||||||
WanSettingsInput,
|
WanSettingsInput,
|
||||||
WanUpdateResult,
|
WanUpdateResult,
|
||||||
} from '../types/wan';
|
} from '../types/wan';
|
||||||
|
import type { RecommendedRulesStatus } from '../types/rules';
|
||||||
import { callApi } from './rpc';
|
import { callApi } from './rpc';
|
||||||
|
|
||||||
const API_OBJECT = 'smartsafehub';
|
const API_OBJECT = 'smartsafehub';
|
||||||
@@ -88,15 +89,18 @@ export interface DeviceRegistrationStatus {
|
|||||||
lastSuccessAt: number;
|
lastSuccessAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecommendedRulesStatus {
|
|
||||||
revision: string | null;
|
|
||||||
domains: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchRecommendedRulesStatus(): Promise<RecommendedRulesStatus> {
|
export function fetchRecommendedRulesStatus(): Promise<RecommendedRulesStatus> {
|
||||||
return callApi(API_OBJECT, 'device_recommended_rules_status', {}, { timeoutMs: 5000 });
|
return callApi(API_OBJECT, 'device_recommended_rules_status', {}, { timeoutMs: 5000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function refreshRecommendedRulesStatus(): Promise<RecommendedRulesStatus> {
|
||||||
|
return callApi(API_OBJECT, 'device_recommended_rules_refresh', {}, { timeoutMs: 30000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeRecommendedRule(ruleId: number): Promise<RecommendedRulesStatus> {
|
||||||
|
return callApi(API_OBJECT, 'device_recommended_rules_revoke', { rule_id: ruleId }, { timeoutMs: 30000 });
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchDeviceRegistrationStatus(): Promise<DeviceRegistrationStatus> {
|
export function fetchDeviceRegistrationStatus(): Promise<DeviceRegistrationStatus> {
|
||||||
return callApi(API_OBJECT, 'device_registration_status', {}, { timeoutMs: 5000 });
|
return callApi(API_OBJECT, 'device_registration_status', {}, { timeoutMs: 5000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
import { fetchGuestWifiQr, fetchRecommendedRulesStatus, fetchWifiQr, initializeSystemTimezone } from '../api/smartsafehub';
|
import { fetchGuestWifiQr, fetchRecommendedRulesStatus, refreshRecommendedRulesStatus,
|
||||||
|
revokeRecommendedRule, fetchWifiQr, initializeSystemTimezone } from '../api/smartsafehub';
|
||||||
|
|
||||||
import { AppShell } from '../components/AppShell';
|
import { AppShell } from '../components/AppShell';
|
||||||
import { PageTabs } from '../components/PageTabs';
|
import { PageTabs } from '../components/PageTabs';
|
||||||
@@ -375,6 +376,8 @@ export function App({ onAdministratorPasswordChanged }: AppProps) {
|
|||||||
onAddRule={rules.addRule}
|
onAddRule={rules.addRule}
|
||||||
onDeleteRule={rules.deleteRule}
|
onDeleteRule={rules.deleteRule}
|
||||||
onLoadRecommendedRulesStatus={fetchRecommendedRulesStatus}
|
onLoadRecommendedRulesStatus={fetchRecommendedRulesStatus}
|
||||||
|
onSyncRecommendedRules={refreshRecommendedRulesStatus}
|
||||||
|
onRevokeRecommendedRule={revokeRecommendedRule}
|
||||||
onDismissFeedback={rules.dismissFeedback}
|
onDismissFeedback={rules.dismissFeedback}
|
||||||
onRetry={() => void rules.refresh()}
|
onRetry={() => void rules.refresh()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -394,9 +394,20 @@ export function FirmwareUpdatesCard({
|
|||||||
펌웨어 {data.release.version || '미확인'} · {formatBytes(data.release.sysupgrade.sizeBytes)} · {(data.release.channel || data.settings.channel) === 'beta' ? 'Beta' : 'Stable'}
|
펌웨어 {data.release.version || '미확인'} · {formatBytes(data.release.sysupgrade.sizeBytes)} · {(data.release.channel || data.settings.channel) === 'beta' ? 'Beta' : 'Stable'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 rounded-full bg-white px-3 py-1.5 text-xs font-extrabold text-sky-700 ring-1 ring-inset ring-sky-200">
|
<div class="flex shrink-0 flex-wrap items-center justify-start gap-2 sm:justify-end">
|
||||||
{data.release.version || 'Latest'}
|
<span class="rounded-full bg-white px-3 py-1.5 text-xs font-extrabold text-sky-700 ring-1 ring-inset ring-sky-200">
|
||||||
</span>
|
{data.release.version || 'Latest'}
|
||||||
|
</span>
|
||||||
|
{bundledVersion ? (
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full bg-white px-3 py-1.5 text-xs font-extrabold text-slate-700 ring-1 ring-inset ring-sky-200"
|
||||||
|
data-section="firmware-bundled-software-chip"
|
||||||
|
>
|
||||||
|
<span class="text-[11px] uppercase tracking-[0.12em] text-slate-500">관리 SW</span>
|
||||||
|
<span class="text-slate-950">{bundledVersion}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{bundledVersion ? (
|
{bundledVersion ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SectionHeading } from './SectionHeading';
|
||||||
import { formatNumber, formatTimestamp } from '../app/format';
|
import { formatNumber, formatTimestamp } from '../app/format';
|
||||||
import type { SafeShieldAction } from '../hooks/useSafeShieldActions';
|
import type { SafeShieldAction } from '../hooks/useSafeShieldActions';
|
||||||
import type {
|
import type {
|
||||||
@@ -69,7 +70,7 @@ function Metric({
|
|||||||
value: string;
|
value: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div class="rounded-xl border border-slate-100 bg-slate-50 p-4 sm:p-5">
|
<div class="rounded-xl bg-slate-50 p-4 sm:p-5">
|
||||||
<dt class="text-[0.68rem] font-black uppercase tracking-[0.12em] text-slate-400">
|
<dt class="text-[0.68rem] font-black uppercase tracking-[0.12em] text-slate-400">
|
||||||
{label}
|
{label}
|
||||||
</dt>
|
</dt>
|
||||||
@@ -95,17 +96,22 @@ export function SafeShieldStatisticsPanel({
|
|||||||
}: SafeShieldStatisticsPanelProps) {
|
}: SafeShieldStatisticsPanelProps) {
|
||||||
if (loading && data === null) {
|
if (loading && data === null) {
|
||||||
return (
|
return (
|
||||||
<section class="mt-5 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
<>
|
||||||
|
<SectionHeading eyebrow="Activity" title="최근 24시간 보호 활동" description="DNS 요청과 차단 현황을 확인합니다." />
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
||||||
<p class="m-0 text-sm font-bold text-slate-500">
|
<p class="m-0 text-sm font-bold text-slate-500">
|
||||||
차단 통계를 불러오는 중입니다…
|
차단 통계를 불러오는 중입니다…
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && data === null) {
|
if (error && data === null) {
|
||||||
return (
|
return (
|
||||||
<section class="mt-5 rounded-2xl border border-amber-200 bg-amber-50 p-5 sm:p-6">
|
<>
|
||||||
|
<SectionHeading eyebrow="Activity" title="최근 24시간 보호 활동" description="DNS 요청과 차단 현황을 확인합니다." />
|
||||||
|
<section class="rounded-2xl border border-amber-200 bg-amber-50 p-5 sm:p-6">
|
||||||
<p class="m-0 text-sm font-extrabold text-amber-950">
|
<p class="m-0 text-sm font-extrabold text-amber-950">
|
||||||
차단 통계를 불러오지 못했습니다.
|
차단 통계를 불러오지 못했습니다.
|
||||||
</p>
|
</p>
|
||||||
@@ -118,22 +124,20 @@ export function SafeShieldStatisticsPanel({
|
|||||||
다시 시도
|
다시 시도
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<section class="mt-5 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
<>
|
||||||
<p class="m-0 text-xs font-bold uppercase tracking-[0.16em] text-slate-500">
|
<SectionHeading eyebrow="Activity" title="최근 24시간 보호 활동" description="DNS 요청과 차단 현황을 확인합니다." />
|
||||||
Activity
|
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
||||||
</p>
|
<p class="m-0 text-sm leading-6 text-slate-600">
|
||||||
<h2 class="mt-3 mb-0 text-xl font-extrabold text-slate-950">
|
현재 SafeShield에서 통계 데이터를 제공하지 않습니다. SafeShield 0.3.15 버전 이상이 설치되어 있고, 통계 수집이 활성화되어 있는지 확인해 주세요.
|
||||||
보호 활동
|
</p>
|
||||||
</h2>
|
</section>
|
||||||
<p class="mt-3 mb-0 text-sm leading-6 text-slate-600">
|
</>
|
||||||
현재 SafeShield에서 통계 데이터를 제공하지 않습니다. SafeShield 0.3.15 버전 이상이 설치되어 있고, 통계 수집이 활성화되어 있는지 확인해 주세요.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,76 +169,69 @@ export function SafeShieldStatisticsPanel({
|
|||||||
: '비활성화';
|
: '비활성화';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeading
|
||||||
|
action={
|
||||||
|
<div class="flex items-center gap-3 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2.5">
|
||||||
|
<span class="grid size-10 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-500">
|
||||||
|
<DatabaseIcon class="size-5" />
|
||||||
|
</span>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="m-0 text-[0.68rem] font-bold text-slate-400">통계 수집</p>
|
||||||
|
<p
|
||||||
|
class={`mt-1 mb-0 text-xs font-extrabold ${
|
||||||
|
statisticsActionBusy || targetEnabled
|
||||||
|
? 'text-emerald-700'
|
||||||
|
: 'text-slate-500'
|
||||||
|
}`}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{collectionStatus}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-checked={targetEnabled}
|
||||||
|
aria-label={targetEnabled ? '차단 통계 수집 끄기' : '차단 통계 수집 켜기'}
|
||||||
|
class={`ssh-switch-control relative inline-flex shrink-0 items-center rounded-full border-0 p-0 transition focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-100 ${
|
||||||
|
statisticsActionBusy
|
||||||
|
? 'cursor-wait opacity-80'
|
||||||
|
: 'disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
} ${targetEnabled ? 'bg-teal-600' : 'bg-slate-300'}`}
|
||||||
|
disabled={action !== null}
|
||||||
|
onClick={() => onSetEnabled(!data.enabled)}
|
||||||
|
role="switch"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class={`ssh-switch-thumb absolute top-1 grid place-items-center shadow-sm transition-all ${
|
||||||
|
targetEnabled ? 'left-6' : 'left-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statisticsActionBusy ? (
|
||||||
|
<ReloadIcon aria-hidden="true" class="size-3 animate-spin text-teal-600" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
eyebrow="Activity"
|
||||||
|
title="최근 24시간 보호 활동"
|
||||||
|
description="DNS 요청 원본은 저장하지 않고 숫자만 로컬 메모리에 집계합니다."
|
||||||
|
/>
|
||||||
<section
|
<section
|
||||||
aria-busy={statisticsActionBusy}
|
aria-busy={statisticsActionBusy}
|
||||||
class={`mt-5 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6 ${
|
class={`rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6 ${
|
||||||
statisticsActionBusy ? 'cursor-wait' : ''
|
statisticsActionBusy ? 'cursor-wait' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="m-0 text-[0.68rem] font-black uppercase tracking-[0.18em] text-teal-700">
|
|
||||||
Activity
|
|
||||||
</p>
|
|
||||||
<h2 class="mt-2 mb-0 text-xl font-black tracking-tight text-slate-950">
|
|
||||||
최근 24시간 보호 활동
|
|
||||||
</h2>
|
|
||||||
<p class="mt-2 mb-0 text-sm leading-6 text-slate-500">
|
|
||||||
DNS 요청 원본은 저장하지 않고 숫자만 로컬 메모리에 집계합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2.5">
|
|
||||||
<span class="grid size-9 place-items-center rounded-lg bg-slate-100 text-slate-500">
|
|
||||||
<DatabaseIcon class="size-5" />
|
|
||||||
</span>
|
|
||||||
<div class="text-right">
|
|
||||||
<p class="m-0 text-[0.68rem] font-bold text-slate-400">통계 수집</p>
|
|
||||||
<p
|
|
||||||
class={`mt-1 mb-0 text-xs font-extrabold ${
|
|
||||||
statisticsActionBusy || targetEnabled
|
|
||||||
? 'text-emerald-700'
|
|
||||||
: 'text-slate-500'
|
|
||||||
}`}
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
{collectionStatus}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
aria-checked={targetEnabled}
|
|
||||||
aria-label={targetEnabled ? '차단 통계 수집 끄기' : '차단 통계 수집 켜기'}
|
|
||||||
class={`ssh-switch-control relative inline-flex shrink-0 items-center rounded-full border-0 p-0 transition focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-100 ${
|
|
||||||
statisticsActionBusy
|
|
||||||
? 'cursor-wait opacity-80'
|
|
||||||
: 'disabled:cursor-not-allowed disabled:opacity-50'
|
|
||||||
} ${targetEnabled ? 'bg-teal-600' : 'bg-slate-300'}`}
|
|
||||||
disabled={action !== null}
|
|
||||||
onClick={() => onSetEnabled(!data.enabled)}
|
|
||||||
role="switch"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class={`ssh-switch-thumb absolute top-1 grid place-items-center shadow-sm transition-all ${
|
|
||||||
targetEnabled ? 'left-6' : 'left-1'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{statisticsActionBusy ? (
|
|
||||||
<ReloadIcon aria-hidden="true" class="size-3 animate-spin text-teal-600" />
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!data.enabled ? (
|
{!data.enabled ? (
|
||||||
<div class="mt-5 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600">
|
<div class="mb-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600">
|
||||||
통계 수집이 꺼져 있습니다. 활성화하면 DNS 요청 원본은 저장하지 않고 요청 수와 차단 수만 로컬 메모리에 집계합니다.
|
통계 수집이 꺼져 있습니다. 활성화하면 DNS 요청 원본은 저장하지 않고 요청 수와 차단 수만 로컬 메모리에 집계합니다.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<dl class="mt-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<dl class="mt-2 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<Metric label="최근 24시간 DNS 요청" value={formatNumber(recentTotals.queries)} />
|
<Metric label="최근 24시간 DNS 요청" value={formatNumber(recentTotals.queries)} />
|
||||||
<Metric
|
<Metric
|
||||||
emphasized
|
emphasized
|
||||||
@@ -248,7 +245,7 @@ export function SafeShieldStatisticsPanel({
|
|||||||
<Metric label="현재 시간 차단" value={formatNumber(latest?.blocked ?? 0)} />
|
<Metric label="현재 시간 차단" value={formatNumber(latest?.blocked ?? 0)} />
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div class="mt-5 rounded-2xl border border-slate-100 bg-slate-50/70 p-4 sm:p-5">
|
<div class="mt-5 rounded-xl bg-slate-50/70 p-4 sm:p-5">
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="m-0 text-xs font-bold text-slate-500">최근 24시간</p>
|
<p class="m-0 text-xs font-bold text-slate-500">최근 24시간</p>
|
||||||
@@ -286,5 +283,6 @@ export function SafeShieldStatisticsPanel({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ComponentChildren } from 'preact';
|
||||||
|
|
||||||
|
interface SectionHeadingProps {
|
||||||
|
action?: ComponentChildren;
|
||||||
|
description: string;
|
||||||
|
eyebrow: string;
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section context is outside the content card; controls stay with their functional card. */
|
||||||
|
export function SectionHeading({ action, description, eyebrow, id, title }: SectionHeadingProps) {
|
||||||
|
return (
|
||||||
|
<div class="ssh-section-heading mb-4 min-w-0">
|
||||||
|
<p class="m-0 text-[0.68rem] font-black uppercase tracking-[0.18em] text-teal-700">{eyebrow}</p>
|
||||||
|
<div class="mt-2 flex min-w-0 flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 class="m-0 min-w-0 text-xl font-black tracking-tight text-slate-950" id={id}>{title}</h2>
|
||||||
|
{action ? <div class="shrink-0">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 mb-0 text-sm leading-6 text-slate-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -649,9 +649,6 @@ export function SoftwareUpdatesCard({
|
|||||||
<div class="flex min-w-0 items-start justify-between gap-4">
|
<div class="flex min-w-0 items-start justify-between gap-4">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<strong class="block text-sm font-black text-slate-950">자동으로 업데이트 확인</strong>
|
<strong class="block text-sm font-black text-slate-950">자동으로 업데이트 확인</strong>
|
||||||
<span class="mt-1 block text-xs leading-5 text-slate-500">
|
|
||||||
선택한 주기에 새 관리 소프트웨어 버전을 확인합니다.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
checked={checkEnabled}
|
checked={checkEnabled}
|
||||||
@@ -691,9 +688,6 @@ export function SoftwareUpdatesCard({
|
|||||||
<div class="flex min-w-0 items-start justify-between gap-4">
|
<div class="flex min-w-0 items-start justify-between gap-4">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<strong class="block text-sm font-black text-slate-950">자동으로 업데이트 설치</strong>
|
<strong class="block text-sm font-black text-slate-950">자동으로 업데이트 설치</strong>
|
||||||
<span class="mt-1 block text-xs leading-5 text-slate-500">
|
|
||||||
새 버전이 있으면 지정한 시각에 자동으로 설치합니다.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
checked={autoInstall}
|
checked={autoInstall}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function installBootstrap(sessionId: string, host: HTMLElement): void {
|
|||||||
sessionId,
|
sessionId,
|
||||||
rpcUrl: luciUrl('/admin/ubus'),
|
rpcUrl: luciUrl('/admin/ubus'),
|
||||||
assetBase: host.dataset.assetBase ?? '/luci-static/smartsafehub/',
|
assetBase: host.dataset.assetBase ?? '/luci-static/smartsafehub/',
|
||||||
assetVersion: host.dataset.assetVersion ?? '0.2.29-r1',
|
assetVersion: host.dataset.assetVersion ?? '0.2.30-r1',
|
||||||
locale: document.documentElement.lang || 'ko',
|
locale: document.documentElement.lang || 'ko',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import { SectionHeading } from '../components/SectionHeading';
|
||||||
import { formatNumber, formatRelativeTime } from '../app/format';
|
import { formatNumber, formatRelativeTime } from '../app/format';
|
||||||
import { ActivityLoadState, ActivityTimeline } from '../components/ActivityTimeline';
|
import { ActivityLoadState, ActivityTimeline } from '../components/ActivityTimeline';
|
||||||
import type { ActivityCloudSync, ActivityHistory } from '../types/activity';
|
import type { ActivityCloudSync, ActivityHistory } from '../types/activity';
|
||||||
@@ -92,19 +95,18 @@ function CloudActivitySyncCard({
|
|||||||
const canToggle = cloud.enabled || canEnable;
|
const canToggle = cloud.enabled || canEnable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
<section aria-label="Cloud 활동 기록 설정" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5 sm:p-5">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="m-0 text-xs font-extrabold uppercase tracking-[0.16em] text-teal-700">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
Cloud history
|
<span class={`rounded-full px-2.5 py-1 text-xs font-extrabold ring-1 ring-inset ${status.tone}`}>
|
||||||
</p>
|
{saving ? '설정 중' : status.label}
|
||||||
<h2 class="mt-2 mb-0 text-base font-black text-slate-950">Cloud 활동 기록</h2>
|
</span>
|
||||||
<p class="mt-2 mb-0 max-w-3xl text-sm leading-6 text-slate-500">{status.description}</p>
|
</div>
|
||||||
|
<p class="mt-2 mb-0 text-sm leading-6 text-slate-600">{status.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 items-center gap-3">
|
<div class="flex shrink-0 items-center justify-end gap-3 sm:justify-start">
|
||||||
<span class={`rounded-full px-3 py-1.5 text-xs font-extrabold ring-1 ring-inset ${status.tone}`}>
|
<span class="text-xs font-bold text-slate-600">Cloud 전송 {cloud.enabled ? '켜짐' : '꺼짐'}</span>
|
||||||
{saving ? '설정 중' : status.label}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
aria-checked={cloud.enabled}
|
aria-checked={cloud.enabled}
|
||||||
aria-label="Cloud 활동 기록 전송 사용"
|
aria-label="Cloud 활동 기록 전송 사용"
|
||||||
@@ -148,37 +150,52 @@ function CloudActivitySyncCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<dl class="mt-5 grid gap-3 sm:grid-cols-4">
|
{cloud.enabled ? (
|
||||||
<div class="rounded-xl bg-slate-50 px-4 py-3 ring-1 ring-inset ring-slate-200">
|
<p class="mt-3 mb-0 text-xs leading-5 text-slate-500">
|
||||||
<dt class="text-xs font-bold text-slate-500">멤버십</dt>
|
Cloud 전송을 끄면 아직 전송하지 않은 대기 기록이 삭제됩니다. 공유기의 로컬 기록은 유지됩니다.
|
||||||
<dd class="mt-1 text-sm font-black text-slate-900">{plan ?? (cloud.eligible === false ? 'Free' : '확인 중')}</dd>
|
</p>
|
||||||
</div>
|
) : null}
|
||||||
<div class="rounded-xl bg-slate-50 px-4 py-3 ring-1 ring-inset ring-slate-200">
|
|
||||||
<dt class="text-xs font-bold text-slate-500">전송 대기</dt>
|
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-slate-200 pt-3 text-xs font-semibold text-slate-500">
|
||||||
<dd class="mt-1 text-sm font-black text-slate-900">{formatNumber(cloud.pendingEvents)}건</dd>
|
<span>마지막 동기화: {cloud.lastSuccessAt > 0 ? formatRelativeTime(cloud.lastSuccessAt) : '아직 없음'}</span>
|
||||||
</div>
|
<span>Cloud 보관: {retentionLabel(cloud)}</span>
|
||||||
<div class="rounded-xl bg-slate-50 px-4 py-3 ring-1 ring-inset ring-slate-200">
|
{cloud.pendingEvents > 0 ? <span>전송 대기: {formatNumber(cloud.pendingEvents)}건</span> : null}
|
||||||
<dt class="text-xs font-bold text-slate-500">마지막 동기화</dt>
|
</div>
|
||||||
<dd class="mt-1 text-sm font-black text-slate-900">
|
|
||||||
{cloud.lastSuccessAt > 0 ? formatRelativeTime(cloud.lastSuccessAt) : '아직 없음'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl bg-slate-50 px-4 py-3 ring-1 ring-inset ring-slate-200">
|
|
||||||
<dt class="text-xs font-bold text-slate-500">Cloud 보관</dt>
|
|
||||||
<dd class="mt-1 text-sm font-black text-slate-900">
|
|
||||||
{retentionLabel(cloud)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{cloud.enabled && cloud.phase === 'error' && cloud.lastErrorCode ? (
|
{cloud.enabled && cloud.phase === 'error' && cloud.lastErrorCode ? (
|
||||||
<p class="mt-3 mb-0 text-xs font-semibold text-amber-700">
|
<p class="mt-3 mb-0 text-xs font-semibold text-amber-700">
|
||||||
오류 코드: <code>{cloud.lastErrorCode}</code>
|
오류 코드: <code>{cloud.lastErrorCode}</code>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p class="mt-3 mb-0 text-xs leading-5 text-slate-500">
|
|
||||||
SmartSafeHub 계정에 연결된 Free는 최근 7일, Pro와 Ultimate는 최근 90일 동안 Cloud 활동 기록을 보관합니다. 끄면 전송 대기 데이터와 임시 인증 정보를 삭제하며, 다시 켠 뒤 새로 발생한 활동부터 Cloud에 전송합니다. 로컬 최근 활동은 항상 유지됩니다.
|
<details class="mt-3 border-t border-slate-200 pt-3">
|
||||||
</p>
|
<summary class="cursor-pointer text-xs font-extrabold text-teal-700 hover:text-teal-800 focus-visible:rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-teal-500">
|
||||||
|
Cloud 동기화 상세 정보
|
||||||
|
</summary>
|
||||||
|
<dl class="mt-3 grid gap-3 sm:grid-cols-4">
|
||||||
|
<div class="rounded-xl bg-slate-50 px-4 py-3 ring-1 ring-inset ring-slate-200">
|
||||||
|
<dt class="text-xs font-bold text-slate-500">멤버십</dt>
|
||||||
|
<dd class="mt-1 text-sm font-black text-slate-900">{plan ?? (cloud.eligible === false ? 'Free' : '확인 중')}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl bg-slate-50 px-4 py-3 ring-1 ring-inset ring-slate-200">
|
||||||
|
<dt class="text-xs font-bold text-slate-500">전송 대기</dt>
|
||||||
|
<dd class="mt-1 text-sm font-black text-slate-900">{formatNumber(cloud.pendingEvents)}건</dd>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl bg-slate-50 px-4 py-3 ring-1 ring-inset ring-slate-200">
|
||||||
|
<dt class="text-xs font-bold text-slate-500">마지막 동기화</dt>
|
||||||
|
<dd class="mt-1 text-sm font-black text-slate-900">
|
||||||
|
{cloud.lastSuccessAt > 0 ? formatRelativeTime(cloud.lastSuccessAt) : '아직 없음'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl bg-slate-50 px-4 py-3 ring-1 ring-inset ring-slate-200">
|
||||||
|
<dt class="text-xs font-bold text-slate-500">Cloud 보관</dt>
|
||||||
|
<dd class="mt-1 text-sm font-black text-slate-900">{retentionLabel(cloud)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="mt-3 mb-0 text-xs leading-5 text-slate-500">
|
||||||
|
SmartSafeHub 계정에 연결된 Free는 최근 7일, Pro와 Ultimate는 최근 90일 동안 Cloud 활동 기록을 보관합니다. 끄면 전송 대기 데이터와 임시 인증 정보를 삭제하며, 다시 켠 뒤 새로 발생한 활동부터 Cloud에 전송합니다. 로컬 최근 활동은 항상 유지됩니다.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -204,61 +221,90 @@ export function ActivityPage({
|
|||||||
onSetCloudSync: (enabled: boolean) => void;
|
onSetCloudSync: (enabled: boolean) => void;
|
||||||
savingCloudSync: boolean;
|
savingCloudSync: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const [visibleCount, setVisibleCount] = useState(20);
|
||||||
<div class="min-w-0 space-y-5">
|
const events = data?.events ?? [];
|
||||||
<section
|
const visibleEvents = events.slice(0, visibleCount);
|
||||||
aria-labelledby="activity-history-title"
|
const remainingCount = events.length - visibleEvents.length;
|
||||||
class="min-w-0 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="m-0 text-xs font-extrabold uppercase tracking-[0.16em] text-teal-700">
|
|
||||||
Local history
|
|
||||||
</p>
|
|
||||||
<h2 class="mt-2 mb-0 text-xl font-black tracking-tight text-slate-950" id="activity-history-title">
|
|
||||||
현재 부팅 이후의 최근 활동
|
|
||||||
</h2>
|
|
||||||
<p class="mt-2 mb-0 max-w-3xl text-sm leading-6 text-slate-500">
|
|
||||||
인터넷, SafeShield, 업데이트, 계정 권한과 주요 설정에서 실제 상태가 변경된 시점만 기록합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="rounded-full bg-slate-100 px-3 py-1.5 text-xs font-extrabold text-slate-600 ring-1 ring-inset ring-slate-200">
|
|
||||||
최대 {data?.maxEvents ?? 128}건
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5">
|
return (
|
||||||
|
<div class="min-w-0 space-y-6">
|
||||||
|
<section aria-labelledby="activity-cloud-title" class="min-w-0">
|
||||||
|
<SectionHeading
|
||||||
|
id="activity-cloud-title"
|
||||||
|
eyebrow="Cloud history"
|
||||||
|
title="Cloud 활동 기록"
|
||||||
|
description="활동 기록을 Cloud에 자동으로 전송할지 설정합니다. 공유기 내 최근 활동은 항상 기록됩니다."
|
||||||
|
/>
|
||||||
|
<CloudActivitySyncCard
|
||||||
|
actionError={actionError}
|
||||||
|
actionMessage={actionMessage}
|
||||||
|
cloud={
|
||||||
|
data?.cloud ?? {
|
||||||
|
enabled: false,
|
||||||
|
phase: 'preparing',
|
||||||
|
eligible: null,
|
||||||
|
plan: null,
|
||||||
|
retentionDays: 0,
|
||||||
|
pendingEvents: 0,
|
||||||
|
lastAttemptAt: 0,
|
||||||
|
lastSuccessAt: 0,
|
||||||
|
lastUploadedCount: 0,
|
||||||
|
lastErrorCode: null,
|
||||||
|
nextSyncAt: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDismissFeedback={onDismissActionFeedback}
|
||||||
|
onSetEnabled={onSetCloudSync}
|
||||||
|
saving={savingCloudSync}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="activity-history-title" class="min-w-0">
|
||||||
|
<SectionHeading
|
||||||
|
id="activity-history-title"
|
||||||
|
eyebrow="Local history"
|
||||||
|
title="현재 부팅 이후의 최근 활동"
|
||||||
|
description="인터넷, SafeShield, 업데이트, 계정 권한과 주요 설정에서 실제 상태가 변경된 시점만 기록합니다."
|
||||||
|
action={<span class="rounded-full bg-slate-100 px-3 py-1.5 text-xs font-extrabold text-slate-600 ring-1 ring-inset ring-slate-200">최대 {data?.maxEvents ?? 128}건</span>}
|
||||||
|
/>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
||||||
{loading || (error && !data) ? (
|
{loading || (error && !data) ? (
|
||||||
<ActivityLoadState error={error} loading={loading} onRetry={onRetry} />
|
<ActivityLoadState error={error} loading={loading} onRetry={onRetry} />
|
||||||
) : (
|
) : (
|
||||||
<ActivityTimeline events={data?.events ?? []} />
|
<>
|
||||||
|
<ActivityTimeline events={visibleEvents} />
|
||||||
|
{events.length > 20 ? (
|
||||||
|
<div class="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-4">
|
||||||
|
<p class="m-0 text-xs font-semibold text-slate-500">
|
||||||
|
{formatNumber(visibleEvents.length)} / {formatNumber(events.length)}건 표시 중
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
{visibleCount > 20 ? (
|
||||||
|
<button
|
||||||
|
class="inline-flex min-h-10 items-center justify-center rounded-xl border border-slate-200 bg-white px-4 py-2 text-xs font-extrabold text-slate-700 transition hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-teal-500"
|
||||||
|
onClick={() => setVisibleCount(20)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
최신 20건만 보기
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{remainingCount > 0 ? (
|
||||||
|
<button
|
||||||
|
class="inline-flex min-h-10 items-center justify-center rounded-xl border border-teal-200 bg-teal-50 px-4 py-2 text-xs font-extrabold text-teal-800 transition hover:bg-teal-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-teal-500"
|
||||||
|
onClick={() => setVisibleCount((current) => Math.min(current + 20, events.length))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
이전 활동 더 보기 ({formatNumber(Math.min(20, remainingCount))}건)
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<CloudActivitySyncCard
|
|
||||||
actionError={actionError}
|
|
||||||
actionMessage={actionMessage}
|
|
||||||
cloud={
|
|
||||||
data?.cloud ?? {
|
|
||||||
enabled: false,
|
|
||||||
phase: 'preparing',
|
|
||||||
eligible: null,
|
|
||||||
plan: null,
|
|
||||||
retentionDays: 0,
|
|
||||||
pendingEvents: 0,
|
|
||||||
lastAttemptAt: 0,
|
|
||||||
lastSuccessAt: 0,
|
|
||||||
lastUploadedCount: 0,
|
|
||||||
lastErrorCode: null,
|
|
||||||
nextSyncAt: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onDismissFeedback={onDismissActionFeedback}
|
|
||||||
onSetEnabled={onSetCloudSync}
|
|
||||||
saving={savingCloudSync}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<aside class="rounded-2xl border border-slate-200 bg-slate-50 px-5 py-4">
|
<aside class="rounded-2xl border border-slate-200 bg-slate-50 px-5 py-4">
|
||||||
<p class="m-0 text-sm font-extrabold text-slate-800">로컬 활동 기록 안내</p>
|
<p class="m-0 text-sm font-extrabold text-slate-800">로컬 활동 기록 안내</p>
|
||||||
<p class="mt-1 mb-0 text-xs leading-5 text-slate-500">
|
<p class="mt-1 mb-0 text-xs leading-5 text-slate-500">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SectionHeading } from '../components/SectionHeading';
|
||||||
import type { ComponentChildren, TargetedEvent } from 'preact';
|
import type { ComponentChildren, TargetedEvent } from 'preact';
|
||||||
import { useMemo, useState } from 'preact/hooks';
|
import { useMemo, useState } from 'preact/hooks';
|
||||||
|
|
||||||
@@ -265,6 +266,7 @@ export function ConnectedDevicesPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<SectionHeading eyebrow="Overview" title="연결 기기 현황" description="네트워크에 연결되었거나 최근 확인된 기기를 요약합니다." />
|
||||||
<section aria-label="연결 기기 요약" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<section aria-label="연결 기기 요약" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
description={`${data.totals.offline}대는 최근 DHCP 임대에서 확인`}
|
description={`${data.totals.offline}대는 최근 DHCP 임대에서 확인`}
|
||||||
@@ -292,49 +294,41 @@ export function ConnectedDevicesPage({
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-5 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5 sm:p-5">
|
<section class="mt-7" aria-label="기기 목록 및 필터">
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<SectionHeading eyebrow="Device inventory" title="기기 목록" description="연결된 기기를 검색하고 무선·유선 상태별로 확인합니다." />
|
||||||
<div>
|
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5 sm:p-5">
|
||||||
<p class="m-0 text-xs font-bold uppercase tracking-[0.16em] text-slate-500">
|
<div class="flex min-w-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
Device inventory
|
<div class="ssh-touch-scroll flex min-w-0 gap-2 overflow-x-auto pb-1 lg:flex-1" role="group" aria-label="기기 필터">
|
||||||
</p>
|
{FILTERS.map((item) => (
|
||||||
<h2 class="mt-2 mb-0 text-xl font-extrabold text-slate-950">
|
<button
|
||||||
기기 목록
|
aria-pressed={filter === item.value}
|
||||||
</h2>
|
class={`min-h-10 shrink-0 rounded-xl border px-4 py-2 text-sm font-extrabold transition ${
|
||||||
|
filter === item.value
|
||||||
|
? 'border-teal-700 bg-teal-700 text-white'
|
||||||
|
: 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-950'
|
||||||
|
}`}
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => setFilter(item.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label class="relative block w-full lg:w-72 lg:shrink-0 xl:w-80">
|
||||||
|
<span class="sr-only">기기 검색</span>
|
||||||
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex w-11 items-center justify-center text-slate-400">
|
||||||
|
<SearchIcon class="size-5" />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
class="min-h-11 w-full rounded-xl border-2 border-slate-300 bg-slate-50 py-2.5 pr-4 pl-11 text-sm font-semibold text-slate-950 shadow-inner outline-none transition placeholder:text-slate-400 focus:border-teal-500 focus:bg-white focus:ring-4 focus:ring-teal-100"
|
||||||
|
onInput={(event: TargetedEvent<HTMLInputElement>) => setQuery(event.currentTarget.value)}
|
||||||
|
placeholder="이름, IP, MAC 또는 SSID 검색"
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="relative block w-full lg:max-w-sm">
|
|
||||||
<span class="sr-only">기기 검색</span>
|
|
||||||
<span class="pointer-events-none absolute inset-y-0 left-0 flex w-11 items-center justify-center text-slate-400">
|
|
||||||
<SearchIcon class="size-5" />
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
class="min-h-11 w-full rounded-xl border-2 border-slate-300 bg-slate-50 py-2.5 pr-4 pl-11 text-sm font-semibold text-slate-950 shadow-inner outline-none transition placeholder:text-slate-400 focus:border-teal-500 focus:bg-white focus:ring-4 focus:ring-teal-100"
|
|
||||||
onInput={(event: TargetedEvent<HTMLInputElement>) =>
|
|
||||||
setQuery(event.currentTarget.value)
|
|
||||||
}
|
|
||||||
placeholder="이름, IP, MAC 또는 SSID 검색"
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ssh-touch-scroll mt-4 flex gap-2 overflow-x-auto pb-1" role="group" aria-label="기기 필터">
|
|
||||||
{FILTERS.map((item) => (
|
|
||||||
<button
|
|
||||||
aria-pressed={filter === item.value}
|
|
||||||
class={`min-h-10 shrink-0 rounded-xl border px-4 py-2 text-sm font-extrabold transition ${
|
|
||||||
filter === item.value
|
|
||||||
? 'border-teal-700 bg-teal-700 text-white'
|
|
||||||
: 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-950'
|
|
||||||
}`}
|
|
||||||
key={item.value}
|
|
||||||
onClick={() => setFilter(item.value)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SectionHeading } from '../components/SectionHeading';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
|
||||||
@@ -160,35 +161,6 @@ function OverviewCard({
|
|||||||
return <article class={className}>{content}</article>;
|
return <article class={className}>{content}</article>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeading({
|
|
||||||
action,
|
|
||||||
description,
|
|
||||||
eyebrow,
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
action?: ComponentChildren;
|
|
||||||
description: string;
|
|
||||||
eyebrow: string;
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div class="mb-4 min-w-0">
|
|
||||||
<p class="m-0 text-[0.68rem] font-black uppercase tracking-[0.18em] text-teal-700">
|
|
||||||
{eyebrow}
|
|
||||||
</p>
|
|
||||||
<div class="mt-2 flex min-w-0 items-center justify-between gap-3">
|
|
||||||
<h2 class="m-0 min-w-0 text-xl font-black tracking-tight text-slate-950" id={id}>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
{action ? <div class="shrink-0">{action}</div> : null}
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 mb-0 text-sm leading-6 text-slate-500">{description}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const RELATIVE_TIME_TICK_MS = 60_000;
|
const RELATIVE_TIME_TICK_MS = 60_000;
|
||||||
|
|
||||||
function elapsedSeconds(timestamp: number | null | undefined, nowTimestamp: number): number | null {
|
function elapsedSeconds(timestamp: number | null | undefined, nowTimestamp: number): number | null {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SectionHeading } from '../components/SectionHeading';
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
import { CustomSelect } from '../components/CustomSelect';
|
import { CustomSelect } from '../components/CustomSelect';
|
||||||
@@ -127,6 +128,7 @@ export function IptvPage({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<SectionHeading eyebrow="IPTV" title="IPTV 설정" description="통신사 환경에 맞는 멀티캐스트 중계 기능을 관리합니다." />
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
<span class="grid size-11 shrink-0 place-items-center rounded-xl bg-teal-50 text-teal-700">
|
<span class="grid size-11 shrink-0 place-items-center rounded-xl bg-teal-50 text-teal-700">
|
||||||
@@ -239,12 +241,10 @@ export function IptvPage({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<SectionHeading eyebrow="Runtime" title="IPTV 동작 상태" description="마지막으로 적용한 IPTV 설정과 중계 서비스의 실행 상태를 확인합니다." />
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<p class="m-0 text-xs leading-5 text-slate-500">적용된 IPTV 중계 서비스와 멀티캐스트 전달 상태입니다.</p>
|
||||||
<p class="m-0 text-xs font-black uppercase tracking-[0.14em] text-teal-700">Runtime</p>
|
|
||||||
<h2 class="mt-1 mb-0 text-lg font-black text-slate-950">IPTV 동작 상태</h2>
|
|
||||||
</div>
|
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-black ${
|
class={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-black ${
|
||||||
runtimeHealthy
|
runtimeHealthy
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SectionHeading } from '../components/SectionHeading';
|
||||||
import type { TargetedSubmitEvent } from 'preact';
|
import type { TargetedSubmitEvent } from 'preact';
|
||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
|
||||||
@@ -11,7 +12,6 @@ import type { LanFeedback } from '../hooks/useLan';
|
|||||||
import type { LanSettings, LanSettingsInput } from '../types/lan';
|
import type { LanSettings, LanSettingsInput } from '../types/lan';
|
||||||
import {
|
import {
|
||||||
AlertIcon,
|
AlertIcon,
|
||||||
CableIcon,
|
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
RouterIcon,
|
RouterIcon,
|
||||||
@@ -442,6 +442,7 @@ export function LanPage({
|
|||||||
<FeedbackPanel feedback={feedback} onDismiss={onDismissFeedback} />
|
<FeedbackPanel feedback={feedback} onDismiss={onDismissFeedback} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<SectionHeading eyebrow="Overview" title="내부 네트워크 상태" description="LAN 주소와 DHCP 할당 상태를 확인합니다." />
|
||||||
<NetworkStatus data={data} />
|
<NetworkStatus data={data} />
|
||||||
|
|
||||||
{data.conflict.detected ? (
|
{data.conflict.detected ? (
|
||||||
@@ -473,26 +474,15 @@ export function LanPage({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div class="mt-7">
|
||||||
|
<SectionHeading eyebrow="Local network" title="LAN 및 DHCP 설정" description="상위 공유기와 다른 주소 대역을 사용해야 안정적으로 라우팅할 수 있습니다." />
|
||||||
|
</div>
|
||||||
<form
|
<form
|
||||||
class="mt-5 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6"
|
class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6"
|
||||||
onSubmit={(event) => void submit(event)}
|
onSubmit={(event) => void submit(event)}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
{settingsDirty ? (
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
<div class="mb-5 flex justify-end">
|
||||||
<span class="grid size-11 shrink-0 place-items-center rounded-xl bg-teal-50 text-teal-700">
|
|
||||||
<CableIcon class="size-6" />
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="m-0 text-xs font-extrabold uppercase tracking-[0.16em] text-slate-500">
|
|
||||||
내부 네트워크
|
|
||||||
</p>
|
|
||||||
<h2 class="mt-2 mb-0 text-xl font-black text-slate-950">LAN 및 DHCP 설정</h2>
|
|
||||||
<p class="mt-2 mb-0 text-sm font-semibold leading-6 text-slate-600">
|
|
||||||
상위 공유기와 다른 주소 대역을 사용해야 안정적으로 라우팅할 수 있습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{settingsDirty ? (
|
|
||||||
<span
|
<span
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="inline-flex shrink-0 self-start items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-extrabold text-amber-800"
|
class="inline-flex shrink-0 self-start items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-extrabold text-amber-800"
|
||||||
@@ -501,10 +491,10 @@ export function LanPage({
|
|||||||
<AlertIcon aria-hidden="true" class="size-3.5 shrink-0" />
|
<AlertIcon aria-hidden="true" class="size-3.5 shrink-0" />
|
||||||
저장되지 않음
|
저장되지 않음
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
<div class="mt-6 grid gap-5 lg:grid-cols-2">
|
<div class="grid gap-5 lg:grid-cols-2">
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<span class="text-sm font-extrabold text-slate-800">공유기 IP 주소</span>
|
<span class="text-sm font-extrabold text-slate-800">공유기 IP 주소</span>
|
||||||
<Ipv4OctetInput
|
<Ipv4OctetInput
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SectionHeading } from '../components/SectionHeading';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
@@ -7,7 +8,6 @@ import {
|
|||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
ListIcon,
|
|
||||||
PowerIcon,
|
PowerIcon,
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
} from '../components/Icons';
|
} from '../components/Icons';
|
||||||
@@ -452,30 +452,6 @@ function FreePlanUpgrade() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeading({
|
|
||||||
description,
|
|
||||||
eyebrow,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
description: string;
|
|
||||||
eyebrow: string;
|
|
||||||
title: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p class="m-0 text-[0.68rem] font-black uppercase tracking-[0.18em] text-teal-700">
|
|
||||||
{eyebrow}
|
|
||||||
</p>
|
|
||||||
<h2 class="mt-2 mb-0 text-xl font-black tracking-tight text-slate-950">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<p class="mt-2 mb-0 text-sm leading-6 text-slate-500">{description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailCard({
|
function DetailCard({
|
||||||
children,
|
children,
|
||||||
eyebrow,
|
eyebrow,
|
||||||
@@ -488,15 +464,15 @@ function DetailCard({
|
|||||||
title: string;
|
title: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<article class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
<article class="min-w-0 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6 xl:p-4 2xl:p-5">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex min-w-0 items-start justify-between gap-4 xl:gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p class="m-0 text-[0.68rem] font-black uppercase tracking-[0.16em] text-slate-400">
|
<p class="m-0 text-[0.68rem] font-black uppercase tracking-[0.16em] text-slate-400">
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</p>
|
</p>
|
||||||
<h3 class="mt-2 mb-0 text-lg font-black tracking-tight text-slate-950">{title}</h3>
|
<h3 class="mt-2 mb-0 text-lg font-black tracking-tight text-slate-950 xl:text-base 2xl:text-lg">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<span class="grid size-10 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-500">
|
<span class="grid size-10 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-500 xl:size-9 2xl:size-10">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -507,9 +483,9 @@ function DetailCard({
|
|||||||
|
|
||||||
function DetailRow({ label, value }: { label: string; value: ComponentChildren }) {
|
function DetailRow({ label, value }: { label: string; value: ComponentChildren }) {
|
||||||
return (
|
return (
|
||||||
<div class="flex min-w-0 items-center justify-between gap-4 border-b border-slate-100 pb-3 last:border-b-0 last:pb-0">
|
<div class="flex min-w-0 items-center justify-between gap-2 border-b border-slate-100 pb-3 last:border-b-0 last:pb-0">
|
||||||
<dt class="text-sm font-semibold text-slate-500">{label}</dt>
|
<dt class="text-sm font-semibold text-slate-500">{label}</dt>
|
||||||
<dd class="m-0 min-w-0 text-right text-sm font-extrabold text-slate-950">{value}</dd>
|
<dd class="m-0 min-w-0 break-words text-right text-sm font-extrabold text-slate-950">{value}</dd>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -658,77 +634,133 @@ export function SafeShieldPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm shadow-slate-900/5">
|
<SectionHeading
|
||||||
<div class="flex flex-col gap-5 p-5 sm:p-6 lg:flex-row lg:items-start lg:justify-between">
|
eyebrow="Protection"
|
||||||
<div class="flex min-w-0 items-start gap-3 sm:gap-4">
|
title="SafeShield 보호 상태"
|
||||||
<span class="grid size-12 shrink-0 place-items-center rounded-2xl bg-teal-50 text-teal-700">
|
description="DNS 보호 상태와 적용 중인 차단 목록을 확인합니다."
|
||||||
<ShieldIcon class="size-7" />
|
/>
|
||||||
</span>
|
<section class="mb-8 grid min-w-0 gap-4 xl:grid-cols-[minmax(0,1.08fr)_minmax(0,0.92fr)] xl:items-stretch">
|
||||||
<div class="min-w-0">
|
<article aria-label="보호 상태 요약" class="min-w-0 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6 xl:h-full">
|
||||||
<p class="m-0 text-[0.68rem] font-black uppercase tracking-[0.18em] text-teal-700">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between xl:flex-col 2xl:flex-row">
|
||||||
Protection
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
</p>
|
<span class="grid size-10 shrink-0 place-items-center rounded-xl bg-teal-50 text-teal-700">
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
<ShieldIcon class="size-5" />
|
||||||
<h2 class="m-0 break-words text-xl font-black tracking-tight text-slate-950 sm:text-2xl">
|
</span>
|
||||||
SafeShield 보호 상태
|
<div class="min-w-0">
|
||||||
</h2>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<SummaryBadge data={data} />
|
<h3 class="m-0 text-base font-black text-slate-950">현재 보호 상태</h3>
|
||||||
|
<SummaryBadge data={data} />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 mb-0 text-sm leading-6 text-slate-600">{getSummaryMessage(data)}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 mb-0 max-w-3xl text-sm leading-6 text-slate-600">
|
</div>
|
||||||
{getSummaryMessage(data)}
|
<div class="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap xl:justify-end">
|
||||||
</p>
|
<button
|
||||||
<RefreshProgress data={data} />
|
class={`inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-extrabold transition focus:outline-none focus-visible:ring-4 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
|
data.enabled
|
||||||
|
? 'border-red-200 bg-white text-red-700 hover:border-red-300 hover:bg-red-50 focus-visible:ring-red-100'
|
||||||
|
: 'border-teal-700 bg-teal-700 text-white hover:border-teal-800 hover:bg-teal-800 focus-visible:ring-teal-100'
|
||||||
|
}`}
|
||||||
|
disabled={actionBusy || refreshing}
|
||||||
|
onClick={handleToggle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<PowerIcon
|
||||||
|
class={`size-4 ${action === 'enable' || action === 'disable' ? 'animate-pulse' : ''}`}
|
||||||
|
/>
|
||||||
|
{toggleLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-extrabold text-slate-700 transition hover:border-teal-300 hover:bg-teal-50 hover:text-teal-800 focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={!data.enabled || actionBusy || refreshing}
|
||||||
|
onClick={onRefreshBlocklist}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<DownloadIcon
|
||||||
|
class={`size-4 ${action === 'refresh' || refreshing ? 'animate-bounce' : ''}`}
|
||||||
|
/>
|
||||||
|
{action === 'refresh'
|
||||||
|
? '시작 중…'
|
||||||
|
: refreshing
|
||||||
|
? '갱신 중…'
|
||||||
|
: data.status === 'error'
|
||||||
|
? '다시 갱신'
|
||||||
|
: '지금 갱신'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<RefreshProgress data={data} />
|
||||||
<div class="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
<dl class="mt-5 mb-0 grid min-w-0 grid-cols-2 overflow-hidden rounded-xl border border-slate-200 bg-slate-200">
|
||||||
<button
|
|
||||||
class={`inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-extrabold transition focus:outline-none focus-visible:ring-4 disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
||||||
data.enabled
|
|
||||||
? 'border-red-200 bg-white text-red-700 hover:border-red-300 hover:bg-red-50 focus-visible:ring-red-100'
|
|
||||||
: 'border-teal-700 bg-teal-700 text-white hover:border-teal-800 hover:bg-teal-800 focus-visible:ring-teal-100'
|
|
||||||
}`}
|
|
||||||
disabled={actionBusy || refreshing}
|
|
||||||
onClick={handleToggle}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<PowerIcon
|
|
||||||
class={`size-4 ${action === 'enable' || action === 'disable' ? 'animate-pulse' : ''}`}
|
|
||||||
/>
|
|
||||||
{toggleLabel}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-extrabold text-slate-700 transition hover:border-teal-300 hover:bg-teal-50 hover:text-teal-800 focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-100 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
disabled={!data.enabled || actionBusy || refreshing}
|
|
||||||
onClick={onRefreshBlocklist}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<DownloadIcon
|
|
||||||
class={`size-4 ${action === 'refresh' || refreshing ? 'animate-bounce' : ''}`}
|
|
||||||
/>
|
|
||||||
{action === 'refresh'
|
|
||||||
? '시작 중…'
|
|
||||||
: refreshing
|
|
||||||
? '갱신 중…'
|
|
||||||
: data.status === 'error'
|
|
||||||
? '다시 갱신'
|
|
||||||
: '지금 갱신'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-5 pb-5 sm:px-6 sm:pb-6">
|
|
||||||
<dl class="grid overflow-hidden rounded-xl border border-slate-200 bg-slate-200 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<SummaryFact label="SafeShield" value={data.version ?? '확인되지 않음'} />
|
<SummaryFact label="SafeShield" value={data.version ?? '확인되지 않음'} />
|
||||||
<SummaryFact
|
<SummaryFact label="Blocklist" value={`${formatNumber(displayedBlocklistCount)}개 도메인`} />
|
||||||
label="Blocklist"
|
|
||||||
value={`${formatNumber(displayedBlocklistCount)}개 도메인`}
|
|
||||||
/>
|
|
||||||
<SummaryFact label="Last refresh" value={<RelativeTimestamp timestamp={data.timestamps.lastSuccess} />} />
|
<SummaryFact label="Last refresh" value={<RelativeTimestamp timestamp={data.timestamps.lastSuccess} />} />
|
||||||
<PlanSummary plan={planName} />
|
<PlanSummary plan={planName} />
|
||||||
</dl>
|
</dl>
|
||||||
{planName === 'FREE' ? <FreePlanUpgrade /> : null}
|
{planName === 'FREE' ? <FreePlanUpgrade /> : null}
|
||||||
</div>
|
</article>
|
||||||
|
<section
|
||||||
|
aria-label="보호 데이터 요약"
|
||||||
|
class="min-w-0 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6 xl:h-full"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<span class="grid size-10 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-500">
|
||||||
|
<DatabaseIcon class="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="m-0 text-[0.65rem] font-black uppercase tracking-[0.16em] text-slate-400">
|
||||||
|
Protection data
|
||||||
|
</p>
|
||||||
|
<h3 class="mt-1 mb-0 text-sm font-black text-slate-950">보호 데이터</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
aria-label={`보호 데이터 상태: ${
|
||||||
|
data.artifact.resolved && data.blocklist.installed && data.blocklist.verificationOk
|
||||||
|
? '최신'
|
||||||
|
: '확인 필요'
|
||||||
|
}`}
|
||||||
|
class={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-extrabold ${
|
||||||
|
data.artifact.resolved && data.blocklist.installed && data.blocklist.verificationOk
|
||||||
|
? 'bg-emerald-50 text-emerald-700'
|
||||||
|
: 'bg-amber-50 text-amber-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{data.artifact.resolved && data.blocklist.installed && data.blocklist.verificationOk ? (
|
||||||
|
<CheckCircleIcon class="size-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertIcon class="size-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
{data.artifact.resolved && data.blocklist.installed && data.blocklist.verificationOk
|
||||||
|
? '최신'
|
||||||
|
: '확인 필요'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 mb-0 text-xs leading-5 text-slate-500">
|
||||||
|
SafeShield가 기기 성능에 맞춰 사용하는 보호 목록의 구성과 최신 상태를 확인합니다.
|
||||||
|
</p>
|
||||||
|
<dl class="mt-3 mb-0 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<div class="min-w-0 rounded-lg border border-slate-200 bg-white px-3 py-2.5">
|
||||||
|
<dt class="text-xs font-bold text-slate-500">기기 최적화</dt>
|
||||||
|
<dd class="mt-1 mb-0 text-sm font-extrabold text-slate-950">
|
||||||
|
{getArtifactOptimizationLabel(data.artifact.tier)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 rounded-lg border border-slate-200 bg-white px-3 py-2.5">
|
||||||
|
<dt class="text-xs font-bold text-slate-500">마지막 업데이트</dt>
|
||||||
|
<dd class="mt-1 mb-0 text-sm font-extrabold leading-5 text-slate-950">
|
||||||
|
{formatArtifactUpdatedAt(data.artifact.version)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<section aria-label="보호 데이터 세부 정보" class="mt-3 border-t border-slate-200 pt-3">
|
||||||
|
<h4 class="m-0 text-xs font-extrabold text-slate-600">세부 정보</h4>
|
||||||
|
<dl class="mt-3 mb-0 grid gap-2">
|
||||||
|
<DetailRow label="데이터 프로필" value={data.artifact.tier || '확인되지 않음'} />
|
||||||
|
<DetailRow label="데이터 버전" value={data.artifact.version || '확인되지 않음'} />
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ActionFeedback
|
<ActionFeedback
|
||||||
@@ -754,7 +786,7 @@ export function SafeShieldPage({
|
|||||||
title="보호 구성"
|
title="보호 구성"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<DetailCard
|
<DetailCard
|
||||||
eyebrow="Runtime"
|
eyebrow="Runtime"
|
||||||
icon={<CheckCircleIcon class="size-5" />}
|
icon={<CheckCircleIcon class="size-5" />}
|
||||||
@@ -824,114 +856,7 @@ export function SafeShieldPage({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-7">
|
|
||||||
<SectionHeading
|
|
||||||
description="현재 적용 중인 보호 데이터와 사용자 규칙을 관리합니다."
|
|
||||||
eyebrow="Settings"
|
|
||||||
title="SafeShield 설정"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
|
||||||
<article class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="m-0 text-[0.68rem] font-black uppercase tracking-[0.16em] text-slate-400">
|
|
||||||
Protection data
|
|
||||||
</p>
|
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
|
||||||
<h3 class="m-0 text-lg font-black tracking-tight text-slate-950">
|
|
||||||
보호 데이터
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
aria-label={`보호 데이터 상태: ${
|
|
||||||
data.artifact.resolved && data.blocklist.installed && data.blocklist.verificationOk
|
|
||||||
? '최신'
|
|
||||||
: '확인 필요'
|
|
||||||
}`}
|
|
||||||
class={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-extrabold ${
|
|
||||||
data.artifact.resolved && data.blocklist.installed && data.blocklist.verificationOk
|
|
||||||
? 'bg-emerald-50 text-emerald-700'
|
|
||||||
: 'bg-amber-50 text-amber-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{data.artifact.resolved && data.blocklist.installed && data.blocklist.verificationOk ? (
|
|
||||||
<CheckCircleIcon class="size-3.5 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<AlertIcon class="size-3.5 shrink-0" />
|
|
||||||
)}
|
|
||||||
{data.artifact.resolved && data.blocklist.installed && data.blocklist.verificationOk
|
|
||||||
? '최신'
|
|
||||||
: '확인 필요'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 mb-0 text-sm leading-6 text-slate-500">
|
|
||||||
SafeShield가 기기 성능에 맞춰 사용하는 보호 목록의 구성과 최신 상태를 확인합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="grid size-10 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-500">
|
|
||||||
<DatabaseIcon class="size-5" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl class="mt-5 mb-0 grid grid-cols-2 gap-3">
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
|
|
||||||
<dt class="text-xs font-bold text-slate-500">기기 최적화</dt>
|
|
||||||
<dd class="mt-1 mb-0 text-sm font-extrabold text-slate-950">
|
|
||||||
{getArtifactOptimizationLabel(data.artifact.tier)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-3">
|
|
||||||
<dt class="text-xs font-bold text-slate-500">마지막 업데이트</dt>
|
|
||||||
<dd class="mt-1 mb-0 text-sm font-extrabold leading-5 text-slate-950">
|
|
||||||
{formatArtifactUpdatedAt(data.artifact.version)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<details class="mt-4 rounded-xl border border-slate-200 bg-slate-50/70 px-4 py-3">
|
|
||||||
<summary class="cursor-pointer text-sm font-extrabold text-slate-700">
|
|
||||||
세부 정보
|
|
||||||
</summary>
|
|
||||||
<dl class="mt-3 mb-0 grid gap-3 border-t border-slate-200 pt-3">
|
|
||||||
<DetailRow
|
|
||||||
label="데이터 프로필"
|
|
||||||
value={data.artifact.tier || '확인되지 않음'}
|
|
||||||
/>
|
|
||||||
<DetailRow
|
|
||||||
label="데이터 버전"
|
|
||||||
value={data.artifact.version || '확인되지 않음'}
|
|
||||||
/>
|
|
||||||
</dl>
|
|
||||||
</details>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5">
|
|
||||||
<p class="m-0 text-[0.68rem] font-black uppercase tracking-[0.16em] text-slate-400">
|
|
||||||
Custom rules
|
|
||||||
</p>
|
|
||||||
<h3 class="mt-2 mb-0 text-lg font-black tracking-tight text-slate-950">
|
|
||||||
사용자 규칙
|
|
||||||
</h3>
|
|
||||||
<div class="mt-4">
|
|
||||||
<BooleanState
|
|
||||||
falseLabel="비활성화"
|
|
||||||
trueLabel="활성화"
|
|
||||||
value={data.localOverrides.enabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-4 mb-0 text-sm font-semibold leading-6 text-slate-500">
|
|
||||||
허용하거나 차단할 도메인을 직접 관리해 SafeShield 보호 정책에 반영합니다.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
class="mt-5 inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border border-teal-700 bg-teal-700 px-4 py-2 text-sm font-extrabold text-white no-underline shadow-sm transition hover:border-teal-800 hover:bg-teal-800 focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-100"
|
|
||||||
href="#rules"
|
|
||||||
>
|
|
||||||
<ListIcon class="size-4" />
|
|
||||||
사용자 규칙 관리
|
|
||||||
</a>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { SectionHeading } from '../components/SectionHeading';
|
||||||
import type { TargetedEvent, TargetedSubmitEvent } from 'preact';
|
import type { TargetedEvent, TargetedSubmitEvent } from 'preact';
|
||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import { RelativeTimestamp } from '../components/RelativeTimestamp';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertIcon,
|
AlertIcon,
|
||||||
@@ -12,7 +15,7 @@ import {
|
|||||||
} from '../components/Icons';
|
} from '../components/Icons';
|
||||||
import { ErrorPanel, LoadingPanel } from '../components/StatePanels';
|
import { ErrorPanel, LoadingPanel } from '../components/StatePanels';
|
||||||
import type { SafeShieldRulesAction } from '../hooks/useSafeShieldRules';
|
import type { SafeShieldRulesAction } from '../hooks/useSafeShieldRules';
|
||||||
import type { SafeShieldRuleAction, SafeShieldRules } from '../types/rules';
|
import type { RecommendedRuleAssignment, RecommendedRulesStatus, SafeShieldRuleAction, SafeShieldRules } from '../types/rules';
|
||||||
|
|
||||||
interface SafeShieldRulesPageProps {
|
interface SafeShieldRulesPageProps {
|
||||||
action: SafeShieldRulesAction | null;
|
action: SafeShieldRulesAction | null;
|
||||||
@@ -32,7 +35,9 @@ interface SafeShieldRulesPageProps {
|
|||||||
onDismissFeedback: () => void;
|
onDismissFeedback: () => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
// Injected only by the authenticated router app; the public demo has no RPC.
|
// Injected only by the authenticated router app; the public demo has no RPC.
|
||||||
onLoadRecommendedRulesStatus?: () => Promise<{ domains: string[] }>;
|
onLoadRecommendedRulesStatus?: () => Promise<RecommendedRulesStatus>;
|
||||||
|
onSyncRecommendedRules?: () => Promise<RecommendedRulesStatus>;
|
||||||
|
onRevokeRecommendedRule?: (ruleId: number) => Promise<RecommendedRulesStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuleListCardProps {
|
interface RuleListCardProps {
|
||||||
@@ -50,6 +55,9 @@ interface RuleListCardProps {
|
|||||||
rules: string[];
|
rules: string[];
|
||||||
recommendedDomains: string[];
|
recommendedDomains: string[];
|
||||||
recommendedKnown: boolean;
|
recommendedKnown: boolean;
|
||||||
|
recommendedRules: RecommendedRuleAssignment[];
|
||||||
|
onRequestRevoke?: (rule: RecommendedRuleAssignment) => void;
|
||||||
|
revokeBusy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDomain(value: string): string {
|
function normalizeDomain(value: string): string {
|
||||||
@@ -81,36 +89,48 @@ function RuleListCard({
|
|||||||
rules,
|
rules,
|
||||||
recommendedDomains,
|
recommendedDomains,
|
||||||
recommendedKnown,
|
recommendedKnown,
|
||||||
|
recommendedRules,
|
||||||
|
onRequestRevoke,
|
||||||
|
revokeBusy,
|
||||||
}: RuleListCardProps) {
|
}: RuleListCardProps) {
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
const [manualExpanded, setManualExpanded] = useState(true);
|
||||||
|
// Small groups start expanded; larger lists start collapsed. Once the user
|
||||||
|
// changes it, keep their choice even when the next sync updates the list.
|
||||||
|
const [recommendedExpanded, setRecommendedExpanded] = useState<boolean | null>(null);
|
||||||
const isAllow = kind === 'allow';
|
const isAllow = kind === 'allow';
|
||||||
const busy = action !== null;
|
const busy = action !== null;
|
||||||
const filteredRules = useMemo(() => {
|
const filteredRules = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase();
|
const query = search.trim().toLowerCase();
|
||||||
return query.length > 0
|
return query ? rules.filter((domain) => domain.includes(query)) : rules;
|
||||||
? rules.filter((domain) => domain.includes(query))
|
|
||||||
: rules;
|
|
||||||
}, [rules, search]);
|
}, [rules, search]);
|
||||||
|
const searching = search.trim().length > 0;
|
||||||
|
// Reconciler-owned domains remain separate from user-managed manual entries.
|
||||||
|
const managedRules = isAllow && recommendedKnown
|
||||||
|
? filteredRules.filter((domain) => recommendedDomains.includes(domain))
|
||||||
|
: [];
|
||||||
|
const manualRules = isAllow && recommendedKnown
|
||||||
|
? filteredRules.filter((domain) => !recommendedDomains.includes(domain))
|
||||||
|
: filteredRules;
|
||||||
|
const visibleRecommendedRules = isAllow ? recommendedRules.filter((rule) =>
|
||||||
|
!searching || rule.title.toLowerCase().includes(search.trim().toLowerCase()) ||
|
||||||
|
rule.domains.some((domain) => domain.includes(search.trim().toLowerCase()))
|
||||||
|
) : [];
|
||||||
|
const showRecommended = searching || (recommendedExpanded ?? (recommendedRules.length || managedRules.length) <= 3);
|
||||||
|
const showManual = searching || manualExpanded;
|
||||||
|
|
||||||
async function handleSubmit(
|
async function handleSubmit(event: TargetedSubmitEvent<HTMLFormElement>): Promise<void> {
|
||||||
event: TargetedSubmitEvent<HTMLFormElement>,
|
|
||||||
): Promise<void> {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const domain = normalizeDomain(input);
|
const domain = normalizeDomain(input);
|
||||||
|
|
||||||
if (!isValidDomain(domain)) {
|
if (!isValidDomain(domain)) {
|
||||||
setValidationError(
|
setValidationError('example.com 형식의 도메인을 입력해 주세요. URL, IP 주소, 와일드카드는 사용할 수 없습니다.');
|
||||||
'example.com 형식의 도메인을 입력해 주세요. URL, IP 주소, 와일드카드는 사용할 수 없습니다.',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
await onAddRule(kind, domain, () => {
|
await onAddRule(kind, domain, () => {
|
||||||
// Clear as soon as the server has saved the rule, not after DNS apply.
|
// Clear when persisted, not after the slower DNS refresh finishes.
|
||||||
// Do not erase a newer draft if the input was edited in the meantime.
|
|
||||||
setInput((current) => normalizeDomain(current) === domain ? '' : current);
|
setInput((current) => normalizeDomain(current) === domain ? '' : current);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -118,56 +138,71 @@ function RuleListCard({
|
|||||||
const theme = isAllow
|
const theme = isAllow
|
||||||
? {
|
? {
|
||||||
badge: 'bg-emerald-100 text-emerald-800',
|
badge: 'bg-emerald-100 text-emerald-800',
|
||||||
button:
|
button: 'border-emerald-700 bg-emerald-700 text-white hover:border-emerald-800 hover:bg-emerald-800 focus-visible:ring-emerald-100',
|
||||||
'border-emerald-700 bg-emerald-700 text-white hover:border-emerald-800 hover:bg-emerald-800 focus-visible:ring-emerald-100',
|
|
||||||
icon: 'bg-emerald-50 text-emerald-700',
|
icon: 'bg-emerald-50 text-emerald-700',
|
||||||
label: '허용 목록',
|
label: '허용 목록',
|
||||||
description:
|
description: '직접 허용하거나 웹사이트 추천으로 추가된 도메인을 관리합니다.',
|
||||||
'차단 목록에 포함되어도 허용할 도메인과 하위 도메인을 등록합니다.',
|
|
||||||
empty: '등록된 허용 도메인이 없습니다.',
|
empty: '등록된 허용 도메인이 없습니다.',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
badge: 'bg-red-100 text-red-800',
|
badge: 'bg-red-100 text-red-800',
|
||||||
button:
|
button: 'border-red-700 bg-red-700 text-white hover:border-red-800 hover:bg-red-800 focus-visible:ring-red-100',
|
||||||
'border-red-700 bg-red-700 text-white hover:border-red-800 hover:bg-red-800 focus-visible:ring-red-100',
|
|
||||||
icon: 'bg-red-50 text-red-700',
|
icon: 'bg-red-50 text-red-700',
|
||||||
label: '차단 목록',
|
label: '차단 목록',
|
||||||
description: 'SafeShield 기본 목록에 없더라도 직접 차단할 도메인을 등록합니다.',
|
description: 'SafeShield 기본 목록에 없더라도 직접 차단할 도메인을 등록합니다.',
|
||||||
empty: '등록된 차단 도메인이 없습니다.',
|
empty: '등록된 차단 도메인이 없습니다.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function renderRule(domain: string) {
|
||||||
|
// A manual entry that happens to match a cloud recommendation must
|
||||||
|
// remain deletable. Only entries actually owned by the reconciler lock.
|
||||||
|
const cloudManaged = isAllow && recommendedKnown && recommendedDomains.includes(domain);
|
||||||
|
const ruleOrigin = !isAllow || recommendedKnown
|
||||||
|
? (cloudManaged ? '추천 규칙' : '직접 추가')
|
||||||
|
: null;
|
||||||
|
const deleting = action?.operation === 'delete' && action.action === kind && action.domain === domain;
|
||||||
|
return (
|
||||||
|
<li class="flex min-w-0 items-center justify-between gap-3 px-3.5 py-3" key={domain}>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
|
<code class="min-w-0 break-all font-mono text-sm font-semibold text-slate-800">{domain}</code>
|
||||||
|
</div>
|
||||||
|
{cloudManaged ? (
|
||||||
|
<span class="shrink-0 rounded-full bg-teal-50 px-2.5 py-1 text-[11px] font-bold text-teal-800" title="추천 규칙의 적용과 해제는 웹사이트에서 관리합니다.">
|
||||||
|
웹사이트 관리
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
aria-label={`${domain} ${theme.label}에서 삭제`}
|
||||||
|
class="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 transition hover:border-red-200 hover:bg-red-50 hover:text-red-700 focus:outline-none focus-visible:ring-4 focus-visible:ring-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={busy || cloudManaged}
|
||||||
|
onClick={() => void onDeleteRule(kind, domain)}
|
||||||
|
title={ruleOrigin === '직접 추가' ? '직접 추가한 규칙 삭제' : '삭제'}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<TrashIcon class={`size-4 ${deleting ? 'animate-pulse' : ''}`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article class="min-w-0 rounded-2xl border border-slate-200 bg-white shadow-sm shadow-slate-900/5">
|
<article class="min-w-0 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm shadow-slate-900/5">
|
||||||
<div class="border-b border-slate-200 p-4 sm:p-6">
|
<div class="border-b border-slate-200 p-4 sm:p-6">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
<span class={`grid size-11 shrink-0 place-items-center rounded-xl ${theme.icon}`}>
|
||||||
<span class={`grid size-11 shrink-0 place-items-center rounded-xl ${theme.icon}`}>
|
{isAllow ? <CheckCircleIcon class="size-6" /> : <ShieldIcon class="size-6" />}
|
||||||
{isAllow ? (
|
</span>
|
||||||
<CheckCircleIcon class="size-6" />
|
<div class="min-w-0">
|
||||||
) : (
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<ShieldIcon class="size-6" />
|
<h2 class="m-0 text-xl font-extrabold tracking-tight text-slate-950">{theme.label}</h2>
|
||||||
)}
|
<span class={`rounded-full px-2.5 py-1 text-xs font-extrabold ${theme.badge}`}>{rules.length}</span>
|
||||||
</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<h2 class="m-0 text-xl font-extrabold tracking-tight text-slate-950">
|
|
||||||
{theme.label}
|
|
||||||
</h2>
|
|
||||||
<span class={`rounded-full px-2.5 py-1 text-xs font-extrabold ${theme.badge}`}>
|
|
||||||
{rules.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 mb-0 text-sm leading-6 text-slate-600">
|
|
||||||
{theme.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 mb-0 text-sm leading-6 text-slate-600">{theme.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="mt-5" onSubmit={handleSubmit}>
|
<form class="mt-5" onSubmit={handleSubmit}>
|
||||||
<label class="block text-xs font-extrabold text-slate-600" for={`${kind}-domain`}>
|
<label class="block text-xs font-extrabold text-slate-600" for={`${kind}-domain`}>새 도메인</label>
|
||||||
새 도메인
|
|
||||||
</label>
|
|
||||||
<div class="mt-2 flex flex-col gap-2 sm:flex-row">
|
<div class="mt-2 flex flex-col gap-2 sm:flex-row">
|
||||||
<input
|
<input
|
||||||
aria-describedby={validationError ? `${kind}-domain-error` : undefined}
|
aria-describedby={validationError ? `${kind}-domain-error` : undefined}
|
||||||
@@ -176,109 +211,111 @@ function RuleListCard({
|
|||||||
class="min-h-11 min-w-0 flex-1 rounded-xl border-2 border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-inner outline-none transition placeholder:text-slate-400 focus:border-teal-500 focus:bg-white focus:ring-4 focus:ring-teal-100 disabled:cursor-not-allowed disabled:bg-slate-100"
|
class="min-h-11 min-w-0 flex-1 rounded-xl border-2 border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-950 shadow-inner outline-none transition placeholder:text-slate-400 focus:border-teal-500 focus:bg-white focus:ring-4 focus:ring-teal-100 disabled:cursor-not-allowed disabled:bg-slate-100"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
id={`${kind}-domain`}
|
id={`${kind}-domain`}
|
||||||
onInput={(event: TargetedEvent<HTMLInputElement, InputEvent>) =>
|
onInput={(event: TargetedEvent<HTMLInputElement, InputEvent>) => setInput(event.currentTarget.value)}
|
||||||
setInput(event.currentTarget.value)
|
|
||||||
}
|
|
||||||
placeholder="example.com"
|
placeholder="example.com"
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class={`inline-flex min-h-11 w-full shrink-0 items-center justify-center gap-2 sm:w-auto rounded-xl border px-4 py-2.5 text-sm font-extrabold transition focus:outline-none focus-visible:ring-4 disabled:cursor-not-allowed disabled:opacity-50 ${theme.button}`}
|
class={`inline-flex min-h-11 w-full shrink-0 items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-extrabold transition focus:outline-none focus-visible:ring-4 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto ${theme.button}`}
|
||||||
disabled={busy || input.trim().length === 0}
|
disabled={busy || input.trim().length === 0}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
<PlusIcon class="size-4" />
|
<PlusIcon class="size-4" />
|
||||||
{action?.operation === 'add' && action.action === kind
|
{action?.operation === 'add' && action.action === kind ? '추가 중…' : '추가'}
|
||||||
? '추가 중…'
|
|
||||||
: '추가'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{validationError ? (
|
{validationError ? <p class="mt-2 mb-0 text-xs font-semibold leading-5 text-red-700" id={`${kind}-domain-error`}>{validationError}</p> : null}
|
||||||
<p class="mt-2 mb-0 text-xs font-semibold leading-5 text-red-700" id={`${kind}-domain-error`}>
|
|
||||||
{validationError}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 sm:p-6">
|
<div class="p-4 sm:p-6">
|
||||||
<label class="relative block" for={`${kind}-search`}>
|
<label class="relative block" for={`${kind}-search`}>
|
||||||
<span class="sr-only">{theme.label} 검색</span>
|
<span class="sr-only">{theme.label} 검색</span>
|
||||||
<span class="pointer-events-none absolute inset-y-0 left-0 flex w-11 items-center justify-center text-slate-400">
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex w-11 items-center justify-center text-slate-400"><SearchIcon class="size-4" /></span>
|
||||||
<SearchIcon class="size-4" />
|
|
||||||
</span>
|
|
||||||
<input
|
<input
|
||||||
class="min-h-11 w-full rounded-xl border-2 border-slate-300 bg-slate-50 py-2.5 pr-4 pl-11 text-sm font-semibold text-slate-950 shadow-inner outline-none transition placeholder:text-slate-400 focus:border-teal-500 focus:bg-white focus:ring-4 focus:ring-teal-100"
|
class="min-h-11 w-full rounded-xl border-2 border-slate-300 bg-slate-50 py-2.5 pr-4 pl-11 text-sm font-semibold text-slate-950 shadow-inner outline-none transition placeholder:text-slate-400 focus:border-teal-500 focus:bg-white focus:ring-4 focus:ring-teal-100"
|
||||||
id={`${kind}-search`}
|
id={`${kind}-search`}
|
||||||
onInput={(event: TargetedEvent<HTMLInputElement, InputEvent>) =>
|
onInput={(event: TargetedEvent<HTMLInputElement, InputEvent>) => setSearch(event.currentTarget.value)}
|
||||||
setSearch(event.currentTarget.value)
|
|
||||||
}
|
|
||||||
placeholder={`${theme.label} 검색`}
|
placeholder={`${theme.label} 검색`}
|
||||||
type="search"
|
type="search"
|
||||||
value={search}
|
value={search}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="mt-4 max-h-[28rem] overflow-y-auto rounded-xl border border-slate-200">
|
{isAllow && recommendedKnown ? (
|
||||||
{filteredRules.length > 0 ? (
|
<div class="mt-4 overflow-hidden rounded-xl border border-slate-200">
|
||||||
<ul class="m-0 list-none divide-y divide-slate-200 p-0">
|
<section aria-label="직접 추가한 허용 규칙">
|
||||||
{filteredRules.map((domain) => {
|
<button
|
||||||
// Only domains created by the local recommender are managed.
|
aria-controls="allow-manual-domains"
|
||||||
// A previously manual allow entry stays manual even when the
|
aria-expanded={showManual}
|
||||||
// same domain appears in an assigned recommendation.
|
class="flex min-h-14 w-full items-center justify-between gap-3 ssh-rule-group-toggle bg-slate-50 px-4 py-3 text-left focus-visible:outline-2 focus-visible:outline-teal-500"
|
||||||
const cloudManaged = isAllow && recommendedDomains.includes(domain);
|
onClick={() => setManualExpanded(!showManual)}
|
||||||
const ruleOrigin = !isAllow || recommendedKnown
|
type="button"
|
||||||
? (cloudManaged ? '추천 규칙' : '직접 추가')
|
>
|
||||||
: null;
|
<span class="min-w-0 text-sm font-extrabold text-slate-800">직접 추가한 규칙 <span class="ml-1 rounded-full bg-white px-2 py-0.5 text-xs font-bold text-slate-600">{manualRules.length}{searching ? '개 검색됨' : '개'}</span></span>
|
||||||
const deleting =
|
<span aria-hidden="true" class="shrink-0 text-sm font-bold text-slate-500">{showManual ? '접기 −' : '펼치기 +'}</span>
|
||||||
action?.operation === 'delete' &&
|
</button>
|
||||||
action.action === kind &&
|
<div hidden={!showManual} id="allow-manual-domains">
|
||||||
action.domain === domain;
|
{manualRules.length ? (
|
||||||
|
<ul class="m-0 max-h-[20rem] list-none divide-y divide-slate-200 overflow-y-auto p-0">{manualRules.map(renderRule)}</ul>
|
||||||
return (
|
) : (
|
||||||
<li class="flex min-w-0 items-center justify-between gap-3 px-3.5 py-3" key={domain}>
|
<p class="m-0 px-4 py-5 text-sm text-slate-500">{searching ? '검색 결과가 없습니다.' : '직접 추가한 허용 도메인이 없습니다.'}</p>
|
||||||
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1.5">
|
)}
|
||||||
<code class="min-w-0 break-all font-mono text-sm font-semibold text-slate-800">
|
</div>
|
||||||
{domain}
|
</section>
|
||||||
</code>
|
<section aria-label="웹사이트 추천 규칙" class="border-t border-slate-200">
|
||||||
{ruleOrigin ? (
|
<button
|
||||||
<span
|
aria-controls="allow-recommended-domains"
|
||||||
class={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-bold ${cloudManaged ? 'bg-teal-50 text-teal-800' : 'bg-slate-100 text-slate-600'}`}
|
aria-expanded={showRecommended}
|
||||||
title={cloudManaged
|
class="flex min-h-14 w-full items-center justify-between gap-3 ssh-rule-group-toggle bg-slate-50 px-4 py-3 text-left focus-visible:outline-2 focus-visible:outline-teal-500"
|
||||||
? '추천 규칙 동기화로 추가된 도메인입니다. 웹사이트에서 적용을 해제해 주세요.'
|
onClick={() => setRecommendedExpanded(!showRecommended)}
|
||||||
: '공유기에 직접 등록되어 유지되는 규칙입니다.'}
|
type="button"
|
||||||
>
|
>
|
||||||
{ruleOrigin}
|
<span class="min-w-0 text-sm font-extrabold text-slate-800">추천 규칙 <span class="ml-1 rounded-full bg-white px-2 py-0.5 text-xs font-bold text-teal-800">{!searching && recommendedRules.length ? recommendedRules.length : managedRules.length}{searching ? '개 검색됨' : '개'}</span></span>
|
||||||
</span>
|
<span aria-hidden="true" class="shrink-0 text-sm font-bold text-slate-500">{showRecommended ? '접기 −' : '펼치기 +'}</span>
|
||||||
) : null}
|
</button>
|
||||||
</div>
|
<div hidden={!showRecommended} id="allow-recommended-domains">
|
||||||
<button
|
{visibleRecommendedRules.length ? (
|
||||||
aria-label={`${domain} ${theme.label}에서 삭제`}
|
<ul class="m-0 max-h-[22rem] list-none divide-y divide-slate-200 overflow-y-auto p-0">
|
||||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 transition hover:border-red-200 hover:bg-red-50 hover:text-red-700 focus:outline-none focus-visible:ring-4 focus-visible:ring-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
{visibleRecommendedRules.map((rule) => (
|
||||||
disabled={busy || cloudManaged}
|
<li class="flex min-w-0 flex-wrap items-center justify-between gap-3 px-3.5 py-3" key={rule.id}>
|
||||||
onClick={() => void onDeleteRule(kind, domain)}
|
<div class="min-w-0 flex-1">
|
||||||
title={cloudManaged ? '웹사이트에서 적용 해제할 수 있습니다.' : '삭제'}
|
<strong class="block break-words text-sm text-slate-900">{rule.title}</strong>
|
||||||
type="button"
|
<div class="mt-1 flex flex-wrap gap-1.5">
|
||||||
>
|
{rule.domains.map((domain) => <code class="break-all text-xs text-slate-600" key={domain}>{domain}</code>)}
|
||||||
<TrashIcon class={`size-4 ${deleting ? 'animate-pulse' : ''}`} />
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</li>
|
{onRequestRevoke ? (
|
||||||
);
|
<button class="shrink-0 rounded-lg border border-red-200 px-3 py-2 text-xs font-bold text-red-700 hover:bg-red-50 disabled:opacity-50" disabled={revokeBusy || busy} onClick={() => onRequestRevoke(rule)} type="button">적용 해제</button>
|
||||||
})}
|
) : <span class="text-xs text-slate-500">웹사이트 관리</span>}
|
||||||
</ul>
|
</li>
|
||||||
) : (
|
))}
|
||||||
<div class="px-5 py-10 text-center">
|
</ul>
|
||||||
<ListIcon class="mx-auto size-8 text-slate-300" />
|
) : managedRules.length ? (
|
||||||
<p class="mt-3 mb-0 text-sm font-bold text-slate-600">
|
<ul class="m-0 max-h-[20rem] list-none divide-y divide-slate-200 overflow-y-auto p-0">{managedRules.map(renderRule)}</ul>
|
||||||
{search.trim().length > 0
|
) : (
|
||||||
? '검색 결과가 없습니다.'
|
<p class="m-0 px-4 py-5 text-sm text-slate-500">{searching ? '검색 결과가 없습니다.' : '추천으로 추가된 허용 도메인이 없습니다.'}</p>
|
||||||
: theme.empty}
|
)}
|
||||||
</p>
|
{!searching ? (
|
||||||
</div>
|
<p class="m-0 border-t border-slate-100 px-4 py-3 text-xs leading-5 text-slate-500">추천 규칙은 이 공유기에서 적용 해제할 수 있습니다. 새 규칙의 적용은 SmartSafeHub 웹사이트에서 관리해 주세요.</p>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="mt-4 max-h-[28rem] overflow-y-auto rounded-xl border border-slate-200">
|
||||||
|
{filteredRules.length ? (
|
||||||
|
<ul class="m-0 list-none divide-y divide-slate-200 p-0">{filteredRules.map(renderRule)}</ul>
|
||||||
|
) : (
|
||||||
|
<div class="px-5 py-10 text-center">
|
||||||
|
<ListIcon class="mx-auto size-8 text-slate-300" />
|
||||||
|
<p class="mt-3 mb-0 text-sm font-bold text-slate-600">{searching ? '검색 결과가 없습니다.' : theme.empty}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -295,25 +332,192 @@ export function SafeShieldRulesPage({
|
|||||||
onDismissFeedback,
|
onDismissFeedback,
|
||||||
onRetry,
|
onRetry,
|
||||||
onLoadRecommendedRulesStatus,
|
onLoadRecommendedRulesStatus,
|
||||||
|
onSyncRecommendedRules,
|
||||||
|
onRevokeRecommendedRule,
|
||||||
}: SafeShieldRulesPageProps) {
|
}: SafeShieldRulesPageProps) {
|
||||||
const [recommendedDomains, setRecommendedDomains] = useState<string[]>([]);
|
const [recommendedStatus, setRecommendedStatus] = useState<RecommendedRulesStatus | null>(null);
|
||||||
const [recommendedKnown, setRecommendedKnown] = useState(false);
|
const [recommendedKnown, setRecommendedKnown] = useState(false);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [revokeTarget, setRevokeTarget] = useState<RecommendedRuleAssignment | null>(null);
|
||||||
|
const [revokeBusy, setRevokeBusy] = useState(false);
|
||||||
|
const [revokeConfirming, setRevokeConfirming] = useState(false);
|
||||||
|
const [revokeConfirmationDelayed, setRevokeConfirmationDelayed] = useState(false);
|
||||||
|
const pendingRevokeId = useRef<number | null>(null);
|
||||||
|
const previousStatusSignature = useRef<string | null>(null);
|
||||||
|
const onRetryRef = useRef(onRetry);
|
||||||
|
onRetryRef.current = onRetry;
|
||||||
|
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||||
|
const [syncError, setSyncError] = useState<string | null>(null);
|
||||||
|
const recommendedDomains = recommendedStatus?.domains ?? [];
|
||||||
|
const recommendedRules = recommendedStatus?.rules ?? [];
|
||||||
|
|
||||||
|
// A revoke can reach Hub even if rpcd times out while SafeShield is applying
|
||||||
|
// the new rules. Only a confirmed local read can settle its final outcome.
|
||||||
|
// The local SafeShield list also needs a refresh when a background poll
|
||||||
|
// observes a new recommendation revision, without reloading the whole page.
|
||||||
|
const acceptRecommendedStatus = useCallback((status: RecommendedRulesStatus): void => {
|
||||||
|
setRecommendedStatus(status);
|
||||||
|
setRecommendedKnown(true);
|
||||||
|
const signature = JSON.stringify([
|
||||||
|
status.revision, status.domains, status.rules.map((rule) => rule.id),
|
||||||
|
status.syncStatus,
|
||||||
|
]);
|
||||||
|
if (previousStatusSignature.current !== null && previousStatusSignature.current !== signature) {
|
||||||
|
onRetryRef.current();
|
||||||
|
}
|
||||||
|
previousStatusSignature.current = signature;
|
||||||
|
|
||||||
|
if (status.syncStatus === 'confirmed' && pendingRevokeId.current === null) {
|
||||||
|
setSyncError(null);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
pendingRevokeId.current !== null &&
|
||||||
|
status.syncStatus === 'confirmed' &&
|
||||||
|
!status.rules.some((rule) => rule.id === pendingRevokeId.current)
|
||||||
|
) {
|
||||||
|
pendingRevokeId.current = null;
|
||||||
|
setRevokeConfirming(false);
|
||||||
|
setRevokeConfirmationDelayed(false);
|
||||||
|
setSyncError(null);
|
||||||
|
setSyncMessage('추천 규칙 적용 해제를 확인했습니다.');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onLoadRecommendedRulesStatus) return;
|
if (!onLoadRecommendedRulesStatus) return;
|
||||||
|
const loadStatus = onLoadRecommendedRulesStatus;
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
void onLoadRecommendedRulesStatus()
|
let inFlight = false;
|
||||||
.then((status) => {
|
// Read only the local rpcd state. Hub's 30-second poll runs separately.
|
||||||
if (mounted) {
|
function updateStatus(): void {
|
||||||
setRecommendedDomains(status.domains);
|
if (inFlight || syncing || revokeBusy) return;
|
||||||
setRecommendedKnown(true);
|
inFlight = true;
|
||||||
}
|
void loadStatus()
|
||||||
})
|
.then((status) => {
|
||||||
.catch(() => {
|
if (!mounted) return;
|
||||||
// Older firmware/API errors must not hide or break manual rules.
|
acceptRecommendedStatus(status);
|
||||||
if (mounted) setRecommendedKnown(false);
|
})
|
||||||
});
|
.catch(() => {
|
||||||
return () => { mounted = false; };
|
// Retain the last known result on a transient local RPC failure.
|
||||||
}, [data?.allow.join('\n'), onLoadRecommendedRulesStatus]);
|
// Do not mislabel a known recommendation as a manually added rule.
|
||||||
|
})
|
||||||
|
.finally(() => { inFlight = false; });
|
||||||
|
}
|
||||||
|
updateStatus();
|
||||||
|
const timer = window.setInterval(updateStatus, 15_000);
|
||||||
|
const onFocus = () => updateStatus();
|
||||||
|
const onVisible = () => { if (!document.hidden) updateStatus(); };
|
||||||
|
window.addEventListener('focus', onFocus);
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
window.removeEventListener('focus', onFocus);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
};
|
||||||
|
}, [data?.allow.join('\n'), onLoadRecommendedRulesStatus, syncing, revokeBusy, acceptRecommendedStatus]);
|
||||||
|
|
||||||
|
// An RPC timeout cannot prove whether Hub accepted the removal. After a
|
||||||
|
// minute, explain the delay but continue watching for eventual confirmation.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!revokeConfirming) return;
|
||||||
|
const timer = window.setTimeout(() => setRevokeConfirmationDelayed(true), 60_000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [revokeConfirming]);
|
||||||
|
|
||||||
|
async function handleSyncRecommended(): Promise<void> {
|
||||||
|
if (!onSyncRecommendedRules || syncing || revokeBusy) return;
|
||||||
|
setSyncing(true);
|
||||||
|
setSyncError(null);
|
||||||
|
setSyncMessage(null);
|
||||||
|
try {
|
||||||
|
const status = await onSyncRecommendedRules();
|
||||||
|
acceptRecommendedStatus(status);
|
||||||
|
setSyncMessage(status.syncStatus === 'confirmed'
|
||||||
|
? '서버에서 규칙을 가져와 SafeShield 허용 목록을 확인했습니다.'
|
||||||
|
: '추천 규칙 상태를 확인 중입니다. 진행 결과는 자동으로 갱신됩니다.');
|
||||||
|
onRetry(); // Reload the actual SafeShield allow list after reconciliation.
|
||||||
|
} catch {
|
||||||
|
setSyncError('추천 규칙을 동기화하지 못했습니다. 서버 연결 및 기기 등록 상태를 확인해 주세요.');
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevokeRecommended(): Promise<void> {
|
||||||
|
if (!revokeTarget || revokeBusy || !onRevokeRecommendedRule) return;
|
||||||
|
const ruleId = revokeTarget.id;
|
||||||
|
pendingRevokeId.current = ruleId;
|
||||||
|
setRevokeBusy(true);
|
||||||
|
setRevokeConfirming(true);
|
||||||
|
setRevokeConfirmationDelayed(false);
|
||||||
|
setSyncError(null);
|
||||||
|
setSyncMessage(null);
|
||||||
|
try {
|
||||||
|
const status = await onRevokeRecommendedRule(ruleId);
|
||||||
|
acceptRecommendedStatus(status);
|
||||||
|
setRevokeTarget(null);
|
||||||
|
if (pendingRevokeId.current !== null) {
|
||||||
|
setSyncMessage('적용 해제 요청을 처리했습니다. 공유기의 허용 목록을 확인하고 있습니다.');
|
||||||
|
}
|
||||||
|
onRetryRef.current();
|
||||||
|
} catch {
|
||||||
|
// Hub may have removed the assignment before the local SafeShield apply
|
||||||
|
// timed out. Keep checking instead of permanently displaying a failure.
|
||||||
|
setRevokeTarget(null);
|
||||||
|
setSyncError('적용 해제 결과를 아직 확인하지 못했습니다. 공유기의 상태를 자동으로 다시 확인합니다.');
|
||||||
|
try {
|
||||||
|
const status = await onLoadRecommendedRulesStatus?.();
|
||||||
|
if (status) acceptRecommendedStatus(status);
|
||||||
|
} catch { /* The 15-second local poll will retry. */ }
|
||||||
|
} finally {
|
||||||
|
setRevokeBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only one live notice is rendered. In-progress SafeShield refreshes are
|
||||||
|
// not failures; the 15-second local poll advances the visible stage.
|
||||||
|
const syncStage = recommendedStatus?.syncStage;
|
||||||
|
const syncPending = recommendedStatus?.syncStatus === 'pending';
|
||||||
|
const syncDelayed = recommendedStatus?.syncStatus === 'delayed';
|
||||||
|
const syncFailed = recommendedStatus?.syncStatus === 'failed';
|
||||||
|
const stageLabel = syncStage === 'server_sync' ? '서버 동기화 중'
|
||||||
|
: syncStage === 'safeshield_refreshing' ? 'SafeShield 갱신 중'
|
||||||
|
: syncStage === 'applying' ? '추천 규칙 적용 중'
|
||||||
|
: syncStage === 'verifying' ? '허용 목록 확인 중'
|
||||||
|
: syncDelayed ? '재확인 지연' : syncFailed ? '동기화 실패' :
|
||||||
|
syncPending ? '동기화 대기' : '허용 목록 확인';
|
||||||
|
const stageDescription = syncStage === 'safeshield_refreshing' && syncPending
|
||||||
|
? 'SafeShield가 보호 목록을 갱신하고 있습니다. 갱신이 끝나면 추천 규칙을 자동으로 다시 확인합니다.'
|
||||||
|
: syncStage === 'applying' && syncPending
|
||||||
|
? '추천 규칙을 SafeShield 허용 목록에 반영하고 있습니다.'
|
||||||
|
: syncStage === 'verifying' && syncPending
|
||||||
|
? 'SafeShield 허용 목록에서 추천 규칙 반영 결과를 확인하고 있습니다.'
|
||||||
|
: syncStage === 'server_sync' && syncPending
|
||||||
|
? 'SmartSafeHub 서버에서 최신 추천 규칙을 확인하고 있습니다.'
|
||||||
|
: syncDelayed
|
||||||
|
? '서버 응답이 지연되고 있습니다. 이미 확인한 허용 규칙은 유지되며 자동으로 다시 시도합니다.'
|
||||||
|
: syncFailed
|
||||||
|
? recommendedStatus?.syncReason === 'auth_rejected'
|
||||||
|
? 'SmartSafeHub 기기 인증이 거부되었습니다. 공유기 계정 연결 상태를 확인해 주세요.'
|
||||||
|
: recommendedStatus?.syncReason === 'api_rejected'
|
||||||
|
? '서버가 추천 규칙 요청을 거부했습니다. Hub API 배포 상태를 확인해 주세요.'
|
||||||
|
: recommendedStatus?.syncReason === 'invalid_response'
|
||||||
|
? '서버 응답을 확인할 수 없습니다. 규칙은 유지되며 다음 동기화에서 다시 확인합니다.'
|
||||||
|
: '추천 규칙 반영을 완료하지 못했습니다. 다음 자동 동기화에서 다시 시도합니다.'
|
||||||
|
: syncPending ? '추천 규칙의 적용 결과를 기다리고 있습니다.' : null;
|
||||||
|
const waitingForRevoke = revokeConfirming && pendingRevokeId.current !== null;
|
||||||
|
const currentNotice = waitingForRevoke
|
||||||
|
? { tone: 'amber', label: revokeConfirmationDelayed ? '적용 해제 처리 지연' : '적용 해제 확인 중',
|
||||||
|
description: revokeConfirmationDelayed
|
||||||
|
? '적용 해제 확인이 지연되고 있습니다. SafeShield 갱신 중이라면 완료 후 자동으로 재확인합니다.'
|
||||||
|
: stageDescription ?? '적용 해제 상태를 확인하고 있습니다. 확인되면 목록과 상태가 자동 갱신됩니다.' }
|
||||||
|
: syncPending || syncDelayed || syncFailed
|
||||||
|
? { tone: syncFailed ? 'red' : 'amber', label: stageLabel, description: stageDescription ?? '' }
|
||||||
|
: syncError ? { tone: 'red', label: '상태 확인 필요', description: syncError }
|
||||||
|
: recommendedStatus?.syncWarning
|
||||||
|
? { tone: 'amber', label: '서버 재확인 지연', description: '이미 확인한 허용 규칙은 유지되며 자동으로 다시 시도합니다.' }
|
||||||
|
: syncMessage ? { tone: 'green', label: '허용 목록 확인', description: syncMessage } : null;
|
||||||
|
|
||||||
if (loading && !data) {
|
if (loading && !data) {
|
||||||
return <LoadingPanel />;
|
return <LoadingPanel />;
|
||||||
@@ -329,60 +533,94 @@ export function SafeShieldRulesPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5 sm:p-6">
|
{revokeTarget ? (
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4" role="presentation" onClick={() => { if (!revokeBusy) setRevokeTarget(null); }}>
|
||||||
<div class="flex min-w-0 items-start gap-3 sm:gap-4">
|
<section aria-labelledby="recommended-revoke-title" aria-modal="true" class="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-5 shadow-xl" onClick={(event) => event.stopPropagation()} role="dialog">
|
||||||
<span class="grid size-12 shrink-0 place-items-center rounded-2xl bg-teal-50 text-teal-700">
|
<h2 class="m-0 text-lg font-extrabold text-slate-950" id="recommended-revoke-title">추천 규칙 적용 해제</h2>
|
||||||
<ListIcon class="size-7" />
|
<p class="mt-3 text-sm text-slate-700"><strong>{revokeTarget.title}</strong> 규칙을 이 공유기에서 해제할까요?</p>
|
||||||
</span>
|
<p class="mt-2 text-xs leading-5 text-slate-600">포함된 도메인 {revokeTarget.domains.length}개를 다시 확인합니다. 직접 추가한 허용 규칙과 다른 추천 규칙에 필요한 도메인은 유지됩니다.</p>
|
||||||
<div class="min-w-0">
|
<div class="mt-5 flex justify-end gap-2">
|
||||||
<p class="m-0 text-xs font-extrabold uppercase tracking-[0.16em] text-teal-700">
|
<button class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-bold text-slate-700 disabled:cursor-wait disabled:opacity-50" disabled={revokeBusy} onClick={() => setRevokeTarget(null)} type="button">취소</button>
|
||||||
Local overrides
|
<button
|
||||||
</p>
|
class="inline-flex min-w-24 items-center justify-center gap-2 rounded-lg bg-red-700 px-4 py-2 text-sm font-bold text-white transition hover:bg-red-800 disabled:cursor-progress disabled:bg-red-700 disabled:opacity-80"
|
||||||
<h2 class="mt-2 mb-0 break-words text-xl font-black tracking-tight text-slate-950 sm:text-2xl">
|
aria-busy={revokeBusy}
|
||||||
사용자 도메인 규칙
|
disabled={revokeBusy}
|
||||||
</h2>
|
onClick={() => void handleRevokeRecommended()}
|
||||||
<p class="mt-2 mb-0 max-w-3xl text-sm leading-6 text-slate-600">
|
type="button"
|
||||||
허용 목록은 오탐을 해제하고, 차단 목록은 원하는 도메인을 SafeShield 보호 목록에 추가합니다.
|
>
|
||||||
</p>
|
{revokeBusy ? <svg class="size-4 animate-spin" aria-hidden="true" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" opacity="0.25"/><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg> : null}
|
||||||
|
<span role="status">{revokeBusy ? '해제 중…' : '적용 해제'}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<dl class="m-0 grid grid-cols-3 gap-2 sm:min-w-80">
|
|
||||||
<div class="rounded-xl bg-emerald-50 px-3 py-3 text-center">
|
|
||||||
<dt class="text-[0.68rem] font-extrabold uppercase tracking-wide text-emerald-700">허용</dt>
|
|
||||||
<dd class="mt-1 mb-0 ml-0 text-lg font-black text-emerald-950">{data.counts.allow}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl bg-red-50 px-3 py-3 text-center">
|
|
||||||
<dt class="text-[0.68rem] font-extrabold uppercase tracking-wide text-red-700">차단</dt>
|
|
||||||
<dd class="mt-1 mb-0 ml-0 text-lg font-black text-red-950">{data.counts.block}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl bg-slate-100 px-3 py-3 text-center">
|
|
||||||
<dt class="text-[0.68rem] font-extrabold uppercase tracking-wide text-slate-600">전체</dt>
|
|
||||||
<dd class="mt-1 mb-0 ml-0 text-lg font-black text-slate-950">{data.counts.total}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
<SectionHeading eyebrow="Local overrides" title="사용자 도메인 규칙" description="허용 목록은 오탐을 해제하고, 차단 목록은 원하는 도메인을 SafeShield 보호 목록에 추가합니다." />
|
||||||
|
<section aria-label="규칙 개수 요약" class="min-w-0">
|
||||||
|
<dl class="m-0 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<div class="min-w-0 rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm shadow-slate-900/5">
|
||||||
|
<dt class="text-sm font-extrabold text-emerald-700">허용 도메인</dt>
|
||||||
|
<dd class="mt-2 mb-0 ml-0 text-2xl font-black tracking-tight text-slate-950">{data.counts.allow}<span class="ml-1 text-sm font-bold text-slate-500">개</span></dd>
|
||||||
|
<p class="mt-1 mb-0 text-xs text-slate-500">차단하지 않도록 등록한 도메인</p>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm shadow-slate-900/5">
|
||||||
|
<dt class="text-sm font-extrabold text-red-700">차단 도메인</dt>
|
||||||
|
<dd class="mt-2 mb-0 ml-0 text-2xl font-black tracking-tight text-slate-950">{data.counts.block}<span class="ml-1 text-sm font-bold text-slate-500">개</span></dd>
|
||||||
|
<p class="mt-1 mb-0 text-xs text-slate-500">추가로 차단하도록 등록한 도메인</p>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm shadow-slate-900/5">
|
||||||
|
<dt class="text-sm font-extrabold text-slate-600">전체 등록 규칙</dt>
|
||||||
|
<dd class="mt-2 mb-0 ml-0 text-2xl font-black tracking-tight text-slate-950">{data.counts.total}<span class="ml-1 text-sm font-bold text-slate-500">개</span></dd>
|
||||||
|
<p class="mt-1 mb-0 text-xs text-slate-500">허용 및 차단 규칙의 합계</p>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5 sm:p-5" aria-label="추천 사용자 규칙">
|
<div class="mt-7">
|
||||||
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
|
<SectionHeading eyebrow="Recommendations" title="추천 사용자 규칙" description="웹사이트에서 선택한 권장 허용 규칙의 동기화 상태를 확인하고 관리합니다." />
|
||||||
<div class="min-w-0">
|
</div>
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5 sm:p-5" aria-label="추천 사용자 규칙">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="grid size-8 place-items-center rounded-lg bg-teal-50 text-teal-700"><CheckCircleIcon class="size-4" /></span>
|
<span class="grid size-8 place-items-center rounded-lg bg-teal-50 text-teal-700"><CheckCircleIcon class="size-4" /></span>
|
||||||
<h2 class="m-0 text-base font-extrabold text-slate-950">추천 사용자 규칙</h2>
|
<h3 class="m-0 text-base font-extrabold text-slate-950">동기화 상태</h3>
|
||||||
{recommendedKnown && recommendedDomains.length > 0 ? (
|
{onLoadRecommendedRulesStatus ? (
|
||||||
<span class="rounded-full bg-teal-50 px-2.5 py-1 text-xs font-bold text-teal-800">추천으로 추가 {recommendedDomains.length}개</span>
|
<span aria-live="polite" class={`rounded-full px-2.5 py-1 text-xs font-bold ${recommendedStatus?.syncStatus === 'confirmed' ? 'bg-emerald-100 text-emerald-800' : recommendedStatus?.syncStatus === 'failed' ? 'bg-red-100 text-red-800' : 'bg-amber-100 text-amber-800'}`}>
|
||||||
|
{recommendedStatus?.syncStatus === 'confirmed' ? '허용 목록 확인' : stageLabel}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-2" aria-label="추천 규칙 분류">
|
<p class="mt-2 mb-0 text-sm leading-6 text-slate-600">웹사이트에서 선택한 허용 규칙을 약 30초마다 확인합니다. 적용 해제는 이 공유기에서도 할 수 있습니다.</p>
|
||||||
<span class="inline-flex items-center rounded-full bg-teal-50 px-2.5 py-1 text-xs font-bold text-teal-800">공식 추천</span>
|
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs font-semibold text-slate-600">
|
||||||
<span class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-700">커뮤니티</span>
|
<span>추천으로 추가 <strong class="text-teal-800">{recommendedKnown ? recommendedDomains.length : '—'}개 도메인</strong></span>
|
||||||
|
{recommendedStatus?.syncStatus === 'confirmed' ? (
|
||||||
|
<span>허용 확인 <strong class="text-emerald-800">{recommendedStatus.confirmedDomains.length}개</strong></span>
|
||||||
|
) : null}
|
||||||
|
{recommendedStatus?.lastCheckedAt ? (
|
||||||
|
<span>{recommendedStatus.syncWarning ? '최근 재확인 시도' : '최근 확인'}: <RelativeTimestamp timestamp={recommendedStatus.lastCheckedAt} /></span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 mb-0 text-sm leading-6 text-slate-600">SmartSafeHub의 공식 추천과 관리자가 검토한 커뮤니티 허용 규칙을 확인하고, 필요한 규칙을 적용해 보세요.</p>
|
|
||||||
<p class="mt-1 mb-0 text-xs leading-5 text-slate-500">공유기 선택과 적용 관리는 SmartSafeHub 웹사이트에서 진행합니다. 적용 결과는 다음 기기 동기화 후 반영되며, 연결된 모든 기기에 영향을 줍니다.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<a class="inline-flex min-h-10 w-full shrink-0 items-center justify-center rounded-xl border border-teal-700 bg-teal-700 px-4 py-2.5 text-sm font-extrabold text-white transition hover:bg-teal-800 sm:w-auto" href="https://www.smartsafehub.com/dashboard/recommended-rules/" target="_blank" rel="noopener noreferrer">추천 규칙 보기 ↗</a>
|
<div class="flex w-full flex-wrap gap-2 sm:w-auto lg:shrink-0">
|
||||||
|
{onSyncRecommendedRules ? (
|
||||||
|
<button
|
||||||
|
class="inline-flex min-h-10 flex-1 items-center justify-center rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-extrabold text-slate-800 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60 sm:flex-none"
|
||||||
|
disabled={syncing}
|
||||||
|
onClick={() => void handleSyncRecommended()}
|
||||||
|
type="button"
|
||||||
|
>{syncing ? '동기화 중…' : '지금 동기화'}</button>
|
||||||
|
) : null}
|
||||||
|
<a class="inline-flex min-h-10 flex-1 items-center justify-center rounded-xl border border-teal-700 bg-teal-700 px-4 py-2.5 text-sm font-extrabold text-white transition hover:bg-teal-800 sm:flex-none" href="https://www.smartsafehub.com/dashboard/recommended-rules/" target="_blank" rel="noopener noreferrer">추천 규칙 보기 ↗</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{currentNotice ? (
|
||||||
|
<div aria-live="polite" class={`mt-3 flex items-start gap-2 rounded-xl px-3 py-2.5 text-sm ${currentNotice.tone === 'red' ? 'bg-red-50 text-red-800' : currentNotice.tone === 'green' ? 'bg-emerald-50 text-emerald-800' : 'bg-amber-50 text-amber-800'}`} role="status">
|
||||||
|
<span aria-hidden="true" class={`mt-1 size-2 shrink-0 rounded-full ${currentNotice.tone === 'red' ? 'bg-red-500' : currentNotice.tone === 'green' ? 'bg-emerald-500' : 'bg-amber-500'}`} />
|
||||||
|
<div class="min-w-0"><strong>{currentNotice.label}</strong><p class="mt-1 mb-0 leading-5">{currentNotice.description}</p></div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p class="mt-3 mb-0 text-xs leading-5 text-slate-500">추천 규칙 동기화는 SafeShield 차단 목록 갱신과 별개입니다. 허용 목록 확인은 DNS 처리나 서비스 재생 성공을 보장하지 않습니다.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!data.applyLocalOverrides ? (
|
{!data.applyLocalOverrides ? (
|
||||||
@@ -441,6 +679,9 @@ export function SafeShieldRulesPage({
|
|||||||
rules={data.allow}
|
rules={data.allow}
|
||||||
recommendedDomains={recommendedDomains}
|
recommendedDomains={recommendedDomains}
|
||||||
recommendedKnown={recommendedKnown}
|
recommendedKnown={recommendedKnown}
|
||||||
|
recommendedRules={recommendedRules}
|
||||||
|
{...(onRevokeRecommendedRule ? { onRequestRevoke: setRevokeTarget } : {})}
|
||||||
|
revokeBusy={revokeBusy}
|
||||||
/>
|
/>
|
||||||
<RuleListCard
|
<RuleListCard
|
||||||
action={action}
|
action={action}
|
||||||
@@ -450,12 +691,10 @@ export function SafeShieldRulesPage({
|
|||||||
rules={data.block}
|
rules={data.block}
|
||||||
recommendedDomains={recommendedDomains}
|
recommendedDomains={recommendedDomains}
|
||||||
recommendedKnown={recommendedKnown}
|
recommendedKnown={recommendedKnown}
|
||||||
|
recommendedRules={recommendedRules}
|
||||||
|
revokeBusy={revokeBusy}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p class="mt-5 mb-0 text-xs leading-5 text-slate-500">
|
|
||||||
규칙 저장과 유효성 검사는 SafeShield API가 관리합니다. 도메인은 소문자로 정규화되고 중복 항목은 하나로 유지됩니다.
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SectionHeading } from '../components/SectionHeading';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
@@ -104,25 +105,33 @@ function ActionCard(props: {
|
|||||||
icon: ComponentChildren;
|
icon: ComponentChildren;
|
||||||
children?: ComponentChildren;
|
children?: ComponentChildren;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
titleActions?: ComponentChildren;
|
||||||
|
headerActions?: ComponentChildren;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
class={`min-w-0 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6 ${props.className ?? ''}`}
|
class={`h-full min-w-0 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6 ${props.className ?? ''}`}
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-start gap-4">
|
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||||
<div
|
<div class="flex min-w-0 items-start gap-4">
|
||||||
class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-600"
|
<div
|
||||||
>
|
class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-600"
|
||||||
{props.icon}
|
>
|
||||||
</div>
|
{props.icon}
|
||||||
<div class="min-w-0">
|
</div>
|
||||||
<h2 class="m-0 text-lg font-black text-slate-950">
|
<div class="min-w-0">
|
||||||
{props.title}
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
</h2>
|
<h2 class="m-0 text-lg font-black text-slate-950">
|
||||||
<p class="mt-1.5 mb-0 text-sm leading-6 text-slate-500">
|
{props.title}
|
||||||
{props.description}
|
</h2>
|
||||||
</p>
|
{props.titleActions}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1.5 mb-0 text-sm leading-6 text-slate-500">
|
||||||
|
{props.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{props.headerActions ? <div class="shrink-0">{props.headerActions}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5">{props.children}</div>
|
<div class="mt-5">{props.children}</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -199,6 +208,7 @@ function TimeSettingsCard(props: {
|
|||||||
selectedTimezone !== props.data.zonename;
|
selectedTimezone !== props.data.zonename;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ActionCard
|
<ActionCard
|
||||||
description="공유기의 기준 시간대를 설정합니다. 로그, 통계, 자동 설치와 예약 재부팅 시각도 이 시간대를 기준으로 동작합니다."
|
description="공유기의 기준 시간대를 설정합니다. 로그, 통계, 자동 설치와 예약 재부팅 시각도 이 시간대를 기준으로 동작합니다."
|
||||||
icon={<ClockIcon class="size-5" />}
|
icon={<ClockIcon class="size-5" />}
|
||||||
@@ -321,6 +331,8 @@ function TimeSettingsCard(props: {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</ActionCard>
|
||||||
|
<div class="min-w-0 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
||||||
<ScheduledRebootSection
|
<ScheduledRebootSection
|
||||||
data={props.scheduledRebootData}
|
data={props.scheduledRebootData}
|
||||||
error={props.scheduledRebootError}
|
error={props.scheduledRebootError}
|
||||||
@@ -332,7 +344,8 @@ function TimeSettingsCard(props: {
|
|||||||
saveMessage={props.scheduledRebootSaveMessage}
|
saveMessage={props.scheduledRebootSaveMessage}
|
||||||
saving={props.scheduledRebootSaving}
|
saving={props.scheduledRebootSaving}
|
||||||
/>
|
/>
|
||||||
</ActionCard>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,10 +469,11 @@ function HealthDiagnosticCard(props: {
|
|||||||
: '로컬 진단은 계속 동작하지만 장치 상태는 SmartSafeHub 서버에 자동으로 전송되지 않습니다.';
|
: '로컬 진단은 계속 동작하지만 장치 상태는 SmartSafeHub 서버에 자동으로 전송되지 않습니다.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ActionCard
|
<ActionCard
|
||||||
description="SmartSafeHub가 장치 상태를 직접 점검하고 이상 항목을 알려줍니다."
|
description="SmartSafeHub가 장치 상태를 직접 점검하고 이상 항목을 알려줍니다."
|
||||||
icon={<DownloadIcon class="size-5" />}
|
icon={<DownloadIcon class="size-5" />}
|
||||||
title="진단 및 지원"
|
title="장치 진단"
|
||||||
>
|
>
|
||||||
{(props.actionError || props.actionMessage) && (
|
{(props.actionError || props.actionMessage) && (
|
||||||
<div
|
<div
|
||||||
@@ -578,43 +592,45 @@ function HealthDiagnosticCard(props: {
|
|||||||
다운로드 파일에는 호스트명, WAN IP와 Wi-Fi SSID 같은 네트워크 식별 정보가 포함될 수 있으므로 외부 전달 전에 내용을 확인해 주세요.
|
다운로드 파일에는 호스트명, WAN IP와 Wi-Fi SSID 같은 네트워크 식별 정보가 포함될 수 있으므로 외부 전달 전에 내용을 확인해 주세요.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-5 border-t border-slate-200 pt-5">
|
</>
|
||||||
<div class="flex min-w-0 items-start justify-between gap-4">
|
)}
|
||||||
<div class="min-w-0">
|
</ActionCard>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="grid min-w-0 content-start gap-4">
|
||||||
<p class="m-0 text-sm font-black text-slate-950">원격 상태 보고</p>
|
<ActionCard
|
||||||
<span class="rounded-full bg-slate-100 px-2 py-1 text-[10px] font-extrabold uppercase tracking-[0.08em] text-slate-600">
|
description="장치의 최소 상태 정보와 이상 코드만 서버로 전송합니다. 직접 켜고 언제든지 끌 수 있습니다."
|
||||||
{reporterMembershipLabel(reporter)}
|
icon={<ShieldIcon class="size-5" />}
|
||||||
</span>
|
title="원격 상태 보고"
|
||||||
</div>
|
titleActions={
|
||||||
<p class="mt-1 mb-0 text-xs leading-5 text-slate-500">
|
<span class="inline-flex rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-extrabold uppercase tracking-[0.08em] text-slate-600">
|
||||||
장치의 최소 상태 정보와 이상 코드만 SmartSafeHub 서버에 전송합니다. 기본값은 꺼짐이며 언제든지 다시 끌 수 있습니다.
|
{reporterMembershipLabel(reporter)}
|
||||||
</p>
|
</span>
|
||||||
</div>
|
}
|
||||||
<button
|
headerActions={
|
||||||
aria-checked={reporter?.enabled === true}
|
<button
|
||||||
aria-label="원격 상태 보고 사용"
|
aria-checked={reporter?.enabled === true}
|
||||||
class={`ssh-switch-control relative inline-flex shrink-0 rounded-full border transition focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-100 disabled:cursor-not-allowed disabled:opacity-50 ${
|
aria-label="원격 상태 보고 사용"
|
||||||
reporter?.enabled
|
class={`ssh-switch-control relative inline-flex shrink-0 rounded-full border transition focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-100 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
? 'border-teal-600 bg-teal-600'
|
reporter?.enabled
|
||||||
: 'border-slate-300 bg-slate-200'
|
? 'border-teal-600 bg-teal-600'
|
||||||
}`}
|
: 'border-slate-300 bg-slate-200'
|
||||||
disabled={!data || props.savingReporter || !canToggleReporter}
|
}`}
|
||||||
onClick={() => props.onSetReporter(!(reporter?.enabled ?? false))}
|
disabled={!data || props.savingReporter || !canToggleReporter}
|
||||||
role="switch"
|
onClick={() => props.onSetReporter(!(reporter?.enabled ?? false))}
|
||||||
type="button"
|
role="switch"
|
||||||
>
|
type="button"
|
||||||
<span
|
>
|
||||||
aria-hidden="true"
|
<span
|
||||||
class={`ssh-switch-thumb absolute top-1 shadow-sm transition-[left] ${
|
aria-hidden="true"
|
||||||
reporter?.enabled ? 'left-6' : 'left-1'
|
class={`ssh-switch-thumb absolute top-1 shadow-sm transition-[left] ${
|
||||||
}`}
|
reporter?.enabled ? 'left-6' : 'left-1'
|
||||||
/>
|
}`}
|
||||||
</button>
|
/>
|
||||||
</div>
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
{reporter?.eligible ? (
|
{reporter?.eligible ? (
|
||||||
<div class="mt-4 rounded-xl bg-slate-50 p-4">
|
<div class="rounded-xl bg-slate-50 p-4">
|
||||||
<div
|
<div
|
||||||
class={`mb-4 flex items-start gap-3 rounded-xl border p-3 ${
|
class={`mb-4 flex items-start gap-3 rounded-xl border p-3 ${
|
||||||
reporterEnabled
|
reporterEnabled
|
||||||
@@ -671,7 +687,7 @@ function HealthDiagnosticCard(props: {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||||
<p class="m-0 text-sm font-extrabold text-slate-900">
|
<p class="m-0 text-sm font-extrabold text-slate-900">
|
||||||
원격 상태 보고는 유료 멤버십 또는 체험 기간에 사용할 수 있습니다.
|
원격 상태 보고는 유료 멤버십 또는 체험 기간에 사용할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
@@ -686,20 +702,14 @@ function HealthDiagnosticCard(props: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div class="mt-4 rounded-xl border border-slate-200 bg-white p-4">
|
</div>
|
||||||
<div class="flex items-start gap-3">
|
</ActionCard>
|
||||||
<span class="flex size-9 shrink-0 items-center justify-center rounded-xl bg-teal-50 text-teal-700">
|
<ActionCard
|
||||||
<ShieldIcon class="size-4" />
|
description="진단에 필요한 최소한의 상태 정보만 전송하며, 네트워크 식별 정보와 이용 내용은 보내지 않습니다."
|
||||||
</span>
|
icon={<ShieldIcon class="size-5" />}
|
||||||
<div class="min-w-0">
|
title="개인정보 보호"
|
||||||
<p class="m-0 text-sm font-black text-slate-900">개인정보 보호</p>
|
>
|
||||||
<p class="mt-1 mb-0 text-xs leading-5 text-slate-500">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
진단에 필요한 최소한의 상태 정보만 전송하며, 네트워크 식별 정보와 이용 내용은 보내지 않습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3.5">
|
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3.5">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="flex size-7 shrink-0 items-center justify-center rounded-lg bg-white text-slate-600">
|
<span class="flex size-7 shrink-0 items-center justify-center rounded-lg bg-white text-slate-600">
|
||||||
@@ -738,11 +748,9 @@ function HealthDiagnosticCard(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ActionCard>
|
</ActionCard>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,7 +774,7 @@ function PasswordInputField(props: {
|
|||||||
<span class="relative block min-w-0">
|
<span class="relative block min-w-0">
|
||||||
<input
|
<input
|
||||||
autoComplete={props.autoComplete}
|
autoComplete={props.autoComplete}
|
||||||
class="min-h-11 w-full rounded-xl border border-slate-300 bg-white px-3 py-2.5 pr-12 text-sm font-bold text-slate-900 outline-none transition focus:border-teal-500 focus:ring-2 focus:ring-teal-100 disabled:cursor-wait disabled:opacity-60"
|
class="ssh-password-input min-h-11 w-full rounded-xl border border-slate-300 bg-white px-3 py-2.5 pr-12 text-sm font-bold text-slate-900 outline-none transition focus:border-teal-500 focus:ring-2 focus:ring-teal-100 disabled:cursor-wait disabled:opacity-60"
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
id={props.id}
|
id={props.id}
|
||||||
name={props.name}
|
name={props.name}
|
||||||
@@ -1371,18 +1379,18 @@ function ScheduledRebootSection(props: {
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-labelledby="scheduled-reboot-heading"
|
aria-labelledby="scheduled-reboot-heading"
|
||||||
class="mt-6 border-t border-slate-200 pt-6"
|
class="min-w-0"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
|
<span class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
|
||||||
<CalendarIcon class="size-5" />
|
<CalendarIcon class="size-5" />
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h3 class="m-0 text-base font-black text-slate-950" id="scheduled-reboot-heading">
|
<h2 class="m-0 text-lg font-black text-slate-950" id="scheduled-reboot-heading">
|
||||||
예약 재부팅
|
예약 재부팅
|
||||||
</h3>
|
</h2>
|
||||||
<p class="mt-1.5 mb-0 text-xs leading-5 text-slate-500">
|
<p class="mt-1.5 mb-0 text-sm leading-6 text-slate-500">
|
||||||
공유기를 정해진 시간에 자동으로 재부팅합니다. 기본값은 꺼짐이며 필요한 장치에서만 사용하세요.
|
공유기를 정해진 시간에 자동으로 재부팅합니다. 기본값은 꺼짐이며 필요한 장치에서만 사용하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1650,7 +1658,8 @@ export function SettingsPage({
|
|||||||
) : loading && !data ? (
|
) : loading && !data ? (
|
||||||
<p class="mb-4 text-sm font-semibold text-slate-500" role="status">진단 파일에 필요한 시스템 정보를 확인하고 있습니다.</p>
|
<p class="mb-4 text-sm font-semibold text-slate-500" role="status">진단 파일에 필요한 시스템 정보를 확인하고 있습니다.</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div class="min-w-0">
|
<SectionHeading eyebrow="Device health" title="진단 및 지원" description="장치 진단과 원격 상태 보고를 확인하고 개인정보 보호 정책을 관리합니다." />
|
||||||
|
<div class="grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<HealthDiagnosticCard
|
<HealthDiagnosticCard
|
||||||
actionError={healthActionError}
|
actionError={healthActionError}
|
||||||
actionMessage={healthActionMessage}
|
actionMessage={healthActionMessage}
|
||||||
@@ -1676,12 +1685,8 @@ export function SettingsPage({
|
|||||||
id="time-panel"
|
id="time-panel"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
>
|
>
|
||||||
<div class="mb-4">
|
<SectionHeading eyebrow="Device settings" title="시간 및 예약" description="시간 기준과 예약 재부팅을 SmartSafeHub에서 직접 관리합니다." />
|
||||||
<p class="m-0 text-xs font-extrabold uppercase tracking-[0.16em] text-teal-700">Device settings</p>
|
<div class="grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<h2 class="mt-2 mb-0 text-xl font-black text-slate-950">시간 및 예약</h2>
|
|
||||||
<p class="mt-2 mb-0 text-sm leading-6 text-slate-500">시간 기준과 예약 재부팅을 SmartSafeHub에서 직접 관리합니다.</p>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 max-w-4xl">
|
|
||||||
<TimeSettingsCard
|
<TimeSettingsCard
|
||||||
data={timeData}
|
data={timeData}
|
||||||
error={timeError}
|
error={timeError}
|
||||||
@@ -1713,15 +1718,7 @@ export function SettingsPage({
|
|||||||
id="management-panel"
|
id="management-panel"
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
>
|
>
|
||||||
<div class="mb-4">
|
<SectionHeading eyebrow="System management" title="시스템 관리" description="관리자 비밀번호, 설정 백업·복원과 공유기 시스템 도구를 한곳에서 관리합니다." />
|
||||||
<p class="m-0 text-xs font-extrabold uppercase tracking-[0.16em] text-teal-700">
|
|
||||||
System management
|
|
||||||
</p>
|
|
||||||
<h2 class="mt-2 mb-0 text-xl font-black text-slate-950">시스템 관리</h2>
|
|
||||||
<p class="mt-2 mb-0 text-sm leading-6 text-slate-500">
|
|
||||||
관리자 비밀번호, 설정 백업·복원과 공유기 시스템 도구를 한곳에서 관리합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2">
|
<div class="grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<AdministratorPasswordCard onChanged={onAdministratorPasswordChanged} />
|
<AdministratorPasswordCard onChanged={onAdministratorPasswordChanged} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { SectionHeading } from '../components/SectionHeading';
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
import { formatRelativeTime } from '../app/format';
|
import { formatRelativeTime } from '../app/format';
|
||||||
import { CheckCircleIcon, ClockIcon, CopyIcon, KeyIcon, ReloadIcon } from '../components/Icons';
|
import { CheckCircleIcon, ClockIcon, CopyIcon, ReloadIcon } from '../components/Icons';
|
||||||
import { ErrorPanel, LoadingPanel } from '../components/StatePanels';
|
import { ErrorPanel, LoadingPanel } from '../components/StatePanels';
|
||||||
import type { DeviceRegistrationStatus } from '../api/smartsafehub';
|
import type { DeviceRegistrationStatus } from '../api/smartsafehub';
|
||||||
|
|
||||||
@@ -134,30 +135,23 @@ export function SmartSafeHubAccountPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
|
<div class="min-w-0">
|
||||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
<SectionHeading
|
||||||
<div class="flex items-start justify-between gap-4">
|
eyebrow="Account connection"
|
||||||
<div>
|
title="기기 계정 연결"
|
||||||
<p class="m-0 text-[0.68rem] font-black uppercase tracking-[0.16em] text-teal-700">Account connection</p>
|
description="계정에 연결하면 웹사이트에서 기기를 관리하고 Cloud 기능과 구독 권한을 사용할 수 있습니다."
|
||||||
<h2 class="mt-2 mb-0 text-xl font-black tracking-tight text-slate-950">기기 계정 연결</h2>
|
/>
|
||||||
<p class="mt-2 mb-0 max-w-2xl text-sm leading-6 text-slate-500">
|
<div class="grid items-start gap-5 xl:items-stretch xl:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
|
||||||
계정에 연결하면 웹사이트에서 기기를 관리하고 Cloud 기능과 구독 권한을 사용할 수 있습니다.
|
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6 xl:h-full">
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="grid size-11 shrink-0 place-items-center rounded-xl bg-teal-50 text-teal-700">
|
|
||||||
<KeyIcon class="size-5" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{connected ? (
|
{connected ? (
|
||||||
<div class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
|
<div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="grid size-10 shrink-0 place-items-center rounded-full bg-emerald-100 text-emerald-700">
|
<span class="grid size-10 shrink-0 place-items-center rounded-full bg-emerald-100 text-emerald-700">
|
||||||
<CheckCircleIcon class="size-5" />
|
<CheckCircleIcon class="size-5" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p class="m-0 text-sm font-black text-emerald-900">SmartSafeHub 계정에 연결됨</p>
|
<p class="m-0 text-base font-black text-slate-950">SmartSafeHub 계정에 연결됨</p>
|
||||||
<p class="mt-1 mb-0 text-sm leading-5 text-emerald-800">연결 코드는 더 이상 필요하지 않으며 새 코드도 발급하지 않습니다.</p>
|
<p class="mt-1 mb-0 text-sm leading-6 text-slate-600">이 기기가 계정과 정상적으로 연결되어 있습니다. 연결 코드는 더 이상 필요하지 않습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
@@ -168,12 +162,12 @@ export function SmartSafeHubAccountPage({
|
|||||||
>
|
>
|
||||||
웹사이트에서 기기 관리
|
웹사이트에서 기기 관리
|
||||||
</a>
|
</a>
|
||||||
<p class="mt-3 mb-0 text-xs font-semibold leading-5 text-emerald-800">
|
<p class="mt-4 mb-0 border-t border-slate-100 pt-4 text-xs font-semibold leading-5 text-slate-500">
|
||||||
웹사이트에서 기기 등록을 해제하면 공유기도 다음 계정 상태 동기화에서 자동으로 연결 해제를 반영합니다.
|
웹사이트에서 기기 등록을 해제하면 공유기도 다음 계정 상태 동기화에서 자동으로 연결 해제를 반영합니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4 sm:p-5">
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p class="m-0 text-sm font-black text-slate-900">계정 연결 코드</p>
|
<p class="m-0 text-sm font-black text-slate-900">계정 연결 코드</p>
|
||||||
@@ -223,7 +217,7 @@ export function SmartSafeHubAccountPage({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6">
|
<aside class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6 xl:h-full">
|
||||||
<p class="m-0 text-[0.68rem] font-black uppercase tracking-[0.16em] text-slate-400">Device status</p>
|
<p class="m-0 text-[0.68rem] font-black uppercase tracking-[0.16em] text-slate-400">Device status</p>
|
||||||
<h2 class="mt-2 mb-0 text-lg font-black tracking-tight text-slate-950">연결 상태</h2>
|
<h2 class="mt-2 mb-0 text-lg font-black tracking-tight text-slate-950">연결 상태</h2>
|
||||||
<dl class="mt-5 mb-0 grid gap-4">
|
<dl class="mt-5 mb-0 grid gap-4">
|
||||||
@@ -257,5 +251,6 @@ export function SmartSafeHubAccountPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SectionHeading } from '../components/SectionHeading';
|
||||||
import { FirmwareUpdatesCard } from '../components/FirmwareUpdatesCard';
|
import { FirmwareUpdatesCard } from '../components/FirmwareUpdatesCard';
|
||||||
import { SoftwareUpdatesCard } from '../components/SoftwareUpdatesCard';
|
import { SoftwareUpdatesCard } from '../components/SoftwareUpdatesCard';
|
||||||
import type { FirmwareAction } from '../hooks/useFirmwareUpdates';
|
import type { FirmwareAction } from '../hooks/useFirmwareUpdates';
|
||||||
@@ -59,6 +60,9 @@ export function UpdatePage({
|
|||||||
}: UpdatePageProps) {
|
}: UpdatePageProps) {
|
||||||
return (
|
return (
|
||||||
<section class="min-w-0 space-y-5">
|
<section class="min-w-0 space-y-5">
|
||||||
|
<div>
|
||||||
|
<SectionHeading eyebrow="Firmware" title="시스템 펌웨어" description="공유기 운영체제의 새 버전을 확인하고 설치합니다." />
|
||||||
|
</div>
|
||||||
<FirmwareUpdatesCard
|
<FirmwareUpdatesCard
|
||||||
action={firmware.action}
|
action={firmware.action}
|
||||||
actionError={firmware.actionError}
|
actionError={firmware.actionError}
|
||||||
@@ -77,6 +81,9 @@ export function UpdatePage({
|
|||||||
onRetry={() => void firmware.refresh()}
|
onRetry={() => void firmware.refresh()}
|
||||||
onUpload={firmware.upload}
|
onUpload={firmware.upload}
|
||||||
/>
|
/>
|
||||||
|
<div class="pt-2">
|
||||||
|
<SectionHeading eyebrow="Software" title="관리 소프트웨어" description="SmartSafeHub 관리 화면 및 서비스의 업데이트를 확인합니다." />
|
||||||
|
</div>
|
||||||
<SoftwareUpdatesCard
|
<SoftwareUpdatesCard
|
||||||
firmware={firmware.data}
|
firmware={firmware.data}
|
||||||
action={action}
|
action={action}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SectionHeading } from '../components/SectionHeading';
|
||||||
import type { TargetedSubmitEvent } from 'preact';
|
import type { TargetedSubmitEvent } from 'preact';
|
||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
|
||||||
@@ -419,6 +420,7 @@ export function WanPage({
|
|||||||
<>
|
<>
|
||||||
{feedback ? <FeedbackPanel feedback={feedback} onDismiss={onDismissFeedback} /> : null}
|
{feedback ? <FeedbackPanel feedback={feedback} onDismiss={onDismissFeedback} /> : null}
|
||||||
|
|
||||||
|
<SectionHeading eyebrow="Overview" title="인터넷 연결 상태" description="WAN 연결 상태와 네트워크 정보를 확인합니다." />
|
||||||
<WanStatusCards data={data} busy={busy} onReconnect={reconnect} />
|
<WanStatusCards data={data} busy={busy} onReconnect={reconnect} />
|
||||||
|
|
||||||
{!data.configuration.supported ? (
|
{!data.configuration.supported ? (
|
||||||
@@ -435,26 +437,15 @@ export function WanPage({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div class="mt-7">
|
||||||
|
<SectionHeading eyebrow="Internet" title="인터넷 연결 설정" description="통신사 또는 상위 네트워크 환경에 맞는 WAN 연결 방식을 선택합니다." />
|
||||||
|
</div>
|
||||||
<form
|
<form
|
||||||
class="mt-5 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6"
|
class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6"
|
||||||
onSubmit={(event) => void submit(event)}
|
onSubmit={(event) => void submit(event)}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
{settingsDirty ? (
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
<div class="mb-5 flex justify-end">
|
||||||
<span class="grid size-11 shrink-0 place-items-center rounded-xl bg-teal-50 text-teal-700">
|
|
||||||
<GlobeIcon class="size-6" />
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="m-0 text-xs font-extrabold uppercase tracking-[0.16em] text-slate-500">
|
|
||||||
Internet
|
|
||||||
</p>
|
|
||||||
<h2 class="mt-2 mb-0 text-xl font-black text-slate-950">인터넷 연결 설정</h2>
|
|
||||||
<p class="mt-2 mb-0 text-sm font-semibold leading-6 text-slate-600">
|
|
||||||
통신사 또는 상위 네트워크 환경에 맞는 WAN 연결 방식을 선택합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{settingsDirty ? (
|
|
||||||
<span
|
<span
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="inline-flex shrink-0 self-start items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-extrabold text-amber-800"
|
class="inline-flex shrink-0 self-start items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-extrabold text-amber-800"
|
||||||
@@ -463,10 +454,10 @@ export function WanPage({
|
|||||||
<AlertIcon aria-hidden="true" class="size-3.5 shrink-0" />
|
<AlertIcon aria-hidden="true" class="size-3.5 shrink-0" />
|
||||||
저장되지 않음
|
저장되지 않음
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
<div class="mt-6">
|
<div>
|
||||||
<span class="text-sm font-extrabold text-slate-800">연결 방식</span>
|
<span class="text-sm font-extrabold text-slate-800">연결 방식</span>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
ariaLabel="WAN 연결 방식"
|
ariaLabel="WAN 연결 방식"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SectionHeading } from '../components/SectionHeading';
|
||||||
import type { TargetedSubmitEvent } from 'preact';
|
import type { TargetedSubmitEvent } from 'preact';
|
||||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
@@ -393,6 +394,7 @@ export function WifiPage({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<SectionHeading eyebrow="Wireless" title="기본 Wi-Fi" description="무선 네트워크의 이름, 비밀번호, 보안 방식과 QR 코드를 관리합니다." />
|
||||||
{data.networks.length ? (
|
{data.networks.length ? (
|
||||||
<section aria-label="기본 Wi-Fi 설정" class="grid gap-5 xl:grid-cols-2">
|
<section aria-label="기본 Wi-Fi 설정" class="grid gap-5 xl:grid-cols-2">
|
||||||
{data.networks.map((network) => (
|
{data.networks.map((network) => (
|
||||||
@@ -420,6 +422,9 @@ export function WifiPage({
|
|||||||
{data.guest && (
|
{data.guest && (
|
||||||
<>
|
<>
|
||||||
<div role="separator" aria-label="기본 Wi-Fi와 게스트 Wi-Fi 구분" class="mt-5 w-full border-t border-slate-200" />
|
<div role="separator" aria-label="기본 Wi-Fi와 게스트 Wi-Fi 구분" class="mt-5 w-full border-t border-slate-200" />
|
||||||
|
<div class="mt-5">
|
||||||
|
<SectionHeading eyebrow="Guest network" title="방문자용 네트워크" description="기본 네트워크와 분리된 게스트 Wi-Fi를 관리합니다." />
|
||||||
|
</div>
|
||||||
<GuestWifiCard
|
<GuestWifiCard
|
||||||
guest={data.guest}
|
guest={data.guest}
|
||||||
busy={updatingSection !== null}
|
busy={updatingSection !== null}
|
||||||
|
|||||||
@@ -475,6 +475,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Domain-rule group headers are theme-aware even when hovered. */
|
||||||
|
.ssh-app .ssh-rule-group-toggle:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssh-app[data-theme='dark'] .ssh-rule-group-toggle:hover {
|
||||||
|
background-color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WebKit's credential autofill can otherwise leave a bright yellow password
|
||||||
|
* field inside the dark settings panel. Keep saved-password support enabled. */
|
||||||
|
.ssh-app[data-theme='dark'] .ssh-password-input:-webkit-autofill,
|
||||||
|
.ssh-app[data-theme='dark'] .ssh-password-input:-webkit-autofill:hover,
|
||||||
|
.ssh-app[data-theme='dark'] .ssh-password-input:-webkit-autofill:focus {
|
||||||
|
-webkit-text-fill-color: #f8fafc;
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px #0f172a inset;
|
||||||
|
box-shadow: 0 0 0 1000px #0f172a inset;
|
||||||
|
caret-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ssh-app[data-theme='light'] .ssh-password-input:-webkit-autofill,
|
||||||
|
.ssh-app[data-theme='light'] .ssh-password-input:-webkit-autofill:hover,
|
||||||
|
.ssh-app[data-theme='light'] .ssh-password-input:-webkit-autofill:focus {
|
||||||
|
-webkit-text-fill-color: #0f172a;
|
||||||
|
-webkit-box-shadow: 0 0 0 1000px #ffffff inset;
|
||||||
|
box-shadow: 0 0 0 1000px #ffffff inset;
|
||||||
|
caret-color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
/* Authenticated product color theme. The application lives inside a shadow
|
/* Authenticated product color theme. The application lives inside a shadow
|
||||||
* root, so these overrides can safely remap the existing utility palette
|
* root, so these overrides can safely remap the existing utility palette
|
||||||
* without affecting LuCI itself. */
|
* without affecting LuCI itself. */
|
||||||
|
|||||||
@@ -36,3 +36,25 @@ export interface SafeShieldRuleMutationResult {
|
|||||||
};
|
};
|
||||||
rules: SafeShieldRules;
|
rules: SafeShieldRules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Local SafeShield allow-list confirmation, not a DNS traffic test.
|
||||||
|
export interface RecommendedRuleAssignment {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
domains: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecommendedRulesStatus {
|
||||||
|
revision: string | null;
|
||||||
|
domains: string[];
|
||||||
|
rules: RecommendedRuleAssignment[];
|
||||||
|
confirmedDomains: Array<{ domain: string; source: 'managed' | 'manual' }>;
|
||||||
|
syncStatus: 'pending' | 'confirmed' | 'delayed' | 'failed';
|
||||||
|
syncStage: string | null;
|
||||||
|
syncReason: string | null;
|
||||||
|
syncWarning: boolean;
|
||||||
|
lastCheckedAt: number;
|
||||||
|
lastDeviceSyncAt: number;
|
||||||
|
nextDeviceSyncAt: number;
|
||||||
|
accountRegistered: boolean;
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ CREDENTIAL_FILE="${SMARTSAFEHUB_DEVICE_CREDENTIAL_FILE:-/etc/smartsafehub/device
|
|||||||
ACTIVITY_CREDENTIAL_FILE="${SMARTSAFEHUB_ACTIVITY_CREDENTIAL_FILE:-$RUNTIME_DIR/activity-sync-credential.json}"
|
ACTIVITY_CREDENTIAL_FILE="${SMARTSAFEHUB_ACTIVITY_CREDENTIAL_FILE:-$RUNTIME_DIR/activity-sync-credential.json}"
|
||||||
RECOMMENDED_HELPER="${SMARTSAFEHUB_RECOMMENDED_HELPER:-/usr/libexec/smartsafehub-recommended-rules}"
|
RECOMMENDED_HELPER="${SMARTSAFEHUB_RECOMMENDED_HELPER:-/usr/libexec/smartsafehub-recommended-rules}"
|
||||||
RECOMMENDED_RECEIPT="${SMARTSAFEHUB_RECOMMENDED_RECEIPT_FILE:-$RUNTIME_DIR/recommended-rules.receipt.json}"
|
RECOMMENDED_RECEIPT="${SMARTSAFEHUB_RECOMMENDED_RECEIPT_FILE:-$RUNTIME_DIR/recommended-rules.receipt.json}"
|
||||||
|
RECOMMENDED_STATE="${SMARTSAFEHUB_RECOMMENDED_STATE_FILE:-/etc/smartsafehub/recommended-rules.state}"
|
||||||
|
RECOMMENDED_SYNC_STATUS="${SMARTSAFEHUB_RECOMMENDED_SYNC_STATUS_FILE:-$RUNTIME_DIR/recommended-rules.sync.json}"
|
||||||
|
RECOMMENDED_CATALOG="${SMARTSAFEHUB_RECOMMENDED_CATALOG_FILE:-$RUNTIME_DIR/recommended-rules.catalog.json}"
|
||||||
|
RECOMMENDED_ACK_STATE="${SMARTSAFEHUB_RECOMMENDED_ACK_STATE_FILE:-$RUNTIME_DIR/recommended-rules.reported}"
|
||||||
API_BASE_URL="${SMARTSAFEHUB_DEVICE_API_BASE_URL:-https://www.smartsafehub.com/api/v1}"
|
API_BASE_URL="${SMARTSAFEHUB_DEVICE_API_BASE_URL:-https://www.smartsafehub.com/api/v1}"
|
||||||
UCLIENT_FETCH_BIN="${SMARTSAFEHUB_DEVICE_UCLIENT_FETCH_BIN:-uclient-fetch}"
|
UCLIENT_FETCH_BIN="${SMARTSAFEHUB_DEVICE_UCLIENT_FETCH_BIN:-uclient-fetch}"
|
||||||
JSONFILTER_BIN="${SMARTSAFEHUB_DEVICE_JSONFILTER_BIN:-jsonfilter}"
|
JSONFILTER_BIN="${SMARTSAFEHUB_DEVICE_JSONFILTER_BIN:-jsonfilter}"
|
||||||
@@ -26,6 +30,8 @@ HEXDUMP_BIN="${SMARTSAFEHUB_DEVICE_HEXDUMP_BIN:-hexdump}"
|
|||||||
UBUS_BIN="${SMARTSAFEHUB_DEVICE_UBUS_BIN:-ubus}"
|
UBUS_BIN="${SMARTSAFEHUB_DEVICE_UBUS_BIN:-ubus}"
|
||||||
REQUEST_TIMEOUT_S=10
|
REQUEST_TIMEOUT_S=10
|
||||||
CHECK_INTERVAL_S=300
|
CHECK_INTERVAL_S=300
|
||||||
|
RECOMMENDED_POLL_INTERVAL_S=30
|
||||||
|
RECOMMENDED_POLL_MAX_RETRY_S=300
|
||||||
STARTUP_DELAY_S=35
|
STARTUP_DELAY_S=35
|
||||||
MAX_RETRY_S=3600
|
MAX_RETRY_S=3600
|
||||||
STOP_REQUESTED=0
|
STOP_REQUESTED=0
|
||||||
@@ -205,6 +211,36 @@ rotate_credential_if_due() {
|
|||||||
rm -f "$body" "$out"
|
rm -f "$body" "$out"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Keep the latest reconciliation outcome in tmpfs, not flash. A successful
|
||||||
|
# device/account sync does not imply SafeShield accepted the recommended rules.
|
||||||
|
# Status is volatile. Keep the most recent confirmed receipt separately so a
|
||||||
|
# transient Hub failure never becomes an unverified SafeShield failure.
|
||||||
|
write_recommended_sync_status() {
|
||||||
|
local result="$1" stage="${2:-$1}" reason="${3:-}" now tmp
|
||||||
|
now="$(now_epoch)"
|
||||||
|
mkdir -p "$RUNTIME_DIR" || return 0
|
||||||
|
tmp="${RECOMMENDED_SYNC_STATUS}.tmp.$$"
|
||||||
|
if printf '{"status":"%s","stage":"%s","reason":"%s","checkedAt":%s}\n' \
|
||||||
|
"$result" "$stage" "$reason" "$now" > "$tmp"; then
|
||||||
|
mv -f "$tmp" "$RECOMMENDED_SYNC_STATUS" 2>/dev/null || rm -f "$tmp"
|
||||||
|
else
|
||||||
|
rm -f "$tmp"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# The reconciler exits 75 when SafeShield is refreshing or another reconcile
|
||||||
|
# holds its lock. These are deferred operations, not failed recommendations.
|
||||||
|
reconcile_recommended_response() {
|
||||||
|
local response="$1" rc=0
|
||||||
|
[ -x "$RECOMMENDED_HELPER" ] || { write_recommended_sync_status failed helper_missing; return 1; }
|
||||||
|
"$RECOMMENDED_HELPER" "$response" || rc=$?
|
||||||
|
case "$rc" in
|
||||||
|
0) write_recommended_sync_status confirmed; return 0 ;;
|
||||||
|
75) return 0 ;; # helper writes its own progress state (or is already running)
|
||||||
|
*) write_recommended_sync_status failed apply_error; return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
status_sync() {
|
status_sync() {
|
||||||
local token body out now registered previous_registered previous_plan plan last_success next code expires protection_refresh_pending
|
local token body out now registered previous_registered previous_plan plan last_success next code expires protection_refresh_pending
|
||||||
now="$(now_epoch)"; next=$((now + CHECK_INTERVAL_S))
|
now="$(now_epoch)"; next=$((now + CHECK_INTERVAL_S))
|
||||||
@@ -265,8 +301,10 @@ status_sync() {
|
|||||||
# Hub-approved recommendations are reconciled by a separate local helper.
|
# Hub-approved recommendations are reconciled by a separate local helper.
|
||||||
# Keep the account state available even when SafeShield cannot be reached;
|
# Keep the account state available even when SafeShield cannot be reached;
|
||||||
# a failed reconciliation is retried on the next device sync.
|
# a failed reconciliation is retried on the next device sync.
|
||||||
if [ -x "$RECOMMENDED_HELPER" ]; then
|
if [ -n "$(json_value "$out" '@.recommended_rules.revision')" ]; then
|
||||||
"$RECOMMENDED_HELPER" "$out" || log_message 'recommended rules will retry on next device sync'
|
if ! reconcile_recommended_response "$out"; then
|
||||||
|
log_message 'recommended rules will retry on next device sync'
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$protection_refresh_pending" = true ]; then
|
if [ "$protection_refresh_pending" = true ]; then
|
||||||
@@ -281,6 +319,133 @@ status_sync() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# The Hub removes the assignment first and returns the new authoritative
|
||||||
|
# allow-list state. If local reconciliation fails, the next poll still repairs
|
||||||
|
# the router without recreating the removed assignment.
|
||||||
|
recommended_revoke() {
|
||||||
|
local rule_id="${1:-}" token body out rc
|
||||||
|
case "$rule_id" in ''|0|*[!0-9]*) return 2 ;; esac
|
||||||
|
[ "${#rule_id}" -le 10 ] || return 2
|
||||||
|
[ "$(json_value "$STATE_FILE" '@.accountRegistered')" = true ] || return 1
|
||||||
|
token="$(credential_token)" || return 1
|
||||||
|
write_recommended_sync_status pending server_sync
|
||||||
|
body="$RUNTIME_DIR/recommended-revoke-request.$$.json"
|
||||||
|
out="$RUNTIME_DIR/recommended-revoke-response.$$.json"
|
||||||
|
printf '{}\n' > "$body" || return 1
|
||||||
|
if ! post_json "$API_BASE_URL/devices/recommended-rules/$rule_id/revoke" "$body" "$out" "$token"; then
|
||||||
|
write_recommended_sync_status delayed hub_unavailable
|
||||||
|
rm -f "$body" "$out" "${out}.log"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rc=1
|
||||||
|
if [ "$(json_value "$out" '@.changed')" = true ]; then
|
||||||
|
if reconcile_recommended_response "$out"; then
|
||||||
|
rc=0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
write_recommended_sync_status failed invalid_response
|
||||||
|
fi
|
||||||
|
rm -f "$body" "$out" "${out}.log"
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A lightweight authenticated poll; unlike status-sync it does not resolve
|
||||||
|
# artifacts, licenses, or other device services on the Hub.
|
||||||
|
recommended_fast_sync() {
|
||||||
|
local token body out revision changed server_revision rc rules_revision
|
||||||
|
mkdir -p "$RUNTIME_DIR" || return 1
|
||||||
|
[ "$(json_value "$STATE_FILE" '@.accountRegistered')" = true ] || return 0
|
||||||
|
[ -x "$RECOMMENDED_HELPER" ] || return 1
|
||||||
|
token="$(credential_token)" || return 1
|
||||||
|
revision=''
|
||||||
|
if [ -f "$RECOMMENDED_STATE" ] && [ ! -L "$RECOMMENDED_STATE" ]; then
|
||||||
|
revision="$(sed -n '1p' "$RECOMMENDED_STATE")"
|
||||||
|
case "$revision" in
|
||||||
|
*[!0-9a-f]*|'') revision='' ;;
|
||||||
|
*) [ "${#revision}" -eq 64 ] || revision='' ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
# A state revision alone is not proof that SafeShield applied the rules.
|
||||||
|
# When a prior reconciliation lost its receipt, force a full payload from
|
||||||
|
# Hub rather than acknowledging 'changed: false' forever.
|
||||||
|
if [ -n "$revision" ] && {
|
||||||
|
[ "$(json_value "$RECOMMENDED_RECEIPT" '@.revision')" != "$revision" ] ||
|
||||||
|
[ "$(json_value "$RECOMMENDED_RECEIPT" '@.status')" != confirmed ];
|
||||||
|
}; then
|
||||||
|
revision=''
|
||||||
|
fi
|
||||||
|
rules_revision="$(json_value "${RECOMMENDED_CATALOG:-$RUNTIME_DIR/recommended-rules.catalog.json}" '@.rules_revision')"
|
||||||
|
case "$rules_revision" in *[!0-9a-f]*|'') rules_revision='' ;; *) [ "${#rules_revision}" -eq 64 ] || rules_revision='' ;; esac
|
||||||
|
body="$RUNTIME_DIR/recommended-poll-request.$$.json"
|
||||||
|
out="$RUNTIME_DIR/recommended-poll-response.$$.json"
|
||||||
|
if [ -z "$revision" ]; then write_recommended_sync_status pending server_sync; fi
|
||||||
|
# Only report after the local reconciler wrote a matching SafeShield
|
||||||
|
# receipt. Refresh confirmation every ten minutes, rather than writing to
|
||||||
|
# Hub's database on every 30-second revision check.
|
||||||
|
local report_due=0 report_revision='' report_rules='' report_at=0 now ack_state
|
||||||
|
ack_state="${RECOMMENDED_ACK_STATE:-$RUNTIME_DIR/recommended-rules.reported}"
|
||||||
|
if command -v now_epoch >/dev/null 2>&1; then now="$(now_epoch)"; else now="$(date +%s)"; fi
|
||||||
|
if [ -n "$revision" ] && [ "$(json_value "$RECOMMENDED_RECEIPT" '@.revision')" = "$revision" ] \
|
||||||
|
&& [ "$(json_value "$RECOMMENDED_RECEIPT" '@.status')" = confirmed ]; then
|
||||||
|
if [ -f "$ack_state" ]; then
|
||||||
|
read -r report_revision report_rules report_at < "$ack_state" || true
|
||||||
|
fi
|
||||||
|
case "$report_at" in ''|*[!0-9]*) report_at=0 ;; esac
|
||||||
|
[ -n "$rules_revision" ] || rules_revision='-'
|
||||||
|
if [ "$report_revision" != "$revision" ] || [ "$report_rules" != "$rules_revision" ] \
|
||||||
|
|| [ "$now" -lt "$report_at" ] || [ "$((now - report_at))" -ge 600 ]; then
|
||||||
|
report_due=1
|
||||||
|
fi
|
||||||
|
[ "$rules_revision" = '-' ] && rules_revision=''
|
||||||
|
fi
|
||||||
|
if [ "$report_due" -eq 1 ]; then
|
||||||
|
printf '{"revision":"%s","rules_revision":"%s","recommended_rules_ack":' "$revision" "$rules_revision" > "$body" || return 1
|
||||||
|
cat "$RECOMMENDED_RECEIPT" >> "$body" || return 1
|
||||||
|
printf '}\n' >> "$body" || return 1
|
||||||
|
else
|
||||||
|
printf '{"revision":"%s","rules_revision":"%s"}\n' "$revision" "$rules_revision" > "$body" || return 1
|
||||||
|
fi
|
||||||
|
if ! post_json "$API_BASE_URL/devices/recommended-rules" "$body" "$out" "$token"; then
|
||||||
|
write_recommended_sync_status delayed hub_unavailable
|
||||||
|
rm -f "$body" "$out" "${out}.log"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Mark the receipt reported only when Hub explicitly accepted it. If a
|
||||||
|
# newer assignment raced with this request, retain the receipt for retry.
|
||||||
|
if [ "$report_due" -eq 1 ] && [ "$(json_value "$out" '@.acknowledged')" = true ]; then
|
||||||
|
printf '%s %s %s\n' "$revision" "${rules_revision:--}" "$now" > "${ack_state}.tmp.$$" && \
|
||||||
|
mv -f "${ack_state}.tmp.$$" "$ack_state"
|
||||||
|
fi
|
||||||
|
changed="$(json_value "$out" '@.changed')"
|
||||||
|
case "$changed" in
|
||||||
|
false)
|
||||||
|
server_revision="$(json_value "$out" '@.revision')"
|
||||||
|
if [ -n "$revision" ] && [ "$server_revision" = "$revision" ]; then
|
||||||
|
write_recommended_sync_status confirmed
|
||||||
|
rc=0
|
||||||
|
else
|
||||||
|
write_recommended_sync_status failed invalid_response
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
true)
|
||||||
|
if reconcile_recommended_response "$out"; then
|
||||||
|
rm -f "$ack_state"
|
||||||
|
rc=0
|
||||||
|
else
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*) write_recommended_sync_status failed invalid_response; rc=1 ;;
|
||||||
|
esac
|
||||||
|
# Defer transient local reconciler work instead of overwriting its stage.
|
||||||
|
[ "$rc" -eq 0 ] || log_message 'recommended rules reconciliation failed'
|
||||||
|
rm -f "$body" "$out" "${out}.log"
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
pairing_session() {
|
pairing_session() {
|
||||||
local token body out code expires registered now plan last_success next protection_refresh_pending
|
local token body out code expires registered now plan last_success next protection_refresh_pending
|
||||||
now="$(now_epoch)"; next=$((now + CHECK_INTERVAL_S))
|
now="$(now_epoch)"; next=$((now + CHECK_INTERVAL_S))
|
||||||
@@ -313,16 +478,66 @@ retry_delay() {
|
|||||||
printf '%s\n' "$base"
|
printf '%s\n' "$base"
|
||||||
}
|
}
|
||||||
wait_for() { local seconds="$1"; [ "$STOP_REQUESTED" -eq 1 ] && return 1; "$SLEEP_BIN" "$seconds" & WAIT_PID=$!; wait "$WAIT_PID" 2>/dev/null || true; WAIT_PID=''; [ "$STOP_REQUESTED" -eq 0 ]; }
|
wait_for() { local seconds="$1"; [ "$STOP_REQUESTED" -eq 1 ] && return 1; "$SLEEP_BIN" "$seconds" & WAIT_PID=$!; wait "$WAIT_PID" 2>/dev/null || true; WAIT_PID=''; [ "$STOP_REQUESTED" -eq 0 ]; }
|
||||||
daemon() { local failures=0 delay; mkdir -p "$RUNTIME_DIR"; prepare_credential || true; wait_for "$STARTUP_DELAY_S" || return 0; while [ "$STOP_REQUESTED" -eq 0 ]; do if ssh_runtime_execute status-sync status_sync; then failures=0; delay="$CHECK_INTERVAL_S"; else delay="$(retry_delay "$failures")"; [ "$failures" -lt 4 ] && failures=$((failures + 1)); fi; wait_for "$delay" || break; done; }
|
daemon() {
|
||||||
# Existing commands retain their behaviour; runtime-status is read-only.
|
local failures=0 fast_failures=0 now next_full next_fast delay jitter random_hex fast_delay
|
||||||
ssh_runtime_debug_arg "${1:-daemon}" "${2:-}" || { echo 'invalid argument' >&2; exit 2; }
|
mkdir -p "$RUNTIME_DIR"
|
||||||
|
prepare_credential || true
|
||||||
|
wait_for "$STARTUP_DELAY_S" || return 0
|
||||||
|
now="$(now_epoch)"
|
||||||
|
next_full="$now"
|
||||||
|
# Stagger first fast poll, avoiding a burst after mass reboot/release.
|
||||||
|
random_hex="$("$HEXDUMP_BIN" -n 1 -v -e '1/1 "%u"' /dev/urandom 2>/dev/null || true)"
|
||||||
|
case "$random_hex" in ''|*[!0-9]*) jitter=15 ;; *) jitter=$((random_hex % 21)) ;; esac
|
||||||
|
next_fast=$((now + RECOMMENDED_POLL_INTERVAL_S + jitter))
|
||||||
|
while [ "$STOP_REQUESTED" -eq 0 ]; do
|
||||||
|
now="$(now_epoch)"
|
||||||
|
if [ "$now" -ge "$next_full" ]; then
|
||||||
|
if ssh_runtime_execute status-sync status_sync; then
|
||||||
|
failures=0; delay="$CHECK_INTERVAL_S"
|
||||||
|
else
|
||||||
|
delay="$(retry_delay "$failures")"
|
||||||
|
[ "$failures" -lt 4 ] && failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
now="$(now_epoch)"
|
||||||
|
next_full=$((now + delay))
|
||||||
|
# Full sync already handled the recommended payload.
|
||||||
|
next_fast=$((now + RECOMMENDED_POLL_INTERVAL_S))
|
||||||
|
elif [ "$now" -ge "$next_fast" ]; then
|
||||||
|
if recommended_fast_sync; then
|
||||||
|
fast_failures=0; fast_delay="$RECOMMENDED_POLL_INTERVAL_S"
|
||||||
|
else
|
||||||
|
# Network/Hub failures must not turn into a tight retry loop.
|
||||||
|
case "$fast_failures" in 0) fast_delay=60 ;; 1) fast_delay=120 ;; *) fast_delay="$RECOMMENDED_POLL_MAX_RETRY_S" ;; esac
|
||||||
|
[ "$fast_failures" -lt 2 ] && fast_failures=$((fast_failures + 1))
|
||||||
|
log_message "recommended rules quick sync failed; retry in ${fast_delay}s"
|
||||||
|
fi
|
||||||
|
now="$(now_epoch)"; next_fast=$((now + fast_delay))
|
||||||
|
fi
|
||||||
|
now="$(now_epoch)"
|
||||||
|
if [ "$next_fast" -lt "$next_full" ]; then delay=$((next_fast - now)); else delay=$((next_full - now)); fi
|
||||||
|
[ "$delay" -ge 1 ] || delay=1
|
||||||
|
wait_for "$delay" || break
|
||||||
|
done
|
||||||
|
}
|
||||||
|
# Runtime diagnostics use an optional second `--debug` argument, except for
|
||||||
|
# recommended-revoke, which takes a required numeric rule ID in that position.
|
||||||
|
# Validate that ID here so the command reaches recommended_revoke(), where it
|
||||||
|
# is checked again before any authenticated Hub request.
|
||||||
|
if [ "${1:-daemon}" = recommended-revoke ]; then
|
||||||
|
case "${2:-}" in ''|0|*[!0-9]*) echo 'invalid rule ID' >&2; exit 2 ;; esac
|
||||||
|
[ "${#2}" -le 10 ] && [ "$#" -eq 2 ] || { echo 'invalid rule ID' >&2; exit 2; }
|
||||||
|
else
|
||||||
|
ssh_runtime_debug_arg "${1:-daemon}" "${2:-}" || { echo 'invalid argument' >&2; exit 2; }
|
||||||
|
fi
|
||||||
case "${1:-daemon}" in
|
case "${1:-daemon}" in
|
||||||
prepare-credential) prepare_credential ;;
|
prepare-credential) prepare_credential ;;
|
||||||
bootstrap) bootstrap ;;
|
bootstrap) bootstrap ;;
|
||||||
status-sync|run-once) ssh_runtime_execute status-sync status_sync ;;
|
status-sync|run-once) ssh_runtime_execute status-sync status_sync ;;
|
||||||
|
recommended-sync) recommended_fast_sync ;;
|
||||||
|
recommended-revoke) recommended_revoke "${2:-}" ;;
|
||||||
pairing-session) pairing_session ;;
|
pairing-session) pairing_session ;;
|
||||||
status) [ -s "$STATE_FILE" ] && cat "$STATE_FILE" || printf '{}\n' ;;
|
status) [ -s "$STATE_FILE" ] && cat "$STATE_FILE" || printf '{}\n' ;;
|
||||||
runtime-status) ssh_runtime_status ;;
|
runtime-status) ssh_runtime_status ;;
|
||||||
daemon) daemon ;;
|
daemon) daemon ;;
|
||||||
*) echo "usage: $0 {prepare-credential|daemon|bootstrap|status-sync|run-once|pairing-session|status|runtime-status} [--debug]" >&2; exit 2 ;;
|
*) echo "usage: $0 {prepare-credential|daemon|bootstrap|status-sync|recommended-sync|recommended-revoke RULE_ID|run-once|pairing-session|status|runtime-status} [--debug]" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
@@ -11,13 +11,14 @@ JSONFILTER="${SMARTSAFEHUB_DEVICE_JSONFILTER_BIN:-jsonfilter}"
|
|||||||
UBUS="${SMARTSAFEHUB_DEVICE_UBUS_BIN:-ubus}"
|
UBUS="${SMARTSAFEHUB_DEVICE_UBUS_BIN:-ubus}"
|
||||||
RUNTIME="${SMARTSAFEHUB_DEVICE_RUNTIME_DIR:-/tmp/smartsafehub}"
|
RUNTIME="${SMARTSAFEHUB_DEVICE_RUNTIME_DIR:-/tmp/smartsafehub}"
|
||||||
RECEIPT="${SMARTSAFEHUB_RECOMMENDED_RECEIPT_FILE:-${RUNTIME}/recommended-rules.receipt.json}"
|
RECEIPT="${SMARTSAFEHUB_RECOMMENDED_RECEIPT_FILE:-${RUNTIME}/recommended-rules.receipt.json}"
|
||||||
|
CATALOG="${SMARTSAFEHUB_RECOMMENDED_CATALOG_FILE:-${RUNTIME}/recommended-rules.catalog.json}"
|
||||||
mkdir -p "$RUNTIME" "${STATE%/*}"
|
mkdir -p "$RUNTIME" "${STATE%/*}"
|
||||||
LOCK="$RUNTIME/recommended-rules.lock"
|
LOCK="$RUNTIME/recommended-rules.lock"
|
||||||
if ! mkdir "$LOCK" 2>/dev/null; then
|
if ! mkdir "$LOCK" 2>/dev/null; then
|
||||||
# A crashed reconciler may have left its lock; a live one must win.
|
# A crashed reconciler may have left its lock; a live one must win.
|
||||||
owner="$(cat "$LOCK/pid" 2>/dev/null || true)"
|
owner="$(cat "$LOCK/pid" 2>/dev/null || true)"
|
||||||
case "$owner" in ''|*[!0-9]*) exit 1 ;; esac
|
case "$owner" in ''|*[!0-9]*) exit 1 ;; esac
|
||||||
kill -0 "$owner" 2>/dev/null && exit 1
|
kill -0 "$owner" 2>/dev/null && exit 75
|
||||||
rm -f "$LOCK/pid"
|
rm -f "$LOCK/pid"
|
||||||
rmdir "$LOCK" 2>/dev/null || exit 1
|
rmdir "$LOCK" 2>/dev/null || exit 1
|
||||||
mkdir "$LOCK" 2>/dev/null || exit 1
|
mkdir "$LOCK" 2>/dev/null || exit 1
|
||||||
@@ -35,6 +36,18 @@ trap 'exit 1' HUP INT TERM
|
|||||||
|
|
||||||
field() { "$JSONFILTER" -i "$1" -e "$2" 2>/dev/null || true; }
|
field() { "$JSONFILTER" -i "$1" -e "$2" 2>/dev/null || true; }
|
||||||
valid_hash() { [ "${#1}" -eq 64 ] && ! printf '%s' "$1" | LC_ALL=C grep -q '[^0-9a-f]'; }
|
valid_hash() { [ "${#1}" -eq 64 ] && ! printf '%s' "$1" | LC_ALL=C grep -q '[^0-9a-f]'; }
|
||||||
|
# jsonfilter returns 1 when [*] matches no elements, even for a valid empty
|
||||||
|
# array. Verify the array itself before treating that specific result as empty.
|
||||||
|
extract_array() {
|
||||||
|
local file="$1" path="$2" output="$3" status
|
||||||
|
[ "$("$JSONFILTER" -i "$file" -t "$path" 2>/dev/null)" = array ] || return 1
|
||||||
|
if "$JSONFILTER" -i "$file" -e "${path}[*]" > "$output"; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
status=$?
|
||||||
|
[ "$status" -eq 1 ] && [ ! -s "$output" ]
|
||||||
|
fi
|
||||||
|
}
|
||||||
valid_domain() {
|
valid_domain() {
|
||||||
[ -n "$1" ] && [ "${#1}" -le 253 ] || return 1
|
[ -n "$1" ] && [ "${#1}" -le 253 ] || return 1
|
||||||
printf '%s\n' "$1" | LC_ALL=C grep -Eq '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$' || return 1
|
printf '%s\n' "$1" | LC_ALL=C grep -Eq '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$' || return 1
|
||||||
@@ -50,10 +63,10 @@ valid_domain() {
|
|||||||
revision="$(field "$RESPONSE" '@.recommended_rules.revision')"
|
revision="$(field "$RESPONSE" '@.recommended_rules.revision')"
|
||||||
# Do not erase cloud-managed rules if the server is not yet sending this feature.
|
# Do not erase cloud-managed rules if the server is not yet sending this feature.
|
||||||
[ -n "$revision" ] || exit 0
|
[ -n "$revision" ] || exit 0
|
||||||
# Never resend a stale confirmation if reconciliation fails midway.
|
# A prior receipt is trustworthy for its own revision until a new reconciliation
|
||||||
rm -f "$RECEIPT"
|
# changes the allow list. The RPC compares the receipt's revision to STATE.
|
||||||
valid_hash "$revision" || exit 1
|
valid_hash "$revision" || exit 1
|
||||||
"$JSONFILTER" -i "$RESPONSE" -e '@.recommended_rules.domains[*]' > "$DESIRED" || exit 1
|
extract_array "$RESPONSE" '@.recommended_rules.domains' "$DESIRED" || exit 1
|
||||||
[ "$(wc -l < "$DESIRED")" -le 100 ] || exit 1
|
[ "$(wc -l < "$DESIRED")" -le 100 ] || exit 1
|
||||||
while IFS= read -r domain || [ -n "$domain" ]; do valid_domain "$domain" || exit 1; done < "$DESIRED"
|
while IFS= read -r domain || [ -n "$domain" ]; do valid_domain "$domain" || exit 1; done < "$DESIRED"
|
||||||
sort -u "$DESIRED" -o "$DESIRED"
|
sort -u "$DESIRED" -o "$DESIRED"
|
||||||
@@ -75,14 +88,68 @@ persist() {
|
|||||||
mv -f "$temp" "$STATE"
|
mv -f "$temp" "$STATE"
|
||||||
}
|
}
|
||||||
|
|
||||||
"$UBUS" call safeshield rules_list '{}' > "$REPLY" || exit 1
|
# Report progress from the worker itself. The parent daemon only commits a
|
||||||
|
# final result; a pending phase survives SafeShield refresh deferral.
|
||||||
|
phase() {
|
||||||
|
stage="$1"
|
||||||
|
tmp="$RUNTIME/recommended-phase.$$.json"
|
||||||
|
printf '{"status":"pending","stage":"%s","reason":"","checkedAt":%s}\n' \
|
||||||
|
"$stage" "$(date +%s)" > "$tmp"
|
||||||
|
mv -f "$tmp" "${SMARTSAFEHUB_RECOMMENDED_SYNC_STATUS_FILE:-$RUNTIME/recommended-rules.sync.json}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only status=running denotes an actual protection-list refresh. The refreshd
|
||||||
|
# service can be running permanently, so never use runtime.refreshd_running.
|
||||||
|
safeshield_refreshing() {
|
||||||
|
status_file="$RUNTIME/recommended-safeshield-status.$$.json"
|
||||||
|
if "$UBUS" call safeshield status '{}' > "$status_file" 2>/dev/null; then
|
||||||
|
status_value="$(field "$status_file" '@.status')"
|
||||||
|
else
|
||||||
|
status_value=''
|
||||||
|
fi
|
||||||
|
rm -f "$status_file"
|
||||||
|
[ "$status_value" = running ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Defer a different desired revision before calling potentially busy SafeShield
|
||||||
|
# rules_list, which can itself be delayed during a long refresh.
|
||||||
|
if [ "$previous" != "$revision" ] && safeshield_refreshing; then
|
||||||
|
phase safeshield_refreshing
|
||||||
|
exit 75
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No changes have been made yet, but this attempt needs a fresh verification.
|
||||||
|
# Keep the old receipt only when deferring before this point.
|
||||||
|
rm -f "$RECEIPT"
|
||||||
|
if ! "$UBUS" call safeshield rules_list '{}' > "$REPLY"; then
|
||||||
|
if safeshield_refreshing; then phase safeshield_refreshing; exit 75; fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
[ "$(field "$REPLY" '@.ok')" != false ] || exit 1
|
[ "$(field "$REPLY" '@.ok')" != false ] || exit 1
|
||||||
# A broken or truncated rules_list must never be mistaken for an empty list.
|
# A broken or truncated rules_list must never be mistaken for an empty list.
|
||||||
LC_ALL=C grep -Eq '"allow"[[:space:]]*:[[:space:]]*\[' "$REPLY" || exit 1
|
LC_ALL=C grep -Eq '"allow"[[:space:]]*:[[:space:]]*\[' "$REPLY" || exit 1
|
||||||
"$JSONFILTER" -i "$REPLY" -e '@.allow[*]' > "$ALLOW" || exit 1
|
extract_array "$REPLY" '@.allow' "$ALLOW" || exit 1
|
||||||
while IFS= read -r domain || [ -n "$domain" ]; do valid_domain "$domain" || exit 1; done < "$ALLOW"
|
while IFS= read -r domain || [ -n "$domain" ]; do valid_domain "$domain" || exit 1; done < "$ALLOW"
|
||||||
sort -u "$ALLOW" -o "$ALLOW"
|
sort -u "$ALLOW" -o "$ALLOW"
|
||||||
|
|
||||||
|
# Do not mutate overrides while a full protection refresh is already running.
|
||||||
|
# Retain ownership and let the 30-second poll retry when SafeShield settles.
|
||||||
|
changes_needed=0
|
||||||
|
[ "$previous" = '-' ] && changes_needed=1
|
||||||
|
while IFS= read -r domain; do
|
||||||
|
[ -n "$domain" ] || continue
|
||||||
|
if ! grep -Fxq "$domain" "$ALLOW"; then changes_needed=1; fi
|
||||||
|
done < "$DESIRED"
|
||||||
|
while IFS= read -r domain; do
|
||||||
|
[ -n "$domain" ] || continue
|
||||||
|
if ! grep -Fxq "$domain" "$DESIRED" && grep -Fxq "$domain" "$ALLOW"; then changes_needed=1; fi
|
||||||
|
done < "$OWNED"
|
||||||
|
if [ "$changes_needed" -eq 1 ] && safeshield_refreshing; then
|
||||||
|
phase safeshield_refreshing
|
||||||
|
exit 75
|
||||||
|
fi
|
||||||
|
if [ "$changes_needed" -eq 1 ]; then phase applying; fi
|
||||||
|
|
||||||
mutate() {
|
mutate() {
|
||||||
method="$1"; domain="$2"
|
method="$1"; domain="$2"
|
||||||
"$UBUS" call safeshield "$method" "{\"action\":\"allow\",\"domain\":\"$domain\",\"refresh\":false}" > "$REPLY" || return 1
|
"$UBUS" call safeshield "$method" "{\"action\":\"allow\",\"domain\":\"$domain\",\"refresh\":false}" > "$REPLY" || return 1
|
||||||
@@ -100,8 +167,9 @@ while IFS= read -r domain; do
|
|||||||
mutate rule_delete "$domain" || exit 1
|
mutate rule_delete "$domain" || exit 1
|
||||||
need_refresh=1
|
need_refresh=1
|
||||||
fi
|
fi
|
||||||
grep -Fxv "$domain" "$OWNED" > "$REPLY" || true
|
# Keep the ownership claim until the final SafeShield read confirms the
|
||||||
mv "$REPLY" "$OWNED"
|
# domain is gone. A refresh failure must not turn a leftover cloud entry
|
||||||
|
# into an apparently user-created allow rule on the next assignment.
|
||||||
persist '-'
|
persist '-'
|
||||||
fi
|
fi
|
||||||
done < "$SNAPSHOT"
|
done < "$SNAPSHOT"
|
||||||
@@ -109,31 +177,65 @@ done < "$SNAPSHOT"
|
|||||||
while IFS= read -r domain; do
|
while IFS= read -r domain; do
|
||||||
[ -n "$domain" ] || continue
|
[ -n "$domain" ] || continue
|
||||||
if ! grep -Fxq "$domain" "$ALLOW"; then
|
if ! grep -Fxq "$domain" "$ALLOW"; then
|
||||||
mutate rule_add "$domain" || exit 1
|
# Write the ownership intent before creating the allow entry. If the
|
||||||
need_refresh=1
|
# process is interrupted after rule_add, the retry must still recognize
|
||||||
|
# the entry as cloud-managed rather than silently treating it as manual.
|
||||||
if ! grep -Fxq "$domain" "$OWNED"; then
|
if ! grep -Fxq "$domain" "$OWNED"; then
|
||||||
printf '%s\n' "$domain" >> "$OWNED"
|
printf '%s\n' "$domain" >> "$OWNED"
|
||||||
|
persist '-'
|
||||||
fi
|
fi
|
||||||
|
mutate rule_add "$domain" || exit 1
|
||||||
|
need_refresh=1
|
||||||
persist '-'
|
persist '-'
|
||||||
fi
|
fi
|
||||||
done < "$DESIRED"
|
done < "$DESIRED"
|
||||||
|
|
||||||
if [ "$need_refresh" -eq 1 ]; then
|
if [ "$need_refresh" -eq 1 ]; then
|
||||||
"$UBUS" call safeshield refresh '{}' > "$REPLY" || exit 1
|
phase safeshield_refreshing
|
||||||
[ "$(field "$REPLY" '@.ok')" != false ] || exit 1
|
if ! "$UBUS" call safeshield refresh '{}' > "$REPLY"; then
|
||||||
[ "$(field "$REPLY" '@.accepted')" != false ] || exit 1
|
if safeshield_refreshing; then phase safeshield_refreshing; exit 75; fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(field "$REPLY" '@.ok')" = false ] || [ "$(field "$REPLY" '@.accepted')" = false ]; then
|
||||||
|
if safeshield_refreshing; then phase safeshield_refreshing; exit 75; fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
# Read the actual SafeShield rule list again rather than trusting rule_add results.
|
# Read the actual SafeShield rule list again rather than trusting rule_add results.
|
||||||
"$UBUS" call safeshield rules_list '{}' > "$REPLY" || exit 1
|
if [ "$changes_needed" -eq 1 ] || [ "$previous" != "$revision" ]; then phase verifying; fi
|
||||||
|
if ! "$UBUS" call safeshield rules_list '{}' > "$REPLY"; then
|
||||||
|
if safeshield_refreshing; then phase safeshield_refreshing; exit 75; fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
[ "$(field "$REPLY" '@.ok')" != false ] || exit 1
|
[ "$(field "$REPLY" '@.ok')" != false ] || exit 1
|
||||||
LC_ALL=C grep -Eq '"allow"[[:space:]]*:[[:space:]]*\[' "$REPLY" || exit 1
|
LC_ALL=C grep -Eq '"allow"[[:space:]]*:[[:space:]]*\[' "$REPLY" || exit 1
|
||||||
"$JSONFILTER" -i "$REPLY" -e '@.allow[*]' > "$VERIFY" || exit 1
|
extract_array "$REPLY" '@.allow' "$VERIFY" || exit 1
|
||||||
while IFS= read -r domain || [ -n "$domain" ]; do valid_domain "$domain" || exit 1; done < "$VERIFY"
|
while IFS= read -r domain || [ -n "$domain" ]; do valid_domain "$domain" || exit 1; done < "$VERIFY"
|
||||||
sort -u "$VERIFY" -o "$VERIFY"
|
sort -u "$VERIFY" -o "$VERIFY"
|
||||||
while IFS= read -r domain; do
|
while IFS= read -r domain; do
|
||||||
[ -n "$domain" ] || continue
|
[ -n "$domain" ] || continue
|
||||||
grep -Fxq "$domain" "$VERIFY" || exit 1
|
grep -Fxq "$domain" "$VERIFY" || exit 1
|
||||||
done < "$DESIRED"
|
done < "$DESIRED"
|
||||||
|
# Do not retire ownership if a previously managed, now-unassigned domain is
|
||||||
|
# still present in SafeShield. It may reappear during a failed refresh or an
|
||||||
|
# overlapping request, and a subsequent recommendation must not adopt it as a
|
||||||
|
# manual rule. Retry the deletion while the ownership marker remains intact.
|
||||||
|
while IFS= read -r domain; do
|
||||||
|
[ -n "$domain" ] || continue
|
||||||
|
if ! grep -Fxq "$domain" "$DESIRED" && grep -Fxq "$domain" "$VERIFY"; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done < "$OWNED"
|
||||||
|
# Only after successful reconciliation may we forget ownership of removed
|
||||||
|
# domains. The preserved marker survives a failed refresh and a reassignment.
|
||||||
|
cp "$OWNED" "$SNAPSHOT"
|
||||||
|
: > "$OWNED"
|
||||||
|
while IFS= read -r domain; do
|
||||||
|
[ -n "$domain" ] || continue
|
||||||
|
if grep -Fxq "$domain" "$DESIRED"; then
|
||||||
|
printf '%s\n' "$domain" >> "$OWNED"
|
||||||
|
fi
|
||||||
|
done < "$SNAPSHOT"
|
||||||
# The receipt describes *allow-list presence*, not proven DNS query results.
|
# The receipt describes *allow-list presence*, not proven DNS query results.
|
||||||
# Domains not in OWNED existed before this recommendation (manual configuration).
|
# Domains not in OWNED existed before this recommendation (manual configuration).
|
||||||
receipt_tmp="${RECEIPT}.tmp.$$"
|
receipt_tmp="${RECEIPT}.tmp.$$"
|
||||||
@@ -152,3 +254,20 @@ receipt_tmp="${RECEIPT}.tmp.$$"
|
|||||||
chmod 600 "$receipt_tmp"
|
chmod 600 "$receipt_tmp"
|
||||||
persist "$revision"
|
persist "$revision"
|
||||||
mv -f "$receipt_tmp" "$RECEIPT"
|
mv -f "$receipt_tmp" "$RECEIPT"
|
||||||
|
|
||||||
|
# Keep rule IDs and titles on tmpfs only. If the server still uses the old
|
||||||
|
# domain-only contract, never keep metadata from a previous assignment.
|
||||||
|
catalog_tmp="${CATALOG}.tmp.$$"
|
||||||
|
rules_revision="$(field "$RESPONSE" '@.recommended_rules.rules_revision')"
|
||||||
|
if valid_hash "$rules_revision" && [ "$("$JSONFILTER" -i "$RESPONSE" -t '@.recommended_rules.rules' 2>/dev/null)" = array ]; then
|
||||||
|
if "$JSONFILTER" -i "$RESPONSE" -e '@.recommended_rules' > "$catalog_tmp" \
|
||||||
|
&& [ "$("$JSONFILTER" -i "$catalog_tmp" -e '@.revision' 2>/dev/null)" = "$revision" ] \
|
||||||
|
&& [ "$("$JSONFILTER" -i "$catalog_tmp" -t '@.rules' 2>/dev/null)" = array ]; then
|
||||||
|
chmod 600 "$catalog_tmp"
|
||||||
|
mv -f "$catalog_tmp" "$CATALOG"
|
||||||
|
else
|
||||||
|
rm -f "$catalog_tmp" "$CATALOG"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rm -f "$catalog_tmp" "$CATALOG"
|
||||||
|
fi
|
||||||
@@ -63,6 +63,8 @@
|
|||||||
"health_run",
|
"health_run",
|
||||||
"health_reporter_update",
|
"health_reporter_update",
|
||||||
"device_registration_refresh",
|
"device_registration_refresh",
|
||||||
|
"device_recommended_rules_refresh",
|
||||||
|
"device_recommended_rules_revoke",
|
||||||
"safeshield_set_enabled",
|
"safeshield_set_enabled",
|
||||||
"safeshield_refresh",
|
"safeshield_refresh",
|
||||||
"safeshield_statistics_update",
|
"safeshield_statistics_update",
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
read_device_registration_status,
|
read_device_registration_status,
|
||||||
read_recommended_rules_status,
|
read_recommended_rules_status,
|
||||||
|
refresh_recommended_rules_status,
|
||||||
|
revoke_recommended_rule,
|
||||||
refresh_device_registration_status,
|
refresh_device_registration_status,
|
||||||
refresh_device_pairing
|
refresh_device_pairing
|
||||||
} from './smartsafehub/device-registration.uc';
|
} from './smartsafehub/device-registration.uc';
|
||||||
@@ -324,6 +326,17 @@ const methods = {
|
|||||||
return read_recommended_rules_status(request);
|
return read_recommended_rules_status(request);
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
device_recommended_rules_revoke: {
|
||||||
|
args: { rule_id: 0 },
|
||||||
|
call: require_root_password(function(request) {
|
||||||
|
return revoke_recommended_rule(request);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
device_recommended_rules_refresh: {
|
||||||
|
call: require_root_password(function(request) {
|
||||||
|
return refresh_recommended_rules_status(request);
|
||||||
|
}),
|
||||||
|
},
|
||||||
device_registration_status: {
|
device_registration_status: {
|
||||||
call: require_root_password(function(request) {
|
call: require_root_password(function(request) {
|
||||||
return read_device_registration_status(request);
|
return read_device_registration_status(request);
|
||||||
|
|||||||
+69
-10
@@ -12,20 +12,79 @@ function read_state() {
|
|||||||
// Local state only: the hub owns the list of recommended rules, while
|
// Local state only: the hub owns the list of recommended rules, while
|
||||||
// the router exposes the domains actually owned by the reconciler.
|
// the router exposes the domains actually owned by the reconciler.
|
||||||
const RECOMMENDED_FILE = '/etc/smartsafehub/recommended-rules.state';
|
const RECOMMENDED_FILE = '/etc/smartsafehub/recommended-rules.state';
|
||||||
|
const RECOMMENDED_RECEIPT = '/tmp/smartsafehub/recommended-rules.receipt.json';
|
||||||
|
const RECOMMENDED_CATALOG = '/tmp/smartsafehub/recommended-rules.catalog.json';
|
||||||
|
const RECOMMENDED_SYNC_STATUS = '/tmp/smartsafehub/recommended-rules.sync.json';
|
||||||
|
function read_json_file(path) {
|
||||||
|
const raw = fs.readfile(path);
|
||||||
|
if (raw == null || length(raw) == 0) return null;
|
||||||
|
try { return json(raw); } catch (e) { return null; }
|
||||||
|
}
|
||||||
export function read_recommended_rules_status(request) {
|
export function read_recommended_rules_status(request) {
|
||||||
const raw = fs.readfile(RECOMMENDED_FILE);
|
const raw = fs.readfile(RECOMMENDED_FILE);
|
||||||
if (raw == null || length(raw) == 0) return success({ revision: null, domains: [] });
|
let revision = null;
|
||||||
const lines = split(raw, '\n');
|
|
||||||
const revision = lines[0];
|
|
||||||
if (revision != '-' && match(revision, /^[0-9a-f]{64}$/) == null) return failure('RECOMMENDED_STATE_INVALID', '추천 규칙 상태가 올바르지 않습니다.');
|
|
||||||
let domains = [];
|
let domains = [];
|
||||||
for (let i = 1; i < length(lines); i++) {
|
if (raw != null && length(raw) > 0) {
|
||||||
let domain = lines[i];
|
const lines = split(raw, '\n');
|
||||||
if (domain == '') continue;
|
const saved = lines[0];
|
||||||
if (match(domain, /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/) == null) return failure('RECOMMENDED_STATE_INVALID', '추천 규칙 상태가 올바르지 않습니다.');
|
if (saved != '-' && match(saved, /^[0-9a-f]{64}$/) == null) return failure('RECOMMENDED_STATE_INVALID', '추천 규칙 상태가 올바르지 않습니다.');
|
||||||
push(domains, domain);
|
revision = saved == '-' ? null : saved;
|
||||||
|
for (let i = 1; i < length(lines); i++) {
|
||||||
|
let domain = lines[i];
|
||||||
|
if (domain == '') continue;
|
||||||
|
if (match(domain, /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/) == null) return failure('RECOMMENDED_STATE_INVALID', '추천 규칙 상태가 올바르지 않습니다.');
|
||||||
|
push(domains, domain);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return success({ revision: revision == '-' ? null : revision, domains: domains });
|
const receipt = read_json_file(RECOMMENDED_RECEIPT);
|
||||||
|
const sync = read_json_file(RECOMMENDED_SYNC_STATUS);
|
||||||
|
const catalog = read_json_file(RECOMMENDED_CATALOG);
|
||||||
|
const rules = catalog?.revision == revision && type(catalog?.rules) == 'array' ? catalog.rules : [];
|
||||||
|
const device = read_state();
|
||||||
|
const confirmed = revision != null && receipt?.status == 'confirmed' && receipt?.revision == revision;
|
||||||
|
// The receipt records SafeShield's verified allow list. A transient Hub poll
|
||||||
|
// failure does not undo that local confirmation, nor does an unrelated 5-minute
|
||||||
|
// device/account sync failure. Failed reconciliation removes the receipt.
|
||||||
|
// A busy SafeShield refresh is pending work, not an application failure.
|
||||||
|
// Keep an older verified receipt for its revision without treating a newer
|
||||||
|
// in-progress operation as already complete.
|
||||||
|
const syncStatus = sync?.status == 'pending' ? 'pending' :
|
||||||
|
confirmed ? 'confirmed' : sync?.status == 'delayed' ? 'delayed' :
|
||||||
|
sync?.status == 'failed' ? 'failed' : 'pending';
|
||||||
|
const syncWarning = confirmed && (sync?.status == 'failed' || sync?.status == 'delayed');
|
||||||
|
const confirmedDomains = confirmed && type(receipt?.domains) == 'array' ? receipt.domains : [];
|
||||||
|
return success({
|
||||||
|
revision: revision,
|
||||||
|
domains: domains,
|
||||||
|
rules: rules,
|
||||||
|
confirmedDomains: confirmedDomains,
|
||||||
|
syncStatus: syncStatus,
|
||||||
|
syncWarning: syncWarning,
|
||||||
|
syncStage: sync?.stage ?? null,
|
||||||
|
syncReason: sync?.reason ?? null,
|
||||||
|
lastCheckedAt: sync?.checkedAt ?? 0,
|
||||||
|
lastDeviceSyncAt: device?.lastSuccessAt ?? 0,
|
||||||
|
nextDeviceSyncAt: device?.nextSyncAt ?? 0,
|
||||||
|
accountRegistered: device?.accountRegistered ?? false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export function refresh_recommended_rules_status(request) {
|
||||||
|
// The blocklist download and the Hub device sync are different operations.
|
||||||
|
// Reuse the authenticated device sync, never the SafeShield blocklist refresh.
|
||||||
|
if (!run_command([ HELPER, 'recommended-sync' ], 25000)) {
|
||||||
|
const latest = read_json_file(RECOMMENDED_SYNC_STATUS);
|
||||||
|
if (latest?.status != 'pending' && latest?.status != 'delayed')
|
||||||
|
return failure('RECOMMENDED_SYNC_UNAVAILABLE', 'SmartSafeHub 서버와 동기화하지 못했습니다. 잠시 후 다시 시도해 주세요.');
|
||||||
|
}
|
||||||
|
return read_recommended_rules_status(request);
|
||||||
|
};
|
||||||
|
export function revoke_recommended_rule(request) {
|
||||||
|
const rule_id = request?.args?.rule_id;
|
||||||
|
if (rule_id == null || match(sprintf('%s', rule_id), /^[1-9][0-9]{0,9}$/) == null)
|
||||||
|
return failure('INVALID_RULE_ID', '적용 해제할 추천 규칙을 선택해 주세요.');
|
||||||
|
if (!run_command([ HELPER, 'recommended-revoke', sprintf('%s', rule_id) ], 25000))
|
||||||
|
return failure('RECOMMENDED_REVOKE_FAILED', '규칙 해제 또는 SafeShield 반영에 실패했습니다. 서버 변경은 다음 동기화에서 반영될 수 있습니다.');
|
||||||
|
return read_recommended_rules_status(request);
|
||||||
};
|
};
|
||||||
export function read_device_registration_status(request) { const state = read_state(); return state != null ? success(state) : failure('DEVICE_STATE_INVALID', '기기 등록 상태를 읽지 못했습니다.'); };
|
export function read_device_registration_status(request) { const state = read_state(); return state != null ? success(state) : failure('DEVICE_STATE_INVALID', '기기 등록 상태를 읽지 못했습니다.'); };
|
||||||
export function refresh_device_registration_status(request) { if (!run_command([ HELPER, 'status-sync' ], 25000)) return failure('DEVICE_SYNC_FAILED', 'SmartSafeHub 계정 연결 상태를 확인하지 못했습니다.'); const state = read_state(); return state != null ? success(state) : failure('DEVICE_STATE_INVALID', '기기 등록 상태를 읽지 못했습니다.'); };
|
export function refresh_device_registration_status(request) { if (!run_command([ HELPER, 'status-sync' ], 25000)) return failure('DEVICE_SYNC_FAILED', 'SmartSafeHub 계정 연결 상태를 확인하지 못했습니다.'); const state = read_state(); return state != null ? success(state) : failure('DEVICE_STATE_INVALID', '기기 등록 상태를 읽지 못했습니다.'); };
|
||||||
|
|||||||
@@ -10,16 +10,16 @@
|
|||||||
<meta name="robots" content="noindex,nofollow,noarchive">
|
<meta name="robots" content="noindex,nofollow,noarchive">
|
||||||
<meta name="referrer" content="same-origin">
|
<meta name="referrer" content="same-origin">
|
||||||
<title>SmartSafeHub</title>
|
<title>SmartSafeHub</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/luci-static/smartsafehub/favicon.svg?v=0.2.29-r1">
|
<link rel="icon" type="image/svg+xml" href="/luci-static/smartsafehub/favicon.svg?v=0.2.30-r1">
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;background:#f8fafc;min-height:100vh;">
|
<body style="margin:0;background:#f8fafc;min-height:100vh;">
|
||||||
<div
|
<div
|
||||||
id="smartsafehub-entry-root"
|
id="smartsafehub-entry-root"
|
||||||
data-asset-base="/luci-static/smartsafehub/"
|
data-asset-base="/luci-static/smartsafehub/"
|
||||||
data-asset-version="0.2.29-r1"
|
data-asset-version="0.2.30-r1"
|
||||||
style="display:block;width:100%;min-height:100vh;"
|
style="display:block;width:100%;min-height:100vh;"
|
||||||
></div>
|
></div>
|
||||||
<noscript>SmartSafeHub를 사용하려면 JavaScript를 활성화해 주세요.</noscript>
|
<noscript>SmartSafeHub를 사용하려면 JavaScript를 활성화해 주세요.</noscript>
|
||||||
<script type="module" src="/luci-static/smartsafehub/app.js?v=0.2.29-r1"></script>
|
<script type="module" src="/luci-static/smartsafehub/app.js?v=0.2.30-r1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
||||||
@layer properties{:host,*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-outline-style:solid;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:Line truncated
|
@layer properties{:host,*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-outline-style:solid;--tw-ease:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:Line truncated
|
||||||
/*$vite$:1*/
|
/*$vite$:1*/
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
var e,t,n,r,i,a,o,s,c,l,u={},d=[],f=/^m(i|n|o|s|text|space)$/,p=Array.isArray,m=d.slice,h=Object.assign;function g(e){e&&e.parentNode&&e.remove()}function _(n,r,i,a,o){var s={type:n,props:r,key:i,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o||++t,__i:-1,__u:0};return!o&&e.vnode&&e.vnode(s),s}function v(e){return e.children}function y(e,t){this.props=e,this.context=t,this.__g=0}function b(e,t){if(t==null)return e.__?b(e.__,e.__i+1):null;for(var n;t<e.__k.length;t++)if((n=e.__k[t])&&n.__e)return n.__e;return typeof e.type!=`function`||e.props.__P?null:b(e)}function x(e){if((e=e.__)&&e.__c&&!e.props.__P)return e.__e=null,e.__k.some(function(t){return t&&(e.__e=t.__e)}),x(e)}function S(t){(8&t.__g||!(t.__g|=8)||!n.push(t)||i++)&&r==e.debounceRendering||((r=e.debounceRendering)||queueMicrotask)(C)}function C(){var t,r,o,s,c,l,u,d,f;try{for(r=1;n.length;)n.length>r&&n.sort(a),t=n.shift(),r=n.length,8&t.__g&&(s=void 0,c=void 0,l=(c=(o=t).__v).__e,u=[],d=[],(f=o.__P)&&((s=h({constructor:void 0},c)).__v=c.__v+1,e.vnode&&e.vnode(s),ee(f,s,c,o.__n,f.namespaceURI,32&c.__u?[l]:null,u,l||b(c),32&c.__u,d),s.__v=c.__v,s.__.__k[s.__i]=s,ne(u,s,d),c.__=c.__e=null,s.__e!=l&&x(s)))}finally{n.length=i=0}}function w(e,t,n,r,i,a,o,s,c,l,f){var p,m,h,g,_,v,y=r.__k||d,b=t.length;for(c=T(n,t,y,c,b),p=0;p<b;p++)(h=n.__k[p])!=null&&(m=~h.__i&&y[h.__i]||u,h.__i=p,v=ee(e,h,m,i,a,o,s,c,l,f),g=h.__e,(m.ref!=h.ref||8&m.__u)&&(m.ref&&ie(m.ref,null,h,m),h.ref&&f.push(h.ref,h.__c||g,h)),_=_||g,4&h.__u?(c=E(h,c,e,!m.__v),m.__e&&(m.__e=null)):typeof h.type==`function`&&v!==void 0?c=v:g&&(c=g.nextSibling),h.__u&=-7);return n.__e=_,c}function T(e,t,n,r,i){var a,o,s,c,l,u,d,f,m,h,g=n.length,y=g,x=0,S=!1,C=e.__k=Array(i);for(a=0;a<i;a++)(o=t[a])!=null&&typeof o!=`boolean`&&typeof o!=`function`?(typeof o!=`object`||o.constructor==String?o=C[a]=_(null,o):p(o)?o=C[a]=_(v,{children:o}):o.constructor===void 0&&o.__b?o=C[a]=_(o.type,o.props,o.key,o.ref,o.__v):C[a]=o,c=a+x,o.__=e,o.__b=e.__b+1,s=null,~(l=o.__i=O(o,n,c,y))&&(y--,(s=n[l])&&(s.__u|=2)),s&&s.__v?(o.__u|=2,l==c-1?x--:l==c+1?x++:l!=c&&(l>c?x--:x++,S=!0)):(~l||(i>g?x--:i<g&&x++),typeof o.type!=`function`&&(o.__u|=4))):C[a]=null;if(S){for(u=[],d=[],a=0;a<i;a++)if((o=C[a])&&2&o.__u){for(f=0,m=u.length;f<m;)u[h=f+m>>1]<o.__i?f=h+1:m=h;u[f]=o.__i,d[a]=f+1}for(x=u.length;a--;)d[a]&&(d[a]==x?x--:C[a].__u|=4)}if(y)for(a=0;a<g;a++)!(s=n[a])||2&s.__u||(s.__e==r&&(r=b(s)),ae(s,s));return r}function E(e,t,n,r){var i,a,o;if(typeof e.type==`function`){if(e.props.__P)return t;if(i=e.__k)for(a=0;a<i.length;a++)i[a]&&(i[a].__=e,t=E(i[a],t,n,!1));return t}for(t&&!t.parentNode&&(t=b(e))&&!t.parentNode&&(t=null),o=t;o&&o.nodeType==8;)o=o.nextSibling;for(e.__e!=o&&(!r&&n.moveBefore&&e.__e.parentNode?n.moveBefore(e.__e,t):n.insertBefore(e.__e,t||null)),t=e.__e;(t=t&&t.nextSibling)&&t.nodeType==8;);return t}function D(e,t){return t=t||[],e!=null&&typeof e!=`boolean`&&(p(e)?e.some(function(e){D(e,t)}):t.push(e)),t}function O(e,t,n,r){var i,a,o,s=e.key,c=e.type,l=t[n],u=l&&!(2&l.__u);if(l===null&&s==null||u&&s==l.key&&c==l.type)return n;if(r>+!!u){for(i=n-1,a=n+1;i>=0||a<t.length;)if((l=t[o=i>=0?i--:a++])&&!(2&l.__u)&&s==l.key&&c==l.type)return o}return-1}function k(e,t,n){n??(n=``),t[0]==`-`?e.setProperty(t,n):e[t]=n}function A(e,t,n,r,i){var a,o;n:if(t==`style`){if(typeof n==`string`)e.style.cssText=n;else{if(typeof r==`string`&&(e.style.cssText=r=``),r)for(t in r)n&&t in n||k(e.style,t,``);if(n)for(t in n)r&&n[t]==r[t]||k(e.style,t,n[t])}}else if(t[0]==`o`&&t[1]==`n`)(e.__e||(e.__e={}))[t]=n,n&&r||(o=l[t]||(l[t]=j(t)),(e.__a||(e.__a={}))[t]=c,a=t!=(t=t.replace(s,`$1`)),(t=t.slice(2))[0]<`a`&&(t=t.toLowerCase()),n?e.addEventListener(t,o,a):e.removeEventListener(t,o,a));else{if(i==`http://www.w3.org/2000/svg`)t=t.replace(/xlink(H|:h)/,`h`).replace(/sName$/,`s`);else if(t!=`width`&&t!=`height`&&t!=`href`&&t!=`list`&&t!=`form`&&t!=`tabIndex`&&t!=`download`&&t!=`rowSpan`&&t!=`colSpan`&&t!=`role`&&t!=`popover`&&t in e)try{e[t]=n??``;break n}catch{}typeof n==`function`||(n==null||!1===n&&t[4]!=`-`?e.removeAttribute(t):e.setAttribute(t,t==`popover`&&n==1?``:n))}}function j(t){return function(n){if(this.__e){var r=this.__e[t];if(n[o]==null)n[o]=c++;else if(n[o]<this.__a[t])return;return r(e.event?e.event(n):n)}}}function ee(t,n,r,i,a,o,s,c,l,u){var f,m,_,x,S,C,T,E,D,O,k,A,j,ee,ne,ie,se,ce,le,ue,de=n.type;if(n.constructor!==void 0)return null;if(128&r.__u&&(l=32&r.__u,f=r.__c.__z)){if(n.__u|=l,m=o=[],f.nodeType==8)for(_=1,x=f.nextSibling;x;x=x.nextSibling){if(x.nodeType==8){if(x.data.startsWith(`$s`))_++;else if(x.data.startsWith(`/$s`)&&!--_)break}o.push(x)}else o.push(f);c=o[0]}(f=e.__b)&&f(n);n:if(typeof de==`function`){S=s.length;try{if(O=n.props,k=(f=de.prototype)&&f.render,A=(f=de.contextType)&&i[f.__c],j=f?A?A.props.value:f.__:i,r.__c?2&(C=n.__c=r.__c).__g&&(C.__g|=1):(k?n.__c=C=new de(O,j):(n.__c=C=new y(O,j),C.constructor=de,C.render=oe),A&&A.sub(C),C.state||(C.state={}),C.__n=i,C.__g|=8,C.Line truncated
|
var e,t,n,r,i,a,o,s,c,l,u={},d=[],f=/^m(i|n|o|s|text|space)$/,p=Array.isArray,m=d.slice,h=Object.assign;function g(e){e&&e.parentNode&&e.remove()}function _(n,r,i,a,o){var s={type:n,props:r,key:i,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o||++t,__i:-1,__u:0};return!o&&e.vnode&&e.vnode(s),s}function v(e){return e.children}function y(e,t){this.props=e,this.context=t,this.__g=0}function b(e,t){if(t==null)return e.__?b(e.__,e.__i+1):null;for(var n;t<e.__k.length;t++)if((n=e.__k[t])&&n.__e)return n.__e;return typeof e.type!=`function`||e.props.__P?null:b(e)}function x(e){if((e=e.__)&&e.__c&&!e.props.__P)return e.__e=null,e.__k.some(function(t){return t&&(e.__e=t.__e)}),x(e)}function S(t){(8&t.__g||!(t.__g|=8)||!n.push(t)||i++)&&r==e.debounceRendering||((r=e.debounceRendering)||queueMicrotask)(C)}function C(){var t,r,o,s,c,l,u,d,f;try{for(r=1;n.length;)n.length>r&&n.sort(a),t=n.shift(),r=n.length,8&t.__g&&(s=void 0,c=void 0,l=(c=(o=t).__v).__e,u=[],d=[],(f=o.__P)&&((s=h({constructor:void 0},c)).__v=c.__v+1,e.vnode&&e.vnode(s),ee(f,s,c,o.__n,f.namespaceURI,32&c.__u?[l]:null,u,l||b(c),32&c.__u,d),s.__v=c.__v,s.__.__k[s.__i]=s,ne(u,s,d),c.__=c.__e=null,s.__e!=l&&x(s)))}finally{n.length=i=0}}function w(e,t,n,r,i,a,o,s,c,l,f){var p,m,h,g,_,v,y=r.__k||d,b=t.length;for(c=T(n,t,y,c,b),p=0;p<b;p++)(h=n.__k[p])!=null&&(m=~h.__i&&y[h.__i]||u,h.__i=p,v=ee(e,h,m,i,a,o,s,c,l,f),g=h.__e,(m.ref!=h.ref||8&m.__u)&&(m.ref&&ie(m.ref,null,h,m),h.ref&&f.push(h.ref,h.__c||g,h)),_=_||g,4&h.__u?(c=E(h,c,e,!m.__v),m.__e&&(m.__e=null)):typeof h.type==`function`&&v!==void 0?c=v:g&&(c=g.nextSibling),h.__u&=-7);return n.__e=_,c}function T(e,t,n,r,i){var a,o,s,c,l,u,d,f,m,h,g=n.length,y=g,x=0,S=!1,C=e.__k=Array(i);for(a=0;a<i;a++)(o=t[a])!=null&&typeof o!=`boolean`&&typeof o!=`function`?(typeof o!=`object`||o.constructor==String?o=C[a]=_(null,o):p(o)?o=C[a]=_(v,{children:o}):o.constructor===void 0&&o.__b?o=C[a]=_(o.type,o.props,o.key,o.ref,o.__v):C[a]=o,c=a+x,o.__=e,o.__b=e.__b+1,s=null,~(l=o.__i=O(o,n,c,y))&&(y--,(s=n[l])&&(s.__u|=2)),s&&s.__v?(o.__u|=2,l==c-1?x--:l==c+1?x++:l!=c&&(l>c?x--:x++,S=!0)):(~l||(i>g?x--:i<g&&x++),typeof o.type!=`function`&&(o.__u|=4))):C[a]=null;if(S){for(u=[],d=[],a=0;a<i;a++)if((o=C[a])&&2&o.__u){for(f=0,m=u.length;f<m;)u[h=f+m>>1]<o.__i?f=h+1:m=h;u[f]=o.__i,d[a]=f+1}for(x=u.length;a--;)d[a]&&(d[a]==x?x--:C[a].__u|=4)}if(y)for(a=0;a<g;a++)!(s=n[a])||2&s.__u||(s.__e==r&&(r=b(s)),ae(s,s));return r}function E(e,t,n,r){var i,a,o;if(typeof e.type==`function`){if(e.props.__P)return t;if(i=e.__k)for(a=0;a<i.length;a++)i[a]&&(i[a].__=e,t=E(i[a],t,n,!1));return t}for(t&&!t.parentNode&&(t=b(e))&&!t.parentNode&&(t=null),o=t;o&&o.nodeType==8;)o=o.nextSibling;for(e.__e!=o&&(!r&&n.moveBefore&&e.__e.parentNode?n.moveBefore(e.__e,t):n.insertBefore(e.__e,t||null)),t=e.__e;(t=t&&t.nextSibling)&&t.nodeType==8;);return t}function D(e,t){return t=t||[],e!=null&&typeof e!=`boolean`&&(p(e)?e.some(function(e){D(e,t)}):t.push(e)),t}function O(e,t,n,r){var i,a,o,s=e.key,c=e.type,l=t[n],u=l&&!(2&l.__u);if(l===null&&s==null||u&&s==l.key&&c==l.type)return n;if(r>+!!u){for(i=n-1,a=n+1;i>=0||a<t.length;)if((l=t[o=i>=0?i--:a++])&&!(2&l.__u)&&s==l.key&&c==l.type)return o}return-1}function k(e,t,n){n??(n=``),t[0]==`-`?e.setProperty(t,n):e[t]=n}function A(e,t,n,r,i){var a,o;n:if(t==`style`){if(typeof n==`string`)e.style.cssText=n;else{if(typeof r==`string`&&(e.style.cssText=r=``),r)for(t in r)n&&t in n||k(e.style,t,``);if(n)for(t in n)r&&n[t]==r[t]||k(e.style,t,n[t])}}else if(t[0]==`o`&&t[1]==`n`)(e.__e||(e.__e={}))[t]=n,n&&r||(o=l[t]||(l[t]=j(t)),(e.__a||(e.__a={}))[t]=c,a=t!=(t=t.replace(s,`$1`)),(t=t.slice(2))[0]<`a`&&(t=t.toLowerCase()),n?e.addEventListener(t,o,a):e.removeEventListener(t,o,a));else{if(i==`http://www.w3.org/2000/svg`)t=t.replace(/xlink(H|:h)/,`h`).replace(/sName$/,`s`);else if(t!=`width`&&t!=`height`&&t!=`href`&&t!=`list`&&t!=`form`&&t!=`tabIndex`&&t!=`download`&&t!=`rowSpan`&&t!=`colSpan`&&t!=`role`&&t!=`popover`&&t in e)try{e[t]=n??``;break n}catch{}typeof n==`function`||(n==null||!1===n&&t[4]!=`-`?e.removeAttribute(t):e.setAttribute(t,t==`popover`&&n==1?``:n))}}function j(t){return function(n){if(this.__e){var r=this.__e[t];if(n[o]==null)n[o]=c++;else if(n[o]<this.__a[t])return;return r(e.event?e.event(n):n)}}}function ee(t,n,r,i,a,o,s,c,l,u){var f,m,_,x,S,C,T,E,D,O,k,A,j,ee,ne,ie,se,ce,le,ue,de=n.type;if(n.constructor!==void 0)return null;if(128&r.__u&&(l=32&r.__u,f=r.__c.__z)){if(n.__u|=l,m=o=[],f.nodeType==8)for(_=1,x=f.nextSibling;x;x=x.nextSibling){if(x.nodeType==8){if(x.data.startsWith(`$s`))_++;else if(x.data.startsWith(`/$s`)&&!--_)break}o.push(x)}else o.push(f);c=o[0]}(f=e.__b)&&f(n);n:if(typeof de==`function`){S=s.length;try{if(O=n.props,k=(f=de.prototype)&&f.render,A=(f=de.contextType)&&i[f.__c],j=f?A?A.props.value:f.__:i,r.__c?2&(C=n.__c=r.__c).__g&&(C.__g|=1):(k?n.__c=C=new de(O,j):(n.__c=C=new y(O,j),C.constructor=de,C.render=oe),A&&A.sub(C),C.state||(C.state={}),C.__n=i,C.__g|=8,C.Line truncated
|
||||||
`)>-1?e.split(`
|
`)>-1?e.split(`
|
||||||
`):e}function Im(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function Lm(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=kl(t.bodyFont),l=kl(t.titleFont),u=kl(t.footerFont),d=a.length,f=i.length,p=r.length,m=Ol(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,Z(e.title,y),n.font=c.string,Z(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,Z(r,e=>{Z(e.before,y),Z(e.lines,y),Z(e.after,y)}),v=0,n.font=u.string,Z(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function Rm(e,t){let{y:n,height:r}=t;return n<r/2?`top`:n>e.height-r/2?`bottom`:`center`}function zm(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function Bm(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),zm(l,e,t,n)&&(l=`center`),l}function Vm(e,t,n){let r=n.yAlign||t.yAlign||Rm(e,n);return{xAlign:n.xAlign||t.xAlign||Bm(e,t,n,r),yAlign:r}}function Hm(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function Um(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:r-=t===`bottom`?i+n:i/2,r}function Wm(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=Dl(o),m=Hm(t,s),h=Um(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:_c(m,0,r.width-t.width),y:_c(h,0,r.height-t.height)}}function Gm(e,t,n){let r=Ol(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function Km(e){return Pm([],Fm(e))}function qm(e,t,n){return Ml(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function Jm(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var Ym={beforeTitle:ws,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex<r)return n[t.dataIndex]}return``},afterTitle:ws,beforeBody:ws,beforeLabel:ws,label(e){if(this&&this.options&&this.options.mode===`dataset`)return e.label+`: `+e.formattedValue||e.formattedValue;let t=e.dataset.label||``;t&&(t+=`: `);let n=e.formattedValue;return J(n)||(t+=n),t},labelColor(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{borderColor:t.borderColor,backgroundColor:t.backgroundColor,borderWidth:t.borderWidth,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{pointStyle:t.pointStyle,rotation:t.rotation}},afterLabel:ws,afterBody:ws,beforeFooter:ws,footer:ws,afterFooter:ws};function Xm(e,t,n,r){let i=e[t].call(n,r);return i===void 0?Ym[t].call(n,r):i}var Zm=class extends Uf{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){let e=this._cachedAnimations;if(e)return e;let t=this.chart,n=this.options.setContext(this.getContext()),r=n.enabled&&t.options.animation&&n.animations,i=new Qu(this.chart,r);return r._cacheable&&(this._cachedAnimations=Object.freeze(i)),i}getContext(){return this.$context||(this.$context=qm(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,t){let{callbacks:n}=t,r=Xm(n,`beforeTitle`,this,e),i=Xm(n,`title`,this,e),a=Xm(n,`afterTitle`,this,e),o=[];return o=Pm(o,Fm(r)),o=Pm(o,Fm(i)),o=Pm(o,Fm(a)),o}getBeforeBody(e,t){return Km(Xm(t.callbacks,`beforeBody`,this,e))}getBody(e,t){let{callbacks:n}=t,r=[];return Z(e
Line truncated
|
`):e}function zm(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function Bm(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=Ml(t.bodyFont),l=Ml(t.titleFont),u=Ml(t.footerFont),d=a.length,f=i.length,p=r.length,m=jl(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,Q(e.title,y),n.font=c.string,Q(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,Q(r,e=>{Q(e.before,y),Q(e.lines,y),Q(e.after,y)}),v=0,n.font=u.string,Q(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function Vm(e,t){let{y:n,height:r}=t;return n<r/2?`top`:n>e.height-r/2?`bottom`:`center`}function Hm(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function Um(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),Hm(l,e,t,n)&&(l=`center`),l}function Wm(e,t,n){let r=n.yAlign||t.yAlign||Vm(e,n);return{xAlign:n.xAlign||t.xAlign||Um(e,t,n,r),yAlign:r}}function Gm(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function Km(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:r-=t===`bottom`?i+n:i/2,r}function qm(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=Al(o),m=Gm(t,s),h=Km(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:bc(m,0,r.width-t.width),y:bc(h,0,r.height-t.height)}}function Jm(e,t,n){let r=jl(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function Ym(e){return Lm([],Rm(e))}function Xm(e,t,n){return Fl(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function Zm(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var Qm={beforeTitle:Es,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex<r)return n[t.dataIndex]}return``},afterTitle:Es,beforeBody:Es,beforeLabel:Es,label(e){if(this&&this.options&&this.options.mode===`dataset`)return e.label+`: `+e.formattedValue||e.formattedValue;let t=e.dataset.label||``;t&&(t+=`: `);let n=e.formattedValue;return Y(n)||(t+=n),t},labelColor(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{borderColor:t.borderColor,backgroundColor:t.backgroundColor,borderWidth:t.borderWidth,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{pointStyle:t.pointStyle,rotation:t.rotation}},afterLabel:Es,afterBody:Es,beforeFooter:Es,footer:Es,afterFooter:Es};function $m(e,t,n,r){let i=e[t].call(n,r);return i===void 0?Qm[t].call(n,r):i}var eh=class extends Kf{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){let e=this._cachedAnimations;if(e)return e;let t=this.chart,n=this.options.setContext(this.getContext()),r=n.enabled&&t.options.animation&&n.animations,i=new td(this.chart,r);return r._cacheable&&(this._cachedAnimations=Object.freeze(i)),i}getContext(){return this.$context||(this.$context=Xm(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,t){let{callbacks:n}=t,r=$m(n,`beforeTitle`,this,e),i=$m(n,`title`,this,e),a=$m(n,`afterTitle`,this,e),o=[];return o=Lm(o,Rm(r)),o=Lm(o,Rm(i)),o=Lm(o,Rm(a)),o}getBeforeBody(e,t){return Ym($m(t.callbacks,`beforeBody`,this,e))}getBody(e,t){let{callbacks:n}=t,r=[];return Q(e
Line truncated
|
||||||
`),l]),i&&!t?z(vo,{}):n&&!t?z(yo,{message:n,onRetry:c}):t?z(v,{children:[z(`section`,{class:`rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5 sm:p-6`,children:z(`div`,{class:`flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between`,children:[z(`div`,{class:`flex min-w-0 items-start gap-3 sm:gap-4`,children:[z(`span`,{class:`grid size-12 shrink-0 place-items-center rounded-2xl bg-teal-50 text-teal-700`,children:z(tn,{class:`size-7`})}),z(`div`,{class:`min-w-0`,children:[z(`p`,{class:`m-0 text-xs font-extrabold uppercase tracking-[0.16em] text-teal-700`,children:`Local overrides`}),z(`h2`,{class:`mt-2 mb-0 break-words text-xl font-black tracking-tight text-slate-950 sm:text-2xl`,children:`사용자 도메인 규칙`}),z(`p`,{class:`mt-2 mb-0 max-w-3xl text-sm leading-6 text-slate-600`,children:`허용 목록은 오탐을 해제하고, 차단 목록은 원하는 도메인을 SafeShield 보호 목록에 추가합니다.`})]})]}),z(`dl`,{class:`m-0 grid grid-cols-3 gap-2 sm:min-w-80`,children:[z(`div`,{class:`rounded-xl bg-emerald-50 px-3 py-3 text-center`,children:[z(`dt`,{class:`text-[0.68rem] font-extrabold uppercase tracking-wide text-emerald-700`,children:`허용`}),z(`dd`,{class:`mt-1 mb-0 ml-0 text-lg font-black text-emerald-950`,children:t.counts.allow})]}),z(`div`,{class:`rounded-xl bg-red-50 px-3 py-3 text-center`,children:[z(`dt`,{class:`text-[0.68rem] font-extrabold uppercase tracking-wide text-red-700`,children:`차단`}),z(`dd`,{class:`mt-1 mb-0 ml-0 text-lg font-black text-red-950`,children:t.counts.block})]}),z(`div`,{class:`rounded-xl bg-slate-100 px-3 py-3 text-center`,children:[z(`dt`,{class:`text-[0.68rem] font-extrabold uppercase tracking-wide text-slate-600`,children:`전체`}),z(`dd`,{class:`mt-1 mb-0 ml-0 text-lg font-black text-slate-950`,children:t.counts.total})]})]})]})}),z(`section`,{class:`mt-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm shadow-slate-900/5 sm:p-5`,"aria-label":`추천 사용자 규칙`,children:z(`div`,{class:`flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center`,children:[z(`div`,{class:`min-w-0`,children:[z(`div`,{class:`flex flex-wrap items-center gap-2`,children:[z(`span`,{class:`grid size-8 place-items-center rounded-lg bg-teal-50 text-teal-700`,children:z(H,{class:`size-4`})}),z(`h2`,{class:`m-0 text-base font-extrabold text-slate-950`,children:`추천 사용자 규칙`}),f&&u.length>0?z(`span`,{class:`rounded-full bg-teal-50 px-2.5 py-1 text-xs font-bold text-teal-800`,children:[`추천으로 추가 `,u.length,`개`]}):null]}),z(`div`,{class:`mt-2 flex flex-wrap items-center gap-2`,"aria-label":`추천 규칙 분류`,children:[z(`span`,{class:`inline-flex items-center rounded-full bg-teal-50 px-2.5 py-1 text-xs font-bold text-teal-800`,children:`공식 추천`}),z(`span`,{class:`inline-flex items-center rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-700`,children:`커뮤니티`})]}),z(`p`,{class:`mt-2 mb-0 text-sm leading-6 text-slate-600`,children:`SmartSafeHub의 공식 추천과 관리자가 검토한 커뮤니티 허용 규칙을 확인하고, 필요한 규칙을 적용해 보세요.`}),z(`p`,{class:`mt-1 mb-0 text-xs leading-5 text-slate-500`,children:`공유기 선택과 적용 관리는 SmartSafeHub 웹사이트에서 진행합니다. 적용 결과는 다음 기기 동기화 후 반영되며, 연결된 모든 기기에 영향을 줍니다.`})]}),z(`a`,{class:`inline-flex min-h-10 w-full shrink-0 items-center justify-center rounded-xl border border-teal-700 bg-teal-700 px-4 py-2.5 text-sm font-extrabold text-white transition hover:bg-teal-800 sm:w-auto`,href:`https://www.smartsafehub.com/dashboard/recommended-rules/`,target:`_blank`,rel:`noopener noreferrer`,children:`추천 규칙 보기 ↗`})]})}),t.applyLocalOverrides?null:z(`section`,{class:`mt-4 flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 p-4`,role:`alert`,children:[z(U,{class:`mt-0.5 size-5 shrink-0 text-amber-700`}),z(`div`,{children:[z(`p`,{class:`m-0 text-sm font-extrabold text-amber-950`,children:`로컬 규칙 적용이 꺼져 있습니다`}),z(`p`,{class:`mt-1 mb-0 text-sm leading-6 text-amber-800`,children:`규칙은 저장되지만 SafeShield의 apply_local_overrides 설정을 켜기 전까지 DNS에 반영되지 않습니다.`})]})]}),t.safeshieldEnabled?null:z(`section`,{class:`mt-4 flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-100 p-4`,role:`status`,children:[z(At,{class:`mt-0.5 size-5 shrink-0 text-slate-600`}),z(`div`,{children:[z(`p`,{class:`m-0 text-sm font-extrabold text-slate-950`,children:`SafeShield가 비활성화되어 있습니다`}),z(`p`,{class:`mt-1 mb-0 text-sm leading-6 text-slate-600`,children:`규칙을 미리 저장할 수 있으며, SafeShield를 켜고 차단 목록을 갱신하면 적용됩니다.`})]})]}),r?z(`section`,{"aria-live":`polite`,class:`mt-4 flex flex-col items-start gap-3 rounded-2xl border border-teal-200
Line truncated
|
`),l,g,x,ie]),N(()=>{if(!C)return;let e=window.setTimeout(()=>E(!0),6e4);return()=>window.clearTimeout(e)},[C]);async function ae(){if(!(!u||g||x)){_(!0),te(null),j(null);try{let e=await u();ie(e),j(e.syncStatus===`confirmed`?`서버에서 규칙을 가져와 SafeShield 허용 목록을 확인했습니다.`:`추천 규칙 상태를 확인 중입니다. 진행 결과는 자동으로 갱신됩니다.`),c()}catch{te(`추천 규칙을 동기화하지 못했습니다. 서버 연결 및 기기 등록 상태를 확인해 주세요.`)}finally{_(!1)}}}async function oe(){if(!y||x||!d)return;let e=y.id;D.current=e,S(!0),w(!0),E(!1),te(null),j(null);try{let t=await d(e);ie(t),b(null),D.current!==null&&j(`적용 해제 요청을 처리했습니다. 공유기의 허용 목록을 확인하고 있습니다.`),k.current()}catch{b(null),te(`적용 해제 결과를 아직 확인하지 못했습니다. 공유기의 상태를 자동으로 다시 확인합니다.`);try{let e=await l?.();e&&ie(e)}catch{}}finally{S(!1)}}let se=f?.syncStage,ce=f?.syncStatus===`pending`,le=f?.syncStatus===`delayed`,ue=f?.syncStatus===`failed`,de=se===`server_sync`?`서버 동기화 중`:se===`safeshield_refreshing`?`SafeShield 갱신 중`:se===`applying`?`추천 규칙 적용 중`:se===`verifying`?`허용 목록 확인 중`:le?`재확인 지연`:ue?`동기화 실패`:ce?`동기화 대기`:`허용 목록 확인`,fe=se===`safeshield_refreshing`&&ce?`SafeShield가 보호 목록을 갱신하고 있습니다. 갱신이 끝나면 추천 규칙을 자동으로 다시 확인합니다.`:se===`applying`&&ce?`추천 규칙을 SafeShield 허용 목록에 반영하고 있습니다.`:se===`verifying`&&ce?`SafeShield 허용 목록에서 추천 규칙 반영 결과를 확인하고 있습니다.`:se===`server_sync`&&ce?`SmartSafeHub 서버에서 최신 추천 규칙을 확인하고 있습니다.`:le?`서버 응답이 지연되고 있습니다. 이미 확인한 허용 규칙은 유지되며 자동으로 다시 시도합니다.`:ue?f?.syncReason===`auth_rejected`?`SmartSafeHub 기기 인증이 거부되었습니다. 공유기 계정 연결 상태를 확인해 주세요.`:f?.syncReason===`api_rejected`?`서버가 추천 규칙 요청을 거부했습니다. Hub API 배포 상태를 확인해 주세요.`:f?.syncReason===`invalid_response`?`서버 응답을 확인할 수 없습니다. 규칙은 유지되며 다음 동기화에서 다시 확인합니다.`:`추천 규칙 반영을 완료하지 못했습니다. 다음 자동 동기화에서 다시 시도합니다.`:ce?`추천 규칙의 적용 결과를 기다리고 있습니다.`:null,pe=C&&D.current!==null?{tone:`amber`,label:T?`적용 해제 처리 지연`:`적용 해제 확인 중`,description:T?`적용 해제 확인이 지연되고 있습니다. SafeShield 갱신 중이라면 완료 후 자동으로 재확인합니다.`:fe??`적용 해제 상태를 확인하고 있습니다. 확인되면 목록과 상태가 자동 갱신됩니다.`}:ce||le||ue?{tone:ue?`red`:`amber`,label:de,description:fe??``}:ee?{tone:`red`,label:`상태 확인 필요`,description:ee}:f?.syncWarning?{tone:`amber`,label:`서버 재확인 지연`,description:`이미 확인한 허용 규칙은 유지되며 자동으로 다시 시도합니다.`}:A?{tone:`green`,label:`허용 목록 확인`,description:A}:null;return i&&!t?z(bo,{}):n&&!t?z(xo,{message:n,onRetry:c}):t?z(v,{children:[y?z(`div`,{class:`fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4`,role:`presentation`,onClick:()=>{x||b(null)},children:z(`section`,{"aria-labelledby":`recommended-revoke-title`,"aria-modal":`true`,class:`w-full max-w-md rounded-2xl border border-slate-200 bg-white p-5 shadow-xl`,onClick:e=>e.stopPropagation(),role:`dialog`,children:[z(`h2`,{class:`m-0 text-lg font-extrabold text-slate-950`,id:`recommended-revoke-title`,children:`추천 규칙 적용 해제`}),z(`p`,{class:`mt-3 text-sm text-slate-700`,children:[z(`strong`,{children:y.title}),` 규칙을 이 공유기에서 해제할까요?`]}),z(`p`,{class:`mt-2 text-xs leading-5 text-slate-600`,children:[`포함된 도메인 `,y.domains.length,`개를 다시 확인합니다. 직접 추가한 허용 규칙과 다른 추천 규칙에 필요한 도메인은 유지됩니다.`]}),z(`div`,{class:`mt-5 flex justify-end gap-2`,children:[z(`button`,{class:`rounded-lg border border-slate-200 px-4 py-2 text-sm font-bold text-slate-700 disabled:cursor-wait disabled:opacity-50`,disabled:x,onClick:()=>b(null),type:`button`,children:`취소`}),z(`button`,{class:`inline-flex min-w-24 items-center justify-center gap-2 rounded-lg bg-red-700 px-4 py-2 text-sm font-bold text-white transition hover:bg-red-800 disabled:cursor-progress disabled:bg-red-700 disabled:opacity-80`,"aria-busy":x,disabled:x,onClick:()=>void oe(),type:`button`,children:[x?z(`svg`,{class:`size-4 animate-spin`,"aria-hidden":`true`,viewBox:`0 0 24 24`,fill:`none`,children:[z(`circle`,{cx:`12`,cy:`12`,r:`9`,stroke:`currentColor`,"stroke-width":`3`,opac
Line truncated
|
||||||
@@ -8,6 +8,13 @@ Describe 'SmartSafeHub 셸 계약 테스트'
|
|||||||
The error should be blank
|
The error should be blank
|
||||||
End
|
End
|
||||||
|
|
||||||
|
It '공유기 각 메뉴의 카드 밖 섹션 제목과 기능 버튼 보존을 검증한다'
|
||||||
|
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-section-layout.sh"
|
||||||
|
The status should be success
|
||||||
|
The output should start with 'PASS:'
|
||||||
|
The error should be blank
|
||||||
|
End
|
||||||
|
|
||||||
It 'Preact 11 의존성과 JSX 타입 호환성을 검증한다'
|
It 'Preact 11 의존성과 JSX 타입 호환성을 검증한다'
|
||||||
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-preact11-contract.sh"
|
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-preact11-contract.sh"
|
||||||
The status should be success
|
The status should be success
|
||||||
@@ -70,6 +77,13 @@ Describe 'SmartSafeHub 셸 계약 테스트'
|
|||||||
The error should be blank
|
The error should be blank
|
||||||
End
|
End
|
||||||
|
|
||||||
|
It '추천 사용자 규칙의 빠른 적용 확인 보고와 재전송 방지를 검증한다'
|
||||||
|
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-recommended-ack.sh"
|
||||||
|
The status should be success
|
||||||
|
The output should start with 'PASS: lightweight recommended confirmation'
|
||||||
|
The error should be blank
|
||||||
|
End
|
||||||
|
|
||||||
It '계정 기반 Cloud 활동 동기화와 직접/관찰 이벤트 분리를 검증한다'
|
It '계정 기반 Cloud 활동 동기화와 직접/관찰 이벤트 분리를 검증한다'
|
||||||
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-activity-cloud-sync.sh"
|
When run command sh "$SHELLSPEC_PROJECT_ROOT/tests/test-activity-cloud-sync.sh"
|
||||||
The status should be success
|
The status should be success
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const read = (file) => readFileSync(resolve(root, file), 'utf8');
|
||||||
|
|
||||||
|
test('추천 규칙은 도메인이 아니라 할당된 규칙 ID로 해제한다', () => {
|
||||||
|
const script = read('root/usr/libexec/smartsafehub-device');
|
||||||
|
const page = read('frontend/src/pages/SafeShieldRulesPage.tsx');
|
||||||
|
assert.match(script, /devices\/recommended-rules\/\$rule_id\/revoke/);
|
||||||
|
assert.match(script, /reconcile_recommended_response "\$out"/);
|
||||||
|
assert.match(page, /onRevokeRecommendedRule\?\:/);
|
||||||
|
assert.match(page, /revokeTarget\.id/);
|
||||||
|
assert.match(page, /aria-modal="true"/);
|
||||||
|
assert.match(page, /다른 추천 규칙에 필요한 도메인은 유지/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('인증된 공유기에서만 적용 해제 RPC가 호출되고 공개 데모는 차단된다', () => {
|
||||||
|
const app = read('frontend/src/app/App.tsx');
|
||||||
|
const page = read('frontend/src/pages/SafeShieldRulesPage.tsx');
|
||||||
|
const api = read('frontend/src/api/smartsafehub.ts');
|
||||||
|
const rpc = read('root/usr/share/rpcd/ucode/smartsafehub.uc');
|
||||||
|
const acl = read('root/usr/share/rpcd/acl.d/luci-app-smartsafehub.json');
|
||||||
|
assert.match(app, /onRevokeRecommendedRule=\{revokeRecommendedRule\}/);
|
||||||
|
assert.match(api, /device_recommended_rules_revoke/);
|
||||||
|
assert.match(rpc, /require_root_password\(function\(request\)/);
|
||||||
|
assert.match(acl, /device_recommended_rules_revoke/);
|
||||||
|
assert.doesNotMatch(page, /from ['"]\.\.\/api\//);
|
||||||
|
assert.doesNotMatch(read('frontend/src/demo/main.tsx'), /onRevokeRecommendedRule=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// rpcd-mod-ucode handlers receive named arguments in request.args.
|
||||||
|
// Calling `ubus call smartsafehub device_recommended_rules_revoke '{"rule_id":1}'`
|
||||||
|
// must not be rejected because the handler looked for request.rule_id.
|
||||||
|
test('추천 규칙 해제 RPC는 request.args에서 ID를 읽는다', () => {
|
||||||
|
const rpc = read('root/usr/share/rpcd/ucode/smartsafehub.uc');
|
||||||
|
const handler = read('root/usr/share/rpcd/ucode/smartsafehub/device-registration.uc');
|
||||||
|
assert.match(rpc, /device_recommended_rules_revoke:\s*\{\s*args:\s*\{\s*rule_id:\s*0/);
|
||||||
|
assert.match(handler, /const rule_id = request\?\.args\?\.rule_id;/);
|
||||||
|
assert.doesNotMatch(handler, /const rule_id = request\?\.rule_id;/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const source = (path) => readFileSync(resolve(root, path), 'utf8');
|
||||||
|
|
||||||
|
test('SafeShield 갱신 중에는 추천 규칙을 보류하고 자동 재시도한다', () => {
|
||||||
|
const helper = source('root/usr/libexec/smartsafehub-recommended-rules');
|
||||||
|
const device = source('root/usr/libexec/smartsafehub-device');
|
||||||
|
assert.match(helper, /safeshield_refreshing\(\)/);
|
||||||
|
assert.match(helper, /\[ "\$status_value" = running \]/);
|
||||||
|
assert.match(helper, /phase safeshield_refreshing\s+exit 75/);
|
||||||
|
assert.match(helper, /persist '-'/); // retain ownership across partial operations
|
||||||
|
assert.match(device, /75\) return 0/); // deferred, not failed
|
||||||
|
assert.match(device, /RECOMMENDED_POLL_INTERVAL_S=30/);
|
||||||
|
assert.match(device, /write_recommended_sync_status delayed hub_unavailable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('추천 규칙 상태를 단계별로 보여주고 UI의 오류를 중복 표시하지 않는다', () => {
|
||||||
|
const rpc = source('root/usr/share/rpcd/ucode/smartsafehub/device-registration.uc');
|
||||||
|
const page = source('frontend/src/pages/SafeShieldRulesPage.tsx');
|
||||||
|
const types = source('frontend/src/types/rules.ts');
|
||||||
|
assert.match(rpc, /syncStage: sync\?\.stage/);
|
||||||
|
assert.match(rpc, /syncReason: sync\?\.reason/);
|
||||||
|
assert.match(types, /syncStage: string \| null/);
|
||||||
|
for (const stage of ['server_sync', 'safeshield_refreshing', 'applying', 'verifying']) {
|
||||||
|
assert.ok(page.includes(`syncStage === '${stage}'`), stage);
|
||||||
|
}
|
||||||
|
assert.match(page, /const currentNotice =/);
|
||||||
|
assert.match(page, /\{currentNotice \? \(/);
|
||||||
|
assert.match(page, /window\.setInterval\(updateStatus, 15_000\)/);
|
||||||
|
assert.doesNotMatch(page, /\{syncError \? <p/);
|
||||||
|
});
|
||||||
@@ -9,19 +9,17 @@ const page = readFileSync(resolve(root, 'frontend/src/pages/SafeShieldRulesPage.
|
|||||||
const rpc = readFileSync(resolve(root, 'root/usr/share/rpcd/ucode/smartsafehub/device-registration.uc'), 'utf8');
|
const rpc = readFileSync(resolve(root, 'root/usr/share/rpcd/ucode/smartsafehub/device-registration.uc'), 'utf8');
|
||||||
const helper = readFileSync(resolve(root, 'root/usr/libexec/smartsafehub-recommended-rules'), 'utf8');
|
const helper = readFileSync(resolve(root, 'root/usr/libexec/smartsafehub-recommended-rules'), 'utf8');
|
||||||
|
|
||||||
test('추천 규칙 영역은 공식 추천과 커뮤니티를 구분하여 안내한다', () => {
|
test('추천 규칙 영역에서 웹사이트의 추천 목록으로 이동할 수 있다', () => {
|
||||||
assert.match(page, /aria-label="추천 규칙 분류"/);
|
assert.match(page, /aria-label="추천 사용자 규칙"/);
|
||||||
assert.match(page, /공식 추천<\/span>/);
|
assert.match(page, /추천 규칙 보기/);
|
||||||
assert.match(page, /커뮤니티<\/span>/);
|
assert.match(page, /https:\/\/www\.smartsafehub\.com\/dashboard\/recommended-rules\//);
|
||||||
assert.match(page, /관리자가 검토한 커뮤니티 허용 규칙/);
|
|
||||||
assert.doesNotMatch(page, /다른 사용자가 공유한 검증된 허용 규칙/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('허용 목록의 수동 규칙과 추천 규칙을 로컬 소유 정보로 구분한다', () => {
|
test('허용 목록의 수동 규칙과 추천 규칙을 로컬 소유 정보로 구분한다', () => {
|
||||||
assert.match(rpc, /recommended-rules\.state/);
|
assert.match(rpc, /recommended-rules\.state/);
|
||||||
assert.match(rpc, /domains: domains/);
|
assert.match(rpc, /domains: domains/);
|
||||||
assert.match(helper, /grep -Fxq "\$domain" "\$OWNED"/);
|
assert.match(helper, /grep -Fxq "\$domain" "\$OWNED"/);
|
||||||
assert.match(page, /const cloudManaged = isAllow && recommendedDomains\.includes\(domain\);/);
|
assert.match(page, /const cloudManaged = isAllow && recommendedKnown && recommendedDomains\.includes\(domain\);/);
|
||||||
assert.match(page, /cloudManaged \? '추천 규칙' : '직접 추가'/);
|
assert.match(page, /cloudManaged \? '추천 규칙' : '직접 추가'/);
|
||||||
assert.match(page, /disabled=\{busy \|\| cloudManaged\}/);
|
assert.match(page, /disabled=\{busy \|\| cloudManaged\}/);
|
||||||
});
|
});
|
||||||
@@ -33,3 +31,86 @@ test('허용 규칙 출처를 조회하지 못하면 직접 추가로 단정하
|
|||||||
assert.match(page, /!onLoadRecommendedRulesStatus\) return;/);
|
assert.match(page, /!onLoadRecommendedRulesStatus\) return;/);
|
||||||
assert.doesNotMatch(page, /from ['"]\.\.\/api\//);
|
assert.doesNotMatch(page, /from ['"]\.\.\/api\//);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('추천 규칙 수동 동기화와 적용 확인 상태는 데모 API를 사용하지 않는다', () => {
|
||||||
|
const app = readFileSync(resolve(root, 'frontend/src/app/App.tsx'), 'utf8');
|
||||||
|
const api = readFileSync(resolve(root, 'frontend/src/api/smartsafehub.ts'), 'utf8');
|
||||||
|
const main = readFileSync(resolve(root, 'root/usr/share/rpcd/ucode/smartsafehub.uc'), 'utf8');
|
||||||
|
const acl = readFileSync(resolve(root, 'root/usr/share/rpcd/acl.d/luci-app-smartsafehub.json'), 'utf8');
|
||||||
|
const device = readFileSync(resolve(root, 'root/usr/libexec/smartsafehub-device'), 'utf8');
|
||||||
|
assert.match(page, /onSyncRecommendedRules\?: \(\) => Promise<RecommendedRulesStatus>/);
|
||||||
|
assert.match(page, /지금 동기화/);
|
||||||
|
assert.match(page, /SafeShield 차단 목록 갱신과 별개/);
|
||||||
|
assert.match(page, /status\.syncStatus === 'confirmed'/);
|
||||||
|
assert.match(page, /recommendedStatus\.confirmedDomains\.length/);
|
||||||
|
assert.match(app, /onSyncRecommendedRules=\{refreshRecommendedRulesStatus\}/);
|
||||||
|
assert.match(api, /callApi\(API_OBJECT, 'device_recommended_rules_refresh'/);
|
||||||
|
assert.match(main, /device_recommended_rules_refresh/);
|
||||||
|
assert.match(acl, /"device_recommended_rules_refresh"/);
|
||||||
|
assert.match(device, /write_recommended_sync_status failed/);
|
||||||
|
assert.match(device, /write_recommended_sync_status confirmed/);
|
||||||
|
assert.match(rpc, /const syncStatus = sync\?\.status == 'pending'/);
|
||||||
|
assert.match(rpc, /const syncWarning = confirmed &&/);
|
||||||
|
assert.match(rpc, /const confirmed = revision != null && receipt\?\.status == 'confirmed'/);
|
||||||
|
assert.doesNotMatch(page, /from ['"]\.\.\/api\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('추천 규칙의 로컬 적용 확인과 일시적 서버 동기화 실패를 구분한다', () => {
|
||||||
|
const type = readFileSync(resolve(root, 'frontend/src/types/rules.ts'), 'utf8');
|
||||||
|
assert.match(rpc, /confirmed \? 'confirmed' : sync\?\.status == 'delayed'/);
|
||||||
|
assert.doesNotMatch(rpc, /device\?\.lastResult == 'failed' \|\| sync\?\.status == 'failed'/);
|
||||||
|
assert.match(rpc, /syncWarning: syncWarning/);
|
||||||
|
assert.match(type, /syncWarning: boolean/);
|
||||||
|
assert.match(page, /recommendedStatus\?\.syncWarning/);
|
||||||
|
assert.match(page, /이미 확인한 허용 규칙은 유지되며 자동으로 다시 시도합니다/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('추천 규칙 상태는 화면을 새로고침하지 않아도 주기적으로 다시 조회한다', () => {
|
||||||
|
assert.match(page, /window\.setInterval\(updateStatus, 15_000\)/);
|
||||||
|
assert.match(page, /window\.clearInterval\(timer\)/);
|
||||||
|
assert.match(page, /if \(inFlight \|\| syncing \|\| revokeBusy\) return;/);
|
||||||
|
assert.match(page, /const loadStatus = onLoadRecommendedRulesStatus;/);
|
||||||
|
assert.doesNotMatch(page, /from ['"]\.\.\/api\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('갱신 실패 후에도 추천 규칙의 소유권을 유지하고 확인서가 없으면 재조회한다', () => {
|
||||||
|
const device = readFileSync(resolve(root, 'root/usr/libexec/smartsafehub-device'), 'utf8');
|
||||||
|
assert.match(helper, /Keep the ownership claim until the final SafeShield read confirms/);
|
||||||
|
assert.match(helper, /Only after successful reconciliation may we forget ownership/);
|
||||||
|
assert.match(helper, /ownership intent before creating the allow entry/);
|
||||||
|
assert.match(device, /A state revision alone is not proof that SafeShield applied the rules/);
|
||||||
|
assert.match(device, /json_value "\$RECOMMENDED_RECEIPT" '@\.revision'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('추천 규칙 해제 중에는 스피너와 진행 커서를 표시한다', () => {
|
||||||
|
assert.match(page, /aria-busy=\{revokeBusy\}/);
|
||||||
|
assert.match(page, /class="size-4 animate-spin"/);
|
||||||
|
assert.match(page, /disabled:cursor-progress/);
|
||||||
|
assert.match(page, /\{revokeBusy \? '해제 중…' : '적용 해제'\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('해제 요청 시간 초과 후에도 로컬 적용 확인을 반복하고 오래된 오류를 지운다', () => {
|
||||||
|
assert.match(page, /pendingRevokeId\.current = ruleId/);
|
||||||
|
assert.match(page, /status\.syncStatus === 'confirmed'/);
|
||||||
|
assert.match(page, /!status\.rules\.some\(\(rule\) => rule\.id === pendingRevokeId\.current\)/);
|
||||||
|
assert.match(page, /setSyncError\(null\)/);
|
||||||
|
assert.match(page, /적용 해제 결과를 아직 확인하지 못했습니다/);
|
||||||
|
assert.match(page, /acceptRecommendedStatus\(status\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('백그라운드 규칙 변경을 감지하면 SafeShield 목록을 다시 읽는다', () => {
|
||||||
|
assert.match(page, /previousStatusSignature\.current !== signature/);
|
||||||
|
assert.match(page, /onRetryRef\.current\(\)/);
|
||||||
|
assert.match(page, /window\.setInterval\(updateStatus, 15_000\)/);
|
||||||
|
assert.match(page, /window\.addEventListener\('focus', onFocus\)/);
|
||||||
|
assert.match(page, /document\.addEventListener\('visibilitychange', onVisible\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('적용 해제 확인이 길어지면 안내하고 확인을 계속한다', () => {
|
||||||
|
assert.match(page, /setRevokeConfirmationDelayed\(true\), 60_000/);
|
||||||
|
assert.match(page, /적용 해제 확인이 지연되고 있습니다/);
|
||||||
|
assert.match(page, /setRevokeConfirmationDelayed\(false\)/);
|
||||||
|
});
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
|
PAGE="$ROOT_DIR/frontend/src/pages/ActivityPage.tsx"
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Cloud controls must precede the arbitrarily long local event stream.
|
||||||
|
cloud_line=$(grep -n 'id="activity-cloud-title"' "$PAGE" | head -n 1 | cut -d: -f1)
|
||||||
|
history_line=$(grep -n 'id="activity-history-title"' "$PAGE" | head -n 1 | cut -d: -f1)
|
||||||
|
[ -n "$cloud_line" ] && [ -n "$history_line" ] && [ "$cloud_line" -lt "$history_line" ] || fail 'Cloud preferences must be above the local activity timeline'
|
||||||
|
|
||||||
|
# The toggle and its ineligibility/error messaging must remain visible while details are closed.
|
||||||
|
switch_line=$(grep -n 'aria-label="Cloud 활동 기록 전송 사용"' "$PAGE" | head -n 1 | cut -d: -f1)
|
||||||
|
details_line=$(grep -n '<details class=' "$PAGE" | head -n 1 | cut -d: -f1)
|
||||||
|
[ -n "$switch_line" ] && [ -n "$details_line" ] && [ "$switch_line" -lt "$details_line" ] || fail 'Cloud setting must stay outside the collapsible details'
|
||||||
|
grep -Fq 'disabled={saving || !canToggle}' "$PAGE" || fail 'Cloud toggle must respect eligibility and pending requests'
|
||||||
|
grep -Fq 'role="switch"' "$PAGE" || fail 'Cloud switch accessibility semantics removed'
|
||||||
|
grep -Fq 'actionError || actionMessage' "$PAGE" || fail 'Cloud save feedback must remain visible'
|
||||||
|
grep -Fq 'Cloud 전송을 끄면 아직 전송하지 않은 대기 기록이 삭제됩니다.' "$PAGE" || fail 'Turning off Cloud upload must disclose the pending-event deletion before toggling'
|
||||||
|
grep -Fq 'cloud.lastErrorCode' "$PAGE" || fail 'Cloud sync failures must remain visible'
|
||||||
|
grep -Fq 'Cloud 동기화 상세 정보' "$PAGE" || fail 'Collapsible Cloud details missing'
|
||||||
|
grep -Fq 'SmartSafeHub 계정에 연결된 Free는 최근 7일' "$PAGE" || fail 'Cloud retention explanation missing'
|
||||||
|
|
||||||
|
# Data loading and pagination affect presentation only; the RPC still returns up to 128 records.
|
||||||
|
grep -Fq 'const [visibleCount, setVisibleCount] = useState(20);' "$PAGE" || fail 'Activity page must initially show 20 events'
|
||||||
|
grep -Fq 'const visibleEvents = events.slice(0, visibleCount);' "$PAGE" || fail 'Activity list must use display-only slicing'
|
||||||
|
grep -Fq '<ActivityTimeline events={visibleEvents} />' "$PAGE" || fail 'Paginated events must drive the timeline'
|
||||||
|
grep -Fq 'Math.min(current + 20, events.length)' "$PAGE" || fail 'More button must advance by 20 without exceeding the available data'
|
||||||
|
grep -Fq 'onClick={() => setVisibleCount(20)}' "$PAGE" || fail 'Expanded list must be collapsible again'
|
||||||
|
grep -Fq '이전 활동 더 보기' "$PAGE" || fail 'Older activities must be discoverable'
|
||||||
|
grep -Fq '재부팅하면 초기화됩니다' "$PAGE" || fail 'Local retention disclaimer must remain'
|
||||||
|
|
||||||
|
echo 'PASS: Cloud-first recent activity and incremental local history contracts'
|
||||||
@@ -113,8 +113,8 @@ grep -Fq '외부 상태를 확인해야 하는 항목은 실제 상태 변화가
|
|||||||
fail 'full page must explain observer-owned transition recording'
|
fail 'full page must explain observer-owned transition recording'
|
||||||
grep -Fq '업데이트, 계정 권한과 주요 설정' "$PAGE" || \
|
grep -Fq '업데이트, 계정 권한과 주요 설정' "$PAGE" || \
|
||||||
fail 'activity page must use account entitlement wording instead of license wording'
|
fail 'activity page must use account entitlement wording instead of license wording'
|
||||||
grep -Fq '<ActivityTimeline events={data?.events ?? []} />' "$PAGE" || \
|
grep -Fq '<ActivityTimeline events={visibleEvents} />' "$PAGE" || \
|
||||||
fail 'full page must render the complete local activity response'
|
fail 'full page must render the visible slice of the current-boot activity history'
|
||||||
|
|
||||||
for event_type in \
|
for event_type in \
|
||||||
system.booted \
|
system.booted \
|
||||||
|
|||||||
@@ -522,7 +522,11 @@ grep -Fq '원격 상태 보고가 켜져 있습니다.' "$SETTINGS_PAGE" || fail
|
|||||||
grep -Fq "return '첫 보고 준비 중';" "$SETTINGS_PAGE" || fail '활성화 직후 stale disabled/never 상태를 꺼짐이 아니라 첫 보고 준비 중으로 표시해야 합니다.'
|
grep -Fq "return '첫 보고 준비 중';" "$SETTINGS_PAGE" || fail '활성화 직후 stale disabled/never 상태를 꺼짐이 아니라 첫 보고 준비 중으로 표시해야 합니다.'
|
||||||
grep -Fq "return reporter.enabled ? '첫 보고 대기 중' : '보고 기록 없음';" "$SETTINGS_PAGE" || fail 'Reporter UI가 첫 서버 보고 전 상태를 명확하게 구분해야 합니다.'
|
grep -Fq "return reporter.enabled ? '첫 보고 대기 중' : '보고 기록 없음';" "$SETTINGS_PAGE" || fail 'Reporter UI가 첫 서버 보고 전 상태를 명확하게 구분해야 합니다.'
|
||||||
grep -Fq '최근 전송 상태' "$SETTINGS_PAGE" || fail 'Reporter 결과는 토글 상태와 혼동되지 않도록 최근 전송 상태로 표시해야 합니다.'
|
grep -Fq '최근 전송 상태' "$SETTINGS_PAGE" || fail 'Reporter 결과는 토글 상태와 혼동되지 않도록 최근 전송 상태로 표시해야 합니다.'
|
||||||
grep -Fq '기본값은 꺼짐이며 언제든지 다시 끌 수 있습니다.' "$SETTINGS_PAGE" || fail '설정 UI가 Reporter opt-in과 opt-out을 설명해야 합니다.'
|
grep -Fq '직접 켜고 언제든지 끌 수 있습니다.' "$SETTINGS_PAGE" || fail '설정 UI가 Reporter opt-in과 opt-out을 설명해야 합니다.'
|
||||||
|
grep -Fq 'aria-label="원격 상태 보고 사용"' "$SETTINGS_PAGE" || fail 'Reporter opt-in 스위치의 접근성 라벨이 유지되어야 합니다.'
|
||||||
|
if grep -Fq '기본값은 꺼짐이며 언제든지 다시 끌 수 있습니다.' "$SETTINGS_PAGE"; then
|
||||||
|
fail '원격 상태 보고 안내에 불필요한 기본값 문구가 남아 있으면 안 됩니다.'
|
||||||
|
fi
|
||||||
grep -Fq '개인정보 보호' "$SETTINGS_PAGE" || fail '설정 UI가 Health Reporter의 개인정보 보호 영역을 명확하게 표시해야 합니다.'
|
grep -Fq '개인정보 보호' "$SETTINGS_PAGE" || fail '설정 UI가 Health Reporter의 개인정보 보호 영역을 명확하게 표시해야 합니다.'
|
||||||
grep -Fq '전송되는 정보' "$SETTINGS_PAGE" || fail '설정 UI가 Health Reporter의 전송 항목을 구분해서 안내해야 합니다.'
|
grep -Fq '전송되는 정보' "$SETTINGS_PAGE" || fail '설정 UI가 Health Reporter의 전송 항목을 구분해서 안내해야 합니다.'
|
||||||
grep -Fq '전송하지 않는 정보' "$SETTINGS_PAGE" || fail '설정 UI가 Health Reporter의 개인정보 제외 항목을 안내해야 합니다.'
|
grep -Fq '전송하지 않는 정보' "$SETTINGS_PAGE" || fail '설정 UI가 Health Reporter의 개인정보 제외 항목을 안내해야 합니다.'
|
||||||
|
|||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# Verify lightweight confirmation reporting without relying on a host Python version.
|
||||||
|
set -eu
|
||||||
|
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
|
DEVICE="$ROOT/root/usr/libexec/smartsafehub-device"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: recommended ack: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Exercise the production function with mocked transport and JSON queries.
|
||||||
|
sed -n '/^recommended_fast_sync() {/,/^}/p' "$DEVICE" > "$TMP/fast.sh"
|
||||||
|
[ -s "$TMP/fast.sh" ] || fail 'production function not found'
|
||||||
|
. "$TMP/fast.sh"
|
||||||
|
|
||||||
|
RUNTIME_DIR="$TMP"
|
||||||
|
# Use a fixture-owned executable: /bin/true exists on many Linux systems,
|
||||||
|
# but macOS ships true in /usr/bin. The production fast-sync function
|
||||||
|
# explicitly checks this helper path with [ -x ], so an absent helper
|
||||||
|
# makes the test fail before it can make its first mocked API request.
|
||||||
|
RECOMMENDED_HELPER="$TMP/recommended-helper"
|
||||||
|
printf '#!/bin/sh\nexit 0\n' > "$RECOMMENDED_HELPER"
|
||||||
|
chmod 0755 "$RECOMMENDED_HELPER"
|
||||||
|
[ -x "$RECOMMENDED_HELPER" ] || fail 'mock recommended helper is not executable'
|
||||||
|
STATE_FILE="$TMP/device.json"
|
||||||
|
RECOMMENDED_STATE="$TMP/state"
|
||||||
|
RECOMMENDED_CATALOG="$TMP/catalog.json"
|
||||||
|
RECOMMENDED_RECEIPT="$TMP/receipt.json"
|
||||||
|
RECOMMENDED_ACK_STATE="$TMP/reported"
|
||||||
|
API_BASE_URL='https://example.test/api/v1'
|
||||||
|
REVISION="$(printf '%064d' 1)"
|
||||||
|
RULES_REVISION="$(printf '%064d' 2)"
|
||||||
|
|
||||||
|
printf '{"accountRegistered":true}\n' > "$STATE_FILE"
|
||||||
|
printf '%s\n' "$REVISION" > "$RECOMMENDED_STATE"
|
||||||
|
printf '{"rules_revision":"%s"}\n' "$RULES_REVISION" > "$RECOMMENDED_CATALOG"
|
||||||
|
printf '{"revision":"%s","status":"confirmed","domains":[]}\n' "$REVISION" > "$RECOMMENDED_RECEIPT"
|
||||||
|
|
||||||
|
CLOCK=1000
|
||||||
|
SERVER_ACK=true
|
||||||
|
now_epoch() { printf '%s' "$CLOCK"; }
|
||||||
|
credential_token() { printf '%s' ssh_dev_mock; }
|
||||||
|
|
||||||
|
# Parse only the scalar fields used in these fixtures with POSIX awk.
|
||||||
|
# In particular, avoid relying on BSD/GNU grep or sed regex behavior for
|
||||||
|
# quoted JSON values. The production JSON parser is not replaced by this mock.
|
||||||
|
json_value() {
|
||||||
|
[ -f "$1" ] || return 0
|
||||||
|
awk -v key="${2#@.}" '
|
||||||
|
{
|
||||||
|
marker = "\"" key "\""
|
||||||
|
at = index($0, marker)
|
||||||
|
if (!at) next
|
||||||
|
value = substr($0, at + length(marker))
|
||||||
|
sub(/^[[:space:]]*:[[:space:]]*/, "", value)
|
||||||
|
if (substr(value, 1, 1) == "\"") {
|
||||||
|
value = substr(value, 2)
|
||||||
|
closing = index(value, "\"")
|
||||||
|
if (closing) print substr(value, 1, closing - 1)
|
||||||
|
} else if (substr(value, 1, 4) == "true") {
|
||||||
|
print "true"
|
||||||
|
} else if (substr(value, 1, 5) == "false") {
|
||||||
|
print "false"
|
||||||
|
}
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate the mocked inputs before invoking the production function. On a
|
||||||
|
# platform that parses a fixture differently, print the cause rather than an
|
||||||
|
# unhelpful 'initial fast sync failed'.
|
||||||
|
[ "$(json_value "$STATE_FILE" '@.accountRegistered')" = true ] || fail 'mock account registration unreadable'
|
||||||
|
[ "$(json_value "$RECOMMENDED_RECEIPT" '@.revision')" = "$REVISION" ] || fail 'mock receipt revision unreadable'
|
||||||
|
[ "$(json_value "$RECOMMENDED_RECEIPT" '@.status')" = confirmed ] || fail 'mock receipt status unreadable'
|
||||||
|
[ "$(json_value "$RECOMMENDED_CATALOG" '@.rules_revision')" = "$RULES_REVISION" ] || fail 'mock rules revision unreadable'
|
||||||
|
|
||||||
|
write_recommended_sync_status() { :; }
|
||||||
|
log_message() { :; }
|
||||||
|
post_json() {
|
||||||
|
cp "$2" "$TMP/request.json" || fail 'could not capture request'
|
||||||
|
printf '{"changed":false,"revision":"%s","acknowledged":%s}\n' "$REVISION" "$SERVER_ACK" > "$3"
|
||||||
|
}
|
||||||
|
assert_request_has_ack() {
|
||||||
|
grep -Fq '"recommended_rules_ack"' "$TMP/request.json" || fail "$1: missing confirmation"
|
||||||
|
}
|
||||||
|
assert_request_has_no_ack() {
|
||||||
|
if grep -Fq '"recommended_rules_ack"' "$TMP/request.json"; then
|
||||||
|
fail "$1: sent an unnecessary confirmation"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
assert_report_time() {
|
||||||
|
[ -f "$RECOMMENDED_ACK_STATE" ] || fail "$1: missing acknowledged marker"
|
||||||
|
actual="$(awk '{print $3}' "$RECOMMENDED_ACK_STATE")"
|
||||||
|
[ "$actual" = "$2" ] || fail "$1: marker time '$actual' differs from '$2'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print the last request/response only on failure, with fixture-controlled
|
||||||
|
# data. No real device credential or production response is ever logged here.
|
||||||
|
run_fast_sync() {
|
||||||
|
phase="$1"
|
||||||
|
if recommended_fast_sync; then return 0; fi
|
||||||
|
printf 'FAIL: recommended ack: %s fast sync failed\n' "$phase" >&2
|
||||||
|
for file in "$TMP/request.json" "$TMP"/recommended-poll-response.*.json; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
printf 'DEBUG: %s: ' "${file##*/}" >&2
|
||||||
|
cat "$file" >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_fast_sync initial
|
||||||
|
assert_request_has_ack initial
|
||||||
|
assert_report_time initial 1000
|
||||||
|
|
||||||
|
run_fast_sync unchanged
|
||||||
|
assert_request_has_no_ack unchanged
|
||||||
|
|
||||||
|
CLOCK=1601
|
||||||
|
SERVER_ACK=false
|
||||||
|
run_fast_sync unacknowledged
|
||||||
|
assert_request_has_ack unacknowledged
|
||||||
|
assert_report_time unacknowledged 1000
|
||||||
|
|
||||||
|
SERVER_ACK=true
|
||||||
|
run_fast_sync retry
|
||||||
|
assert_request_has_ack retry
|
||||||
|
assert_report_time retry 1601
|
||||||
|
|
||||||
|
# Never report a receipt that has disappeared or failed to match local state.
|
||||||
|
rm -f "$RECOMMENDED_RECEIPT"
|
||||||
|
SERVER_ACK=false
|
||||||
|
recommended_fast_sync || true
|
||||||
|
[ -f "$TMP/request.json" ] || fail 'missing request after receipt removal'
|
||||||
|
assert_request_has_no_ack missing-receipt
|
||||||
|
|
||||||
|
printf 'PASS: lightweight recommended confirmation and retry contract\n'
|
||||||
@@ -7,22 +7,50 @@ DEVICE="$ROOT/root/usr/libexec/smartsafehub-device"
|
|||||||
PAGE="$ROOT/frontend/src/pages/SafeShieldRulesPage.tsx"
|
PAGE="$ROOT/frontend/src/pages/SafeShieldRulesPage.tsx"
|
||||||
RPC="$ROOT/root/usr/share/rpcd/ucode/smartsafehub.uc"
|
RPC="$ROOT/root/usr/share/rpcd/ucode/smartsafehub.uc"
|
||||||
TMP="$(mktemp -d)"
|
TMP="$(mktemp -d)"
|
||||||
trap 'rm -rf "$TMP"' EXIT HUP INT TERM
|
# Print the current scenario on assertion failure. ShellSpec otherwise receives
|
||||||
|
# only an empty stdout/stderr when `set -e` stops on an unlabelled assertion.
|
||||||
|
TEST_STAGE='초기화'
|
||||||
|
cleanup() {
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
printf 'FAIL: 추천 규칙 테스트 단계: %s (exit=%s)\n' "$TEST_STAGE" "$rc" >&2
|
||||||
|
if [ -f "$TMP/run/recommended-rules.sync.json" ]; then
|
||||||
|
printf ' last sync status: ' >&2
|
||||||
|
cat "$TMP/run/recommended-rules.sync.json" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
rm -rf "$TMP"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 1' HUP INT TERM
|
||||||
mkdir -p "$TMP/bin" "$TMP/run" "$TMP/etc"
|
mkdir -p "$TMP/bin" "$TMP/run" "$TMP/etc"
|
||||||
cat > "$TMP/bin/jsonfilter" <<'JS'
|
cat > "$TMP/bin/jsonfilter" <<'JS'
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
const fs=require('fs'), args=process.argv.slice(2);
|
const fs=require('fs'), args=process.argv.slice(2);
|
||||||
let obj=JSON.parse(fs.readFileSync(args[args.indexOf('-i')+1]));
|
let obj=JSON.parse(fs.readFileSync(args[args.indexOf('-i')+1]));
|
||||||
const expr=args[args.indexOf('-e')+1].replace(/^@\./,'').replace(/\[\*\]$/,'');
|
const typeMode=args.includes('-t');
|
||||||
|
const expression=args[args.indexOf(typeMode ? '-t' : '-e')+1];
|
||||||
|
const expr=expression.replace(/^@\./,'').replace(/\[\*\]$/,'');
|
||||||
for (const key of expr.split('.')) obj=obj && !Array.isArray(obj) ? obj[key] : undefined;
|
for (const key of expr.split('.')) obj=obj && !Array.isArray(obj) ? obj[key] : undefined;
|
||||||
if (Array.isArray(obj)) for (const item of obj) console.log(item);
|
if (obj === undefined || obj === null) process.exit(1);
|
||||||
else if (obj !== undefined && obj !== null) console.log(obj);
|
if (typeMode) {
|
||||||
|
console.log(Array.isArray(obj) ? 'array' : typeof obj);
|
||||||
|
} else if (Array.isArray(obj)) {
|
||||||
|
if (!obj.length) process.exit(1); // OpenWrt jsonfilter: [*] has no match
|
||||||
|
for (const item of obj) console.log(item);
|
||||||
|
} else {
|
||||||
|
console.log(typeof obj === 'object' ? JSON.stringify(obj) : obj);
|
||||||
|
}
|
||||||
JS
|
JS
|
||||||
cat > "$TMP/bin/ubus" <<'JS'
|
cat > "$TMP/bin/ubus" <<'JS'
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
const fs=require('fs'),path=require('path');
|
const fs=require('fs'),path=require('path');
|
||||||
const dir=process.env.MOCK_RECOMMENDED_DIR, method=process.argv[4], file=path.join(dir,'rules.json');
|
const dir=process.env.MOCK_RECOMMENDED_DIR, method=process.argv[4], file=path.join(dir,'rules.json');
|
||||||
let rules=JSON.parse(fs.readFileSync(file,'utf8'));
|
let rules=JSON.parse(fs.readFileSync(file,'utf8'));
|
||||||
|
if (method==='status') {
|
||||||
|
console.log(JSON.stringify({status:process.env.MOCK_SAFE_REFRESHING === '1' ? 'running' : 'ready'}));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
if (method==='rules_list') {
|
if (method==='rules_list') {
|
||||||
console.log(process.env.BROKEN_LIST==='1' ? '{"ok":true}' : JSON.stringify({ok:true,allow:rules,block:[]}));
|
console.log(process.env.BROKEN_LIST==='1' ? '{"ok":true}' : JSON.stringify({ok:true,allow:rules,block:[]}));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -41,13 +69,14 @@ fs.appendFileSync(path.join(dir,'calls.log'),method+' '+domain+'\n');
|
|||||||
console.log('{"ok":true}');
|
console.log('{"ok":true}');
|
||||||
JS
|
JS
|
||||||
chmod +x "$TMP/bin/ubus" "$TMP/bin/jsonfilter"
|
chmod +x "$TMP/bin/ubus" "$TMP/bin/jsonfilter"
|
||||||
printf '["manual.example.org"]' > "$TMP/rules.json"
|
printf '[]' > "$TMP/rules.json"
|
||||||
export MOCK_RECOMMENDED_DIR="$TMP"
|
export MOCK_RECOMMENDED_DIR="$TMP"
|
||||||
export SMARTSAFEHUB_RECOMMENDED_STATE_FILE="$TMP/etc/state"
|
export SMARTSAFEHUB_RECOMMENDED_STATE_FILE="$TMP/etc/state"
|
||||||
export SMARTSAFEHUB_RECOMMENDED_RECEIPT_FILE="$TMP/etc/receipt.json"
|
export SMARTSAFEHUB_RECOMMENDED_RECEIPT_FILE="$TMP/etc/receipt.json"
|
||||||
export SMARTSAFEHUB_DEVICE_JSONFILTER_BIN="$TMP/bin/jsonfilter"
|
export SMARTSAFEHUB_DEVICE_JSONFILTER_BIN="$TMP/bin/jsonfilter"
|
||||||
export SMARTSAFEHUB_DEVICE_UBUS_BIN="$TMP/bin/ubus"
|
export SMARTSAFEHUB_DEVICE_UBUS_BIN="$TMP/bin/ubus"
|
||||||
export SMARTSAFEHUB_DEVICE_RUNTIME_DIR="$TMP/run"
|
export SMARTSAFEHUB_DEVICE_RUNTIME_DIR="$TMP/run"
|
||||||
|
export SMARTSAFEHUB_RECOMMENDED_SYNC_STATUS_FILE="$TMP/run/recommended-rules.sync.json"
|
||||||
response() {
|
response() {
|
||||||
python3 - "$TMP/response.json" "$1" <<'PY'
|
python3 - "$TMP/response.json" "$1" <<'PY'
|
||||||
import sys,json,hashlib
|
import sys,json,hashlib
|
||||||
@@ -61,6 +90,75 @@ import json,sys
|
|||||||
assert sorted(json.load(open(sys.argv[1])))==sorted(json.loads(sys.argv[2]))
|
assert sorted(json.load(open(sys.argv[1])))==sorted(json.loads(sys.argv[2]))
|
||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
TEST_STAGE='SafeShield 갱신 중 동기화 보류'
|
||||||
|
# An empty SafeShield allow list is normal on a newly configured router.
|
||||||
|
# OpenWrt jsonfilter returns 1 for @.allow[*] when there are no entries.
|
||||||
|
response '["tv.example.org"]'
|
||||||
|
# The SafeShield blocklist refresh can take minutes. Do not report an error,
|
||||||
|
# modify rule ownership, or run rule_add while it reports status=running.
|
||||||
|
if MOCK_SAFE_REFRESHING=1 "$HELPER" "$TMP/response.json"; then
|
||||||
|
echo 'FAIL: busy SafeShield refresh should defer work' >&2; exit 1
|
||||||
|
else
|
||||||
|
[ "$?" -eq 75 ] || { echo 'FAIL: busy refresh should use deferred exit 75' >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
assert_rules '[]'
|
||||||
|
[ ! -f "$TMP/etc/state" ]
|
||||||
|
python3 - "$TMP/run/recommended-rules.sync.json" <<'PY_STAGE'
|
||||||
|
import json,sys
|
||||||
|
s=json.load(open(sys.argv[1]))
|
||||||
|
assert s['status']=='pending' and s['stage']=='safeshield_refreshing',s
|
||||||
|
PY_STAGE
|
||||||
|
"$HELPER" "$TMP/response.json"
|
||||||
|
assert_rules '["tv.example.org"]'
|
||||||
|
[ -f "$TMP/etc/state" ]
|
||||||
|
TEST_STAGE='추천 규칙 메타데이터 저장'
|
||||||
|
# Server-assigned IDs and titles are stored only after SafeShield confirms the rules.
|
||||||
|
python3 - "$TMP/response.json" <<'PY_RULES'
|
||||||
|
import hashlib,json,sys
|
||||||
|
path=sys.argv[1]
|
||||||
|
entry={'id':42,'title':'TV 테스트 규칙','domains':['tv.example.org']}
|
||||||
|
obj=json.load(open(path))
|
||||||
|
obj['recommended_rules'].update(rules=[entry],rules_revision=hashlib.sha256(b'test-rule-metadata').hexdigest())
|
||||||
|
json.dump(obj,open(path,'w'))
|
||||||
|
PY_RULES
|
||||||
|
"$HELPER" "$TMP/response.json"
|
||||||
|
python3 - "$TMP/run/recommended-rules.catalog.json" <<'PY_CATALOG'
|
||||||
|
import json,sys
|
||||||
|
obj=json.load(open(sys.argv[1]))
|
||||||
|
assert obj['rules'][0]['id'] == 42
|
||||||
|
assert obj['rules'][0]['domains'] == ['tv.example.org']
|
||||||
|
PY_CATALOG
|
||||||
|
TEST_STAGE='SafeShield refresh 실패 후 소유 정보 보존'
|
||||||
|
# 회귀: 삭제 후 SafeShield refresh가 실패하면 추천 소유 정보를 잃으면 안 됩니다.
|
||||||
|
# 그 사이 허용 목록에 도메인이 남거나 다시 나타나더라도 재할당 시 수동
|
||||||
|
# 규칙으로 잘못 분류되어서는 안 됩니다.
|
||||||
|
response '[]'
|
||||||
|
if FAIL_METHOD=refresh "$HELPER" "$TMP/response.json"; then
|
||||||
|
echo 'FAIL: failed SafeShield refresh must fail reconciliation' >&2; exit 1
|
||||||
|
fi
|
||||||
|
[ "$(sed -n '1p' "$TMP/etc/state")" = '-' ]
|
||||||
|
grep -Fqx 'tv.example.org' "$TMP/etc/state"
|
||||||
|
# Simulate a still-present allow entry after the failed refresh.
|
||||||
|
printf '["tv.example.org"]' > "$TMP/rules.json"
|
||||||
|
response '["tv.example.org"]'
|
||||||
|
"$HELPER" "$TMP/response.json"
|
||||||
|
python3 - "$TMP/etc/receipt.json" <<'PY_REASSIGN'
|
||||||
|
import json,sys
|
||||||
|
receipt=json.load(open(sys.argv[1]))
|
||||||
|
assert receipt['domains'] == [{'domain':'tv.example.org','source':'managed'}], receipt
|
||||||
|
PY_REASSIGN
|
||||||
|
# A valid empty recommended_rules.domains array must also be accepted.
|
||||||
|
response '[]'
|
||||||
|
"$HELPER" "$TMP/response.json"
|
||||||
|
assert_rules '[]'
|
||||||
|
[ "$(wc -l < "$TMP/etc/state")" -eq 1 ]
|
||||||
|
# An empty allow list on both the initial read and verification is valid.
|
||||||
|
"$HELPER" "$TMP/response.json"
|
||||||
|
assert_rules '[]'
|
||||||
|
TEST_STAGE='기존 수동 허용 규칙 보존'
|
||||||
|
# Continue existing scenarios with pre-existing manual rules.
|
||||||
|
rm -f "$TMP/etc/state" "$TMP/etc/receipt.json" "$TMP/calls.log"
|
||||||
|
printf '["manual.example.org"]' > "$TMP/rules.json"
|
||||||
# Existing manual entries are never claimed even if the cloud recommends them.
|
# Existing manual entries are never claimed even if the cloud recommends them.
|
||||||
response '["tv.example.org","manual.example.org"]'
|
response '["tv.example.org","manual.example.org"]'
|
||||||
"$HELPER" "$TMP/response.json"
|
"$HELPER" "$TMP/response.json"
|
||||||
@@ -81,11 +179,13 @@ count="$(wc -l < "$TMP/calls.log")"
|
|||||||
# Removing one recommendation must only remove the locally owned entry.
|
# Removing one recommendation must only remove the locally owned entry.
|
||||||
response '[]'; "$HELPER" "$TMP/response.json"
|
response '[]'; "$HELPER" "$TMP/response.json"
|
||||||
assert_rules '["manual.example.org"]'
|
assert_rules '["manual.example.org"]'
|
||||||
|
TEST_STAGE='잘못된 SafeShield 응답 방어'
|
||||||
# Broken responses must not be interpreted as an empty rules list.
|
# Broken responses must not be interpreted as an empty rules list.
|
||||||
response '["tv.example.org"]'
|
response '["tv.example.org"]'
|
||||||
if BROKEN_LIST=1 "$HELPER" "$TMP/response.json"; then exit 1; fi
|
if BROKEN_LIST=1 "$HELPER" "$TMP/response.json"; then exit 1; fi
|
||||||
[ ! -f "$TMP/etc/receipt.json" ]
|
[ ! -f "$TMP/etc/receipt.json" ]
|
||||||
assert_rules '["manual.example.org"]'
|
assert_rules '["manual.example.org"]'
|
||||||
|
TEST_STAGE='규칙 변경 실패 후 재시도'
|
||||||
# Rule mutation failures must not acknowledge a newer revision.
|
# Rule mutation failures must not acknowledge a newer revision.
|
||||||
if FAIL_METHOD=rule_add "$HELPER" "$TMP/response.json"; then exit 1; fi
|
if FAIL_METHOD=rule_add "$HELPER" "$TMP/response.json"; then exit 1; fi
|
||||||
[ ! -f "$TMP/etc/receipt.json" ]
|
[ ! -f "$TMP/etc/receipt.json" ]
|
||||||
@@ -105,6 +205,17 @@ if FAIL_METHOD=refresh "$HELPER" "$TMP/response.json"; then exit 1; fi
|
|||||||
"$HELPER" "$TMP/response.json"
|
"$HELPER" "$TMP/response.json"
|
||||||
assert_rules '["manual.example.org"]'
|
assert_rules '["manual.example.org"]'
|
||||||
[ "$(sed -n '1p' "$TMP/etc/state")" = "$expected" ] && exit 1
|
[ "$(sed -n '1p' "$TMP/etc/state")" = "$expected" ] && exit 1
|
||||||
|
TEST_STAGE='잘못된 Hub 응답 및 도메인 검증'
|
||||||
|
# A missing or incorrectly typed array is NOT the same as an empty one.
|
||||||
|
revision="$(python3 - "$TMP/response.json" <<'PYREV'
|
||||||
|
import hashlib
|
||||||
|
print(hashlib.sha256(b'').hexdigest())
|
||||||
|
PYREV
|
||||||
|
)"
|
||||||
|
printf '{"recommended_rules":{"revision":"%s","domains":null}}\n' "$revision" > "$TMP/response.json"
|
||||||
|
if "$HELPER" "$TMP/response.json"; then exit 1; fi
|
||||||
|
printf '{"recommended_rules":{"revision":"%s","domains":{}}}\n' "$revision" > "$TMP/response.json"
|
||||||
|
if "$HELPER" "$TMP/response.json"; then exit 1; fi
|
||||||
# Bad domain or malformed hash must never mutate SafeShield.
|
# Bad domain or malformed hash must never mutate SafeShield.
|
||||||
response '["evil.example.org;reboot"]'
|
response '["evil.example.org;reboot"]'
|
||||||
if "$HELPER" "$TMP/response.json"; then exit 1; fi
|
if "$HELPER" "$TMP/response.json"; then exit 1; fi
|
||||||
@@ -115,6 +226,37 @@ if "$HELPER" "$TMP/response.json"; then exit 1; fi
|
|||||||
printf '{}\n' > "$TMP/response.json"
|
printf '{}\n' > "$TMP/response.json"
|
||||||
"$HELPER" "$TMP/response.json"
|
"$HELPER" "$TMP/response.json"
|
||||||
assert_rules '["manual.example.org"]'
|
assert_rules '["manual.example.org"]'
|
||||||
|
TEST_STAGE='동기화 상태 JSON 및 RPC 계약'
|
||||||
|
# The last reconciliation result must be written to tmpfs and distinguish a
|
||||||
|
# SafeShield rule failure from a successful device account synchronization.
|
||||||
|
RECOMMENDED_STATUS="$ROOT/root/usr/share/rpcd/ucode/smartsafehub/device-registration.uc"
|
||||||
|
ACL="$ROOT/root/usr/share/rpcd/acl.d/luci-app-smartsafehub.json"
|
||||||
|
API="$ROOT/frontend/src/api/smartsafehub.ts"
|
||||||
|
APP="$ROOT/frontend/src/app/App.tsx"
|
||||||
|
grep -Fq 'RECOMMENDED_SYNC_STATUS=' "$DEVICE"
|
||||||
|
grep -Fq 'write_recommended_sync_status failed' "$DEVICE"
|
||||||
|
grep -Fq 'write_recommended_sync_status confirmed' "$DEVICE"
|
||||||
|
grep -Fq "sync?.status == 'failed'" "$RECOMMENDED_STATUS"
|
||||||
|
grep -Fq 'refresh_recommended_rules_status' "$RECOMMENDED_STATUS"
|
||||||
|
grep -Fq 'device_recommended_rules_refresh' "$RPC"
|
||||||
|
grep -Fq '"device_recommended_rules_refresh"' "$ACL"
|
||||||
|
grep -Fq 'refreshRecommendedRulesStatus' "$API"
|
||||||
|
grep -Fq 'onSyncRecommendedRules={refreshRecommendedRulesStatus}' "$APP"
|
||||||
|
grep -Fq '지금 동기화' "$PAGE"
|
||||||
|
! grep -Fq 'recommendedStatus.confirmedDomains.map' "$PAGE" # no repeated list
|
||||||
|
# Exercise the real helper's atomic status writer with an isolated temp dir.
|
||||||
|
sed -n '/^write_recommended_sync_status() {/,/^}/p' "$DEVICE" > "$TMP/status-function.sh"
|
||||||
|
(
|
||||||
|
. "$TMP/status-function.sh"
|
||||||
|
RUNTIME_DIR="$TMP/status-runtime"
|
||||||
|
RECOMMENDED_SYNC_STATUS="$RUNTIME_DIR/recommended-rules.sync.json"
|
||||||
|
now_epoch() { printf '12345'; }
|
||||||
|
write_recommended_sync_status failed
|
||||||
|
grep -Fq '"status":"failed","stage":"failed","reason":"","checkedAt":12345' "$RECOMMENDED_SYNC_STATUS"
|
||||||
|
write_recommended_sync_status confirmed
|
||||||
|
grep -Fq '"status":"confirmed","stage":"confirmed","reason":"","checkedAt":12345' "$RECOMMENDED_SYNC_STATUS"
|
||||||
|
[ ! -e "$RECOMMENDED_SYNC_STATUS.tmp.$$" ]
|
||||||
|
)
|
||||||
# The UI must not allow manual deletion of entries owned by the cloud.
|
# The UI must not allow manual deletion of entries owned by the cloud.
|
||||||
grep -Fq 'device_recommended_rules_status' "$RPC"
|
grep -Fq 'device_recommended_rules_status' "$RPC"
|
||||||
# A missing semicolon after an exported ucode function prevents the whole
|
# A missing semicolon after an exported ucode function prevents the whole
|
||||||
@@ -141,9 +283,169 @@ grep -Fq 'onLoadRecommendedRulesStatus' "$PAGE"
|
|||||||
grep -Fq 'onLoadRecommendedRulesStatus={fetchRecommendedRulesStatus}' "$ROOT/frontend/src/app/App.tsx"
|
grep -Fq 'onLoadRecommendedRulesStatus={fetchRecommendedRulesStatus}' "$ROOT/frontend/src/app/App.tsx"
|
||||||
grep -Fq 'disabled={busy || cloudManaged}' "$PAGE"
|
grep -Fq 'disabled={busy || cloudManaged}' "$PAGE"
|
||||||
grep -Fq "cloudManaged ? '추천 규칙' : '직접 추가'" "$PAGE"
|
grep -Fq "cloudManaged ? '추천 규칙' : '직접 추가'" "$PAGE"
|
||||||
grep -Fq 'aria-label="추천 규칙 분류"' "$PAGE"
|
# Group allow entries by who controls them without repeating the domains.
|
||||||
|
grep -Fq 'aria-label="직접 추가한 허용 규칙"' "$PAGE"
|
||||||
|
grep -Fq 'aria-label="웹사이트 추천 규칙"' "$PAGE"
|
||||||
|
grep -Fq 'recommendedDomains.includes(domain)' "$PAGE"
|
||||||
|
grep -Fq 'aria-expanded={showRecommended}' "$PAGE"
|
||||||
|
grep -Fq 'const showRecommended = searching ||' "$PAGE"
|
||||||
|
grep -Fq 'max-h-[20rem]' "$PAGE"
|
||||||
|
grep -Fq '약 30초마다 확인합니다' "$PAGE"
|
||||||
|
! grep -Fq '추천 규칙은 약 5분마다 자동 동기화됩니다' "$PAGE"
|
||||||
|
# Never invent service names that the router API does not provide.
|
||||||
|
! grep -Fq '삼성 TV Plus' "$PAGE"
|
||||||
|
grep -Fq 'aria-label="추천 사용자 규칙"' "$PAGE"
|
||||||
grep -Fq 'https://www.smartsafehub.com/dashboard/recommended-rules/' "$PAGE"
|
grep -Fq 'https://www.smartsafehub.com/dashboard/recommended-rules/' "$PAGE"
|
||||||
grep -Fq 'RECOMMENDED_HELPER' "$DEVICE"
|
grep -Fq 'RECOMMENDED_HELPER' "$DEVICE"
|
||||||
grep -Fq 'recommended_rules_ack' "$DEVICE"
|
grep -Fq 'recommended_rules_ack' "$DEVICE"
|
||||||
grep -Fq '/etc/smartsafehub/recommended-rules.state' "$ROOT/root/lib/upgrade/keep.d/smartsafehub-device"
|
grep -Fq '/etc/smartsafehub/recommended-rules.state' "$ROOT/root/lib/upgrade/keep.d/smartsafehub-device"
|
||||||
|
TEST_STAGE='30초 경량 동기화 및 갱신 중 대기'
|
||||||
|
# The short-poll path must not use full device sync or reapply unchanged
|
||||||
|
# rules. Exercise the real function with a fake authenticated Hub response.
|
||||||
|
sed -n '/^reconcile_recommended_response() {/,/^}/p' "$DEVICE" > "$TMP/fast-function.sh"
|
||||||
|
sed -n '/^recommended_fast_sync() {/,/^}/p' "$DEVICE" >> "$TMP/fast-function.sh"
|
||||||
|
(
|
||||||
|
. "$TMP/fast-function.sh"
|
||||||
|
RUNTIME_DIR="$TMP/run"
|
||||||
|
API_BASE_URL="https://example.test/api/v1"
|
||||||
|
STATE_FILE="$TMP/device.json"
|
||||||
|
RECOMMENDED_STATE="$TMP/etc/state"
|
||||||
|
RECOMMENDED_HELPER="$HELPER"
|
||||||
|
RECOMMENDED_RECEIPT="$TMP/etc/receipt.json"
|
||||||
|
printf '{"accountRegistered":true}\n' > "$STATE_FILE"
|
||||||
|
printf '[]\n' > "$TMP/rules.json"
|
||||||
|
rm -f "$RECOMMENDED_STATE" "$TMP/calls.log"
|
||||||
|
credential_token() { printf 'ssh_dev_test'; }
|
||||||
|
json_value() { "$TMP/bin/jsonfilter" -i "$1" -e "$2" 2>/dev/null | head -n 1 || true; }
|
||||||
|
write_recommended_sync_status() { printf '%s\n' "$1" > "$TMP/fast-status"; }
|
||||||
|
log_message() { :; }
|
||||||
|
post_json() {
|
||||||
|
printf '%s\n' "$1" > "$TMP/fast-url"
|
||||||
|
cp "$2" "$TMP/fast-request.json"
|
||||||
|
cp "$TMP/hub-poll.json" "$3"
|
||||||
|
}
|
||||||
|
python3 - "$TMP/hub-poll.json" <<'PY_FAST'
|
||||||
|
import hashlib,json,sys
|
||||||
|
items=['tv.example.org']; revision=hashlib.sha256('\n'.join(items).encode()).hexdigest()
|
||||||
|
json.dump({'changed':True,'recommended_rules':{'revision':revision,'domains':items}},open(sys.argv[1],'w'))
|
||||||
|
PY_FAST
|
||||||
|
# Hub request can succeed while SafeShield is refreshing. Keep the current
|
||||||
|
# rules and do not report an irreversible failure; the next poll repairs it.
|
||||||
|
# A subshell keeps the mock refresh flag isolated on macOS /bin/sh too.
|
||||||
|
# The state and SafeShield mock results are written to files and remain visible.
|
||||||
|
( MOCK_SAFE_REFRESHING=1 recommended_fast_sync )
|
||||||
|
grep -Fqx 'pending' "$TMP/fast-status"
|
||||||
|
[ ! -f "$RECOMMENDED_STATE" ]
|
||||||
|
recommended_fast_sync
|
||||||
|
grep -Fqx 'confirmed' "$TMP/fast-status"
|
||||||
|
grep -Fq '/devices/recommended-rules' "$TMP/fast-url"
|
||||||
|
grep -Fqx 'tv.example.org' "$RECOMMENDED_STATE"
|
||||||
|
grep -Fq 'tv.example.org' "$TMP/rules.json"
|
||||||
|
[ -f "$RECOMMENDED_RECEIPT" ]
|
||||||
|
old_count="$(wc -l < "$TMP/calls.log")"
|
||||||
|
revision="$(sed -n '1p' "$RECOMMENDED_STATE")"
|
||||||
|
printf '{"changed":false,"revision":"%s"}\n' "$revision" > "$TMP/hub-poll.json"
|
||||||
|
recommended_fast_sync
|
||||||
|
[ "$old_count" -eq "$(wc -l < "$TMP/calls.log")" ] || exit 1
|
||||||
|
# 회귀: 이전 동기화가 수신 확인서를 잃으면 revision이 같더라도
|
||||||
|
# 변경 없음으로 인정하지 말고 Hub에서 전체 데이터를 다시 요청합니다.
|
||||||
|
rm -f "$RECOMMENDED_RECEIPT"
|
||||||
|
python3 - "$TMP/hub-poll.json" <<'PY_RECOVER'
|
||||||
|
import hashlib,json,sys
|
||||||
|
items=['tv.example.org']; revision=hashlib.sha256('\n'.join(items).encode()).hexdigest()
|
||||||
|
json.dump({'changed':True,'recommended_rules':{'revision':revision,'domains':items}},open(sys.argv[1],'w'))
|
||||||
|
PY_RECOVER
|
||||||
|
recommended_fast_sync
|
||||||
|
python3 - "$TMP/fast-request.json" <<'PY_REQUEST'
|
||||||
|
import json,sys
|
||||||
|
assert json.load(open(sys.argv[1]))['revision'] == ''
|
||||||
|
PY_REQUEST
|
||||||
|
[ -f "$RECOMMENDED_RECEIPT" ]
|
||||||
|
[ "$old_count" -eq "$(wc -l < "$TMP/calls.log")" ] || exit 1
|
||||||
|
# Removing an assigned rule must remove only the Hub-owned rule.
|
||||||
|
python3 - "$TMP/hub-poll.json" <<'PY_EMPTY'
|
||||||
|
import hashlib,json,sys
|
||||||
|
json.dump({'changed':True,'recommended_rules':{'revision':hashlib.sha256(b'').hexdigest(),'domains':[]}},open(sys.argv[1],'w'))
|
||||||
|
PY_EMPTY
|
||||||
|
recommended_fast_sync
|
||||||
|
[ "$(cat "$TMP/rules.json")" = '[]' ] || exit 1
|
||||||
|
# Never accept an invalid changed flag or an unverified no-change revision.
|
||||||
|
printf '{"changed":false,"revision":"invalid"}\n' > "$TMP/hub-poll.json"
|
||||||
|
if recommended_fast_sync; then exit 1; fi
|
||||||
|
grep -Fqx 'failed' "$TMP/fast-status"
|
||||||
|
printf '{"changed":true,"recommended_rules":{"revision":"bad","domains":[]}}\n' > "$TMP/hub-poll.json"
|
||||||
|
if recommended_fast_sync; then exit 1; fi
|
||||||
|
[ "$(cat "$TMP/rules.json")" = '[]' ] || exit 1
|
||||||
|
)
|
||||||
|
TEST_STAGE='추천 규칙 해제 명령행 인자 검증'
|
||||||
|
# Exercise the real CLI argument gate. Previous code treated a numeric rule ID
|
||||||
|
# as an invalid `--debug` flag, so the Hub request was never attempted.
|
||||||
|
# With an unregistered mock device, a valid ID reaches recommended_revoke()
|
||||||
|
# and fails with rc=1; an invalid ID is rejected at the CLI with rc=2.
|
||||||
|
export SMARTSAFEHUB_COMMON_LIB="$ROOT/root/usr/lib/smartsafehub/common.sh"
|
||||||
|
export SMARTSAFEHUB_RUNTIME_LIB="$ROOT/root/usr/lib/smartsafehub/runtime.sh"
|
||||||
|
export SMARTSAFEHUB_DEVICE_STATE_FILE="$TMP/nonexistent-device-state.json"
|
||||||
|
rc=0
|
||||||
|
"$DEVICE" recommended-revoke 42 > "$TMP/revoke-cli.out" 2>&1 || rc=$?
|
||||||
|
[ "$rc" -eq 1 ] || { echo 'FAIL: valid rule ID did not reach the revoke handler' >&2; cat "$TMP/revoke-cli.out" >&2; exit 1; }
|
||||||
|
rc=0
|
||||||
|
"$DEVICE" recommended-revoke invalid > "$TMP/revoke-cli.out" 2>&1 || rc=$?
|
||||||
|
[ "$rc" -eq 2 ] || { echo 'FAIL: invalid rule ID was accepted' >&2; exit 1; }
|
||||||
|
TEST_STAGE='추천 규칙 Hub 해제 및 로컬 적용'
|
||||||
|
# Revocation must call the device-authenticated Hub endpoint before touching
|
||||||
|
# SafeShield and must immediately reconcile the Hub's authoritative new list.
|
||||||
|
sed -n '/^reconcile_recommended_response() {/,/^}/p' "$DEVICE" > "$TMP/revoke-function.sh"
|
||||||
|
sed -n '/^recommended_revoke() {/,/^}/p' "$DEVICE" >> "$TMP/revoke-function.sh"
|
||||||
|
(
|
||||||
|
. "$TMP/revoke-function.sh"
|
||||||
|
API_BASE_URL="https://example.test/api/v1"
|
||||||
|
RUNTIME_DIR="$TMP/run"
|
||||||
|
STATE_FILE="$TMP/device.json"
|
||||||
|
RECOMMENDED_HELPER="$HELPER"
|
||||||
|
printf '{"accountRegistered":true}\n' > "$STATE_FILE"
|
||||||
|
printf '[]\n' > "$TMP/rules.json"
|
||||||
|
rm -f "$SMARTSAFEHUB_RECOMMENDED_STATE_FILE"
|
||||||
|
response '["tv.example.org"]'
|
||||||
|
"$HELPER" "$TMP/response.json"
|
||||||
|
python3 - "$TMP/revoke-hub.json" <<'PY_REVOKE'
|
||||||
|
import json,hashlib,sys
|
||||||
|
json.dump({'changed':True,'recommended_rules':{
|
||||||
|
'revision':hashlib.sha256(b'').hexdigest(),'domains':[],
|
||||||
|
'rules':[], 'rules_revision':hashlib.sha256(b'[]').hexdigest()
|
||||||
|
}},open(sys.argv[1],'w'))
|
||||||
|
PY_REVOKE
|
||||||
|
credential_token() { printf 'ssh_dev_test'; }
|
||||||
|
json_value() { "$TMP/bin/jsonfilter" -i "$1" -e "$2" 2>/dev/null | head -n 1 || true; }
|
||||||
|
write_recommended_sync_status() { printf '%s\n' "$1" > "$TMP/revoke-status"; }
|
||||||
|
post_json() { printf '%s\n' "$1" > "$TMP/revoke-url"; cp "$TMP/revoke-hub.json" "$3"; }
|
||||||
|
if recommended_revoke invalid; then exit 1; fi
|
||||||
|
recommended_revoke 42
|
||||||
|
grep -Fq '/devices/recommended-rules/42/revoke' "$TMP/revoke-url"
|
||||||
|
grep -Fxq confirmed "$TMP/revoke-status"
|
||||||
|
[ "$(cat "$TMP/rules.json")" = '[]' ] || exit 1
|
||||||
|
python3 - "$TMP/run/recommended-rules.catalog.json" <<'PY_EMPTY_RULES'
|
||||||
|
import json,sys
|
||||||
|
assert json.load(open(sys.argv[1]))['rules'] == []
|
||||||
|
PY_EMPTY_RULES
|
||||||
|
)
|
||||||
|
TEST_STAGE='추천 규칙 UI 및 API 계약'
|
||||||
|
grep -Fq 'RECOMMENDED_POLL_INTERVAL_S=30' "$DEVICE"
|
||||||
|
grep -Fq 'next_fast=' "$DEVICE"
|
||||||
|
grep -Fq 'next_full=' "$DEVICE"
|
||||||
|
grep -Fq "recommended-sync) recommended_fast_sync" "$DEVICE"
|
||||||
|
grep -Fq "[ HELPER, 'recommended-sync' ]" "$ROOT/root/usr/share/rpcd/ucode/smartsafehub/device-registration.uc"
|
||||||
|
# With exactOptionalPropertyTypes, omit an optional callback rather than pass undefined.
|
||||||
|
# The block card does not need a revoke callback; only recommended allow rules do.
|
||||||
|
if grep -Fq 'onRequestRevoke={onRevokeRecommendedRule ? setRevokeTarget : undefined}' "$PAGE"; then
|
||||||
|
echo 'FAIL: optional revoke callbacks must be omitted when unavailable' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -Fq '{...(onRevokeRecommendedRule ? { onRequestRevoke: setRevokeTarget } : {})}' "$PAGE" || {
|
||||||
|
echo 'FAIL: the allow card must conditionally pass the revoke callback' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[ "$(grep -Fc 'onRequestRevoke: setRevokeTarget' "$PAGE")" -eq 1 ] || {
|
||||||
|
echo 'FAIL: only the allow card may request recommended rule revocation' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
echo '추천 사용자 규칙 동기화 및 UI 계약: PASS'
|
echo '추천 사용자 규칙 동기화 및 UI 계약: PASS'
|
||||||
Regular → Executable
+6
-1
@@ -8,9 +8,10 @@ FORMAT="$ROOT/frontend/src/app/format.ts"
|
|||||||
FIRMWARE="$ROOT/frontend/src/components/FirmwareUpdatesCard.tsx"
|
FIRMWARE="$ROOT/frontend/src/components/FirmwareUpdatesCard.tsx"
|
||||||
SOFTWARE="$ROOT/frontend/src/components/SoftwareUpdatesCard.tsx"
|
SOFTWARE="$ROOT/frontend/src/components/SoftwareUpdatesCard.tsx"
|
||||||
SAFE="$ROOT/frontend/src/pages/SafeShieldPage.tsx"
|
SAFE="$ROOT/frontend/src/pages/SafeShieldPage.tsx"
|
||||||
|
RULES="$ROOT/frontend/src/pages/SafeShieldRulesPage.tsx"
|
||||||
|
|
||||||
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
||||||
for file in "$COMPONENT" "$FORMAT" "$FIRMWARE" "$SOFTWARE" "$SAFE" "$ROOT/tests/runtime/relative-time.test.mjs"; do
|
for file in "$COMPONENT" "$FORMAT" "$FIRMWARE" "$SOFTWARE" "$SAFE" "$RULES" "$ROOT/tests/runtime/relative-time.test.mjs"; do
|
||||||
[ -f "$file" ] || fail "상대 시간 소스가 없습니다: $file"
|
[ -f "$file" ] || fail "상대 시간 소스가 없습니다: $file"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -28,5 +29,9 @@ grep -Fq '<SummaryFact label="Last refresh" value={<RelativeTimestamp timestamp=
|
|||||||
grep -Fq 'parseArtifactVersionTimestamp(version)' "$SAFE" || fail '보호 데이터 버전의 UTC 시각을 파싱해야 합니다'
|
grep -Fq 'parseArtifactVersionTimestamp(version)' "$SAFE" || fail '보호 데이터 버전의 UTC 시각을 파싱해야 합니다'
|
||||||
grep -Fq '<RelativeTimestamp timestamp={timestamp} />' "$SAFE" || fail '보호 데이터 마지막 업데이트를 경과 시간으로 표시해야 합니다'
|
grep -Fq '<RelativeTimestamp timestamp={timestamp} />' "$SAFE" || fail '보호 데이터 마지막 업데이트를 경과 시간으로 표시해야 합니다'
|
||||||
grep -Fq 'label="다음 갱신" value={formatTimestamp(data.timestamps.nextRefreshAt)}' "$SAFE" || fail '다음 갱신 예정 시각은 절대 시간으로 유지해야 합니다'
|
grep -Fq 'label="다음 갱신" value={formatTimestamp(data.timestamps.nextRefreshAt)}' "$SAFE" || fail '다음 갱신 예정 시각은 절대 시간으로 유지해야 합니다'
|
||||||
|
grep -Fq '<RelativeTimestamp timestamp={recommendedStatus.lastCheckedAt} />' "$RULES" || fail '추천 규칙의 최근 확인은 상대 시간으로 표시해야 합니다'
|
||||||
|
if grep -Fq 'new Date(recommendedStatus.lastCheckedAt * 1000).toLocaleString' "$RULES"; then
|
||||||
|
fail '추천 규칙 최근 확인에 절대 시각을 직접 표시해서는 안 됩니다'
|
||||||
|
fi
|
||||||
|
|
||||||
printf 'PASS: relative time presentation contract\n'
|
printf 'PASS: relative time presentation contract\n'
|
||||||
@@ -61,4 +61,12 @@ grep -Fq 'DNS 적용 상태를 확인하지 못했습니다.' "$HOOK" || \
|
|||||||
grep -Fq 'onDeleteRule(kind, domain)' "$PAGE" || \
|
grep -Fq 'onDeleteRule(kind, domain)' "$PAGE" || \
|
||||||
fail 'delete action must continue to work independently of form clearing'
|
fail 'delete action must continue to work independently of form clearing'
|
||||||
|
|
||||||
|
# Theme-aware group headers must not leak a light teal background in dark mode.
|
||||||
|
grep -Fq 'ssh-rule-group-toggle bg-slate-50' "$PAGE" || fail 'both rule groups must use the theme-aware slate surface'
|
||||||
|
if grep -Fq 'bg-teal-50/60' "$PAGE"; then
|
||||||
|
fail 'semi-transparent light rule group background bypasses the dark theme mapping'
|
||||||
|
fi
|
||||||
|
grep -Fq ".ssh-app[data-theme='dark'] .ssh-rule-group-toggle:hover" "$ROOT_DIR/frontend/src/styles/app.css" || \
|
||||||
|
fail 'hovered rule headers must remain dark in the dark theme'
|
||||||
|
|
||||||
echo 'PASS: SafeShield rules input, persistence and DNS apply contracts are present'
|
echo 'PASS: SafeShield rules input, persistence and DNS apply contracts are present'
|
||||||
@@ -27,10 +27,23 @@ grep -Fq 'eyebrow="Protection details"' "$PAGE" || \
|
|||||||
fail 'SafeShield page must provide a dedicated protection details section'
|
fail 'SafeShield page must provide a dedicated protection details section'
|
||||||
grep -Fq 'title="보호 구성"' "$PAGE" || \
|
grep -Fq 'title="보호 구성"' "$PAGE" || \
|
||||||
fail 'SafeShield page must group runtime, blocklist, refresh and health details'
|
fail 'SafeShield page must group runtime, blocklist, refresh and health details'
|
||||||
grep -Fq 'eyebrow="Settings"' "$PAGE" || \
|
grep -Fq 'aria-label="보호 데이터 요약"' "$PAGE" || \
|
||||||
fail 'SafeShield page must provide a dedicated settings section'
|
fail 'SafeShield summary must include the moved protection-data information'
|
||||||
grep -Fq 'title="SafeShield 설정"' "$PAGE" || \
|
# 보호 데이터 배경과 보호 구성 카드 밀도는 다른 상단 요약/대시보드 카드와 통일합니다.
|
||||||
fail 'SafeShield settings section must be clearly labeled'
|
grep -Fq 'class="min-w-0 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6 xl:h-full"' "$PAGE" || \
|
||||||
|
fail '보호 데이터 요약은 데스크톱에서 다른 카드와 높이를 맞추고 동일한 흰색 배경을 사용해야 합니다'
|
||||||
|
if grep -Fq 'class="min-w-0 rounded-xl border border-slate-200 bg-slate-50/70 p-4"' "$PAGE"; then
|
||||||
|
fail '보호 데이터 요약에 기존 회색 배경이 남아 있으면 안 됩니다'
|
||||||
|
fi
|
||||||
|
grep -Fq 'class="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"' "$PAGE" || \
|
||||||
|
fail '보호 구성은 모바일 한 열, 태블릿 두 열, 데스크톱 네 열이어야 합니다'
|
||||||
|
grep -Fq 'xl:p-4 2xl:p-5' "$PAGE" || \
|
||||||
|
fail '보호 구성 네 열에서는 카드 여백을 줄여야 합니다'
|
||||||
|
grep -Fq 'xl:text-base 2xl:text-lg' "$PAGE" || \
|
||||||
|
fail '네 열 레이아웃에서 카드 제목이 잘리지 않도록 크기를 조절해야 합니다'
|
||||||
|
if grep -Fq 'title="SafeShield 설정"' "$PAGE" || grep -Fq 'href="#rules"' "$PAGE"; then
|
||||||
|
fail 'SafeShield overview must not duplicate the dedicated user rules page'
|
||||||
|
fi
|
||||||
if grep -Fq 'SmartSafeHub 계정' "$PAGE"; then fail 'SafeShield page must not own SmartSafeHub account connection UI'; fi
|
if grep -Fq 'SmartSafeHub 계정' "$PAGE"; then fail 'SafeShield page must not own SmartSafeHub account connection UI'; fi
|
||||||
if grep -Fq '계정 연결 코드' "$PAGE"; then fail 'SafeShield page must not render pairing codes'; fi
|
if grep -Fq '계정 연결 코드' "$PAGE"; then fail 'SafeShield page must not render pairing codes'; fi
|
||||||
if grep -Fq 'requestDevicePairingCode' "$PAGE"; then fail 'SafeShield page must not call device pairing APIs'; fi
|
if grep -Fq 'requestDevicePairingCode' "$PAGE"; then fail 'SafeShield page must not call device pairing APIs'; fi
|
||||||
@@ -46,18 +59,14 @@ fi
|
|||||||
if grep -Fq '라이선스를 확인하고 이 기기에 적용하고 있습니다…' "$PAGE"; then
|
if grep -Fq '라이선스를 확인하고 이 기기에 적용하고 있습니다…' "$PAGE"; then
|
||||||
fail 'SafeShield settings must not expose the legacy license activation flow'
|
fail 'SafeShield settings must not expose the legacy license activation flow'
|
||||||
fi
|
fi
|
||||||
grep -Fq 'Custom rules' "$PAGE" || fail 'SafeShield settings must expose product-facing custom rules'
|
|
||||||
grep -Fq 'href="#rules"' "$PAGE" || fail 'SafeShield settings must link to the user rules page'
|
|
||||||
if grep -Fq 'data.localOverrides.allowlistPath' "$PAGE" || grep -Fq 'data.localOverrides.blocklistPath' "$PAGE"; then
|
if grep -Fq 'data.localOverrides.allowlistPath' "$PAGE" || grep -Fq 'data.localOverrides.blocklistPath' "$PAGE"; then
|
||||||
fail 'SafeShield product UI must not expose internal local rule file paths'
|
fail 'SafeShield product UI must not expose internal local rule file paths'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
grep -Fq 'px-5 pb-5 sm:px-6 sm:pb-6' "$PAGE" || \
|
grep -Fq 'aria-label="보호 상태 요약"' "$PAGE" || \
|
||||||
fail 'SafeShield summary facts must remain visually inside the protection card'
|
fail 'SafeShield summary facts must remain visually inside the protection card'
|
||||||
grep -Fq 'class="bg-white px-5 py-4 sm:px-6"' "$PAGE" || \
|
grep -Fq 'class="bg-white px-5 py-4 sm:px-6"' "$PAGE" || \
|
||||||
fail 'SafeShield summary fact cells must use the protection card surface color'
|
fail 'SafeShield summary fact cells must use the protection card surface color'
|
||||||
grep -Fq 'border border-teal-700 bg-teal-700' "$PAGE" || \
|
|
||||||
fail 'SafeShield custom rules action must remain recognizable as a primary button'
|
|
||||||
|
|
||||||
grep -Fq 'Protection data' "$PAGE" || \
|
grep -Fq 'Protection data' "$PAGE" || \
|
||||||
fail 'SafeShield protection data card must use a user-facing eyebrow instead of Artifact'
|
fail 'SafeShield protection data card must use a user-facing eyebrow instead of Artifact'
|
||||||
@@ -81,8 +90,15 @@ grep -Fq '마지막 업데이트' "$PAGE" || \
|
|||||||
fail 'SafeShield protection data card must expose the last update time'
|
fail 'SafeShield protection data card must expose the last update time'
|
||||||
grep -Fq 'formatArtifactUpdatedAt' "$PAGE" || \
|
grep -Fq 'formatArtifactUpdatedAt' "$PAGE" || \
|
||||||
fail 'SafeShield protection data version must be converted into a readable update time'
|
fail 'SafeShield protection data version must be converted into a readable update time'
|
||||||
grep -Fq '<details class=' "$PAGE" || \
|
grep -Fq 'aria-label="보호 데이터 세부 정보"' "$PAGE" || \
|
||||||
fail 'SafeShield technical artifact metadata must be collapsed into details'
|
fail 'SafeShield technical metadata must always be visible below the protection summary'
|
||||||
|
if grep -Fq '<details class="mt-2 rounded-lg border border-slate-200 bg-white px-3 py-2">' "$PAGE"; then
|
||||||
|
fail 'SafeShield protection data must not hide technical metadata behind an expand control'
|
||||||
|
fi
|
||||||
|
grep -Fq 'grid size-10 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-500' "$PAGE" || \
|
||||||
|
fail 'SafeShield protection-data icon must match the shared detail-card icon surface and size'
|
||||||
|
grep -Fq 'grid size-10 shrink-0 place-items-center rounded-xl bg-slate-100 text-slate-500' "$PANEL" || \
|
||||||
|
fail 'SafeShield statistics toggle icon must match the shared detail-card icon surface and size'
|
||||||
grep -Fq '데이터 프로필' "$PAGE" || \
|
grep -Fq '데이터 프로필' "$PAGE" || \
|
||||||
fail 'SafeShield protection data details must retain the raw artifact tier as a technical data profile'
|
fail 'SafeShield protection data details must retain the raw artifact tier as a technical data profile'
|
||||||
grep -Fq 'value={data.artifact.tier' "$PAGE" || \
|
grep -Fq 'value={data.artifact.tier' "$PAGE" || \
|
||||||
@@ -98,8 +114,13 @@ if grep -Fq '<DetailRow label="Tier"' "$PAGE" || \
|
|||||||
grep -Fq '<DetailRow label="Unique domains"' "$PAGE"; then
|
grep -Fq '<DetailRow label="Unique domains"' "$PAGE"; then
|
||||||
fail 'SafeShield protection data card must not expose raw English artifact fields in the default view'
|
fail 'SafeShield protection data card must not expose raw English artifact fields in the default view'
|
||||||
fi
|
fi
|
||||||
grep -Fq '현재 적용 중인 보호 데이터와 사용자 규칙을 관리합니다.' "$PAGE" || \
|
# The protection-data summary must precede the activity panel and appear only once.
|
||||||
fail 'SafeShield settings description must focus on protection data and custom rules'
|
protection_line=$(grep -n 'aria-label="보호 데이터 요약"' "$PAGE" | head -n 1 | cut -d: -f1)
|
||||||
|
activity_line=$(grep -n '<SafeShieldStatisticsPanel' "$PAGE" | head -n 1 | cut -d: -f1)
|
||||||
|
[ -n "$protection_line" ] && [ -n "$activity_line" ] && [ "$protection_line" -lt "$activity_line" ] || \
|
||||||
|
fail 'SafeShield protection data should appear above the activity section'
|
||||||
|
[ "$(grep -Fc 'aria-label="보호 데이터 요약"' "$PAGE")" -eq 1 ] || \
|
||||||
|
fail 'SafeShield should render the protection-data panel only once'
|
||||||
|
|
||||||
grep -Fq 'const DISPLAY_HOURS = 24;' "$PANEL" || \
|
grep -Fq 'const DISPLAY_HOURS = 24;' "$PANEL" || \
|
||||||
fail 'SafeShield activity must continue to use 24 hourly buckets'
|
fail 'SafeShield activity must continue to use 24 hourly buckets'
|
||||||
|
|||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# Contract: section context sits outside content cards; interactive controls remain with their card.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
|
PAGE="$ROOT_DIR/frontend/src/pages"
|
||||||
|
COMP="$ROOT_DIR/frontend/src/components"
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ -f "$COMP/SectionHeading.tsx" ] || fail 'Shared section heading missing'
|
||||||
|
grep -Fq 'ssh-section-heading mb-4 min-w-0' "$COMP/SectionHeading.tsx" || fail 'Shared section spacing missing'
|
||||||
|
grep -Fq '<h2 class=' "$COMP/SectionHeading.tsx" || fail 'Accessible section title missing'
|
||||||
|
grep -Fq '{action ?' "$COMP/SectionHeading.tsx" || fail 'Shared heading action slot missing'
|
||||||
|
|
||||||
|
for name in HomePage SafeShieldPage ActivityPage ConnectedDevicesPage WanPage LanPage WifiPage IptvPage SmartSafeHubAccountPage SafeShieldRulesPage UpdatePage SettingsPage; do
|
||||||
|
grep -Fq 'SectionHeading' "$PAGE/$name.tsx" || fail "$name does not use common section style"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Core screens follow the same title > card order (no nested section title in card).
|
||||||
|
for pair in \
|
||||||
|
'SafeShieldStatisticsPanel.tsx:최근 24시간 보호 활동' \
|
||||||
|
'ActivityPage.tsx:현재 부팅 이후의 최근 활동' \
|
||||||
|
'ConnectedDevicesPage.tsx:기기 목록' \
|
||||||
|
'WanPage.tsx:인터넷 연결 설정' \
|
||||||
|
'LanPage.tsx:LAN 및 DHCP 설정' \
|
||||||
|
'IptvPage.tsx:IPTV 동작 상태' \
|
||||||
|
'SafeShieldRulesPage.tsx:사용자 도메인 규칙' \
|
||||||
|
'SmartSafeHubAccountPage.tsx:기기 계정 연결'; do
|
||||||
|
name=${pair%%:*}
|
||||||
|
title=${pair#*:}
|
||||||
|
file="$PAGE/$name"
|
||||||
|
[ "$name" = 'SafeShieldStatisticsPanel.tsx' ] && file="$COMP/$name"
|
||||||
|
grep -Fq "title=\"$title\"" "$file" || fail "Section heading missing: $name/$title"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The two desktop summary cards should have equal height without forcing a
|
||||||
|
# fixed height on mobile or while asynchronous status text changes.
|
||||||
|
grep -Fq 'gap-5 xl:items-stretch xl:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]' "$PAGE/SmartSafeHubAccountPage.tsx" || \
|
||||||
|
fail 'Account connection and status cards must stretch to equal height at desktop widths'
|
||||||
|
grep -Fq 'gap-4 xl:grid-cols-[minmax(0,1.08fr)_minmax(0,0.92fr)] xl:items-stretch' "$PAGE/SafeShieldPage.tsx" || \
|
||||||
|
fail 'SafeShield protection summary and protection data cards must stretch to equal height'
|
||||||
|
|
||||||
|
# Explicitly fill the stretched row for both cards, even as status text
|
||||||
|
# changes asynchronously. Only use this at desktop widths.
|
||||||
|
[ "$(grep -Fc 'bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6 xl:h-full' "$PAGE/SafeShieldPage.tsx")" -ge 2 ] || \
|
||||||
|
fail 'Both SafeShield summary cards must fill their desktop grid row'
|
||||||
|
[ "$(grep -Fc 'bg-white p-5 shadow-sm shadow-slate-900/5 sm:p-6 xl:h-full' "$PAGE/SmartSafeHubAccountPage.tsx")" -eq 2 ] || \
|
||||||
|
fail 'Account connection and status cards must fill their desktop grid row'
|
||||||
|
|
||||||
|
# Protection controls and their current status must stay together in a functional card.
|
||||||
|
grep -Fq 'SafeShield 보호 상태' "$PAGE/SafeShieldPage.tsx" || fail 'Protection card heading removed'
|
||||||
|
grep -Fq 'aria-label={targetEnabled ?' "$COMP/SafeShieldStatisticsPanel.tsx" || fail 'Statistics collection switch removed'
|
||||||
|
grep -Fq 'role="switch"' "$PAGE/IptvPage.tsx" || fail 'IPTV switch removed'
|
||||||
|
grep -Fq '저장되지 않음' "$PAGE/WanPage.tsx" || fail 'WAN dirty state removed'
|
||||||
|
grep -Fq '저장되지 않음' "$PAGE/LanPage.tsx" || fail 'LAN dirty state removed'
|
||||||
|
grep -Fq 'id="diagnostics-panel"' "$PAGE/SettingsPage.tsx" || fail 'Settings diagnostics panel missing'
|
||||||
|
grep -Fq 'id="time-panel"' "$PAGE/SettingsPage.tsx" || fail 'Settings time panel missing'
|
||||||
|
grep -Fq 'id="management-panel"' "$PAGE/SettingsPage.tsx" || fail 'Settings management panel missing'
|
||||||
|
|
||||||
|
# Same section headings must be kept in production and demo: only shared source is modified.
|
||||||
|
|
||||||
|
echo 'PASS: common section-first layout contract'
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
# Regression checks for the r11 follow-up layout adjustment.
|
||||||
|
set -eu
|
||||||
|
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
|
SAFE="$ROOT_DIR/frontend/src/pages/SafeShieldPage.tsx"
|
||||||
|
RULES="$ROOT_DIR/frontend/src/pages/SafeShieldRulesPage.tsx"
|
||||||
|
ACCOUNT="$ROOT_DIR/frontend/src/pages/SmartSafeHubAccountPage.tsx"
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Shared outside-heading first; buttons remain in the same card as protection state.
|
||||||
|
grep -Fq 'title="SafeShield 보호 상태"' "$SAFE" || fail 'protection heading is not a shared outside section'
|
||||||
|
grep -Fq 'class="mb-8 grid min-w-0 gap-4 xl:grid-cols-2 xl:items-start"' "$SAFE" || fail 'spacing below protection section is missing'
|
||||||
|
grep -Fq 'aria-label="보호 상태 요약"' "$SAFE" || fail 'protection controls card is missing'
|
||||||
|
grep -Fq 'aria-label="보호 데이터 요약"' "$SAFE" || fail 'protection data card is missing'
|
||||||
|
grep -Fq 'onClick={handleToggle}' "$SAFE" || fail 'protection toggle missing'
|
||||||
|
grep -Fq 'onClick={onRefreshBlocklist}' "$SAFE" || fail 'blocklist refresh missing'
|
||||||
|
grep -Fq 'RefreshProgress data={data}' "$SAFE" || fail 'progress indicator missing'
|
||||||
|
|
||||||
|
# Three independently visible summary cards, no tinted tiles floating on page background.
|
||||||
|
grep -Fq 'title="사용자 도메인 규칙"' "$RULES" || fail 'rules heading missing'
|
||||||
|
grep -Fq 'grid grid-cols-1 gap-3 sm:grid-cols-3' "$RULES" || fail 'responsive summary cards missing'
|
||||||
|
for label in '허용 도메인' '차단 도메인' '전체 등록 규칙'; do
|
||||||
|
grep -Fq "$label" "$RULES" || fail "summary label missing: $label"
|
||||||
|
done
|
||||||
|
if grep -Fq 'rounded-xl bg-emerald-50 px-3 py-3 text-center' "$RULES"; then fail 'old floating stats remain'; fi
|
||||||
|
|
||||||
|
# Parent card is the status surface; connected and pairing states avoid a second wrapper.
|
||||||
|
grep -Fq 'title="기기 계정 연결"' "$ACCOUNT" || fail 'account heading missing'
|
||||||
|
grep -Fq 'grid items-start gap-5 xl:grid-cols-' "$ACCOUNT" || fail 'account cards should not stretch to leave blank space'
|
||||||
|
if grep -Fq 'rounded-2xl border border-emerald-200 bg-emerald-50 p-5' "$ACCOUNT"; then fail 'nested connected-state card remains'; fi
|
||||||
|
if grep -Fq 'mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4 sm:p-5' "$ACCOUNT"; then fail 'nested pairing-state card remains'; fi
|
||||||
|
grep -Fq 'SmartSafeHub 계정에 연결됨' "$ACCOUNT" || fail 'connected state missing'
|
||||||
|
grep -Fq '계정 연결 코드 복사' "$ACCOUNT" || fail 'pairing copy action missing'
|
||||||
|
echo 'PASS: SafeShield, user rules and account section refinements'
|
||||||
@@ -64,12 +64,16 @@ grep -Fq 'title="시간 및 시간대"' "$SETTINGS_PAGE" || \
|
|||||||
fail 'settings page must expose time and timezone controls'
|
fail 'settings page must expose time and timezone controls'
|
||||||
grep -Fq "'지금 동기화'" "$SETTINGS_PAGE" || \
|
grep -Fq "'지금 동기화'" "$SETTINGS_PAGE" || \
|
||||||
fail 'settings page must expose immediate NTP synchronization'
|
fail 'settings page must expose immediate NTP synchronization'
|
||||||
grep -Fq 'title="진단 및 지원"' "$SETTINGS_PAGE" || \
|
grep -Fq 'title="장치 진단"' "$SETTINGS_PAGE" || \
|
||||||
fail 'diagnostic download must be grouped as diagnostic and support functionality'
|
fail 'diagnostic download must remain in its own card'
|
||||||
grep -Fq '원격 상태 보고' "$SETTINGS_PAGE" || \
|
grep -Fq '원격 상태 보고' "$SETTINGS_PAGE" || \
|
||||||
fail 'settings diagnostics must expose paid/trial remote health reporting'
|
fail 'settings diagnostics must expose paid/trial remote health reporting'
|
||||||
grep -Fq '기본값은 꺼짐이며 언제든지 다시 끌 수 있습니다.' "$SETTINGS_PAGE" || \
|
grep -Fq '장치의 최소 상태 정보와 이상 코드만 서버로 전송합니다.' "$SETTINGS_PAGE" || \
|
||||||
fail 'remote health reporting must be explicit opt-in with opt-out copy'
|
fail 'remote health reporting must explain the information transmitted'
|
||||||
|
grep -Fq '직접 켜고 언제든지 끌 수 있습니다.' "$SETTINGS_PAGE" || \
|
||||||
|
fail 'remote health reporting must explain explicit opt-in and opt-out without default-state copy'
|
||||||
|
! grep -Fq '기본값은 꺼짐이며 언제든지 다시 끌 수 있습니다.' "$SETTINGS_PAGE" || \
|
||||||
|
fail 'remote health reporting must not repeat unnecessary default-state copy'
|
||||||
grep -Fq '개인정보 보호' "$SETTINGS_PAGE" || \
|
grep -Fq '개인정보 보호' "$SETTINGS_PAGE" || \
|
||||||
fail 'remote health reporting must expose a recognizable privacy section'
|
fail 'remote health reporting must expose a recognizable privacy section'
|
||||||
grep -Fq '진단에 필요한 최소한의 상태 정보만 전송하며, 네트워크 식별 정보와 이용 내용은 보내지 않습니다.' "$SETTINGS_PAGE" || \
|
grep -Fq '진단에 필요한 최소한의 상태 정보만 전송하며, 네트워크 식별 정보와 이용 내용은 보내지 않습니다.' "$SETTINGS_PAGE" || \
|
||||||
@@ -213,4 +217,29 @@ grep -Fq 'await requestConfigurationBackupValidation(file.name);' "$BACKUP_HOOK"
|
|||||||
grep -Fq 'if (!validated)' "$BACKUP_HOOK" || \
|
grep -Fq 'if (!validated)' "$BACKUP_HOOK" || \
|
||||||
fail 'restore hook must reject attempts without a validated archive'
|
fail 'restore hook must reject attempts without a validated archive'
|
||||||
|
|
||||||
|
# Consistent, responsive three-tab UI (the health and time cards share the existing grid geometry).
|
||||||
|
grep -Fq 'title="장치 진단"' "$SETTINGS_PAGE" || fail 'local diagnostics must have their own card'
|
||||||
|
grep -Fq 'title="원격 상태 보고"' "$SETTINGS_PAGE" || fail 'remote health reporting must have its own card'
|
||||||
|
grep -Fq 'headerActions?: ComponentChildren;' "$SETTINGS_PAGE" || \
|
||||||
|
fail 'ActionCard must offer an independent right-aligned header control slot'
|
||||||
|
grep -Fq 'justify-between gap-3' "$SETTINGS_PAGE" || \
|
||||||
|
fail 'ActionCard header must push the remote-report switch to the right edge'
|
||||||
|
grep -Fq '{props.headerActions ? <div class="shrink-0">{props.headerActions}</div> : null}' "$SETTINGS_PAGE" || \
|
||||||
|
fail 'Remote reporting switch must use a right-aligned action area'
|
||||||
|
# Membership remains attached to the title, but the switch is a separate header action.
|
||||||
|
reporter_header=$(sed -n '/title="원격 상태 보고"/,/^ >/p' "$SETTINGS_PAGE")
|
||||||
|
printf '%s\n' "$reporter_header" | grep -Fq 'titleActions={' || fail 'Remote-report PRO badge must remain adjacent to its title'
|
||||||
|
printf '%s\n' "$reporter_header" | grep -Fq 'headerActions={' || fail 'Remote-report switch must use a distinct header action slot'
|
||||||
|
printf '%s\n' "$reporter_header" | grep -Fq 'aria-label="원격 상태 보고 사용"' || fail 'Remote-report switch semantics must remain intact'
|
||||||
|
grep -Fq 'title="개인정보 보호"' "$SETTINGS_PAGE" || fail 'privacy policy must have its own card'
|
||||||
|
grep -Fq 'class="grid min-w-0 content-start gap-4"' "$SETTINGS_PAGE" || fail 'privacy policy must sit directly below remote health reporting'
|
||||||
|
if grep -Fq 'max-w-4xl' "$SETTINGS_PAGE"; then
|
||||||
|
fail 'settings time tab must use the common content width'
|
||||||
|
fi
|
||||||
|
[ "$(grep -Fc 'grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2' "$SETTINGS_PAGE")" -ge 3 ] || \
|
||||||
|
fail 'diagnostics, time and management must use the same responsive two-column grid'
|
||||||
|
grep -Fq 'ssh-password-input' "$SETTINGS_PAGE" || fail 'password fields must include dedicated autofill styling'
|
||||||
|
grep -Fq ".ssh-app[data-theme='dark'] .ssh-password-input:-webkit-autofill" "$APP_STYLES" || \
|
||||||
|
fail 'dark theme must normalize browser password autofill background'
|
||||||
|
|
||||||
echo 'PASS: settings page keeps backup/restore, timezone and device management with safe system controls'
|
echo 'PASS: settings page keeps backup/restore, timezone and device management with safe system controls'
|
||||||
@@ -70,6 +70,7 @@ for script in \
|
|||||||
tests/test-relative-time-contract.sh \
|
tests/test-relative-time-contract.sh \
|
||||||
tests/test-reload-safety.sh \
|
tests/test-reload-safety.sh \
|
||||||
tests/test-settings-ui-contract.sh \
|
tests/test-settings-ui-contract.sh \
|
||||||
|
tests/test-section-layout.sh \
|
||||||
tests/test-system-time-contract.sh \
|
tests/test-system-time-contract.sh \
|
||||||
tests/test-scheduled-reboot.sh \
|
tests/test-scheduled-reboot.sh \
|
||||||
tests/test-health.sh \
|
tests/test-health.sh \
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
set -eu
|
||||||
|
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||||
|
IPTV="$ROOT/frontend/src/pages/IptvPage.tsx"
|
||||||
|
DEV="$ROOT/frontend/src/pages/ConnectedDevicesPage.tsx"
|
||||||
|
SHIELD="$ROOT/frontend/src/pages/SafeShieldPage.tsx"
|
||||||
|
STATS="$ROOT/frontend/src/components/SafeShieldStatisticsPanel.tsx"
|
||||||
|
SETTINGS="$ROOT/frontend/src/pages/SettingsPage.tsx"
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
grep -Fq '적용된 IPTV 중계 서비스와 멀티캐스트 전달 상태입니다.' "$IPTV" || fail 'IPTV runtime explanation missing'
|
||||||
|
grep -Fq 'flex min-w-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between' "$DEV" || fail 'device filter/search shared toolbar missing'
|
||||||
|
grep -Fq 'role="group" aria-label="기기 필터"' "$DEV" || fail 'device filter semantics missing'
|
||||||
|
grep -Fq 'lg:w-72 lg:shrink-0 xl:w-80' "$DEV" || fail 'search sizing at desktop missing'
|
||||||
|
grep -Fq 'xl:grid-cols-[minmax(0,1.08fr)_minmax(0,0.92fr)]' "$SHIELD" || fail 'protection-data width ratio missing'
|
||||||
|
grep -Fq 'action={' "$STATS" || fail 'statistics control must move into section-heading action slot'
|
||||||
|
grep -Fq 'aria-label={targetEnabled ?' "$STATS" || fail 'statistics collection switch missing'
|
||||||
|
grep -Fq 'disabled={action !== null}' "$STATS" || fail 'statistics switch busy-state guard removed'
|
||||||
|
grep -Fq 'titleActions={ ' "$SETTINGS" && fail 'unexpected whitespace near title actions'
|
||||||
|
grep -Fq 'titleActions={' "$SETTINGS" || fail 'remote reporting badge/switch header missing'
|
||||||
|
grep -Fq 'reporterMembershipLabel(reporter)' "$SETTINGS" || fail 'remote reporting plan missing'
|
||||||
|
grep -Fq 'aria-label="원격 상태 보고 사용"' "$SETTINGS" || fail 'remote reporting switch missing'
|
||||||
|
grep -Fq 'class="grid min-w-0 content-start gap-4"' "$SETTINGS" || fail 'remote/privacy stacked grouping missing'
|
||||||
|
! grep -Fq '기본값은 꺼짐이며 언제든지 다시 끌 수 있습니다.' "$SETTINGS" || fail 'unneeded default-state text remains'
|
||||||
|
grep -Fq 'title="개인정보 보호"' "$SETTINGS" || fail 'privacy content missing'
|
||||||
|
echo 'PASS: four-screen UI polish and control contracts'
|
||||||
@@ -412,6 +412,16 @@ grep -Fq '빌드 ID {data.release.buildId}' "$FIRMWARE_CARD" || \
|
|||||||
fail 'available firmware version must keep build_id as secondary diagnostic identity'
|
fail 'available firmware version must keep build_id as secondary diagnostic identity'
|
||||||
grep -Fq "펌웨어 {data.release.version || '미확인'} · {formatBytes(data.release.sysupgrade.sizeBytes)}" "$FIRMWARE_CARD" || \
|
grep -Fq "펌웨어 {data.release.version || '미확인'} · {formatBytes(data.release.sysupgrade.sizeBytes)}" "$FIRMWARE_CARD" || \
|
||||||
fail 'available firmware details must show product firmware version and image size without OpenWrt base metadata'
|
fail 'available firmware details must show product firmware version and image size without OpenWrt base metadata'
|
||||||
|
grep -Fq 'data-section="firmware-bundled-software-chip"' "$FIRMWARE_CARD" || \
|
||||||
|
fail 'available firmware summary must expose the bundled management-software version near the release badge'
|
||||||
|
grep -Fq '관리 SW' "$FIRMWARE_CARD" || \
|
||||||
|
fail 'firmware bundled management-software chip must use a compact Korean label'
|
||||||
|
if grep -Fq '선택한 주기에 새 관리 소프트웨어 버전을 확인합니다.' "$UPDATES_CARD"; then
|
||||||
|
fail 'automatic update check card must not keep the redundant helper sentence'
|
||||||
|
fi
|
||||||
|
if grep -Fq '새 버전이 있으면 지정한 시각에 자동으로 설치합니다.' "$UPDATES_CARD"; then
|
||||||
|
fail 'automatic update install card must not keep the redundant helper sentence'
|
||||||
|
fi
|
||||||
if grep -Fq "펌웨어 {data.release.version || '미확인'} · OpenWrt" "$FIRMWARE_CARD"; then
|
if grep -Fq "펌웨어 {data.release.version || '미확인'} · OpenWrt" "$FIRMWARE_CARD"; then
|
||||||
fail 'available firmware summary must not expose the OpenWrt base version'
|
fail 'available firmware summary must not expose the OpenWrt base version'
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -571,7 +571,8 @@ const methods = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getMountPoints: {
|
getMountPoints: {
|
||||||
call: function() {
|
args: { root_only: false },
|
||||||
|
call: function(request) {
|
||||||
const fd = open('/proc/mounts', 'r');
|
const fd = open('/proc/mounts', 'r');
|
||||||
|
|
||||||
if (fd) {
|
if (fd) {
|
||||||
@@ -581,6 +582,11 @@ const methods = {
|
|||||||
const m = split(line, ' ');
|
const m = split(line, ' ');
|
||||||
const device = replace(m[0], /\\([0-9][0-9][0-9])/g, (m, n) => chr(int(n, 8)));
|
const device = replace(m[0], /\\([0-9][0-9][0-9])/g, (m, n) => chr(int(n, 8)));
|
||||||
const mount = replace(m[1], /\\([0-9][0-9][0-9])/g, (m, n) => chr(int(n, 8)));
|
const mount = replace(m[1], /\\([0-9][0-9][0-9])/g, (m, n) => chr(int(n, 8)));
|
||||||
|
// The overview only needs root storage. Avoid waking disks or
|
||||||
|
// scanning FAT free space on unrelated mounts during first login.
|
||||||
|
if (request.args.root_only && mount != '/' && mount != '/overlay')
|
||||||
|
continue;
|
||||||
|
|
||||||
const stat = statvfs(mount);
|
const stat = statvfs(mount);
|
||||||
|
|
||||||
if (stat?.blocks > 0) {
|
if (stat?.blocks > 0) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk
|
|||||||
LUCI_TITLE:=LuCI Status Pages
|
LUCI_TITLE:=LuCI Status Pages
|
||||||
LUCI_DEPENDS:=+luci-base +luci-app-oui +luci-mod-dashboard +libiwinfo +rpcd-mod-iwinfo +rpcd-mod-rrdns +ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-socket
|
LUCI_DEPENDS:=+luci-base +luci-app-oui +luci-mod-dashboard +libiwinfo +rpcd-mod-iwinfo +rpcd-mod-rrdns +ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-socket
|
||||||
|
|
||||||
PKG_RELEASE:=33
|
PKG_RELEASE:=34
|
||||||
PKG_BUILD_DEPENDS:=iwinfo
|
PKG_BUILD_DEPENDS:=iwinfo
|
||||||
PKG_LICENSE:=Apache-2.0
|
PKG_LICENSE:=Apache-2.0
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
var callMountPoints = rpc.declare({
|
var callMountPoints = rpc.declare({
|
||||||
object: 'luci',
|
object: 'luci',
|
||||||
method: 'getMountPoints',
|
method: 'getMountPoints',
|
||||||
|
params: [ 'root_only' ],
|
||||||
|
nobatch: true,
|
||||||
expect: { result: [] }
|
expect: { result: [] }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ return baseclass.extend({
|
|||||||
|
|
||||||
load: function() {
|
load: function() {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
L.resolveDefault(callMountPoints(), {}),
|
L.resolveDefault(callMountPoints(true), []),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
const callIwinfoAssoclistCompat = rpc.declare({
|
const callIwinfoAssoclistCompat = rpc.declare({
|
||||||
object: 'iwinfo',
|
object: 'iwinfo',
|
||||||
|
nobatch: true,
|
||||||
method: 'assoclist',
|
method: 'assoclist',
|
||||||
params: [ 'device' ],
|
params: [ 'device' ],
|
||||||
expect: { results: [] }
|
expect: { results: [] }
|
||||||
@@ -16,6 +17,7 @@ const callIwinfoAssoclistCompat = rpc.declare({
|
|||||||
|
|
||||||
const callIwinfoInfoCompat = rpc.declare({
|
const callIwinfoInfoCompat = rpc.declare({
|
||||||
object: 'iwinfo',
|
object: 'iwinfo',
|
||||||
|
nobatch: true,
|
||||||
method: 'info',
|
method: 'info',
|
||||||
params: [ 'device' ],
|
params: [ 'device' ],
|
||||||
expect: {}
|
expect: {}
|
||||||
@@ -23,6 +25,7 @@ const callIwinfoInfoCompat = rpc.declare({
|
|||||||
|
|
||||||
const callIwinfoDevices = rpc.declare({
|
const callIwinfoDevices = rpc.declare({
|
||||||
object: 'iwinfo',
|
object: 'iwinfo',
|
||||||
|
nobatch: true,
|
||||||
method: 'devices',
|
method: 'devices',
|
||||||
expect: { devices: [] }
|
expect: { devices: [] }
|
||||||
});
|
});
|
||||||
@@ -31,6 +34,27 @@ function isQcaWifiHwtype(hwtype) {
|
|||||||
return (hwtype == 'qcawifi' || hwtype == 'qcawificfg80211');
|
return (hwtype == 'qcawifi' || hwtype == 'qcawificfg80211');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConfigWifiHwtype(hwtype) {
|
||||||
|
return isQcaWifiHwtype(hwtype) || ['mtwifi', 'mt_dbdc', 'mtdbdc', 'ralink'].includes(hwtype);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredIfname(iface) {
|
||||||
|
if (iface.ifname)
|
||||||
|
return iface.ifname;
|
||||||
|
const hwtype = uci.get('wireless', iface.device, 'type');
|
||||||
|
if (isQcaWifiHwtype(hwtype))
|
||||||
|
return getQcaFallbackIfname(iface.device, iface['.name']);
|
||||||
|
// Only use real VIF names, never invent a primary interface for a VAP.
|
||||||
|
if (isConfigWifiHwtype(hwtype) && /^(ra[xiyez]?|apcli[xiyez]?)\d+$/.test(iface['.name']))
|
||||||
|
return iface['.name'];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useConfiguredWifi() {
|
||||||
|
const radios = uci.sections('wireless', 'wifi-device');
|
||||||
|
return radios.length > 0 && radios.every((radio) => isConfigWifiHwtype(radio.type));
|
||||||
|
}
|
||||||
|
|
||||||
function pushUnique(list, value) {
|
function pushUnique(list, value) {
|
||||||
if (value && list.indexOf(value) < 0)
|
if (value && list.indexOf(value) < 0)
|
||||||
list.push(value);
|
list.push(value);
|
||||||
@@ -62,9 +86,9 @@ function getResolvedIwinfoDeviceName(net, deviceLookup) {
|
|||||||
const device = net.getWifiDeviceName();
|
const device = net.getWifiDeviceName();
|
||||||
const fallback = getQcaFallbackIfname(device, section);
|
const fallback = getQcaFallbackIfname(device, section);
|
||||||
const configuredIfname = uci.get('wireless', section, 'ifname');
|
const configuredIfname = uci.get('wireless', section, 'ifname');
|
||||||
const isQca = isQcaWifiHwtype(uci.get('wireless', device, 'type'));
|
const isConfig = isConfigWifiHwtype(uci.get('wireless', device, 'type'));
|
||||||
|
|
||||||
for (const candidate of [ configuredIfname, ifname, section, fallback, isQca ? null : device ])
|
for (const candidate of [ configuredIfname, ifname, section, fallback, isConfig ? null : device ])
|
||||||
if (candidate && deviceLookup[candidate])
|
if (candidate && deviceLookup[candidate])
|
||||||
return candidate;
|
return candidate;
|
||||||
|
|
||||||
@@ -110,7 +134,7 @@ function buildIwinfoResolver(radios, networks, devices) {
|
|||||||
radios.forEach((radio) => {
|
radios.forEach((radio) => {
|
||||||
const name = radio.getName();
|
const name = radio.getName();
|
||||||
if (uci.get('wireless', name, 'disabled') == '1' ||
|
if (uci.get('wireless', name, 'disabled') == '1' ||
|
||||||
isQcaWifiHwtype(uci.get('wireless', name, 'type')))
|
isConfigWifiHwtype(uci.get('wireless', name, 'type')))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const target = radioTargets[name] || (deviceLookup[name] ? name : null);
|
const target = radioTargets[name] || (deviceLookup[name] ? name : null);
|
||||||
@@ -613,18 +637,15 @@ return baseclass.extend({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
loadIwinfoResolver(radios, networks) {
|
loadIwinfoResolver(radios, networks) {
|
||||||
// Query enabled QCA VAPs only: probing a bare wifiN may create a
|
// Vendor radios are queried through configured VIFs, not global probes.
|
||||||
// temporary VAP and block rpcd while the disabled radio starts up.
|
const configOnly = useConfiguredWifi();
|
||||||
const configuredRadios = uci.sections('wireless', 'wifi-device');
|
|
||||||
const configOnly = configuredRadios.length > 0 &&
|
|
||||||
configuredRadios.every((radio) => isQcaWifiHwtype(radio.type));
|
|
||||||
const configuredDevices = [];
|
const configuredDevices = [];
|
||||||
|
|
||||||
for (const iface of uci.sections('wireless', 'wifi-iface')) {
|
for (const iface of uci.sections('wireless', 'wifi-iface')) {
|
||||||
if (iface.disabled == '1' || uci.get('wireless', iface.device, 'disabled') == '1')
|
if (iface.disabled == '1' || uci.get('wireless', iface.device, 'disabled') == '1')
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
pushUnique(configuredDevices, iface.ifname || getQcaFallbackIfname(iface.device, iface['.name']));
|
pushUnique(configuredDevices, getConfiguredIfname(iface));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (configOnly ? Promise.resolve(configuredDevices) : L.resolveDefault(callIwinfoDevices(), [])).then((devices) => {
|
return (configOnly ? Promise.resolve(configuredDevices) : L.resolveDefault(callIwinfoDevices(), [])).then((devices) => {
|
||||||
@@ -662,6 +683,16 @@ return baseclass.extend({
|
|||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
|
|
||||||
const hwtype = uci.get('wireless', net.getWifiDeviceName(), 'type');
|
const hwtype = uci.get('wireless', net.getWifiDeviceName(), 'type');
|
||||||
|
if (isConfigWifiHwtype(hwtype)) {
|
||||||
|
const target = this.iwinfoResolver?.aliasMap[net.getName()];
|
||||||
|
if (!target)
|
||||||
|
return Promise.resolve([]);
|
||||||
|
return L.resolveDefault(callIwinfoAssoclistCompat(target), []).then((entries) => {
|
||||||
|
if (entries.length || !isQcaWifiHwtype(hwtype) || net.getMode() != 'ap')
|
||||||
|
return entries;
|
||||||
|
return callWlanconfigAssoclistCompat(target);
|
||||||
|
});
|
||||||
|
}
|
||||||
const candidates = getAssocListCandidates(net, this.iwinfoResolver);
|
const candidates = getAssocListCandidates(net, this.iwinfoResolver);
|
||||||
const resolvedIfname = candidates[0];
|
const resolvedIfname = candidates[0];
|
||||||
const useResolvedIfname = (isQcaWifiHwtype(hwtype) && resolvedIfname && resolvedIfname != net.getIfname());
|
const useResolvedIfname = (isQcaWifiHwtype(hwtype) && resolvedIfname && resolvedIfname != net.getIfname());
|
||||||
@@ -765,6 +796,8 @@ return baseclass.extend({
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderbox(radio, networks) {
|
renderbox(radio, networks) {
|
||||||
|
const radioUp = radio.isUp() ||
|
||||||
|
(uci.get('wireless', radio.getName(), 'disabled') != '1' && this.iwinfoInfoMap?.[radio.getName()]?.mode);
|
||||||
let chan = null;
|
let chan = null;
|
||||||
let freq = null;
|
let freq = null;
|
||||||
let rate = null;
|
let rate = null;
|
||||||
@@ -841,7 +874,7 @@ return baseclass.extend({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return E('div', { class: 'ifacebox' }, [
|
return E('div', { class: 'ifacebox' }, [
|
||||||
E('div', { class: 'ifacebox-head center ' + (radio.isUp() ? 'active' : '') },
|
E('div', { class: 'ifacebox-head center ' + (radioUp ? 'active' : '') },
|
||||||
E('strong', radio.getName())),
|
E('strong', radio.getName())),
|
||||||
E('div', { class: 'ifacebox-body left' }, [
|
E('div', { class: 'ifacebox-body left' }, [
|
||||||
L.itemlist(E('span'), [
|
L.itemlist(E('span'), [
|
||||||
@@ -860,15 +893,14 @@ return baseclass.extend({
|
|||||||
isWPSEnabled: {},
|
isWPSEnabled: {},
|
||||||
|
|
||||||
load() {
|
load() {
|
||||||
return Promise.all([
|
return L.resolveDefault(uci.load('wireless')).then(() => Promise.all([
|
||||||
network.getWifiDevices(),
|
useConfiguredWifi() ? network.getWifiDevicesFromConfig() : network.getWifiDevices(),
|
||||||
network.getWifiNetworks(),
|
useConfiguredWifi() ? network.getWifiNetworksFromConfig() : network.getWifiNetworks(),
|
||||||
network.getHostHints(),
|
network.getHostHints(),
|
||||||
this.callSessionAccess('access-group', 'luci-mod-status-index-wifi', 'read'),
|
this.callSessionAccess('access-group', 'luci-mod-status-index-wifi', 'read'),
|
||||||
this.callSessionAccess('access-group', 'luci-mod-status-index-wifi', 'write'),
|
this.callSessionAccess('access-group', 'luci-mod-status-index-wifi', 'write'),
|
||||||
firewall.getZones(),
|
firewall.getZones(),
|
||||||
L.hasSystemFeature('wifi') ? L.resolveDefault(uci.load('wireless')) : L.resolveDefault(),
|
])).then(L.bind(data => {
|
||||||
]).then(L.bind(data => {
|
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
const radios_networks_hints = data[1];
|
const radios_networks_hints = data[1];
|
||||||
const hasWPS = L.hasSystemFeature('hostapd', 'wps');
|
const hasWPS = L.hasSystemFeature('hostapd', 'wps');
|
||||||
@@ -901,6 +933,23 @@ return baseclass.extend({
|
|||||||
}, this)));
|
}, this)));
|
||||||
|
|
||||||
return Promise.all(tasks).then(() => {
|
return Promise.all(tasks).then(() => {
|
||||||
|
if (useConfiguredWifi()) {
|
||||||
|
const states = {};
|
||||||
|
for (const radio of data[0]) {
|
||||||
|
const name = radio.getName();
|
||||||
|
const info = this.iwinfoInfoMap[name];
|
||||||
|
states[name] = { iwinfo: info || {}, up: !!info?.mode && uci.get('wireless', name, 'disabled') != '1' };
|
||||||
|
}
|
||||||
|
data[0] = data[0].map((radio) => network.instantiateWifiDevice(radio.getName(), states[radio.getName()]));
|
||||||
|
data[1] = data[1].map((net) => {
|
||||||
|
const target = resolver.aliasMap[net.getName()];
|
||||||
|
const live = network.instantiateWifiNetwork(net.getName(), net.getWifiDeviceName(),
|
||||||
|
states[net.getWifiDeviceName()], net.getID(), { ifname: target, iwinfo: this.iwinfoInfoMap[target] || {} });
|
||||||
|
for (const key of ['assoclist', 'isWPSEnabled', 'wps_status'])
|
||||||
|
live[key] = net[key];
|
||||||
|
return live;
|
||||||
|
});
|
||||||
|
}
|
||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
}, this));
|
}, this));
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ return view.extend({
|
|||||||
this._include = include;
|
this._include = include;
|
||||||
this._loading = null;
|
this._loading = null;
|
||||||
|
|
||||||
|
if (include != null)
|
||||||
|
this.disableCache = include.disableCache;
|
||||||
|
|
||||||
if (include != null && include.title != null)
|
if (include != null && include.title != null)
|
||||||
this.title = include.title;
|
this.title = include.title;
|
||||||
|
|
||||||
@@ -150,16 +153,20 @@ return view.extend({
|
|||||||
if (include.hide && !first_load)
|
if (include.hide && !first_load)
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
|
|
||||||
if (!include.hide && !container.hasAttribute('data-cached'))
|
if (include._pendingLoad)
|
||||||
|
return include._pendingLoad;
|
||||||
|
|
||||||
|
if (first_load && !include.hide && !container.hasAttribute('data-cached'))
|
||||||
this.renderIncludePlaceholder(container);
|
this.renderIncludePlaceholder(container);
|
||||||
|
|
||||||
if (typeof(include.load) == 'function') {
|
if (typeof(include.load) == 'function') {
|
||||||
return include.load().then(L.bind(function(result) {
|
include._pendingLoad = Promise.resolve().then(function() { return include.load(); }).then(L.bind(function(result) {
|
||||||
this.applyIncludeResult(include, container, result, first_load);
|
this.applyIncludeResult(include, container, result, first_load);
|
||||||
return result;
|
return result;
|
||||||
}, this)).catch(function() {
|
}, this)).catch(function() {
|
||||||
return null;
|
return null;
|
||||||
});
|
}).finally(function() { include._pendingLoad = null; });
|
||||||
|
return include._pendingLoad;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.applyIncludeResult(include, container, null, first_load);
|
this.applyIncludeResult(include, container, null, first_load);
|
||||||
@@ -188,43 +195,13 @@ return view.extend({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
invokeIncludesLoad: function(includes, first_load) {
|
|
||||||
var tasks = [];
|
|
||||||
|
|
||||||
for (var i = 0; i < includes.length; i++) {
|
|
||||||
if (includes[i].hide && !first_load) {
|
|
||||||
tasks.push(Promise.resolve(null));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof(includes[i].load) == 'function') {
|
|
||||||
tasks.push(Promise.resolve(includes[i].load()).catch(function() {
|
|
||||||
return null;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
tasks.push(Promise.resolve(null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.all(tasks);
|
|
||||||
},
|
|
||||||
|
|
||||||
poll_status: function(includes, containers, first_load) {
|
poll_status: function(includes, containers, first_load) {
|
||||||
var fetch = first_load ? Promise.resolve(null) : network.flushCache();
|
var fetch = first_load ? Promise.resolve(null) : network.flushCache();
|
||||||
|
|
||||||
return fetch.then(L.bind(
|
return fetch.then(L.bind(function() {
|
||||||
this.invokeIncludesLoad, this, includes, first_load
|
// Slow wireless queries must not hold up other cards or their next poll.
|
||||||
)).then(L.bind(function(results) {
|
this.loadIncludeGroup(includes, containers, true, first_load);
|
||||||
for (var i = 0; i < includes.length; i++) {
|
return this.loadIncludeGroup(includes, containers, false, first_load);
|
||||||
this.applyIncludeResult(includes[i], containers[i], results ? results[i] : null, first_load);
|
|
||||||
}
|
|
||||||
|
|
||||||
var ssi = document.querySelector('div.includes');
|
|
||||||
if (ssi) {
|
|
||||||
ssi.style.display = '';
|
|
||||||
ssi.classList.add('fade-in');
|
|
||||||
}
|
|
||||||
}, this));
|
}, this));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -37,3 +37,15 @@ mtwifi/mt_dbdc/ralink radios. It does not start a repeating scan poll or stop
|
|||||||
LuCI's global poller. The refresh button is enabled immediately on completion or failure;
|
LuCI's global poller. The refresh button is enabled immediately on completion or failure;
|
||||||
requests are coalesced per radio and serialized across radios. Run
|
requests are coalesced per radio and serialized across radios. Run
|
||||||
`node modules/luci-mod-status/tests/channel-analysis.js` from the LuCI root.
|
`node modules/luci-mod-status/tests/channel-analysis.js` from the LuCI root.
|
||||||
|
|
||||||
|
Run `node modules/luci-mod-status/tests/overview-async.js` to check that a
|
||||||
|
pending wireless query does not delay other overview cards or their next
|
||||||
|
refresh, duplicate itself, or prevent retry after failure. Run
|
||||||
|
`node modules/luci-mod-status/tests/wifi-disabled.js` for configured QCA and
|
||||||
|
MediaTek VIF selection, disabled interfaces and mixed-backend handling.
|
||||||
|
|
||||||
|
Run `node modules/luci-mod-status/tests/mount-points.js` to verify that the
|
||||||
|
homepage's root-only storage query never calls statvfs on unrelated mounts,
|
||||||
|
while existing callers without the option still receive the complete list.
|
||||||
|
Cold-boot validation should keep a FAT USB volume mounted: its initial free-space
|
||||||
|
scan must not delay the overview's system, memory or storage cards.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '../../luci-base/root/usr/share/rpcd/ucode/luci'), 'utf8');
|
||||||
|
const method = source.slice(source.indexOf('\tgetMountPoints: {'), source.indexOf('\tgetRealtimeStats: {'));
|
||||||
|
let visited = [], closed = false;
|
||||||
|
const lines = ['/dev/root /rom squashfs ro 0 0', '/dev/ubi0_5 /overlay ubifs rw 0 0',
|
||||||
|
'overlayfs:/overlay / overlay rw 0 0', '/dev/sda1 /mnt/USB\\040Disk vfat rw 0 0'];
|
||||||
|
const api = new Function('open', 'split', 'replace', 'chr', 'int', 'statvfs', 'length', 'push',
|
||||||
|
'return ({' + method + '}).getMountPoints;')(
|
||||||
|
() => { let i = 0; return {read: () => lines[i++] || '', close: () => {closed = true;}}; },
|
||||||
|
(s, sep) => s.split(sep), (s, re, fn) => s.replace(re, fn), String.fromCharCode, parseInt,
|
||||||
|
mount => {visited.push(mount); return {blocks: 100, bsize: 4096, bavail: 20, bfree: 25};},
|
||||||
|
s => s.length, (a, v) => a.push(v));
|
||||||
|
const roots = api.call({args: {root_only: true}}).result;
|
||||||
|
assert.deepEqual(visited, ['/overlay', '/'], 'overview must not stat unrelated filesystems');
|
||||||
|
assert.deepEqual(roots.map(m => m.mount), visited);
|
||||||
|
assert.equal(roots[0].size, 409600);
|
||||||
|
assert(closed);
|
||||||
|
visited = []; closed = false;
|
||||||
|
const all = api.call({args: {}}).result;
|
||||||
|
assert.deepEqual(visited, ['/rom', '/overlay', '/', '/mnt/USB Disk']);
|
||||||
|
assert.equal(all.length, 4, 'existing callers must retain all mounts');
|
||||||
|
assert(closed);
|
||||||
|
console.log('Root-only storage query and backward-compatible full mount listing passed');
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '../htdocs/luci-static/resources/view/status/index.js'), 'utf8');
|
||||||
|
const view = new Function('view', 'network', 'L', source)(
|
||||||
|
{extend: o => o}, {flushCache: () => Promise.resolve()},
|
||||||
|
{bind: (fn, ctx, ...args) => fn.bind(ctx, ...args)});
|
||||||
|
(async () => {
|
||||||
|
let release, queries = 0;
|
||||||
|
const results = [];
|
||||||
|
const slow = {deferFirstLoad: true, load: () => { queries++; return new Promise(r => {release = r;}); }};
|
||||||
|
const fast = {load: () => Promise.resolve('system ready')};
|
||||||
|
const container = {hasAttribute: () => true};
|
||||||
|
view.applyIncludeResult = (include, el, result) => results.push(result);
|
||||||
|
await view.poll_status([slow, fast], [container, container], false);
|
||||||
|
assert.deepEqual(results, ['system ready'], 'slow wireless must not block system rendering');
|
||||||
|
await view.poll_status([slow, fast], [container, container], false);
|
||||||
|
assert.equal(queries, 1, 'polls must not duplicate an outstanding wireless query');
|
||||||
|
assert.equal(results.length, 2, 'system must continue refreshing while wireless is pending');
|
||||||
|
release('wireless ready');
|
||||||
|
await slow._pendingLoad;
|
||||||
|
assert.equal(results[2], 'wireless ready');
|
||||||
|
assert.equal(slow._pendingLoad, null);
|
||||||
|
slow.load = () => Promise.reject(new Error('radio not ready'));
|
||||||
|
await view.poll_status([slow, fast], [container, container], false);
|
||||||
|
await slow._pendingLoad;
|
||||||
|
assert.equal(slow._pendingLoad, null, 'failed wireless query must be retried on a later poll');
|
||||||
|
console.log('Overview asynchronous rendering, in-flight coalescing and failure retry passed');
|
||||||
|
})().catch(e => {console.error(e); process.exit(1);});
|
||||||
@@ -61,5 +61,26 @@ function networks() { return uci.sections('wireless', 'wifi-iface').map(s => ({
|
|||||||
config.wifi0.disabled = '0';
|
config.wifi0.disabled = '0';
|
||||||
resolver = await view.loadIwinfoResolver(radios(), networks());
|
resolver = await view.loadIwinfoResolver(radios(), networks());
|
||||||
assert.deepEqual(resolver.queryTargets, ['radio0'], 'mixed setups must not probe bare QCA radios');
|
assert.deepEqual(resolver.queryTargets, ['radio0'], 'mixed setups must not probe bare QCA radios');
|
||||||
|
for (const type of ['mtwifi', 'mt_dbdc', 'mtdbdc', 'ralink']) {
|
||||||
|
config = {
|
||||||
|
ra: {'.type':'wifi-device', type}, rai: {'.type':'wifi-device', type},
|
||||||
|
ra0: {'.type':'wifi-iface', device:'ra', ifname:'ra0'},
|
||||||
|
cfg5g: {'.type':'wifi-iface', device:'rai', ifname:'rai0'},
|
||||||
|
rai1: {'.type':'wifi-iface', device:'rai', disabled:'1'}
|
||||||
|
};
|
||||||
|
calls = [];
|
||||||
|
resolver = await view.loadIwinfoResolver(radios(), networks());
|
||||||
|
assert.deepEqual(resolver.queryTargets, ['ra0', 'rai0']);
|
||||||
|
assert.equal(resolver.aliasMap.cfg5g, 'rai0');
|
||||||
|
view.iwinfoResolver = resolver;
|
||||||
|
await view.loadIwinfoInfoMap(resolver);
|
||||||
|
for (const net of networks()) await view.getAssocListForNetwork(net);
|
||||||
|
assert.deepEqual(calls, [['info','ra0'], ['info','rai0'], ['assoclist','ra0'], ['assoclist','rai0']],
|
||||||
|
type + ': query only enabled real VIFs, without enumeration or synthetic netids');
|
||||||
|
delete config.ra0.ifname;
|
||||||
|
config.cfg5g.disabled = '1';
|
||||||
|
resolver = await view.loadIwinfoResolver(radios(), networks());
|
||||||
|
assert.deepEqual(resolver.queryTargets, ['ra0'], 'legacy section name is a valid VIF');
|
||||||
|
}
|
||||||
console.log('Wi-Fi status: disabled radios/VAPs, active VAP, custom ifname, missing VAP and mixed backends passed');
|
console.log('Wi-Fi status: disabled radios/VAPs, active VAP, custom ifname, missing VAP and mixed backends passed');
|
||||||
})().catch(e => {console.error(e); process.exit(1);});
|
})().catch(e => {console.error(e); process.exit(1);});
|
||||||
+2
-2
@@ -6,11 +6,11 @@ include $(TOPDIR)/rules.mk
|
|||||||
|
|
||||||
PKG_NAME:=redsocks2
|
PKG_NAME:=redsocks2
|
||||||
PKG_VERSION:=0.72
|
PKG_VERSION:=0.72
|
||||||
PKG_RELEASE:=3
|
PKG_RELEASE:=4
|
||||||
|
|
||||||
PKG_SOURCE_PROTO:=git
|
PKG_SOURCE_PROTO:=git
|
||||||
PKG_SOURCE_URL:=https://github.com/semigodking/redsocks.git
|
PKG_SOURCE_URL:=https://github.com/semigodking/redsocks.git
|
||||||
PKG_SOURCE_DATE:=2025-12-12
|
PKG_SOURCE_DATE:=2026-09-10
|
||||||
PKG_SOURCE_VERSION:=afca1de29e36005d7f90c7b003abd5b3efc20964
|
PKG_SOURCE_VERSION:=afca1de29e36005d7f90c7b003abd5b3efc20964
|
||||||
PKG_MIRROR_HASH:=skip
|
PKG_MIRROR_HASH:=skip
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user