f
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user