""" Server add/edit dialog — modal window with all server fields. Form adapts visible fields based on selected server type. """ import customtkinter as ctk from core.server_store import SERVER_TYPES, DEFAULT_PORTS from core.i18n import t from core.icons import icon_text, type_display, type_from_display, make_icon_button, reconfigure_icon_button # Which conditional fields to show for each server type. # Fields NOT listed here (alias, ip, type+port, skip_check, notes, buttons) # are always visible. FIELD_MAP = { "ssh": ["user", "password", "totp", "bind_interface"], "telnet": ["user", "password"], "winrm": ["user", "password", "use_ssl"], "mariadb": ["user", "password", "database"], "mssql": ["user", "password", "database"], "postgresql": ["user", "password", "database"], "redis": ["password", "db_index", "use_ssl"], "grafana": ["user", "password", "api_token", "use_ssl"], "prometheus": ["use_ssl"], "rdp": ["user", "password", "rdp_resolution", "rdp_quality", "rdp_clipboard", "rdp_drives", "rdp_printers"], "vnc": ["password"], "s3": ["access_key", "secret_key", "bucket", "use_ssl"], } def _get_network_interfaces() -> list[tuple[str, str]]: """Return list of (name, ipv4_address) for available network interfaces.""" try: import psutil result = [] for name, addrs in psutil.net_if_addrs().items(): for addr in addrs: if addr.family.name == "AF_INET" and addr.address != "127.0.0.1": result.append((name, addr.address)) return result except Exception: return [] class ServerDialog(ctk.CTkToplevel): def __init__(self, master, store, server: dict | None = None): super().__init__(master) self.store = store self.editing = server self._original_alias = server["alias"] if server else None self.result = None self.title(t("edit_server") if server else t("add_server")) self.geometry("450x720") self.resizable(False, False) # transient BEFORE grab_set — prevents focus lock on minimize self.transient(master) self.grab_set() self.focus_force() # Release grab on close (prevents stuck app) self.protocol("WM_DELETE_WINDOW", self._on_close) self._field_frames: dict[str, ctk.CTkFrame] = {} self._build_ui(server) def _build_ui(self, server: dict | None): pad = {"padx": 20, "pady": (5, 0)} entry_pad = {"padx": 20, "pady": (2, 5)} # ── Always visible: Alias ── ctk.CTkLabel(self, text=t("alias"), anchor="w").pack(fill="x", **pad) self.alias_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_alias")) self.alias_entry.pack(fill="x", **entry_pad) # ── Always visible: IP ── ctk.CTkLabel(self, text=t("ip"), anchor="w").pack(fill="x", **pad) self.ip_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_ip")) self.ip_entry.pack(fill="x", **entry_pad) # ── Always visible: Type + Port row ── row = ctk.CTkFrame(self, fg_color="transparent") row.pack(fill="x", padx=20, pady=(5, 5)) type_frame = ctk.CTkFrame(row, fg_color="transparent") type_frame.pack(side="left", fill="x", expand=True, padx=(0, 5)) ctk.CTkLabel(type_frame, text=t("type"), anchor="w").pack(fill="x") self._type_display_values = [type_display(t) for t in SERVER_TYPES] self.type_var = ctk.StringVar(value=type_display("ssh")) self.type_menu = ctk.CTkOptionMenu( type_frame, values=self._type_display_values, variable=self.type_var, command=self._on_type_change ) self.type_menu.pack(fill="x") port_frame = ctk.CTkFrame(row, fg_color="transparent") port_frame.pack(side="left", fill="x", expand=True, padx=(5, 0)) ctk.CTkLabel(port_frame, text=t("port"), anchor="w").pack(fill="x") self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port")) self.port_entry.pack(fill="x") # ── Group selector (only when groups exist) ── self._group_id_map: dict[str, str | None] = {} self._group_var = ctk.StringVar(value=t("no_group")) groups = self.store.get_groups() if groups: group_frame = ctk.CTkFrame(self, fg_color="transparent") group_frame.pack(fill="x", padx=20, pady=(5, 5)) ctk.CTkLabel(group_frame, text=t("group"), anchor="w").pack(fill="x") no_group_label = t("no_group") group_values = [no_group_label] self._group_id_map[no_group_label] = None for g in groups: display = f"\u25cf {g['name']}" group_values.append(display) self._group_id_map[display] = g["id"] ctk.CTkOptionMenu(group_frame, values=group_values, variable=self._group_var).pack(fill="x") # Pre-select if editing if server and server.get("group"): for display, gid in self._group_id_map.items(): if gid == server.get("group"): self._group_var.set(display) break # ── Conditional fields container — all packed here, shown/hidden dynamically ── # We use self as parent but wrap each field group in a frame for easy show/hide. # --- bind_interface --- f = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("network_interface"), anchor="w").pack(fill="x", **pad) self._iface_map: dict[str, str] = {} ifaces = _get_network_interfaces() auto_label = t("auto_default") iface_values = [auto_label] for name, ip in ifaces: label = f"{name} ({ip})" iface_values.append(label) self._iface_map[label] = ip self._iface_var = ctk.StringVar(value=auto_label) self._iface_menu = ctk.CTkOptionMenu(f, values=iface_values, variable=self._iface_var) self._iface_menu.pack(fill="x", **entry_pad) self._field_frames["bind_interface"] = f # --- user --- f = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("username"), anchor="w").pack(fill="x", **pad) self.user_entry = ctk.CTkEntry(f, placeholder_text=t("placeholder_user")) self.user_entry.pack(fill="x", **entry_pad) self._field_frames["user"] = f # --- password --- f = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("password"), anchor="w").pack(fill="x", **pad) pass_inner = ctk.CTkFrame(f, fg_color="transparent") pass_inner.pack(fill="x", padx=20, pady=(2, 5)) self.password_entry = ctk.CTkEntry(pass_inner, show="*", placeholder_text=t("placeholder_password")) self.password_entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) self.show_pass = make_icon_button(pass_inner, "eye", t("show"), width=70, command=self._toggle_password) self.show_pass.pack(side="right") self._pass_visible = False self._field_frames["password"] = f # --- totp --- f = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("totp_secret_dialog"), anchor="w").pack(fill="x", **pad) self.totp_entry = ctk.CTkEntry(f, placeholder_text=t("placeholder_totp_secret"), font=ctk.CTkFont(family="Consolas", size=12)) self.totp_entry.pack(fill="x", **entry_pad) self._field_frames["totp"] = f # --- database --- f = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("database"), anchor="w").pack(fill="x", **pad) self.database_entry = ctk.CTkEntry(f, placeholder_text="mydb") self.database_entry.pack(fill="x", **entry_pad) self._field_frames["database"] = f # --- db_index --- f = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("db_index"), anchor="w").pack(fill="x", **pad) self.db_index_entry = ctk.CTkEntry(f, placeholder_text="0") self.db_index_entry.pack(fill="x", **entry_pad) self._field_frames["db_index"] = f # --- api_token --- f = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("api_token"), anchor="w").pack(fill="x", **pad) self.api_token_entry = ctk.CTkEntry(f, show="*", placeholder_text=t("placeholder_api_token")) self.api_token_entry.pack(fill="x", **entry_pad) self._field_frames["api_token"] = f # --- rdp_resolution --- f = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("rdp_resolution"), anchor="w").pack(fill="x", **pad) self._rdp_resolution_var = ctk.StringVar(value=t("rdp_resolution_auto")) resolution_values = [ t("rdp_resolution_auto"), "800\u00d7600", "1024\u00d7768", "1280\u00d71024", "1366\u00d7768", "1600\u00d7900", "1920\u00d71080", ] self._rdp_resolution_menu = ctk.CTkOptionMenu(f, values=resolution_values, variable=self._rdp_resolution_var) self._rdp_resolution_menu.pack(fill="x", **entry_pad) self._field_frames["rdp_resolution"] = f # --- rdp_quality --- f = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("rdp_quality"), anchor="w").pack(fill="x", **pad) self._rdp_quality_var = ctk.StringVar(value="auto") quality_values = [ t("rdp_quality_auto"), t("rdp_quality_lan"), t("rdp_quality_broadband"), t("rdp_quality_modem"), ] self._rdp_quality_map = { t("rdp_quality_auto"): "auto", t("rdp_quality_lan"): "lan", t("rdp_quality_broadband"): "broadband", t("rdp_quality_modem"): "modem", } self._rdp_quality_rmap = {v: k for k, v in self._rdp_quality_map.items()} self._rdp_quality_menu = ctk.CTkOptionMenu(f, values=quality_values, variable=self._rdp_quality_var) self._rdp_quality_menu.pack(fill="x", **entry_pad) self._rdp_quality_var.set(t("rdp_quality_auto")) self._field_frames["rdp_quality"] = f # --- rdp_clipboard --- f = ctk.CTkFrame(self, fg_color="transparent") self._rdp_clipboard_var = ctk.BooleanVar(value=True) ctk.CTkCheckBox(f, text=t("rdp_clipboard"), variable=self._rdp_clipboard_var).pack(fill="x", padx=20, pady=(8, 2)) self._field_frames["rdp_clipboard"] = f # --- rdp_drives --- f = ctk.CTkFrame(self, fg_color="transparent") self._rdp_drives_var = ctk.BooleanVar(value=False) ctk.CTkCheckBox(f, text=t("rdp_drives"), variable=self._rdp_drives_var).pack(fill="x", padx=20, pady=(4, 2)) self._field_frames["rdp_drives"] = f # --- rdp_printers --- f = ctk.CTkFrame(self, fg_color="transparent") self._rdp_printers_var = ctk.BooleanVar(value=False) ctk.CTkCheckBox(f, text=t("rdp_printers"), variable=self._rdp_printers_var).pack(fill="x", padx=20, pady=(4, 2)) self._field_frames["rdp_printers"] = f # --- access_key --- f = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("access_key"), anchor="w").pack(fill="x", **pad) self.access_key_entry = ctk.CTkEntry(f, placeholder_text="AKIAIOSFODNN7EXAMPLE") self.access_key_entry.pack(fill="x", **entry_pad) self._field_frames["access_key"] = f # --- secret_key --- f = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("secret_key"), anchor="w").pack(fill="x", **pad) self.secret_key_entry = ctk.CTkEntry(f, show="*", placeholder_text=t("placeholder_secret_key")) self.secret_key_entry.pack(fill="x", **entry_pad) self._field_frames["secret_key"] = f # --- bucket --- f = ctk.CTkFrame(self, fg_color="transparent") ctk.CTkLabel(f, text=t("bucket"), anchor="w").pack(fill="x", **pad) self.bucket_entry = ctk.CTkEntry(f, placeholder_text="my-bucket") self.bucket_entry.pack(fill="x", **entry_pad) self._field_frames["bucket"] = f # --- use_ssl --- f = ctk.CTkFrame(self, fg_color="transparent") self.use_ssl_var = ctk.BooleanVar(value=False) self.use_ssl_cb = ctk.CTkCheckBox(f, text=t("use_ssl"), variable=self.use_ssl_var) self.use_ssl_cb.pack(fill="x", padx=20, pady=(8, 2)) self._field_frames["use_ssl"] = f # ── Always visible: Skip status checks ── self.skip_check_var = ctk.BooleanVar(value=False) self.skip_check_cb = ctk.CTkCheckBox( self, text=t("skip_check"), variable=self.skip_check_var ) self.skip_check_cb.pack(fill="x", padx=20, pady=(8, 2)) # ── Always visible: Notes ── ctk.CTkLabel(self, text=t("notes"), anchor="w").pack(fill="x", **pad) self.notes_entry = ctk.CTkEntry(self, placeholder_text=t("placeholder_notes")) self.notes_entry.pack(fill="x", **entry_pad) # ── Always visible: Buttons ── btn_frame = ctk.CTkFrame(self, fg_color="transparent") btn_frame.pack(fill="x", padx=20, pady=(15, 20)) make_icon_button(btn_frame, "close", t("cancel"), fg_color="#6b7280", command=self.destroy).pack(side="left", expand=True, padx=(0, 5)) make_icon_button(btn_frame, "confirm", t("save"), command=self._save).pack(side="right", expand=True, padx=(5, 0)) # Fill values if editing if server: self.alias_entry.insert(0, server.get("alias", "")) self.ip_entry.insert(0, server.get("ip", "")) self.type_var.set(type_display(server.get("type", "ssh"))) self.port_entry.insert(0, str(server.get("port", 22))) self.user_entry.insert(0, server.get("user", "")) self.password_entry.insert(0, server.get("password", "")) self.totp_entry.insert(0, server.get("totp_secret", "")) self.skip_check_var.set(server.get("skip_check", False)) self.notes_entry.insert(0, server.get("notes", "")) self.database_entry.insert(0, server.get("database", "")) self.db_index_entry.insert(0, str(server.get("db_index", ""))) self.api_token_entry.insert(0, server.get("api_token", "")) self.use_ssl_var.set(server.get("use_ssl", False)) self.access_key_entry.insert(0, server.get("access_key", "")) self.secret_key_entry.insert(0, server.get("secret_key", "")) self.bucket_entry.insert(0, server.get("bucket", "")) # RDP settings res_raw = server.get("rdp_resolution", "auto") if res_raw == "auto": self._rdp_resolution_var.set(t("rdp_resolution_auto")) else: self._rdp_resolution_var.set(res_raw.replace("x", "\u00d7")) q_raw = server.get("rdp_quality", "auto") q_display = self._rdp_quality_rmap.get(q_raw, t("rdp_quality_auto")) self._rdp_quality_var.set(q_display) self._rdp_clipboard_var.set(server.get("rdp_clipboard", True)) self._rdp_drives_var.set(server.get("rdp_drives", False)) self._rdp_printers_var.set(server.get("rdp_printers", False)) # Restore network interface selection saved_ip = server.get("bind_interface") if saved_ip: found = False for label, ip in self._iface_map.items(): if ip == saved_ip: self._iface_var.set(label) found = True break if not found: unavail_label = f"? ({saved_ip})" self._iface_map[unavail_label] = saved_ip current_values = self._iface_menu.cget("values") current_values.append(unavail_label) self._iface_menu.configure(values=current_values) self._iface_var.set(unavail_label) # Apply field visibility for initial type self._apply_field_visibility(type_from_display(self.type_var.get())) def _apply_field_visibility(self, server_type: str): """Hide all conditional fields, then show only those for the given type.""" visible = set(FIELD_MAP.get(server_type, [])) for name, frame in self._field_frames.items(): if name in visible: frame.pack(fill="x", before=self.skip_check_cb) else: frame.pack_forget() def _on_type_change(self, value): raw_type = type_from_display(value) default_port = DEFAULT_PORTS.get(raw_type, 22) self.port_entry.delete(0, "end") self.port_entry.insert(0, str(default_port)) self._apply_field_visibility(raw_type) def _toggle_password(self): self._pass_visible = not self._pass_visible self.password_entry.configure(show="" if self._pass_visible else "*") reconfigure_icon_button(self.show_pass, "eye", t("hide") if self._pass_visible else t("show")) def _save(self): alias = self.alias_entry.get().strip() ip = self.ip_entry.get().strip() port_str = self.port_entry.get().strip() user = self.user_entry.get().strip() password = self.password_entry.get() server_type = type_from_display(self.type_var.get()) totp_secret = self.totp_entry.get().strip() notes = self.notes_entry.get().strip() # Validation if not alias: self._show_error(t("alias_required")) return if not ip: self._show_error(t("ip_required")) return try: port = int(port_str) if port_str else DEFAULT_PORTS.get(server_type, 22) except ValueError: self._show_error(t("port_must_be_number")) return if port < 1 or port > 65535: self._show_error(t("port_out_of_range")) return server_data = { "alias": alias, "ip": ip, "port": port, "user": user or "root", "password": password, "type": server_type, "notes": notes, } if totp_secret: server_data["totp_secret"] = totp_secret # Group assignment if self._group_id_map: selected_group = self._group_id_map.get(self._group_var.get()) if selected_group: server_data["group"] = selected_group if self.skip_check_var.get(): server_data["skip_check"] = True # Network interface binding iface_selection = self._iface_var.get() bind_ip = self._iface_map.get(iface_selection) if bind_ip: server_data["bind_interface"] = bind_ip # New conditional fields visible = set(FIELD_MAP.get(server_type, [])) if "database" in visible: db = self.database_entry.get().strip() if db: server_data["database"] = db if "db_index" in visible: db_idx = self.db_index_entry.get().strip() if db_idx: try: server_data["db_index"] = int(db_idx) except ValueError: self._show_error(t("db_index_must_be_number")) return if "api_token" in visible: token = self.api_token_entry.get().strip() if token: server_data["api_token"] = token if "access_key" in visible: ak = self.access_key_entry.get().strip() if ak: server_data["access_key"] = ak if "secret_key" in visible: sk = self.secret_key_entry.get() if sk: server_data["secret_key"] = sk if "bucket" in visible: bkt = self.bucket_entry.get().strip() if bkt: server_data["bucket"] = bkt if "use_ssl" in visible: if self.use_ssl_var.get(): server_data["use_ssl"] = True # RDP settings if "rdp_resolution" in visible: res_display = self._rdp_resolution_var.get() if res_display == t("rdp_resolution_auto"): server_data["rdp_resolution"] = "auto" else: server_data["rdp_resolution"] = res_display.replace("\u00d7", "x") if "rdp_quality" in visible: q_display = self._rdp_quality_var.get() server_data["rdp_quality"] = self._rdp_quality_map.get(q_display, "auto") if "rdp_clipboard" in visible: server_data["rdp_clipboard"] = self._rdp_clipboard_var.get() if "rdp_drives" in visible: server_data["rdp_drives"] = self._rdp_drives_var.get() if "rdp_printers" in visible: server_data["rdp_printers"] = self._rdp_printers_var.get() try: if self.editing: if alias != self._original_alias and self.store.get_server(alias): self._show_error(t("alias_exists").format(alias=alias)) return self.store.update_server(self._original_alias, server_data) else: self.store.add_server(server_data) self.result = server_data self.destroy() except ValueError as e: self._show_error(str(e)) def _on_close(self): """Release grab and destroy — prevents stuck app on minimize.""" try: self.grab_release() except Exception: pass self.destroy() def _show_error(self, message: str): # Simple error via title flash self.title(t("error_prefix").format(msg=message)) self.after(2000, lambda: self.title(t("edit_server") if self.editing else t("add_server")))