mirror of
https://github.com/evilsocket/opensnitch.git
synced 2026-05-09 20:22:26 +00:00
Merge pull request #1561 from nvandamme/list_subscriptions
[Feature] Blocklist subscriptions plugin with auto scheduled downloads and management UI
This commit is contained in:
+1
-1
@@ -4,4 +4,4 @@
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# This file is part of OpenSnitch.
|
||||
#
|
||||
# OpenSnitch is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# OpenSnitch is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OpenSnitch. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
|
||||
class RuleOperatorLike(Protocol):
|
||||
operand: str
|
||||
data: str | None
|
||||
list: list["RuleOperatorLike"]
|
||||
|
||||
|
||||
class RuleLike(Protocol):
|
||||
name: str | None
|
||||
enabled: bool
|
||||
operator: RuleOperatorLike
|
||||
|
||||
|
||||
class StatsDialogProto(Protocol):
|
||||
"""Typed subset of StatsDialog used by the plugin.
|
||||
|
||||
actionsButton is injected by uic from stats.ui and otherwise appears
|
||||
as unknown to static analyzers.
|
||||
"""
|
||||
|
||||
actionsButton: "QtWidgets.QPushButton"
|
||||
|
||||
def windowIcon(self) -> "QtGui.QIcon": ...
|
||||
|
||||
|
||||
class RulesEditorDialogProto(Protocol):
|
||||
"""Typed subset of RulesEditorDialog used by the plugin controllers."""
|
||||
|
||||
_old_rule_name: str
|
||||
buttonBox: "QtWidgets.QDialogButtonBox"
|
||||
ruleNameEdit: "QtWidgets.QLineEdit"
|
||||
ruleDescEdit: "QtWidgets.QPlainTextEdit"
|
||||
nodesCombo: "QtWidgets.QComboBox"
|
||||
nodeApplyAllCheck: "QtWidgets.QCheckBox"
|
||||
uidCombo: "QtWidgets.QComboBox"
|
||||
uidCheck: "QtWidgets.QCheckBox"
|
||||
enableCheck: "QtWidgets.QCheckBox"
|
||||
durationCombo: "QtWidgets.QComboBox"
|
||||
dstListsCheck: "QtWidgets.QCheckBox"
|
||||
dstListsLine: "QtWidgets.QLineEdit"
|
||||
|
||||
def installEventFilter(self, filterObj: "QtCore.QObject") -> None: ...
|
||||
|
||||
def hide(self) -> None: ...
|
||||
|
||||
def raise_(self) -> None: ...
|
||||
|
||||
def activateWindow(self) -> None: ...
|
||||
|
||||
def new_rule(self) -> None: ...
|
||||
|
||||
def edit_rule(self, records: Any, _addr: str | None = None) -> None: ...
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Runtime compatibility shim for StatsDialog across OpenSnitch versions."""
|
||||
|
||||
|
||||
# Runtime class kept for isinstance checks.
|
||||
try:
|
||||
from opensnitch.dialogs.events import StatsDialog
|
||||
except ImportError:
|
||||
from opensnitch.dialogs.stats import StatsDialog # type: ignore[assignment]
|
||||
|
||||
__all__ = ["StatsDialog"]
|
||||
@@ -0,0 +1,394 @@
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Final
|
||||
from urllib.parse import urlparse, unquote
|
||||
|
||||
from opensnitch.utils.xdg import xdg_config_home
|
||||
|
||||
ACTION_FILE: Final[str] = os.path.join(
|
||||
xdg_config_home, "opensnitch", "actions", "list_subscriptions.json"
|
||||
)
|
||||
DEFAULT_LISTS_DIR: Final[str] = os.path.join(
|
||||
xdg_config_home, "opensnitch", "list_subscriptions"
|
||||
)
|
||||
PLUGIN_DIR: Final[str] = os.path.abspath(os.path.dirname(__file__))
|
||||
RES_DIR: Final[str] = os.path.join(PLUGIN_DIR, "res")
|
||||
|
||||
INTERVAL_UNITS: Final[tuple[str, ...]] = (
|
||||
"seconds",
|
||||
"minutes",
|
||||
"hours",
|
||||
"days",
|
||||
"weeks",
|
||||
)
|
||||
TIMEOUT_UNITS: Final[tuple[str, ...]] = ("seconds", "minutes", "hours", "days", "weeks")
|
||||
SIZE_UNITS: Final[tuple[str, ...]] = ("bytes", "KB", "MB", "GB")
|
||||
TIME_MULT: Final[dict[str, int]] = {
|
||||
"seconds": 1,
|
||||
"minutes": 60,
|
||||
"hours": 60 * 60,
|
||||
"days": 24 * 60 * 60,
|
||||
"weeks": 7 * 24 * 60 * 60,
|
||||
"s": 1,
|
||||
"m": 60,
|
||||
"h": 60 * 60,
|
||||
"d": 24 * 60 * 60,
|
||||
"w": 7 * 24 * 60 * 60,
|
||||
}
|
||||
SHORT_TIME_MULT: Final[dict[str, int]] = {
|
||||
"s": TIME_MULT["seconds"],
|
||||
"m": TIME_MULT["minutes"],
|
||||
"h": TIME_MULT["hours"],
|
||||
"d": TIME_MULT["days"],
|
||||
"w": TIME_MULT["weeks"],
|
||||
}
|
||||
SIZE_MULT: Final[dict[str, int]] = {
|
||||
"bytes": 1,
|
||||
"kb": 1024,
|
||||
"mb": 1024 * 1024,
|
||||
"gb": 1024 * 1024 * 1024,
|
||||
}
|
||||
|
||||
DEFAULT_UA: Final[str] = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0"
|
||||
)
|
||||
|
||||
DEFAULT_NOTIFY_CONFIG: Final[dict[str, dict[str, str]]] = {
|
||||
"success": {"desktop": "Lists subscriptions updated"},
|
||||
"error": {"desktop": "Error updating lists subscriptions"},
|
||||
}
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.now().astimezone().isoformat()
|
||||
|
||||
|
||||
def parse_iso(ts: str):
|
||||
try:
|
||||
return datetime.fromisoformat(ts)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def normalize_iso_timestamp(value: Any, fallback: str | None = None):
|
||||
text = str(value or "").strip()
|
||||
if text != "" and parse_iso(text) is not None:
|
||||
return text
|
||||
if fallback:
|
||||
return fallback
|
||||
return now_iso()
|
||||
|
||||
|
||||
def opt_int(value: Any):
|
||||
try:
|
||||
return int(value) if value is not None else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def opt_str(value: Any):
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = (str(value) or "").strip().lower()
|
||||
return normalized if normalized != "" else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def display_str(value: Any):
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def strip_or_none(value: Any):
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def normalize_lists_dir(path: str | None):
|
||||
default_dir = os.path.join(xdg_config_home, "opensnitch", "list_subscriptions")
|
||||
raw = (path or "").strip()
|
||||
if raw == "":
|
||||
raw = default_dir
|
||||
expanded = os.path.expandvars(os.path.expanduser(raw))
|
||||
if not os.path.isabs(expanded):
|
||||
return os.path.abspath(expanded)
|
||||
return expanded
|
||||
|
||||
|
||||
def is_valid_url(value: str | None):
|
||||
parsed = urlparse(str(value or "").strip())
|
||||
return parsed.scheme in {"http", "https"} and parsed.netloc != ""
|
||||
|
||||
|
||||
def safe_filename(value: Any):
|
||||
return os.path.basename((str(value or "")).strip())
|
||||
|
||||
|
||||
def normalized_list_type(value: str | None):
|
||||
return (value or "hosts").strip().lower()
|
||||
|
||||
|
||||
def filename_from_url(url: str | None):
|
||||
try:
|
||||
parsed = urlparse((url or "").strip())
|
||||
return safe_filename(unquote(parsed.path or ""))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def slugify_name(name: str | None):
|
||||
raw = (name or "").strip().lower()
|
||||
if raw == "":
|
||||
return "subscription.list"
|
||||
slug = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-._")
|
||||
if slug == "":
|
||||
slug = "subscription"
|
||||
if "." not in slug:
|
||||
slug += ".list"
|
||||
return safe_filename(slug)
|
||||
|
||||
|
||||
def deslugify_filename(filename: str | None, list_type: str | None):
|
||||
safe = safe_filename(filename)
|
||||
base, _ext = os.path.splitext(safe)
|
||||
suffix = f"-{normalized_list_type(list_type)}"
|
||||
if base.lower().endswith(suffix):
|
||||
base = base[: -len(suffix)]
|
||||
pretty = re.sub(r"[-_.]+", " ", base).strip()
|
||||
pretty = re.sub(r"\s+", " ", pretty)
|
||||
if pretty == "":
|
||||
return safe
|
||||
return pretty.title()
|
||||
|
||||
|
||||
def filename_from_content_disposition(value: str | None):
|
||||
cd = str(value or "").strip()
|
||||
if cd == "":
|
||||
return ""
|
||||
filename = ""
|
||||
m_star = re.search(
|
||||
r'filename\*\s*=\s*[^\'";]+\'[^\'";]*\'([^;]+)', cd, re.IGNORECASE
|
||||
)
|
||||
if m_star:
|
||||
filename = unquote(m_star.group(1).strip().strip('"'))
|
||||
if filename == "":
|
||||
params = {}
|
||||
try:
|
||||
raw_params = ";".join(cd.split(";")[1:])
|
||||
for part in raw_params.split(";"):
|
||||
if "=" not in part:
|
||||
continue
|
||||
key, raw_value = part.split("=", 1)
|
||||
params[key.strip().lower()] = raw_value.strip().strip('"')
|
||||
except Exception:
|
||||
params = {}
|
||||
raw = params.get("filename")
|
||||
if raw:
|
||||
filename = unquote(str(raw)).strip()
|
||||
return safe_filename(filename)
|
||||
|
||||
|
||||
def derive_filename(
|
||||
name: str | None,
|
||||
url: str | None,
|
||||
filename: str | None,
|
||||
header_filename: str | None = None,
|
||||
):
|
||||
fn = safe_filename(header_filename)
|
||||
if fn != "":
|
||||
return fn
|
||||
fn = safe_filename(filename)
|
||||
if fn != "":
|
||||
return fn
|
||||
fn = filename_from_url(url)
|
||||
if fn != "":
|
||||
return fn
|
||||
return slugify_name(name)
|
||||
|
||||
|
||||
def ensure_filename_type_suffix(filename: str, list_type: str):
|
||||
fn = safe_filename(filename)
|
||||
base, ext = os.path.splitext(fn)
|
||||
ltype = normalized_list_type(list_type)
|
||||
suffix = f"-{ltype}"
|
||||
if not base.lower().endswith(suffix):
|
||||
base = f"{base}{suffix}" if base else ltype
|
||||
if ext == "":
|
||||
ext = ".txt"
|
||||
return safe_filename(f"{base}{ext}")
|
||||
|
||||
|
||||
def normalized_subscription_filename(filename: str | None, list_type: str | None):
|
||||
safe_name = safe_filename(filename)
|
||||
if safe_name == "":
|
||||
safe_name = "subscription.list"
|
||||
return ensure_filename_type_suffix(safe_name, normalized_list_type(list_type))
|
||||
|
||||
|
||||
def subscription_dirname(filename: str | None, list_type: str | None):
|
||||
safe_name = normalized_subscription_filename(filename, list_type)
|
||||
base, _ext = os.path.splitext(safe_name)
|
||||
normalized_type = normalized_list_type(list_type)
|
||||
suffix = f"-{normalized_type}"
|
||||
dirname = base if base else "subscription"
|
||||
if not dirname.lower().endswith(suffix):
|
||||
dirname = f"{dirname}{suffix}"
|
||||
return dirname
|
||||
|
||||
|
||||
def list_file_path(lists_dir: str, filename: str | None, list_type: str | None):
|
||||
safe_name = normalized_subscription_filename(filename, list_type)
|
||||
return os.path.join(lists_dir, "sources.list.d", safe_name)
|
||||
|
||||
|
||||
def subscription_rule_dir(lists_dir: str, filename: str | None, list_type: str | None):
|
||||
return os.path.join(
|
||||
lists_dir,
|
||||
"rules.list.d",
|
||||
subscription_dirname(filename, list_type),
|
||||
)
|
||||
|
||||
|
||||
def normalize_unit(value: str | None, allowed: tuple[str, ...], fallback: str):
|
||||
normalized = (value or "").strip().lower()
|
||||
for unit in allowed:
|
||||
if unit.lower() == normalized:
|
||||
return unit
|
||||
return fallback
|
||||
|
||||
|
||||
def timestamp_sort_key(value: str | None):
|
||||
normalized = str(value or "").strip()
|
||||
return (normalized == "", normalized)
|
||||
|
||||
|
||||
def normalize_group(group: str | None):
|
||||
raw = (group or "").strip().lower()
|
||||
if raw == "":
|
||||
return ""
|
||||
raw = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-._")
|
||||
return raw
|
||||
|
||||
|
||||
def normalize_groups(groups: Any):
|
||||
out: list[str] = []
|
||||
if isinstance(groups, (list, tuple, set)):
|
||||
raw_items = [str(x) for x in groups]
|
||||
else:
|
||||
raw_items = str(groups or "").split(",")
|
||||
seen: set[str] = set()
|
||||
for item in raw_items:
|
||||
g = normalize_group(item)
|
||||
if g == "" or g == "all" or g in seen:
|
||||
continue
|
||||
seen.add(g)
|
||||
out.append(g)
|
||||
return out
|
||||
|
||||
|
||||
def dedupe_subscription_identity(
|
||||
filename: str,
|
||||
name: str,
|
||||
url: str,
|
||||
list_type: str,
|
||||
seen_filenames: dict[str, str] | None,
|
||||
):
|
||||
if seen_filenames is None:
|
||||
return filename, name, False
|
||||
|
||||
key = filename
|
||||
seen_url = seen_filenames.get(key)
|
||||
if seen_url is None:
|
||||
seen_filenames[key] = url
|
||||
return filename, name, False
|
||||
if seen_url == url:
|
||||
return filename, name, True
|
||||
|
||||
base, ext = os.path.splitext(filename)
|
||||
if ext == "":
|
||||
ext = ".txt"
|
||||
suffix = f"-{normalized_list_type(list_type)}"
|
||||
root = base
|
||||
if root.lower().endswith(suffix):
|
||||
root = root[: -len(suffix)]
|
||||
root = root.rstrip("-")
|
||||
n = 2
|
||||
candidate = filename
|
||||
while candidate in seen_filenames:
|
||||
candidate = f"{root}-{n}{suffix}{ext}"
|
||||
n += 1
|
||||
display_name = (name or "").strip()
|
||||
if display_name == "":
|
||||
display_name = root or "subscription"
|
||||
seen_filenames[candidate] = url
|
||||
return candidate, f"{display_name} ({n - 1})", False
|
||||
|
||||
|
||||
def to_seconds(value: Any, units: str | None, default_seconds: int):
|
||||
try:
|
||||
if value is None:
|
||||
return default_seconds
|
||||
u = (units or "seconds").lower()
|
||||
mult = TIME_MULT.get(u)
|
||||
if mult is None:
|
||||
return default_seconds
|
||||
sec = int(value) * mult
|
||||
return sec if sec > 0 else default_seconds
|
||||
except Exception:
|
||||
return default_seconds
|
||||
|
||||
|
||||
def parse_compact_duration(value: Any):
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
s = value.strip().lower().replace(" ", "")
|
||||
if not s:
|
||||
return None
|
||||
|
||||
total = 0
|
||||
pos = 0
|
||||
for m in re.finditer(r"(\d+)([smhdw])", s):
|
||||
if m.start() != pos:
|
||||
return None
|
||||
total += int(m.group(1)) * SHORT_TIME_MULT[m.group(2)]
|
||||
pos = m.end()
|
||||
if pos != len(s):
|
||||
return None
|
||||
return total if total > 0 else None
|
||||
|
||||
|
||||
def to_max_bytes(value: Any, units: str | None, default_bytes: int):
|
||||
try:
|
||||
if value is None:
|
||||
return default_bytes
|
||||
u = (units or "bytes").lower()
|
||||
mult = SIZE_MULT.get(u)
|
||||
if mult is None:
|
||||
return default_bytes
|
||||
out = int(value) * mult
|
||||
return out if out > 0 else default_bytes
|
||||
except Exception:
|
||||
return default_bytes
|
||||
|
||||
|
||||
def is_hosts_file_like(sample_lines: list[str]):
|
||||
valid = 0
|
||||
total = 0
|
||||
for line in sample_lines:
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
total += 1
|
||||
parts = s.split()
|
||||
if len(parts) >= 2 and parts[0] in ("0.0.0.0", "127.0.0.1", "::"):
|
||||
if "." in parts[1] and "/" not in parts[1]:
|
||||
valid += 1
|
||||
elif len(parts) == 1 and "." in parts[0]:
|
||||
valid += 1
|
||||
if total <= 10:
|
||||
return True
|
||||
return (valid / max(total, 1)) >= 0.60
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": 1,
|
||||
"url": "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/light.txt",
|
||||
"format": "hosts",
|
||||
|
||||
"etag": "\"abc123\"",
|
||||
"last_modified": "Sun, 01 Mar 2026 08:40:00 GMT",
|
||||
|
||||
"last_checked": "2026-03-01T12:00:00+01:00",
|
||||
"last_updated": "2026-03-01T12:00:02+01:00",
|
||||
|
||||
"last_result": "updated",
|
||||
"last_error": "",
|
||||
|
||||
"fail_count": 0,
|
||||
"backoff_until": "",
|
||||
|
||||
"bytes": 1234567
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "listSubscriptionsActions",
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"description": "Manage and auto-update blocklist subscriptions (hosts format)",
|
||||
"type": ["global", "main-dialog"],
|
||||
"actions": {
|
||||
"list_subscriptions": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"lists_dir": "~/.config/opensnitch/list_subscriptions",
|
||||
|
||||
"interval": 24,
|
||||
"interval_units": "hours",
|
||||
|
||||
"timeout": 20,
|
||||
"timeout_units": "seconds",
|
||||
|
||||
"max_size": 50,
|
||||
"max_size_units": "MB",
|
||||
|
||||
"subscriptions": [
|
||||
{
|
||||
"name": "HaGeZi Light Hosts",
|
||||
"enabled": true,
|
||||
"url": "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/light.txt",
|
||||
"format": "hosts",
|
||||
"filename": "hagezi-light.txt",
|
||||
|
||||
"interval": 12,
|
||||
"interval_units": "hours"
|
||||
}
|
||||
],
|
||||
|
||||
"notify": {
|
||||
"success": {
|
||||
"desktop": "Lists subscriptions updated"
|
||||
},
|
||||
"error": {
|
||||
"desktop": "Error updating lists subscriptions"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import errno
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
class FileLock:
|
||||
def __init__(self, lock_path: str):
|
||||
self.lock_path = lock_path
|
||||
self.fd: int | None = None
|
||||
|
||||
def _read_owner_pid(self):
|
||||
try:
|
||||
with open(self.lock_path, "r", encoding="utf-8") as f:
|
||||
raw = f.read().strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
if raw == "":
|
||||
return -1
|
||||
try:
|
||||
return int(raw)
|
||||
except Exception:
|
||||
return -1
|
||||
|
||||
def _pid_is_alive(self, pid: int):
|
||||
if pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
return True
|
||||
|
||||
def is_stale(self, max_age: float = 30.0):
|
||||
try:
|
||||
st = os.stat(self.lock_path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
pid = self._read_owner_pid()
|
||||
if pid is not None and pid > 0 and self._pid_is_alive(pid):
|
||||
return False
|
||||
|
||||
age = time.time() - st.st_mtime
|
||||
return age > max_age
|
||||
|
||||
def break_stale(self, max_age: float = 30.0):
|
||||
if not self.is_stale(max_age=max_age):
|
||||
return False
|
||||
try:
|
||||
os.unlink(self.lock_path)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def acquire(self):
|
||||
pid = os.getpid()
|
||||
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
||||
try:
|
||||
fd = os.open(self.lock_path, flags, 0o600)
|
||||
try:
|
||||
os.write(fd, f"{pid}\n".encode("utf-8"))
|
||||
os.fsync(fd)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(fd)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(self.lock_path)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
self.fd = fd
|
||||
return True
|
||||
except FileExistsError:
|
||||
return False
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.EEXIST:
|
||||
return False
|
||||
raise
|
||||
|
||||
def release(self):
|
||||
try:
|
||||
if self.fd is not None:
|
||||
os.close(self.fd)
|
||||
finally:
|
||||
self.fd = None
|
||||
try:
|
||||
os.unlink(self.lock_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
@@ -0,0 +1,59 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.io.lock import FileLock
|
||||
|
||||
|
||||
def read_json(path: str):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def write_json_atomic(path: str, obj: dict[str, Any]):
|
||||
d = os.path.dirname(path)
|
||||
if d:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(obj, f, indent=2, sort_keys=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def json_lock_path(path: str):
|
||||
return f"{path}.lock"
|
||||
|
||||
|
||||
def read_json_locked(path: str, timeout: float = 5.0, poll_interval: float = 0.05):
|
||||
lock_path = json_lock_path(path)
|
||||
lock = FileLock(lock_path)
|
||||
deadline = time.monotonic() + max(timeout, 0.0)
|
||||
while os.path.exists(lock_path):
|
||||
lock.break_stale()
|
||||
if not os.path.exists(lock_path):
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(f"timed out waiting for lock: {lock_path}")
|
||||
time.sleep(poll_interval)
|
||||
return read_json(path)
|
||||
|
||||
|
||||
def write_json_atomic_locked(
|
||||
path: str,
|
||||
obj: dict[str, Any],
|
||||
timeout: float = 5.0,
|
||||
poll_interval: float = 0.05,
|
||||
):
|
||||
lock = FileLock(json_lock_path(path))
|
||||
deadline = time.monotonic() + max(timeout, 0.0)
|
||||
while not lock.acquire():
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(f"timed out waiting for lock: {lock.lock_path}")
|
||||
time.sleep(poll_interval)
|
||||
try:
|
||||
write_json_atomic(path, obj)
|
||||
finally:
|
||||
lock.release()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
from opensnitch.plugins.list_subscriptions._utils import normalize_iso_timestamp, now_iso
|
||||
from opensnitch.plugins.list_subscriptions.models.config import MutablePluginConfig
|
||||
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class MutableActionConfig:
|
||||
enabled: bool = False
|
||||
plugin: MutablePluginConfig = field(default_factory=MutablePluginConfig.default)
|
||||
action_name: str = "listSubscriptionsActions"
|
||||
created: str = ""
|
||||
updated: str = ""
|
||||
description: str = "Manage and auto-update blocklist subscriptions (hosts format)"
|
||||
types: list[str] = field(default_factory=lambda: ["global", "main-dialog"])
|
||||
|
||||
@staticmethod
|
||||
def from_action_dict(raw_action: dict[str, Any], lists_dir: str | None = None):
|
||||
action_name = str(raw_action.get("name", "listSubscriptionsActions"))
|
||||
created = normalize_iso_timestamp(raw_action.get("created"))
|
||||
updated = normalize_iso_timestamp(raw_action.get("updated"), fallback=created)
|
||||
description = str(
|
||||
raw_action.get(
|
||||
"description",
|
||||
"Manage and auto-update blocklist subscriptions (hosts format)",
|
||||
)
|
||||
)
|
||||
action_types_raw = raw_action.get("type", ["global", "main-dialog"])
|
||||
if isinstance(action_types_raw, list):
|
||||
action_types = [str(t) for t in action_types_raw]
|
||||
else:
|
||||
action_types = ["global", "main-dialog"]
|
||||
|
||||
actions_obj = raw_action.get("actions", {})
|
||||
action_cfg = (
|
||||
actions_obj.get("list_subscriptions", {})
|
||||
if isinstance(actions_obj, dict)
|
||||
else {}
|
||||
)
|
||||
plugin_cfg_raw = (
|
||||
action_cfg.get("config", {}) if isinstance(action_cfg, dict) else {}
|
||||
)
|
||||
plugin_cfg = plugin_cfg_raw if isinstance(plugin_cfg_raw, dict) else {}
|
||||
mutable_plugin = MutablePluginConfig.from_dict(
|
||||
plugin_cfg, lists_dir=plugin_cfg.get("lists_dir") or lists_dir
|
||||
)
|
||||
enabled = (
|
||||
bool(action_cfg.get("enabled", False))
|
||||
if isinstance(action_cfg, dict)
|
||||
else False
|
||||
)
|
||||
|
||||
return MutableActionConfig(
|
||||
enabled=enabled,
|
||||
plugin=mutable_plugin,
|
||||
action_name=action_name,
|
||||
created=created,
|
||||
updated=updated,
|
||||
description=description,
|
||||
types=action_types,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def default(lists_dir: str | None = None):
|
||||
created = now_iso()
|
||||
return MutableActionConfig(
|
||||
enabled=True,
|
||||
plugin=MutablePluginConfig.default(lists_dir),
|
||||
created=created,
|
||||
updated=created,
|
||||
)
|
||||
|
||||
def to_action_dict(self):
|
||||
created = normalize_iso_timestamp(self.created)
|
||||
updated = now_iso()
|
||||
self.created = created
|
||||
self.updated = updated
|
||||
return {
|
||||
"name": self.action_name,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"description": self.description,
|
||||
"type": list(self.types),
|
||||
"actions": {
|
||||
"list_subscriptions": {
|
||||
"enabled": bool(self.enabled),
|
||||
"config": self.plugin.to_dict(),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
from opensnitch.plugins.list_subscriptions._utils import DEFAULT_NOTIFY_CONFIG, DEFAULT_UA, normalize_lists_dir
|
||||
from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults
|
||||
from opensnitch.plugins.list_subscriptions.models.subscriptions import MutableSubscriptionSpec, SubscriptionSpec, normalize_subscription_identities
|
||||
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginConfig:
|
||||
defaults: GlobalDefaults = field(
|
||||
default_factory=lambda: GlobalDefaults.from_dict({})
|
||||
)
|
||||
subscriptions: list[SubscriptionSpec] = field(default_factory=list)
|
||||
notify: dict[str, Any] = field(default_factory=lambda: dict(DEFAULT_NOTIFY_CONFIG))
|
||||
|
||||
@staticmethod
|
||||
def from_dict(
|
||||
raw_cfg: dict[str, Any],
|
||||
lists_dir: str | None = None,
|
||||
invalidate_duplicates: bool = False,
|
||||
):
|
||||
raw_cfg = raw_cfg or {}
|
||||
if not isinstance(raw_cfg, dict):
|
||||
raw_cfg = {}
|
||||
defaults = GlobalDefaults.from_dict(raw_cfg, lists_dir)
|
||||
|
||||
subs: list[SubscriptionSpec] = []
|
||||
for item in raw_cfg.get("subscriptions") or []:
|
||||
sub = SubscriptionSpec.from_dict(
|
||||
item,
|
||||
defaults,
|
||||
)
|
||||
if sub is not None:
|
||||
subs.append(sub)
|
||||
|
||||
normalized_subs = normalize_subscription_identities(
|
||||
subs,
|
||||
invalidate_duplicates=invalidate_duplicates,
|
||||
clone=lambda sub, name, url, filename, list_type: replace(
|
||||
sub,
|
||||
name=name,
|
||||
url=url,
|
||||
filename=filename,
|
||||
format=list_type,
|
||||
),
|
||||
)
|
||||
if normalized_subs is None:
|
||||
normalized_subs = []
|
||||
|
||||
notify = raw_cfg.get("notify")
|
||||
if not isinstance(notify, dict):
|
||||
notify = dict(DEFAULT_NOTIFY_CONFIG)
|
||||
|
||||
return PluginConfig(
|
||||
defaults=defaults, subscriptions=normalized_subs, notify=notify
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MutablePluginConfig:
|
||||
defaults: GlobalDefaults = field(
|
||||
default_factory=lambda: GlobalDefaults.from_dict({})
|
||||
)
|
||||
subscriptions: list[MutableSubscriptionSpec] = field(default_factory=list)
|
||||
notify: dict[str, Any] = field(default_factory=lambda: dict(DEFAULT_NOTIFY_CONFIG))
|
||||
|
||||
@staticmethod
|
||||
def from_plugin_config(config: PluginConfig):
|
||||
return MutablePluginConfig(
|
||||
defaults=config.defaults,
|
||||
subscriptions=[
|
||||
MutableSubscriptionSpec.from_spec(sub) for sub in config.subscriptions
|
||||
],
|
||||
notify=dict(config.notify),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(raw_cfg: dict[str, Any], lists_dir: str | None = None):
|
||||
compiled_cfg = PluginConfig.from_dict(raw_cfg, lists_dir=lists_dir)
|
||||
return MutablePluginConfig.from_plugin_config(compiled_cfg)
|
||||
|
||||
@staticmethod
|
||||
def default(lists_dir: str | None = None):
|
||||
return MutablePluginConfig(
|
||||
defaults=GlobalDefaults.from_dict({}, lists_dir=lists_dir),
|
||||
subscriptions=[],
|
||||
notify=dict(DEFAULT_NOTIFY_CONFIG),
|
||||
)
|
||||
|
||||
def normalize_subscriptions(self, invalidate_duplicates: bool = False):
|
||||
normalized = normalize_subscription_identities(
|
||||
self.subscriptions,
|
||||
invalidate_duplicates=invalidate_duplicates,
|
||||
clone=lambda sub, name, url, filename, list_type: MutableSubscriptionSpec(
|
||||
name=name,
|
||||
url=url,
|
||||
filename=filename,
|
||||
groups=list(sub.groups),
|
||||
enabled=sub.enabled,
|
||||
format=list_type,
|
||||
interval=sub.interval,
|
||||
interval_units=sub.interval_units,
|
||||
timeout=sub.timeout,
|
||||
timeout_units=sub.timeout_units,
|
||||
max_size=sub.max_size,
|
||||
max_size_units=sub.max_size_units,
|
||||
),
|
||||
)
|
||||
if normalized is None:
|
||||
return None
|
||||
self.subscriptions = normalized
|
||||
return normalized
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"lists_dir": normalize_lists_dir(self.defaults.lists_dir),
|
||||
"interval": int(self.defaults.interval),
|
||||
"interval_units": self.defaults.interval_units,
|
||||
"timeout": int(self.defaults.timeout),
|
||||
"timeout_units": self.defaults.timeout_units,
|
||||
"max_size": int(self.defaults.max_size),
|
||||
"max_size_units": self.defaults.max_size_units,
|
||||
"user_agent": (
|
||||
self.defaults.user_agent
|
||||
if self.defaults.user_agent is not None
|
||||
else DEFAULT_UA
|
||||
),
|
||||
"subscriptions": [sub.to_dict() for sub in self.subscriptions],
|
||||
"notify": self.notify,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
from typing import TypedDict
|
||||
from enum import IntEnum
|
||||
|
||||
class SubscriptionEventItem(TypedDict, total=False):
|
||||
key: str
|
||||
name: str
|
||||
url: str
|
||||
filename: str
|
||||
format: str
|
||||
state: str | None
|
||||
path: str | None
|
||||
|
||||
|
||||
class SubscriptionEventPayload(TypedDict, total=False):
|
||||
enabled: bool
|
||||
name: str
|
||||
url: str
|
||||
filename: str
|
||||
format: str
|
||||
groups: list[str]
|
||||
interval: int | None
|
||||
interval_units: str | None
|
||||
timeout: int | None
|
||||
timeout_units: str | None
|
||||
max_size: int | None
|
||||
max_size_units: str | None
|
||||
|
||||
|
||||
class RuntimeEventType(IntEnum):
|
||||
RUNTIME_ENABLED = 1
|
||||
CONFIG_RELOADED = 2
|
||||
RUNTIME_DISABLED = 3
|
||||
RUNTIME_STOPPED = 4
|
||||
RUNTIME_ERROR = 5
|
||||
DOWNLOAD_STARTED = 6
|
||||
DOWNLOAD_FINISHED = 7
|
||||
DOWNLOAD_FAILED = 8
|
||||
FILE_SAVE_FINISHED = 9
|
||||
FILE_SAVE_ERROR = 10
|
||||
FILE_LOAD_FINISHED = 11
|
||||
FILE_LOAD_ERROR = 12
|
||||
@@ -0,0 +1,42 @@
|
||||
from opensnitch.plugins.list_subscriptions._utils import DEFAULT_UA, normalize_lists_dir
|
||||
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GlobalDefaults:
|
||||
lists_dir: str
|
||||
interval: int = 24
|
||||
interval_units: str = "hours"
|
||||
timeout: int = 60
|
||||
timeout_units: str = "seconds"
|
||||
max_size: int = 20
|
||||
max_size_units: str = "MB"
|
||||
user_agent: str | None = DEFAULT_UA
|
||||
|
||||
@staticmethod
|
||||
def from_dict(d: dict[str, Any], lists_dir: str | None = None):
|
||||
lists_dir = normalize_lists_dir(str(d.get("lists_dir") or lists_dir or ""))
|
||||
|
||||
def _int(v: int | float | str | None, default: int):
|
||||
try:
|
||||
return int(v) if v is not None else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _str(v: str | None, default: str):
|
||||
v = (v or "").strip()
|
||||
return v if v else default
|
||||
|
||||
return GlobalDefaults(
|
||||
lists_dir=lists_dir,
|
||||
interval=_int(d.get("interval"), 24),
|
||||
interval_units=_str(d.get("interval_units"), "hours"),
|
||||
timeout=_int(d.get("timeout"), 60),
|
||||
timeout_units=_str(d.get("timeout_units"), "seconds"),
|
||||
max_size=_int(d.get("max_size"), 20),
|
||||
max_size_units=_str(d.get("max_size_units"), "MB"),
|
||||
user_agent=(d.get("user_agent") or DEFAULT_UA),
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListMetadata:
|
||||
version: int = 1
|
||||
url: str = ""
|
||||
format: str = "hosts"
|
||||
etag: str = ""
|
||||
last_modified: str = ""
|
||||
last_checked: str = ""
|
||||
last_updated: str = ""
|
||||
backoff_until: str = ""
|
||||
last_result: str = "never"
|
||||
last_error: str = ""
|
||||
fail_count: int = 0
|
||||
bytes: int = 0
|
||||
|
||||
@staticmethod
|
||||
def from_dict(d: dict[str, Any]):
|
||||
m = ListMetadata()
|
||||
if not isinstance(d, dict):
|
||||
return m
|
||||
|
||||
def _int(v: Any, default: int):
|
||||
try:
|
||||
return int(v) if v is not None else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _str(v: Any, default: str = ""):
|
||||
return str(v or default)
|
||||
|
||||
m.version = _int(d.get("version", 1), 1)
|
||||
m.url = _str(d.get("url", ""))
|
||||
m.format = _str(d.get("format", "hosts")) or "hosts"
|
||||
m.etag = _str(d.get("etag", ""))
|
||||
m.last_modified = _str(d.get("last_modified", ""))
|
||||
m.last_checked = _str(d.get("last_checked", ""))
|
||||
m.last_updated = _str(d.get("last_updated", ""))
|
||||
m.backoff_until = _str(d.get("backoff_until", ""))
|
||||
m.last_result = _str(d.get("last_result", "never")) or "never"
|
||||
m.last_error = _str(d.get("last_error", ""))
|
||||
m.fail_count = _int(d.get("fail_count", 0), 0)
|
||||
m.bytes = _int(d.get("bytes", 0), 0)
|
||||
|
||||
return m
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
@@ -0,0 +1,284 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from opensnitch.plugins.list_subscriptions._utils import dedupe_subscription_identity, derive_filename, ensure_filename_type_suffix, normalize_groups, opt_int, opt_str, parse_compact_duration, safe_filename, to_max_bytes, to_seconds
|
||||
from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults
|
||||
|
||||
SubscriptionLike = TypeVar(
|
||||
"SubscriptionLike", "SubscriptionSpec", "MutableSubscriptionSpec"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionSpec:
|
||||
name: str
|
||||
url: str
|
||||
filename: str
|
||||
groups: tuple[str, ...] = ()
|
||||
enabled: bool = True
|
||||
format: str = "hosts"
|
||||
interval: int | None = None
|
||||
interval_units: str | None = None
|
||||
timeout: int | None = None
|
||||
timeout_units: str | None = None
|
||||
max_size: int | None = None
|
||||
max_size_units: str | None = None
|
||||
interval_seconds: int = 24 * 3600
|
||||
timeout_seconds: int = 60
|
||||
max_bytes: int = 20 * 1024 * 1024
|
||||
|
||||
@staticmethod
|
||||
def from_dict(
|
||||
d: dict[str, Any] | None,
|
||||
defaults: GlobalDefaults | None = None,
|
||||
require_url: bool = True,
|
||||
ensure_suffix: bool = True,
|
||||
):
|
||||
d = d or {}
|
||||
defaults = defaults or GlobalDefaults.from_dict({})
|
||||
|
||||
name = (d.get("name") or "").strip()
|
||||
url = (d.get("url") or "").strip()
|
||||
list_type = str(d.get("format", "hosts") or "hosts").strip().lower()
|
||||
filename = derive_filename(name, url, d.get("filename"))
|
||||
if ensure_suffix and filename != "":
|
||||
filename = ensure_filename_type_suffix(filename, list_type)
|
||||
elif not ensure_suffix and filename == "":
|
||||
filename = ""
|
||||
groups_raw = d.get("groups")
|
||||
if "group" in d:
|
||||
legacy_group = d.get("group")
|
||||
if isinstance(groups_raw, (list, tuple, set)):
|
||||
groups_raw = list(groups_raw) + [legacy_group]
|
||||
elif groups_raw is None:
|
||||
groups_raw = [legacy_group]
|
||||
else:
|
||||
groups_raw = [groups_raw, legacy_group]
|
||||
groups = normalize_groups(groups_raw)
|
||||
if require_url and not url:
|
||||
return None
|
||||
if require_url and not name:
|
||||
name = filename
|
||||
|
||||
interval_raw: Any = d.get("interval")
|
||||
timeout_raw: Any = d.get("timeout")
|
||||
interval_units_raw: Any = d.get("interval_units")
|
||||
timeout_units_raw: Any = d.get("timeout_units")
|
||||
|
||||
interval = opt_int(interval_raw)
|
||||
interval_units_opt = opt_str(interval_units_raw)
|
||||
interval_units = interval_units_opt
|
||||
timeout = opt_int(timeout_raw)
|
||||
timeout_units_opt = opt_str(timeout_units_raw)
|
||||
timeout_units = timeout_units_opt
|
||||
max_size = opt_int(d.get("max_size"))
|
||||
max_size_units = opt_str(d.get("max_size_units"))
|
||||
|
||||
default_interval_seconds = to_seconds(
|
||||
defaults.interval, defaults.interval_units, 24 * 3600
|
||||
)
|
||||
default_timeout_seconds = to_seconds(
|
||||
defaults.timeout, defaults.timeout_units, 60
|
||||
)
|
||||
default_max_bytes = to_max_bytes(
|
||||
defaults.max_size, defaults.max_size_units, 20 * 1024 * 1024
|
||||
)
|
||||
|
||||
effective_interval = interval if interval is not None else defaults.interval
|
||||
effective_interval_units = interval_units or defaults.interval_units
|
||||
effective_timeout = timeout if timeout is not None else defaults.timeout
|
||||
effective_timeout_units = timeout_units or defaults.timeout_units
|
||||
effective_max_size = max_size if max_size is not None else defaults.max_size
|
||||
effective_max_size_units = max_size_units or defaults.max_size_units
|
||||
|
||||
interval_seconds: int | None = None
|
||||
interval_is_composite = False
|
||||
if interval_units_opt is None:
|
||||
interval_seconds = parse_compact_duration(interval_raw)
|
||||
interval_is_composite = interval_seconds is not None
|
||||
if interval_seconds is None:
|
||||
interval_seconds = to_seconds(
|
||||
effective_interval, effective_interval_units, default_interval_seconds
|
||||
)
|
||||
elif interval_is_composite:
|
||||
interval = interval_seconds
|
||||
interval_units = "composite"
|
||||
|
||||
timeout_seconds: int | None = None
|
||||
timeout_is_composite = False
|
||||
if timeout_units_opt is None:
|
||||
timeout_seconds = parse_compact_duration(timeout_raw)
|
||||
timeout_is_composite = timeout_seconds is not None
|
||||
if timeout_seconds is None:
|
||||
timeout_seconds = to_seconds(
|
||||
effective_timeout, effective_timeout_units, default_timeout_seconds
|
||||
)
|
||||
elif timeout_is_composite:
|
||||
timeout = timeout_seconds
|
||||
timeout_units = "composite"
|
||||
|
||||
max_bytes = to_max_bytes(
|
||||
effective_max_size, effective_max_size_units, default_max_bytes
|
||||
)
|
||||
|
||||
return SubscriptionSpec(
|
||||
name=name,
|
||||
url=url,
|
||||
filename=filename,
|
||||
groups=tuple(groups),
|
||||
enabled=bool(d.get("enabled", True)),
|
||||
format=list_type,
|
||||
interval=interval,
|
||||
interval_units=interval_units,
|
||||
timeout=timeout,
|
||||
timeout_units=timeout_units,
|
||||
max_size=max_size,
|
||||
max_size_units=max_size_units,
|
||||
interval_seconds=interval_seconds,
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MutableSubscriptionSpec:
|
||||
name: str = ""
|
||||
url: str = ""
|
||||
filename: str = ""
|
||||
groups: list[str] = field(default_factory=list)
|
||||
enabled: bool = True
|
||||
format: str = "hosts"
|
||||
interval: int | None = None
|
||||
interval_units: str | None = None
|
||||
timeout: int | None = None
|
||||
timeout_units: str | None = None
|
||||
max_size: int | None = None
|
||||
max_size_units: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def from_spec(spec: SubscriptionSpec):
|
||||
return MutableSubscriptionSpec(
|
||||
name=spec.name,
|
||||
url=spec.url,
|
||||
filename=spec.filename,
|
||||
groups=list(spec.groups),
|
||||
enabled=spec.enabled,
|
||||
format=spec.format,
|
||||
interval=spec.interval,
|
||||
interval_units=spec.interval_units,
|
||||
timeout=spec.timeout,
|
||||
timeout_units=spec.timeout_units,
|
||||
max_size=spec.max_size,
|
||||
max_size_units=spec.max_size_units,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(
|
||||
d: dict[str, Any] | None,
|
||||
defaults: GlobalDefaults | None = None,
|
||||
require_url: bool = True,
|
||||
ensure_suffix: bool = True,
|
||||
):
|
||||
spec = SubscriptionSpec.from_dict(
|
||||
d,
|
||||
defaults,
|
||||
require_url=require_url,
|
||||
ensure_suffix=ensure_suffix,
|
||||
)
|
||||
if spec is None:
|
||||
return None
|
||||
|
||||
d = d or {}
|
||||
|
||||
def _has_value(value: Any):
|
||||
return value is not None and str(value).strip() != ""
|
||||
|
||||
return MutableSubscriptionSpec(
|
||||
name=spec.name,
|
||||
url=spec.url,
|
||||
filename=spec.filename,
|
||||
groups=list(spec.groups),
|
||||
enabled=spec.enabled,
|
||||
format=spec.format,
|
||||
interval=(
|
||||
spec.interval
|
||||
if _has_value(d.get("interval")) or spec.interval_units == "composite"
|
||||
else None
|
||||
),
|
||||
interval_units=(
|
||||
spec.interval_units
|
||||
if _has_value(d.get("interval_units"))
|
||||
or spec.interval_units == "composite"
|
||||
else None
|
||||
),
|
||||
timeout=(
|
||||
spec.timeout
|
||||
if _has_value(d.get("timeout")) or spec.timeout_units == "composite"
|
||||
else None
|
||||
),
|
||||
timeout_units=(
|
||||
spec.timeout_units
|
||||
if _has_value(d.get("timeout_units"))
|
||||
or spec.timeout_units == "composite"
|
||||
else None
|
||||
),
|
||||
max_size=spec.max_size if _has_value(d.get("max_size")) else None,
|
||||
max_size_units=(
|
||||
spec.max_size_units if _has_value(d.get("max_size_units")) else None
|
||||
),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
data: dict[str, Any] = {
|
||||
"enabled": bool(self.enabled),
|
||||
"name": (self.name or "").strip(),
|
||||
"url": (self.url or "").strip(),
|
||||
"filename": safe_filename(self.filename),
|
||||
"format": (self.format or "hosts").strip().lower(),
|
||||
"groups": normalize_groups(self.groups),
|
||||
}
|
||||
if self.interval is not None:
|
||||
data["interval"] = int(self.interval)
|
||||
data["interval_units"] = (self.interval_units or "hours").strip().lower()
|
||||
if self.timeout is not None:
|
||||
data["timeout"] = int(self.timeout)
|
||||
data["timeout_units"] = (self.timeout_units or "seconds").strip().lower()
|
||||
if self.max_size is not None:
|
||||
data["max_size"] = int(self.max_size)
|
||||
data["max_size_units"] = (self.max_size_units or "MB").strip()
|
||||
return data
|
||||
|
||||
|
||||
def normalize_subscription_identities(
|
||||
subscriptions: list[SubscriptionLike],
|
||||
invalidate_duplicates: bool = False,
|
||||
clone: (
|
||||
Callable[[SubscriptionLike, str, str, str, str], SubscriptionLike] | None
|
||||
) = None,
|
||||
):
|
||||
normalized: list[SubscriptionLike] = []
|
||||
seen_filenames: dict[str, str] = {}
|
||||
for sub in subscriptions:
|
||||
url = (sub.url or "").strip()
|
||||
if url == "":
|
||||
return None
|
||||
list_type = (sub.format or "hosts").strip().lower()
|
||||
filename = ensure_filename_type_suffix(
|
||||
derive_filename(sub.name, url, sub.filename), list_type
|
||||
)
|
||||
name = (sub.name or "").strip() or filename
|
||||
filename, name, duplicate_same_url = dedupe_subscription_identity(
|
||||
filename,
|
||||
name,
|
||||
url,
|
||||
list_type,
|
||||
seen_filenames,
|
||||
)
|
||||
if duplicate_same_url and invalidate_duplicates:
|
||||
return None
|
||||
if clone is None:
|
||||
normalized.append(sub)
|
||||
else:
|
||||
normalized.append(clone(sub, name, url, filename, list_type))
|
||||
return normalized
|
||||
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>AttachedRulesDialog</class>
|
||||
<widget class="QDialog" name="AttachedRulesDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>760</width>
|
||||
<height>420</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Attached rules</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="rootLayout">
|
||||
<property name="leftMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="spacing">
|
||||
<number>8</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="rules_table"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="buttonsLayout">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="create_button">
|
||||
<property name="text">
|
||||
<string>Create rule</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="edit_button">
|
||||
<property name="text">
|
||||
<string>Edit selected</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="toggle_button">
|
||||
<property name="text">
|
||||
<string>Disable</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="remove_button">
|
||||
<property name="text">
|
||||
<string>Remove</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="close_button">
|
||||
<property name="text">
|
||||
<string>Close</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 640 640"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="ban-solid-full.svg"
|
||||
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.8125"
|
||||
inkscape:cx="319.72414"
|
||||
inkscape:cy="320"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1368"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" />
|
||||
<!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
|
||||
<path
|
||||
d="M431.2 476.5L163.5 208.8C141.1 240.2 128 278.6 128 320C128 426 214 512 320 512C361.5 512 399.9 498.9 431.2 476.5zM476.5 431.2C498.9 399.8 512 361.4 512 320C512 214 426 128 320 128C278.5 128 240.1 141.1 208.8 163.5L476.5 431.2zM64 320C64 178.6 178.6 64 320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576C178.6 576 64 461.4 64 320z"
|
||||
id="path1"
|
||||
style="fill:#ff0000" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>BulkEditDialog</class>
|
||||
<widget class="QDialog" name="BulkEditDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>640</width>
|
||||
<height>360</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Edit selected subscriptions</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="rootLayout">
|
||||
<item>
|
||||
<widget class="QFrame" name="changes_section_bar">
|
||||
<layout class="QHBoxLayout" name="changesSectionBarLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="changes_section_label">
|
||||
<property name="text">
|
||||
<string>Selected changes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="selection_hint_label">
|
||||
<property name="text">
|
||||
<string>Choose which changes to apply to the selected subscriptions.</string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTreeWidget" name="changes_tree">
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Property</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>New value</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="footer_separator_line">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="buttons_layout">
|
||||
<item>
|
||||
<widget class="QLabel" name="error_label">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="buttons_spacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="cancel_button">
|
||||
<property name="text">
|
||||
<string>Cancel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="save_button">
|
||||
<property name="text">
|
||||
<string>Apply</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,408 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>ListSubscriptionsDialog</class>
|
||||
<widget class="QDialog" name="ListSubscriptionsDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1180</width>
|
||||
<height>680</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>List subscriptions</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="rootLayout">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="topRowLayout">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="enable_plugin_check">
|
||||
<property name="text">
|
||||
<string>Enable list subscriptions plugin</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="topSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="create_file_button">
|
||||
<property name="text">
|
||||
<string>Create action file</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="save_button">
|
||||
<property name="text">
|
||||
<string>Save</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="reload_button">
|
||||
<property name="text">
|
||||
<string>Reload</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="start_runtime_button">
|
||||
<property name="text">
|
||||
<string>Start</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="stop_runtime_button">
|
||||
<property name="text">
|
||||
<string>Stop</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="runtime_status_title_label">
|
||||
<property name="text">
|
||||
<string>Status</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="runtime_status_label">
|
||||
<property name="text">
|
||||
<string>Runtime: inactive</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="defaultsSectionLayout">
|
||||
<item>
|
||||
<widget class="QFrame" name="defaults_section_bar">
|
||||
<layout class="QHBoxLayout" name="defaultsSectionBarLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="defaults_section_label">
|
||||
<property name="text">
|
||||
<string>Defaults</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="defaultsGridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="lists_dir_label">
|
||||
<property name="text">
|
||||
<string>Lists directory</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1" colspan="5">
|
||||
<widget class="QLineEdit" name="lists_dir_edit"/>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="default_interval_label">
|
||||
<property name="text">
|
||||
<string>Default interval</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QSpinBox" name="default_interval_spin"/>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QComboBox" name="default_interval_units"/>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="QLabel" name="default_timeout_label">
|
||||
<property name="text">
|
||||
<string>Default timeout</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="4">
|
||||
<widget class="QSpinBox" name="default_timeout_spin"/>
|
||||
</item>
|
||||
<item row="1" column="5">
|
||||
<widget class="QComboBox" name="default_timeout_units"/>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="default_max_size_label">
|
||||
<property name="text">
|
||||
<string>Default max size</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QSpinBox" name="default_max_size_spin"/>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QComboBox" name="default_max_size_units"/>
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QLabel" name="default_user_agent_label">
|
||||
<property name="text">
|
||||
<string>Default User-Agent</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="4" colspan="2">
|
||||
<widget class="QLineEdit" name="default_user_agent"/>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="node_label">
|
||||
<property name="text">
|
||||
<string>Node</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1" colspan="2">
|
||||
<widget class="QComboBox" name="nodes_combo"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="tableSectionLayout">
|
||||
<item>
|
||||
<widget class="QFrame" name="table_section_bar">
|
||||
<layout class="QHBoxLayout" name="tableSectionBarLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="table_section_label">
|
||||
<property name="text">
|
||||
<string>List subscriptions</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="tableContentLayout">
|
||||
<item>
|
||||
<widget class="QTableWidget" name="table"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="actionsRowLayout">
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="globalActionsSectionLayout">
|
||||
<item>
|
||||
<widget class="QFrame" name="global_actions_bar">
|
||||
<layout class="QHBoxLayout" name="globalActionsBarLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="global_actions_label">
|
||||
<property name="text">
|
||||
<string>Global actions</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="globalActionsLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="add_sub_button">
|
||||
<property name="text">
|
||||
<string>Add subscription</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="refresh_state_button">
|
||||
<property name="text">
|
||||
<string>Refresh all</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="create_global_rule_button">
|
||||
<property name="text">
|
||||
<string>Create global rule</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="globalActionsSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="actionsSeparatorLayout">
|
||||
<item>
|
||||
<widget class="QFrame" name="actions_vertical_separator">
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::VLine</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Plain</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="selectedActionsSectionLayout">
|
||||
<item>
|
||||
<widget class="QFrame" name="selected_actions_bar">
|
||||
<layout class="QHBoxLayout" name="selectedActionsBarLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="selected_actions_label">
|
||||
<property name="text">
|
||||
<string>Selected subscriptions</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="ruleActionsLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="edit_sub_button">
|
||||
<property name="text">
|
||||
<string>Edit</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="remove_sub_button">
|
||||
<property name="text">
|
||||
<string>Delete</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="refresh_now_button">
|
||||
<property name="text">
|
||||
<string>Refresh</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="create_rule_button">
|
||||
<property name="text">
|
||||
<string>Create rule</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="ruleActionsSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QFrame" name="status_separator_line">
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::HLine</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Plain</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="status_label">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>StatusLogDialog</class>
|
||||
<widget class="QDialog" name="StatusLogDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>760</width>
|
||||
<height>420</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Status log</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="rootLayout">
|
||||
<property name="leftMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="spacing">
|
||||
<number>8</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QTextEdit" name="text_view"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="buttonsLayout">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="copy_button">
|
||||
<property name="text">
|
||||
<string>Copy</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="close_button">
|
||||
<property name="text">
|
||||
<string>Close</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,440 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>SubscriptionDialog</class>
|
||||
<widget class="QDialog" name="SubscriptionDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>920</width>
|
||||
<height>420</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Subscription</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="rootLayout">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="bodyLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="settings_group">
|
||||
<property name="title">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="flat">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="settings_group_layout">
|
||||
<item>
|
||||
<widget class="QFrame" name="settings_section_bar">
|
||||
<layout class="QHBoxLayout" name="settingsSectionBarLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="settings_section_label">
|
||||
<property name="text">
|
||||
<string>Settings</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QFormLayout" name="settings_form">
|
||||
<item row="0" column="0" colspan="2">
|
||||
<widget class="QCheckBox" name="enabled_check">
|
||||
<property name="text">
|
||||
<string>Enabled</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="name_label">
|
||||
<property name="text">
|
||||
<string>Name</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="name_edit"/>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QLabel" name="name_error_label">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="url_label">
|
||||
<property name="text">
|
||||
<string>URL</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QLineEdit" name="url_edit"/>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QLabel" name="url_error_label">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="filename_label">
|
||||
<property name="text">
|
||||
<string>Filename</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="1">
|
||||
<widget class="QLineEdit" name="filename_edit"/>
|
||||
</item>
|
||||
<item row="6" column="1">
|
||||
<widget class="QLabel" name="filename_error_label">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="7" column="0">
|
||||
<widget class="QLabel" name="format_label">
|
||||
<property name="text">
|
||||
<string>Format</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="7" column="1">
|
||||
<widget class="QComboBox" name="format_combo"/>
|
||||
</item>
|
||||
<item row="8" column="0">
|
||||
<widget class="QLabel" name="groups_label">
|
||||
<property name="text">
|
||||
<string>Groups</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="8" column="1">
|
||||
<widget class="QComboBox" name="group_combo"/>
|
||||
</item>
|
||||
<item row="9" column="1">
|
||||
<widget class="QLabel" name="group_error_label">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="10" column="0">
|
||||
<widget class="QLabel" name="interval_label">
|
||||
<property name="text">
|
||||
<string>Interval</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="10" column="1">
|
||||
<layout class="QHBoxLayout" name="interval_layout">
|
||||
<item>
|
||||
<widget class="QSpinBox" name="interval_spin"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="interval_units"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="11" column="0">
|
||||
<widget class="QLabel" name="timeout_label">
|
||||
<property name="text">
|
||||
<string>Timeout</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="11" column="1">
|
||||
<layout class="QHBoxLayout" name="timeout_layout">
|
||||
<item>
|
||||
<widget class="QSpinBox" name="timeout_spin"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="timeout_units"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="12" column="0">
|
||||
<widget class="QLabel" name="max_size_label">
|
||||
<property name="text">
|
||||
<string>Max size</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="12" column="1">
|
||||
<layout class="QHBoxLayout" name="max_size_layout">
|
||||
<item>
|
||||
<widget class="QSpinBox" name="max_size_spin"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="max_size_units"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="meta_group">
|
||||
<property name="title">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="flat">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="meta_group_layout">
|
||||
<item>
|
||||
<widget class="QFrame" name="meta_section_bar">
|
||||
<layout class="QHBoxLayout" name="metaSectionBarLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="meta_section_label">
|
||||
<property name="text">
|
||||
<string>Metadata</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="meta_grid">
|
||||
<property name="horizontalSpacing">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="meta_state_label">
|
||||
<property name="text">
|
||||
<string>State</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1" rowspan="9">
|
||||
<widget class="QFrame" name="meta_separator">
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::VLine</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Plain</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLabel" name="meta_state">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="meta_last_checked_label">
|
||||
<property name="text">
|
||||
<string>Last checked</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="meta_last_checked">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="meta_last_updated_label">
|
||||
<property name="text">
|
||||
<string>Last updated</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QLabel" name="meta_last_updated">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="meta_failures_label">
|
||||
<property name="text">
|
||||
<string>Failures</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QLabel" name="meta_failures">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="meta_error_label">
|
||||
<property name="text">
|
||||
<string>Error</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="QLabel" name="meta_error">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="meta_file_present_label">
|
||||
<property name="text">
|
||||
<string>List file present</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="2">
|
||||
<widget class="QLabel" name="meta_file_present">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="6" column="0">
|
||||
<widget class="QLabel" name="meta_meta_present_label">
|
||||
<property name="text">
|
||||
<string>List meta present</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="6" column="2">
|
||||
<widget class="QLabel" name="meta_meta_present">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="7" column="0">
|
||||
<widget class="QLabel" name="meta_list_path_label">
|
||||
<property name="text">
|
||||
<string>List path</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="7" column="2">
|
||||
<widget class="QLabel" name="meta_list_path">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="8" column="0">
|
||||
<widget class="QLabel" name="meta_meta_path_label">
|
||||
<property name="text">
|
||||
<string>Meta path</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="8" column="2">
|
||||
<widget class="QLabel" name="meta_meta_path">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="Line" name="footer_separator_line">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="buttons_layout">
|
||||
<item>
|
||||
<widget class="QLabel" name="error_label">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="buttons_spacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="test_url_button">
|
||||
<property name="text">
|
||||
<string>Test URL</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="cancel_button">
|
||||
<property name="text">
|
||||
<string>Cancel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="add_button">
|
||||
<property name="text">
|
||||
<string>Save</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>SubscriptionStatusDialog</class>
|
||||
<widget class="QDialog" name="SubscriptionStatusDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>700</width>
|
||||
<height>440</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Subscription status</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="rootLayout">
|
||||
<property name="leftMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="spacing">
|
||||
<number>8</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="title_label">
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QScrollArea" name="details_scroll">
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::NoFrame</enum>
|
||||
</property>
|
||||
<property name="widgetResizable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<widget class="QWidget" name="details_container"/>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="buttons_layout">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="refresh_button">
|
||||
<property name="text">
|
||||
<string>Refresh</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="edit_button">
|
||||
<property name="text">
|
||||
<string>Edit</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="close_button">
|
||||
<property name="text">
|
||||
<string>Close</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>TextInspectDialog</class>
|
||||
<widget class="QDialog" name="TextInspectDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>760</width>
|
||||
<height>420</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Inspect</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="rootLayout">
|
||||
<property name="leftMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>12</number>
|
||||
</property>
|
||||
<property name="spacing">
|
||||
<number>8</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QPlainTextEdit" name="text_view"/>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="buttonsLayout">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="copy_button">
|
||||
<property name="text">
|
||||
<string>Copy</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="close_button">
|
||||
<property name="text">
|
||||
<string>Close</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -0,0 +1,42 @@
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Keep static typing deterministic for linters/IDEs.
|
||||
# Runtime still supports both PyQt6/PyQt5 below.
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets, uic
|
||||
from PyQt6.QtCore import QCoreApplication as QC
|
||||
from PyQt6.uic.load_ui import loadUiType as load_ui_type
|
||||
else:
|
||||
if "PyQt6" in sys.modules:
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets, uic
|
||||
from PyQt6.QtCore import QCoreApplication as QC
|
||||
from PyQt6.uic.load_ui import loadUiType as load_ui_type
|
||||
elif "PyQt5" in sys.modules:
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets, uic
|
||||
from PyQt5.QtCore import QCoreApplication as QC
|
||||
|
||||
load_ui_type = uic.loadUiType
|
||||
else:
|
||||
try:
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets, uic
|
||||
from PyQt6.QtCore import QCoreApplication as QC
|
||||
from PyQt6.uic.load_ui import loadUiType as load_ui_type
|
||||
except Exception:
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets, uic
|
||||
from PyQt5.QtCore import QCoreApplication as QC
|
||||
|
||||
load_ui_type = uic.loadUiType
|
||||
|
||||
|
||||
__all__ = [
|
||||
"QtCore",
|
||||
"QtGui",
|
||||
"QtWidgets",
|
||||
"uic",
|
||||
"QC",
|
||||
"load_ui_type",
|
||||
"Any",
|
||||
"TYPE_CHECKING",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.io.storage import (
|
||||
read_json_locked,
|
||||
write_json_atomic_locked,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.models.action import MutableActionConfig
|
||||
from opensnitch.plugins.list_subscriptions.models.config import PluginConfig
|
||||
from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults
|
||||
from opensnitch.plugins.list_subscriptions.ui import QC
|
||||
from opensnitch.plugins.list_subscriptions._utils import (
|
||||
DEFAULT_LISTS_DIR,
|
||||
normalize_lists_dir,
|
||||
safe_filename,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
class ActionFileController:
|
||||
def __init__(
|
||||
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
|
||||
):
|
||||
self._dialog = dialog
|
||||
self._cols = columns
|
||||
|
||||
def _col(self, key: str):
|
||||
return self._cols[key]
|
||||
|
||||
def load_action_file(self):
|
||||
with self._dialog._table_view_controller.sorting_suspended():
|
||||
self._dialog._loading = True
|
||||
self._dialog._status_controller.set_status("")
|
||||
self._dialog._defaults_ui_controller.reload_nodes()
|
||||
self._dialog.table.setRowCount(0)
|
||||
self._dialog.create_file_button.setVisible(True)
|
||||
self._dialog.lists_dir_edit.setText(DEFAULT_LISTS_DIR)
|
||||
self._dialog.enable_plugin_check.setChecked(True)
|
||||
self._dialog._runtime_controller.set_runtime_state(active=False)
|
||||
self._dialog._global_defaults = GlobalDefaults.from_dict(
|
||||
{}, lists_dir=DEFAULT_LISTS_DIR
|
||||
)
|
||||
self._dialog._defaults_ui_controller.apply_defaults_to_widgets()
|
||||
|
||||
if not os.path.exists(self._dialog._action_path):
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats", "Action file not found. Click 'Create action file'."
|
||||
),
|
||||
error=False,
|
||||
)
|
||||
self._dialog._loading = False
|
||||
return
|
||||
|
||||
try:
|
||||
data = read_json_locked(self._dialog._action_path)
|
||||
except Exception as e:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Error reading action file: {0}").format(
|
||||
str(e)
|
||||
),
|
||||
error=True,
|
||||
)
|
||||
self._dialog._loading = False
|
||||
return
|
||||
|
||||
action_model = MutableActionConfig.from_action_dict(
|
||||
data, lists_dir=DEFAULT_LISTS_DIR
|
||||
)
|
||||
self._dialog._global_defaults = action_model.plugin.defaults
|
||||
self._dialog.enable_plugin_check.setChecked(True)
|
||||
self._dialog._runtime_controller.sync_runtime_binding_state()
|
||||
self._dialog.lists_dir_edit.setText(
|
||||
normalize_lists_dir(self._dialog._global_defaults.lists_dir)
|
||||
)
|
||||
self._dialog._defaults_ui_controller.apply_defaults_to_widgets()
|
||||
|
||||
normalized_subs = action_model.plugin.subscriptions
|
||||
actions_obj = data.get("actions", {})
|
||||
action_cfg = (
|
||||
actions_obj.get("list_subscriptions", {})
|
||||
if isinstance(actions_obj, dict)
|
||||
else {}
|
||||
)
|
||||
plugin_cfg_raw = (
|
||||
action_cfg.get("config", {}) if isinstance(action_cfg, dict) else {}
|
||||
)
|
||||
plugin_cfg = plugin_cfg_raw if isinstance(plugin_cfg_raw, dict) else {}
|
||||
raw_subs = plugin_cfg.get("subscriptions")
|
||||
migrated_legacy_group = False
|
||||
if isinstance(raw_subs, list):
|
||||
for item in raw_subs:
|
||||
if (
|
||||
isinstance(item, dict)
|
||||
and ("group" in item)
|
||||
and ("groups" not in item)
|
||||
):
|
||||
migrated_legacy_group = True
|
||||
break
|
||||
normalized_subs_dicts = [s.to_dict() for s in normalized_subs]
|
||||
fixed_count = (
|
||||
1
|
||||
if (isinstance(raw_subs, list) and raw_subs != normalized_subs_dicts)
|
||||
else 0
|
||||
)
|
||||
|
||||
for sub in normalized_subs:
|
||||
self._dialog._table_data_controller.append_row(sub)
|
||||
|
||||
self._dialog._loading = False
|
||||
self._dialog._table_data_controller.refresh_states()
|
||||
self._dialog._selection_controller.update_selected_actions_state()
|
||||
self._dialog.create_file_button.setVisible(False)
|
||||
if migrated_legacy_group:
|
||||
self._dialog._status_controller.append_log(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Detected legacy 'group' fields and migrated them to 'groups'.",
|
||||
),
|
||||
level="WARN",
|
||||
)
|
||||
self.save_action_file()
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Migrated legacy 'group' entries to 'groups' and auto-saved configuration.",
|
||||
),
|
||||
error=False,
|
||||
)
|
||||
return
|
||||
if fixed_count > 0:
|
||||
self._dialog._status_controller.append_log(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Normalized subscription fields while loading configuration.",
|
||||
),
|
||||
level="WARN",
|
||||
)
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Loaded configuration with normalized subscription fields.",
|
||||
),
|
||||
error=False,
|
||||
)
|
||||
else:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "List subscriptions configuration loaded."),
|
||||
error=False,
|
||||
)
|
||||
|
||||
def create_action_file(self):
|
||||
try:
|
||||
os.makedirs(
|
||||
os.path.dirname(self._dialog._action_path), mode=0o700, exist_ok=True
|
||||
)
|
||||
if not os.path.exists(self._dialog._action_path):
|
||||
action_model = MutableActionConfig.default(DEFAULT_LISTS_DIR)
|
||||
write_json_atomic_locked(
|
||||
self._dialog._action_path,
|
||||
action_model.to_action_dict(),
|
||||
)
|
||||
self.load_action_file()
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Action file created."),
|
||||
error=False,
|
||||
)
|
||||
except Exception as e:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Error creating action file: {0}").format(str(e)),
|
||||
error=True,
|
||||
)
|
||||
|
||||
def save_action_file(self):
|
||||
if self._dialog._loading:
|
||||
return
|
||||
|
||||
if not os.path.exists(self._dialog._action_path):
|
||||
self.create_action_file()
|
||||
if not os.path.exists(self._dialog._action_path):
|
||||
return
|
||||
|
||||
subscriptions = self._dialog._table_data_controller.collect_subscriptions()
|
||||
if subscriptions is None:
|
||||
return
|
||||
|
||||
lists_dir = normalize_lists_dir(
|
||||
self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR
|
||||
)
|
||||
try:
|
||||
os.makedirs(lists_dir, mode=0o700, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
defaults = GlobalDefaults(
|
||||
lists_dir=lists_dir,
|
||||
interval=max(1, int(self._dialog.default_interval_spin.value())),
|
||||
interval_units=self._dialog.default_interval_units.currentText(),
|
||||
timeout=max(1, int(self._dialog.default_timeout_spin.value())),
|
||||
timeout_units=self._dialog.default_timeout_units.currentText(),
|
||||
max_size=max(1, int(self._dialog.default_max_size_spin.value())),
|
||||
max_size_units=self._dialog.default_max_size_units.currentText(),
|
||||
user_agent=(self._dialog.default_user_agent.text() or "").strip(),
|
||||
)
|
||||
action_model = MutableActionConfig.default(lists_dir)
|
||||
action_model.enabled = True
|
||||
action_model.plugin.defaults = defaults
|
||||
action_model.plugin.subscriptions = subscriptions
|
||||
normalized_subscriptions = action_model.plugin.normalize_subscriptions(
|
||||
invalidate_duplicates=True
|
||||
)
|
||||
if normalized_subscriptions is None:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Invalid subscriptions: duplicate filename for the same URL.",
|
||||
),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
action = action_model.to_action_dict()
|
||||
|
||||
compiled_cfg = PluginConfig.from_dict(
|
||||
action_model.plugin.to_dict(),
|
||||
lists_dir=lists_dir,
|
||||
invalidate_duplicates=True,
|
||||
)
|
||||
if len(compiled_cfg.subscriptions) != len(normalized_subscriptions):
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats", "Invalid subscriptions: URL and filename are mandatory."
|
||||
),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
for row, sub in enumerate(normalized_subscriptions):
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("name"),
|
||||
sub.name,
|
||||
)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("filename"),
|
||||
safe_filename(sub.filename),
|
||||
)
|
||||
|
||||
try:
|
||||
write_json_atomic_locked(self._dialog._action_path, action)
|
||||
except Exception as e:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Error saving action file: {0}").format(str(e)),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
self._dialog._status_controller.append_log(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Saving configuration: {0} subscriptions, runtime {1}.",
|
||||
).format(
|
||||
len(normalized_subscriptions),
|
||||
QC.translate("stats", "enabled")
|
||||
if action_model.enabled
|
||||
else QC.translate("stats", "disabled"),
|
||||
),
|
||||
)
|
||||
self._dialog._runtime_controller.apply_runtime_state(
|
||||
action_model.enabled
|
||||
)
|
||||
self._dialog._table_data_controller.refresh_states()
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "List subscriptions configuration saved."),
|
||||
error=False,
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AttachedRulesIndex:
|
||||
def __init__(self) -> None:
|
||||
self._snapshot: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
def snapshot(self) -> dict[str, list[dict[str, Any]]]:
|
||||
return dict(self._snapshot)
|
||||
|
||||
def set_from_snapshot_obj(
|
||||
self,
|
||||
snapshot: object,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
data: dict[str, list[dict[str, Any]]] = {}
|
||||
if isinstance(snapshot, dict):
|
||||
for key, value in snapshot.items():
|
||||
if not isinstance(key, str) or not isinstance(value, list):
|
||||
continue
|
||||
normalized_key = os.path.normpath(key)
|
||||
items: list[dict[str, Any]] = []
|
||||
for entry in value:
|
||||
if isinstance(entry, dict):
|
||||
items.append(entry)
|
||||
data[normalized_key] = items
|
||||
self._snapshot = data
|
||||
return data
|
||||
|
||||
def apply_rule_editor_change(self, change: dict[str, Any]) -> bool:
|
||||
addr = str(change.get("addr", "") or "").strip()
|
||||
if addr == "":
|
||||
return False
|
||||
|
||||
old_name = str(change.get("old_name", "") or "").strip()
|
||||
new_name = str(change.get("new_name", "") or "").strip()
|
||||
enabled = bool(change.get("enabled", True))
|
||||
directories = [
|
||||
str(path).strip()
|
||||
for path in list(change.get("directories", []))
|
||||
if str(path).strip() != ""
|
||||
]
|
||||
|
||||
names_to_remove = {name for name in (old_name, new_name) if name != ""}
|
||||
self._remove_rule_from_snapshot(addr, names_to_remove)
|
||||
if new_name != "" and directories:
|
||||
self._upsert_rule_in_snapshot(
|
||||
addr=addr,
|
||||
rule_name=new_name,
|
||||
enabled=enabled,
|
||||
directories=directories,
|
||||
)
|
||||
return True
|
||||
|
||||
def update_rule_enabled(self, addr: str, rule_name: str, enabled: bool) -> bool:
|
||||
changed = False
|
||||
for entries in self._snapshot.values():
|
||||
for entry in entries:
|
||||
if (
|
||||
str(entry.get("addr", "")).strip() == addr
|
||||
and str(entry.get("name", "")).strip() == rule_name
|
||||
):
|
||||
entry["enabled"] = bool(enabled)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
def remove_rule(self, addr: str, rule_name: str) -> None:
|
||||
self._remove_rule_from_snapshot(addr, {rule_name})
|
||||
|
||||
def _remove_rule_from_snapshot(self, addr: str, rule_names: set[str]) -> None:
|
||||
if not rule_names:
|
||||
return
|
||||
for directory, entries in list(self._snapshot.items()):
|
||||
filtered = [
|
||||
entry
|
||||
for entry in entries
|
||||
if not (
|
||||
str(entry.get("addr", "")).strip() == addr
|
||||
and str(entry.get("name", "")).strip() in rule_names
|
||||
)
|
||||
]
|
||||
if filtered:
|
||||
self._snapshot[directory] = filtered
|
||||
else:
|
||||
del self._snapshot[directory]
|
||||
|
||||
def _upsert_rule_in_snapshot(
|
||||
self,
|
||||
*,
|
||||
addr: str,
|
||||
rule_name: str,
|
||||
enabled: bool,
|
||||
directories: list[str],
|
||||
) -> None:
|
||||
normalized_dirs = [
|
||||
os.path.normpath(path)
|
||||
for path in directories
|
||||
if path.strip()
|
||||
]
|
||||
if not normalized_dirs:
|
||||
return
|
||||
for directory in normalized_dirs:
|
||||
entries = self._snapshot.setdefault(directory, [])
|
||||
for entry in entries:
|
||||
if (
|
||||
str(entry.get("addr", "")).strip() == addr
|
||||
and str(entry.get("name", "")).strip() == rule_name
|
||||
):
|
||||
entry["enabled"] = bool(enabled)
|
||||
break
|
||||
else:
|
||||
entries.append(
|
||||
{
|
||||
"addr": addr,
|
||||
"name": rule_name,
|
||||
"enabled": bool(enabled),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.bulk_edit_dialog import (
|
||||
BulkEditDialog,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions._utils import (
|
||||
INTERVAL_UNITS,
|
||||
TIMEOUT_UNITS,
|
||||
SIZE_UNITS,
|
||||
display_str,
|
||||
normalize_groups,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
class BulkEditController:
|
||||
def __init__(
|
||||
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
|
||||
):
|
||||
self._dialog = dialog
|
||||
self._cols = columns
|
||||
|
||||
def _col(self, key: str):
|
||||
return self._cols[key]
|
||||
|
||||
def bulk_edit(self, rows: list[int]):
|
||||
if not rows:
|
||||
return
|
||||
dlg = BulkEditDialog(
|
||||
self._dialog,
|
||||
self._dialog._global_defaults,
|
||||
groups=self._dialog._selection_controller.known_groups(),
|
||||
selected_count=len(rows),
|
||||
)
|
||||
if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted:
|
||||
return
|
||||
values = dlg.values()
|
||||
with self._dialog._table_view_controller.sorting_suspended():
|
||||
for row in rows:
|
||||
if values.get("enabled") is not None:
|
||||
enabled_item = self._dialog.table.item(row, self._col("enabled"))
|
||||
if enabled_item is None:
|
||||
enabled_item = self._dialog._table_data_controller.new_enabled_item(False)
|
||||
self._dialog.table.setItem(
|
||||
row,
|
||||
self._col("enabled"),
|
||||
enabled_item,
|
||||
)
|
||||
enabled_item.setCheckState(
|
||||
QtCore.Qt.CheckState.Checked
|
||||
if bool(values["enabled"])
|
||||
else QtCore.Qt.CheckState.Unchecked
|
||||
)
|
||||
if values.get("groups") is not None:
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("group"),
|
||||
", ".join(normalize_groups(values["groups"])),
|
||||
)
|
||||
if values.get("format") is not None:
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("format"),
|
||||
str(values["format"]),
|
||||
)
|
||||
if values.get("apply_interval"):
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("interval"),
|
||||
display_str(values.get("interval")),
|
||||
)
|
||||
interval_units = display_str(values.get("interval_units"))
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("interval_units"),
|
||||
interval_units,
|
||||
)
|
||||
self._dialog._defaults_ui_controller.set_units_combo(
|
||||
row,
|
||||
self._col("interval_units"),
|
||||
INTERVAL_UNITS,
|
||||
interval_units,
|
||||
)
|
||||
if values.get("apply_timeout"):
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("timeout"),
|
||||
display_str(values.get("timeout")),
|
||||
)
|
||||
timeout_units = display_str(values.get("timeout_units"))
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("timeout_units"),
|
||||
timeout_units,
|
||||
)
|
||||
self._dialog._defaults_ui_controller.set_units_combo(
|
||||
row,
|
||||
self._col("timeout_units"),
|
||||
TIMEOUT_UNITS,
|
||||
timeout_units,
|
||||
)
|
||||
if values.get("apply_max_size"):
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("max_size"),
|
||||
display_str(values.get("max_size")),
|
||||
)
|
||||
max_size_units = display_str(values.get("max_size_units"))
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("max_size_units"),
|
||||
max_size_units,
|
||||
)
|
||||
self._dialog._defaults_ui_controller.set_units_combo(
|
||||
row,
|
||||
self._col("max_size_units"),
|
||||
SIZE_UNITS,
|
||||
max_size_units,
|
||||
)
|
||||
self._dialog._table_data_controller.ensure_row_final_filename(row)
|
||||
self._dialog._table_data_controller.update_row_sort_keys(row)
|
||||
self._dialog._action_file_controller.save_action_file()
|
||||
self._dialog._table_data_controller.refresh_states()
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Updated {0} selected subscriptions.").format(
|
||||
len(rows)
|
||||
),
|
||||
error=False,
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
class ContextMenuController:
|
||||
def __init__(self, *, dialog: "ListSubscriptionsDialog"):
|
||||
self._dialog = dialog
|
||||
|
||||
def open_table_context_menu(self, pos: Any):
|
||||
rows = self._dialog._selection_controller.selected_rows()
|
||||
if not rows:
|
||||
row = self._dialog.table.rowAt(pos.y())
|
||||
if row >= 0:
|
||||
self._dialog.table.selectRow(row)
|
||||
rows = [row]
|
||||
|
||||
menu = QtWidgets.QMenu(self._dialog.table)
|
||||
viewport = self._dialog.table.viewport()
|
||||
if viewport is None:
|
||||
return
|
||||
|
||||
if not rows:
|
||||
act_reset_sort = menu.addAction(QC.translate("stats", "Reset sorting"))
|
||||
act_reset_widths = menu.addAction(
|
||||
QC.translate("stats", "Reset column widths")
|
||||
)
|
||||
chosen = QtWidgets.QMenu.exec(
|
||||
menu.actions(),
|
||||
viewport.mapToGlobal(pos),
|
||||
None,
|
||||
menu,
|
||||
)
|
||||
if chosen is act_reset_sort:
|
||||
self._dialog._table_view_controller.reset_table_sort_for_current_tab()
|
||||
elif chosen is act_reset_widths:
|
||||
self._dialog._table_view_controller.reset_table_column_widths_for_current_tab()
|
||||
return
|
||||
|
||||
if len(rows) == 1:
|
||||
act_inspect = menu.addAction(QC.translate("stats", "Inspect"))
|
||||
act_edit = menu.addAction(QC.translate("stats", "Edit"))
|
||||
act_remove = menu.addAction(QC.translate("stats", "Delete"))
|
||||
act_refresh = menu.addAction(QC.translate("stats", "Refresh"))
|
||||
act_rule = menu.addAction(QC.translate("stats", "Rules"))
|
||||
menu.addSeparator()
|
||||
act_reset_sort = menu.addAction(QC.translate("stats", "Reset sorting"))
|
||||
act_reset_widths = menu.addAction(
|
||||
QC.translate("stats", "Reset column widths")
|
||||
)
|
||||
chosen = QtWidgets.QMenu.exec(
|
||||
menu.actions(),
|
||||
viewport.mapToGlobal(pos),
|
||||
None,
|
||||
menu,
|
||||
)
|
||||
if chosen is act_inspect:
|
||||
self._dialog._selection_controller.open_selected_inspector()
|
||||
elif chosen is act_edit:
|
||||
self._dialog._subscription_edit_controller.edit_selected_subscription()
|
||||
elif chosen is act_remove:
|
||||
self._dialog.remove_selected_subscription()
|
||||
elif chosen is act_refresh:
|
||||
self._dialog._table_data_controller.refresh_selected_now()
|
||||
elif chosen is act_rule:
|
||||
self._dialog._rules_attachment_controller.show_attached_rules_dialog()
|
||||
elif chosen is act_reset_sort:
|
||||
self._dialog._table_view_controller.reset_table_sort_for_current_tab()
|
||||
elif chosen is act_reset_widths:
|
||||
self._dialog._table_view_controller.reset_table_column_widths_for_current_tab()
|
||||
return
|
||||
|
||||
act_inspect = menu.addAction(QC.translate("stats", "Inspect"))
|
||||
act_edit = menu.addAction(QC.translate("stats", "Edit"))
|
||||
act_remove = menu.addAction(QC.translate("stats", "Delete"))
|
||||
act_refresh = menu.addAction(QC.translate("stats", "Refresh"))
|
||||
act_rule = menu.addAction(QC.translate("stats", "Create rule"))
|
||||
menu.addSeparator()
|
||||
act_reset_sort = menu.addAction(QC.translate("stats", "Reset sorting"))
|
||||
act_reset_widths = menu.addAction(QC.translate("stats", "Reset column widths"))
|
||||
chosen = QtWidgets.QMenu.exec(
|
||||
menu.actions(),
|
||||
viewport.mapToGlobal(pos),
|
||||
None,
|
||||
menu,
|
||||
)
|
||||
if chosen is act_inspect:
|
||||
self._dialog._selection_controller.open_selected_inspector()
|
||||
elif chosen is act_edit:
|
||||
self._dialog._bulk_edit_controller.bulk_edit(rows)
|
||||
elif chosen is act_remove:
|
||||
self._dialog.remove_selected_subscription()
|
||||
elif chosen is act_refresh:
|
||||
self._dialog._table_data_controller.refresh_selected_now()
|
||||
elif chosen is act_rule:
|
||||
self._dialog._rules_editor_controller.create_rule_from_selected()
|
||||
elif chosen is act_reset_sort:
|
||||
self._dialog._table_view_controller.reset_table_sort_for_current_tab()
|
||||
elif chosen is act_reset_widths:
|
||||
self._dialog._table_view_controller.reset_table_column_widths_for_current_tab()
|
||||
@@ -0,0 +1,77 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC
|
||||
from opensnitch.plugins.list_subscriptions.ui.widgets.helpers import _configure_spin_and_units
|
||||
from opensnitch.plugins.list_subscriptions._utils import (
|
||||
INTERVAL_UNITS,
|
||||
SIZE_UNITS,
|
||||
TIMEOUT_UNITS,
|
||||
normalize_unit,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
class DefaultsUiController:
|
||||
def __init__(self, *, dialog: "ListSubscriptionsDialog"):
|
||||
self._dialog = dialog
|
||||
|
||||
def reload_nodes(self):
|
||||
self._dialog.nodes_combo.blockSignals(True)
|
||||
self._dialog.nodes_combo.clear()
|
||||
for addr in self._dialog._nodes.get_nodes():
|
||||
self._dialog.nodes_combo.addItem(addr, addr)
|
||||
self._dialog.nodes_combo.blockSignals(False)
|
||||
|
||||
def apply_defaults_to_widgets(self):
|
||||
_configure_spin_and_units(
|
||||
self._dialog.default_interval_spin,
|
||||
self._dialog.default_interval_units,
|
||||
value=int(self._dialog._global_defaults.interval),
|
||||
unit_value=self._dialog._global_defaults.interval_units,
|
||||
allowed_units=INTERVAL_UNITS,
|
||||
fallback_unit="hours",
|
||||
min_value=1,
|
||||
)
|
||||
_configure_spin_and_units(
|
||||
self._dialog.default_timeout_spin,
|
||||
self._dialog.default_timeout_units,
|
||||
value=int(self._dialog._global_defaults.timeout),
|
||||
unit_value=self._dialog._global_defaults.timeout_units,
|
||||
allowed_units=TIMEOUT_UNITS,
|
||||
fallback_unit="seconds",
|
||||
min_value=1,
|
||||
)
|
||||
_configure_spin_and_units(
|
||||
self._dialog.default_max_size_spin,
|
||||
self._dialog.default_max_size_units,
|
||||
value=int(self._dialog._global_defaults.max_size),
|
||||
unit_value=self._dialog._global_defaults.max_size_units,
|
||||
allowed_units=SIZE_UNITS,
|
||||
fallback_unit="MB",
|
||||
min_value=1,
|
||||
)
|
||||
self._dialog.default_user_agent.setText(
|
||||
(self._dialog._global_defaults.user_agent or "").strip()
|
||||
)
|
||||
|
||||
def set_units_combo(
|
||||
self, row: int, col: int, allowed: tuple[str, ...], value: str | None
|
||||
):
|
||||
combo = QtWidgets.QComboBox()
|
||||
combo.addItem("")
|
||||
combo.addItems(allowed)
|
||||
combo.setToolTip(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Leave blank to inherit the global default for this subscription.",
|
||||
)
|
||||
)
|
||||
if value is None or value.strip() == "":
|
||||
combo.setCurrentIndex(0)
|
||||
else:
|
||||
combo.setCurrentText(normalize_unit(value, allowed, allowed[0]))
|
||||
self._dialog.table.setCellWidget(row, col, combo)
|
||||
@@ -0,0 +1,378 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.text_inspect_dialog import (
|
||||
TextInspectDialog,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions._utils import safe_filename, timestamp_sort_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
class InspectorController:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
dialog: "ListSubscriptionsDialog",
|
||||
columns: dict[str, int],
|
||||
error_preview_limit: int,
|
||||
):
|
||||
self._dialog = dialog
|
||||
self._cols = columns
|
||||
self._error_preview_limit = error_preview_limit
|
||||
|
||||
def _col(self, key: str):
|
||||
return self._cols[key]
|
||||
|
||||
def show_error_inspect_dialog(self):
|
||||
text = (self._dialog._inspect_error_full_text or "").strip()
|
||||
dlg = TextInspectDialog(
|
||||
self._dialog,
|
||||
title=QC.translate("stats", "Error details"),
|
||||
text=text,
|
||||
)
|
||||
dlg.exec()
|
||||
|
||||
def set_inspector_toggle_icon(self):
|
||||
style = self._dialog.style()
|
||||
if style is None:
|
||||
return
|
||||
if not self._dialog._inspect_has_selection:
|
||||
icon = style.standardIcon(
|
||||
QtWidgets.QStyle.StandardPixmap.SP_ArrowLeft
|
||||
)
|
||||
tip = QC.translate("stats", "Select a subscription to inspect")
|
||||
self._dialog._inspect_toggle_button.setIcon(icon)
|
||||
self._dialog._inspect_toggle_button.setToolTip(tip)
|
||||
self._dialog._inspect_toggle_button.setEnabled(False)
|
||||
return
|
||||
if self._dialog._inspect_collapsed:
|
||||
icon = style.standardIcon(
|
||||
QtWidgets.QStyle.StandardPixmap.SP_ArrowLeft
|
||||
)
|
||||
tip = QC.translate("stats", "Expand inspector")
|
||||
else:
|
||||
icon = style.standardIcon(
|
||||
QtWidgets.QStyle.StandardPixmap.SP_ArrowRight
|
||||
)
|
||||
tip = QC.translate("stats", "Collapse inspector")
|
||||
self._dialog._inspect_toggle_button.setEnabled(True)
|
||||
self._dialog._inspect_toggle_button.setIcon(icon)
|
||||
self._dialog._inspect_toggle_button.setToolTip(tip)
|
||||
|
||||
def toggle_inspector_collapsed(self):
|
||||
if not self._dialog._inspect_has_selection:
|
||||
return
|
||||
self._dialog._inspect_collapsed = not self._dialog._inspect_collapsed
|
||||
if not self._dialog._inspect_panel.isVisible():
|
||||
return
|
||||
if self._dialog._inspect_collapsed:
|
||||
self._dialog._inspect_scroll.setVisible(False)
|
||||
self._dialog._inspect_title_label.setVisible(False)
|
||||
self._dialog._inspect_header_separator.setVisible(False)
|
||||
self._dialog._inspect_panel.setMinimumWidth(36)
|
||||
self._dialog._inspect_panel.setMaximumWidth(36)
|
||||
total = max(36, self._dialog._table_inspect_splitter.width())
|
||||
self._dialog._table_inspect_splitter.setSizes([max(1, total - 36), 36])
|
||||
else:
|
||||
self._dialog._inspect_scroll.setVisible(True)
|
||||
self._dialog._inspect_title_label.setVisible(True)
|
||||
self._dialog._inspect_header_separator.setVisible(True)
|
||||
self._dialog._inspect_panel.setMinimumWidth(240)
|
||||
self._dialog._inspect_panel.setMaximumWidth(16777215)
|
||||
total = max(300, self._dialog._table_inspect_splitter.width())
|
||||
width = min(self._dialog._inspect_default_width, max(280, total // 2))
|
||||
self._dialog._table_inspect_splitter.setSizes([max(1, total - width), width])
|
||||
self.set_inspector_toggle_icon()
|
||||
|
||||
def set_inspector_visible(self, visible: bool):
|
||||
if not hasattr(self._dialog, "_inspect_panel"):
|
||||
return
|
||||
self._dialog._inspect_has_selection = bool(visible)
|
||||
self._dialog._inspect_panel.setVisible(True)
|
||||
if not self._dialog._inspect_has_selection:
|
||||
self._dialog._inspect_collapsed = True
|
||||
self._dialog._inspect_scroll.setVisible(False)
|
||||
self._dialog._inspect_title_label.setVisible(False)
|
||||
self._dialog._inspect_header_separator.setVisible(False)
|
||||
self._dialog._inspect_panel.setMinimumWidth(36)
|
||||
self._dialog._inspect_panel.setMaximumWidth(36)
|
||||
total = max(36, self._dialog._table_inspect_splitter.width())
|
||||
self._dialog._table_inspect_splitter.setSizes([max(1, total - 36), 36])
|
||||
self.set_inspector_toggle_icon()
|
||||
return
|
||||
if self._dialog._inspect_collapsed:
|
||||
self._dialog._inspect_scroll.setVisible(False)
|
||||
self._dialog._inspect_title_label.setVisible(False)
|
||||
self._dialog._inspect_header_separator.setVisible(False)
|
||||
self._dialog._inspect_panel.setMinimumWidth(36)
|
||||
self._dialog._inspect_panel.setMaximumWidth(36)
|
||||
total = max(36, self._dialog._table_inspect_splitter.width())
|
||||
self._dialog._table_inspect_splitter.setSizes([max(1, total - 36), 36])
|
||||
else:
|
||||
self._dialog._inspect_scroll.setVisible(True)
|
||||
self._dialog._inspect_title_label.setVisible(True)
|
||||
self._dialog._inspect_header_separator.setVisible(True)
|
||||
self._dialog._inspect_panel.setMinimumWidth(240)
|
||||
self._dialog._inspect_panel.setMaximumWidth(16777215)
|
||||
total = max(300, self._dialog._table_inspect_splitter.width())
|
||||
width = min(self._dialog._inspect_default_width, max(240, total // 3))
|
||||
self._dialog._table_inspect_splitter.setSizes([max(1, total - width), width])
|
||||
self.set_inspector_toggle_icon()
|
||||
|
||||
def set_inspector_values(
|
||||
self,
|
||||
*,
|
||||
row: int,
|
||||
name: str,
|
||||
url: str,
|
||||
filename: str,
|
||||
meta: dict[str, str],
|
||||
):
|
||||
enabled_item = self._dialog.table.item(row, self._col("enabled"))
|
||||
enabled = enabled_item is not None and (
|
||||
enabled_item.checkState() == QtCore.Qt.CheckState.Checked
|
||||
)
|
||||
interval_value = self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("interval")
|
||||
)
|
||||
interval_units = self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("interval_units")
|
||||
)
|
||||
timeout_value = self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("timeout")
|
||||
)
|
||||
timeout_units = self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("timeout_units")
|
||||
)
|
||||
max_size_value = self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("max_size")
|
||||
)
|
||||
max_size_units = self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("max_size_units")
|
||||
)
|
||||
values = {
|
||||
"enabled": QC.translate("stats", "Yes") if enabled else QC.translate("stats", "No"),
|
||||
"name": name,
|
||||
"url": url,
|
||||
"filename": filename,
|
||||
"format": self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("format")
|
||||
),
|
||||
"groups": self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("group")
|
||||
),
|
||||
"interval": " ".join(
|
||||
part for part in (interval_value, interval_units) if (part or "").strip() != ""
|
||||
),
|
||||
"timeout": " ".join(
|
||||
part for part in (timeout_value, timeout_units) if (part or "").strip() != ""
|
||||
),
|
||||
"max_size": " ".join(
|
||||
part for part in (max_size_value, max_size_units) if (part or "").strip() != ""
|
||||
),
|
||||
"state": meta.get("state", ""),
|
||||
"last_checked": meta.get("last_checked", ""),
|
||||
"last_updated": meta.get("last_updated", ""),
|
||||
"failures": meta.get("failures", ""),
|
||||
"error": meta.get("error", ""),
|
||||
"list_path": meta.get("list_path", ""),
|
||||
"meta_path": meta.get("meta_path", ""),
|
||||
}
|
||||
for key, value in values.items():
|
||||
label = self._dialog._inspect_value_labels.get(key)
|
||||
if label is None:
|
||||
continue
|
||||
text = (str(value or "")).strip() or "-"
|
||||
if key == "error":
|
||||
self.set_error_preview(text)
|
||||
continue
|
||||
if key == "state":
|
||||
label.setText(text)
|
||||
label.setStyleSheet(
|
||||
f"color: {self.state_bucket_color(text).name()};"
|
||||
)
|
||||
continue
|
||||
label.setText(text)
|
||||
|
||||
def state_bucket_color(self, state: str):
|
||||
normalized = (state or "").strip().lower()
|
||||
if normalized in ("updated", "not_modified"):
|
||||
return self._dialog._table_data_controller.state_text_color("updated")
|
||||
if normalized == "pending":
|
||||
return self._dialog._table_data_controller.state_text_color("pending")
|
||||
return self._dialog._table_data_controller.state_text_color("error")
|
||||
|
||||
def set_error_preview(self, text: str):
|
||||
error_label = self._dialog._inspect_value_labels.get("error")
|
||||
if error_label is None:
|
||||
return
|
||||
|
||||
normalized = (text or "").strip()
|
||||
if normalized in ("", "-"):
|
||||
self._dialog._inspect_error_full_text = ""
|
||||
error_label.setText("-")
|
||||
error_label.setToolTip("")
|
||||
if self._dialog._inspect_error_button is not None:
|
||||
self._dialog._inspect_error_button.setVisible(False)
|
||||
return
|
||||
|
||||
self._dialog._inspect_error_full_text = normalized
|
||||
if len(normalized) <= self._error_preview_limit:
|
||||
error_label.setText(normalized)
|
||||
error_label.setToolTip(normalized)
|
||||
if self._dialog._inspect_error_button is not None:
|
||||
self._dialog._inspect_error_button.setVisible(False)
|
||||
return
|
||||
|
||||
preview = normalized[: self._error_preview_limit - 1].rstrip() + "..."
|
||||
error_label.setText(preview)
|
||||
error_label.setToolTip(normalized)
|
||||
if self._dialog._inspect_error_button is not None:
|
||||
self._dialog._inspect_error_button.setVisible(True)
|
||||
self._dialog._inspect_error_button.setEnabled(True)
|
||||
|
||||
def set_inspector_multi_selection_mode(self, enabled: bool):
|
||||
if enabled:
|
||||
self._dialog._inspect_details_widget.setVisible(False)
|
||||
self._dialog._inspect_summary_widget.setVisible(True)
|
||||
return
|
||||
self._dialog._inspect_details_widget.setVisible(True)
|
||||
self._dialog._inspect_summary_widget.setVisible(False)
|
||||
|
||||
def set_inspector_summary_values(self, rows: list[int]):
|
||||
selected_count = len(rows)
|
||||
enabled_count = 0
|
||||
healthy_count = 0
|
||||
pending_count = 0
|
||||
problematic_count = 0
|
||||
total_failures = 0
|
||||
with_errors = 0
|
||||
newest_checked = ""
|
||||
oldest_checked = ""
|
||||
newest_key = None
|
||||
oldest_key = None
|
||||
|
||||
for row in rows:
|
||||
enabled_item = self._dialog.table.item(row, self._col("enabled"))
|
||||
if enabled_item is not None and (
|
||||
enabled_item.checkState() == QtCore.Qt.CheckState.Checked
|
||||
):
|
||||
enabled_count += 1
|
||||
|
||||
meta = self._dialog._table_data_controller.row_meta_snapshot(row)
|
||||
state = (meta.get("state", "") or "").strip().lower()
|
||||
if state in ("updated", "not_modified"):
|
||||
healthy_count += 1
|
||||
elif state == "pending":
|
||||
pending_count += 1
|
||||
else:
|
||||
problematic_count += 1
|
||||
|
||||
failures_text = (meta.get("failures", "") or "").strip()
|
||||
try:
|
||||
total_failures += int(failures_text or "0")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if (meta.get("error", "") or "").strip() != "":
|
||||
with_errors += 1
|
||||
|
||||
checked = (meta.get("last_checked", "") or "").strip()
|
||||
if checked == "":
|
||||
continue
|
||||
checked_key = timestamp_sort_key(checked)
|
||||
if newest_key is None or checked_key > newest_key:
|
||||
newest_key = checked_key
|
||||
newest_checked = checked
|
||||
if oldest_key is None or checked_key < oldest_key:
|
||||
oldest_key = checked_key
|
||||
oldest_checked = checked
|
||||
|
||||
values = {
|
||||
"selected": str(selected_count),
|
||||
"enabled": f"{enabled_count}/{selected_count}",
|
||||
"healthy": str(healthy_count),
|
||||
"pending": str(pending_count),
|
||||
"problematic": str(problematic_count),
|
||||
"failures": str(total_failures),
|
||||
"with_errors": str(with_errors),
|
||||
"newest_checked": newest_checked,
|
||||
"oldest_checked": oldest_checked,
|
||||
}
|
||||
for key, value in values.items():
|
||||
label = self._dialog._inspect_summary_labels.get(key)
|
||||
if label is None:
|
||||
continue
|
||||
label.setText((value or "").strip() or "-")
|
||||
|
||||
def update_inspector_panel(self):
|
||||
if not hasattr(self._dialog, "_inspect_panel"):
|
||||
return
|
||||
|
||||
rows = self._dialog._selection_controller.selected_rows()
|
||||
if not rows:
|
||||
self.set_inspector_visible(False)
|
||||
return
|
||||
|
||||
self.set_inspector_multi_selection_mode(len(rows) > 1)
|
||||
if len(rows) > 1:
|
||||
self.set_inspector_summary_values(rows)
|
||||
self.set_inspector_visible(True)
|
||||
return
|
||||
|
||||
row = rows[0]
|
||||
name = self._dialog._table_data_controller.cell_text(row, self._col("name"))
|
||||
url = self._dialog._table_data_controller.cell_text(row, self._col("url"))
|
||||
filename = safe_filename(
|
||||
self._dialog._table_data_controller.cell_text(row, self._col("filename"))
|
||||
)
|
||||
meta = self._dialog._table_data_controller.row_meta_snapshot(row)
|
||||
self.set_inspector_values(
|
||||
row=row,
|
||||
name=name,
|
||||
url=url,
|
||||
filename=filename,
|
||||
meta=meta,
|
||||
)
|
||||
self.set_inspector_visible(True)
|
||||
|
||||
def handle_table_selection_changed(self, *_):
|
||||
self._dialog._selection_controller.update_selected_actions_state()
|
||||
self.update_inspector_panel()
|
||||
|
||||
def on_subscription_state_refreshed(self, url: str, filename: str, meta: dict[str, str]):
|
||||
if not hasattr(self._dialog, "_inspect_panel"):
|
||||
return
|
||||
if not self._dialog._inspect_panel.isVisible():
|
||||
return
|
||||
|
||||
rows = self._dialog._selection_controller.selected_rows()
|
||||
if not rows:
|
||||
self.set_inspector_visible(False)
|
||||
return
|
||||
if len(rows) > 1:
|
||||
changed_row = self._dialog._subscription_status_controller.find_row_by_identity(
|
||||
url,
|
||||
filename,
|
||||
)
|
||||
if changed_row in rows:
|
||||
self.set_inspector_summary_values(rows)
|
||||
return
|
||||
row = rows[0]
|
||||
row_url = self._dialog._table_data_controller.cell_text(row, self._col("url"))
|
||||
row_filename = safe_filename(
|
||||
self._dialog._table_data_controller.cell_text(row, self._col("filename"))
|
||||
)
|
||||
if row_url != url or row_filename != filename:
|
||||
return
|
||||
self.set_inspector_values(
|
||||
row=row,
|
||||
name=self._dialog._table_data_controller.cell_text(row, self._col("name")),
|
||||
url=row_url,
|
||||
filename=row_filename,
|
||||
meta=meta,
|
||||
)
|
||||
+927
@@ -0,0 +1,927 @@
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.attached_rules_dialog import (
|
||||
AttachedRulesDialog,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.attached_rules_index import (
|
||||
AttachedRulesIndex,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.workers import attached_rules_snapshot_worker as attached_rules_workers
|
||||
from opensnitch.database import Database
|
||||
from opensnitch.config import Config
|
||||
from opensnitch.rules import Rule, Rules
|
||||
from opensnitch.proto import ui_pb2 as ui_pb2
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
def _is_memory_db_file(db_file: str) -> bool:
|
||||
value = str(db_file or "").strip().lower()
|
||||
if value in ("", ":memory:", "file::memory:"):
|
||||
return True
|
||||
if value.startswith("file::memory"):
|
||||
return True
|
||||
return "mode=memory" in value
|
||||
|
||||
|
||||
def _is_shared_memory_db_file(db_file: str) -> bool:
|
||||
value = str(db_file or "").strip().lower()
|
||||
# OpenSnitch's default in-memory URI uses file::memory: and enables
|
||||
# shared-cache via connection options at DB initialization time.
|
||||
if value.startswith("file::memory:"):
|
||||
return True
|
||||
if "cache=shared" not in value:
|
||||
return False
|
||||
return "mode=memory" in value or value.startswith("file::memory:")
|
||||
|
||||
|
||||
def _is_sqlite_uri(db_file: str) -> bool:
|
||||
return str(db_file or "").strip().lower().startswith("file:")
|
||||
|
||||
|
||||
ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS = 2000
|
||||
ATTACHED_RULES_SNAPSHOT_WARN_RULES = 100
|
||||
ATTACHED_RULES_COUNT_CACHE_TTL_SECONDS = 6 * 60 * 60
|
||||
ATTACHED_RULES_COUNT_BASELINE_MS = 8
|
||||
ATTACHED_RULES_TIMEOUT_MAX_MS = 8000
|
||||
ATTACHED_RULES_FETCH_BASELINE_MS_PER_ROW = 0.1
|
||||
ATTACHED_RULES_PROCESS_BASELINE_MS_PER_ROW = 0.05
|
||||
ATTACHED_RULES_TIMEOUT_SAFETY_FACTOR = 3.0
|
||||
ATTACHED_RULES_WARN_MIN_ESTIMATED_DELAY_MS = 2000
|
||||
|
||||
|
||||
class RulesAttachmentController:
|
||||
def __init__(self, *, dialog: "ListSubscriptionsDialog"):
|
||||
self._dialog = dialog
|
||||
self._rules = Rules.instance()
|
||||
self._rules_index = AttachedRulesIndex()
|
||||
self._snapshot_cache_dirty = False
|
||||
self._snapshot_worker: Any = None
|
||||
self._snapshot_thread: QtCore.QThread | None = None
|
||||
self._snapshot_phase = "idle"
|
||||
self._snapshot_callbacks: list[
|
||||
Callable[[dict[str, list[dict[str, Any]]]], None]
|
||||
] = []
|
||||
self._rules_count_cache: dict[
|
||||
tuple[str, tuple[str, ...]],
|
||||
tuple[int | None, bool, float],
|
||||
] = {}
|
||||
self._pending_snapshot_db_file = ""
|
||||
self._pending_snapshot_local_nodes: list[str] = []
|
||||
self._pending_snapshot_in_memory_db = False
|
||||
self._pending_snapshot_shared_memory_db = False
|
||||
self._pending_snapshot_count_over_limit = False
|
||||
self._pending_snapshot_row_count = 0
|
||||
self._count_query_delay_factor = 1.0
|
||||
self._fetch_ms_per_row = ATTACHED_RULES_FETCH_BASELINE_MS_PER_ROW
|
||||
self._process_ms_per_row = ATTACHED_RULES_PROCESS_BASELINE_MS_PER_ROW
|
||||
self._snapshot_timeout_timer = QtCore.QTimer(dialog)
|
||||
self._snapshot_timeout_timer.setSingleShot(True)
|
||||
self._snapshot_timeout_timer.setInterval(ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS)
|
||||
self._snapshot_timeout_timer.timeout.connect(self._on_snapshot_worker_timeout)
|
||||
self._snapshot_timed_out = False
|
||||
try:
|
||||
self._dialog._nodes.nodesUpdated.connect(self._on_nodes_updated)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._rules.updated.connect(self._on_rules_updated)
|
||||
except Exception:
|
||||
pass
|
||||
dialog.destroyed.connect(self._on_dialog_destroyed)
|
||||
|
||||
def _on_dialog_destroyed(self, *_args):
|
||||
worker = self._snapshot_worker
|
||||
thread = self._snapshot_thread
|
||||
if worker is None or thread is None:
|
||||
return
|
||||
try:
|
||||
worker.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._snapshot_timeout_timer.stop()
|
||||
if thread.isRunning():
|
||||
thread.quit()
|
||||
thread.wait(300)
|
||||
|
||||
def attached_rules_snapshot(self):
|
||||
return self._rules_index.snapshot()
|
||||
|
||||
def has_active_snapshot(self) -> bool:
|
||||
thread = self._snapshot_thread
|
||||
return bool(thread is not None and thread.isRunning())
|
||||
|
||||
def cancel_active_snapshot(self) -> None:
|
||||
worker = self._snapshot_worker
|
||||
thread = self._snapshot_thread
|
||||
self._snapshot_timeout_timer.stop()
|
||||
if worker is None or thread is None or not thread.isRunning():
|
||||
return
|
||||
try:
|
||||
stop = getattr(worker, "stop", None)
|
||||
if callable(stop):
|
||||
stop()
|
||||
except Exception:
|
||||
pass
|
||||
thread.quit()
|
||||
self._snapshot_phase = "idle"
|
||||
if self._snapshot_callbacks:
|
||||
snapshot = self._rules_index.snapshot()
|
||||
callbacks = self._snapshot_callbacks[:]
|
||||
self._snapshot_callbacks.clear()
|
||||
for callback in callbacks:
|
||||
callback(snapshot)
|
||||
|
||||
def apply_rule_editor_change(self, change: dict[str, Any]) -> None:
|
||||
if not self._rules_index.apply_rule_editor_change(change):
|
||||
self._mark_snapshot_cache_dirty("rule-editor-missing-addr")
|
||||
return
|
||||
self._snapshot_cache_dirty = False
|
||||
|
||||
def update_cached_rule_enabled(self, addr: str, rule_name: str, enabled: bool) -> None:
|
||||
if not self._rules_index.update_rule_enabled(addr, rule_name, enabled):
|
||||
self._mark_snapshot_cache_dirty("rule-enabled-miss")
|
||||
return
|
||||
self._snapshot_cache_dirty = False
|
||||
|
||||
def remove_cached_rule(self, addr: str, rule_name: str) -> None:
|
||||
self._rules_index.remove_rule(addr, rule_name)
|
||||
self._snapshot_cache_dirty = False
|
||||
|
||||
def _mark_snapshot_cache_dirty(self, reason: str) -> None:
|
||||
self._snapshot_cache_dirty = True
|
||||
self._rules_count_cache.clear()
|
||||
self._dialog._status_controller.debug(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Attached-rules cache invalidated: {0}",
|
||||
).format(reason),
|
||||
origin="ui:attached-rules",
|
||||
)
|
||||
|
||||
def invalidate_snapshot_cache(self, reason: str) -> None:
|
||||
self._mark_snapshot_cache_dirty(reason)
|
||||
|
||||
def _on_nodes_updated(self, _count: int) -> None:
|
||||
self._mark_snapshot_cache_dirty("nodes-updated")
|
||||
|
||||
def _on_rules_updated(self, _value: int) -> None:
|
||||
self._mark_snapshot_cache_dirty("rules-updated")
|
||||
|
||||
def _attached_rules_snapshot_sync(self):
|
||||
attached_rules_by_dir: dict[str, list[dict[str, Any]]] = {}
|
||||
seen_entries: set[tuple[str, str, str]] = set()
|
||||
for addr in self._dialog._nodes.get().keys():
|
||||
try:
|
||||
if not self._dialog._nodes.is_local(addr):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
records = self._dialog._nodes.get_rules(addr)
|
||||
if records is None or records == -1:
|
||||
continue
|
||||
|
||||
while records.next():
|
||||
try:
|
||||
rule = cast(ui_pb2.Rule, Rule.new_from_records(records))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
rule_name = str(getattr(rule, "name", "") or "").strip()
|
||||
if rule_name == "":
|
||||
continue
|
||||
rule_enabled = bool(getattr(rule, "enabled", True))
|
||||
|
||||
if rule.operator.operand == Config.OPERAND_LIST_DOMAINS:
|
||||
direct = os.path.normpath(str(rule.operator.data or "").strip())
|
||||
if direct != "":
|
||||
entry_key = (direct, addr, rule_name)
|
||||
if entry_key not in seen_entries:
|
||||
seen_entries.add(entry_key)
|
||||
attached_rules_by_dir.setdefault(direct, []).append(
|
||||
{
|
||||
"addr": addr,
|
||||
"name": rule_name,
|
||||
"enabled": rule_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
for operator in getattr(rule.operator, "list", []):
|
||||
if operator.operand != Config.OPERAND_LIST_DOMAINS:
|
||||
continue
|
||||
nested = os.path.normpath(str(operator.data or "").strip())
|
||||
if nested != "":
|
||||
entry_key = (nested, addr, rule_name)
|
||||
if entry_key not in seen_entries:
|
||||
seen_entries.add(entry_key)
|
||||
attached_rules_by_dir.setdefault(nested, []).append(
|
||||
{
|
||||
"addr": addr,
|
||||
"name": rule_name,
|
||||
"enabled": rule_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
return attached_rules_by_dir
|
||||
|
||||
def _on_snapshot_worker_done(self, snapshot: object) -> None:
|
||||
self._snapshot_timeout_timer.stop()
|
||||
self._snapshot_phase = "idle"
|
||||
actual_snapshot: object = snapshot
|
||||
if isinstance(snapshot, dict) and "snapshot" in snapshot and "elapsed_ms" in snapshot:
|
||||
raw_elapsed = snapshot.get("elapsed_ms")
|
||||
raw_row_count = snapshot.get("row_count")
|
||||
if isinstance(raw_elapsed, int):
|
||||
row_count = raw_row_count if isinstance(raw_row_count, int) else 0
|
||||
self._update_process_ms_per_row(raw_elapsed, row_count)
|
||||
self._dialog._status_controller.debug(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Attached-rules process: {0} ms for {1} rows ({2:.3f} ms/row EMA)",
|
||||
).format(raw_elapsed, row_count, self._process_ms_per_row),
|
||||
origin="ui:attached-rules",
|
||||
)
|
||||
actual_snapshot = snapshot.get("snapshot") or {}
|
||||
data = self._rules_index.set_from_snapshot_obj(actual_snapshot)
|
||||
self._snapshot_cache_dirty = False
|
||||
if not self._snapshot_callbacks:
|
||||
return
|
||||
callbacks = self._snapshot_callbacks[:]
|
||||
self._snapshot_callbacks.clear()
|
||||
for callback in callbacks:
|
||||
callback(data)
|
||||
|
||||
def _on_snapshot_worker_stopped(
|
||||
self,
|
||||
worker: Any,
|
||||
thread: QtCore.QThread,
|
||||
) -> None:
|
||||
if self._snapshot_worker is worker:
|
||||
self._snapshot_worker = None
|
||||
if self._snapshot_thread is thread:
|
||||
self._snapshot_thread = None
|
||||
|
||||
def _on_snapshot_worker_timeout(self) -> None:
|
||||
worker = self._snapshot_worker
|
||||
thread = self._snapshot_thread
|
||||
if worker is None or thread is None or not thread.isRunning():
|
||||
return
|
||||
self._snapshot_timed_out = True
|
||||
self._snapshot_phase = "timeout"
|
||||
try:
|
||||
stop = getattr(worker, "stop", None)
|
||||
if callable(stop):
|
||||
stop()
|
||||
except Exception:
|
||||
pass
|
||||
thread.quit()
|
||||
fallback_snapshot = self._rules_index.snapshot()
|
||||
if fallback_snapshot:
|
||||
self._dialog._status_controller.warn(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Attached-rules lookup timed out; showing cached results.",
|
||||
),
|
||||
origin="ui:attached-rules",
|
||||
)
|
||||
else:
|
||||
self._dialog._status_controller.warn(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Attached-rules lookup timed out; showing no results.",
|
||||
),
|
||||
origin="ui:attached-rules",
|
||||
)
|
||||
callbacks = self._snapshot_callbacks[:]
|
||||
self._snapshot_callbacks.clear()
|
||||
for callback in callbacks:
|
||||
callback(fallback_snapshot)
|
||||
|
||||
def _snapshot_db_mode(self, db_file: str) -> tuple[str, str]:
|
||||
if _is_shared_memory_db_file(db_file):
|
||||
return "memory-shared", "async"
|
||||
if _is_memory_db_file(db_file):
|
||||
return "memory-private", "sync"
|
||||
if _is_sqlite_uri(db_file):
|
||||
return "file-uri", "async"
|
||||
return "file", "async"
|
||||
|
||||
def _count_cache_key(self, db_file: str, local_nodes: list[str]) -> tuple[str, tuple[str, ...]]:
|
||||
return db_file, tuple(sorted(local_nodes))
|
||||
|
||||
def _get_cached_rules_count(
|
||||
self,
|
||||
db_file: str,
|
||||
local_nodes: list[str],
|
||||
) -> tuple[int | None, bool] | None:
|
||||
key = self._count_cache_key(db_file, local_nodes)
|
||||
cached = self._rules_count_cache.get(key)
|
||||
if cached is None:
|
||||
return None
|
||||
count, over_limit, ts = cached
|
||||
if (time.monotonic() - ts) > ATTACHED_RULES_COUNT_CACHE_TTL_SECONDS:
|
||||
self._rules_count_cache.pop(key, None)
|
||||
return None
|
||||
return count, over_limit
|
||||
|
||||
def _set_cached_rules_count(
|
||||
self,
|
||||
db_file: str,
|
||||
local_nodes: list[str],
|
||||
count: int | None,
|
||||
over_limit: bool,
|
||||
) -> None:
|
||||
key = self._count_cache_key(db_file, local_nodes)
|
||||
self._rules_count_cache[key] = (count, over_limit, time.monotonic())
|
||||
|
||||
def _start_stage_worker(
|
||||
self,
|
||||
*,
|
||||
worker: Any,
|
||||
done_signal_name: str,
|
||||
done_handler: Callable[[object], None],
|
||||
thread_name: str,
|
||||
) -> None:
|
||||
worker_thread = QtCore.QThread(self._dialog)
|
||||
worker_thread.setObjectName(thread_name)
|
||||
worker.moveToThread(worker_thread)
|
||||
self._snapshot_worker = worker
|
||||
self._snapshot_thread = worker_thread
|
||||
|
||||
worker_thread.started.connect(worker.run)
|
||||
getattr(worker, done_signal_name).connect(done_handler)
|
||||
worker.finished.connect(worker_thread.quit)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
worker_thread.finished.connect(
|
||||
lambda w=worker, t=worker_thread: self._on_snapshot_worker_stopped(w, t)
|
||||
)
|
||||
worker_thread.finished.connect(worker_thread.deleteLater)
|
||||
worker_thread.start()
|
||||
|
||||
def _confirm_potential_snapshot_delay(
|
||||
self,
|
||||
*,
|
||||
estimated_rules: int | None,
|
||||
count_over_limit: bool,
|
||||
in_memory_db: bool,
|
||||
shared_memory_db: bool,
|
||||
) -> bool:
|
||||
may_freeze = in_memory_db and not shared_memory_db
|
||||
estimated_delay_ms: int | None = None
|
||||
if estimated_rules is not None and estimated_rules > 0:
|
||||
estimated_delay_ms = int(
|
||||
estimated_rules * (self._fetch_ms_per_row + self._process_ms_per_row)
|
||||
)
|
||||
may_delay = count_over_limit or (
|
||||
estimated_delay_ms is not None
|
||||
and estimated_delay_ms >= ATTACHED_RULES_WARN_MIN_ESTIMATED_DELAY_MS
|
||||
)
|
||||
|
||||
if not may_freeze and not may_delay:
|
||||
return True
|
||||
|
||||
if may_freeze and count_over_limit and estimated_rules is not None:
|
||||
message = QC.translate(
|
||||
"stats",
|
||||
"Loading attached rules may freeze the UI briefly (at least {0} rules on local nodes). Continue?",
|
||||
).format(estimated_rules)
|
||||
elif may_freeze and estimated_rules is not None:
|
||||
message = QC.translate(
|
||||
"stats",
|
||||
"Loading attached rules may freeze the UI briefly (about {0} rules on local nodes). Continue?",
|
||||
).format(estimated_rules)
|
||||
elif may_freeze:
|
||||
message = QC.translate(
|
||||
"stats",
|
||||
"Loading attached rules may freeze the UI briefly on this setup. Continue?",
|
||||
)
|
||||
elif count_over_limit and estimated_rules is not None:
|
||||
message = QC.translate(
|
||||
"stats",
|
||||
"Loading attached rules may take longer than usual (at least {0} rules on local nodes). Continue?",
|
||||
).format(estimated_rules)
|
||||
elif estimated_rules is not None and estimated_delay_ms is not None:
|
||||
message = QC.translate(
|
||||
"stats",
|
||||
"Loading attached rules may take longer than usual (~{0:.1f}s, {1} rules on local nodes). Continue?",
|
||||
).format(max(0.1, estimated_delay_ms / 1000.0), estimated_rules)
|
||||
else:
|
||||
message = QC.translate(
|
||||
"stats",
|
||||
"Loading attached rules may take longer than usual. Continue?",
|
||||
)
|
||||
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
self._dialog,
|
||||
QC.translate("stats", "Attached rules lookup"),
|
||||
message,
|
||||
QtWidgets.QMessageBox.StandardButton.Yes
|
||||
| QtWidgets.QMessageBox.StandardButton.No,
|
||||
QtWidgets.QMessageBox.StandardButton.No,
|
||||
)
|
||||
return reply == QtWidgets.QMessageBox.StandardButton.Yes
|
||||
|
||||
def _update_count_query_delay_factor(self, elapsed_ms: int) -> None:
|
||||
sample = max(0.25, min(4.0, elapsed_ms / ATTACHED_RULES_COUNT_BASELINE_MS))
|
||||
self._count_query_delay_factor = (0.7 * self._count_query_delay_factor) + (0.3 * sample)
|
||||
|
||||
def _update_fetch_ms_per_row(self, elapsed_ms: int, row_count: int) -> None:
|
||||
if row_count < 1:
|
||||
return
|
||||
sample = max(0.001, min(50.0, elapsed_ms / row_count))
|
||||
self._fetch_ms_per_row = 0.7 * self._fetch_ms_per_row + 0.3 * sample
|
||||
|
||||
def _update_process_ms_per_row(self, elapsed_ms: int, row_count: int) -> None:
|
||||
if row_count < 1:
|
||||
return
|
||||
sample = max(0.001, min(50.0, elapsed_ms / row_count))
|
||||
self._process_ms_per_row = 0.7 * self._process_ms_per_row + 0.3 * sample
|
||||
|
||||
def _snapshot_timeout_interval_ms(self) -> int:
|
||||
count = self._pending_snapshot_row_count
|
||||
if count > 0:
|
||||
estimated = int(
|
||||
count
|
||||
* (self._fetch_ms_per_row + self._process_ms_per_row)
|
||||
* ATTACHED_RULES_TIMEOUT_SAFETY_FACTOR
|
||||
)
|
||||
return max(ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS, min(ATTACHED_RULES_TIMEOUT_MAX_MS, estimated))
|
||||
# fallback: scale base timeout by count query delay factor
|
||||
scaled = int(ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS * max(self._count_query_delay_factor, 0.75))
|
||||
return max(ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS, min(ATTACHED_RULES_TIMEOUT_MAX_MS, scaled))
|
||||
|
||||
def refresh_attached_rules_snapshot_async(
|
||||
self,
|
||||
callback: Callable[[dict[str, list[dict[str, Any]]]], None],
|
||||
) -> None:
|
||||
self._snapshot_callbacks.append(callback)
|
||||
self._snapshot_timed_out = False
|
||||
thread = self._snapshot_thread
|
||||
if thread is not None and thread.isRunning():
|
||||
return
|
||||
|
||||
local_nodes: list[str] = []
|
||||
for addr in self._dialog._nodes.get().keys():
|
||||
try:
|
||||
if self._dialog._nodes.is_local(addr):
|
||||
local_nodes.append(addr)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
db_file = str(Database.instance().get_db_file() or "").strip()
|
||||
in_memory_db = _is_memory_db_file(db_file)
|
||||
shared_memory_db = _is_shared_memory_db_file(db_file)
|
||||
self._pending_snapshot_db_file = db_file
|
||||
self._pending_snapshot_local_nodes = local_nodes
|
||||
self._pending_snapshot_in_memory_db = in_memory_db
|
||||
self._pending_snapshot_shared_memory_db = shared_memory_db
|
||||
db_mode, snapshot_mode = self._snapshot_db_mode(db_file)
|
||||
self._dialog._status_controller.debug(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Attached-rules snapshot DB mode: {0} ({1})",
|
||||
).format(db_mode, snapshot_mode),
|
||||
origin="ui:attached-rules",
|
||||
)
|
||||
if self._snapshot_cache_dirty:
|
||||
self._dialog._status_controller.debug(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Attached-rules cache is dirty; forcing fresh snapshot.",
|
||||
),
|
||||
origin="ui:attached-rules",
|
||||
)
|
||||
# Three DB modes:
|
||||
# 1) classic private in-memory -> no detached worker; use sync snapshot
|
||||
# 2) shared in-memory URI -> detached worker is safe
|
||||
# 3) file DB (path or file: URI) -> detached worker is safe
|
||||
if in_memory_db and not shared_memory_db:
|
||||
if not self._confirm_potential_snapshot_delay(
|
||||
estimated_rules=None,
|
||||
count_over_limit=False,
|
||||
in_memory_db=in_memory_db,
|
||||
shared_memory_db=shared_memory_db,
|
||||
):
|
||||
snapshot = self._rules_index.snapshot()
|
||||
callbacks = self._snapshot_callbacks[:]
|
||||
self._snapshot_callbacks.clear()
|
||||
for pending_callback in callbacks:
|
||||
pending_callback(snapshot)
|
||||
return
|
||||
snapshot = self._attached_rules_snapshot_sync()
|
||||
self._on_snapshot_worker_done(snapshot)
|
||||
return
|
||||
|
||||
cached_rules_count = self._get_cached_rules_count(db_file, local_nodes)
|
||||
if cached_rules_count is None:
|
||||
self._snapshot_phase = "count"
|
||||
count_worker_cls = getattr(attached_rules_workers, "AttachedRulesCountWorker")
|
||||
count_worker = count_worker_cls(
|
||||
db_file=db_file,
|
||||
local_nodes=local_nodes,
|
||||
)
|
||||
self._start_stage_worker(
|
||||
worker=count_worker,
|
||||
done_signal_name="count_done",
|
||||
done_handler=self._on_rules_count_done,
|
||||
thread_name="AttachedRulesCountWorkerThread",
|
||||
)
|
||||
return
|
||||
|
||||
cached_count, cached_over_limit = cached_rules_count
|
||||
self._pending_snapshot_count_over_limit = cached_over_limit
|
||||
self._continue_snapshot_after_count(cached_count)
|
||||
|
||||
def _continue_snapshot_after_count(self, estimated_rules: int | None) -> None:
|
||||
if not self._confirm_potential_snapshot_delay(
|
||||
estimated_rules=estimated_rules,
|
||||
count_over_limit=self._pending_snapshot_count_over_limit,
|
||||
in_memory_db=self._pending_snapshot_in_memory_db,
|
||||
shared_memory_db=self._pending_snapshot_shared_memory_db,
|
||||
):
|
||||
snapshot = self._rules_index.snapshot()
|
||||
callbacks = self._snapshot_callbacks[:]
|
||||
self._snapshot_callbacks.clear()
|
||||
for pending_callback in callbacks:
|
||||
pending_callback(snapshot)
|
||||
return
|
||||
|
||||
self._pending_snapshot_row_count = estimated_rules if estimated_rules is not None else 0
|
||||
self._snapshot_timeout_timer.setInterval(self._snapshot_timeout_interval_ms())
|
||||
self._snapshot_timeout_timer.start()
|
||||
self._snapshot_phase = "fetch"
|
||||
fetch_worker_cls = getattr(attached_rules_workers, "AttachedRulesFetchWorker")
|
||||
fetch_worker = fetch_worker_cls(
|
||||
db_file=self._pending_snapshot_db_file,
|
||||
local_nodes=self._pending_snapshot_local_nodes,
|
||||
)
|
||||
self._start_stage_worker(
|
||||
worker=fetch_worker,
|
||||
done_signal_name="rows_done",
|
||||
done_handler=self._on_snapshot_rows_fetched,
|
||||
thread_name="AttachedRulesFetchWorkerThread",
|
||||
)
|
||||
|
||||
def _on_rules_count_done(self, count_obj: object) -> None:
|
||||
db_file = self._pending_snapshot_db_file
|
||||
local_nodes = self._pending_snapshot_local_nodes
|
||||
count: int | None = None
|
||||
elapsed_ms: int | None = None
|
||||
over_limit = False
|
||||
if isinstance(count_obj, dict):
|
||||
raw_count = count_obj.get("count")
|
||||
raw_elapsed = count_obj.get("elapsed_ms")
|
||||
raw_over_limit = count_obj.get("over_limit")
|
||||
count = raw_count if isinstance(raw_count, int) else None
|
||||
elapsed_ms = raw_elapsed if isinstance(raw_elapsed, int) else None
|
||||
over_limit = bool(raw_over_limit)
|
||||
elif isinstance(count_obj, int):
|
||||
count = count_obj
|
||||
|
||||
if elapsed_ms is not None:
|
||||
self._update_count_query_delay_factor(elapsed_ms)
|
||||
self._dialog._status_controller.debug(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Attached-rules count query: {0} ms (delay factor {1:.2f})",
|
||||
).format(elapsed_ms, self._count_query_delay_factor),
|
||||
origin="ui:attached-rules",
|
||||
)
|
||||
self._pending_snapshot_count_over_limit = over_limit
|
||||
self._set_cached_rules_count(db_file, local_nodes, count, over_limit)
|
||||
self._continue_snapshot_after_count(count)
|
||||
|
||||
def _on_snapshot_rows_fetched(self, rows_obj: object) -> None:
|
||||
rows: list = []
|
||||
if isinstance(rows_obj, dict):
|
||||
raw_rows = rows_obj.get("rows")
|
||||
raw_elapsed = rows_obj.get("elapsed_ms")
|
||||
raw_row_count = rows_obj.get("row_count")
|
||||
rows = raw_rows if isinstance(raw_rows, list) else []
|
||||
if isinstance(raw_elapsed, int):
|
||||
actual_count = raw_row_count if isinstance(raw_row_count, int) else len(rows)
|
||||
self._update_fetch_ms_per_row(raw_elapsed, actual_count)
|
||||
self._dialog._status_controller.debug(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Attached-rules fetch: {0} ms for {1} rows ({2:.3f} ms/row EMA)",
|
||||
).format(raw_elapsed, actual_count, self._fetch_ms_per_row),
|
||||
origin="ui:attached-rules",
|
||||
)
|
||||
elif isinstance(rows_obj, list):
|
||||
rows = rows_obj
|
||||
self._snapshot_phase = "process"
|
||||
process_worker_cls = getattr(attached_rules_workers, "AttachedRulesProcessWorker")
|
||||
process_worker = process_worker_cls(rows=rows)
|
||||
self._start_stage_worker(
|
||||
worker=process_worker,
|
||||
done_signal_name="snapshot_done",
|
||||
done_handler=self._on_snapshot_worker_done,
|
||||
thread_name="AttachedRulesSnapshotWorkerThread",
|
||||
)
|
||||
|
||||
def show_attached_rules_dialog(self):
|
||||
rows = self._dialog._selection_controller.selected_rows()
|
||||
if len(rows) != 1:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Select a single subscription row first."),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
row = rows[0]
|
||||
|
||||
def _open_dialog(snapshot: dict[str, list[dict[str, Any]]]) -> None:
|
||||
if not self._dialog.isVisible():
|
||||
return
|
||||
|
||||
def _get_attached_rules() -> list[dict[str, Any]]:
|
||||
from opensnitch.plugins.list_subscriptions._utils import (
|
||||
DEFAULT_LISTS_DIR,
|
||||
normalize_groups,
|
||||
normalize_lists_dir,
|
||||
safe_filename,
|
||||
)
|
||||
|
||||
tdc = self._dialog._table_data_controller
|
||||
lists_dir = normalize_lists_dir(
|
||||
self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR
|
||||
)
|
||||
filename = safe_filename(tdc.cell_text(row, tdc._col("filename")))
|
||||
list_type = (tdc.cell_text(row, tdc._col("format")) or "hosts").strip().lower()
|
||||
groups = normalize_groups(tdc.cell_text(row, tdc._col("group")))
|
||||
return self.aggregate_attached_rules(
|
||||
tdc.rule_attachment_matches(
|
||||
lists_dir,
|
||||
filename,
|
||||
list_type,
|
||||
groups,
|
||||
attached_rules_by_dir=snapshot,
|
||||
include_disabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
dlg = AttachedRulesDialog(
|
||||
self._dialog,
|
||||
get_attached_rules=_get_attached_rules,
|
||||
on_create_rule=self._dialog._rules_editor_controller.create_rule_from_selected,
|
||||
on_edit_rule=self.edit_attached_rule_entry,
|
||||
on_toggle_rule=self.toggle_attached_rule_entry,
|
||||
on_remove_rule=self.remove_attached_rule_entry,
|
||||
)
|
||||
dlg.exec()
|
||||
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Loading attached rules..."),
|
||||
error=False,
|
||||
log=False,
|
||||
)
|
||||
def _on_snapshot(snapshot: dict[str, list[dict[str, Any]]]) -> None:
|
||||
timed_out = self._snapshot_timed_out
|
||||
self._snapshot_timed_out = False
|
||||
if timed_out:
|
||||
if snapshot:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Attached-rules lookup timed out; showing cached results.",
|
||||
),
|
||||
error=True,
|
||||
origin="ui:attached-rules",
|
||||
)
|
||||
else:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Attached-rules lookup timed out; showing no results.",
|
||||
),
|
||||
error=True,
|
||||
origin="ui:attached-rules",
|
||||
)
|
||||
else:
|
||||
self._dialog._status_controller.set_status("", error=False, log=False)
|
||||
_open_dialog(snapshot)
|
||||
|
||||
self.refresh_attached_rules_snapshot_async(_on_snapshot)
|
||||
|
||||
def attached_rule_scope_parts(self, source: str):
|
||||
normalized = (source or "").strip()
|
||||
if normalized == "subscription":
|
||||
return "single", ""
|
||||
if normalized == "all":
|
||||
return "all", ""
|
||||
if normalized.startswith("group:"):
|
||||
return "group", normalized.split(":", 1)[1].strip()
|
||||
return normalized or "other", ""
|
||||
|
||||
def aggregate_attached_rules(
|
||||
self,
|
||||
attached_rules: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
aggregated: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for entry in attached_rules:
|
||||
addr = str(entry.get("addr", "")).strip()
|
||||
name = str(entry.get("name", "")).strip()
|
||||
if addr == "" or name == "":
|
||||
continue
|
||||
key = (addr, name)
|
||||
current = aggregated.get(key)
|
||||
if current is None:
|
||||
current = {
|
||||
"addr": addr,
|
||||
"name": name,
|
||||
"enabled": bool(entry.get("enabled", True)),
|
||||
"single": False,
|
||||
"all": False,
|
||||
"groups": set(),
|
||||
}
|
||||
aggregated[key] = current
|
||||
else:
|
||||
current["enabled"] = bool(entry.get("enabled", True))
|
||||
scope_kind, scope_value = self.attached_rule_scope_parts(
|
||||
str(entry.get("source", ""))
|
||||
)
|
||||
if scope_kind == "single":
|
||||
current["single"] = True
|
||||
elif scope_kind == "all":
|
||||
current["all"] = True
|
||||
elif scope_kind == "group" and scope_value != "":
|
||||
from typing import cast
|
||||
cast(set[str], current["groups"]).add(scope_value)
|
||||
aggregated_rows = list(aggregated.values())
|
||||
for entry in aggregated_rows:
|
||||
entry["groups"] = sorted(entry["groups"])
|
||||
aggregated_rows.sort(key=lambda item: (item["name"].lower(), item["addr"]))
|
||||
return aggregated_rows
|
||||
|
||||
def rule_attachment_scope_summary(self, matches: list[dict[str, Any]]):
|
||||
has_single = False
|
||||
has_all = False
|
||||
groups: set[str] = set()
|
||||
other_sources: set[str] = set()
|
||||
|
||||
for entry in matches:
|
||||
scope_kind, scope_value = self.attached_rule_scope_parts(
|
||||
str(entry.get("source", ""))
|
||||
)
|
||||
if scope_kind == "single":
|
||||
has_single = True
|
||||
elif scope_kind == "all":
|
||||
has_all = True
|
||||
elif scope_kind == "group" and scope_value != "":
|
||||
groups.add(scope_value)
|
||||
elif scope_kind != "":
|
||||
other_sources.add(scope_kind)
|
||||
|
||||
parts: list[str] = []
|
||||
if has_single:
|
||||
parts.append(QC.translate("stats", "single"))
|
||||
if has_all:
|
||||
parts.append(QC.translate("stats", "all"))
|
||||
if groups:
|
||||
parts.append(
|
||||
QC.translate("stats", "groups: {0}").format(
|
||||
", ".join(sorted(groups))
|
||||
)
|
||||
)
|
||||
if other_sources:
|
||||
parts.extend(sorted(other_sources))
|
||||
return ", ".join(parts)
|
||||
|
||||
def rule_entry_identity(self, entry: dict[str, Any]):
|
||||
addr = str(entry.get("addr", "")).strip()
|
||||
name = str(entry.get("name", "")).strip()
|
||||
if addr == "" or name == "":
|
||||
return None
|
||||
return addr, name
|
||||
|
||||
def find_rule_record(self, addr: str, rule_name: str):
|
||||
records = self._dialog._nodes.get_rules(addr)
|
||||
if records is None or records == -1:
|
||||
return None
|
||||
|
||||
while records.next():
|
||||
try:
|
||||
rule = cast(ui_pb2.Rule, Rule.new_from_records(records))
|
||||
except Exception:
|
||||
continue
|
||||
if str(rule.name or "").strip() == rule_name:
|
||||
return records
|
||||
return None
|
||||
|
||||
def edit_attached_rule_entry(self, entry: dict[str, Any]):
|
||||
identity = self.rule_entry_identity(entry)
|
||||
if identity is None:
|
||||
return
|
||||
addr, name = identity
|
||||
self.open_attached_rule_in_editor(addr, name)
|
||||
|
||||
def toggle_attached_rule_entry(self, entry: dict[str, Any]):
|
||||
identity = self.rule_entry_identity(entry)
|
||||
if identity is None:
|
||||
return
|
||||
addr, name = identity
|
||||
records = self.find_rule_record(addr, name)
|
||||
if records is None:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Rule not found: {0}").format(name),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
rule = cast(ui_pb2.Rule, Rule.new_from_records(records))
|
||||
except Exception:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Failed to load rule: {0}").format(name),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
if bool(getattr(rule, "enabled", True)):
|
||||
self._dialog._nodes.disable_rule(addr, name)
|
||||
self.update_cached_rule_enabled(addr, name, False)
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Rule updated: {0} disabled").format(name),
|
||||
error=False,
|
||||
)
|
||||
else:
|
||||
rule.enabled = True
|
||||
self._dialog._nodes.add_rules(addr, [rule])
|
||||
self._dialog._nodes.send_notification(
|
||||
addr,
|
||||
ui_pb2.Notification(
|
||||
type=ui_pb2.CHANGE_RULE,
|
||||
rules=[rule],
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.update_cached_rule_enabled(addr, name, True)
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Rule updated: {0} enabled").format(name),
|
||||
error=False,
|
||||
)
|
||||
|
||||
self._dialog._table_data_controller.refresh_states()
|
||||
|
||||
def remove_attached_rule_entry(self, entry: dict[str, Any]):
|
||||
identity = self.rule_entry_identity(entry)
|
||||
if identity is None:
|
||||
return
|
||||
addr, name = identity
|
||||
confirmed = QtWidgets.QMessageBox.question(
|
||||
self._dialog,
|
||||
QC.translate("stats", "Remove rule"),
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Remove rule '{0}' on node {1}? This action cannot be undone.",
|
||||
).format(name, addr),
|
||||
QtWidgets.QMessageBox.StandardButton.Yes
|
||||
| QtWidgets.QMessageBox.StandardButton.No,
|
||||
QtWidgets.QMessageBox.StandardButton.No,
|
||||
)
|
||||
if confirmed != QtWidgets.QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
nid, _noti = self._dialog._nodes.delete_rule(name, addr, None)
|
||||
if nid is None:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Failed to remove rule: {0}").format(name),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
self.remove_cached_rule(addr, name)
|
||||
self._dialog._table_data_controller.refresh_states()
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Rule deleted: {0}").format(name),
|
||||
error=False,
|
||||
)
|
||||
|
||||
def open_attached_rule_in_editor(self, addr: str, rule_name: str):
|
||||
records = self.find_rule_record(addr, rule_name)
|
||||
if records is None:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Rule not found: {0}").format(rule_name),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
rules_dialog = self._dialog._rules_editor_controller.ensure_rules_dialog()
|
||||
if rules_dialog is None:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Rules editor is not available."),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
rules_dialog.edit_rule(records, _addr=addr)
|
||||
self._dialog._table_data_controller.refresh_states()
|
||||
@@ -0,0 +1,462 @@
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC
|
||||
from opensnitch.plugins.list_subscriptions._utils import (
|
||||
DEFAULT_LISTS_DIR,
|
||||
list_file_path,
|
||||
normalize_group,
|
||||
normalize_groups,
|
||||
normalize_lists_dir,
|
||||
safe_filename,
|
||||
subscription_rule_dir,
|
||||
)
|
||||
from opensnitch.config import Config
|
||||
from opensnitch.dialogs.ruleseditor import RulesEditorDialog
|
||||
from opensnitch.dialogs.ruleseditor import constants as ruleseditor_constants
|
||||
from opensnitch.rules import Rules
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
class _RulesDialogEventFilter(QtCore.QObject):
|
||||
def __init__(self, controller: "RulesEditorController"):
|
||||
super().__init__(controller._dialog)
|
||||
self._controller = controller
|
||||
|
||||
def eventFilter(self, a0, a1):
|
||||
event_type = a1.type() if a1 is not None else None
|
||||
if event_type == QtCore.QEvent.Type.Show:
|
||||
self._controller._on_rules_dialog_shown()
|
||||
elif event_type in (
|
||||
QtCore.QEvent.Type.Hide,
|
||||
QtCore.QEvent.Type.Close,
|
||||
):
|
||||
self._controller._on_rules_dialog_hidden()
|
||||
return super().eventFilter(a0, a1)
|
||||
|
||||
|
||||
class RulesEditorController:
|
||||
def __init__(
|
||||
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
|
||||
):
|
||||
self._dialog = dialog
|
||||
self._cols = columns
|
||||
self._rules = Rules.instance()
|
||||
self._pending_rule_change: dict[str, Any] | None = None
|
||||
self._rules_dialog_event_filter: _RulesDialogEventFilter | None = None
|
||||
self._rules.updated.connect(self._handle_rules_updated)
|
||||
|
||||
def _col(self, key: str):
|
||||
return self._cols[key]
|
||||
|
||||
def ensure_rules_dialog(self):
|
||||
if self._dialog._rules_dialog is None:
|
||||
appicon = (
|
||||
self._dialog.windowIcon()
|
||||
if self._dialog.windowIcon() is not None
|
||||
else None
|
||||
)
|
||||
try:
|
||||
self._dialog._rules_dialog = RulesEditorDialog(
|
||||
parent=None,
|
||||
appicon=appicon,
|
||||
)
|
||||
except TypeError:
|
||||
self._dialog._rules_dialog = RulesEditorDialog()
|
||||
self._install_rules_dialog_event_filter()
|
||||
self._connect_rules_dialog_signals()
|
||||
return self._dialog._rules_dialog
|
||||
|
||||
def _install_rules_dialog_event_filter(self):
|
||||
rules_dialog = self._dialog._rules_dialog
|
||||
if rules_dialog is None:
|
||||
return
|
||||
if self._rules_dialog_event_filter is None:
|
||||
self._rules_dialog_event_filter = _RulesDialogEventFilter(self)
|
||||
rules_dialog.installEventFilter(self._rules_dialog_event_filter)
|
||||
|
||||
def _connect_rules_dialog_signals(self):
|
||||
rules_dialog = self._dialog._rules_dialog
|
||||
if rules_dialog is None:
|
||||
return
|
||||
save_button = rules_dialog.buttonBox.button(
|
||||
QtWidgets.QDialogButtonBox.StandardButton.Save
|
||||
)
|
||||
if save_button is None:
|
||||
return
|
||||
try:
|
||||
save_button.pressed.disconnect(self._capture_rule_save_context)
|
||||
except Exception:
|
||||
pass
|
||||
save_button.pressed.connect(self._capture_rule_save_context)
|
||||
|
||||
def _capture_rule_save_context(self):
|
||||
rules_dialog = self._dialog._rules_dialog
|
||||
if rules_dialog is None:
|
||||
return
|
||||
node_index = rules_dialog.nodesCombo.currentIndex()
|
||||
node_addr = str(rules_dialog.nodesCombo.itemData(node_index) or "").strip()
|
||||
rule_dirs: list[str] = []
|
||||
if rules_dialog.dstListsCheck.isChecked():
|
||||
rule_dir = str(rules_dialog.dstListsLine.text() or "").strip()
|
||||
if rule_dir != "":
|
||||
rule_dirs.append(rule_dir)
|
||||
self._pending_rule_change = {
|
||||
"mode": int(getattr(ruleseditor_constants, "WORK_MODE", 0)),
|
||||
"old_name": str(getattr(rules_dialog, "_old_rule_name", "") or "").strip(),
|
||||
"new_name": str(rules_dialog.ruleNameEdit.text() or "").strip(),
|
||||
"addr": node_addr,
|
||||
"enabled": bool(rules_dialog.enableCheck.isChecked()),
|
||||
"directories": rule_dirs,
|
||||
"apply_all": bool(rules_dialog.nodeApplyAllCheck.isChecked()),
|
||||
}
|
||||
|
||||
def _handle_rules_updated(self, _value: int):
|
||||
if self._pending_rule_change is None:
|
||||
return
|
||||
# Defer so we exit cb_save_clicked's call chain before touching the DB.
|
||||
# Running a DB scan synchronously inside Rules.updated (which is emitted
|
||||
# from cb_save_clicked) blocks the main thread while the gRPC reply for
|
||||
# the CHANGE_RULE notification is still pending, causing the daemon to
|
||||
# appear locked.
|
||||
QtCore.QTimer.singleShot(0, self._finalize_pending_rule_change)
|
||||
|
||||
def _finalize_pending_rule_change(self):
|
||||
pending = self._pending_rule_change
|
||||
self._pending_rule_change = None
|
||||
if pending is None:
|
||||
return
|
||||
|
||||
mode = int(pending.get("mode") or 0)
|
||||
old_name = str(pending.get("old_name") or "").strip()
|
||||
new_name = str(pending.get("new_name") or "").strip()
|
||||
|
||||
if mode == ruleseditor_constants.ADD_RULE:
|
||||
message = QC.translate("stats", "Rule created: {0}").format(
|
||||
new_name or old_name
|
||||
)
|
||||
elif old_name != "" and new_name != "" and old_name != new_name:
|
||||
message = QC.translate(
|
||||
"stats", "Rule updated: {0} (renamed from {1})"
|
||||
).format(new_name, old_name)
|
||||
else:
|
||||
message = QC.translate("stats", "Rule updated: {0}").format(
|
||||
new_name or old_name
|
||||
)
|
||||
|
||||
if bool(pending.get("apply_all", False)):
|
||||
self._dialog._rules_attachment_controller.invalidate_snapshot_cache(
|
||||
"rule-editor-apply-all"
|
||||
)
|
||||
else:
|
||||
self._dialog._rules_attachment_controller.apply_rule_editor_change(pending)
|
||||
|
||||
self._dialog._status_controller.set_status(message, error=False)
|
||||
self._dialog._table_data_controller.refresh_states()
|
||||
self._dialog._selection_controller.update_selected_actions_state()
|
||||
|
||||
def _on_rules_dialog_shown(self):
|
||||
self._dialog._table_data_controller.stop_poll()
|
||||
|
||||
def _on_rules_dialog_hidden(self):
|
||||
self._pending_rule_change = None
|
||||
# Restart polling right away, but defer the DB-heavy refresh so it
|
||||
# runs after the dialog teardown event chain has fully unwound.
|
||||
self._dialog._table_data_controller.start_poll()
|
||||
if self._dialog.isVisible() and not self._dialog._loading:
|
||||
QtCore.QTimer.singleShot(0, self._deferred_post_editor_refresh)
|
||||
|
||||
def _deferred_post_editor_refresh(self):
|
||||
if not self._dialog.isVisible() or self._dialog._loading:
|
||||
return
|
||||
self._dialog._table_data_controller.refresh_states()
|
||||
self._dialog._selection_controller.update_selected_actions_state()
|
||||
|
||||
def configure_rules_dialog_for_local_user(self):
|
||||
rules_dialog = self._dialog._rules_dialog
|
||||
if rules_dialog is None:
|
||||
return False
|
||||
|
||||
local_addr = None
|
||||
for addr in self._dialog._nodes.get().keys():
|
||||
try:
|
||||
if self._dialog._nodes.is_local(addr):
|
||||
local_addr = addr
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if local_addr is None:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"No local OpenSnitch node is connected. Rules can only be created for the local user.",
|
||||
),
|
||||
error=True,
|
||||
)
|
||||
rules_dialog.hide()
|
||||
return False
|
||||
|
||||
nodes_combo = rules_dialog.nodesCombo
|
||||
node_idx = nodes_combo.findData(local_addr)
|
||||
if node_idx != -1:
|
||||
nodes_combo.setCurrentIndex(node_idx)
|
||||
nodes_combo.setEnabled(False)
|
||||
rules_dialog.nodeApplyAllCheck.setChecked(False)
|
||||
rules_dialog.nodeApplyAllCheck.setEnabled(False)
|
||||
rules_dialog.nodeApplyAllCheck.setVisible(False)
|
||||
|
||||
uid_text = str(os.getuid())
|
||||
uid_combo = rules_dialog.uidCombo
|
||||
uid_idx = uid_combo.findData(int(uid_text))
|
||||
rules_dialog.uidCheck.setChecked(True)
|
||||
uid_combo.setEnabled(True)
|
||||
if uid_idx != -1:
|
||||
uid_combo.setCurrentIndex(uid_idx)
|
||||
else:
|
||||
uid_combo.setCurrentText(uid_text)
|
||||
return True
|
||||
|
||||
def apply_rule_editor_defaults(self):
|
||||
rules_dialog = self._dialog._rules_dialog
|
||||
if rules_dialog is None:
|
||||
return
|
||||
rules_dialog.enableCheck.setChecked(True)
|
||||
duration_idx = rules_dialog.durationCombo.findData(Config.DURATION_ALWAYS)
|
||||
if duration_idx < 0:
|
||||
duration_idx = rules_dialog.durationCombo.findText(
|
||||
Config.DURATION_ALWAYS,
|
||||
QtCore.Qt.MatchFlag.MatchFixedString,
|
||||
)
|
||||
if duration_idx < 0:
|
||||
duration_idx = 8
|
||||
rules_dialog.durationCombo.setCurrentIndex(duration_idx)
|
||||
|
||||
def choose_group_for_selected(self, rows: list[int]):
|
||||
if not rows:
|
||||
return None
|
||||
selected_group_sets = [
|
||||
set(
|
||||
normalize_groups(
|
||||
self._dialog._table_data_controller.cell_text(
|
||||
r,
|
||||
self._col("group"),
|
||||
)
|
||||
)
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
common = (
|
||||
set.intersection(*selected_group_sets) if selected_group_sets else set()
|
||||
)
|
||||
known = self._dialog._selection_controller.known_groups()
|
||||
default_group = ""
|
||||
if common:
|
||||
default_group = sorted(common)[0]
|
||||
if default_group != "" and default_group not in known:
|
||||
known.append(default_group)
|
||||
known = sorted(set(known)) or [""]
|
||||
try:
|
||||
default_idx = known.index(default_group)
|
||||
except ValueError:
|
||||
default_idx = 0
|
||||
value, ok = QtWidgets.QInputDialog.getItem(
|
||||
self._dialog,
|
||||
QC.translate("stats", "Create rule from multiple subscriptions"),
|
||||
QC.translate(
|
||||
"stats", "Select or enter a group to aggregate selected subscriptions:"
|
||||
),
|
||||
known,
|
||||
default_idx,
|
||||
True,
|
||||
)
|
||||
if not ok:
|
||||
return None
|
||||
group = normalize_group(value)
|
||||
if group in ("", "all"):
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Group cannot be empty."), error=True
|
||||
)
|
||||
return None
|
||||
return group
|
||||
|
||||
def assign_group_to_rows(self, rows: list[int], group: str):
|
||||
if not rows:
|
||||
return False
|
||||
target_group = normalize_group(group)
|
||||
for row in rows:
|
||||
groups = normalize_groups(
|
||||
self._dialog._table_data_controller.cell_text(row, self._col("group"))
|
||||
)
|
||||
groups.append(target_group)
|
||||
groups = normalize_groups(groups)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("group"),
|
||||
", ".join(groups),
|
||||
)
|
||||
return True
|
||||
|
||||
def prepare_rule_dir(
|
||||
self,
|
||||
url: str,
|
||||
filename: str,
|
||||
list_path: str,
|
||||
lists_dir: str,
|
||||
list_type: str,
|
||||
):
|
||||
_ = (url, list_path)
|
||||
rule_dir = subscription_rule_dir(lists_dir, filename, list_type)
|
||||
try:
|
||||
os.makedirs(rule_dir, mode=0o700, exist_ok=True)
|
||||
return rule_dir
|
||||
except Exception as e:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats", "Error preparing list rule directory: {0}"
|
||||
).format(str(e)),
|
||||
error=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def create_rule_from_selected(self):
|
||||
rows = self._dialog._selection_controller.selected_rows()
|
||||
if not rows:
|
||||
row = self._dialog.table.currentRow()
|
||||
if row >= 0:
|
||||
rows = [row]
|
||||
if not rows:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Select one or more subscriptions first."),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
lists_dir = normalize_lists_dir(
|
||||
self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR
|
||||
)
|
||||
if len(rows) == 1:
|
||||
row = rows[0]
|
||||
url = self._dialog._table_data_controller.cell_text(row, self._col("url"))
|
||||
filename, filename_changed = (
|
||||
self._dialog._table_data_controller.ensure_row_final_filename(row)
|
||||
)
|
||||
if url == "" or filename == "":
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "URL and filename cannot be empty."),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
if filename_changed:
|
||||
self._dialog._action_file_controller.save_action_file()
|
||||
|
||||
list_type = (
|
||||
self._dialog._table_data_controller.cell_text(row, self._col("format"))
|
||||
) or "hosts"
|
||||
list_type = list_type.strip().lower()
|
||||
list_path = list_file_path(lists_dir, filename, list_type)
|
||||
rule_dir = self.prepare_rule_dir(
|
||||
url,
|
||||
filename,
|
||||
list_path,
|
||||
lists_dir,
|
||||
list_type,
|
||||
)
|
||||
if rule_dir is None:
|
||||
return
|
||||
rule_token = os.path.splitext(safe_filename(filename))[0]
|
||||
rule_name = f"00-blocklist-{rule_token}"
|
||||
desc = f"From list subscription : {filename}"
|
||||
else:
|
||||
rule_group = self.choose_group_for_selected(rows)
|
||||
if rule_group is None:
|
||||
return
|
||||
if not self.assign_group_to_rows(rows, rule_group):
|
||||
return
|
||||
self._dialog._action_file_controller.save_action_file()
|
||||
rule_dir = os.path.join(lists_dir, "rules.list.d", rule_group)
|
||||
try:
|
||||
os.makedirs(rule_dir, mode=0o700, exist_ok=True)
|
||||
except Exception as e:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats", "Error preparing grouped rule directory: {0}"
|
||||
).format(str(e)),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
rule_name = f"00-blocklist-{rule_group}"
|
||||
desc = f"From list subscription : {rule_group}"
|
||||
|
||||
rules_dialog = self.ensure_rules_dialog()
|
||||
if rules_dialog is None:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Rules editor is not available."),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
rules_dialog.new_rule()
|
||||
if not self.configure_rules_dialog_for_local_user():
|
||||
return
|
||||
self.apply_rule_editor_defaults()
|
||||
rules_dialog.dstListsCheck.setChecked(True)
|
||||
rules_dialog.dstListsLine.setText(rule_dir)
|
||||
if rules_dialog.ruleNameEdit.text().strip() == "":
|
||||
rules_dialog.ruleNameEdit.setText(rule_name)
|
||||
if rules_dialog.ruleDescEdit.toPlainText().strip() == "":
|
||||
rules_dialog.ruleDescEdit.setPlainText(desc)
|
||||
rules_dialog.raise_()
|
||||
rules_dialog.activateWindow()
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats", "Rules Editor opened with prefilled list directory path."
|
||||
),
|
||||
error=False,
|
||||
)
|
||||
|
||||
def create_global_rule(self):
|
||||
lists_dir = normalize_lists_dir(
|
||||
self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR
|
||||
)
|
||||
rule_dir = os.path.join(lists_dir, "rules.list.d", "all")
|
||||
try:
|
||||
os.makedirs(rule_dir, mode=0o700, exist_ok=True)
|
||||
except Exception as e:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats", "Error preparing global rule directory: {0}"
|
||||
).format(str(e)),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
rules_dialog = self.ensure_rules_dialog()
|
||||
if rules_dialog is None:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Rules editor is not available."),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
rules_dialog.new_rule()
|
||||
if not self.configure_rules_dialog_for_local_user():
|
||||
return
|
||||
self.apply_rule_editor_defaults()
|
||||
rule_name = "00-blocklist-all"
|
||||
rules_dialog.dstListsCheck.setChecked(True)
|
||||
rules_dialog.dstListsLine.setText(rule_dir)
|
||||
if rules_dialog.ruleNameEdit.text().strip() == "":
|
||||
rules_dialog.ruleNameEdit.setText(rule_name)
|
||||
if rules_dialog.ruleDescEdit.toPlainText().strip() == "":
|
||||
rules_dialog.ruleDescEdit.setPlainText("From list subscription : all")
|
||||
rules_dialog.raise_()
|
||||
rules_dialog.activateWindow()
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats", "Rules Editor opened with global list directory path."
|
||||
),
|
||||
error=False,
|
||||
)
|
||||
@@ -0,0 +1,439 @@
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from opensnitch.plugins import PluginSignal
|
||||
from opensnitch.plugins.list_subscriptions.list_subscriptions import ListSubscriptions
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC
|
||||
from opensnitch.plugins.list_subscriptions.models.events import RuntimeEventType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeController:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
dialog: "ListSubscriptionsDialog",
|
||||
status_label: QtWidgets.QLabel,
|
||||
start_button: QtWidgets.QPushButton,
|
||||
stop_button: QtWidgets.QPushButton,
|
||||
):
|
||||
self._dialog = dialog
|
||||
self._status_label = status_label
|
||||
self._start_button = start_button
|
||||
self._stop_button = stop_button
|
||||
|
||||
# -- UI state -----------------------------------------------------------
|
||||
|
||||
def set_runtime_state(self, active: bool | None, text: str | None = None):
|
||||
if text is None:
|
||||
if active is True:
|
||||
text = QC.translate("stats", "Runtime: active")
|
||||
elif active is False:
|
||||
text = QC.translate("stats", "Runtime: inactive")
|
||||
else:
|
||||
text = QC.translate("stats", "Runtime: pending")
|
||||
|
||||
if active is True:
|
||||
style = "color: green;"
|
||||
elif active is False:
|
||||
style = "color: #666666;"
|
||||
else:
|
||||
style = "color: #b36b00;"
|
||||
|
||||
self._status_label.setStyleSheet(style)
|
||||
self._status_label.setText(text)
|
||||
self._start_button.setEnabled(active is not True)
|
||||
self._stop_button.setEnabled(active is not False)
|
||||
|
||||
# -- Refresh busy state -------------------------------------------------
|
||||
|
||||
def set_refresh_busy(self, busy: bool):
|
||||
self._dialog.refresh_state_button.setEnabled(not busy)
|
||||
self._dialog.refresh_now_button.setEnabled(
|
||||
not busy and len(self._dialog._selection_controller.selected_rows()) > 0
|
||||
)
|
||||
|
||||
def track_refresh_keys(self, keys: set[str]):
|
||||
if not keys:
|
||||
return
|
||||
self._dialog._pending_refresh_keys.update(keys)
|
||||
self.set_refresh_busy(True)
|
||||
|
||||
def clear_refresh_key(self, key: str):
|
||||
self._dialog._pending_refresh_keys.discard(key)
|
||||
self._dialog._active_refresh_keys.discard(key)
|
||||
if not self._dialog._pending_refresh_keys and not self._dialog._active_refresh_keys:
|
||||
self.set_refresh_busy(False)
|
||||
|
||||
def refresh_keys_from_payload(self, payload: dict[str, Any]):
|
||||
items_raw = payload.get("items")
|
||||
keys: set[str] = set()
|
||||
if isinstance(items_raw, list):
|
||||
for item in items_raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = str(item.get("key") or "").strip()
|
||||
if key != "":
|
||||
keys.add(key)
|
||||
return keys
|
||||
|
||||
def runtime_event_items(self, payload: dict[str, Any]):
|
||||
items_raw = payload.get("items")
|
||||
if not isinstance(items_raw, list):
|
||||
return []
|
||||
return [item for item in items_raw if isinstance(item, dict)]
|
||||
|
||||
def runtime_download_message(
|
||||
self,
|
||||
event_name: RuntimeEventType | None,
|
||||
payload: dict[str, Any],
|
||||
fallback: str,
|
||||
):
|
||||
items = self.runtime_event_items(payload)
|
||||
if not items:
|
||||
return fallback
|
||||
count = len(items)
|
||||
first_name = str(items[0].get("name") or "").strip()
|
||||
if event_name == RuntimeEventType.DOWNLOAD_STARTED:
|
||||
if count == 1 and first_name != "":
|
||||
return QC.translate("stats", "Refreshing subscription '{0}'.").format(
|
||||
first_name
|
||||
)
|
||||
return QC.translate("stats", "Refreshing {0} subscriptions.").format(count)
|
||||
if event_name == RuntimeEventType.DOWNLOAD_FINISHED:
|
||||
if count == 1 and first_name != "":
|
||||
return QC.translate("stats", "Subscription '{0}' refreshed.").format(
|
||||
first_name
|
||||
)
|
||||
return QC.translate("stats", "Refreshed {0} subscriptions.").format(count)
|
||||
if event_name == RuntimeEventType.DOWNLOAD_FAILED:
|
||||
if count == 1 and first_name != "":
|
||||
return QC.translate(
|
||||
"stats", "Subscription '{0}' refresh failed."
|
||||
).format(first_name)
|
||||
return QC.translate(
|
||||
"stats", "Refresh failed for {0} subscriptions."
|
||||
).format(count)
|
||||
return fallback
|
||||
|
||||
def handle_runtime_event(self, event: dict[str, Any]):
|
||||
payload = event if isinstance(event, dict) else {}
|
||||
message = str(payload.get("message") or "").strip()
|
||||
error_detail = str(payload.get("error") or "").strip()
|
||||
event_keys = self.refresh_keys_from_payload(payload)
|
||||
event_value = payload.get("event")
|
||||
if isinstance(event_value, int):
|
||||
try:
|
||||
event_name = RuntimeEventType(event_value)
|
||||
except Exception:
|
||||
event_name = None
|
||||
else:
|
||||
event_name = None
|
||||
is_error = event_name in (
|
||||
RuntimeEventType.RUNTIME_ERROR,
|
||||
RuntimeEventType.DOWNLOAD_FAILED,
|
||||
RuntimeEventType.FILE_SAVE_ERROR,
|
||||
RuntimeEventType.FILE_LOAD_ERROR,
|
||||
)
|
||||
if event_name == RuntimeEventType.DOWNLOAD_STARTED:
|
||||
for key in event_keys:
|
||||
if key in self._dialog._pending_refresh_keys:
|
||||
self._dialog._pending_refresh_keys.discard(key)
|
||||
self._dialog._active_refresh_keys.add(key)
|
||||
self.set_refresh_busy(True)
|
||||
elif event_name in (
|
||||
RuntimeEventType.DOWNLOAD_FINISHED,
|
||||
RuntimeEventType.DOWNLOAD_FAILED,
|
||||
):
|
||||
for key in event_keys:
|
||||
self.clear_refresh_key(key)
|
||||
if event_name in (
|
||||
RuntimeEventType.DOWNLOAD_FINISHED,
|
||||
RuntimeEventType.DOWNLOAD_FAILED,
|
||||
):
|
||||
self._dialog._table_data_controller.refresh_states()
|
||||
self._dialog._selection_controller.update_selected_actions_state()
|
||||
if event_name == RuntimeEventType.RUNTIME_ENABLED:
|
||||
self.set_runtime_state(active=True)
|
||||
elif event_name in (
|
||||
RuntimeEventType.RUNTIME_DISABLED,
|
||||
RuntimeEventType.RUNTIME_STOPPED,
|
||||
):
|
||||
self.set_runtime_state(active=False)
|
||||
elif self._dialog._pending_runtime_reload is not None:
|
||||
self.set_runtime_state(
|
||||
active=None,
|
||||
text=QC.translate("stats", "Runtime: reloading"),
|
||||
)
|
||||
elif is_error:
|
||||
self.set_runtime_state(
|
||||
active=None, text=QC.translate("stats", "Runtime: error")
|
||||
)
|
||||
if self._dialog._pending_runtime_reload == "waiting_config_reload":
|
||||
if event_name == RuntimeEventType.CONFIG_RELOADED:
|
||||
self._dialog._pending_runtime_reload = None
|
||||
self._dialog._action_file_controller.load_action_file()
|
||||
return
|
||||
if is_error:
|
||||
self._dialog._pending_runtime_reload = None
|
||||
if message == "":
|
||||
message = QC.translate("stats", "Plugin runtime event: {0}").format(
|
||||
str(event_value or "unknown")
|
||||
)
|
||||
if event_name in (
|
||||
RuntimeEventType.DOWNLOAD_STARTED,
|
||||
RuntimeEventType.DOWNLOAD_FINISHED,
|
||||
RuntimeEventType.DOWNLOAD_FAILED,
|
||||
):
|
||||
message = self.runtime_download_message(event_name, payload, message)
|
||||
if is_error and error_detail != "":
|
||||
message = f"{message} {error_detail}".strip()
|
||||
self._dialog._status_controller.set_status(
|
||||
message,
|
||||
error=is_error,
|
||||
origin="backend:event",
|
||||
)
|
||||
|
||||
# -- Lifecycle ----------------------------------------------------------
|
||||
|
||||
def _runtime_reload_failed_message(self):
|
||||
return QC.translate(
|
||||
"stats", "Config saved but runtime reload failed. Restart UI."
|
||||
)
|
||||
|
||||
def start_runtime_clicked(self):
|
||||
runtime_plugin = self.sync_runtime_binding_state()
|
||||
if runtime_plugin is not None and bool(getattr(runtime_plugin, "enabled", False)):
|
||||
self.bind_runtime_plugin(runtime_plugin)
|
||||
self.set_runtime_state(active=True)
|
||||
self._dialog._status_controller.set_status(QC.translate("stats", "Runtime is already active."))
|
||||
return
|
||||
|
||||
if not os.path.exists(self._dialog._action_path):
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Action file not found. Create and save the configuration first.",
|
||||
),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
if runtime_plugin is None:
|
||||
runtime_plugin = ListSubscriptions({})
|
||||
|
||||
self.bind_runtime_plugin(runtime_plugin)
|
||||
self.set_runtime_state(
|
||||
active=None,
|
||||
text=QC.translate("stats", "Runtime: starting"),
|
||||
)
|
||||
self._dialog._status_controller.append_log(
|
||||
QC.translate("stats", "Runtime start requested."),
|
||||
)
|
||||
try:
|
||||
runtime_plugin.signal_in.emit(
|
||||
{
|
||||
"plugin": runtime_plugin.get_name(),
|
||||
"signal": PluginSignal.ENABLE,
|
||||
"action_path": self._dialog._action_path,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
self.set_runtime_state(active=False)
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Failed to start runtime."),
|
||||
error=True,
|
||||
)
|
||||
|
||||
def stop_runtime_clicked(self):
|
||||
runtime_plugin = self.sync_runtime_binding_state()
|
||||
if runtime_plugin is None or not bool(getattr(runtime_plugin, "enabled", False)):
|
||||
self.set_runtime_state(active=False)
|
||||
self._dialog._status_controller.set_status(QC.translate("stats", "Runtime is already inactive."))
|
||||
return
|
||||
|
||||
self.bind_runtime_plugin(runtime_plugin)
|
||||
self.set_runtime_state(
|
||||
active=None,
|
||||
text=QC.translate("stats", "Runtime: stopping"),
|
||||
)
|
||||
self._dialog._status_controller.append_log(
|
||||
QC.translate("stats", "Runtime stop requested."),
|
||||
)
|
||||
try:
|
||||
runtime_plugin.signal_in.emit(
|
||||
{
|
||||
"plugin": runtime_plugin.get_name(),
|
||||
"signal": PluginSignal.DISABLE,
|
||||
"action_path": self._dialog._action_path,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Failed to stop runtime."),
|
||||
error=True,
|
||||
)
|
||||
|
||||
def reload_runtime_and_config(self):
|
||||
runtime_plugin = self.sync_runtime_binding_state()
|
||||
if runtime_plugin is None or not bool(getattr(runtime_plugin, "enabled", False)):
|
||||
self._dialog._action_file_controller.load_action_file()
|
||||
return
|
||||
|
||||
self.bind_runtime_plugin(runtime_plugin)
|
||||
self._dialog._pending_runtime_reload = "waiting_config_reload"
|
||||
self._dialog._status_controller.append_log(
|
||||
QC.translate("stats", "Runtime config reload requested."),
|
||||
)
|
||||
try:
|
||||
runtime_plugin.signal_in.emit(
|
||||
{
|
||||
"plugin": runtime_plugin.get_name(),
|
||||
"signal": PluginSignal.CONFIG_UPDATE,
|
||||
"action_path": self._dialog._action_path,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
self._dialog._pending_runtime_reload = None
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Runtime reload failed to start. Restart UI."),
|
||||
error=True,
|
||||
)
|
||||
|
||||
def apply_runtime_state(self, enabled: bool):
|
||||
loaded = self.find_loaded_action()
|
||||
old_key, _old_action, old_plugin = loaded if loaded is not None else (None, None, None)
|
||||
runtime_plugin = ListSubscriptions.get_instance()
|
||||
target_plugin = runtime_plugin if runtime_plugin is not None else old_plugin
|
||||
was_enabled = bool(getattr(target_plugin, "enabled", False))
|
||||
|
||||
if target_plugin is not None:
|
||||
self.bind_runtime_plugin(target_plugin)
|
||||
try:
|
||||
signal = None
|
||||
if enabled:
|
||||
signal = (
|
||||
PluginSignal.CONFIG_UPDATE
|
||||
if was_enabled
|
||||
else PluginSignal.ENABLE
|
||||
)
|
||||
elif was_enabled:
|
||||
signal = PluginSignal.DISABLE
|
||||
|
||||
if signal is not None:
|
||||
target_plugin.signal_in.emit(
|
||||
{
|
||||
"plugin": target_plugin.get_name(),
|
||||
"signal": signal,
|
||||
"action_path": self._dialog._action_path,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
self._dialog._status_controller.set_status(
|
||||
self._runtime_reload_failed_message(),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
if not enabled and old_key is not None:
|
||||
self._dialog._actions.delete(old_key)
|
||||
return
|
||||
|
||||
if not enabled:
|
||||
if old_key is not None:
|
||||
self._dialog._actions.delete(old_key)
|
||||
return
|
||||
|
||||
obj, compiled = self._dialog._actions.load(self._dialog._action_path)
|
||||
if obj is None or compiled is None:
|
||||
self._dialog._status_controller.set_status(
|
||||
self._runtime_reload_failed_message(),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
obj = cast(dict[str, Any], obj)
|
||||
compiled = cast(dict[str, Any], compiled)
|
||||
action_name = obj.get("name")
|
||||
if old_key is not None and old_key != action_name:
|
||||
self._dialog._actions.delete(old_key)
|
||||
if isinstance(action_name, str) and action_name != "":
|
||||
self._dialog._actions._actions_list[action_name] = compiled
|
||||
|
||||
compiled_actions = cast(dict[str, Any], compiled.get("actions", {}))
|
||||
plug = cast(
|
||||
ListSubscriptions | None, compiled_actions.get("list_subscriptions")
|
||||
)
|
||||
if plug is None:
|
||||
self._dialog._status_controller.set_status(
|
||||
self._runtime_reload_failed_message(),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
self.bind_runtime_plugin(plug)
|
||||
try:
|
||||
plug.signal_in.emit(
|
||||
{
|
||||
"plugin": plug.get_name(),
|
||||
"signal": PluginSignal.ENABLE,
|
||||
"action_path": self._dialog._action_path,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
self._dialog._status_controller.set_status(
|
||||
self._runtime_reload_failed_message(),
|
||||
error=True,
|
||||
)
|
||||
|
||||
def sync_runtime_binding_state(self):
|
||||
runtime_plugin = ListSubscriptions.get_instance()
|
||||
if runtime_plugin is None:
|
||||
loaded = self.find_loaded_action()
|
||||
runtime_plugin = loaded[2] if loaded is not None else None
|
||||
|
||||
if runtime_plugin is not None:
|
||||
self.bind_runtime_plugin(runtime_plugin)
|
||||
self.set_runtime_state(
|
||||
active=bool(getattr(runtime_plugin, "enabled", False)),
|
||||
)
|
||||
return runtime_plugin
|
||||
|
||||
self._dialog._runtime_plugin = None
|
||||
self._dialog._status_controller.set_backend_log_sink(None)
|
||||
self.set_runtime_state(active=False)
|
||||
self.set_refresh_busy(False)
|
||||
return None
|
||||
|
||||
def bind_runtime_plugin(self, plug: ListSubscriptions | None):
|
||||
if plug is None:
|
||||
self._dialog._status_controller.set_backend_log_sink(None)
|
||||
return
|
||||
try:
|
||||
plug.signal_out.disconnect(self.handle_runtime_event)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
plug.log_out.disconnect(self._dialog._status_controller.ingest_backend_log)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
plug.signal_out.connect(self.handle_runtime_event)
|
||||
plug.log_out.connect(self._dialog._status_controller.ingest_backend_log)
|
||||
self._dialog._status_controller.set_backend_log_sink(plug.ingest_ui_log)
|
||||
self._dialog._runtime_plugin = plug
|
||||
except Exception:
|
||||
self._dialog._status_controller.set_backend_log_sink(None)
|
||||
self._dialog._runtime_plugin = None
|
||||
|
||||
def find_loaded_action(self):
|
||||
for action_key, action_obj in self._dialog._actions.getAll().items():
|
||||
if action_obj is None:
|
||||
continue
|
||||
action_obj_dict = cast(dict[str, Any], action_obj)
|
||||
action_cfg = cast(dict[str, Any], action_obj_dict.get("actions", {}))
|
||||
plug = cast(ListSubscriptions | None, action_cfg.get("list_subscriptions"))
|
||||
if plug is not None:
|
||||
return str(action_key), action_obj_dict, plug
|
||||
@@ -0,0 +1,79 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QC
|
||||
from opensnitch.plugins.list_subscriptions._utils import normalize_groups
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
class SelectionController:
|
||||
def __init__(
|
||||
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
|
||||
):
|
||||
self._dialog = dialog
|
||||
self._cols = columns
|
||||
|
||||
def _col(self, key: str):
|
||||
return self._cols[key]
|
||||
|
||||
def selected_rows(self):
|
||||
idx = self._dialog.table.selectionModel()
|
||||
if idx is None:
|
||||
return []
|
||||
return sorted({i.row() for i in idx.selectedRows()})
|
||||
|
||||
def update_selected_actions_state(self):
|
||||
count = len(self.selected_rows())
|
||||
has_selection = count > 0
|
||||
self._dialog.edit_sub_button.setEnabled(has_selection)
|
||||
self._dialog.remove_sub_button.setEnabled(has_selection)
|
||||
self._dialog.refresh_now_button.setEnabled(
|
||||
has_selection
|
||||
and not self._dialog._pending_refresh_keys
|
||||
and not self._dialog._active_refresh_keys
|
||||
)
|
||||
self._dialog.create_rule_button.setEnabled(has_selection)
|
||||
if count == 1:
|
||||
self._dialog.create_rule_button.setText(QC.translate("stats", "Rules"))
|
||||
else:
|
||||
self._dialog.create_rule_button.setText(
|
||||
QC.translate("stats", "Create rule")
|
||||
)
|
||||
|
||||
def open_rules_action(self):
|
||||
rows = self.selected_rows()
|
||||
if not rows:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Select a subscription row first."),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
if len(rows) == 1:
|
||||
self._dialog._rules_attachment_controller.show_attached_rules_dialog()
|
||||
return
|
||||
self._dialog._rules_editor_controller.create_rule_from_selected()
|
||||
|
||||
def open_selected_inspector(self):
|
||||
rows = self.selected_rows()
|
||||
if not rows:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Select a subscription row first."),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
if hasattr(self._dialog, "_inspect_collapsed"):
|
||||
self._dialog._inspect_collapsed = False
|
||||
self._dialog._inspector_controller.update_inspector_panel()
|
||||
|
||||
def known_groups(self):
|
||||
groups: set[str] = set()
|
||||
for row in range(self._dialog.table.rowCount()):
|
||||
for g in normalize_groups(
|
||||
self._dialog._table_data_controller.cell_text(row, self._col("group"))
|
||||
):
|
||||
if g not in ("", "all"):
|
||||
groups.add(g)
|
||||
return sorted(groups)
|
||||
@@ -0,0 +1,344 @@
|
||||
from collections.abc import Callable
|
||||
import queue
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.status_log_dialog import (
|
||||
StatusLogDialog,
|
||||
)
|
||||
|
||||
EmptyButtonBehavior = Literal["hide", "show-if-logs"]
|
||||
MAX_BACKEND_LOGS_PER_UI_TICK = 100
|
||||
|
||||
|
||||
def _set_preview_label_text(
|
||||
label: QtWidgets.QLabel,
|
||||
*,
|
||||
text: str,
|
||||
preview_limit: int,
|
||||
):
|
||||
full_text = (text or "").strip()
|
||||
if full_text == "":
|
||||
label.setText("")
|
||||
label.setToolTip("")
|
||||
return
|
||||
|
||||
if len(full_text) <= preview_limit:
|
||||
label.setText(full_text)
|
||||
label.setToolTip(full_text)
|
||||
return
|
||||
|
||||
preview = full_text[: preview_limit - 1].rstrip() + "..."
|
||||
label.setText(preview)
|
||||
label.setToolTip(full_text)
|
||||
|
||||
|
||||
def append_log_entry(
|
||||
entries: list[str],
|
||||
*,
|
||||
message: str,
|
||||
error: bool = False,
|
||||
level: str | None = None,
|
||||
origin: str | None = None,
|
||||
dedupe: bool = False,
|
||||
last_signature: tuple[str, bool] | None = None,
|
||||
timestamp_format: str = "HH:mm:ss",
|
||||
limit: int = 200,
|
||||
):
|
||||
full_text = (message or "").strip()
|
||||
if full_text == "":
|
||||
return
|
||||
|
||||
signature = (full_text, bool(error))
|
||||
if dedupe and signature == last_signature:
|
||||
return
|
||||
|
||||
timestamp = QtCore.QDateTime.currentDateTime().toString(timestamp_format)
|
||||
log_level = level or ("ERROR" if error else "INFO")
|
||||
log_origin = (origin or "ui").strip()
|
||||
entries.append(f"[{timestamp}] [{log_level}] [{log_origin}] {full_text}")
|
||||
if len(entries) > limit:
|
||||
del entries[:-limit]
|
||||
|
||||
|
||||
def apply_status_label(
|
||||
label: QtWidgets.QLabel,
|
||||
*,
|
||||
message: str,
|
||||
error: bool,
|
||||
preview_limit: int,
|
||||
inspect_button: QtWidgets.QPushButton | None = None,
|
||||
empty_button_behavior: Literal["hide", "show-if-logs"] = "hide",
|
||||
log_entry_count: int = 0,
|
||||
ok_color: str = "green",
|
||||
error_color: str = "red",
|
||||
):
|
||||
label.setStyleSheet(f"color: {error_color if error else ok_color};")
|
||||
full_text = (message or "").strip()
|
||||
if full_text == "":
|
||||
label.setText("")
|
||||
label.setToolTip("")
|
||||
if inspect_button is not None:
|
||||
if empty_button_behavior == "show-if-logs":
|
||||
has_logs = log_entry_count > 0
|
||||
inspect_button.setVisible(has_logs)
|
||||
inspect_button.setEnabled(has_logs)
|
||||
else:
|
||||
inspect_button.setVisible(False)
|
||||
inspect_button.setEnabled(False)
|
||||
return full_text
|
||||
|
||||
_set_preview_label_text(
|
||||
label,
|
||||
text=full_text,
|
||||
preview_limit=preview_limit,
|
||||
)
|
||||
if inspect_button is not None:
|
||||
inspect_button.setVisible(True)
|
||||
inspect_button.setEnabled(True)
|
||||
return full_text
|
||||
|
||||
|
||||
class DialogStatusController:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
label: QtWidgets.QLabel,
|
||||
inspect_button: QtWidgets.QPushButton | None,
|
||||
preview_limit: int,
|
||||
log_limit: int,
|
||||
timestamp_format: str,
|
||||
ok_color: str,
|
||||
error_color: str,
|
||||
empty_button_behavior: EmptyButtonBehavior,
|
||||
):
|
||||
self._label = label
|
||||
self._inspect_button = inspect_button
|
||||
self._preview_limit = preview_limit
|
||||
self._log_limit = log_limit
|
||||
self._timestamp_format = timestamp_format
|
||||
self._ok_color = ok_color
|
||||
self._error_color = error_color
|
||||
self._empty_button_behavior: EmptyButtonBehavior = empty_button_behavior
|
||||
self._full_text = ""
|
||||
self._log_entries: list[str] = []
|
||||
self._last_signature: tuple[str, bool] | None = None
|
||||
self._backend_log_sink: Callable[[str, str, str], None] | None = None
|
||||
self._backend_sink_queue: queue.SimpleQueue[tuple[str, str, str]] = (
|
||||
queue.SimpleQueue()
|
||||
)
|
||||
self._backend_sink_worker_started = False
|
||||
self._backend_to_ui_queue: queue.SimpleQueue[tuple[str, str, str]] = (
|
||||
queue.SimpleQueue()
|
||||
)
|
||||
self._backend_to_ui_timer = QtCore.QTimer(self._label)
|
||||
self._backend_to_ui_timer.setInterval(100)
|
||||
self._backend_to_ui_timer.timeout.connect(self._drain_backend_to_ui_queue)
|
||||
self._backend_to_ui_timer.start()
|
||||
self._log_dialog: "StatusLogDialog | None" = None
|
||||
self._log_dialog_level_color: Callable[[str], str] | None = None
|
||||
self._log_dialog_timestamp_color = ""
|
||||
|
||||
def _start_backend_sink_worker(self) -> None:
|
||||
if self._backend_sink_worker_started:
|
||||
return
|
||||
self._backend_sink_worker_started = True
|
||||
|
||||
def _run() -> None:
|
||||
while True:
|
||||
message, level, origin = self._backend_sink_queue.get()
|
||||
sink = self._backend_log_sink
|
||||
if sink is None:
|
||||
continue
|
||||
try:
|
||||
sink(message, level, origin)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
th = threading.Thread(
|
||||
target=_run,
|
||||
name="DialogStatusBackendSink",
|
||||
daemon=True,
|
||||
)
|
||||
th.start()
|
||||
|
||||
def _drain_backend_to_ui_queue(self) -> None:
|
||||
for _ in range(MAX_BACKEND_LOGS_PER_UI_TICK):
|
||||
try:
|
||||
message, level, origin = self._backend_to_ui_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
self.append_log(
|
||||
message,
|
||||
level=level,
|
||||
origin=origin,
|
||||
forward_backend=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def full_text(self):
|
||||
return self._full_text
|
||||
|
||||
@property
|
||||
def log_entries(self):
|
||||
return self._log_entries
|
||||
|
||||
def log(self, message: str, level: str = "INFO", origin: str = "ui") -> None:
|
||||
"""Generic slot: connect any pyqtSignal(str, str) directly to this."""
|
||||
self.append_log(message, level=level, origin=origin)
|
||||
|
||||
def ingest_backend_log(
|
||||
self,
|
||||
message: str,
|
||||
level: str = "INFO",
|
||||
origin: str = "backend",
|
||||
) -> None:
|
||||
"""Backend -> UI path; enqueue to avoid blocking backend threads."""
|
||||
full_text = (message or "").strip()
|
||||
if full_text == "":
|
||||
return
|
||||
self._backend_to_ui_queue.put((full_text, level, origin))
|
||||
|
||||
def set_backend_log_sink(
|
||||
self,
|
||||
sink: Callable[[str, str, str], None] | None,
|
||||
) -> None:
|
||||
self._backend_log_sink = sink
|
||||
if sink is not None:
|
||||
self._start_backend_sink_worker()
|
||||
|
||||
def debug(self, message: str, origin: str = "ui") -> None:
|
||||
self.append_log(message, level="DEBUG", origin=origin)
|
||||
|
||||
def info(self, message: str, origin: str = "ui") -> None:
|
||||
self.append_log(message, level="INFO", origin=origin)
|
||||
|
||||
def warn(self, message: str, origin: str = "ui") -> None:
|
||||
self.append_log(message, level="WARN", origin=origin)
|
||||
|
||||
def error(self, message: str, origin: str = "ui") -> None:
|
||||
self.append_log(message, level="ERROR", origin=origin)
|
||||
|
||||
def trace(self, message: str, origin: str = "ui") -> None:
|
||||
self.append_log(message, level="TRACE", origin=origin)
|
||||
|
||||
def append_log(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
error: bool = False,
|
||||
level: str | None = None,
|
||||
origin: str = "ui",
|
||||
dedupe: bool = False,
|
||||
forward_backend: bool = True,
|
||||
):
|
||||
full_text = (message or "").strip()
|
||||
if full_text == "":
|
||||
return
|
||||
resolved_level = (level or ("ERROR" if error else "INFO")).upper()
|
||||
append_log_entry(
|
||||
self._log_entries,
|
||||
message=full_text,
|
||||
error=error,
|
||||
level=resolved_level,
|
||||
origin=origin,
|
||||
dedupe=dedupe,
|
||||
last_signature=self._last_signature,
|
||||
timestamp_format=self._timestamp_format,
|
||||
limit=self._log_limit,
|
||||
)
|
||||
if (
|
||||
forward_backend
|
||||
and self._backend_log_sink is not None
|
||||
and not origin.lower().startswith("backend")
|
||||
):
|
||||
self._backend_sink_queue.put((full_text, resolved_level, origin))
|
||||
self._refresh_log_dialog_if_open()
|
||||
|
||||
def _refresh_log_dialog_if_open(self) -> None:
|
||||
dlg = self._log_dialog
|
||||
if dlg is None or not dlg.isVisible():
|
||||
return
|
||||
if self._log_dialog_level_color is None:
|
||||
return
|
||||
try:
|
||||
dlg.update_entries(
|
||||
lines=self._log_entries[:],
|
||||
fallback_text=self._full_text,
|
||||
level_color=self._log_dialog_level_color,
|
||||
timestamp_color=self._log_dialog_timestamp_color,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_status(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
error: bool = False,
|
||||
log: bool = True,
|
||||
origin: str = "ui",
|
||||
):
|
||||
full_text = apply_status_label(
|
||||
self._label,
|
||||
message=message,
|
||||
error=error,
|
||||
preview_limit=self._preview_limit,
|
||||
inspect_button=self._inspect_button,
|
||||
empty_button_behavior=self._empty_button_behavior,
|
||||
log_entry_count=len(self._log_entries),
|
||||
ok_color=self._ok_color,
|
||||
error_color=self._error_color,
|
||||
)
|
||||
self._full_text = full_text
|
||||
if full_text == "":
|
||||
return
|
||||
|
||||
if not log:
|
||||
return
|
||||
|
||||
signature = (full_text, bool(error))
|
||||
if signature != self._last_signature:
|
||||
self.append_log(full_text, error=error, origin=origin)
|
||||
self._last_signature = signature
|
||||
|
||||
def show_log_dialog(
|
||||
self,
|
||||
parent: "QtWidgets.QWidget",
|
||||
*,
|
||||
title: str,
|
||||
level_color: Callable[[str], str],
|
||||
timestamp_color: str,
|
||||
) -> None:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.status_log_dialog import (
|
||||
StatusLogDialog,
|
||||
)
|
||||
self._log_dialog_level_color = level_color
|
||||
self._log_dialog_timestamp_color = timestamp_color
|
||||
dlg = self._log_dialog
|
||||
if dlg is None:
|
||||
dlg = StatusLogDialog(
|
||||
parent,
|
||||
title=title,
|
||||
lines=self._log_entries[:],
|
||||
fallback_text=self._full_text,
|
||||
level_color=level_color,
|
||||
timestamp_color=timestamp_color,
|
||||
)
|
||||
dlg.setWindowModality(QtCore.Qt.WindowModality.NonModal)
|
||||
dlg.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||
dlg.destroyed.connect(lambda *_: setattr(self, "_log_dialog", None))
|
||||
self._log_dialog = dlg
|
||||
else:
|
||||
dlg.update_entries(
|
||||
lines=self._log_entries[:],
|
||||
fallback_text=self._full_text,
|
||||
level_color=level_color,
|
||||
timestamp_color=timestamp_color,
|
||||
)
|
||||
dlg.show()
|
||||
dlg.raise_()
|
||||
dlg.activateWindow()
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtGui, QC
|
||||
from opensnitch.plugins.list_subscriptions._utils import (
|
||||
deslugify_filename,
|
||||
derive_filename,
|
||||
ensure_filename_type_suffix,
|
||||
is_valid_url,
|
||||
safe_filename,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.workers import UrlTestWorker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.subscription_dialog import (
|
||||
SubscriptionDialog,
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionDialogController:
|
||||
def __init__(self, *, dialog: "SubscriptionDialog"):
|
||||
self._dialog = dialog
|
||||
self._refresh_signal: Any = None
|
||||
self._url_worker: UrlTestWorker | None = None
|
||||
self._url_thread: QtCore.QThread | None = None
|
||||
self._url_worker_stopped_callbacks: list[Callable[[], None]] = []
|
||||
self._shutting_down = False
|
||||
dialog.destroyed.connect(self._on_dialog_destroyed)
|
||||
app = QtGui.QGuiApplication.instance()
|
||||
if app is not None:
|
||||
app.aboutToQuit.connect(self._on_app_about_to_quit)
|
||||
|
||||
def _on_dialog_destroyed(self, *_args):
|
||||
self.shutdown_url_worker(wait_ms=3000)
|
||||
|
||||
def _on_app_about_to_quit(self):
|
||||
self.shutdown_url_worker(wait_ms=3000)
|
||||
|
||||
def has_active_url_test(self) -> bool:
|
||||
thread = self._url_thread
|
||||
return bool(thread is not None and thread.isRunning())
|
||||
|
||||
def on_url_test_stopped(self, callback: Callable[[], None]) -> None:
|
||||
if not self.has_active_url_test():
|
||||
callback()
|
||||
return
|
||||
self._url_worker_stopped_callbacks.append(callback)
|
||||
|
||||
def cancel_active_url_test(self):
|
||||
worker = self._url_worker
|
||||
thread = self._url_thread
|
||||
if worker is None or thread is None or not thread.isRunning():
|
||||
return
|
||||
worker.stop()
|
||||
thread.quit()
|
||||
|
||||
def wait_for_active_url_test_stop(self, wait_ms: int = 1200) -> bool:
|
||||
worker = self._url_worker
|
||||
thread = self._url_thread
|
||||
if worker is None or thread is None:
|
||||
return True
|
||||
if not thread.isRunning():
|
||||
self._url_worker = None
|
||||
self._url_thread = None
|
||||
return True
|
||||
|
||||
worker.stop()
|
||||
thread.quit()
|
||||
if wait_ms <= 0:
|
||||
stopped = thread.wait()
|
||||
else:
|
||||
stopped = thread.wait(wait_ms)
|
||||
if stopped:
|
||||
self._url_worker = None
|
||||
self._url_thread = None
|
||||
return bool(stopped)
|
||||
|
||||
def shutdown_url_worker(self, wait_ms: int = 2000) -> bool:
|
||||
self._shutting_down = True
|
||||
worker = self._url_worker
|
||||
thread = self._url_thread
|
||||
if worker is None or thread is None:
|
||||
self._url_worker = None
|
||||
self._url_thread = None
|
||||
return True
|
||||
try:
|
||||
worker.test_result.disconnect(self._dialog._url_test_finished.emit)
|
||||
except Exception:
|
||||
pass
|
||||
if thread.isRunning():
|
||||
worker.stop()
|
||||
thread.quit()
|
||||
if wait_ms <= 0:
|
||||
thread.wait()
|
||||
else:
|
||||
thread.wait(wait_ms)
|
||||
if thread.isRunning():
|
||||
thread.terminate()
|
||||
thread.wait(500)
|
||||
if thread.isRunning():
|
||||
self._url_worker = worker
|
||||
self._url_thread = thread
|
||||
return False
|
||||
self._url_worker = None
|
||||
self._url_thread = None
|
||||
return True
|
||||
|
||||
# -- Meta refresh -------------------------------------------------------
|
||||
|
||||
def connect_to_refresh_signal(self, signal: Any) -> None:
|
||||
self._refresh_signal = signal
|
||||
signal.connect(self.on_state_refreshed)
|
||||
|
||||
def on_state_refreshed(self, url: str, filename: str, meta: dict[str, str]) -> None:
|
||||
if url != str(self._dialog._sub.url) or filename != str(self._dialog._sub.filename):
|
||||
return
|
||||
self.update_meta(meta)
|
||||
|
||||
def update_meta(self, meta: dict[str, str]) -> None:
|
||||
self._dialog.meta_file_present.setText(str(meta.get("file_present", "")))
|
||||
self._dialog.meta_meta_present.setText(str(meta.get("meta_present", "")))
|
||||
self._dialog.meta_state.setText(str(meta.get("state", "")))
|
||||
self.apply_meta_state_color(str(meta.get("state", "")))
|
||||
self._dialog.meta_last_checked.setText(str(meta.get("last_checked", "")))
|
||||
self._dialog.meta_last_updated.setText(str(meta.get("last_updated", "")))
|
||||
self._dialog.meta_failures.setText(str(meta.get("failures", "")))
|
||||
self._dialog.meta_error.setText(str(meta.get("error", "")))
|
||||
self._dialog.meta_list_path.setText(str(meta.get("list_path", "")))
|
||||
self._dialog.meta_meta_path.setText(str(meta.get("meta_path", "")))
|
||||
|
||||
def apply_meta_state_color(self, state: str) -> None:
|
||||
normalized = (state or "").strip().lower()
|
||||
dark_theme = (
|
||||
self._dialog.palette()
|
||||
.color(QtGui.QPalette.ColorRole.Window)
|
||||
.lightness()
|
||||
< 128
|
||||
)
|
||||
if dark_theme:
|
||||
healthy_color = "#7CE3A1"
|
||||
pending_color = "#F5D76E"
|
||||
problematic_color = "#FF8A80"
|
||||
else:
|
||||
healthy_color = "#0F8A4B"
|
||||
pending_color = "#9A6700"
|
||||
problematic_color = "#C62828"
|
||||
if normalized in ("updated", "not_modified"):
|
||||
color = healthy_color
|
||||
elif normalized == "pending":
|
||||
color = pending_color
|
||||
else:
|
||||
color = problematic_color
|
||||
self._dialog.meta_state.setStyleSheet(f"color: {color};")
|
||||
|
||||
def disconnect_signal(self) -> None:
|
||||
self.cancel_active_url_test()
|
||||
if self._refresh_signal is not None:
|
||||
try:
|
||||
self._refresh_signal.disconnect(self.on_state_refreshed)
|
||||
except Exception:
|
||||
pass
|
||||
self._refresh_signal = None
|
||||
|
||||
# -- Field helpers ------------------------------------------------------
|
||||
|
||||
def sync_optional_fields_state(self) -> None:
|
||||
self._dialog.interval_units.setEnabled(self._dialog.interval_spin.value() > 0)
|
||||
self._dialog.timeout_units.setEnabled(self._dialog.timeout_spin.value() > 0)
|
||||
self._dialog.max_size_units.setEnabled(self._dialog.max_size_spin.value() > 0)
|
||||
|
||||
def clear_field_errors(self) -> None:
|
||||
self.set_dialog_message("", error=False)
|
||||
self._dialog.name_error_label.setText("")
|
||||
self._dialog.url_error_label.setText("")
|
||||
self._dialog.filename_error_label.setText("")
|
||||
|
||||
def set_dialog_message(self, message: str, error: bool) -> None:
|
||||
self._dialog._dialog_message_controller.set_status(message, error=error)
|
||||
if (message or "").strip():
|
||||
self._dialog.log_message.emit(
|
||||
message,
|
||||
"ERROR" if error else "INFO",
|
||||
str(getattr(self._dialog, "_log_origin", "ui:subscription")),
|
||||
)
|
||||
|
||||
# -- URL test -----------------------------------------------------------
|
||||
|
||||
def test_url(self) -> None:
|
||||
if self._shutting_down:
|
||||
return
|
||||
self._dialog.url_error_label.setText("")
|
||||
self.set_dialog_message("", error=False)
|
||||
url = (self._dialog.url_edit.text() or "").strip()
|
||||
if url == "":
|
||||
self._dialog.url_error_label.setText(QC.translate("stats", "URL is required."))
|
||||
self.set_dialog_message(
|
||||
QC.translate("stats", "Fix the highlighted fields."), error=True
|
||||
)
|
||||
return
|
||||
if not is_valid_url(url):
|
||||
self._dialog.url_error_label.setText(
|
||||
QC.translate("stats", "Enter a valid http:// or https:// URL.")
|
||||
)
|
||||
self.set_dialog_message(
|
||||
QC.translate("stats", "Fix the highlighted fields."), error=True
|
||||
)
|
||||
return
|
||||
self._dialog.test_url_button.setEnabled(False)
|
||||
self.set_dialog_message(QC.translate("stats", "Testing URL..."), error=False)
|
||||
self.shutdown_url_worker(wait_ms=100)
|
||||
self._shutting_down = False
|
||||
list_type = (self._dialog.format_combo.currentText() or "hosts").strip().lower()
|
||||
thread = QtCore.QThread(self._dialog)
|
||||
thread.setObjectName("UrlTestWorkerThread")
|
||||
worker = UrlTestWorker(url, list_type)
|
||||
worker.setObjectName("UrlTestWorker")
|
||||
worker.moveToThread(thread)
|
||||
self._url_worker = worker
|
||||
self._url_thread = thread
|
||||
thread.started.connect(worker.run)
|
||||
worker.test_result.connect(self._dialog._url_test_finished.emit)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
thread.finished.connect(self._on_url_test_worker_stopped)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
thread.start()
|
||||
|
||||
def _on_url_test_worker_stopped(self) -> None:
|
||||
self._url_worker = None
|
||||
self._url_thread = None
|
||||
callbacks = self._url_worker_stopped_callbacks[:]
|
||||
self._url_worker_stopped_callbacks.clear()
|
||||
for callback in callbacks:
|
||||
callback()
|
||||
|
||||
def handle_url_test_finished(self, success: bool, message: str) -> None:
|
||||
if self._shutting_down:
|
||||
return
|
||||
self._dialog.test_url_button.setEnabled(True)
|
||||
if success:
|
||||
self._dialog.url_error_label.setText("")
|
||||
self.set_dialog_message(message, error=False)
|
||||
return
|
||||
self._dialog.url_error_label.setText(QC.translate("stats", "URL check failed."))
|
||||
self.set_dialog_message(message, error=True)
|
||||
|
||||
# -- Validation ---------------------------------------------------------
|
||||
|
||||
def validate_then_accept(self) -> None:
|
||||
self.clear_field_errors()
|
||||
raw_url = (self._dialog.url_edit.text() or "").strip()
|
||||
raw_name = (self._dialog.name_edit.text() or "").strip()
|
||||
raw_filename = (self._dialog.filename_edit.text() or "").strip()
|
||||
list_type = (self._dialog.format_combo.currentText() or "hosts").strip().lower()
|
||||
name = raw_name
|
||||
filename = safe_filename(raw_filename)
|
||||
has_error = False
|
||||
|
||||
if raw_url == "":
|
||||
self._dialog.url_error_label.setText(QC.translate("stats", "URL is required."))
|
||||
has_error = True
|
||||
elif not is_valid_url(raw_url):
|
||||
self._dialog.url_error_label.setText(
|
||||
QC.translate("stats", "Enter a valid http:// or https:// URL.")
|
||||
)
|
||||
has_error = True
|
||||
|
||||
if raw_name == "" and raw_filename == "":
|
||||
self._dialog.name_error_label.setText(
|
||||
QC.translate("stats", "Provide a name or filename.")
|
||||
)
|
||||
self._dialog.filename_error_label.setText(
|
||||
QC.translate("stats", "Provide a filename or name.")
|
||||
)
|
||||
has_error = True
|
||||
elif raw_filename != "" and filename != raw_filename:
|
||||
self._dialog.filename_error_label.setText(
|
||||
QC.translate("stats", "Filename must not include directory components.")
|
||||
)
|
||||
has_error = True
|
||||
|
||||
if has_error:
|
||||
self.set_dialog_message(
|
||||
QC.translate("stats", "Fix the highlighted fields."), error=True
|
||||
)
|
||||
return
|
||||
|
||||
if filename == "" and name != "":
|
||||
filename = safe_filename(derive_filename(name, None, ""))
|
||||
filename = ensure_filename_type_suffix(filename, list_type)
|
||||
|
||||
if name == "" and filename != "":
|
||||
name = deslugify_filename(filename, list_type)
|
||||
|
||||
self._dialog.name_edit.setText(name)
|
||||
self._dialog.filename_edit.setText(filename)
|
||||
self._dialog.accept()
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC
|
||||
from opensnitch.plugins.list_subscriptions.models.subscriptions import (
|
||||
MutableSubscriptionSpec,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.subscription_dialog import (
|
||||
SubscriptionDialog,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions._utils import (
|
||||
INTERVAL_UNITS,
|
||||
SIZE_UNITS,
|
||||
TIMEOUT_UNITS,
|
||||
display_str,
|
||||
normalize_groups,
|
||||
safe_filename,
|
||||
strip_or_none,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionEditController:
|
||||
def __init__(
|
||||
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
|
||||
):
|
||||
self._dialog = dialog
|
||||
self._cols = columns
|
||||
|
||||
def _col(self, key: str):
|
||||
return self._cols[key]
|
||||
|
||||
def add_subscription_row(self):
|
||||
dlg = SubscriptionDialog(
|
||||
self._dialog,
|
||||
self._dialog._global_defaults,
|
||||
groups=self._dialog._selection_controller.known_groups(),
|
||||
sub=MutableSubscriptionSpec.from_dict(
|
||||
{"enabled": True},
|
||||
defaults=self._dialog._global_defaults,
|
||||
require_url=False,
|
||||
ensure_suffix=False,
|
||||
),
|
||||
title="New subscription",
|
||||
)
|
||||
dlg.log_message.connect(self._dialog._status_controller.log)
|
||||
if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
sub = dlg.subscription_spec()
|
||||
with self._dialog._table_view_controller.sorting_suspended():
|
||||
self._dialog._table_data_controller.append_row(sub)
|
||||
row = self._dialog.table.rowCount() - 1
|
||||
_, changed = self._dialog._table_data_controller.ensure_row_final_filename(row)
|
||||
if changed:
|
||||
self._dialog._table_data_controller.refresh_states()
|
||||
|
||||
if not os.path.exists(self._dialog._action_path):
|
||||
self._dialog._action_file_controller.create_action_file()
|
||||
self._dialog._action_file_controller.save_action_file()
|
||||
self._dialog._selection_controller.update_selected_actions_state()
|
||||
|
||||
def edit_selected_subscription(self):
|
||||
row = self._dialog.table.currentRow()
|
||||
if row < 0:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Select a subscription row first."), error=True
|
||||
)
|
||||
return
|
||||
with self._dialog._table_view_controller.sorting_suspended():
|
||||
enabled_item = self._dialog.table.item(row, self._col("enabled"))
|
||||
if enabled_item is None:
|
||||
enabled_item = self._dialog._table_data_controller.new_enabled_item(False)
|
||||
self._dialog.table.setItem(row, self._col("enabled"), enabled_item)
|
||||
|
||||
interval_ok, interval_val = self._dialog._table_data_controller.optional_int_from_text(
|
||||
self._dialog._table_data_controller.cell_text(row, self._col("interval")),
|
||||
"Interval",
|
||||
row=row,
|
||||
)
|
||||
timeout_ok, timeout_val = self._dialog._table_data_controller.optional_int_from_text(
|
||||
self._dialog._table_data_controller.cell_text(row, self._col("timeout")),
|
||||
"Timeout",
|
||||
row=row,
|
||||
)
|
||||
max_size_ok, max_size_val = self._dialog._table_data_controller.optional_int_from_text(
|
||||
self._dialog._table_data_controller.cell_text(row, self._col("max_size")),
|
||||
"Max size",
|
||||
row=row,
|
||||
)
|
||||
if not interval_ok or not timeout_ok or not max_size_ok:
|
||||
return
|
||||
sub = MutableSubscriptionSpec(
|
||||
enabled=enabled_item.checkState() == QtCore.Qt.CheckState.Checked,
|
||||
name=self._dialog._table_data_controller.cell_text(row, self._col("name")),
|
||||
url=self._dialog._table_data_controller.cell_text(row, self._col("url")),
|
||||
filename=self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("filename")
|
||||
),
|
||||
format=self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("format")
|
||||
) or "hosts",
|
||||
groups=normalize_groups(
|
||||
self._dialog._table_data_controller.cell_text(row, self._col("group"))
|
||||
),
|
||||
interval=interval_val,
|
||||
interval_units=strip_or_none(
|
||||
self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("interval_units")
|
||||
)
|
||||
),
|
||||
timeout=timeout_val,
|
||||
timeout_units=strip_or_none(
|
||||
self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("timeout_units")
|
||||
)
|
||||
),
|
||||
max_size=max_size_val,
|
||||
max_size_units=strip_or_none(
|
||||
self._dialog._table_data_controller.cell_text(
|
||||
row, self._col("max_size_units")
|
||||
)
|
||||
),
|
||||
)
|
||||
meta = self._dialog._table_data_controller.row_meta_snapshot(row)
|
||||
dlg = SubscriptionDialog(
|
||||
self._dialog,
|
||||
self._dialog._global_defaults,
|
||||
groups=self._dialog._selection_controller.known_groups(),
|
||||
sub=sub,
|
||||
meta=meta,
|
||||
title="Edit subscription",
|
||||
)
|
||||
dlg.log_message.connect(self._dialog._status_controller.log)
|
||||
dlg._subscription_dialog_controller.connect_to_refresh_signal(
|
||||
self._dialog.subscription_state_refreshed
|
||||
)
|
||||
if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted:
|
||||
return
|
||||
updated = dlg.subscription_spec()
|
||||
|
||||
with self._dialog._table_view_controller.sorting_suspended():
|
||||
enabled_item = self._dialog.table.item(row, self._col("enabled"))
|
||||
if enabled_item is None:
|
||||
enabled_item = self._dialog._table_data_controller.new_enabled_item(False)
|
||||
self._dialog.table.setItem(row, self._col("enabled"), enabled_item)
|
||||
enabled_item.setCheckState(
|
||||
QtCore.Qt.CheckState.Checked
|
||||
if bool(updated.enabled)
|
||||
else QtCore.Qt.CheckState.Unchecked
|
||||
)
|
||||
enabled_item.setData(
|
||||
QtCore.Qt.ItemDataRole.UserRole, 1 if bool(updated.enabled) else 0
|
||||
)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row, self._col("name"), updated.name
|
||||
)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row, self._col("url"), updated.url
|
||||
)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("filename"),
|
||||
safe_filename(updated.filename),
|
||||
)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row, self._col("format"), updated.format
|
||||
)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row, self._col("group"), ", ".join(normalize_groups(updated.groups))
|
||||
)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("interval"),
|
||||
display_str(updated.interval),
|
||||
)
|
||||
interval_units_val = display_str(updated.interval_units)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("interval_units"),
|
||||
interval_units_val,
|
||||
)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("timeout"),
|
||||
display_str(updated.timeout),
|
||||
)
|
||||
timeout_units_val = display_str(updated.timeout_units)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("timeout_units"),
|
||||
timeout_units_val,
|
||||
)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("max_size"),
|
||||
display_str(updated.max_size),
|
||||
)
|
||||
max_size_units_val = display_str(updated.max_size_units)
|
||||
self._dialog._table_data_controller.set_text_item(
|
||||
row,
|
||||
self._col("max_size_units"),
|
||||
max_size_units_val,
|
||||
)
|
||||
self._dialog._defaults_ui_controller.set_units_combo(
|
||||
row, self._col("interval_units"), INTERVAL_UNITS, interval_units_val
|
||||
)
|
||||
self._dialog._defaults_ui_controller.set_units_combo(
|
||||
row, self._col("timeout_units"), TIMEOUT_UNITS, timeout_units_val
|
||||
)
|
||||
self._dialog._defaults_ui_controller.set_units_combo(
|
||||
row, self._col("max_size_units"), SIZE_UNITS, max_size_units_val
|
||||
)
|
||||
_, changed = self._dialog._table_data_controller.ensure_row_final_filename(row)
|
||||
self._dialog._table_data_controller.update_row_sort_keys(row)
|
||||
self._dialog._action_file_controller.save_action_file()
|
||||
self._dialog._table_data_controller.refresh_states()
|
||||
if changed:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Subscription updated and filename normalized."),
|
||||
error=False,
|
||||
)
|
||||
else:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Subscription updated."), error=False
|
||||
)
|
||||
|
||||
def edit_action_clicked(self):
|
||||
rows = self._dialog._selection_controller.selected_rows()
|
||||
if len(rows) == 0:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Select one or more subscriptions first."),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
if len(rows) == 1:
|
||||
self.edit_selected_subscription()
|
||||
return
|
||||
self._dialog._bulk_edit_controller.bulk_edit(rows)
|
||||
|
||||
def remove_selected_subscription(self):
|
||||
rows = self._dialog._selection_controller.selected_rows()
|
||||
if not rows:
|
||||
row = self._dialog.table.currentRow()
|
||||
if row >= 0:
|
||||
rows = [row]
|
||||
if not rows:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Select one or more subscription rows first."),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
for row in sorted(rows, reverse=True):
|
||||
self._dialog.table.removeRow(row)
|
||||
self._dialog._action_file_controller.save_action_file()
|
||||
self._dialog._table_data_controller.refresh_states()
|
||||
self._dialog._selection_controller.update_selected_actions_state()
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Selected subscriptions removed."), error=False
|
||||
)
|
||||
|
||||
def handle_table_item_double_clicked(self, item: QtWidgets.QTableWidgetItem):
|
||||
if item is not None:
|
||||
self._dialog.table.selectRow(item.row())
|
||||
self.edit_selected_subscription()
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.subscription_status_dialog import (
|
||||
SubscriptionStatusDialog,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionStatusController:
|
||||
def __init__(
|
||||
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
|
||||
):
|
||||
self._dialog = dialog
|
||||
self._cols = columns
|
||||
|
||||
def _col(self, key: str):
|
||||
return self._cols[key]
|
||||
|
||||
def show_selected_subscription_status(self):
|
||||
rows = self._dialog._selection_controller.selected_rows()
|
||||
if not rows:
|
||||
row = self._dialog.table.currentRow()
|
||||
if row >= 0:
|
||||
rows = [row]
|
||||
if not rows:
|
||||
self._dialog._status_controller.set_status(
|
||||
QC.translate("stats", "Select a subscription row first."),
|
||||
error=True,
|
||||
)
|
||||
return
|
||||
|
||||
row = rows[0]
|
||||
name = self._dialog._table_data_controller.cell_text(row, self._col("name"))
|
||||
url = self._dialog._table_data_controller.cell_text(row, self._col("url"))
|
||||
filename = self._dialog._table_data_controller.cell_text(
|
||||
row,
|
||||
self._col("filename"),
|
||||
)
|
||||
meta = self.meta_snapshot_by_identity(url, filename)
|
||||
if meta is None:
|
||||
meta = self._dialog._table_data_controller.row_meta_snapshot(row)
|
||||
|
||||
dlg = SubscriptionStatusDialog(
|
||||
self._dialog,
|
||||
name=name,
|
||||
url=url,
|
||||
filename=filename,
|
||||
meta=meta,
|
||||
)
|
||||
dlg.connect_to_refresh_signal(
|
||||
self._dialog.subscription_state_refreshed
|
||||
)
|
||||
if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
action = dlg.action()
|
||||
if action == SubscriptionStatusDialog.ACTION_EDIT:
|
||||
self._dialog._subscription_edit_controller.edit_selected_subscription()
|
||||
elif action == SubscriptionStatusDialog.ACTION_REFRESH:
|
||||
self._dialog._table_data_controller.refresh_selected_now()
|
||||
|
||||
def find_row_by_identity(self, url: str, filename: str):
|
||||
for row in range(self._dialog.table.rowCount()):
|
||||
if self._dialog._table_data_controller.cell_text(row, self._col("url")) != url:
|
||||
continue
|
||||
if (
|
||||
self._dialog._table_data_controller.cell_text(
|
||||
row,
|
||||
self._col("filename"),
|
||||
)
|
||||
!= filename
|
||||
):
|
||||
continue
|
||||
return row
|
||||
return -1
|
||||
|
||||
def meta_snapshot_by_identity(self, url: str, filename: str):
|
||||
row = self.find_row_by_identity(url, filename)
|
||||
if row < 0:
|
||||
return None
|
||||
return self._dialog._table_data_controller.row_meta_snapshot(row)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
class TableViewController:
|
||||
def __init__(
|
||||
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
|
||||
):
|
||||
self._dialog = dialog
|
||||
self._cols = columns
|
||||
|
||||
def _col(self, key: str):
|
||||
return self._cols[key]
|
||||
|
||||
def _visible_columns(self) -> list[int]:
|
||||
return [
|
||||
col
|
||||
for col in range(self._dialog.table.columnCount())
|
||||
if not self._dialog.table.isColumnHidden(col)
|
||||
]
|
||||
|
||||
def _expand_visible_columns_to_viewport(self, base_widths: dict[int, int]) -> None:
|
||||
visible_cols = [col for col in self._visible_columns() if col in base_widths]
|
||||
if not visible_cols:
|
||||
return
|
||||
|
||||
viewport_width = self._dialog.table.viewport().width()
|
||||
if viewport_width <= 0:
|
||||
return
|
||||
|
||||
min_total = sum(max(1, int(base_widths.get(col, 1))) for col in visible_cols)
|
||||
if viewport_width <= min_total:
|
||||
return
|
||||
|
||||
extra = viewport_width - min_total
|
||||
add_each = extra // len(visible_cols)
|
||||
remainder = extra % len(visible_cols)
|
||||
|
||||
for idx, col in enumerate(visible_cols):
|
||||
target = max(1, int(base_widths.get(col, 1))) + add_each
|
||||
if idx < remainder:
|
||||
target += 1
|
||||
self._dialog.table.setColumnWidth(col, target)
|
||||
|
||||
@contextmanager
|
||||
def sorting_suspended(self):
|
||||
header = self._dialog.table.horizontalHeader()
|
||||
sorting_enabled = self._dialog.table.isSortingEnabled()
|
||||
sort_section = header.sortIndicatorSection() if header is not None else -1
|
||||
sort_order = (
|
||||
header.sortIndicatorOrder()
|
||||
if header is not None
|
||||
else QtCore.Qt.SortOrder.AscendingOrder
|
||||
)
|
||||
self._dialog.table.setSortingEnabled(False)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._dialog.table.setSortingEnabled(sorting_enabled)
|
||||
if sorting_enabled and header is not None and sort_section >= 0:
|
||||
self._dialog.table.sortItems(sort_section, sort_order)
|
||||
self.apply_table_view_mode()
|
||||
|
||||
def on_table_view_tab_changed(self, index: int):
|
||||
monitoring = index == 0
|
||||
always_hidden = {
|
||||
self._col("interval"),
|
||||
self._col("interval_units"),
|
||||
self._col("timeout"),
|
||||
self._col("timeout_units"),
|
||||
self._col("max_size"),
|
||||
self._col("max_size_units"),
|
||||
self._col("file"),
|
||||
self._col("meta"),
|
||||
self._col("rule_attached"),
|
||||
}
|
||||
monitoring_only = {
|
||||
self._col("state"),
|
||||
self._col("last_checked"),
|
||||
self._col("last_updated"),
|
||||
}
|
||||
config_only = {
|
||||
self._col("enabled"),
|
||||
self._col("url"),
|
||||
self._col("filename"),
|
||||
self._col("format"),
|
||||
self._col("group"),
|
||||
}
|
||||
for col in range(self._dialog.table.columnCount()):
|
||||
if col in always_hidden:
|
||||
self._dialog.table.setColumnHidden(col, True)
|
||||
elif col in monitoring_only:
|
||||
self._dialog.table.setColumnHidden(col, not monitoring)
|
||||
elif col in config_only:
|
||||
self._dialog.table.setColumnHidden(col, monitoring)
|
||||
self.apply_table_column_sizing(index)
|
||||
self.apply_table_view_mode(index, set_sort=True)
|
||||
self._dialog._inspector_controller.update_inspector_panel()
|
||||
|
||||
def apply_table_column_sizing(self, index: int | None = None):
|
||||
header = self._dialog.table.horizontalHeader()
|
||||
if header is None:
|
||||
return
|
||||
|
||||
monitoring = (
|
||||
index == 0
|
||||
if index is not None
|
||||
else self._dialog._table_tab_bar.currentIndex() == 0
|
||||
)
|
||||
tab_index = 0 if monitoring else 1
|
||||
resized_columns = self._dialog._user_resized_columns_by_tab.get(tab_index, set())
|
||||
|
||||
header.setStretchLastSection(False)
|
||||
self._dialog._applying_table_column_sizing = True
|
||||
base_widths: dict[int, int] = {}
|
||||
|
||||
try:
|
||||
if monitoring:
|
||||
# Monitoring: all visible data columns are user-resizable.
|
||||
header.setSectionResizeMode(self._col("name"), QtWidgets.QHeaderView.ResizeMode.Interactive)
|
||||
header.setSectionResizeMode(self._col("state"), QtWidgets.QHeaderView.ResizeMode.Interactive)
|
||||
header.setSectionResizeMode(
|
||||
self._col("last_checked"), QtWidgets.QHeaderView.ResizeMode.Interactive
|
||||
)
|
||||
header.setSectionResizeMode(
|
||||
self._col("last_updated"), QtWidgets.QHeaderView.ResizeMode.Interactive
|
||||
)
|
||||
if self._col("name") not in resized_columns:
|
||||
self._dialog.table.setColumnWidth(self._col("name"), 260)
|
||||
if self._col("state") not in resized_columns:
|
||||
self._dialog.table.setColumnWidth(self._col("state"), 140)
|
||||
if self._col("last_checked") not in resized_columns:
|
||||
self._dialog.table.setColumnWidth(self._col("last_checked"), 260)
|
||||
if self._col("last_updated") not in resized_columns:
|
||||
self._dialog.table.setColumnWidth(self._col("last_updated"), 260)
|
||||
for col in self._visible_columns():
|
||||
base_widths[col] = self._dialog.table.columnWidth(col)
|
||||
self._expand_visible_columns_to_viewport(base_widths)
|
||||
return
|
||||
|
||||
# Config: keep URL flexible, reserve enough space for frequently edited fields.
|
||||
header.setSectionResizeMode(self._col("enabled"), QtWidgets.QHeaderView.ResizeMode.Fixed)
|
||||
header.setSectionResizeMode(self._col("name"), QtWidgets.QHeaderView.ResizeMode.Interactive)
|
||||
header.setSectionResizeMode(self._col("url"), QtWidgets.QHeaderView.ResizeMode.Interactive)
|
||||
header.setSectionResizeMode(self._col("filename"), QtWidgets.QHeaderView.ResizeMode.Interactive)
|
||||
header.setSectionResizeMode(self._col("format"), QtWidgets.QHeaderView.ResizeMode.Interactive)
|
||||
header.setSectionResizeMode(self._col("group"), QtWidgets.QHeaderView.ResizeMode.Interactive)
|
||||
|
||||
if self._col("name") not in resized_columns:
|
||||
self._dialog.table.setColumnWidth(self._col("name"), 220)
|
||||
if self._col("url") not in resized_columns:
|
||||
self._dialog.table.setColumnWidth(self._col("url"), 380)
|
||||
if self._col("filename") not in resized_columns:
|
||||
self._dialog.table.setColumnWidth(self._col("filename"), 220)
|
||||
if self._col("format") not in resized_columns:
|
||||
self._dialog.table.setColumnWidth(self._col("format"), 120)
|
||||
if self._col("group") not in resized_columns:
|
||||
self._dialog.table.setColumnWidth(self._col("group"), 180)
|
||||
|
||||
for col in self._visible_columns():
|
||||
base_widths[col] = self._dialog.table.columnWidth(col)
|
||||
self._expand_visible_columns_to_viewport(base_widths)
|
||||
finally:
|
||||
self._dialog._applying_table_column_sizing = False
|
||||
|
||||
def on_table_section_resized(self, logical_index: int, _old_size: int, _new_size: int):
|
||||
if self._dialog._applying_table_column_sizing:
|
||||
return
|
||||
if logical_index < 0 or logical_index >= self._dialog.table.columnCount():
|
||||
return
|
||||
if self._dialog.table.isColumnHidden(logical_index):
|
||||
return
|
||||
if not hasattr(self._dialog, "_table_tab_bar"):
|
||||
return
|
||||
tab_index = self._dialog._table_tab_bar.currentIndex()
|
||||
self._dialog._user_resized_columns_by_tab.setdefault(tab_index, set()).add(logical_index)
|
||||
|
||||
def reset_table_column_widths_for_current_tab(self):
|
||||
if not hasattr(self._dialog, "_table_tab_bar"):
|
||||
return
|
||||
tab_index = self._dialog._table_tab_bar.currentIndex()
|
||||
self._dialog._user_resized_columns_by_tab.pop(tab_index, None)
|
||||
self.apply_table_column_sizing(tab_index)
|
||||
|
||||
def reset_table_sort_for_current_tab(self):
|
||||
if not hasattr(self._dialog, "_table_tab_bar"):
|
||||
return
|
||||
tab_index = self._dialog._table_tab_bar.currentIndex()
|
||||
self.apply_table_view_mode(tab_index, set_sort=True)
|
||||
|
||||
def apply_table_view_mode(self, index: int | None = None, *, set_sort: bool = False):
|
||||
if not hasattr(self._dialog, "_table_tab_bar"):
|
||||
return
|
||||
monitoring = (
|
||||
index == 0
|
||||
if index is not None
|
||||
else self._dialog._table_tab_bar.currentIndex() == 0
|
||||
)
|
||||
for row in range(self._dialog.table.rowCount()):
|
||||
enabled_item = self._dialog.table.item(row, self._col("enabled"))
|
||||
enabled = enabled_item is not None and (
|
||||
enabled_item.checkState() == QtCore.Qt.CheckState.Checked
|
||||
)
|
||||
self._dialog.table.setRowHidden(row, monitoring and not enabled)
|
||||
|
||||
if not set_sort:
|
||||
return
|
||||
header = self._dialog.table.horizontalHeader()
|
||||
if header is None:
|
||||
return
|
||||
if monitoring:
|
||||
sort_col = self._col("state")
|
||||
sort_order = QtCore.Qt.SortOrder.AscendingOrder
|
||||
else:
|
||||
sort_col = self._col("enabled")
|
||||
sort_order = QtCore.Qt.SortOrder.AscendingOrder
|
||||
header.setSortIndicator(sort_col, sort_order)
|
||||
if self._dialog.table.isSortingEnabled():
|
||||
self._dialog.table.sortItems(sort_col, sort_order)
|
||||
@@ -0,0 +1,274 @@
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TYPE_CHECKING, Final
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import (
|
||||
QtCore,
|
||||
QtWidgets,
|
||||
QC,
|
||||
load_ui_type,
|
||||
)
|
||||
|
||||
from opensnitch.plugins.list_subscriptions._utils import RES_DIR
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.helpers import _configure_modal_dialog
|
||||
from opensnitch.plugins.list_subscriptions.ui.widgets.table_widgets import (
|
||||
SortableTableWidgetItem,
|
||||
)
|
||||
|
||||
ATTACHED_RULES_DIALOG_UI_PATH: Final[str] = os.path.join(
|
||||
RES_DIR, "attached_rules_dialog.ui"
|
||||
)
|
||||
|
||||
AttachedRulesDialogUI: Final[Any] = load_ui_type(ATTACHED_RULES_DIALOG_UI_PATH)[0]
|
||||
|
||||
ATTACHED_RULE_ENTRY_ROLE = int(QtCore.Qt.ItemDataRole.UserRole) + 1
|
||||
|
||||
|
||||
class AttachedRulesDialog(QtWidgets.QDialog, AttachedRulesDialogUI):
|
||||
if TYPE_CHECKING:
|
||||
rules_table: QtWidgets.QTableWidget
|
||||
create_button: QtWidgets.QPushButton
|
||||
edit_button: QtWidgets.QPushButton
|
||||
toggle_button: QtWidgets.QPushButton
|
||||
remove_button: QtWidgets.QPushButton
|
||||
close_button: QtWidgets.QPushButton
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
*,
|
||||
get_attached_rules: Callable[[], list[dict[str, Any]]],
|
||||
on_create_rule: Callable[[], None],
|
||||
on_edit_rule: Callable[[dict[str, Any]], None],
|
||||
on_toggle_rule: Callable[[dict[str, Any]], None],
|
||||
on_remove_rule: Callable[[dict[str, Any]], None],
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._get_attached_rules = get_attached_rules
|
||||
self._on_create_rule = on_create_rule
|
||||
self._on_edit_rule = on_edit_rule
|
||||
self._on_toggle_rule = on_toggle_rule
|
||||
self._on_remove_rule = on_remove_rule
|
||||
|
||||
self.setupUi(self)
|
||||
self._build_ui()
|
||||
self._refresh_table()
|
||||
|
||||
def _configure_rules_table(self) -> None:
|
||||
self.rules_table.setColumnCount(6)
|
||||
self.rules_table.setHorizontalHeaderLabels(
|
||||
[
|
||||
QC.translate("stats", "Rule"),
|
||||
QC.translate("stats", "Node"),
|
||||
QC.translate("stats", "Status"),
|
||||
QC.translate("stats", "Single sub"),
|
||||
QC.translate("stats", "All"),
|
||||
QC.translate("stats", "Groups"),
|
||||
]
|
||||
)
|
||||
self.rules_table.setEditTriggers(
|
||||
QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers
|
||||
)
|
||||
self.rules_table.setSelectionBehavior(
|
||||
QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows
|
||||
)
|
||||
self.rules_table.setSelectionMode(
|
||||
QtWidgets.QAbstractItemView.SelectionMode.SingleSelection
|
||||
)
|
||||
self.rules_table.setAlternatingRowColors(True)
|
||||
self.rules_table.setSortingEnabled(True)
|
||||
|
||||
vertical_header = self.rules_table.verticalHeader()
|
||||
if vertical_header is not None:
|
||||
vertical_header.setVisible(False)
|
||||
|
||||
header = self.rules_table.horizontalHeader()
|
||||
if header is not None:
|
||||
header.setStretchLastSection(False)
|
||||
header.setSortIndicatorShown(True)
|
||||
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch)
|
||||
for column in (1, 2, 3, 4):
|
||||
header.setSectionResizeMode(
|
||||
column,
|
||||
QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
|
||||
)
|
||||
header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeMode.Interactive)
|
||||
header.setSortIndicator(0, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
|
||||
self.rules_table.setColumnWidth(5, 180)
|
||||
|
||||
def _build_ui(self):
|
||||
_configure_modal_dialog(
|
||||
self,
|
||||
title=QC.translate("stats", "Attached rules"),
|
||||
size=(760, 420),
|
||||
)
|
||||
self._configure_rules_table()
|
||||
self.rules_table.itemDoubleClicked.connect(lambda _item: self._edit_selected())
|
||||
self.rules_table.itemSelectionChanged.connect(self._update_action_buttons)
|
||||
|
||||
self.create_button.setText(QC.translate("stats", "Create rule"))
|
||||
self.edit_button.setText(QC.translate("stats", "Edit selected"))
|
||||
self.toggle_button.setText(QC.translate("stats", "Disable"))
|
||||
self.remove_button.setText(QC.translate("stats", "Remove"))
|
||||
self.close_button.setText(QC.translate("stats", "Close"))
|
||||
|
||||
self.create_button.clicked.connect(self._create_rule)
|
||||
self.edit_button.clicked.connect(self._edit_selected)
|
||||
self.toggle_button.clicked.connect(self._toggle_selected)
|
||||
self.remove_button.clicked.connect(self._remove_selected)
|
||||
self.close_button.clicked.connect(self.accept)
|
||||
|
||||
def _create_rule(self):
|
||||
self.accept()
|
||||
self._on_create_rule()
|
||||
|
||||
def _selected_entry(self):
|
||||
row = self.rules_table.currentRow()
|
||||
if row < 0:
|
||||
return None
|
||||
item = self.rules_table.item(row, 0)
|
||||
if item is None:
|
||||
return None
|
||||
entry = item.data(ATTACHED_RULE_ENTRY_ROLE)
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
addr = str(entry.get("addr", "")).strip()
|
||||
name = str(entry.get("name", "")).strip()
|
||||
if addr == "" or name == "":
|
||||
return None
|
||||
return {
|
||||
"addr": addr,
|
||||
"name": name,
|
||||
"enabled": bool(entry.get("enabled", True)),
|
||||
}
|
||||
|
||||
def _populate_table(self, aggregated_rules: list[dict[str, Any]]):
|
||||
header = self.rules_table.horizontalHeader()
|
||||
sort_column = 0
|
||||
sort_order = QtCore.Qt.SortOrder.AscendingOrder
|
||||
if header is not None:
|
||||
current_sort_column = header.sortIndicatorSection()
|
||||
if 0 <= current_sort_column < self.rules_table.columnCount():
|
||||
sort_column = current_sort_column
|
||||
sort_order = header.sortIndicatorOrder()
|
||||
selected_entry = self._selected_entry()
|
||||
|
||||
self.rules_table.setSortingEnabled(False)
|
||||
self.rules_table.clearContents()
|
||||
self.rules_table.setRowCount(len(aggregated_rules))
|
||||
|
||||
for row, entry in enumerate(aggregated_rules):
|
||||
state_text = (
|
||||
QC.translate("stats", "enabled")
|
||||
if bool(entry.get("enabled", True))
|
||||
else QC.translate("stats", "disabled")
|
||||
)
|
||||
groups_text = ", ".join(entry.get("groups", [])) or "-"
|
||||
row_values = [
|
||||
(
|
||||
QtWidgets.QTableWidgetItem(str(entry.get("name", "")).strip()),
|
||||
None,
|
||||
),
|
||||
(
|
||||
SortableTableWidgetItem(str(entry.get("addr", "")).strip()),
|
||||
str(entry.get("addr", "")).strip().lower(),
|
||||
),
|
||||
(
|
||||
SortableTableWidgetItem(state_text),
|
||||
0 if bool(entry.get("enabled", True)) else 1,
|
||||
),
|
||||
(
|
||||
SortableTableWidgetItem(
|
||||
QC.translate("stats", "yes") if bool(entry.get("single")) else "-"
|
||||
),
|
||||
0 if bool(entry.get("single")) else 1,
|
||||
),
|
||||
(
|
||||
SortableTableWidgetItem(
|
||||
QC.translate("stats", "yes") if bool(entry.get("all")) else "-"
|
||||
),
|
||||
0 if bool(entry.get("all")) else 1,
|
||||
),
|
||||
(
|
||||
SortableTableWidgetItem(groups_text),
|
||||
[group.lower() for group in entry.get("groups", [])],
|
||||
),
|
||||
]
|
||||
for column, (item, sort_key) in enumerate(row_values):
|
||||
if column == 0:
|
||||
item.setData(ATTACHED_RULE_ENTRY_ROLE, entry)
|
||||
if sort_key is not None:
|
||||
item.setData(QtCore.Qt.ItemDataRole.UserRole, sort_key)
|
||||
item.setFlags(item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable)
|
||||
self.rules_table.setItem(row, column, item)
|
||||
|
||||
self.rules_table.setSortingEnabled(True)
|
||||
self.rules_table.sortItems(sort_column, sort_order)
|
||||
|
||||
if self.rules_table.rowCount() <= 0:
|
||||
self._update_action_buttons()
|
||||
return
|
||||
|
||||
if selected_entry is not None:
|
||||
selected_addr = str(selected_entry.get("addr", "")).strip()
|
||||
selected_name = str(selected_entry.get("name", "")).strip()
|
||||
for row in range(self.rules_table.rowCount()):
|
||||
item = self.rules_table.item(row, 0)
|
||||
if item is None:
|
||||
continue
|
||||
entry = item.data(ATTACHED_RULE_ENTRY_ROLE)
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
row_addr = str(entry.get("addr", "")).strip()
|
||||
row_name = str(entry.get("name", "")).strip()
|
||||
if row_addr == selected_addr and row_name == selected_name:
|
||||
self.rules_table.selectRow(row)
|
||||
self._update_action_buttons()
|
||||
return
|
||||
|
||||
self.rules_table.selectRow(0)
|
||||
self._update_action_buttons()
|
||||
|
||||
def _refresh_table(self):
|
||||
self._populate_table(self._get_attached_rules())
|
||||
|
||||
def _update_toggle_button(self):
|
||||
entry = self._selected_entry()
|
||||
if entry is None:
|
||||
self.toggle_button.setEnabled(False)
|
||||
self.toggle_button.setText(QC.translate("stats", "Disable"))
|
||||
return
|
||||
|
||||
self.toggle_button.setEnabled(True)
|
||||
if bool(entry.get("enabled", True)):
|
||||
self.toggle_button.setText(QC.translate("stats", "Disable"))
|
||||
else:
|
||||
self.toggle_button.setText(QC.translate("stats", "Enable"))
|
||||
|
||||
def _update_action_buttons(self):
|
||||
entry = self._selected_entry()
|
||||
has_selection = entry is not None
|
||||
self.edit_button.setEnabled(has_selection)
|
||||
self.remove_button.setEnabled(has_selection)
|
||||
self._update_toggle_button()
|
||||
|
||||
def _edit_selected(self):
|
||||
entry = self._selected_entry()
|
||||
if entry is None:
|
||||
return
|
||||
self._on_edit_rule(entry)
|
||||
|
||||
def _toggle_selected(self):
|
||||
entry = self._selected_entry()
|
||||
if entry is None:
|
||||
return
|
||||
self._on_toggle_rule(entry)
|
||||
self._refresh_table()
|
||||
|
||||
def _remove_selected(self):
|
||||
entry = self._selected_entry()
|
||||
if entry is None:
|
||||
return
|
||||
self._on_remove_rule(entry)
|
||||
self._refresh_table()
|
||||
@@ -0,0 +1,361 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, TYPE_CHECKING, Final
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import (
|
||||
QtCore,
|
||||
QtWidgets,
|
||||
QC,
|
||||
load_ui_type,
|
||||
)
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.helpers import (
|
||||
_apply_section_bar_style,
|
||||
_apply_footer_separator_style,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.widgets.helpers import (
|
||||
_configure_spin_and_units,
|
||||
_set_optional_field_tooltips,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.widgets.toggle_switch_widget import (
|
||||
ToggleSwitch,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.models.global_defaults import (
|
||||
GlobalDefaults,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions._utils import (
|
||||
RES_DIR,
|
||||
INTERVAL_UNITS,
|
||||
TIMEOUT_UNITS,
|
||||
SIZE_UNITS,
|
||||
normalize_group,
|
||||
normalize_groups,
|
||||
)
|
||||
|
||||
|
||||
BULK_EDIT_DIALOG_UI_PATH: Final[str] = os.path.join(RES_DIR, "bulk_edit_dialog.ui")
|
||||
|
||||
BulkEditDialogUI: Final[Any] = load_ui_type(BULK_EDIT_DIALOG_UI_PATH)[0]
|
||||
|
||||
logger: Final[logging.Logger] = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BulkEditDialog(QtWidgets.QDialog, BulkEditDialogUI):
|
||||
if TYPE_CHECKING:
|
||||
rootLayout: QtWidgets.QVBoxLayout
|
||||
buttons_layout: QtWidgets.QHBoxLayout
|
||||
changes_section_bar: QtWidgets.QFrame
|
||||
changes_section_label: QtWidgets.QLabel
|
||||
selection_hint_label: QtWidgets.QLabel
|
||||
changes_tree: QtWidgets.QTreeWidget
|
||||
enabled_value: QtWidgets.QCheckBox
|
||||
group_value: QtWidgets.QComboBox
|
||||
format_value: QtWidgets.QComboBox
|
||||
interval_spin: QtWidgets.QSpinBox
|
||||
interval_units: QtWidgets.QComboBox
|
||||
timeout_spin: QtWidgets.QSpinBox
|
||||
timeout_units: QtWidgets.QComboBox
|
||||
max_size_spin: QtWidgets.QSpinBox
|
||||
max_size_units: QtWidgets.QComboBox
|
||||
error_label: QtWidgets.QLabel
|
||||
footer_separator_line: QtWidgets.QFrame
|
||||
cancel_button: QtWidgets.QPushButton
|
||||
save_button: QtWidgets.QPushButton
|
||||
_defaults: GlobalDefaults
|
||||
_groups: list[str]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget | None,
|
||||
defaults: GlobalDefaults,
|
||||
groups: list[str] | None = None,
|
||||
selected_count: int | None = None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(QC.translate("stats", "Edit selected subscriptions"))
|
||||
self._defaults = defaults
|
||||
self._groups = groups or []
|
||||
self._selected_count = selected_count
|
||||
self._field_items: dict[str, QtWidgets.QTreeWidgetItem] = {}
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
self.setupUi(self)
|
||||
self.error_label.setStyleSheet("color: red;")
|
||||
self.rootLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.rootLayout.setSpacing(0)
|
||||
self.selection_hint_label.setContentsMargins(12, 10, 12, 8)
|
||||
self.changes_tree.setContentsMargins(0, 0, 0, 0)
|
||||
self.buttons_layout.setContentsMargins(12, 10, 12, 12)
|
||||
self.buttons_layout.setSpacing(8)
|
||||
_apply_section_bar_style(
|
||||
self,
|
||||
self.changes_section_bar,
|
||||
self.changes_section_label,
|
||||
)
|
||||
_apply_footer_separator_style(self, self.footer_separator_line)
|
||||
self.error_label.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding,
|
||||
QtWidgets.QSizePolicy.Policy.Preferred,
|
||||
)
|
||||
self.error_label.setAlignment(
|
||||
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
if self._selected_count is not None:
|
||||
self.selection_hint_label.setText(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Choose which changes to apply to {0} selected subscriptions.",
|
||||
).format(self._selected_count)
|
||||
)
|
||||
self.changes_tree.setRootIsDecorated(False)
|
||||
self.changes_tree.setUniformRowHeights(False)
|
||||
self.changes_tree.setItemsExpandable(False)
|
||||
self.changes_tree.setAllColumnsShowFocus(False)
|
||||
self.changes_tree.setIndentation(0)
|
||||
self.changes_tree.setSelectionMode(
|
||||
QtWidgets.QAbstractItemView.SelectionMode.NoSelection
|
||||
)
|
||||
header = self.changes_tree.header()
|
||||
if header is not None:
|
||||
header.setStretchLastSection(True)
|
||||
header.setSectionResizeMode(
|
||||
0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents
|
||||
)
|
||||
header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Stretch)
|
||||
expanding = QtWidgets.QSizePolicy.Policy.Expanding
|
||||
fixed = QtWidgets.QSizePolicy.Policy.Fixed
|
||||
|
||||
self.enabled_value = ToggleSwitch(QC.translate("stats", "Enabled"))
|
||||
self.enabled_value.setSizePolicy(expanding, fixed)
|
||||
|
||||
self.group_value = QtWidgets.QComboBox()
|
||||
self.group_value.setEditable(True)
|
||||
self.group_value.setSizePolicy(expanding, fixed)
|
||||
self.group_value.setEditable(True)
|
||||
self.group_value.setToolTip(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Optional explicit groups. Every subscription is always included in the global 'all' rules directory.",
|
||||
)
|
||||
)
|
||||
self.format_value = QtWidgets.QComboBox()
|
||||
self.format_value.setSizePolicy(expanding, fixed)
|
||||
self.interval_spin = QtWidgets.QSpinBox()
|
||||
self.interval_spin.setSizePolicy(expanding, fixed)
|
||||
self.interval_units = QtWidgets.QComboBox()
|
||||
self.interval_units.setSizePolicy(fixed, fixed)
|
||||
self.timeout_spin = QtWidgets.QSpinBox()
|
||||
self.timeout_spin.setSizePolicy(expanding, fixed)
|
||||
self.timeout_units = QtWidgets.QComboBox()
|
||||
self.timeout_units.setSizePolicy(fixed, fixed)
|
||||
self.max_size_spin = QtWidgets.QSpinBox()
|
||||
self.max_size_spin.setSizePolicy(expanding, fixed)
|
||||
self.max_size_units = QtWidgets.QComboBox()
|
||||
self.max_size_units.setSizePolicy(fixed, fixed)
|
||||
unit_combo_width = 132
|
||||
self.interval_units.setMinimumWidth(unit_combo_width)
|
||||
self.timeout_units.setMinimumWidth(unit_combo_width)
|
||||
self.max_size_units.setMinimumWidth(unit_combo_width)
|
||||
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
self.save_button.clicked.connect(self.validate_then_accept)
|
||||
|
||||
self.enabled_value.setChecked(True)
|
||||
self.group_value.clear()
|
||||
for g in self._groups:
|
||||
ng = normalize_group(g)
|
||||
if ng not in ("", "all"):
|
||||
self.group_value.addItem(ng)
|
||||
self.group_value.setCurrentText("")
|
||||
self.format_value.clear()
|
||||
self.format_value.addItems(("hosts",))
|
||||
_configure_spin_and_units(
|
||||
self.interval_spin,
|
||||
self.interval_units,
|
||||
value=0,
|
||||
unit_value=self._defaults.interval_units,
|
||||
allowed_units=INTERVAL_UNITS,
|
||||
fallback_unit="hours",
|
||||
special_value_text=QC.translate(
|
||||
"stats", "Use global default ({0} {1})"
|
||||
).format(
|
||||
self._defaults.interval,
|
||||
self._defaults.interval_units,
|
||||
),
|
||||
)
|
||||
_configure_spin_and_units(
|
||||
self.timeout_spin,
|
||||
self.timeout_units,
|
||||
value=0,
|
||||
unit_value=self._defaults.timeout_units,
|
||||
allowed_units=TIMEOUT_UNITS,
|
||||
fallback_unit="seconds",
|
||||
special_value_text=QC.translate(
|
||||
"stats", "Use global default ({0} {1})"
|
||||
).format(
|
||||
self._defaults.timeout,
|
||||
self._defaults.timeout_units,
|
||||
),
|
||||
)
|
||||
_configure_spin_and_units(
|
||||
self.max_size_spin,
|
||||
self.max_size_units,
|
||||
value=0,
|
||||
unit_value=self._defaults.max_size_units,
|
||||
allowed_units=SIZE_UNITS,
|
||||
fallback_unit="MB",
|
||||
special_value_text=QC.translate(
|
||||
"stats", "Use global default ({0} {1})"
|
||||
).format(
|
||||
self._defaults.max_size,
|
||||
self._defaults.max_size_units,
|
||||
),
|
||||
)
|
||||
|
||||
self._add_change_row(
|
||||
"enabled", QC.translate("stats", "Enabled"), self.enabled_value
|
||||
)
|
||||
self._add_change_row(
|
||||
"groups", QC.translate("stats", "Groups"), self.group_value
|
||||
)
|
||||
self._add_change_row(
|
||||
"format", QC.translate("stats", "Format"), self.format_value
|
||||
)
|
||||
self._add_change_row(
|
||||
"interval",
|
||||
QC.translate("stats", "Interval"),
|
||||
self._build_compound_editor(self.interval_spin, self.interval_units),
|
||||
)
|
||||
self._add_change_row(
|
||||
"timeout",
|
||||
QC.translate("stats", "Timeout"),
|
||||
self._build_compound_editor(self.timeout_spin, self.timeout_units),
|
||||
)
|
||||
self._add_change_row(
|
||||
"max_size",
|
||||
QC.translate("stats", "Max size"),
|
||||
self._build_compound_editor(self.max_size_spin, self.max_size_units),
|
||||
)
|
||||
self.changes_tree.itemChanged.connect(self.handle_item_changed)
|
||||
|
||||
self.interval_spin.valueChanged.connect(self.sync_optional_fields_state)
|
||||
self.timeout_spin.valueChanged.connect(self.sync_optional_fields_state)
|
||||
self.max_size_spin.valueChanged.connect(self.sync_optional_fields_state)
|
||||
_set_optional_field_tooltips(
|
||||
self.interval_spin,
|
||||
self.interval_units,
|
||||
self.timeout_spin,
|
||||
self.timeout_units,
|
||||
self.max_size_spin,
|
||||
self.max_size_units,
|
||||
inherit_wording=False,
|
||||
)
|
||||
self.sync_apply_fields_state()
|
||||
self.sync_optional_fields_state()
|
||||
self.resize(760, 420)
|
||||
|
||||
def _build_compound_editor(
|
||||
self, primary: QtWidgets.QWidget, secondary: QtWidgets.QWidget
|
||||
):
|
||||
container = QtWidgets.QWidget(self.changes_tree)
|
||||
layout = QtWidgets.QHBoxLayout(container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
layout.addWidget(primary, 1)
|
||||
layout.addWidget(secondary, 0)
|
||||
return container
|
||||
|
||||
def _add_change_row(self, key: str, label: str, editor: QtWidgets.QWidget):
|
||||
item = QtWidgets.QTreeWidgetItem(self.changes_tree)
|
||||
item.setText(0, label)
|
||||
item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable)
|
||||
item.setCheckState(0, QtCore.Qt.CheckState.Unchecked)
|
||||
self.changes_tree.setItemWidget(item, 1, editor)
|
||||
self._field_items[key] = item
|
||||
|
||||
# -- Field apply state --------------------------------------------------
|
||||
|
||||
def is_field_applied(self, key: str) -> bool:
|
||||
item = self._field_items.get(key)
|
||||
if item is None:
|
||||
return False
|
||||
return item.checkState(0) == QtCore.Qt.CheckState.Checked
|
||||
|
||||
def handle_item_changed(self, item: QtWidgets.QTreeWidgetItem, column: int) -> None:
|
||||
if column != 0:
|
||||
return
|
||||
self.sync_apply_fields_state()
|
||||
|
||||
def sync_optional_fields_state(self) -> None:
|
||||
self.interval_units.setEnabled(
|
||||
self.is_field_applied("interval") and self.interval_spin.value() > 0
|
||||
)
|
||||
self.timeout_units.setEnabled(
|
||||
self.is_field_applied("timeout") and self.timeout_spin.value() > 0
|
||||
)
|
||||
self.max_size_units.setEnabled(
|
||||
self.is_field_applied("max_size") and self.max_size_spin.value() > 0
|
||||
)
|
||||
|
||||
def sync_apply_fields_state(self) -> None:
|
||||
self.enabled_value.setEnabled(self.is_field_applied("enabled"))
|
||||
self.group_value.setEnabled(self.is_field_applied("groups"))
|
||||
self.format_value.setEnabled(self.is_field_applied("format"))
|
||||
self.interval_spin.setEnabled(self.is_field_applied("interval"))
|
||||
self.timeout_spin.setEnabled(self.is_field_applied("timeout"))
|
||||
self.max_size_spin.setEnabled(self.is_field_applied("max_size"))
|
||||
self.sync_optional_fields_state()
|
||||
|
||||
# -- Validation ---------------------------------------------------------
|
||||
|
||||
def validate_then_accept(self) -> None:
|
||||
if not any(self.is_field_applied(key) for key in self._field_items):
|
||||
self.error_label.setText(
|
||||
QC.translate("stats", "Select at least one field to apply.")
|
||||
)
|
||||
return
|
||||
self.error_label.setText("")
|
||||
self.accept()
|
||||
|
||||
# -- Result extraction --------------------------------------------------
|
||||
|
||||
def values(self) -> dict[str, Any]:
|
||||
return {
|
||||
"enabled": (
|
||||
self.enabled_value.isChecked()
|
||||
if self.is_field_applied("enabled")
|
||||
else None
|
||||
),
|
||||
"groups": (
|
||||
normalize_groups(self.group_value.currentText())
|
||||
if self.is_field_applied("groups")
|
||||
else None
|
||||
),
|
||||
"format": (
|
||||
(self.format_value.currentText() or "hosts").strip().lower()
|
||||
if self.is_field_applied("format")
|
||||
else None
|
||||
),
|
||||
"apply_interval": self.is_field_applied("interval"),
|
||||
"interval": int(self.interval_spin.value()) or None,
|
||||
"interval_units": (
|
||||
self.interval_units.currentText()
|
||||
if self.interval_spin.value() > 0
|
||||
else None
|
||||
),
|
||||
"apply_timeout": self.is_field_applied("timeout"),
|
||||
"timeout": int(self.timeout_spin.value()) or None,
|
||||
"timeout_units": (
|
||||
self.timeout_units.currentText()
|
||||
if self.timeout_spin.value() > 0
|
||||
else None
|
||||
),
|
||||
"apply_max_size": self.is_field_applied("max_size"),
|
||||
"max_size": int(self.max_size_spin.value()) or None,
|
||||
"max_size_units": (
|
||||
self.max_size_units.currentText()
|
||||
if self.max_size_spin.value() > 0
|
||||
else None
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
from typing import Any
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import (
|
||||
QtCore,
|
||||
QtGui,
|
||||
QtWidgets,
|
||||
QC,
|
||||
)
|
||||
|
||||
|
||||
def _section_border_color_name(widget: QtWidgets.QWidget):
|
||||
dark_palette = (
|
||||
widget.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128
|
||||
)
|
||||
border_role = (
|
||||
QtGui.QPalette.ColorRole.Midlight
|
||||
if dark_palette
|
||||
else QtGui.QPalette.ColorRole.Mid
|
||||
)
|
||||
return widget.palette().color(border_role).name()
|
||||
|
||||
|
||||
def _apply_section_bar_style(
|
||||
widget: QtWidgets.QWidget,
|
||||
container: QtWidgets.QFrame,
|
||||
label: QtWidgets.QLabel,
|
||||
*,
|
||||
right_border: bool = False,
|
||||
expanding_label: bool = False,
|
||||
):
|
||||
dark_palette = (
|
||||
widget.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128
|
||||
)
|
||||
bg_role = (
|
||||
QtGui.QPalette.ColorRole.AlternateBase
|
||||
if dark_palette
|
||||
else QtGui.QPalette.ColorRole.Button
|
||||
)
|
||||
bg = widget.palette().color(bg_role).name()
|
||||
border = _section_border_color_name(widget)
|
||||
text = widget.palette().color(QtGui.QPalette.ColorRole.WindowText).name()
|
||||
font = label.font()
|
||||
font.setPointSizeF(font.pointSizeF() + 1.0)
|
||||
label.setFont(font)
|
||||
container.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding,
|
||||
QtWidgets.QSizePolicy.Policy.Fixed,
|
||||
)
|
||||
label.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding
|
||||
if expanding_label
|
||||
else QtWidgets.QSizePolicy.Policy.Preferred,
|
||||
QtWidgets.QSizePolicy.Policy.Fixed,
|
||||
)
|
||||
border_right = f"border-right: 1px solid {border};" if right_border else ""
|
||||
container.setStyleSheet(
|
||||
"QFrame {"
|
||||
f"background-color: {bg};"
|
||||
f"border-top: 1px solid {border};"
|
||||
f"border-bottom: 1px solid {border};"
|
||||
f"{border_right}"
|
||||
"}"
|
||||
)
|
||||
label.setStyleSheet(
|
||||
"QLabel {"
|
||||
f"color: {text};"
|
||||
"background: transparent;"
|
||||
"padding: 3px 10px;"
|
||||
"border: 0;"
|
||||
"}"
|
||||
)
|
||||
|
||||
|
||||
def _apply_footer_separator_style(widget: QtWidgets.QWidget, separator: QtWidgets.QFrame):
|
||||
dark_palette = (
|
||||
widget.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128
|
||||
)
|
||||
footer_role = (
|
||||
QtGui.QPalette.ColorRole.Midlight
|
||||
if dark_palette
|
||||
else QtGui.QPalette.ColorRole.Dark
|
||||
)
|
||||
footer_color = widget.palette().color(footer_role).name()
|
||||
separator.setFixedHeight(1)
|
||||
separator.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
|
||||
separator.setStyleSheet(
|
||||
f"QFrame {{ color: {footer_color}; background-color: {footer_color}; }}"
|
||||
)
|
||||
|
||||
|
||||
def _configure_modal_dialog(
|
||||
dialog: QtWidgets.QDialog,
|
||||
*,
|
||||
title: str | None = None,
|
||||
size: tuple[int, int] | None = None,
|
||||
):
|
||||
if title is not None:
|
||||
dialog.setWindowTitle(title)
|
||||
dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
|
||||
if size is not None:
|
||||
dialog.resize(size[0], size[1])
|
||||
|
||||
|
||||
def _wire_copy_close_buttons(
|
||||
dialog: QtWidgets.QDialog,
|
||||
copy_button: QtWidgets.QPushButton,
|
||||
close_button: QtWidgets.QPushButton,
|
||||
text_view: Any,
|
||||
):
|
||||
copy_button.setText(QC.translate("stats", "Copy"))
|
||||
close_button.setText(QC.translate("stats", "Close"))
|
||||
copy_button.clicked.connect(lambda: text_view.selectAll())
|
||||
copy_button.clicked.connect(lambda: text_view.copy())
|
||||
close_button.clicked.connect(dialog.accept)
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.helpers import (
|
||||
_apply_section_bar_style,
|
||||
_section_border_color_name,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
|
||||
ListSubscriptionsDialog,
|
||||
)
|
||||
|
||||
|
||||
class InspectorPanel(QtWidgets.QFrame):
|
||||
def __init__(self, *, dialog: "ListSubscriptionsDialog") -> None:
|
||||
super().__init__(dialog)
|
||||
self._dialog: "ListSubscriptionsDialog" = dialog
|
||||
|
||||
def build(self) -> None:
|
||||
dialog: "ListSubscriptionsDialog" = self._dialog
|
||||
dialog._inspect_panel = cast("InspectorPanel", self)
|
||||
dialog._inspect_collapsed = False
|
||||
dialog._inspect_default_width = 420
|
||||
dialog._inspect_has_selection = False
|
||||
|
||||
dialog.tableContentLayout.removeWidget(dialog._table_tab_bar)
|
||||
dialog.tableContentLayout.removeWidget(dialog.table)
|
||||
|
||||
dialog._table_inspect_splitter = QtWidgets.QSplitter(
|
||||
QtCore.Qt.Orientation.Horizontal, dialog
|
||||
)
|
||||
dialog._table_inspect_splitter.setChildrenCollapsible(False)
|
||||
|
||||
left_container: QtWidgets.QWidget = QtWidgets.QWidget(
|
||||
dialog._table_inspect_splitter
|
||||
)
|
||||
left_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout(left_container)
|
||||
left_layout.setContentsMargins(0, 0, 0, 0)
|
||||
left_layout.setSpacing(0)
|
||||
dialog._table_tab_bar.setParent(left_container)
|
||||
dialog.table.setParent(left_container)
|
||||
left_layout.addWidget(dialog._table_tab_bar)
|
||||
left_layout.addWidget(dialog.table, 1)
|
||||
|
||||
self.setParent(dialog._table_inspect_splitter)
|
||||
self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
|
||||
|
||||
inspect_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout(self)
|
||||
inspect_layout.setContentsMargins(0, 0, 0, 0)
|
||||
inspect_layout.setSpacing(0)
|
||||
|
||||
dialog._inspect_header = QtWidgets.QFrame(self)
|
||||
tab_row_height = max(28, dialog._table_tab_bar.sizeHint().height())
|
||||
dialog._inspect_header.setMinimumHeight(tab_row_height)
|
||||
dialog._inspect_header.setMaximumHeight(tab_row_height)
|
||||
|
||||
header: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout(dialog._inspect_header)
|
||||
header.setContentsMargins(12, 0, 12, 0)
|
||||
header.setSpacing(4)
|
||||
dialog._inspect_title_label = QtWidgets.QLabel(QC.translate("stats", "Inspect"))
|
||||
dialog._inspect_title_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||||
_apply_section_bar_style(
|
||||
dialog,
|
||||
dialog._inspect_header,
|
||||
dialog._inspect_title_label,
|
||||
expanding_label=True,
|
||||
)
|
||||
header.addWidget(dialog._inspect_title_label)
|
||||
header.addStretch(1)
|
||||
dialog._inspect_toggle_button = QtWidgets.QToolButton(self)
|
||||
dialog._inspect_toggle_button.setAutoRaise(True)
|
||||
dialog._inspect_toggle_button.clicked.connect(
|
||||
dialog._inspector_controller.toggle_inspector_collapsed
|
||||
)
|
||||
header.setAlignment(dialog._inspect_toggle_button, QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||||
header.addWidget(dialog._inspect_toggle_button)
|
||||
inspect_layout.addWidget(dialog._inspect_header)
|
||||
|
||||
dialog._inspect_header_separator = QtWidgets.QFrame(self)
|
||||
dialog._inspect_header_separator.setFrameShape(QtWidgets.QFrame.Shape.HLine)
|
||||
dialog._inspect_header_separator.setFixedHeight(1)
|
||||
dialog._inspect_header_separator.setStyleSheet(
|
||||
f"background-color: {_section_border_color_name(dialog)}; border: 0;"
|
||||
)
|
||||
inspect_layout.addWidget(dialog._inspect_header_separator)
|
||||
|
||||
dialog._inspect_scroll = QtWidgets.QScrollArea(self)
|
||||
dialog._inspect_scroll.setWidgetResizable(True)
|
||||
dialog._inspect_scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
|
||||
dialog._inspect_scroll.setHorizontalScrollBarPolicy(
|
||||
QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
dialog._inspect_scroll.setVerticalScrollBarPolicy(
|
||||
QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
|
||||
dialog._inspect_body = QtWidgets.QWidget(dialog._inspect_scroll)
|
||||
body_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout(
|
||||
dialog._inspect_body
|
||||
)
|
||||
body_layout.setContentsMargins(8, 6, 8, 8)
|
||||
body_layout.setSpacing(0)
|
||||
body_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
dialog._inspect_details_widget = QtWidgets.QWidget(dialog._inspect_body)
|
||||
details_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout(
|
||||
dialog._inspect_details_widget
|
||||
)
|
||||
details_layout.setContentsMargins(0, 0, 0, 0)
|
||||
details_layout.setSpacing(0)
|
||||
|
||||
form: QtWidgets.QFormLayout = QtWidgets.QFormLayout()
|
||||
form.setLabelAlignment(
|
||||
QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignTop
|
||||
)
|
||||
form.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
|
||||
form.setHorizontalSpacing(10)
|
||||
form.setVerticalSpacing(4)
|
||||
form.setFieldGrowthPolicy(
|
||||
QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow
|
||||
)
|
||||
dialog._inspect_value_labels = {}
|
||||
dialog._inspect_error_button = None
|
||||
dialog._inspect_error_full_text = ""
|
||||
for key, label in (
|
||||
("name", QC.translate("stats", "Name")),
|
||||
("enabled", QC.translate("stats", "Enabled")),
|
||||
("state", QC.translate("stats", "State")),
|
||||
("last_checked", QC.translate("stats", "Last checked")),
|
||||
("last_updated", QC.translate("stats", "Last updated")),
|
||||
("failures", QC.translate("stats", "Failures")),
|
||||
("error", QC.translate("stats", "Error")),
|
||||
("url", QC.translate("stats", "URL")),
|
||||
("filename", QC.translate("stats", "Filename")),
|
||||
("format", QC.translate("stats", "Format")),
|
||||
("groups", QC.translate("stats", "Groups")),
|
||||
("interval", QC.translate("stats", "Interval")),
|
||||
("timeout", QC.translate("stats", "Timeout")),
|
||||
("max_size", QC.translate("stats", "Max size")),
|
||||
("list_path", QC.translate("stats", "List path")),
|
||||
("meta_path", QC.translate("stats", "Meta path")),
|
||||
):
|
||||
key_label: QtWidgets.QLabel = QtWidgets.QLabel(label + ":")
|
||||
key_label.setAlignment(
|
||||
QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignTop
|
||||
)
|
||||
key_label.setMinimumWidth(112)
|
||||
value_label: QtWidgets.QLabel = QtWidgets.QLabel("-")
|
||||
value_label.setWordWrap(True)
|
||||
value_label.setAlignment(
|
||||
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop
|
||||
)
|
||||
value_label.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding,
|
||||
QtWidgets.QSizePolicy.Policy.Preferred,
|
||||
)
|
||||
value_label.setTextInteractionFlags(
|
||||
QtCore.Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
dialog._inspect_value_labels[key] = value_label
|
||||
if key == "error":
|
||||
field_widget: QtWidgets.QWidget = QtWidgets.QWidget(
|
||||
dialog._inspect_details_widget
|
||||
)
|
||||
field_layout: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout(
|
||||
field_widget
|
||||
)
|
||||
field_layout.setContentsMargins(0, 0, 0, 0)
|
||||
field_layout.setSpacing(6)
|
||||
field_layout.addWidget(value_label, 1)
|
||||
if key == "error":
|
||||
inspect_button: QtWidgets.QPushButton = QtWidgets.QPushButton(
|
||||
QC.translate("stats", "Inspect"), field_widget
|
||||
)
|
||||
inspect_button.setVisible(False)
|
||||
inspect_button.clicked.connect(
|
||||
dialog._inspector_controller.show_error_inspect_dialog
|
||||
)
|
||||
dialog._inspect_error_button = inspect_button
|
||||
field_layout.addWidget(inspect_button, 0)
|
||||
form.addRow(key_label, field_widget)
|
||||
else:
|
||||
form.addRow(key_label, value_label)
|
||||
details_layout.addLayout(form)
|
||||
|
||||
dialog._inspect_summary_widget = QtWidgets.QWidget(dialog._inspect_body)
|
||||
summary_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout(
|
||||
dialog._inspect_summary_widget
|
||||
)
|
||||
summary_layout.setContentsMargins(0, 0, 0, 0)
|
||||
summary_layout.setSpacing(6)
|
||||
summary_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
summary_form: QtWidgets.QFormLayout = QtWidgets.QFormLayout()
|
||||
summary_form.setLabelAlignment(
|
||||
QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignTop
|
||||
)
|
||||
summary_form.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
|
||||
summary_form.setHorizontalSpacing(10)
|
||||
summary_form.setVerticalSpacing(4)
|
||||
summary_form.setFieldGrowthPolicy(
|
||||
QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow
|
||||
)
|
||||
dialog._inspect_summary_labels = {}
|
||||
for key, label in (
|
||||
("selected", QC.translate("stats", "Selected")),
|
||||
("enabled", QC.translate("stats", "Enabled")),
|
||||
("healthy", QC.translate("stats", "Healthy")),
|
||||
("pending", QC.translate("stats", "Pending")),
|
||||
("problematic", QC.translate("stats", "Problematic")),
|
||||
("failures", QC.translate("stats", "Total failures")),
|
||||
("with_errors", QC.translate("stats", "With errors")),
|
||||
("newest_checked", QC.translate("stats", "Newest checked")),
|
||||
("oldest_checked", QC.translate("stats", "Oldest checked")),
|
||||
):
|
||||
key_label: QtWidgets.QLabel = QtWidgets.QLabel(label + ":")
|
||||
key_label.setAlignment(
|
||||
QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignTop
|
||||
)
|
||||
key_label.setMinimumWidth(112)
|
||||
value_label: QtWidgets.QLabel = QtWidgets.QLabel("-")
|
||||
value_label.setWordWrap(True)
|
||||
value_label.setAlignment(
|
||||
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop
|
||||
)
|
||||
value_label.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding,
|
||||
QtWidgets.QSizePolicy.Policy.Preferred,
|
||||
)
|
||||
value_label.setTextInteractionFlags(
|
||||
QtCore.Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
dialog._inspect_summary_labels[key] = value_label
|
||||
summary_form.addRow(key_label, value_label)
|
||||
summary_layout.addLayout(summary_form)
|
||||
|
||||
body_layout.addWidget(dialog._inspect_details_widget)
|
||||
body_layout.addWidget(dialog._inspect_summary_widget)
|
||||
dialog._inspector_controller.set_inspector_multi_selection_mode(False)
|
||||
dialog._inspect_scroll.setWidget(dialog._inspect_body)
|
||||
inspect_layout.addWidget(dialog._inspect_scroll, 1)
|
||||
|
||||
dialog.tableContentLayout.addWidget(dialog._table_inspect_splitter)
|
||||
dialog._table_inspect_splitter.setStretchFactor(0, 1)
|
||||
dialog._table_inspect_splitter.setStretchFactor(1, 0)
|
||||
dialog._inspector_controller.set_inspector_visible(False)
|
||||
@@ -0,0 +1,850 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, TYPE_CHECKING, Final
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import (
|
||||
QtCore,
|
||||
QtGui,
|
||||
QtWidgets,
|
||||
QC,
|
||||
load_ui_type,
|
||||
)
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults
|
||||
from opensnitch.actions import Actions
|
||||
from opensnitch.nodes import Nodes
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.helpers import (
|
||||
_section_border_color_name,
|
||||
_apply_section_bar_style,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.widgets.helpers import (
|
||||
_configure_spin_and_units,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.widgets.toggle_switch_widget import (
|
||||
_replace_checkbox_with_toggle,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.widgets.table_widgets import (
|
||||
CenteredCheckDelegate,
|
||||
KeepForegroundOnSelectionDelegate,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.inspector_panel import (
|
||||
InspectorPanel,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.status_controller import (
|
||||
DialogStatusController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.runtime_controller import (
|
||||
RuntimeController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.inspector_controller import (
|
||||
InspectorController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.defaults_ui_controller import (
|
||||
DefaultsUiController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.selection_controller import (
|
||||
SelectionController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.context_menu_controller import (
|
||||
ContextMenuController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.bulk_edit_controller import (
|
||||
BulkEditController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.subscription_status_controller import (
|
||||
SubscriptionStatusController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.action_file_controller import (
|
||||
ActionFileController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.table_data_controller import (
|
||||
TableDataController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.table_view_controller import (
|
||||
TableViewController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.rules_attachment_controller import (
|
||||
RulesAttachmentController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.rules_editor_controller import (
|
||||
RulesEditorController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.subscription_edit_controller import (
|
||||
SubscriptionEditController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions._utils import (
|
||||
ACTION_FILE,
|
||||
DEFAULT_LISTS_DIR,
|
||||
RES_DIR,
|
||||
INTERVAL_UNITS,
|
||||
TIMEOUT_UNITS,
|
||||
SIZE_UNITS,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions._annotations import RulesEditorDialogProto
|
||||
from opensnitch.plugins.list_subscriptions.list_subscriptions import ListSubscriptions
|
||||
|
||||
LIST_SUBSCRIPTIONS_DIALOG_UI_PATH: Final[str] = os.path.join(
|
||||
RES_DIR, "list_subscriptions_dialog.ui"
|
||||
)
|
||||
|
||||
ListSubscriptionsDialogUI: Final[Any] = load_ui_type(LIST_SUBSCRIPTIONS_DIALOG_UI_PATH)[
|
||||
0
|
||||
]
|
||||
|
||||
COL_ENABLED: Final[int] = 0
|
||||
COL_NAME: Final[int] = 1
|
||||
COL_URL: Final[int] = 2
|
||||
COL_FILENAME: Final[int] = 3
|
||||
COL_FORMAT: Final[int] = 4
|
||||
COL_GROUP: Final[int] = 5
|
||||
COL_INTERVAL: Final[int] = 6
|
||||
COL_INTERVAL_UNITS: Final[int] = 7
|
||||
COL_TIMEOUT: Final[int] = 8
|
||||
COL_TIMEOUT_UNITS: Final[int] = 9
|
||||
COL_MAX_SIZE: Final[int] = 10
|
||||
COL_MAX_SIZE_UNITS: Final[int] = 11
|
||||
COL_FILE: Final[int] = 12
|
||||
COL_META: Final[int] = 13
|
||||
COL_STATE: Final[int] = 14
|
||||
COL_RULE_ATTACHED: Final[int] = 15
|
||||
COL_LAST_CHECKED: Final[int] = 16
|
||||
COL_LAST_UPDATED: Final[int] = 17
|
||||
INSPECT_ERROR_PREVIEW_LIMIT: Final[int] = 48
|
||||
STATUS_MESSAGE_PREVIEW_LIMIT: Final[int] = 48
|
||||
STATUS_LOG_LIMIT: Final[int] = 200
|
||||
|
||||
logger: Final[logging.Logger] = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI):
|
||||
subscription_state_refreshed = QtCore.pyqtSignal(str, str, dict)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
rootLayout: QtWidgets.QVBoxLayout
|
||||
topRowLayout: QtWidgets.QHBoxLayout
|
||||
defaultsSectionLayout: QtWidgets.QVBoxLayout
|
||||
defaultsGridLayout: QtWidgets.QGridLayout
|
||||
tableSectionLayout: QtWidgets.QVBoxLayout
|
||||
table_section_bar: QtWidgets.QFrame
|
||||
table_section_label: QtWidgets.QLabel
|
||||
tableContentLayout: QtWidgets.QVBoxLayout
|
||||
actionsRowLayout: QtWidgets.QHBoxLayout
|
||||
actionsSeparatorLayout: QtWidgets.QVBoxLayout
|
||||
globalActionsLayout: QtWidgets.QHBoxLayout
|
||||
ruleActionsLayout: QtWidgets.QHBoxLayout
|
||||
enable_plugin_check: QtWidgets.QCheckBox
|
||||
create_file_button: QtWidgets.QPushButton
|
||||
save_button: QtWidgets.QPushButton
|
||||
reload_button: QtWidgets.QPushButton
|
||||
start_runtime_button: QtWidgets.QPushButton
|
||||
stop_runtime_button: QtWidgets.QPushButton
|
||||
runtime_status_title_label: QtWidgets.QLabel
|
||||
runtime_status_label: QtWidgets.QLabel
|
||||
defaults_section_bar: QtWidgets.QFrame
|
||||
defaults_section_label: QtWidgets.QLabel
|
||||
lists_dir_label: QtWidgets.QLabel
|
||||
lists_dir_edit: QtWidgets.QLineEdit
|
||||
default_interval_label: QtWidgets.QLabel
|
||||
default_interval_spin: QtWidgets.QSpinBox
|
||||
default_interval_units: QtWidgets.QComboBox
|
||||
default_timeout_label: QtWidgets.QLabel
|
||||
default_timeout_spin: QtWidgets.QSpinBox
|
||||
default_timeout_units: QtWidgets.QComboBox
|
||||
default_max_size_label: QtWidgets.QLabel
|
||||
default_max_size_spin: QtWidgets.QSpinBox
|
||||
default_max_size_units: QtWidgets.QComboBox
|
||||
default_user_agent_label: QtWidgets.QLabel
|
||||
default_user_agent: QtWidgets.QLineEdit
|
||||
node_label: QtWidgets.QLabel
|
||||
nodes_combo: QtWidgets.QComboBox
|
||||
table: QtWidgets.QTableWidget
|
||||
global_actions_bar: QtWidgets.QFrame
|
||||
global_actions_label: QtWidgets.QLabel
|
||||
actions_vertical_separator: QtWidgets.QFrame
|
||||
add_sub_button: QtWidgets.QPushButton
|
||||
refresh_state_button: QtWidgets.QPushButton
|
||||
create_global_rule_button: QtWidgets.QPushButton
|
||||
selected_actions_bar: QtWidgets.QFrame
|
||||
selected_actions_label: QtWidgets.QLabel
|
||||
edit_sub_button: QtWidgets.QPushButton
|
||||
remove_sub_button: QtWidgets.QPushButton
|
||||
refresh_now_button: QtWidgets.QPushButton
|
||||
create_rule_button: QtWidgets.QPushButton
|
||||
status_separator_line: QtWidgets.QFrame
|
||||
status_label: QtWidgets.QLabel
|
||||
_status_inspect_button: QtWidgets.QPushButton
|
||||
_nodes: Nodes
|
||||
_actions: Actions
|
||||
_action_path: str
|
||||
_loading: bool
|
||||
_global_defaults: GlobalDefaults
|
||||
_rules_dialog: RulesEditorDialogProto | None
|
||||
_runtime_plugin: ListSubscriptions | None
|
||||
_pending_runtime_reload: str | None
|
||||
_pending_refresh_keys: set[str]
|
||||
_active_refresh_keys: set[str]
|
||||
_status_controller: DialogStatusController
|
||||
_runtime_controller: RuntimeController
|
||||
_defaults_ui_controller: DefaultsUiController
|
||||
_selection_controller: SelectionController
|
||||
_context_menu_controller: ContextMenuController
|
||||
_bulk_edit_controller: BulkEditController
|
||||
_subscription_status_controller: SubscriptionStatusController
|
||||
_action_file_controller: ActionFileController
|
||||
_inspector_controller: InspectorController
|
||||
_table_data_controller: TableDataController
|
||||
_rules_attachment_controller: RulesAttachmentController
|
||||
_rules_editor_controller: RulesEditorController
|
||||
_table_tab_bar: QtWidgets.QTabBar
|
||||
_table_inspect_splitter: QtWidgets.QSplitter
|
||||
_inspect_panel: QtWidgets.QFrame
|
||||
_inspect_header: QtWidgets.QFrame
|
||||
_inspect_title_label: QtWidgets.QLabel
|
||||
_inspect_toggle_button: QtWidgets.QToolButton
|
||||
_inspect_header_separator: QtWidgets.QFrame
|
||||
_inspect_scroll: QtWidgets.QScrollArea
|
||||
_inspect_body: QtWidgets.QWidget
|
||||
_inspect_details_widget: QtWidgets.QWidget
|
||||
_inspect_summary_widget: QtWidgets.QWidget
|
||||
_inspect_value_labels: dict[str, QtWidgets.QLabel]
|
||||
_inspect_summary_labels: dict[str, QtWidgets.QLabel]
|
||||
_inspect_error_button: QtWidgets.QPushButton | None
|
||||
_inspect_error_full_text: str
|
||||
_inspect_collapsed: bool
|
||||
_inspect_default_width: int
|
||||
_inspect_has_selection: bool
|
||||
_user_resized_columns_by_tab: dict[int, set[int]]
|
||||
_applying_table_column_sizing: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget | None = None,
|
||||
appicon: QtGui.QIcon | None = None,
|
||||
):
|
||||
dlg_parent = parent if isinstance(parent, QtWidgets.QWidget) else None
|
||||
super().__init__(dlg_parent)
|
||||
self.setWindowTitle(QC.translate("stats", "List subscriptions"))
|
||||
if appicon is not None:
|
||||
self.setWindowIcon(appicon)
|
||||
|
||||
self._nodes = Nodes.instance()
|
||||
self._actions = Actions.instance()
|
||||
self._action_path = ACTION_FILE
|
||||
self._loading = False
|
||||
self._global_defaults: GlobalDefaults = GlobalDefaults.from_dict(
|
||||
{}, lists_dir=DEFAULT_LISTS_DIR
|
||||
)
|
||||
self._rules_dialog: RulesEditorDialogProto | None = None
|
||||
self._runtime_plugin: ListSubscriptions | None = None
|
||||
self._pending_runtime_reload: str | None = None
|
||||
self._pending_refresh_keys: set[str] = set()
|
||||
self._active_refresh_keys: set[str] = set()
|
||||
self._deferred_close_pending = False
|
||||
self._user_resized_columns_by_tab: dict[int, set[int]] = {}
|
||||
self._applying_table_column_sizing = False
|
||||
self._resize_fill_timer = QtCore.QTimer(self)
|
||||
self._resize_fill_timer.setSingleShot(True)
|
||||
self._resize_fill_timer.setInterval(140)
|
||||
self._resize_fill_timer.timeout.connect(self._apply_table_fill_after_resize)
|
||||
self._build_ui()
|
||||
|
||||
def showEvent(self, event: QtGui.QShowEvent | None): # type: ignore[override]
|
||||
super().showEvent(event)
|
||||
self._action_file_controller.load_action_file()
|
||||
self._table_data_controller.start_poll()
|
||||
# Ensure equal-fill sizing runs after the first layout pass when viewport width is valid.
|
||||
QtCore.QTimer.singleShot(
|
||||
0,
|
||||
lambda: self._table_view_controller.apply_table_column_sizing(
|
||||
self._table_tab_bar.currentIndex()
|
||||
),
|
||||
)
|
||||
|
||||
def _pause_background_workers_for_focus_loss(self) -> None:
|
||||
pause_poll = getattr(self._table_data_controller, "pause_for_focus_loss", None)
|
||||
if callable(pause_poll):
|
||||
pause_poll()
|
||||
else:
|
||||
self._table_data_controller.stop_poll()
|
||||
self._table_data_controller.cancel_active_refresh()
|
||||
|
||||
cancel_snapshot = getattr(
|
||||
self._rules_attachment_controller,
|
||||
"cancel_active_snapshot",
|
||||
None,
|
||||
)
|
||||
if callable(cancel_snapshot):
|
||||
cancel_snapshot()
|
||||
|
||||
def _resume_background_workers_for_focus_gain(self) -> None:
|
||||
resume_poll = getattr(self._table_data_controller, "resume_for_focus_gain", None)
|
||||
if callable(resume_poll):
|
||||
resume_poll()
|
||||
elif self.isVisible() and not self._loading:
|
||||
self._table_data_controller.start_poll()
|
||||
|
||||
def changeEvent(self, event: QtCore.QEvent | None): # type: ignore[override]
|
||||
if (
|
||||
event is not None
|
||||
and event.type() == QtCore.QEvent.Type.ActivationChange
|
||||
):
|
||||
if self.isVisible() and self.isActiveWindow():
|
||||
self._resume_background_workers_for_focus_gain()
|
||||
else:
|
||||
self._pause_background_workers_for_focus_loss()
|
||||
super().changeEvent(event)
|
||||
|
||||
def hideEvent(self, event: QtGui.QHideEvent | None): # type: ignore[override]
|
||||
self._pause_background_workers_for_focus_loss()
|
||||
super().hideEvent(event)
|
||||
|
||||
def resizeEvent(self, event: QtGui.QResizeEvent | None): # type: ignore[override]
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, "_resize_fill_timer"):
|
||||
self._resize_fill_timer.start()
|
||||
|
||||
def _apply_table_fill_after_resize(self) -> None:
|
||||
if not self.isVisible():
|
||||
return
|
||||
if hasattr(self, "_table_view_controller") and hasattr(self, "_table_tab_bar"):
|
||||
self._table_view_controller.apply_table_column_sizing(
|
||||
self._table_tab_bar.currentIndex()
|
||||
)
|
||||
|
||||
def _complete_deferred_close(self) -> None:
|
||||
self._deferred_close_pending = False
|
||||
self.setEnabled(True)
|
||||
self._status_controller.set_status("", error=False, log=False)
|
||||
self.close()
|
||||
|
||||
def closeEvent(self, event: QtGui.QCloseEvent | None): # type: ignore[override]
|
||||
self._table_data_controller.stop_poll()
|
||||
self._pause_background_workers_for_focus_loss()
|
||||
if self._table_data_controller.has_active_refresh():
|
||||
if not self._deferred_close_pending:
|
||||
self._deferred_close_pending = True
|
||||
self.setEnabled(False)
|
||||
self._status_controller.set_status(
|
||||
QC.translate("stats", "Stopping background tasks..."),
|
||||
error=False,
|
||||
log=False,
|
||||
)
|
||||
self._table_data_controller.cancel_active_refresh()
|
||||
self._table_data_controller.on_refresh_stopped(
|
||||
self._complete_deferred_close
|
||||
)
|
||||
if event is not None:
|
||||
event.ignore()
|
||||
return
|
||||
self._status_controller.set_status("", error=False, log=False)
|
||||
super().closeEvent(event)
|
||||
|
||||
def _show_log_dialog(self) -> None:
|
||||
color = self.palette().color(QtGui.QPalette.ColorRole.WindowText)
|
||||
self._status_controller.show_log_dialog(
|
||||
self,
|
||||
title=QC.translate("stats", "Status log"),
|
||||
level_color=self._table_data_controller.status_log_level_color,
|
||||
timestamp_color=f"rgba({color.red()}, {color.green()}, {color.blue()}, 0.55)",
|
||||
)
|
||||
|
||||
def _build_ui(self):
|
||||
self.setupUi(self)
|
||||
self.enable_plugin_check = _replace_checkbox_with_toggle(
|
||||
self.enable_plugin_check
|
||||
)
|
||||
self.enable_plugin_check.setChecked(True)
|
||||
self.enable_plugin_check.setEnabled(False)
|
||||
self.enable_plugin_check.setToolTip(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"The plugin action must remain enabled. OpenSnitch only loads enabled action files on startup.",
|
||||
)
|
||||
)
|
||||
self.setWindowTitle(QC.translate("stats", "List subscriptions"))
|
||||
self.resize(1180, 680)
|
||||
self.rootLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.rootLayout.setSpacing(0)
|
||||
self.rootLayout.setStretch(0, 0)
|
||||
self.rootLayout.setStretch(1, 0)
|
||||
self.rootLayout.setStretch(2, 1)
|
||||
self.rootLayout.setStretch(3, 0)
|
||||
self.rootLayout.setStretch(4, 0)
|
||||
self.topRowLayout.setContentsMargins(12, 10, 12, 4)
|
||||
self.topRowLayout.setSpacing(8)
|
||||
self.topRowLayout.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||||
self.defaultsSectionLayout.setContentsMargins(0, 8, 0, 0)
|
||||
self.defaultsGridLayout.setContentsMargins(12, 10, 12, 10)
|
||||
self.tableSectionLayout.setContentsMargins(0, 8, 0, 0)
|
||||
self.tableContentLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.actionsRowLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.actionsRowLayout.setSpacing(0)
|
||||
self.globalActionsLayout.setContentsMargins(12, 10, 12, 10)
|
||||
self.ruleActionsLayout.setContentsMargins(12, 10, 12, 10)
|
||||
self.status_label.setContentsMargins(12, 8, 12, 8)
|
||||
self.status_label.setAlignment(
|
||||
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
_apply_section_bar_style(
|
||||
self,
|
||||
self.defaults_section_bar,
|
||||
self.defaults_section_label,
|
||||
expanding_label=True,
|
||||
)
|
||||
_apply_section_bar_style(
|
||||
self,
|
||||
self.table_section_bar,
|
||||
self.table_section_label,
|
||||
expanding_label=True,
|
||||
)
|
||||
_apply_section_bar_style(
|
||||
self,
|
||||
self.global_actions_bar,
|
||||
self.global_actions_label,
|
||||
expanding_label=True,
|
||||
)
|
||||
_apply_section_bar_style(
|
||||
self,
|
||||
self.selected_actions_bar,
|
||||
self.selected_actions_label,
|
||||
expanding_label=True,
|
||||
)
|
||||
self.actionsRowLayout.setStretch(0, 1)
|
||||
self.actionsRowLayout.setStretch(2, 1)
|
||||
self.actions_vertical_separator.hide()
|
||||
background_role = (
|
||||
QtGui.QPalette.ColorRole.AlternateBase
|
||||
if self.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128
|
||||
else QtGui.QPalette.ColorRole.Button
|
||||
)
|
||||
section_border_color = _section_border_color_name(self)
|
||||
self.global_actions_bar.setStyleSheet(
|
||||
"QFrame {"
|
||||
f"background-color: {self.palette().color(background_role).name()};"
|
||||
f"border-top: 1px solid {section_border_color};"
|
||||
f"border-bottom: 1px solid {section_border_color};"
|
||||
f"border-right: 1px solid {section_border_color};"
|
||||
"}"
|
||||
)
|
||||
self.runtime_status_label.setContentsMargins(12, 0, 0, 0)
|
||||
self.runtime_status_label.setAlignment(
|
||||
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
self.runtime_status_title_label.setContentsMargins(12, 0, 0, 0)
|
||||
self.runtime_status_title_label.setAlignment(
|
||||
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
runtime_status_title_font = self.runtime_status_title_label.font()
|
||||
runtime_status_title_font.setBold(True)
|
||||
self.runtime_status_title_label.setFont(runtime_status_title_font)
|
||||
footer_role = (
|
||||
QtGui.QPalette.ColorRole.Midlight
|
||||
if self.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128
|
||||
else QtGui.QPalette.ColorRole.Dark
|
||||
)
|
||||
footer_border = self.palette().color(footer_role).name()
|
||||
self.status_separator_line.setStyleSheet(f"color: {footer_border};")
|
||||
self.status_label.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding,
|
||||
QtWidgets.QSizePolicy.Policy.Fixed,
|
||||
)
|
||||
self.status_label.setStyleSheet(
|
||||
f"QLabel {{ background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()}; padding: 8px 12px 8px 12px; }}"
|
||||
)
|
||||
self._status_inspect_button = QtWidgets.QPushButton(
|
||||
QC.translate("stats", "Log"),
|
||||
self,
|
||||
)
|
||||
self._status_inspect_button.setVisible(False)
|
||||
self._status_controller = DialogStatusController(
|
||||
label=self.status_label,
|
||||
inspect_button=self._status_inspect_button,
|
||||
preview_limit=STATUS_MESSAGE_PREVIEW_LIMIT,
|
||||
log_limit=STATUS_LOG_LIMIT,
|
||||
timestamp_format="HH:mm:ss",
|
||||
ok_color="green",
|
||||
error_color="red",
|
||||
empty_button_behavior="show-if-logs",
|
||||
)
|
||||
|
||||
self._status_inspect_button.clicked.connect(self._show_log_dialog)
|
||||
self._runtime_controller = RuntimeController(
|
||||
dialog=self,
|
||||
status_label=self.runtime_status_label,
|
||||
start_button=self.start_runtime_button,
|
||||
stop_button=self.stop_runtime_button,
|
||||
)
|
||||
self._defaults_ui_controller = DefaultsUiController(dialog=self)
|
||||
self._selection_controller = SelectionController(
|
||||
dialog=self,
|
||||
columns={
|
||||
"group": COL_GROUP,
|
||||
},
|
||||
)
|
||||
self._context_menu_controller = ContextMenuController(dialog=self)
|
||||
self._bulk_edit_controller = BulkEditController(
|
||||
dialog=self,
|
||||
columns={
|
||||
"enabled": COL_ENABLED,
|
||||
"group": COL_GROUP,
|
||||
"format": COL_FORMAT,
|
||||
"interval": COL_INTERVAL,
|
||||
"interval_units": COL_INTERVAL_UNITS,
|
||||
"timeout": COL_TIMEOUT,
|
||||
"timeout_units": COL_TIMEOUT_UNITS,
|
||||
"max_size": COL_MAX_SIZE,
|
||||
"max_size_units": COL_MAX_SIZE_UNITS,
|
||||
},
|
||||
)
|
||||
self._subscription_status_controller = SubscriptionStatusController(
|
||||
dialog=self,
|
||||
columns={
|
||||
"name": COL_NAME,
|
||||
"url": COL_URL,
|
||||
"filename": COL_FILENAME,
|
||||
},
|
||||
)
|
||||
self._action_file_controller = ActionFileController(
|
||||
dialog=self,
|
||||
columns={
|
||||
"name": COL_NAME,
|
||||
"filename": COL_FILENAME,
|
||||
},
|
||||
)
|
||||
self._rules_attachment_controller = RulesAttachmentController(dialog=self)
|
||||
self._rules_editor_controller = RulesEditorController(
|
||||
dialog=self,
|
||||
columns={
|
||||
"url": COL_URL,
|
||||
"format": COL_FORMAT,
|
||||
"group": COL_GROUP,
|
||||
},
|
||||
)
|
||||
self._table_data_controller = TableDataController(
|
||||
dialog=self,
|
||||
columns={
|
||||
"enabled": COL_ENABLED,
|
||||
"name": COL_NAME,
|
||||
"url": COL_URL,
|
||||
"filename": COL_FILENAME,
|
||||
"format": COL_FORMAT,
|
||||
"group": COL_GROUP,
|
||||
"interval": COL_INTERVAL,
|
||||
"interval_units": COL_INTERVAL_UNITS,
|
||||
"timeout": COL_TIMEOUT,
|
||||
"timeout_units": COL_TIMEOUT_UNITS,
|
||||
"max_size": COL_MAX_SIZE,
|
||||
"max_size_units": COL_MAX_SIZE_UNITS,
|
||||
"file": COL_FILE,
|
||||
"meta": COL_META,
|
||||
"state": COL_STATE,
|
||||
"rule_attached": COL_RULE_ATTACHED,
|
||||
"last_checked": COL_LAST_CHECKED,
|
||||
"last_updated": COL_LAST_UPDATED,
|
||||
},
|
||||
)
|
||||
status_row = QtWidgets.QWidget(self)
|
||||
status_row.setStyleSheet(
|
||||
"QWidget {"
|
||||
f"border-top: 1px solid {footer_border};"
|
||||
f"background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()};"
|
||||
"}"
|
||||
)
|
||||
status_row_layout = QtWidgets.QHBoxLayout(status_row)
|
||||
status_row_layout.setContentsMargins(12, 0, 12, 0)
|
||||
status_row_layout.setSpacing(8)
|
||||
self.rootLayout.removeWidget(self.status_label)
|
||||
self.status_label.setParent(status_row)
|
||||
status_row_layout.addWidget(
|
||||
self._status_inspect_button,
|
||||
0,
|
||||
QtCore.Qt.AlignmentFlag.AlignVCenter,
|
||||
)
|
||||
status_row_layout.addWidget(self.status_label, 1)
|
||||
self.rootLayout.insertWidget(4, status_row)
|
||||
for widget in (
|
||||
self.enable_plugin_check,
|
||||
self.create_file_button,
|
||||
self.save_button,
|
||||
self.reload_button,
|
||||
self.start_runtime_button,
|
||||
self.stop_runtime_button,
|
||||
self.runtime_status_title_label,
|
||||
self.runtime_status_label,
|
||||
):
|
||||
self.topRowLayout.setAlignment(widget, QtCore.Qt.AlignmentFlag.AlignVCenter)
|
||||
self.table.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
|
||||
self.table.setLineWidth(0)
|
||||
self.table.setContentsMargins(0, 0, 0, 0)
|
||||
self.table.setMinimumHeight(0)
|
||||
self.table.setMaximumHeight(16777215)
|
||||
defaults_label_width = 120
|
||||
for label in (
|
||||
self.lists_dir_label,
|
||||
self.default_interval_label,
|
||||
self.default_timeout_label,
|
||||
self.default_max_size_label,
|
||||
self.default_user_agent_label,
|
||||
self.node_label,
|
||||
):
|
||||
label.setMinimumWidth(defaults_label_width)
|
||||
self.node_label.hide()
|
||||
self.nodes_combo.hide()
|
||||
|
||||
_configure_spin_and_units(
|
||||
self.default_interval_spin,
|
||||
self.default_interval_units,
|
||||
value=1,
|
||||
unit_value="hours",
|
||||
allowed_units=INTERVAL_UNITS,
|
||||
fallback_unit="hours",
|
||||
min_value=1,
|
||||
)
|
||||
_configure_spin_and_units(
|
||||
self.default_timeout_spin,
|
||||
self.default_timeout_units,
|
||||
value=1,
|
||||
unit_value="seconds",
|
||||
allowed_units=TIMEOUT_UNITS,
|
||||
fallback_unit="seconds",
|
||||
min_value=1,
|
||||
)
|
||||
_configure_spin_and_units(
|
||||
self.default_max_size_spin,
|
||||
self.default_max_size_units,
|
||||
value=1,
|
||||
unit_value="MB",
|
||||
allowed_units=SIZE_UNITS,
|
||||
fallback_unit="MB",
|
||||
min_value=1,
|
||||
)
|
||||
|
||||
self.table.setColumnCount(18)
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
[
|
||||
"☑",
|
||||
QC.translate("stats", "Name"),
|
||||
QC.translate("stats", "URL"),
|
||||
QC.translate("stats", "Filename"),
|
||||
QC.translate("stats", "Format"),
|
||||
QC.translate("stats", "Groups"),
|
||||
QC.translate("stats", "Interval"),
|
||||
QC.translate("stats", "Interval units"),
|
||||
QC.translate("stats", "Timeout"),
|
||||
QC.translate("stats", "Timeout units"),
|
||||
QC.translate("stats", "Max size"),
|
||||
QC.translate("stats", "Max size units"),
|
||||
QC.translate("stats", "List file present"),
|
||||
QC.translate("stats", "List meta present"),
|
||||
QC.translate("stats", "State"),
|
||||
QC.translate("stats", "Rule attached"),
|
||||
QC.translate("stats", "Last checked"),
|
||||
QC.translate("stats", "Last updated"),
|
||||
]
|
||||
)
|
||||
for col in (
|
||||
COL_INTERVAL,
|
||||
COL_INTERVAL_UNITS,
|
||||
COL_TIMEOUT,
|
||||
COL_TIMEOUT_UNITS,
|
||||
COL_MAX_SIZE,
|
||||
COL_MAX_SIZE_UNITS,
|
||||
):
|
||||
header_item = self.table.horizontalHeaderItem(col)
|
||||
if header_item is not None:
|
||||
header_item.setToolTip(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Leave blank to inherit the global default for this subscription.",
|
||||
)
|
||||
)
|
||||
self.table.setEditTriggers(
|
||||
QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers
|
||||
)
|
||||
self.table.setSelectionBehavior(
|
||||
QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows
|
||||
)
|
||||
self.table.setSelectionMode(
|
||||
QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection
|
||||
)
|
||||
self.table.setItemDelegateForColumn(
|
||||
COL_ENABLED, CenteredCheckDelegate(self.table)
|
||||
)
|
||||
state_delegate = KeepForegroundOnSelectionDelegate(self.table)
|
||||
for col in (
|
||||
COL_STATE,
|
||||
COL_RULE_ATTACHED,
|
||||
COL_LAST_CHECKED,
|
||||
COL_LAST_UPDATED,
|
||||
):
|
||||
self.table.setItemDelegateForColumn(col, state_delegate)
|
||||
self._table_view_controller = TableViewController(
|
||||
dialog=self,
|
||||
columns={
|
||||
"enabled": COL_ENABLED,
|
||||
"name": COL_NAME,
|
||||
"url": COL_URL,
|
||||
"filename": COL_FILENAME,
|
||||
"format": COL_FORMAT,
|
||||
"group": COL_GROUP,
|
||||
"interval": COL_INTERVAL,
|
||||
"interval_units": COL_INTERVAL_UNITS,
|
||||
"timeout": COL_TIMEOUT,
|
||||
"timeout_units": COL_TIMEOUT_UNITS,
|
||||
"max_size": COL_MAX_SIZE,
|
||||
"max_size_units": COL_MAX_SIZE_UNITS,
|
||||
"file": COL_FILE,
|
||||
"meta": COL_META,
|
||||
"state": COL_STATE,
|
||||
"rule_attached": COL_RULE_ATTACHED,
|
||||
"last_checked": COL_LAST_CHECKED,
|
||||
"last_updated": COL_LAST_UPDATED,
|
||||
},
|
||||
)
|
||||
header = self.table.horizontalHeader()
|
||||
if header is not None:
|
||||
header.setStretchLastSection(False)
|
||||
header.setSortIndicatorShown(True)
|
||||
header.setSortIndicator(COL_ENABLED, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
header.setSectionResizeMode(
|
||||
COL_ENABLED, QtWidgets.QHeaderView.ResizeMode.Fixed
|
||||
)
|
||||
style = self.table.style()
|
||||
if style is not None:
|
||||
indicator_w = style.pixelMetric(
|
||||
QtWidgets.QStyle.PixelMetric.PM_IndicatorWidth,
|
||||
None,
|
||||
self.table,
|
||||
)
|
||||
indicator_h = style.pixelMetric(
|
||||
QtWidgets.QStyle.PixelMetric.PM_IndicatorHeight,
|
||||
None,
|
||||
self.table,
|
||||
)
|
||||
self.table.setColumnWidth(
|
||||
COL_ENABLED, max(indicator_w, indicator_h) + 18
|
||||
)
|
||||
header.setSectionResizeMode(COL_URL, QtWidgets.QHeaderView.ResizeMode.Interactive)
|
||||
header.sectionResized.connect(
|
||||
self._table_view_controller.on_table_section_resized
|
||||
)
|
||||
self.table.setSortingEnabled(True)
|
||||
self.table.sortItems(COL_ENABLED, QtCore.Qt.SortOrder.AscendingOrder)
|
||||
# Keep advanced tuning + verbose metadata available internally but
|
||||
# reduce visible table complexity; edit dialog exposes full details.
|
||||
# Initial column visibility is controlled by TableViewController.
|
||||
for col in (
|
||||
COL_INTERVAL,
|
||||
COL_INTERVAL_UNITS,
|
||||
COL_TIMEOUT,
|
||||
COL_TIMEOUT_UNITS,
|
||||
COL_MAX_SIZE,
|
||||
COL_MAX_SIZE_UNITS,
|
||||
COL_FILE,
|
||||
COL_META,
|
||||
):
|
||||
self.table.setColumnHidden(col, True)
|
||||
|
||||
# Switch between Config and Monitoring table views.
|
||||
self._table_tab_bar = QtWidgets.QTabBar(self)
|
||||
self._table_tab_bar.addTab(QC.translate("stats", "Monitoring"))
|
||||
self._table_tab_bar.addTab(QC.translate("stats", "Config"))
|
||||
self._table_tab_bar.setContentsMargins(12, 4, 12, 0)
|
||||
self._table_tab_bar.currentChanged.connect(
|
||||
self._table_view_controller.on_table_view_tab_changed
|
||||
)
|
||||
self.tableContentLayout.insertWidget(0, self._table_tab_bar)
|
||||
self._inspector_controller = InspectorController(
|
||||
dialog=self,
|
||||
columns={
|
||||
"enabled": COL_ENABLED,
|
||||
"name": COL_NAME,
|
||||
"url": COL_URL,
|
||||
"filename": COL_FILENAME,
|
||||
"format": COL_FORMAT,
|
||||
"group": COL_GROUP,
|
||||
"interval": COL_INTERVAL,
|
||||
"interval_units": COL_INTERVAL_UNITS,
|
||||
"timeout": COL_TIMEOUT,
|
||||
"timeout_units": COL_TIMEOUT_UNITS,
|
||||
"max_size": COL_MAX_SIZE,
|
||||
"max_size_units": COL_MAX_SIZE_UNITS,
|
||||
},
|
||||
error_preview_limit=INSPECT_ERROR_PREVIEW_LIMIT,
|
||||
)
|
||||
self._table_view_controller.on_table_view_tab_changed(0)
|
||||
InspectorPanel(dialog=self).build()
|
||||
self._subscription_edit_controller = SubscriptionEditController(
|
||||
dialog=self,
|
||||
columns={
|
||||
"enabled": COL_ENABLED,
|
||||
"name": COL_NAME,
|
||||
"url": COL_URL,
|
||||
"filename": COL_FILENAME,
|
||||
"format": COL_FORMAT,
|
||||
"group": COL_GROUP,
|
||||
"interval": COL_INTERVAL,
|
||||
"interval_units": COL_INTERVAL_UNITS,
|
||||
"timeout": COL_TIMEOUT,
|
||||
"timeout_units": COL_TIMEOUT_UNITS,
|
||||
"max_size": COL_MAX_SIZE,
|
||||
"max_size_units": COL_MAX_SIZE_UNITS,
|
||||
},
|
||||
)
|
||||
|
||||
self.create_file_button.clicked.connect(
|
||||
self._action_file_controller.create_action_file
|
||||
)
|
||||
self.save_button.clicked.connect(self._action_file_controller.save_action_file)
|
||||
self.reload_button.clicked.connect(
|
||||
self._runtime_controller.reload_runtime_and_config
|
||||
)
|
||||
self.start_runtime_button.clicked.connect(
|
||||
self._runtime_controller.start_runtime_clicked
|
||||
)
|
||||
self.stop_runtime_button.clicked.connect(
|
||||
self._runtime_controller.stop_runtime_clicked
|
||||
)
|
||||
self.add_sub_button.clicked.connect(
|
||||
self._subscription_edit_controller.add_subscription_row
|
||||
)
|
||||
self.create_global_rule_button.clicked.connect(
|
||||
self._rules_editor_controller.create_global_rule
|
||||
)
|
||||
self.edit_sub_button.clicked.connect(
|
||||
self._subscription_edit_controller.edit_action_clicked
|
||||
)
|
||||
self.remove_sub_button.clicked.connect(
|
||||
self._subscription_edit_controller.remove_selected_subscription
|
||||
)
|
||||
self.refresh_state_button.clicked.connect(
|
||||
self._table_data_controller.refresh_all_now
|
||||
)
|
||||
self.refresh_now_button.clicked.connect(
|
||||
self._table_data_controller.refresh_selected_now
|
||||
)
|
||||
self.create_rule_button.clicked.connect(
|
||||
self._selection_controller.open_rules_action
|
||||
)
|
||||
self.table.itemDoubleClicked.connect(
|
||||
self._subscription_edit_controller.handle_table_item_double_clicked
|
||||
)
|
||||
self.table.clicked.connect(self._table_data_controller.handle_table_clicked)
|
||||
self.table.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.table.customContextMenuRequested.connect(
|
||||
self._context_menu_controller.open_table_context_menu
|
||||
)
|
||||
sel_model = self.table.selectionModel()
|
||||
if sel_model is not None:
|
||||
sel_model.selectionChanged.connect(
|
||||
self._inspector_controller.handle_table_selection_changed
|
||||
)
|
||||
self.subscription_state_refreshed.connect(
|
||||
self._inspector_controller.on_subscription_state_refreshed
|
||||
)
|
||||
self._runtime_controller.set_runtime_state(active=False)
|
||||
self._selection_controller.update_selected_actions_state()
|
||||
self._inspector_controller.update_inspector_panel()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import html
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TYPE_CHECKING, Final
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import (
|
||||
QtWidgets,
|
||||
load_ui_type,
|
||||
)
|
||||
|
||||
from opensnitch.plugins.list_subscriptions._utils import RES_DIR
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.helpers import (
|
||||
_configure_modal_dialog,
|
||||
_wire_copy_close_buttons,
|
||||
)
|
||||
|
||||
STATUS_LOG_DIALOG_UI_PATH: Final[str] = os.path.join(RES_DIR, "status_log_dialog.ui")
|
||||
StatusLogDialogUI: Final[Any] = load_ui_type(STATUS_LOG_DIALOG_UI_PATH)[0]
|
||||
|
||||
|
||||
class StatusLogDialog(QtWidgets.QDialog, StatusLogDialogUI):
|
||||
if TYPE_CHECKING:
|
||||
text_view: QtWidgets.QTextEdit
|
||||
copy_button: QtWidgets.QPushButton
|
||||
close_button: QtWidgets.QPushButton
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
*,
|
||||
title: str,
|
||||
lines: list[str],
|
||||
fallback_text: str,
|
||||
level_color: Callable[[str], str],
|
||||
timestamp_color: str,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setupUi(self)
|
||||
_configure_modal_dialog(self, title=title)
|
||||
|
||||
self.text_view.setReadOnly(True)
|
||||
self.text_view.setLineWrapMode(QtWidgets.QTextEdit.LineWrapMode.NoWrap)
|
||||
self.text_view.setFontFamily("monospace")
|
||||
self.update_entries(
|
||||
lines=lines,
|
||||
fallback_text=fallback_text,
|
||||
level_color=level_color,
|
||||
timestamp_color=timestamp_color,
|
||||
)
|
||||
# Scroll to the last entry when dialog is shown
|
||||
scrollbar = self.text_view.verticalScrollBar()
|
||||
if scrollbar is not None:
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
|
||||
_wire_copy_close_buttons(
|
||||
self,
|
||||
self.copy_button,
|
||||
self.close_button,
|
||||
self.text_view,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_near_bottom(scrollbar: QtWidgets.QScrollBar) -> bool:
|
||||
return scrollbar.value() >= (scrollbar.maximum() - 2)
|
||||
|
||||
def update_entries(
|
||||
self,
|
||||
*,
|
||||
lines: list[str],
|
||||
fallback_text: str,
|
||||
level_color: Callable[[str], str],
|
||||
timestamp_color: str,
|
||||
) -> None:
|
||||
scrollbar = self.text_view.verticalScrollBar()
|
||||
if scrollbar is None:
|
||||
display_lines = lines[:]
|
||||
if not display_lines and (fallback_text or "").strip() != "":
|
||||
display_lines = [fallback_text]
|
||||
html_text = self._entries_html(display_lines, level_color, timestamp_color)
|
||||
self.text_view.setHtml(html_text)
|
||||
return
|
||||
|
||||
prev_value = scrollbar.value()
|
||||
follow_tail = self._is_near_bottom(scrollbar)
|
||||
|
||||
display_lines = lines[:]
|
||||
if not display_lines and (fallback_text or "").strip() != "":
|
||||
display_lines = [fallback_text]
|
||||
html_text = self._entries_html(display_lines, level_color, timestamp_color)
|
||||
self.text_view.setHtml(html_text)
|
||||
|
||||
if follow_tail:
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
return
|
||||
|
||||
scrollbar.setValue(min(prev_value, scrollbar.maximum()))
|
||||
|
||||
@staticmethod
|
||||
def _entries_html(
|
||||
lines: list[str],
|
||||
level_color: Callable[[str], str],
|
||||
timestamp_color: str,
|
||||
) -> str:
|
||||
html_lines: list[str] = []
|
||||
for line in lines:
|
||||
text = str(line or "").rstrip("\n")
|
||||
if text == "":
|
||||
html_lines.append("<span> </span>")
|
||||
continue
|
||||
|
||||
level = "INFO"
|
||||
timestamp = ""
|
||||
remainder = text
|
||||
if text.startswith("["):
|
||||
timestamp_end = text.find("]")
|
||||
if timestamp_end > 0:
|
||||
timestamp = text[1:timestamp_end].strip()
|
||||
remainder = text[timestamp_end + 1 :].lstrip()
|
||||
if remainder.startswith("["):
|
||||
level_end = remainder.find("]")
|
||||
if level_end > 0:
|
||||
level = remainder[1:level_end].strip() or "INFO"
|
||||
remainder = remainder[level_end + 1 :].lstrip()
|
||||
|
||||
level_html = html.escape(level)
|
||||
timestamp_html = html.escape(timestamp)
|
||||
message_html = html.escape(remainder.lstrip())
|
||||
color = level_color(level)
|
||||
timestamp_prefix = ""
|
||||
if timestamp_html != "":
|
||||
timestamp_prefix = (
|
||||
f"<span style=\"color: {timestamp_color};\">[{timestamp_html}]</span> "
|
||||
)
|
||||
html_lines.append(
|
||||
"<span>"
|
||||
f"{timestamp_prefix}"
|
||||
f"<span style=\"color: {color}; font-weight: 600;\">[{level_html}]</span> "
|
||||
f"{message_html}"
|
||||
"</span>"
|
||||
)
|
||||
|
||||
body = "<br/>".join(html_lines)
|
||||
return (
|
||||
"<html><body "
|
||||
'style="white-space: pre-wrap; font-family: monospace;">'
|
||||
f"{body}"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
def exec(self) -> int:
|
||||
return int(super().exec())
|
||||
|
||||
def show(self) -> None:
|
||||
super().show()
|
||||
# Scroll to the last entry when dialog is shown
|
||||
scrollbar = self.text_view.verticalScrollBar()
|
||||
if scrollbar is not None:
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
@@ -0,0 +1,535 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, TYPE_CHECKING, Final
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import (
|
||||
QtCore,
|
||||
QtGui,
|
||||
QtWidgets,
|
||||
QC,
|
||||
load_ui_type,
|
||||
)
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults
|
||||
from opensnitch.plugins.list_subscriptions.models.subscriptions import (
|
||||
MutableSubscriptionSpec,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions._utils import (
|
||||
RES_DIR,
|
||||
INTERVAL_UNITS,
|
||||
TIMEOUT_UNITS,
|
||||
SIZE_UNITS,
|
||||
normalize_group,
|
||||
normalize_groups,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.helpers import (
|
||||
_apply_footer_separator_style,
|
||||
_apply_section_bar_style,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.widgets.helpers import (
|
||||
_configure_spin_and_units,
|
||||
_set_optional_field_tooltips,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.widgets.toggle_switch_widget import (
|
||||
_replace_checkbox_with_toggle,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.status_controller import (
|
||||
DialogStatusController,
|
||||
)
|
||||
from opensnitch.plugins.list_subscriptions.ui.controllers.subscription_dialog_controller import (
|
||||
SubscriptionDialogController,
|
||||
)
|
||||
|
||||
SUBSCRIPTION_DIALOG_UI_PATH: Final[str] = os.path.join(
|
||||
RES_DIR, "subscription_dialog.ui"
|
||||
)
|
||||
|
||||
SubscriptionDialogUI: Final[Any] = load_ui_type(SUBSCRIPTION_DIALOG_UI_PATH)[0]
|
||||
DIALOG_MESSAGE_PREVIEW_LIMIT: Final[int] = 48
|
||||
DIALOG_MESSAGE_LOG_LIMIT: Final[int] = 200
|
||||
|
||||
logger: Final[logging.Logger] = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _origin_slug_from_title(title: str) -> str:
|
||||
slug = "-".join((title or "subscription").strip().lower().split())
|
||||
return slug or "subscription"
|
||||
|
||||
|
||||
class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI):
|
||||
_url_test_finished = QtCore.pyqtSignal(bool, str)
|
||||
log_message = QtCore.pyqtSignal(str, str, str) # (message, level, origin)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
rootLayout: QtWidgets.QVBoxLayout
|
||||
bodyLayout: QtWidgets.QHBoxLayout
|
||||
settings_group_layout: QtWidgets.QVBoxLayout
|
||||
settings_section_bar: QtWidgets.QFrame
|
||||
settings_section_label: QtWidgets.QLabel
|
||||
settings_group: QtWidgets.QGroupBox
|
||||
meta_grid: QtWidgets.QGridLayout
|
||||
enabled_check: QtWidgets.QCheckBox
|
||||
buttons_layout: QtWidgets.QHBoxLayout
|
||||
settings_form: QtWidgets.QFormLayout
|
||||
name_label: QtWidgets.QLabel
|
||||
name_edit: QtWidgets.QLineEdit
|
||||
name_error_label: QtWidgets.QLabel
|
||||
url_label: QtWidgets.QLabel
|
||||
url_edit: QtWidgets.QLineEdit
|
||||
url_error_label: QtWidgets.QLabel
|
||||
filename_label: QtWidgets.QLabel
|
||||
filename_edit: QtWidgets.QLineEdit
|
||||
filename_error_label: QtWidgets.QLabel
|
||||
format_label: QtWidgets.QLabel
|
||||
format_combo: QtWidgets.QComboBox
|
||||
groups_label: QtWidgets.QLabel
|
||||
group_combo: QtWidgets.QComboBox
|
||||
interval_label: QtWidgets.QLabel
|
||||
interval_layout: QtWidgets.QHBoxLayout
|
||||
interval_spin: QtWidgets.QSpinBox
|
||||
interval_units: QtWidgets.QComboBox
|
||||
timeout_label: QtWidgets.QLabel
|
||||
timeout_layout: QtWidgets.QHBoxLayout
|
||||
timeout_spin: QtWidgets.QSpinBox
|
||||
timeout_units: QtWidgets.QComboBox
|
||||
max_size_label: QtWidgets.QLabel
|
||||
max_size_layout: QtWidgets.QHBoxLayout
|
||||
max_size_spin: QtWidgets.QSpinBox
|
||||
max_size_units: QtWidgets.QComboBox
|
||||
meta_group_layout: QtWidgets.QVBoxLayout
|
||||
meta_section_bar: QtWidgets.QFrame
|
||||
meta_section_label: QtWidgets.QLabel
|
||||
meta_group: QtWidgets.QGroupBox
|
||||
meta_separator: QtWidgets.QFrame
|
||||
meta_file_present_label: QtWidgets.QLabel
|
||||
meta_file_present: QtWidgets.QLabel
|
||||
meta_meta_present_label: QtWidgets.QLabel
|
||||
meta_meta_present: QtWidgets.QLabel
|
||||
meta_state_label: QtWidgets.QLabel
|
||||
meta_state: QtWidgets.QLabel
|
||||
meta_last_checked_label: QtWidgets.QLabel
|
||||
meta_last_checked: QtWidgets.QLabel
|
||||
meta_last_updated_label: QtWidgets.QLabel
|
||||
meta_last_updated: QtWidgets.QLabel
|
||||
meta_failures_label: QtWidgets.QLabel
|
||||
meta_failures: QtWidgets.QLabel
|
||||
meta_error_label: QtWidgets.QLabel
|
||||
meta_error: QtWidgets.QLabel
|
||||
meta_list_path_label: QtWidgets.QLabel
|
||||
meta_list_path: QtWidgets.QLabel
|
||||
meta_meta_path_label: QtWidgets.QLabel
|
||||
meta_meta_path: QtWidgets.QLabel
|
||||
error_label: QtWidgets.QLabel
|
||||
footer_separator_line: QtWidgets.QFrame
|
||||
test_url_button: QtWidgets.QPushButton
|
||||
cancel_button: QtWidgets.QPushButton
|
||||
add_button: QtWidgets.QPushButton
|
||||
_title: str
|
||||
_defaults: GlobalDefaults
|
||||
_groups: list[str]
|
||||
_sub: MutableSubscriptionSpec
|
||||
_meta: dict[str, str]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget | None,
|
||||
defaults: GlobalDefaults,
|
||||
groups: list[str] | None = None,
|
||||
sub: MutableSubscriptionSpec | dict[str, Any] | None = None,
|
||||
meta: dict[str, str] | None = None,
|
||||
title: str = "Subscription",
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(QC.translate("stats", title))
|
||||
self.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
|
||||
self._title = title
|
||||
self._log_origin = f"ui:{_origin_slug_from_title(title)}"
|
||||
self._defaults = defaults
|
||||
self._groups = groups or []
|
||||
try:
|
||||
if isinstance(sub, MutableSubscriptionSpec):
|
||||
self._sub = sub
|
||||
elif sub is None:
|
||||
parsed_sub = MutableSubscriptionSpec.from_dict(
|
||||
{"enabled": True},
|
||||
defaults=self._defaults,
|
||||
require_url=False,
|
||||
ensure_suffix=False,
|
||||
)
|
||||
if parsed_sub is None:
|
||||
raise ValueError(
|
||||
"default subscription state could not be initialized"
|
||||
)
|
||||
self._sub = parsed_sub
|
||||
else:
|
||||
parsed_sub = MutableSubscriptionSpec.from_dict(
|
||||
sub,
|
||||
defaults=self._defaults,
|
||||
require_url=False,
|
||||
ensure_suffix=False,
|
||||
)
|
||||
if parsed_sub is None:
|
||||
raise ValueError("subscription data could not be initialized")
|
||||
self._sub = parsed_sub
|
||||
except Exception as exc:
|
||||
QtWidgets.QMessageBox.critical(
|
||||
parent,
|
||||
QC.translate("stats", "Subscription Error"),
|
||||
QC.translate(
|
||||
"stats", "Failed to initialize subscription data: {0}"
|
||||
).format(str(exc)),
|
||||
)
|
||||
raise
|
||||
self._meta = meta or {}
|
||||
self._dialog_message_inspect_button: QtWidgets.QPushButton | None = None
|
||||
self._deferred_dialog_result: int | None = None
|
||||
self._build_ui()
|
||||
self.finished.connect(lambda _: self._subscription_dialog_controller.disconnect_signal())
|
||||
|
||||
def hideEvent(self, event: QtGui.QHideEvent | None): # type: ignore[override]
|
||||
self._subscription_dialog_controller.cancel_active_url_test()
|
||||
super().hideEvent(event)
|
||||
|
||||
def _defer_dialog_close(self, result: int) -> bool:
|
||||
if not self._subscription_dialog_controller.has_active_url_test():
|
||||
return False
|
||||
if self._deferred_dialog_result is None:
|
||||
self._deferred_dialog_result = result
|
||||
self.setEnabled(False)
|
||||
self._dialog_message_controller.set_status(
|
||||
QC.translate("stats", "Stopping background tasks..."),
|
||||
error=False,
|
||||
log=False,
|
||||
)
|
||||
self._subscription_dialog_controller.cancel_active_url_test()
|
||||
self._subscription_dialog_controller.on_url_test_stopped(
|
||||
self._complete_deferred_dialog_close
|
||||
)
|
||||
return True
|
||||
|
||||
def _complete_deferred_dialog_close(self) -> None:
|
||||
result = self._deferred_dialog_result
|
||||
if result is None:
|
||||
return
|
||||
self._deferred_dialog_result = None
|
||||
self.setEnabled(True)
|
||||
self._dialog_message_controller.set_status("", error=False, log=False)
|
||||
if result == int(QtWidgets.QDialog.DialogCode.Accepted):
|
||||
super().accept()
|
||||
return
|
||||
super().reject()
|
||||
|
||||
def accept(self) -> None:
|
||||
if self._defer_dialog_close(int(QtWidgets.QDialog.DialogCode.Accepted)):
|
||||
return
|
||||
super().accept()
|
||||
|
||||
def reject(self) -> None:
|
||||
if self._defer_dialog_close(int(QtWidgets.QDialog.DialogCode.Rejected)):
|
||||
return
|
||||
super().reject()
|
||||
|
||||
def closeEvent(self, event: QtGui.QCloseEvent | None): # type: ignore[override]
|
||||
if self._defer_dialog_close(int(QtWidgets.QDialog.DialogCode.Rejected)):
|
||||
if event is not None:
|
||||
event.ignore()
|
||||
return
|
||||
self._dialog_message_controller.set_status("", error=False, log=False)
|
||||
super().closeEvent(event)
|
||||
|
||||
def _build_ui(self):
|
||||
self.setupUi(self)
|
||||
self.enabled_check = _replace_checkbox_with_toggle(self.enabled_check)
|
||||
self.rootLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.rootLayout.setSpacing(0)
|
||||
self.bodyLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.bodyLayout.setSpacing(0)
|
||||
self.settings_group.setStyleSheet(
|
||||
"QGroupBox { border: 0; margin: 0; padding: 0; }"
|
||||
)
|
||||
self.meta_group.setStyleSheet("QGroupBox { border: 0; margin: 0; padding: 0; }")
|
||||
self.settings_group_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.settings_group_layout.setSpacing(0)
|
||||
self.settings_form.setContentsMargins(12, 10, 12, 10)
|
||||
self.settings_form.setVerticalSpacing(14)
|
||||
self.meta_group_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.meta_group_layout.setSpacing(0)
|
||||
self.meta_grid.setContentsMargins(12, 10, 12, 10)
|
||||
self.buttons_layout.setContentsMargins(12, 10, 12, 12)
|
||||
self.buttons_layout.setSpacing(8)
|
||||
self.bodyLayout.setStretch(0, 1)
|
||||
self.bodyLayout.setStretch(1, 1)
|
||||
_apply_section_bar_style(
|
||||
self, self.settings_section_bar, self.settings_section_label
|
||||
)
|
||||
_apply_section_bar_style(
|
||||
self, self.meta_section_bar, self.meta_section_label
|
||||
)
|
||||
_apply_section_bar_style(
|
||||
self,
|
||||
self.settings_section_bar,
|
||||
self.settings_section_label,
|
||||
right_border=True,
|
||||
)
|
||||
_apply_footer_separator_style(self, self.footer_separator_line)
|
||||
footer_role = (
|
||||
QtGui.QPalette.ColorRole.Midlight
|
||||
if self.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128
|
||||
else QtGui.QPalette.ColorRole.Dark
|
||||
)
|
||||
footer_border = self.palette().color(footer_role).name()
|
||||
self.footer_separator_line.setStyleSheet(f"color: {footer_border};")
|
||||
self.error_label.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding,
|
||||
QtWidgets.QSizePolicy.Policy.Fixed,
|
||||
)
|
||||
self.error_label.setStyleSheet(
|
||||
f"QLabel {{ background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()}; padding: 8px 12px 8px 12px; }}"
|
||||
)
|
||||
self.error_label.setAlignment(
|
||||
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
self.error_label.setWordWrap(False)
|
||||
self.error_label.setTextFormat(QtCore.Qt.TextFormat.PlainText)
|
||||
self._dialog_message_controller = DialogStatusController(
|
||||
label=self.error_label,
|
||||
inspect_button=None,
|
||||
preview_limit=DIALOG_MESSAGE_PREVIEW_LIMIT,
|
||||
log_limit=DIALOG_MESSAGE_LOG_LIMIT,
|
||||
timestamp_format="yyyy-MM-ddTHH:mm:ss.zzz",
|
||||
ok_color="#2e7d32",
|
||||
error_color="red",
|
||||
empty_button_behavior="hide",
|
||||
)
|
||||
self._subscription_dialog_controller = SubscriptionDialogController(dialog=self)
|
||||
self._dialog_message_controller.set_status("", error=False)
|
||||
footer_index = self.rootLayout.indexOf(self.footer_separator_line)
|
||||
error_index = self.rootLayout.indexOf(self.error_label)
|
||||
if error_index >= 0:
|
||||
status_row = QtWidgets.QWidget(self)
|
||||
status_row.setStyleSheet(
|
||||
"QWidget {"
|
||||
f"border-top: 1px solid {footer_border};"
|
||||
f"background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()};"
|
||||
"}"
|
||||
)
|
||||
status_row_layout = QtWidgets.QHBoxLayout(status_row)
|
||||
status_row_layout.setContentsMargins(12, 0, 12, 0)
|
||||
status_row_layout.setSpacing(8)
|
||||
self.buttons_layout.removeWidget(self.error_label)
|
||||
self.error_label.setParent(status_row)
|
||||
status_row_layout.addWidget(self.error_label, 1)
|
||||
insert_index = footer_index + 1 if footer_index >= 0 else error_index
|
||||
self.rootLayout.insertWidget(insert_index, status_row)
|
||||
self.settings_form.setFieldGrowthPolicy(
|
||||
QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow
|
||||
)
|
||||
settings_label_width = 96
|
||||
for label in (
|
||||
self.name_label,
|
||||
self.url_label,
|
||||
self.filename_label,
|
||||
self.format_label,
|
||||
self.groups_label,
|
||||
self.interval_label,
|
||||
self.timeout_label,
|
||||
self.max_size_label,
|
||||
):
|
||||
label.setMinimumWidth(settings_label_width)
|
||||
self.enabled_check.setContentsMargins(0, 0, 0, 8)
|
||||
|
||||
unit_combo_width = 132
|
||||
expanding = QtWidgets.QSizePolicy.Policy.Expanding
|
||||
fixed = QtWidgets.QSizePolicy.Policy.Fixed
|
||||
self.format_combo.setSizePolicy(expanding, fixed)
|
||||
self.group_combo.setSizePolicy(expanding, fixed)
|
||||
self.interval_spin.setSizePolicy(expanding, fixed)
|
||||
self.timeout_spin.setSizePolicy(expanding, fixed)
|
||||
self.max_size_spin.setSizePolicy(expanding, fixed)
|
||||
self.interval_units.setSizePolicy(fixed, fixed)
|
||||
self.timeout_units.setSizePolicy(fixed, fixed)
|
||||
self.max_size_units.setSizePolicy(fixed, fixed)
|
||||
self.format_combo.setMinimumWidth(0)
|
||||
self.group_combo.setMinimumWidth(0)
|
||||
self.interval_units.setMinimumWidth(unit_combo_width)
|
||||
self.timeout_units.setMinimumWidth(unit_combo_width)
|
||||
section_font = self.settings_section_label.font()
|
||||
section_font.setPointSize(13)
|
||||
section_font.setBold(True)
|
||||
self.settings_section_label.setFont(section_font)
|
||||
self.meta_section_label.setFont(section_font)
|
||||
self.settings_section_label.setMinimumHeight(32)
|
||||
self.meta_section_label.setMinimumHeight(32)
|
||||
self.max_size_units.setMinimumWidth(unit_combo_width)
|
||||
self.interval_layout.setStretch(0, 1)
|
||||
self.interval_layout.setStretch(1, 0)
|
||||
self.timeout_layout.setStretch(0, 1)
|
||||
self.timeout_layout.setStretch(1, 0)
|
||||
self.max_size_layout.setStretch(0, 1)
|
||||
self.max_size_layout.setStretch(1, 0)
|
||||
|
||||
meta_label_width = 150
|
||||
window_color = self.palette().color(QtGui.QPalette.ColorRole.Window)
|
||||
is_dark_theme = window_color.lightness() < 128
|
||||
separator_role = (
|
||||
QtGui.QPalette.ColorRole.Midlight
|
||||
if is_dark_theme
|
||||
else QtGui.QPalette.ColorRole.Dark
|
||||
)
|
||||
separator_color = self.palette().color(separator_role)
|
||||
separator_css = separator_color.name()
|
||||
self.meta_separator.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
|
||||
self.meta_separator.setFixedWidth(1)
|
||||
self.meta_separator.setStyleSheet(
|
||||
f"background-color: {separator_css}; border: 0; margin-left: 4px; margin-right: 10px;"
|
||||
)
|
||||
meta_label_style = "padding-right: 6px;"
|
||||
for label in (
|
||||
self.meta_file_present_label,
|
||||
self.meta_meta_present_label,
|
||||
self.meta_state_label,
|
||||
self.meta_last_checked_label,
|
||||
self.meta_last_updated_label,
|
||||
self.meta_failures_label,
|
||||
self.meta_error_label,
|
||||
self.meta_list_path_label,
|
||||
self.meta_meta_path_label,
|
||||
):
|
||||
label.setMinimumWidth(meta_label_width)
|
||||
label.setStyleSheet(meta_label_style)
|
||||
|
||||
for label in (
|
||||
self.name_error_label,
|
||||
self.url_error_label,
|
||||
self.filename_error_label,
|
||||
):
|
||||
label.setStyleSheet("color: red;")
|
||||
label.setText("")
|
||||
self.group_combo.setEditable(True)
|
||||
self.group_combo.setToolTip(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Optional explicit groups. Every subscription is always included in the global 'all' rules directory.",
|
||||
)
|
||||
)
|
||||
self._url_test_finished.connect(self._subscription_dialog_controller.handle_url_test_finished)
|
||||
self.add_button.clicked.connect(self._subscription_dialog_controller.validate_then_accept)
|
||||
self.test_url_button.clicked.connect(self._subscription_dialog_controller.test_url)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
|
||||
self.enabled_check.setChecked(bool(self._sub.enabled))
|
||||
self.name_edit.setText(str(self._sub.name))
|
||||
self.url_edit.setText(str(self._sub.url))
|
||||
self.filename_edit.setText(str(self._sub.filename))
|
||||
self.format_combo.clear()
|
||||
self.format_combo.addItems(("hosts",))
|
||||
self.format_combo.setCurrentText(str(self._sub.format or "hosts"))
|
||||
for g in self._groups:
|
||||
ng = normalize_group(g)
|
||||
if ng not in ("", "all"):
|
||||
self.group_combo.addItem(ng)
|
||||
current_groups = normalize_groups(self._sub.groups)
|
||||
current_group_text = ", ".join(current_groups)
|
||||
if (
|
||||
current_group_text != ""
|
||||
and self.group_combo.findText(current_group_text) < 0
|
||||
):
|
||||
self.group_combo.addItem(current_group_text)
|
||||
self.group_combo.setCurrentText(current_group_text)
|
||||
_configure_spin_and_units(
|
||||
self.interval_spin,
|
||||
self.interval_units,
|
||||
value=int(self._sub.interval or 0),
|
||||
unit_value=str(self._sub.interval_units or self._defaults.interval_units),
|
||||
allowed_units=INTERVAL_UNITS,
|
||||
fallback_unit="hours",
|
||||
special_value_text=QC.translate(
|
||||
"stats", "Use global default ({0} {1})"
|
||||
).format(
|
||||
self._defaults.interval,
|
||||
self._defaults.interval_units,
|
||||
),
|
||||
)
|
||||
_configure_spin_and_units(
|
||||
self.timeout_spin,
|
||||
self.timeout_units,
|
||||
value=int(self._sub.timeout or 0),
|
||||
unit_value=str(self._sub.timeout_units or self._defaults.timeout_units),
|
||||
allowed_units=TIMEOUT_UNITS,
|
||||
fallback_unit="seconds",
|
||||
special_value_text=QC.translate(
|
||||
"stats", "Use global default ({0} {1})"
|
||||
).format(
|
||||
self._defaults.timeout,
|
||||
self._defaults.timeout_units,
|
||||
),
|
||||
)
|
||||
_configure_spin_and_units(
|
||||
self.max_size_spin,
|
||||
self.max_size_units,
|
||||
value=int(self._sub.max_size or 0),
|
||||
unit_value=str(self._sub.max_size_units or self._defaults.max_size_units),
|
||||
allowed_units=SIZE_UNITS,
|
||||
fallback_unit="MB",
|
||||
special_value_text=QC.translate(
|
||||
"stats", "Use global default ({0} {1})"
|
||||
).format(
|
||||
self._defaults.max_size,
|
||||
self._defaults.max_size_units,
|
||||
),
|
||||
)
|
||||
self.interval_spin.valueChanged.connect(self._subscription_dialog_controller.sync_optional_fields_state)
|
||||
self.timeout_spin.valueChanged.connect(self._subscription_dialog_controller.sync_optional_fields_state)
|
||||
self.max_size_spin.valueChanged.connect(self._subscription_dialog_controller.sync_optional_fields_state)
|
||||
_set_optional_field_tooltips(
|
||||
self.interval_spin,
|
||||
self.interval_units,
|
||||
self.timeout_spin,
|
||||
self.timeout_units,
|
||||
self.max_size_spin,
|
||||
self.max_size_units,
|
||||
inherit_wording=True,
|
||||
)
|
||||
self._subscription_dialog_controller.sync_optional_fields_state()
|
||||
self.meta_file_present.setText(str(self._meta.get("file_present", "")))
|
||||
self.meta_meta_present.setText(str(self._meta.get("meta_present", "")))
|
||||
self.meta_state.setText(str(self._meta.get("state", "")))
|
||||
self._subscription_dialog_controller.apply_meta_state_color(str(self._meta.get("state", "")))
|
||||
self.meta_last_checked.setText(str(self._meta.get("last_checked", "")))
|
||||
self.meta_last_updated.setText(str(self._meta.get("last_updated", "")))
|
||||
self.meta_failures.setText(str(self._meta.get("failures", "")))
|
||||
self.meta_error.setText(str(self._meta.get("error", "")))
|
||||
self.meta_list_path.setText(str(self._meta.get("list_path", "")))
|
||||
self.meta_meta_path.setText(str(self._meta.get("meta_path", "")))
|
||||
if "new" in (self._title or "").strip().lower():
|
||||
self.meta_group.setVisible(False)
|
||||
self.resize(920, 420)
|
||||
|
||||
def subscription_spec(self):
|
||||
groups = normalize_groups((self.group_combo.currentText() or "").strip())
|
||||
return MutableSubscriptionSpec(
|
||||
enabled=self.enabled_check.isChecked(),
|
||||
name=(self.name_edit.text() or "").strip(),
|
||||
url=(self.url_edit.text() or "").strip(),
|
||||
filename=(self.filename_edit.text() or "").strip(),
|
||||
format=(self.format_combo.currentText() or "hosts").strip().lower(),
|
||||
groups=groups,
|
||||
interval=int(self.interval_spin.value()) or None,
|
||||
interval_units=(
|
||||
self.interval_units.currentText()
|
||||
if self.interval_spin.value() > 0
|
||||
else None
|
||||
),
|
||||
timeout=int(self.timeout_spin.value()) or None,
|
||||
timeout_units=(
|
||||
self.timeout_units.currentText()
|
||||
if self.timeout_spin.value() > 0
|
||||
else None
|
||||
),
|
||||
max_size=int(self.max_size_spin.value()) or None,
|
||||
max_size_units=(
|
||||
self.max_size_units.currentText()
|
||||
if self.max_size_spin.value() > 0
|
||||
else None
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
import os
|
||||
from typing import Any, Final, TYPE_CHECKING
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import (
|
||||
QtCore,
|
||||
QtWidgets,
|
||||
QC,
|
||||
load_ui_type,
|
||||
)
|
||||
|
||||
from opensnitch.plugins.list_subscriptions._utils import RES_DIR
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.helpers import _configure_modal_dialog
|
||||
|
||||
SUBSCRIPTION_STATUS_DIALOG_UI_PATH: Final[str] = os.path.join(
|
||||
RES_DIR, "subscription_status_dialog.ui"
|
||||
)
|
||||
|
||||
SubscriptionStatusDialogUI: Final[Any] = load_ui_type(
|
||||
SUBSCRIPTION_STATUS_DIALOG_UI_PATH
|
||||
)[0]
|
||||
|
||||
|
||||
class SubscriptionStatusDialog(QtWidgets.QDialog, SubscriptionStatusDialogUI):
|
||||
ACTION_NONE: Final[str] = "none"
|
||||
ACTION_EDIT: Final[str] = "edit"
|
||||
ACTION_REFRESH: Final[str] = "refresh"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
title_label: QtWidgets.QLabel
|
||||
details_scroll: QtWidgets.QScrollArea
|
||||
details_container: QtWidgets.QWidget
|
||||
buttons_layout: QtWidgets.QHBoxLayout
|
||||
refresh_button: QtWidgets.QPushButton
|
||||
edit_button: QtWidgets.QPushButton
|
||||
close_button: QtWidgets.QPushButton
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget | None,
|
||||
name: str,
|
||||
url: str,
|
||||
filename: str,
|
||||
meta: dict[str, str],
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setupUi(self)
|
||||
self._action = self.ACTION_NONE
|
||||
self._url = url
|
||||
self._filename = filename
|
||||
self._value_labels: dict[str, QtWidgets.QLabel] = {}
|
||||
self._refresh_signal: Any = None
|
||||
_configure_modal_dialog(
|
||||
self,
|
||||
title=QC.translate("stats", "Subscription status"),
|
||||
size=(700, 440),
|
||||
)
|
||||
|
||||
self.title_label.setText(name or filename or url)
|
||||
title_font = self.title_label.font()
|
||||
title_font.setBold(True)
|
||||
self.title_label.setFont(title_font)
|
||||
|
||||
details = QtWidgets.QFormLayout(self.details_container)
|
||||
details.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignRight)
|
||||
details.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
|
||||
details.setHorizontalSpacing(10)
|
||||
details.setVerticalSpacing(6)
|
||||
|
||||
self._add_value_row(details, "url", QC.translate("stats", "URL"), url)
|
||||
self._add_value_row(
|
||||
details,
|
||||
"filename",
|
||||
QC.translate("stats", "Filename"),
|
||||
filename,
|
||||
)
|
||||
self._add_value_row(
|
||||
details,
|
||||
"file_present",
|
||||
QC.translate("stats", "List file present"),
|
||||
meta.get("file_present", ""),
|
||||
)
|
||||
self._add_value_row(
|
||||
details,
|
||||
"meta_present",
|
||||
QC.translate("stats", "List meta present"),
|
||||
meta.get("meta_present", ""),
|
||||
)
|
||||
self._add_value_row(
|
||||
details,
|
||||
"state",
|
||||
QC.translate("stats", "State"),
|
||||
meta.get("state", ""),
|
||||
)
|
||||
self._add_value_row(
|
||||
details,
|
||||
"last_checked",
|
||||
QC.translate("stats", "Last checked"),
|
||||
meta.get("last_checked", ""),
|
||||
)
|
||||
self._add_value_row(
|
||||
details,
|
||||
"last_updated",
|
||||
QC.translate("stats", "Last updated"),
|
||||
meta.get("last_updated", ""),
|
||||
)
|
||||
self._add_value_row(
|
||||
details,
|
||||
"failures",
|
||||
QC.translate("stats", "Failures"),
|
||||
meta.get("failures", ""),
|
||||
)
|
||||
self._add_value_row(
|
||||
details,
|
||||
"error",
|
||||
QC.translate("stats", "Error"),
|
||||
meta.get("error", ""),
|
||||
)
|
||||
self._add_value_row(
|
||||
details,
|
||||
"list_path",
|
||||
QC.translate("stats", "List path"),
|
||||
meta.get("list_path", ""),
|
||||
)
|
||||
self._add_value_row(
|
||||
details,
|
||||
"meta_path",
|
||||
QC.translate("stats", "Meta path"),
|
||||
meta.get("meta_path", ""),
|
||||
)
|
||||
|
||||
self.refresh_button.setText(QC.translate("stats", "Refresh"))
|
||||
self.edit_button.setText(QC.translate("stats", "Edit"))
|
||||
self.close_button.setText(QC.translate("stats", "Close"))
|
||||
self.refresh_button.clicked.connect(self.on_refresh)
|
||||
self.edit_button.clicked.connect(self.on_edit)
|
||||
self.close_button.clicked.connect(self.reject)
|
||||
|
||||
def reject(self):
|
||||
self.disconnect_signal()
|
||||
super().reject()
|
||||
|
||||
def action(self):
|
||||
return self._action
|
||||
|
||||
# -- Meta refresh -------------------------------------------------------
|
||||
|
||||
def connect_to_refresh_signal(self, signal: Any) -> None:
|
||||
self._refresh_signal = signal
|
||||
signal.connect(self.on_state_refreshed)
|
||||
|
||||
def on_state_refreshed(self, url: str, filename: str, meta: dict[str, str]) -> None:
|
||||
if url != self._url or filename != self._filename:
|
||||
return
|
||||
self.update_meta(meta)
|
||||
|
||||
def update_meta(self, meta: dict[str, str]) -> None:
|
||||
fields = (
|
||||
"file_present",
|
||||
"meta_present",
|
||||
"state",
|
||||
"last_checked",
|
||||
"last_updated",
|
||||
"failures",
|
||||
"error",
|
||||
"list_path",
|
||||
"meta_path",
|
||||
)
|
||||
for key in fields:
|
||||
label = self._value_labels.get(key)
|
||||
if label is None:
|
||||
continue
|
||||
label.setText((meta.get(key, "") or "-").strip() or "-")
|
||||
|
||||
def disconnect_signal(self) -> None:
|
||||
if self._refresh_signal is not None:
|
||||
try:
|
||||
self._refresh_signal.disconnect(self.on_state_refreshed)
|
||||
except Exception:
|
||||
pass
|
||||
self._refresh_signal = None
|
||||
|
||||
# -- Actions ------------------------------------------------------------
|
||||
|
||||
def on_refresh(self) -> None:
|
||||
self.disconnect_signal()
|
||||
self._action = self.ACTION_REFRESH
|
||||
self.accept()
|
||||
|
||||
def on_edit(self) -> None:
|
||||
self.disconnect_signal()
|
||||
self._action = self.ACTION_EDIT
|
||||
self.accept()
|
||||
@@ -0,0 +1,54 @@
|
||||
import os
|
||||
from typing import Any, TYPE_CHECKING, Final
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import (
|
||||
QtWidgets,
|
||||
load_ui_type,
|
||||
)
|
||||
|
||||
from opensnitch.plugins.list_subscriptions._utils import RES_DIR
|
||||
from opensnitch.plugins.list_subscriptions.ui.views.helpers import (
|
||||
_configure_modal_dialog,
|
||||
_wire_copy_close_buttons,
|
||||
)
|
||||
|
||||
TEXT_INSPECT_DIALOG_UI_PATH: Final[str] = os.path.join(RES_DIR, "text_inspect_dialog.ui")
|
||||
TextInspectDialogUI: Final[Any] = load_ui_type(TEXT_INSPECT_DIALOG_UI_PATH)[0]
|
||||
|
||||
|
||||
class TextInspectDialog(QtWidgets.QDialog, TextInspectDialogUI):
|
||||
if TYPE_CHECKING:
|
||||
text_view: QtWidgets.QPlainTextEdit
|
||||
copy_button: QtWidgets.QPushButton
|
||||
close_button: QtWidgets.QPushButton
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QtWidgets.QWidget,
|
||||
*,
|
||||
title: str,
|
||||
text: str,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._has_content = (text or "").strip() != ""
|
||||
if not self._has_content:
|
||||
return
|
||||
|
||||
self.setupUi(self)
|
||||
_configure_modal_dialog(self, title=title)
|
||||
|
||||
self.text_view.setReadOnly(True)
|
||||
self.text_view.setLineWrapMode(QtWidgets.QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
self.text_view.setPlainText(text)
|
||||
|
||||
_wire_copy_close_buttons(
|
||||
self,
|
||||
self.copy_button,
|
||||
self.close_button,
|
||||
self.text_view,
|
||||
)
|
||||
|
||||
def exec(self) -> int:
|
||||
if not self._has_content:
|
||||
return int(QtWidgets.QDialog.DialogCode.Rejected)
|
||||
return int(super().exec())
|
||||
@@ -0,0 +1,91 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC
|
||||
|
||||
|
||||
def _set_optional_field_tooltips(
|
||||
interval_spin: QtWidgets.QWidget,
|
||||
interval_units: QtWidgets.QWidget,
|
||||
timeout_spin: QtWidgets.QWidget,
|
||||
timeout_units: QtWidgets.QWidget,
|
||||
max_size_spin: QtWidgets.QWidget,
|
||||
max_size_units: QtWidgets.QWidget,
|
||||
*,
|
||||
inherit_wording: bool,
|
||||
):
|
||||
if inherit_wording:
|
||||
interval_spin.setToolTip(
|
||||
QC.translate("stats", "Set to 0 to inherit the global interval.")
|
||||
)
|
||||
interval_units.setToolTip(
|
||||
QC.translate("stats", "Used only when the interval override is set.")
|
||||
)
|
||||
timeout_spin.setToolTip(
|
||||
QC.translate("stats", "Set to 0 to inherit the global timeout.")
|
||||
)
|
||||
timeout_units.setToolTip(
|
||||
QC.translate("stats", "Used only when the timeout override is set.")
|
||||
)
|
||||
max_size_spin.setToolTip(
|
||||
QC.translate("stats", "Set to 0 to inherit the global max size.")
|
||||
)
|
||||
max_size_units.setToolTip(
|
||||
QC.translate("stats", "Used only when the max size override is set.")
|
||||
)
|
||||
return
|
||||
interval_spin.setToolTip(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Set to 0 to clear the interval override and use the global default.",
|
||||
)
|
||||
)
|
||||
interval_units.setToolTip(
|
||||
QC.translate("stats", "Used only when an interval override is applied.")
|
||||
)
|
||||
timeout_spin.setToolTip(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Set to 0 to clear the timeout override and use the global default.",
|
||||
)
|
||||
)
|
||||
timeout_units.setToolTip(
|
||||
QC.translate("stats", "Used only when a timeout override is applied.")
|
||||
)
|
||||
max_size_spin.setToolTip(
|
||||
QC.translate(
|
||||
"stats",
|
||||
"Set to 0 to clear the max size override and use the global default.",
|
||||
)
|
||||
)
|
||||
max_size_units.setToolTip(
|
||||
QC.translate("stats", "Used only when a max size override is applied.")
|
||||
)
|
||||
|
||||
|
||||
def _configure_spin_and_units(
|
||||
spin: QtWidgets.QSpinBox,
|
||||
units_combo: QtWidgets.QComboBox,
|
||||
*,
|
||||
value: int,
|
||||
unit_value: str | None,
|
||||
allowed_units: Sequence[str],
|
||||
fallback_unit: str,
|
||||
min_value: int = 0,
|
||||
max_value: int = 999999,
|
||||
special_value_text: str | None = None,
|
||||
):
|
||||
spin.setRange(min_value, max_value)
|
||||
if special_value_text is not None:
|
||||
spin.setSpecialValueText(special_value_text)
|
||||
spin.setValue(max(min_value, int(value)))
|
||||
units_combo.clear()
|
||||
units_combo.addItems(tuple(allowed_units))
|
||||
normalized = (unit_value or "").strip().lower()
|
||||
current_unit = fallback_unit
|
||||
for unit in allowed_units:
|
||||
if unit.lower() == normalized:
|
||||
current_unit = unit
|
||||
break
|
||||
units_combo.setCurrentText(
|
||||
current_unit
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtGui, QtWidgets
|
||||
|
||||
|
||||
class KeepForegroundOnSelectionDelegate(QtWidgets.QStyledItemDelegate):
|
||||
def initStyleOption(
|
||||
self,
|
||||
option: QtWidgets.QStyleOptionViewItem | None,
|
||||
index: QtCore.QModelIndex,
|
||||
):
|
||||
super().initStyleOption(option, index)
|
||||
if option is None or index is None:
|
||||
return
|
||||
foreground = index.data(QtCore.Qt.ItemDataRole.ForegroundRole)
|
||||
if foreground is None:
|
||||
return
|
||||
brush = (
|
||||
foreground
|
||||
if isinstance(foreground, QtGui.QBrush)
|
||||
else QtGui.QBrush(foreground)
|
||||
)
|
||||
option.palette.setBrush(
|
||||
QtGui.QPalette.ColorRole.Text,
|
||||
brush,
|
||||
)
|
||||
option.palette.setBrush(
|
||||
QtGui.QPalette.ColorRole.HighlightedText,
|
||||
brush,
|
||||
)
|
||||
|
||||
|
||||
class CenteredCheckDelegate(QtWidgets.QStyledItemDelegate):
|
||||
def _indicator_rect(
|
||||
self,
|
||||
option: QtWidgets.QStyleOptionViewItem,
|
||||
) -> QtCore.QRect:
|
||||
style = (
|
||||
option.widget.style()
|
||||
if option.widget is not None
|
||||
else QtWidgets.QApplication.style()
|
||||
)
|
||||
if style is None:
|
||||
return option.rect
|
||||
indicator_rect = style.subElementRect(
|
||||
QtWidgets.QStyle.SubElement.SE_ItemViewItemCheckIndicator,
|
||||
option,
|
||||
option.widget,
|
||||
)
|
||||
return QtCore.QRect(
|
||||
option.rect.x() + (option.rect.width() - indicator_rect.width()) // 2,
|
||||
option.rect.y() + (option.rect.height() - indicator_rect.height()) // 2,
|
||||
indicator_rect.width(),
|
||||
indicator_rect.height(),
|
||||
)
|
||||
|
||||
def initStyleOption(
|
||||
self,
|
||||
option: QtWidgets.QStyleOptionViewItem | None,
|
||||
index: QtCore.QModelIndex,
|
||||
) -> None:
|
||||
super().initStyleOption(option, index)
|
||||
if option is None:
|
||||
return
|
||||
option.displayAlignment = QtCore.Qt.AlignmentFlag.AlignCenter
|
||||
|
||||
def paint(
|
||||
self,
|
||||
painter: QtGui.QPainter | None,
|
||||
option: QtWidgets.QStyleOptionViewItem,
|
||||
index: QtCore.QModelIndex,
|
||||
) -> None:
|
||||
if painter is None:
|
||||
return
|
||||
opt = QtWidgets.QStyleOptionViewItem(option)
|
||||
self.initStyleOption(opt, index)
|
||||
if not (
|
||||
opt.features
|
||||
& QtWidgets.QStyleOptionViewItem.ViewItemFeature.HasCheckIndicator
|
||||
):
|
||||
super().paint(painter, option, index)
|
||||
return
|
||||
|
||||
style = (
|
||||
opt.widget.style()
|
||||
if opt.widget is not None
|
||||
else QtWidgets.QApplication.style()
|
||||
)
|
||||
if style is None:
|
||||
return
|
||||
|
||||
draw_opt = QtWidgets.QStyleOptionViewItem(opt)
|
||||
draw_opt.features &= (
|
||||
~QtWidgets.QStyleOptionViewItem.ViewItemFeature.HasCheckIndicator
|
||||
)
|
||||
draw_opt.text = ""
|
||||
draw_opt.checkState = QtCore.Qt.CheckState.Unchecked
|
||||
style.drawControl(
|
||||
QtWidgets.QStyle.ControlElement.CE_ItemViewItem,
|
||||
draw_opt,
|
||||
painter,
|
||||
draw_opt.widget,
|
||||
)
|
||||
|
||||
indicator_opt = QtWidgets.QStyleOptionViewItem(opt)
|
||||
indicator_opt.rect = self._indicator_rect(opt)
|
||||
indicator_opt.state &= ~(
|
||||
QtWidgets.QStyle.StateFlag.State_On
|
||||
| QtWidgets.QStyle.StateFlag.State_Off
|
||||
| QtWidgets.QStyle.StateFlag.State_NoChange
|
||||
)
|
||||
check_state_raw = index.data(QtCore.Qt.ItemDataRole.CheckStateRole)
|
||||
check_state = (
|
||||
int(check_state_raw.value)
|
||||
if isinstance(check_state_raw, QtCore.Qt.CheckState)
|
||||
else int(check_state_raw or 0)
|
||||
)
|
||||
if check_state == int(QtCore.Qt.CheckState.Checked.value):
|
||||
indicator_opt.state |= QtWidgets.QStyle.StateFlag.State_On
|
||||
indicator_opt.checkState = QtCore.Qt.CheckState.Checked
|
||||
elif check_state == int(QtCore.Qt.CheckState.PartiallyChecked.value):
|
||||
indicator_opt.state |= QtWidgets.QStyle.StateFlag.State_NoChange
|
||||
indicator_opt.checkState = QtCore.Qt.CheckState.PartiallyChecked
|
||||
else:
|
||||
indicator_opt.state |= QtWidgets.QStyle.StateFlag.State_Off
|
||||
indicator_opt.checkState = QtCore.Qt.CheckState.Unchecked
|
||||
style.drawPrimitive(
|
||||
QtWidgets.QStyle.PrimitiveElement.PE_IndicatorItemViewItemCheck,
|
||||
indicator_opt,
|
||||
painter,
|
||||
opt.widget,
|
||||
)
|
||||
|
||||
|
||||
class SortableTableWidgetItem(QtWidgets.QTableWidgetItem):
|
||||
def __lt__(self, other: QtWidgets.QTableWidgetItem) -> bool:
|
||||
left = self.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
right = other.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if left is not None or right is not None:
|
||||
return (left, self.text().lower()) < (right, other.text().lower())
|
||||
return super().__lt__(other)
|
||||
@@ -0,0 +1,249 @@
|
||||
from opensnitch.plugins.list_subscriptions.ui import (
|
||||
QtCore,
|
||||
QtGui,
|
||||
QtWidgets,
|
||||
QC,
|
||||
)
|
||||
|
||||
|
||||
class ToggleSwitch(QtWidgets.QCheckBox):
|
||||
def __init__(
|
||||
self,
|
||||
text: str = "",
|
||||
parent: QtWidgets.QWidget | None = None,
|
||||
):
|
||||
super().__init__(text, parent)
|
||||
self._base_text = text
|
||||
self._track_width = 38
|
||||
self._track_height = 22
|
||||
self._thumb_diameter = 16
|
||||
self._label_gap = 8
|
||||
self._outer_padding = 4
|
||||
self._paint_margin = 1.5
|
||||
self._focus_margin = 2.0
|
||||
self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||
self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
|
||||
self.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Preferred,
|
||||
QtWidgets.QSizePolicy.Policy.Fixed,
|
||||
)
|
||||
self.setContentsMargins(
|
||||
self._outer_padding,
|
||||
self._outer_padding,
|
||||
self._outer_padding,
|
||||
self._outer_padding,
|
||||
)
|
||||
self.toggled.connect(self._refresh_geometry)
|
||||
font = self.font()
|
||||
font.setBold(True)
|
||||
self.setFont(font)
|
||||
self._refresh_geometry(self.isChecked())
|
||||
|
||||
def sizeHint(self) -> QtCore.QSize:
|
||||
metrics = self.fontMetrics()
|
||||
label_text = self._display_text()
|
||||
text_width = metrics.horizontalAdvance(label_text) if label_text else 0
|
||||
width = self._track_width + int(self._focus_margin * 2) + text_width
|
||||
if text_width:
|
||||
width += self._label_gap
|
||||
margins = self.contentsMargins()
|
||||
width += margins.left() + margins.right()
|
||||
height = max(
|
||||
metrics.height(),
|
||||
self._track_height + int(self._focus_margin * 2),
|
||||
)
|
||||
height += margins.top() + margins.bottom()
|
||||
return QtCore.QSize(width, height)
|
||||
|
||||
def minimumSizeHint(self) -> QtCore.QSize:
|
||||
return self.sizeHint()
|
||||
|
||||
def hitButton(self, pos: QtCore.QPoint) -> bool:
|
||||
return self.rect().contains(pos)
|
||||
|
||||
def _refresh_geometry(self, _checked: bool):
|
||||
self.setMinimumHeight(self.sizeHint().height())
|
||||
self.updateGeometry()
|
||||
self.update()
|
||||
|
||||
def _display_text(self) -> str:
|
||||
base = (self._base_text or "").strip()
|
||||
if base.lower() == "enable list subscriptions plugin":
|
||||
state = QC.translate(
|
||||
"stats",
|
||||
"enabled" if self.isChecked() else "disabled",
|
||||
)
|
||||
return QC.translate(
|
||||
"stats",
|
||||
"List subscriptions plugin {0}",
|
||||
).format(state)
|
||||
if base.lower() in {"enabled", "disabled"}:
|
||||
return QC.translate(
|
||||
"stats",
|
||||
"Enabled" if self.isChecked() else "Disabled",
|
||||
)
|
||||
return base
|
||||
|
||||
def paintEvent(self, event: QtGui.QPaintEvent): # type: ignore[override]
|
||||
del event
|
||||
painter = QtGui.QPainter(self)
|
||||
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
|
||||
|
||||
margins = self.contentsMargins()
|
||||
content_rect = self.rect().adjusted(
|
||||
margins.left(),
|
||||
margins.top(),
|
||||
-margins.right(),
|
||||
-margins.bottom(),
|
||||
)
|
||||
_draw_toggle_switch(
|
||||
painter,
|
||||
self.palette(),
|
||||
QtCore.QRectF(content_rect),
|
||||
checked=self.isChecked(),
|
||||
enabled=self.isEnabled(),
|
||||
text=self._display_text(),
|
||||
bold_text=True,
|
||||
focused=self.hasFocus(),
|
||||
track_width=float(self._track_width),
|
||||
track_height=float(self._track_height),
|
||||
thumb_diameter=float(self._thumb_diameter),
|
||||
label_gap=float(self._label_gap),
|
||||
paint_margin=float(self._paint_margin),
|
||||
focus_margin=float(self._focus_margin),
|
||||
)
|
||||
|
||||
|
||||
def _draw_toggle_switch(
|
||||
painter: QtGui.QPainter,
|
||||
palette: QtGui.QPalette,
|
||||
rect: QtCore.QRectF,
|
||||
*,
|
||||
checked: bool,
|
||||
enabled: bool,
|
||||
text: str = "",
|
||||
bold_text: bool = False,
|
||||
focused: bool = False,
|
||||
track_width: float = 38.0,
|
||||
track_height: float = 22.0,
|
||||
thumb_diameter: float = 16.0,
|
||||
label_gap: float = 8.0,
|
||||
paint_margin: float = 1.5,
|
||||
focus_margin: float = 2.0,
|
||||
):
|
||||
is_dark = palette.color(QtGui.QPalette.ColorRole.Window).lightness() < 128
|
||||
if enabled:
|
||||
off_role = (
|
||||
QtGui.QPalette.ColorRole.Midlight
|
||||
if is_dark
|
||||
else QtGui.QPalette.ColorRole.Mid
|
||||
)
|
||||
border_role = (
|
||||
QtGui.QPalette.ColorRole.Light if is_dark else QtGui.QPalette.ColorRole.Dark
|
||||
)
|
||||
track_color = (
|
||||
palette.color(QtGui.QPalette.ColorRole.Highlight)
|
||||
if checked
|
||||
else palette.color(off_role)
|
||||
)
|
||||
thumb_color = palette.color(QtGui.QPalette.ColorRole.Base)
|
||||
text_color = palette.color(QtGui.QPalette.ColorRole.WindowText)
|
||||
border_color = palette.color(border_role)
|
||||
else:
|
||||
track_color = palette.color(QtGui.QPalette.ColorRole.Dark)
|
||||
thumb_color = palette.color(QtGui.QPalette.ColorRole.Mid)
|
||||
text_color = palette.color(QtGui.QPalette.ColorRole.Mid)
|
||||
border_color = palette.color(QtGui.QPalette.ColorRole.Mid)
|
||||
|
||||
track_x = rect.left() + paint_margin + focus_margin
|
||||
track_y = rect.top() + (rect.height() - track_height) / 2.0 + paint_margin
|
||||
track_rect = QtCore.QRectF(
|
||||
track_x,
|
||||
track_y,
|
||||
track_width - (paint_margin * 2.0),
|
||||
track_height - (paint_margin * 2.0),
|
||||
)
|
||||
thumb_margin = (track_rect.height() - thumb_diameter) / 2.0
|
||||
thumb_left_off = track_rect.left() + thumb_margin
|
||||
thumb_left_on = track_rect.right() - thumb_margin - thumb_diameter
|
||||
thumb_rect = QtCore.QRectF(
|
||||
thumb_left_on if checked else thumb_left_off,
|
||||
track_rect.top() + thumb_margin,
|
||||
thumb_diameter,
|
||||
thumb_diameter,
|
||||
)
|
||||
radius = track_rect.height() / 2.0
|
||||
|
||||
border_pen = QtGui.QPen(border_color)
|
||||
border_pen.setWidth(1)
|
||||
painter.setPen(border_pen)
|
||||
painter.setBrush(track_color)
|
||||
painter.drawRoundedRect(track_rect, radius, radius)
|
||||
painter.setBrush(thumb_color)
|
||||
painter.drawEllipse(thumb_rect)
|
||||
|
||||
if text:
|
||||
text_rect = QtCore.QRectF(
|
||||
track_rect.right() + label_gap,
|
||||
rect.top(),
|
||||
max(0.0, rect.right() - track_rect.right() - label_gap),
|
||||
rect.height(),
|
||||
)
|
||||
painter.setPen(text_color)
|
||||
font = painter.font()
|
||||
font.setBold(bold_text)
|
||||
painter.setFont(font)
|
||||
painter.drawText(
|
||||
text_rect,
|
||||
int(
|
||||
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
|
||||
),
|
||||
text,
|
||||
)
|
||||
|
||||
if focused:
|
||||
focus_pen = QtGui.QPen(palette.color(QtGui.QPalette.ColorRole.Highlight))
|
||||
focus_pen.setWidth(1)
|
||||
painter.setPen(focus_pen)
|
||||
painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
|
||||
painter.drawRoundedRect(
|
||||
track_rect.adjusted(
|
||||
-focus_margin,
|
||||
-focus_margin,
|
||||
focus_margin,
|
||||
focus_margin,
|
||||
),
|
||||
radius + focus_margin,
|
||||
radius + focus_margin,
|
||||
)
|
||||
|
||||
|
||||
def _replace_checkbox_with_toggle(
|
||||
checkbox: QtWidgets.QCheckBox,
|
||||
) -> ToggleSwitch:
|
||||
toggle = ToggleSwitch(checkbox.text(), checkbox.parentWidget())
|
||||
toggle.setObjectName(checkbox.objectName())
|
||||
toggle.setChecked(checkbox.isChecked())
|
||||
toggle.setEnabled(checkbox.isEnabled())
|
||||
toggle.setToolTip(checkbox.toolTip())
|
||||
toggle.setStatusTip(checkbox.statusTip())
|
||||
toggle.setWhatsThis(checkbox.whatsThis())
|
||||
toggle.setAccessibleName(checkbox.accessibleName())
|
||||
toggle.setAccessibleDescription(checkbox.accessibleDescription())
|
||||
toggle.setSizePolicy(checkbox.sizePolicy())
|
||||
toggle.setMinimumSize(checkbox.minimumSize())
|
||||
toggle.setMaximumSize(checkbox.maximumSize())
|
||||
margins = checkbox.contentsMargins()
|
||||
toggle.setContentsMargins(
|
||||
max(toggle.contentsMargins().left(), margins.left()),
|
||||
max(toggle.contentsMargins().top(), margins.top()),
|
||||
max(toggle.contentsMargins().right(), margins.right()),
|
||||
max(toggle.contentsMargins().bottom(), margins.bottom()),
|
||||
)
|
||||
parent = checkbox.parentWidget()
|
||||
layout = parent.layout() if parent is not None else None
|
||||
if layout is not None:
|
||||
layout.replaceWidget(checkbox, toggle)
|
||||
checkbox.hide()
|
||||
checkbox.deleteLater()
|
||||
return toggle
|
||||
@@ -0,0 +1,17 @@
|
||||
from .attached_rules_snapshot_worker import (
|
||||
AttachedRulesCountWorker,
|
||||
AttachedRulesFetchWorker,
|
||||
AttachedRulesProcessWorker,
|
||||
AttachedRulesSnapshotWorker,
|
||||
)
|
||||
from .state_refresh_worker import SubscriptionStateRefreshWorker
|
||||
from .url_test_worker import UrlTestWorker
|
||||
|
||||
__all__ = [
|
||||
"AttachedRulesCountWorker",
|
||||
"AttachedRulesFetchWorker",
|
||||
"AttachedRulesProcessWorker",
|
||||
"AttachedRulesSnapshotWorker",
|
||||
"SubscriptionStateRefreshWorker",
|
||||
"UrlTestWorker",
|
||||
]
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from PyQt6.QtSql import QSqlDatabase, QSqlQuery
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore
|
||||
from opensnitch.config import Config
|
||||
|
||||
|
||||
def _is_sqlite_uri(db_file: str) -> bool:
|
||||
return str(db_file or "").strip().lower().startswith("file:")
|
||||
|
||||
|
||||
def _worker_connection_name(prefix: str) -> str:
|
||||
thread_id = int(QtCore.QThread.currentThreadId())
|
||||
return f"{prefix}_{thread_id}_{time.time_ns()}"
|
||||
|
||||
|
||||
def _open_worker_db(*, db_file: str, busy_timeout_ms: int, conn_name: str) -> QSqlDatabase | None:
|
||||
db = QSqlDatabase.addDatabase("QSQLITE", conn_name)
|
||||
db.setDatabaseName(db_file)
|
||||
options: list[str] = [f"QSQLITE_BUSY_TIMEOUT={busy_timeout_ms}"]
|
||||
if _is_sqlite_uri(db_file):
|
||||
options.extend(["QSQLITE_OPEN_URI", "QSQLITE_ENABLE_SHARED_CACHE"])
|
||||
db.setConnectOptions(";".join(options))
|
||||
if not db.open():
|
||||
return None
|
||||
return db
|
||||
|
||||
|
||||
def _close_worker_db(*, conn_name: str, db: QSqlDatabase | None) -> None:
|
||||
if db is not None:
|
||||
try:
|
||||
if db.isOpen():
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
QSqlDatabase.removeDatabase(conn_name)
|
||||
|
||||
|
||||
class AttachedRulesCountWorker(QtCore.QObject):
|
||||
count_done = QtCore.pyqtSignal(object)
|
||||
finished = QtCore.pyqtSignal()
|
||||
|
||||
def __init__(self, *, db_file: str, local_nodes: list[str]):
|
||||
super().__init__()
|
||||
self._db_file = db_file
|
||||
self._local_nodes = local_nodes
|
||||
self._stop_requested = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_requested = True
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
return self._stop_requested
|
||||
|
||||
@QtCore.pyqtSlot()
|
||||
def run(self):
|
||||
count: int | None = None
|
||||
started = time.monotonic()
|
||||
conn_name = _worker_connection_name("attached_rules_count")
|
||||
db: QSqlDatabase | None = None
|
||||
query: QSqlQuery | None = None
|
||||
try:
|
||||
if self._should_stop() or not self._local_nodes:
|
||||
count = 0
|
||||
return
|
||||
|
||||
db = _open_worker_db(
|
||||
db_file=self._db_file,
|
||||
busy_timeout_ms=800,
|
||||
conn_name=conn_name,
|
||||
)
|
||||
if db is None:
|
||||
count = None
|
||||
return
|
||||
|
||||
placeholders = ",".join("?" for _ in self._local_nodes)
|
||||
query = QSqlQuery(db)
|
||||
query.prepare(f"SELECT COUNT(*) FROM rules WHERE node IN ({placeholders})")
|
||||
for node in self._local_nodes:
|
||||
query.addBindValue(node)
|
||||
if not query.exec():
|
||||
count = None
|
||||
return
|
||||
if query.next():
|
||||
count = int((query.value(0) or 0) or 0)
|
||||
else:
|
||||
count = 0
|
||||
except Exception:
|
||||
count = None
|
||||
finally:
|
||||
if query is not None:
|
||||
try:
|
||||
query.finish()
|
||||
except Exception:
|
||||
pass
|
||||
query = None
|
||||
if db is not None:
|
||||
try:
|
||||
if db.isOpen():
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Drop Python refs before removeDatabase to satisfy Qt lifetime rules.
|
||||
db = QSqlDatabase()
|
||||
_close_worker_db(conn_name=conn_name, db=None)
|
||||
elapsed_ms = max(0, int((time.monotonic() - started) * 1000))
|
||||
if not self._should_stop():
|
||||
self.count_done.emit(
|
||||
{
|
||||
"count": count,
|
||||
"over_limit": False,
|
||||
"is_estimate": False,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
)
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
class AttachedRulesFetchWorker(QtCore.QObject):
|
||||
rows_done = QtCore.pyqtSignal(object)
|
||||
finished = QtCore.pyqtSignal()
|
||||
|
||||
def __init__(self, *, db_file: str, local_nodes: list[str]):
|
||||
super().__init__()
|
||||
self._db_file = db_file
|
||||
self._local_nodes = local_nodes
|
||||
self._stop_requested = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_requested = True
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
return self._stop_requested
|
||||
|
||||
@QtCore.pyqtSlot()
|
||||
def run(self):
|
||||
rows: list[tuple[str, str, bool, str, str, str]] = []
|
||||
started = time.monotonic()
|
||||
conn_name = _worker_connection_name("attached_rules_fetch")
|
||||
db: QSqlDatabase | None = None
|
||||
query: QSqlQuery | None = None
|
||||
try:
|
||||
if self._should_stop() or not self._local_nodes:
|
||||
return
|
||||
|
||||
db = _open_worker_db(
|
||||
db_file=self._db_file,
|
||||
busy_timeout_ms=1000,
|
||||
conn_name=conn_name,
|
||||
)
|
||||
if db is None:
|
||||
rows = []
|
||||
return
|
||||
|
||||
placeholders = ",".join("?" for _ in self._local_nodes)
|
||||
sql = (
|
||||
"SELECT node, name, enabled, operator_type, operator_operand, operator_data "
|
||||
f"FROM rules WHERE node IN ({placeholders})"
|
||||
)
|
||||
query = QSqlQuery(db)
|
||||
query.prepare(sql)
|
||||
for node in self._local_nodes:
|
||||
query.addBindValue(node)
|
||||
if not query.exec():
|
||||
rows = []
|
||||
return
|
||||
|
||||
while query.next():
|
||||
if self._should_stop():
|
||||
break
|
||||
addr = str(query.value(0) or "").strip()
|
||||
rule_name = str(query.value(1) or "").strip()
|
||||
enabled_raw = str(query.value(2) or "").strip().lower()
|
||||
op_type = str(query.value(3) or "").strip()
|
||||
op_operand = str(query.value(4) or "").strip()
|
||||
op_data = str(query.value(5) or "").strip()
|
||||
if addr == "" or rule_name == "":
|
||||
continue
|
||||
rows.append(
|
||||
(
|
||||
addr,
|
||||
rule_name,
|
||||
enabled_raw == "true",
|
||||
op_type,
|
||||
op_operand,
|
||||
op_data,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
rows = []
|
||||
finally:
|
||||
if query is not None:
|
||||
try:
|
||||
query.finish()
|
||||
except Exception:
|
||||
pass
|
||||
query = None
|
||||
if db is not None:
|
||||
try:
|
||||
if db.isOpen():
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Drop Python refs before removeDatabase to satisfy Qt lifetime rules.
|
||||
db = QSqlDatabase()
|
||||
_close_worker_db(conn_name=conn_name, db=None)
|
||||
elapsed_ms = max(0, int((time.monotonic() - started) * 1000))
|
||||
if not self._should_stop():
|
||||
self.rows_done.emit(
|
||||
{
|
||||
"rows": rows,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"row_count": len(rows),
|
||||
}
|
||||
)
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
class AttachedRulesProcessWorker(QtCore.QObject):
|
||||
snapshot_done = QtCore.pyqtSignal(object)
|
||||
finished = QtCore.pyqtSignal()
|
||||
|
||||
def __init__(self, *, rows: list[tuple[str, str, bool, str, str, str]]):
|
||||
super().__init__()
|
||||
self._rows = rows
|
||||
self._stop_requested = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_requested = True
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
return self._stop_requested
|
||||
|
||||
@QtCore.pyqtSlot()
|
||||
def run(self):
|
||||
snapshot: dict[str, list[dict[str, Any]]] = {}
|
||||
started = time.monotonic()
|
||||
try:
|
||||
if self._should_stop() or not self._rows:
|
||||
return
|
||||
|
||||
seen_entries: set[tuple[str, str, str]] = set()
|
||||
for row in self._rows:
|
||||
if self._should_stop():
|
||||
break
|
||||
addr, rule_name, rule_enabled, op_type, op_operand, op_data = row
|
||||
|
||||
if op_operand == Config.OPERAND_LIST_DOMAINS and op_data != "":
|
||||
direct = op_data.strip()
|
||||
entry_key = (direct, addr, rule_name)
|
||||
if entry_key not in seen_entries:
|
||||
seen_entries.add(entry_key)
|
||||
snapshot.setdefault(direct, []).append(
|
||||
{
|
||||
"addr": addr,
|
||||
"name": rule_name,
|
||||
"enabled": rule_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
if op_type != Config.RULE_TYPE_LIST:
|
||||
continue
|
||||
try:
|
||||
operators = json.loads(op_data)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(operators, list):
|
||||
continue
|
||||
for op in operators:
|
||||
if self._should_stop():
|
||||
break
|
||||
if not isinstance(op, dict):
|
||||
continue
|
||||
operand = str(op.get("operand") or "").strip()
|
||||
data = str(op.get("data") or "").strip()
|
||||
if operand != Config.OPERAND_LIST_DOMAINS or data == "":
|
||||
continue
|
||||
entry_key = (data, addr, rule_name)
|
||||
if entry_key in seen_entries:
|
||||
continue
|
||||
seen_entries.add(entry_key)
|
||||
snapshot.setdefault(data, []).append(
|
||||
{
|
||||
"addr": addr,
|
||||
"name": rule_name,
|
||||
"enabled": rule_enabled,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
snapshot = {}
|
||||
finally:
|
||||
elapsed_ms = max(0, int((time.monotonic() - started) * 1000))
|
||||
if not self._should_stop():
|
||||
self.snapshot_done.emit(
|
||||
{
|
||||
"snapshot": snapshot,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"row_count": len(self._rows),
|
||||
}
|
||||
)
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
class AttachedRulesSnapshotWorker(AttachedRulesProcessWorker):
|
||||
"""Backward-compatible alias for snapshot processing worker."""
|
||||
@@ -0,0 +1,177 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore
|
||||
from opensnitch.plugins.list_subscriptions._utils import (
|
||||
list_file_path,
|
||||
subscription_rule_dir,
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionStateRefreshWorker(QtCore.QObject):
|
||||
refresh_done = QtCore.pyqtSignal(int, object)
|
||||
finished = QtCore.pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
generation: int,
|
||||
lists_dir: str,
|
||||
rows: list[dict[str, Any]],
|
||||
attached_rules_by_dir: dict[str, list[dict[str, Any]]],
|
||||
):
|
||||
super().__init__()
|
||||
self._generation = generation
|
||||
self._lists_dir = lists_dir
|
||||
self._rows = rows
|
||||
self._attached_rules_by_dir = attached_rules_by_dir
|
||||
self._stop_requested = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_requested = True
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
return self._stop_requested
|
||||
|
||||
@staticmethod
|
||||
def _rule_attachment_matches(
|
||||
*,
|
||||
lists_dir: str,
|
||||
filename: str,
|
||||
list_type: str,
|
||||
groups: list[str],
|
||||
attached_rules_by_dir: dict[str, list[dict[str, Any]]],
|
||||
) -> list[dict[str, Any]]:
|
||||
rules_root = os.path.join(lists_dir, "rules.list.d")
|
||||
candidate_dirs = [
|
||||
(
|
||||
"subscription",
|
||||
os.path.normpath(subscription_rule_dir(lists_dir, filename, list_type)),
|
||||
),
|
||||
("all", os.path.normpath(os.path.join(rules_root, "all"))),
|
||||
]
|
||||
candidate_dirs.extend(
|
||||
(f"group:{group}", os.path.normpath(os.path.join(rules_root, group)))
|
||||
for group in groups
|
||||
)
|
||||
|
||||
matches: list[dict[str, Any]] = []
|
||||
seen_match: set[tuple[str, str, str]] = set()
|
||||
for source, directory in candidate_dirs:
|
||||
for rule_entry in attached_rules_by_dir.get(directory, []):
|
||||
addr = str(rule_entry.get("addr", "")).strip()
|
||||
name = str(rule_entry.get("name", "")).strip()
|
||||
enabled = bool(rule_entry.get("enabled", True))
|
||||
if addr == "" or name == "" or not enabled:
|
||||
continue
|
||||
key = (addr, name, source)
|
||||
if key in seen_match:
|
||||
continue
|
||||
seen_match.add(key)
|
||||
matches.append(
|
||||
{
|
||||
"addr": addr,
|
||||
"name": name,
|
||||
"enabled": enabled,
|
||||
"source": source,
|
||||
"directory": directory,
|
||||
}
|
||||
)
|
||||
|
||||
matches.sort(
|
||||
key=lambda item: (item["name"].lower(), item["addr"], item["source"])
|
||||
)
|
||||
return matches
|
||||
|
||||
@QtCore.pyqtSlot()
|
||||
def run(self):
|
||||
results: list[dict[str, Any]] = []
|
||||
try:
|
||||
for row_data in self._rows:
|
||||
if self._should_stop():
|
||||
return
|
||||
|
||||
row = int(row_data.get("row", -1))
|
||||
url = str(row_data.get("url", "") or "")
|
||||
filename = str(row_data.get("filename", "") or "")
|
||||
list_type = str(row_data.get("list_type", "hosts") or "hosts")
|
||||
enabled = bool(row_data.get("enabled", True))
|
||||
groups = list(row_data.get("groups", []))
|
||||
|
||||
list_path = list_file_path(self._lists_dir, filename, list_type)
|
||||
meta_path = list_path + ".meta.json"
|
||||
|
||||
file_exists = os.path.exists(list_path)
|
||||
meta_exists = os.path.exists(meta_path)
|
||||
|
||||
meta: dict[str, Any] = {}
|
||||
if meta_exists:
|
||||
try:
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
except Exception:
|
||||
meta = {}
|
||||
|
||||
last_result = str(meta.get("last_result", "never")) if meta else "never"
|
||||
last_checked = str(meta.get("last_checked", "")) if meta else ""
|
||||
last_updated = str(meta.get("last_updated", "")) if meta else ""
|
||||
fail_count = str(meta.get("fail_count", 0)) if meta else "0"
|
||||
last_error = str(meta.get("last_error", "")) if meta else ""
|
||||
|
||||
attachment_matches = self._rule_attachment_matches(
|
||||
lists_dir=self._lists_dir,
|
||||
filename=filename,
|
||||
list_type=list_type,
|
||||
groups=groups,
|
||||
attached_rules_by_dir=self._attached_rules_by_dir,
|
||||
)
|
||||
rule_attached = "yes" if attachment_matches else "no"
|
||||
|
||||
if not enabled:
|
||||
state = "disabled"
|
||||
elif not file_exists:
|
||||
if not meta_exists or last_result in ("never", "", "busy"):
|
||||
state = "pending"
|
||||
else:
|
||||
state = "missing"
|
||||
elif last_result in ("updated", "not_modified"):
|
||||
state = last_result
|
||||
elif last_result in (
|
||||
"error",
|
||||
"write_error",
|
||||
"request_error",
|
||||
"unexpected_error",
|
||||
"bad_format",
|
||||
"too_large",
|
||||
):
|
||||
state = last_result
|
||||
elif last_result == "busy":
|
||||
state = "busy"
|
||||
else:
|
||||
state = last_result
|
||||
|
||||
results.append(
|
||||
{
|
||||
"row": row,
|
||||
"url": url,
|
||||
"filename": filename,
|
||||
"enabled": enabled,
|
||||
"file_present": "yes" if file_exists else "no",
|
||||
"meta_present": "yes" if meta_exists else "no",
|
||||
"state": state,
|
||||
"rule_attached": rule_attached,
|
||||
"attachment_matches": attachment_matches,
|
||||
"last_checked": last_checked,
|
||||
"last_updated": last_updated,
|
||||
"failures": fail_count,
|
||||
"error": last_error,
|
||||
"list_path": list_path,
|
||||
"meta_path": meta_path,
|
||||
}
|
||||
)
|
||||
|
||||
if not self._should_stop():
|
||||
self.refresh_done.emit(self._generation, results)
|
||||
finally:
|
||||
self.finished.emit()
|
||||
@@ -0,0 +1,108 @@
|
||||
from typing import Any
|
||||
|
||||
from opensnitch.plugins.list_subscriptions.ui import QtCore, QC
|
||||
from opensnitch.plugins.list_subscriptions._utils import is_hosts_file_like
|
||||
|
||||
|
||||
class UrlTestWorker(QtCore.QObject):
|
||||
test_result = QtCore.pyqtSignal(bool, str)
|
||||
finished = QtCore.pyqtSignal()
|
||||
|
||||
def __init__(self, url: str, list_type: str = "hosts"):
|
||||
super().__init__()
|
||||
self.url = url
|
||||
self.list_type = (list_type or "hosts").strip().lower()
|
||||
self._stop_requested = False
|
||||
self._active_response: Any = None
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_requested = True
|
||||
resp = self._active_response
|
||||
if resp is not None:
|
||||
try:
|
||||
resp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
return self._stop_requested
|
||||
|
||||
@QtCore.pyqtSlot()
|
||||
def run(self):
|
||||
import requests
|
||||
|
||||
try:
|
||||
if self._should_stop():
|
||||
return
|
||||
# HEAD has no interruptible response object; keep timeout short.
|
||||
response = requests.head(self.url, allow_redirects=True, timeout=3)
|
||||
if response.status_code >= 400 and response.status_code not in (403, 405):
|
||||
raise requests.HTTPError(f"HTTP {response.status_code}")
|
||||
final_url = response.url or self.url
|
||||
response.close()
|
||||
if self._should_stop():
|
||||
return
|
||||
if response.status_code in (403, 405):
|
||||
response = requests.get(
|
||||
self.url, allow_redirects=True, timeout=3, stream=True
|
||||
)
|
||||
self._active_response = response
|
||||
if response.status_code >= 400:
|
||||
raise requests.HTTPError(f"HTTP {response.status_code}")
|
||||
final_url = response.url or final_url
|
||||
response.close()
|
||||
self._active_response = None
|
||||
if self._should_stop():
|
||||
return
|
||||
message = QC.translate("stats", "URL reachable.")
|
||||
if self.list_type == "hosts":
|
||||
sample_lines: list[str] = []
|
||||
response = requests.get(
|
||||
self.url,
|
||||
allow_redirects=True,
|
||||
timeout=5,
|
||||
stream=True,
|
||||
)
|
||||
self._active_response = response
|
||||
if response.status_code >= 400:
|
||||
raise requests.HTTPError(f"HTTP {response.status_code}")
|
||||
|
||||
for chunk in response.iter_content(chunk_size=32 * 1024):
|
||||
if self._should_stop():
|
||||
return
|
||||
if not chunk:
|
||||
continue
|
||||
txt = chunk.decode("utf-8", errors="ignore")
|
||||
for line in txt.splitlines():
|
||||
if len(sample_lines) < 200:
|
||||
sample_lines.append(line)
|
||||
else:
|
||||
break
|
||||
if len(sample_lines) >= 200:
|
||||
break
|
||||
response.close()
|
||||
self._active_response = None
|
||||
|
||||
if not is_hosts_file_like(sample_lines):
|
||||
self.test_result.emit(
|
||||
False,
|
||||
QC.translate(
|
||||
"stats",
|
||||
"URL is reachable but content is not valid hosts format.",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
if final_url != self.url:
|
||||
message = QC.translate("stats", "URL reachable via redirect.")
|
||||
if final_url:
|
||||
message = QC.translate("stats", "URL reachable via redirect.")
|
||||
self.test_result.emit(True, f"{message} {final_url}")
|
||||
return
|
||||
self.test_result.emit(True, message)
|
||||
except requests.RequestException as exc:
|
||||
if not self._should_stop():
|
||||
self.test_result.emit(False, str(exc))
|
||||
finally:
|
||||
self._active_response = None
|
||||
self.finished.emit()
|
||||
Reference in New Issue
Block a user