This commit is contained in:
2026-01-14 01:33:25 -08:00
parent 226367a6ea
commit e27e13b64c
10 changed files with 760 additions and 63 deletions

View File

@@ -17,6 +17,11 @@ from TUI.modalscreen.selection_modal import SelectionModal
class ConfigModal(ModalScreen):
"""A modal for editing the configuration."""
BINDINGS = [
("ctrl+v", "paste", "Paste"),
("ctrl+c", "copy", "Copy"),
]
CSS = """
ConfigModal {
align: center middle;
@@ -63,8 +68,19 @@ class ConfigModal(ModalScreen):
color: $accent;
}
.field-row {
height: 5;
margin-bottom: 1;
align: left middle;
}
.config-input {
width: 100%;
width: 1fr;
}
.paste-btn {
width: 10;
margin-left: 1;
}
#config-actions {
@@ -115,6 +131,7 @@ class ConfigModal(ModalScreen):
yield Label("Categories", classes="config-label")
with ListView(id="category-list"):
yield ListItem(Label("Global Settings"), id="cat-globals")
yield ListItem(Label("Networking"), id="cat-networking")
yield ListItem(Label("Stores"), id="cat-stores")
yield ListItem(Label("Providers"), id="cat-providers")
@@ -124,13 +141,14 @@ class ConfigModal(ModalScreen):
yield Button("Save", variant="success", id="save-btn")
yield Button("Add Store", variant="primary", id="add-store-btn")
yield Button("Add Provider", variant="primary", id="add-provider-btn")
yield Button("Add Net", variant="primary", id="add-net-btn")
yield Button("Back", id="back-btn")
yield Button("Close", variant="error", id="cancel-btn")
def on_mount(self) -> None:
self.query_one("#add-store-btn", Button).display = False
self.query_one("#add-provider-btn", Button).display = False
self.query_one("#back-btn", Button).display = False
self.query_one("#add-net-btn", Button).display = False
self.refresh_view()
def refresh_view(self) -> None:
@@ -146,6 +164,7 @@ class ConfigModal(ModalScreen):
try:
self.query_one("#add-store-btn", Button).display = (self.current_category == "stores" and self.editing_item_name is None)
self.query_one("#add-provider-btn", Button).display = (self.current_category == "providers" and self.editing_item_name is None)
self.query_one("#add-net-btn", Button).display = (self.current_category == "networking" and self.editing_item_name is None)
self.query_one("#back-btn", Button).display = (self.editing_item_name is not None)
self.query_one("#save-btn", Button).display = (self.editing_item_name is not None or self.current_category == "globals")
except Exception:
@@ -158,6 +177,8 @@ class ConfigModal(ModalScreen):
self.render_item_editor(container)
elif self.current_category == "globals":
self.render_globals(container)
elif self.current_category == "networking":
self.render_networking(container)
elif self.current_category == "stores":
self.render_stores(container)
elif self.current_category == "providers":
@@ -202,7 +223,10 @@ class ConfigModal(ModalScreen):
sel = Select(select_options, value=current_val, id=inp_id)
container.mount(sel)
else:
container.mount(Input(value=current_val, id=inp_id, classes="config-input"))
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value=current_val, id=inp_id, classes="config-input"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
# Show any other top-level keys not in schema
@@ -214,9 +238,66 @@ class ConfigModal(ModalScreen):
inp_id = f"global-{idx}"
self._input_id_map[inp_id] = k
container.mount(Label(k))
container.mount(Input(value=str(v), id=inp_id, classes="config-input"))
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value=str(v), id=inp_id, classes="config-input"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
def render_networking(self, container: ScrollableContainer) -> None:
container.mount(Label("ZeroTier Networks (local)", classes="config-label"))
from API import zerotier as zt
# Show whether we have an explicit authtoken available and its source
try:
token_src = zt._get_token_path()
except Exception:
token_src = None
if token_src == "env":
container.mount(Static("Auth: authtoken provided via env var (ZEROTIER_AUTH_TOKEN) — no admin required", classes="config-note"))
elif token_src:
container.mount(Static(f"Auth: authtoken file found: {token_src} — no admin required", classes="config-note"))
else:
container.mount(Static("Auth: authtoken not found in workspace; TUI may need admin to join networks", classes="config-warning"))
try:
local_nets = zt.list_networks()
if not local_nets:
container.mount(Static("No active ZeroTier networks found on this machine."))
else:
for n in local_nets:
row = Horizontal(
Static(f"{n.name} [{n.id}] - {n.status}", classes="item-label"),
Button("Leave", variant="error", id=f"zt-leave-{n.id}"),
classes="item-row"
)
container.mount(row)
except Exception as exc:
container.mount(Static(f"Error listing ZeroTier networks: {exc}"))
container.mount(Rule())
container.mount(Label("Networking Services", classes="config-label"))
net = self.config_data.get("networking", {})
if not net:
container.mount(Static("No networking services configured."))
else:
idx = 0
for ntype, conf in net.items():
edit_id = f"edit-net-{idx}"
del_id = f"del-net-{idx}"
self._button_id_map[edit_id] = ("edit", "networking", ntype)
self._button_id_map[del_id] = ("del", "networking", ntype)
idx += 1
row = Horizontal(
Static(ntype, classes="item-label"),
Button("Edit", id=edit_id),
Button("Delete", variant="error", id=del_id),
classes="item-row"
)
container.mount(row)
def render_stores(self, container: ScrollableContainer) -> None:
container.mount(Label("Configured Stores", classes="config-label"))
stores = self.config_data.get("store", {})
@@ -310,6 +391,23 @@ class ConfigModal(ModalScreen):
provider_schema_map[k.upper()] = field_def
except Exception:
pass
# Fetch Networking schema
if item_type == "networking":
if item_name == "zerotier":
schema = [
{"key": "api_key", "label": "ZeroTier Central API Token", "default": "", "secret": True},
{"key": "network_id", "label": "Network ID to Join", "default": ""},
]
for f in schema:
provider_schema_map[f["key"].upper()] = f
# Use columns for better layout of inputs with paste buttons
container.mount(Label("Edit Settings"))
# render_item_editor will handle the inputs for us if we set these
# but wait, render_item_editor is called from refresh_view, not here.
# actually we don't need to do anything else here because refresh_view calls render_item_editor
# which now handles the paste buttons.
# Show all existing keys
existing_keys_upper = set()
@@ -351,10 +449,13 @@ class ConfigModal(ModalScreen):
sel = Select(select_options, value=current_val, id=inp_id)
container.mount(sel)
else:
row = Horizontal(classes="field-row")
container.mount(row)
inp = Input(value=str(v), id=inp_id, classes="config-input")
if is_secret:
inp.password = True
container.mount(inp)
row.mount(inp)
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
# Add required/optional fields from schema that are missing
@@ -378,12 +479,15 @@ class ConfigModal(ModalScreen):
sel = Select(select_options, value=default_val, id=inp_id)
container.mount(sel)
else:
row = Horizontal(classes="field-row")
container.mount(row)
inp = Input(value=default_val, id=inp_id, classes="config-input")
if field_def.get("secret"):
inp.password = True
if field_def.get("placeholder"):
inp.placeholder = field_def.get("placeholder")
container.mount(inp)
row.mount(inp)
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
# If it's a store, we might have required keys (legacy check fallback)
@@ -398,7 +502,10 @@ class ConfigModal(ModalScreen):
container.mount(Label(rk))
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = rk
container.mount(Input(value="", id=inp_id, classes="config-input"))
row = Horizontal(classes="field-row")
container.mount(row)
row.mount(Input(value="", id=inp_id, classes="config-input"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
idx += 1
# If it's a provider, we might have required keys (legacy check fallback)
@@ -414,7 +521,10 @@ class ConfigModal(ModalScreen):
container.mount(Label(rk))
inp_id = f"item-{idx}"
self._input_id_map[inp_id] = rk
container.mount(Input(value="", id=inp_id, classes="config-input"))
row = Horizontal(classes="field-row")
row.mount(Input(value="", id=inp_id, classes="config-input"))
row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn"))
container.mount(row)
idx += 1
except Exception:
pass
@@ -428,6 +538,8 @@ class ConfigModal(ModalScreen):
if not event.item: return
if event.item.id == "cat-globals":
self.current_category = "globals"
elif event.item.id == "cat-networking":
self.current_category = "networking"
elif event.item.id == "cat-stores":
self.current_category = "stores"
elif event.item.id == "cat-providers":
@@ -451,7 +563,24 @@ class ConfigModal(ModalScreen):
if not self.validate_current_editor():
return
try:
self.save_all()
# If we are editing networking.zerotier, check if network_id changed and join it
if self.editing_item_type == "networking" and self.editing_item_name == "zerotier":
old_id = str(self.config_data.get("networking", {}).get("zerotier", {}).get("network_id") or "").strip()
self.save_all()
new_id = str(self.config_data.get("networking", {}).get("zerotier", {}).get("network_id") or "").strip()
if new_id and new_id != old_id:
from API import zerotier as zt
try:
if zt.join_network(new_id):
self.notify(f"Joined ZeroTier network {new_id}")
else:
self.notify(f"Config saved, but failed to join network {new_id}", severity="warning")
except Exception as exc:
self.notify(f"Join error: {exc}", severity="error")
else:
self.save_all()
self.notify("Configuration saved!")
# Return to the main list view within the current category
self.editing_item_name = None
@@ -459,6 +588,15 @@ class ConfigModal(ModalScreen):
self.refresh_view()
except Exception as exc:
self.notify(f"Save failed: {exc}", severity="error", timeout=10)
elif bid.startswith("zt-leave-"):
nid = bid.replace("zt-leave-", "")
from API import zerotier as zt
try:
zt.leave_network(nid)
self.notify(f"Left ZeroTier network {nid}")
self.refresh_view()
except Exception as exc:
self.notify(f"Failed to leave: {exc}", severity="error")
elif bid in self._button_id_map:
action, itype, name = self._button_id_map[bid]
if action == "edit":
@@ -474,6 +612,9 @@ class ConfigModal(ModalScreen):
elif itype == "provider":
if "provider" in self.config_data and name in self.config_data["provider"]:
del self.config_data["provider"][name]
elif itype == "networking":
if "networking" in self.config_data and name in self.config_data["networking"]:
del self.config_data["networking"][name]
self.refresh_view()
elif bid == "add-store-btn":
all_classes = _discover_store_classes()
@@ -499,9 +640,102 @@ class ConfigModal(ModalScreen):
except Exception:
pass
self.app.push_screen(SelectionModal("Select Provider Type", options), callback=self.on_provider_type_selected)
elif bid == "add-net-btn":
options = ["zerotier"]
self.app.push_screen(SelectionModal("Select Networking Service", options), callback=self.on_net_type_selected)
elif bid.startswith("paste-"):
# Programmatic paste button
target_id = bid.replace("paste-", "")
try:
inp = self.query_one(f"#{target_id}", Input)
self.focus_and_paste(inp)
except Exception:
pass
async def focus_and_paste(self, inp: Input) -> None:
if hasattr(self.app, "paste_from_clipboard"):
text = await self.app.paste_from_clipboard()
if text:
# Replace selection or append
inp.value = str(inp.value) + text
inp.focus()
self.notify("Pasted from clipboard")
else:
self.notify("Clipboard not supported in this terminal", severity="warning")
async def action_paste(self) -> None:
focused = self.focused
if isinstance(focused, Input):
await self.focus_and_paste(focused)
async def action_copy(self) -> None:
focused = self.focused
if isinstance(focused, Input) and focused.value:
if hasattr(self.app, "copy_to_clipboard"):
self.app.copy_to_clipboard(str(focused.value))
self.notify("Copied to clipboard")
else:
self.notify("Clipboard not supported in this terminal", severity="warning")
def on_store_type_selected(self, stype: str) -> None:
if not stype: return
if stype == "zerotier":
# Push a discovery screen
from TUI.modalscreen.selection_modal import SelectionModal
from API import zerotier as zt
# Find all joined networks
joined = zt.list_networks()
if not joined:
self.notify("Error: Join a ZeroTier network first in 'Networking'", severity="error")
return
self.notify("Scanning ZeroTier networks for peers...")
all_peers = []
central_token = self.config_data.get("networking", {}).get("zerotier", {}).get("api_key")
for net in joined:
probes = zt.discover_services_on_network(net.id, ports=[999, 45869, 5000], api_token=central_token)
for p in probes:
label = f"{p.service_hint or 'service'} @ {p.address}:{p.port} ({net.name})"
all_peers.append((label, p, net.id))
if not all_peers:
self.notify("No services found on port 999. Use manual setup.", severity="warning")
else:
options = [p[0] for p in all_peers]
def on_peer_selected(choice: str):
if not choice: return
# Find the probe data
match = next((p for p in all_peers if p[0] == choice), None)
if not match: return
label, probe, net_id = match
# Create a specific name based on host
safe_host = str(probe.address).replace(".", "_")
new_name = f"zt_{safe_host}"
if "store" not in self.config_data: self.config_data["store"] = {}
store_cfg = self.config_data["store"].setdefault("zerotier", {})
new_config = {
"NAME": new_name,
"NETWORK_ID": net_id,
"HOST": probe.address,
"PORT": probe.port,
"SERVICE": "hydrus" if probe.service_hint == "hydrus" else "remote"
}
store_cfg[new_name] = new_config
self.editing_item_type = "store-zerotier"
self.editing_item_name = new_name
self.refresh_view()
self.app.push_screen(SelectionModal("Discovered ZeroTier Services", options), callback=on_peer_selected)
return
new_name = f"new_{stype}"
if "store" not in self.config_data:
self.config_data["store"] = {}
@@ -566,6 +800,18 @@ class ConfigModal(ModalScreen):
self.editing_item_name = ptype
self.refresh_view()
def on_net_type_selected(self, ntype: str) -> None:
if not ntype: return
self.editing_item_type = "networking"
self.editing_item_name = ntype
# Ensure it exists in config_data
net = self.config_data.setdefault("networking", {})
if ntype not in net:
net[ntype] = {}
self.refresh_view()
def _update_config_value(self, widget_id: str, value: Any) -> None:
if widget_id not in self._input_id_map:
return