j
This commit is contained in:
@@ -3569,7 +3569,7 @@ class LocalLibrarySearchOptimizer:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to update search result for {file_path}: {e}")
|
logger.warning(f"Failed to update search result for {file_path}: {e}")
|
||||||
|
|
||||||
def search_by_tag(self, tag: str, limit: int = 100) -> List[Path]:
|
def search_by_tag(self, tag: str, limit: int = 100) -> List[Dict[str, Any]]:
|
||||||
"""Fast tag-based search using database."""
|
"""Fast tag-based search using database."""
|
||||||
if not self.db:
|
if not self.db:
|
||||||
return []
|
return []
|
||||||
@@ -3578,9 +3578,10 @@ class LocalLibrarySearchOptimizer:
|
|||||||
cursor = self.db.connection.cursor()
|
cursor = self.db.connection.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
SELECT f.file_path
|
SELECT f.hash, f.file_path, m.duration, m.size, m.type as media_kind, m.url
|
||||||
FROM file f
|
FROM file f
|
||||||
JOIN tag t ON f.hash = t.hash
|
JOIN tag t ON f.hash = t.hash
|
||||||
|
LEFT JOIN metadata m ON f.hash = m.hash
|
||||||
WHERE t.tag LIKE ?
|
WHERE t.tag LIKE ?
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
@@ -3588,11 +3589,47 @@ class LocalLibrarySearchOptimizer:
|
|||||||
limit),
|
limit),
|
||||||
)
|
)
|
||||||
|
|
||||||
return [self.db._from_db_file_path(row[0]) for row in cursor.fetchall()]
|
results = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
res = dict(row)
|
||||||
|
# Resolve path to absolute string for remote consumption
|
||||||
|
res["file_path"] = str(self.db._from_db_file_path(res["file_path"]))
|
||||||
|
results.append(res)
|
||||||
|
return results
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Tag search failed: {e}")
|
logger.error(f"Tag search failed: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def search_by_name(self, query: str, limit: int = 100) -> List[Dict[str, Any]]:
|
||||||
|
"""Fast name-based search using database."""
|
||||||
|
if not self.db:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor = self.db.connection.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT f.hash, f.file_path, m.duration, m.size, m.type as media_kind, m.url
|
||||||
|
FROM file f
|
||||||
|
LEFT JOIN metadata m ON f.hash = m.hash
|
||||||
|
WHERE f.file_path LIKE ?
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(f"%{query}%",
|
||||||
|
limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
res = dict(row)
|
||||||
|
# Resolve path to absolute string for remote consumption
|
||||||
|
res["file_path"] = str(self.db._from_db_file_path(res["file_path"]))
|
||||||
|
results.append(res)
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Name search failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def save_playlist(self, name: str, items: List[Dict[str, Any]]) -> bool:
|
def save_playlist(self, name: str, items: List[Dict[str, Any]]) -> bool:
|
||||||
"""Save a playlist to the database."""
|
"""Save a playlist to the database."""
|
||||||
if not self.db:
|
if not self.db:
|
||||||
@@ -3705,9 +3742,6 @@ class LocalLibrarySearchOptimizer:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete playlist ID {playlist_id}: {e}")
|
logger.error(f"Failed to delete playlist ID {playlist_id}: {e}")
|
||||||
return False
|
return False
|
||||||
if not self.db:
|
|
||||||
return []
|
|
||||||
return self.db.search_by_tag(tag, limit)
|
|
||||||
|
|
||||||
def search_by_hash(self, file_hash: str) -> Optional[Path]:
|
def search_by_hash(self, file_hash: str) -> Optional[Path]:
|
||||||
"""Fast hash-based search using database."""
|
"""Fast hash-based search using database."""
|
||||||
|
|||||||
@@ -536,14 +536,16 @@ def discover_services_on_network(
|
|||||||
path = paths[0]
|
path = paths[0]
|
||||||
url = f"{scheme}://{host}:{port}{path}"
|
url = f"{scheme}://{host}:{port}{path}"
|
||||||
ok, code, payload = _probe_url(url, timeout=timeout, accept_json=accept_json)
|
ok, code, payload = _probe_url(url, timeout=timeout, accept_json=accept_json)
|
||||||
if ok:
|
if ok or code == 401:
|
||||||
hint = None
|
hint = None
|
||||||
try:
|
try:
|
||||||
# remote_storage_server returns {"status": "ok", ...}
|
# remote_storage_server returns {"status": "ok", ...}
|
||||||
if isinstance(payload, dict) and payload.get("status"):
|
if code == 401:
|
||||||
|
hint = "remote_storage" # Most likely
|
||||||
|
elif isinstance(payload, dict) and payload.get("status"):
|
||||||
hint = "remote_storage"
|
hint = "remote_storage"
|
||||||
# hydrus returns {"api_version": ...}
|
# hydrus returns {"api_version": ...}
|
||||||
if isinstance(payload, dict) and payload.get("api_version"):
|
elif isinstance(payload, dict) and payload.get("api_version"):
|
||||||
hint = "hydrus"
|
hint = "hydrus"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -553,7 +555,7 @@ def discover_services_on_network(
|
|||||||
port=int(port),
|
port=int(port),
|
||||||
path=path,
|
path=path,
|
||||||
url=url,
|
url=url,
|
||||||
ok=True,
|
ok=(code == 200),
|
||||||
status_code=code,
|
status_code=code,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
service_hint=hint,
|
service_hint=hint,
|
||||||
|
|||||||
5
CLI.py
5
CLI.py
@@ -4571,6 +4571,8 @@ class MedeiaCLI:
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
|
ensure_zerotier_server_running()
|
||||||
|
|
||||||
# Ensure Rich tracebacks are active even when invoking subcommands.
|
# Ensure Rich tracebacks are active even when invoking subcommands.
|
||||||
try:
|
try:
|
||||||
config = self._config_loader.load()
|
config = self._config_loader.load()
|
||||||
@@ -4587,7 +4589,6 @@ class MedeiaCLI:
|
|||||||
self.build_app()()
|
self.build_app()()
|
||||||
|
|
||||||
def run_repl(self) -> None:
|
def run_repl(self) -> None:
|
||||||
ensure_zerotier_server_running()
|
|
||||||
# console = Console(width=100)
|
# console = Console(width=100)
|
||||||
|
|
||||||
# Valid Rich rainbow colors
|
# Valid Rich rainbow colors
|
||||||
@@ -5443,6 +5444,8 @@ Come to love it when others take what you share, as there is no greater joy
|
|||||||
if pipeline_ctx_ref:
|
if pipeline_ctx_ref:
|
||||||
pipeline_ctx_ref.clear_current_command_text()
|
pipeline_ctx_ref.clear_current_command_text()
|
||||||
|
|
||||||
|
stop_zerotier_server()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
MedeiaCLI().run()
|
MedeiaCLI().run()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import atexit
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -12,6 +13,11 @@ from SYS.logger import debug, log
|
|||||||
_zt_server_proc: Optional[subprocess.Popen] = None
|
_zt_server_proc: Optional[subprocess.Popen] = None
|
||||||
_zt_server_last_config: Optional[str] = None
|
_zt_server_last_config: Optional[str] = None
|
||||||
|
|
||||||
|
# We no longer use atexit here because explicit lifecycle management
|
||||||
|
# is preferred in TUI/REPL, and background servers use a monitor thread
|
||||||
|
# to shut down when the parent dies.
|
||||||
|
# atexit.register(lambda: stop_zerotier_server())
|
||||||
|
|
||||||
def ensure_zerotier_server_running() -> None:
|
def ensure_zerotier_server_running() -> None:
|
||||||
"""Check config and ensure the ZeroTier storage server is running if needed."""
|
"""Check config and ensure the ZeroTier storage server is running if needed."""
|
||||||
global _zt_server_proc, _zt_server_last_config
|
global _zt_server_proc, _zt_server_last_config
|
||||||
@@ -92,7 +98,8 @@ def ensure_zerotier_server_running() -> None:
|
|||||||
|
|
||||||
cmd = [python_exe, str(server_script),
|
cmd = [python_exe, str(server_script),
|
||||||
"--storage-path", str(storage_path),
|
"--storage-path", str(storage_path),
|
||||||
"--port", str(port)]
|
"--port", str(port),
|
||||||
|
"--monitor"]
|
||||||
if api_key:
|
if api_key:
|
||||||
cmd += ["--api-key", str(api_key)]
|
cmd += ["--api-key", str(api_key)]
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ class ZeroTier(Store):
|
|||||||
try:
|
try:
|
||||||
import httpx
|
import httpx
|
||||||
resp = httpx.request(method, url, params=params, json=json_body, headers=headers, timeout=timeout or self._timeout)
|
resp = httpx.request(method, url, params=params, json=json_body, headers=headers, timeout=timeout or self._timeout)
|
||||||
|
if resp.status_code == 401:
|
||||||
|
log(f"[Store={self._name}] Remote service at {url} requires an API Key. Please configure 'API_KEY' for this store.", severity="warning")
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
try:
|
try:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ class ConfigModal(ModalScreen):
|
|||||||
yield Label("Categories", classes="config-label")
|
yield Label("Categories", classes="config-label")
|
||||||
with ListView(id="category-list"):
|
with ListView(id="category-list"):
|
||||||
yield ListItem(Label("Global Settings"), id="cat-globals")
|
yield ListItem(Label("Global Settings"), id="cat-globals")
|
||||||
yield ListItem(Label("Networking"), id="cat-networking")
|
yield ListItem(Label("Connectors"), id="cat-networking")
|
||||||
yield ListItem(Label("Stores"), id="cat-stores")
|
yield ListItem(Label("Stores"), id="cat-stores")
|
||||||
yield ListItem(Label("Providers"), id="cat-providers")
|
yield ListItem(Label("Providers"), id="cat-providers")
|
||||||
|
|
||||||
@@ -277,10 +277,10 @@ class ConfigModal(ModalScreen):
|
|||||||
container.mount(Static(f"Error listing ZeroTier networks: {exc}"))
|
container.mount(Static(f"Error listing ZeroTier networks: {exc}"))
|
||||||
|
|
||||||
container.mount(Rule())
|
container.mount(Rule())
|
||||||
container.mount(Label("Networking Services", classes="config-label"))
|
container.mount(Label("Connectors", classes="config-label"))
|
||||||
net = self.config_data.get("networking", {})
|
net = self.config_data.get("networking", {})
|
||||||
if not net:
|
if not net:
|
||||||
container.mount(Static("No networking services configured."))
|
container.mount(Static("No connectors configured."))
|
||||||
else:
|
else:
|
||||||
idx = 0
|
idx = 0
|
||||||
for ntype, conf in net.items():
|
for ntype, conf in net.items():
|
||||||
@@ -290,8 +290,21 @@ class ConfigModal(ModalScreen):
|
|||||||
self._button_id_map[del_id] = ("del", "networking", ntype)
|
self._button_id_map[del_id] = ("del", "networking", ntype)
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
|
label = ntype
|
||||||
|
if ntype == "zerotier":
|
||||||
|
serve = conf.get("serve", "Unknown")
|
||||||
|
net_id = conf.get("network_id", "Unknown")
|
||||||
|
net_name = net_id
|
||||||
|
try:
|
||||||
|
for ln in local_nets:
|
||||||
|
if ln.id == net_id:
|
||||||
|
net_name = ln.name
|
||||||
|
break
|
||||||
|
except Exception: pass
|
||||||
|
label = f"{serve} ---> {net_name}"
|
||||||
|
|
||||||
row = Horizontal(
|
row = Horizontal(
|
||||||
Static(ntype, classes="item-label"),
|
Static(label, classes="item-label"),
|
||||||
Button("Edit", id=edit_id),
|
Button("Edit", id=edit_id),
|
||||||
Button("Delete", variant="error", id=del_id),
|
Button("Delete", variant="error", id=del_id),
|
||||||
classes="item-row"
|
classes="item-row"
|
||||||
@@ -395,9 +408,23 @@ class ConfigModal(ModalScreen):
|
|||||||
# Fetch Networking schema
|
# Fetch Networking schema
|
||||||
if item_type == "networking":
|
if item_type == "networking":
|
||||||
if item_name == "zerotier":
|
if item_name == "zerotier":
|
||||||
|
from API import zerotier as zt
|
||||||
|
local_net_choices = []
|
||||||
|
try:
|
||||||
|
for n in zt.list_networks():
|
||||||
|
local_net_choices.append((f"{n.name} ({n.id})", n.id))
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
local_store_choices = []
|
||||||
|
for s_type, s_data in self.config_data.get("store", {}).items():
|
||||||
|
for s_name in s_data.keys():
|
||||||
|
local_store_choices.append(s_name)
|
||||||
|
|
||||||
schema = [
|
schema = [
|
||||||
{"key": "api_key", "label": "ZeroTier Central API Token", "default": "", "secret": True},
|
{"key": "network_id", "label": "Network to Share on", "choices": local_net_choices},
|
||||||
{"key": "network_id", "label": "Network ID to Join", "default": ""},
|
{"key": "serve", "label": "Local Store to Share", "choices": local_store_choices},
|
||||||
|
{"key": "port", "label": "Port", "default": "999"},
|
||||||
|
{"key": "api_key", "label": "Access Key (API Key)", "default": "", "secret": True},
|
||||||
]
|
]
|
||||||
for f in schema:
|
for f in schema:
|
||||||
provider_schema_map[f["key"].upper()] = f
|
provider_schema_map[f["key"].upper()] = f
|
||||||
@@ -440,10 +467,19 @@ class ConfigModal(ModalScreen):
|
|||||||
|
|
||||||
if choices:
|
if choices:
|
||||||
# Select takes a list of (label, value) tuples
|
# Select takes a list of (label, value) tuples
|
||||||
select_options = [(str(c), str(c)) for c in choices]
|
select_options = []
|
||||||
|
choice_values = []
|
||||||
|
for c in choices:
|
||||||
|
if isinstance(c, tuple) and len(c) == 2:
|
||||||
|
select_options.append((str(c[0]), str(c[1])))
|
||||||
|
choice_values.append(str(c[1]))
|
||||||
|
else:
|
||||||
|
select_options.append((str(c), str(c)))
|
||||||
|
choice_values.append(str(c))
|
||||||
|
|
||||||
# If current value not in choices, add it or stay blank
|
# If current value not in choices, add it or stay blank
|
||||||
current_val = str(v)
|
current_val = str(v)
|
||||||
if current_val not in [str(c) for c in choices]:
|
if current_val not in choice_values:
|
||||||
select_options.insert(0, (current_val, current_val))
|
select_options.insert(0, (current_val, current_val))
|
||||||
|
|
||||||
sel = Select(select_options, value=current_val, id=inp_id)
|
sel = Select(select_options, value=current_val, id=inp_id)
|
||||||
@@ -475,7 +511,19 @@ class ConfigModal(ModalScreen):
|
|||||||
self._input_id_map[inp_id] = key
|
self._input_id_map[inp_id] = key
|
||||||
|
|
||||||
if choices:
|
if choices:
|
||||||
select_options = [(str(c), str(c)) for c in choices]
|
select_options = []
|
||||||
|
choice_values = []
|
||||||
|
for c in choices:
|
||||||
|
if isinstance(c, tuple) and len(c) == 2:
|
||||||
|
select_options.append((str(c[0]), str(c[1])))
|
||||||
|
choice_values.append(str(c[1]))
|
||||||
|
else:
|
||||||
|
select_options.append((str(c), str(c)))
|
||||||
|
choice_values.append(str(c))
|
||||||
|
|
||||||
|
if default_val not in choice_values:
|
||||||
|
select_options.insert(0, (default_val, default_val))
|
||||||
|
|
||||||
sel = Select(select_options, value=default_val, id=inp_id)
|
sel = Select(select_options, value=default_val, id=inp_id)
|
||||||
container.mount(sel)
|
container.mount(sel)
|
||||||
else:
|
else:
|
||||||
@@ -778,7 +826,12 @@ class ConfigModal(ModalScreen):
|
|||||||
peer_name = "Unnamed Peer"
|
peer_name = "Unnamed Peer"
|
||||||
if isinstance(p.payload, dict):
|
if isinstance(p.payload, dict):
|
||||||
peer_name = p.payload.get("name") or p.payload.get("NAME") or peer_name
|
peer_name = p.payload.get("name") or p.payload.get("NAME") or peer_name
|
||||||
peer_options.append(f"{p.address} ({peer_name})")
|
|
||||||
|
status_label = ""
|
||||||
|
if p.status_code == 401:
|
||||||
|
status_label = " [Locked/401]"
|
||||||
|
|
||||||
|
peer_options.append(f"{p.address} ({peer_name}){status_label}")
|
||||||
|
|
||||||
def on_peer_selected(peer_choice: str):
|
def on_peer_selected(peer_choice: str):
|
||||||
if not peer_choice: return
|
if not peer_choice: return
|
||||||
@@ -796,9 +849,12 @@ class ConfigModal(ModalScreen):
|
|||||||
"PORT": "999",
|
"PORT": "999",
|
||||||
"SERVICE": "remote"
|
"SERVICE": "remote"
|
||||||
}
|
}
|
||||||
if match and match.service_hint == "hydrus":
|
if match:
|
||||||
new_config["SERVICE"] = "hydrus"
|
if match.service_hint == "hydrus":
|
||||||
new_config["PORT"] = "45869"
|
new_config["SERVICE"] = "hydrus"
|
||||||
|
new_config["PORT"] = "45869"
|
||||||
|
if match.status_code == 401:
|
||||||
|
self.notify("This peer requires an API Key. Please enter it in the settings panel.", severity="warning")
|
||||||
|
|
||||||
store_cfg[new_name] = new_config
|
store_cfg[new_name] = new_config
|
||||||
|
|
||||||
|
|||||||
@@ -1,151 +1,153 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
import sys
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
|
||||||
|
# Add project root to sys.path
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
if str(root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(root))
|
||||||
|
|
||||||
from cmdlet._shared import Cmdlet, CmdletArg
|
from cmdlet._shared import Cmdlet, CmdletArg
|
||||||
from SYS.result_table import ResultTable
|
|
||||||
from SYS.config import load_config
|
from SYS.config import load_config
|
||||||
|
from SYS.result_table import ResultTable
|
||||||
from API import zerotier as zt
|
from API import zerotier as zt
|
||||||
|
|
||||||
def exec(pipe: Sequence[Any], args: Sequence[str], options: Dict[str, Any]) -> ResultTable:
|
def exec_zerotier(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||||
table = ResultTable(title="ZeroTier Status", max_columns=10)
|
# Use provided config or fall back to CWD load
|
||||||
|
cfg = config if config else load_config(Path.cwd())
|
||||||
|
|
||||||
cfg = load_config()
|
table = ResultTable("ZeroTier Status")
|
||||||
|
|
||||||
# 1. Local Node Status
|
# 1. Local Hub Status
|
||||||
node_id = "unknown"
|
row = table.add_row()
|
||||||
|
row.add_column("TYPE", "HOST")
|
||||||
|
row.add_column("NAME", "localhost")
|
||||||
|
|
||||||
|
# Try to get node ID via CLI info
|
||||||
|
node_id = "???"
|
||||||
try:
|
try:
|
||||||
# Best effort to get local node ID
|
if hasattr(zt, "_run_cli_json"):
|
||||||
st = zt._run_cli_json("status")
|
info = zt._run_cli_json("info", "-j")
|
||||||
if isinstance(st, dict):
|
node_id = info.get("address", "???")
|
||||||
node_id = st.get("address") or node_id
|
except:
|
||||||
except: pass
|
pass
|
||||||
|
row.add_column("ID", node_id)
|
||||||
|
|
||||||
# 2. Hosting Status
|
# Check if local server is responsive
|
||||||
zt_net = cfg.get("networking", {}).get("zerotier", {})
|
try:
|
||||||
serve_target = zt_net.get("serve")
|
# endpoint is /health for remote_storage_server
|
||||||
if serve_target:
|
# We try 127.0.0.1 first with a more generous timeout
|
||||||
port = zt_net.get("port") or 999
|
# Using a list of potential local hits to be robust against Windows networking quirks
|
||||||
net_id = zt_net.get("network_id") or "all"
|
import socket
|
||||||
|
|
||||||
status = "OFFLINE"
|
def get_local_ip():
|
||||||
detail = ""
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
# Try to find the local ZT address for this network
|
s.connect(("8.8.8.8", 80))
|
||||||
zt_addrs = []
|
ip = s.getsockname()[0]
|
||||||
if net_id and net_id != "all":
|
s.close()
|
||||||
zt_addrs = zt.get_assigned_addresses(net_id)
|
return ip
|
||||||
|
except:
|
||||||
# We probe localhost for hosting status, but show ZT IP in the table
|
return None
|
||||||
display_addr = zt_addrs[0] if zt_addrs else "localhost"
|
|
||||||
|
|
||||||
# Try probes
|
|
||||||
# Using 127.0.0.1 is often more reliable than 'localhost' on Windows
|
|
||||||
probe_targets = [f"http://127.0.0.1:{port}/health"]
|
|
||||||
if zt_addrs:
|
|
||||||
probe_targets.insert(0, f"http://{zt_addrs[0]}:{port}/health")
|
|
||||||
|
|
||||||
from API.HTTP import HTTPClient
|
hosts = ["127.0.0.1", "localhost"]
|
||||||
with HTTPClient(timeout=1.0, retries=0) as client:
|
local_ip = get_local_ip()
|
||||||
for url in probe_targets:
|
if local_ip:
|
||||||
|
hosts.append(local_ip)
|
||||||
|
|
||||||
|
success = False
|
||||||
|
last_err = ""
|
||||||
|
|
||||||
|
# Try multiple times if server just started
|
||||||
|
import time
|
||||||
|
for attempt in range(3):
|
||||||
|
for host in hosts:
|
||||||
try:
|
try:
|
||||||
resp = client.get(url)
|
resp = requests.get(f"http://{host}:999/health", timeout=3, proxies={"http": None, "https": None})
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
status = "ONLINE"
|
row.add_column("STATUS", "ONLINE")
|
||||||
payload = resp.json()
|
row.add_column("ADDRESS", f"{host}:999")
|
||||||
detail = f"Serving {payload.get('name') or serve_target}"
|
row.add_column("DETAIL", f"Serving {cfg.get('active_store', 'default')}")
|
||||||
|
success = True
|
||||||
break
|
break
|
||||||
else:
|
elif resp.status_code == 401:
|
||||||
status = f"HTTP {resp.status_code}"
|
row.add_column("STATUS", "Serving (Locked)")
|
||||||
except Exception as exc:
|
row.add_column("ADDRESS", f"{host}:999")
|
||||||
if not detail: # Keep the first failure reason if all fail
|
row.add_column("DETAIL", "401 Unauthorized - API Key required")
|
||||||
detail = f"Probe failed: {exc}"
|
success = True
|
||||||
|
break
|
||||||
if status == "OFFLINE" and not zt_addrs:
|
except Exception as e:
|
||||||
detail = "No ZeroTier IP assigned yet. Check 'zerotier-cli listnetworks'."
|
last_err = str(e)
|
||||||
|
continue
|
||||||
row = table.add_row()
|
if success:
|
||||||
row.add_column("TYPE", "HOST")
|
break
|
||||||
row.add_column("NAME", serve_target)
|
time.sleep(1) # Wait between attempts
|
||||||
row.add_column("ID", net_id)
|
|
||||||
row.add_column("ADDRESS", f"{display_addr}:{port}")
|
|
||||||
row.add_column("STATUS", status)
|
|
||||||
row.add_column("DETAIL", detail)
|
|
||||||
|
|
||||||
# 3. Connections (Remote Stores)
|
|
||||||
zt_stores = cfg.get("store", {}).get("zerotier", {})
|
|
||||||
if zt_stores:
|
|
||||||
for name, sconf in zt_stores.items():
|
|
||||||
net_id = sconf.get("NETWORK_ID") or sconf.get("network_id") or ""
|
|
||||||
host = sconf.get("HOST") or sconf.get("host") or ""
|
|
||||||
port = sconf.get("PORT") or sconf.get("port") or 999
|
|
||||||
svc = sconf.get("SERVICE") or sconf.get("service") or "remote"
|
|
||||||
|
|
||||||
status = "probing..."
|
|
||||||
detail = ""
|
|
||||||
|
|
||||||
if not host:
|
|
||||||
status = "MISCONFIGURED"
|
|
||||||
detail = "No host IP"
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
from API.HTTP import HTTPClient
|
|
||||||
with HTTPClient(timeout=2.0) as client:
|
|
||||||
# Paths depend on service type
|
|
||||||
path = "/api_version" if svc == "hydrus" else "/health"
|
|
||||||
resp = client.get(f"http://{host}:{port}{path}")
|
|
||||||
if resp.status_code == 200:
|
|
||||||
status = "ONLINE"
|
|
||||||
if svc == "remote":
|
|
||||||
p = resp.json()
|
|
||||||
detail = f"Remote store: {p.get('name', 'unknown')}"
|
|
||||||
else:
|
|
||||||
detail = "Hydrus API"
|
|
||||||
else:
|
|
||||||
status = f"HTTP {resp.status_code}"
|
|
||||||
except Exception as exc:
|
|
||||||
status = "OFFLINE"
|
|
||||||
detail = str(exc)
|
|
||||||
|
|
||||||
row = table.add_row()
|
|
||||||
row.add_column("TYPE", "REMOTE")
|
|
||||||
row.add_column("NAME", name)
|
|
||||||
row.add_column("ID", net_id)
|
|
||||||
row.add_column("ADDRESS", f"{host}:{port}")
|
|
||||||
row.add_column("STATUS", status)
|
|
||||||
row.add_column("DETAIL", detail)
|
|
||||||
|
|
||||||
# 4. Networking Networks (Raw ZT status)
|
|
||||||
try:
|
|
||||||
nets = zt.list_networks()
|
|
||||||
if not nets:
|
|
||||||
row = table.add_row()
|
|
||||||
row.add_column("TYPE", "INFO")
|
|
||||||
row.add_column("NAME", "ZeroTier CLI")
|
|
||||||
row.add_column("STATUS", "No networks found or CLI error")
|
|
||||||
row.add_column("DETAIL", f"CLI Path: {zt._get_cli_path() or 'Not found'}")
|
|
||||||
|
|
||||||
for n in nets:
|
if not success:
|
||||||
row = table.add_row()
|
row.add_column("STATUS", "OFFLINE")
|
||||||
row.add_column("TYPE", "NETWORK")
|
row.add_column("ADDRESS", "127.0.0.1:999")
|
||||||
row.add_column("NAME", n.name)
|
row.add_column("DETAIL", f"Server not responding on port 999. Last attempt ({hosts[-1]}): {last_err}")
|
||||||
row.add_column("ID", n.id)
|
|
||||||
row.add_column("ADDRESS", ", ".join(n.assigned_addresses))
|
|
||||||
row.add_column("STATUS", n.status)
|
|
||||||
row.add_column("DETAIL", "")
|
|
||||||
except Exception as exc:
|
|
||||||
row = table.add_row()
|
|
||||||
row.add_column("TYPE", "ERROR")
|
|
||||||
row.add_column("NAME", "ZeroTier CLI")
|
|
||||||
row.add_column("STATUS", "EXCEPTION")
|
|
||||||
row.add_column("DETAIL", str(exc))
|
|
||||||
|
|
||||||
return table
|
except Exception as e:
|
||||||
|
row.add_column("STATUS", "OFFLINE")
|
||||||
|
row.add_column("ADDRESS", "127.0.0.1:999")
|
||||||
|
row.add_column("DETAIL", f"Status check failed: {e}")
|
||||||
|
|
||||||
|
# 2. Add Networks
|
||||||
|
if zt.is_available():
|
||||||
|
try:
|
||||||
|
networks = zt.list_networks()
|
||||||
|
for net in networks:
|
||||||
|
row = table.add_row()
|
||||||
|
row.add_column("TYPE", "NETWORK")
|
||||||
|
row.add_column("NAME", getattr(net, "name", "Unnamed"))
|
||||||
|
row.add_column("ID", getattr(net, "id", ""))
|
||||||
|
|
||||||
|
status = getattr(net, "status", "OK")
|
||||||
|
assigned = getattr(net, "assigned_addresses", [])
|
||||||
|
ip_str = assigned[0] if assigned else ""
|
||||||
|
|
||||||
|
row.add_column("STATUS", status)
|
||||||
|
row.add_column("ADDRESS", ip_str)
|
||||||
|
except Exception as e:
|
||||||
|
row = table.add_row()
|
||||||
|
row.add_column("TYPE", "ERROR")
|
||||||
|
row.add_column("DETAIL", f"Failed to list networks: {e}")
|
||||||
|
else:
|
||||||
|
row = table.add_row()
|
||||||
|
row.add_column("TYPE", "SYSTEM")
|
||||||
|
row.add_column("NAME", "ZeroTier")
|
||||||
|
row.add_column("STATUS", "NOT FOUND")
|
||||||
|
row.add_column("DETAIL", "zerotier-cli not in path")
|
||||||
|
|
||||||
|
# Output
|
||||||
|
try:
|
||||||
|
from cmdnat.out_table import TableOutput
|
||||||
|
TableOutput().render(table)
|
||||||
|
except Exception:
|
||||||
|
# Fallback for raw CLI
|
||||||
|
print(f"\n--- {table.title} ---")
|
||||||
|
for r in table.rows:
|
||||||
|
# Use the get_column method from ResultRow
|
||||||
|
t = r.get_column("TYPE") or ""
|
||||||
|
n = r.get_column("NAME") or ""
|
||||||
|
s = r.get_column("STATUS") or ""
|
||||||
|
a = r.get_column("ADDRESS") or ""
|
||||||
|
id = r.get_column("ID") or ""
|
||||||
|
d = r.get_column("DETAIL") or ""
|
||||||
|
print(f"[{t:7}] {n:15} | {s:15} | {a:20} | {id} | {d}")
|
||||||
|
print("-" * 100)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
CMDLET = Cmdlet(
|
CMDLET = Cmdlet(
|
||||||
name=".zerotier",
|
name=".zerotier",
|
||||||
summary="Check ZeroTier hosting and connection status",
|
summary="Check ZeroTier node and hosting status",
|
||||||
usage=".zerotier",
|
usage=".zerotier",
|
||||||
exec=exec
|
exec=exec_zerotier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
exec_zerotier(None, sys.argv[1:], {})
|
||||||
@@ -44,6 +44,8 @@ import sys
|
|||||||
import json
|
import json
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -81,6 +83,46 @@ except ImportError:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_parent(parent_pid: int):
|
||||||
|
"""Monitor the parent process and shut down if it dies."""
|
||||||
|
if parent_pid <= 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Monitoring parent process {parent_pid}")
|
||||||
|
|
||||||
|
# On Windows, we might need a different approach if os.kill(pid, 0) is unreliable
|
||||||
|
is_windows = sys.platform == "win32"
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if is_windows:
|
||||||
|
# OpenProcess with PROCESS_QUERY_LIMITED_INFORMATION (0x1000)
|
||||||
|
# This is safer than os.kill on Windows for existence checks
|
||||||
|
import ctypes
|
||||||
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||||
|
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, parent_pid)
|
||||||
|
if handle:
|
||||||
|
exit_code = ctypes.c_ulong()
|
||||||
|
ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))
|
||||||
|
ctypes.windll.kernel32.CloseHandle(handle)
|
||||||
|
# STILL_ACTIVE is 259
|
||||||
|
if exit_code.value != 259:
|
||||||
|
logger.info(f"Parent process {parent_pid} finished with code {exit_code.value}. Shutting down...")
|
||||||
|
os._exit(0)
|
||||||
|
else:
|
||||||
|
# On Windows, sometimes we lose access to the handle if the parent is transitioning
|
||||||
|
# or if it was started from a shell that already closed.
|
||||||
|
# We'll ignore handle failures for now unless we want to be very strict.
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
os.kill(parent_pid, 0)
|
||||||
|
except Exception as e:
|
||||||
|
# Parent is dead or inaccessible
|
||||||
|
logger.info(f"Parent process {parent_pid} no longer accessible: {e}. Shutting down server...")
|
||||||
|
os._exit(0)
|
||||||
|
time.sleep(5) # Increase check interval to be less aggressive
|
||||||
|
|
||||||
|
|
||||||
def get_local_ip() -> Optional[str]:
|
def get_local_ip() -> Optional[str]:
|
||||||
"""Get the local IP address that would be used for external connections."""
|
"""Get the local IP address that would be used for external connections."""
|
||||||
import socket
|
import socket
|
||||||
@@ -594,9 +636,25 @@ def main():
|
|||||||
help="API key for authentication (optional)"
|
help="API key for authentication (optional)"
|
||||||
)
|
)
|
||||||
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
||||||
|
parser.add_argument(
|
||||||
|
"--monitor",
|
||||||
|
action="store_true",
|
||||||
|
help="Shut down if parent process dies"
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Start monitor thread if requested
|
||||||
|
if args.monitor:
|
||||||
|
ppid = os.getppid()
|
||||||
|
if ppid > 1:
|
||||||
|
monitor_thread = threading.Thread(
|
||||||
|
target=monitor_parent,
|
||||||
|
args=(ppid, ),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
monitor_thread.start()
|
||||||
|
|
||||||
global STORAGE_PATH, API_KEY
|
global STORAGE_PATH, API_KEY
|
||||||
STORAGE_PATH = Path(args.storage_path).resolve()
|
STORAGE_PATH = Path(args.storage_path).resolve()
|
||||||
API_KEY = args.api_key
|
API_KEY = args.api_key
|
||||||
|
|||||||
Reference in New Issue
Block a user