server groups: grouped sidebar, GroupDialog, context menus, i18n + cleanup old releases

- Groups CRUD in sidebar (create, rename, change color, reorder, delete)
- Collapsible group headers with color dots and server count
- "Move to Group" context menu on servers
- Group dropdown in ServerDialog (add/edit)
- 17 i18n keys (EN/RU/ZH)
- Search auto-expands groups
- Cleaned up old release binaries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chrome-storm-c442
2026-03-03 02:12:35 -05:00
parent 2f84429b10
commit c9e3ee8fc5
17 changed files with 852 additions and 24 deletions

View File

@@ -477,6 +477,22 @@ _EN = {
"update_mode_auto": "Full Auto", "update_mode_auto": "Full Auto",
"update_no_updates": "You're up to date!", "update_no_updates": "You're up to date!",
"update_not_frozen": "Updates only work in packaged (exe) mode", "update_not_frozen": "Updates only work in packaged (exe) mode",
# Groups
"group": "Group",
"no_group": "No group",
"ungrouped": "Ungrouped",
"add_group": "Add Group",
"edit_group": "Edit Group",
"rename_group": "Rename",
"delete_group": "Delete Group",
"delete_group_confirm": "Delete group '{name}'? Servers will become ungrouped.",
"group_name": "Group Name",
"group_color": "Color",
"group_name_required": "Group name is required",
"move_to_group": "Move to Group",
"move_up": "Move Up",
"move_down": "Move Down",
"change_color": "Change Color",
} }
_RU = { _RU = {
@@ -931,6 +947,22 @@ _RU = {
"update_mode_auto": "Полный авто", "update_mode_auto": "Полный авто",
"update_no_updates": "У вас последняя версия!", "update_no_updates": "У вас последняя версия!",
"update_not_frozen": "Обновления работают только в exe-режиме", "update_not_frozen": "Обновления работают только в exe-режиме",
# Groups
"group": "Группа",
"no_group": "Без группы",
"ungrouped": "Без группы",
"add_group": "Добавить группу",
"edit_group": "Редактировать группу",
"rename_group": "Переименовать",
"delete_group": "Удалить группу",
"delete_group_confirm": "Удалить группу '{name}'? Серверы станут без группы.",
"group_name": "Название группы",
"group_color": "Цвет",
"group_name_required": "Название группы обязательно",
"move_to_group": "Переместить в группу",
"move_up": "Вверх",
"move_down": "Вниз",
"change_color": "Изменить цвет",
} }
_ZH = { _ZH = {
@@ -1385,6 +1417,22 @@ _ZH = {
"update_mode_auto": "全自动", "update_mode_auto": "全自动",
"update_no_updates": "已是最新版本!", "update_no_updates": "已是最新版本!",
"update_not_frozen": "更新仅在打包(exe)模式下有效", "update_not_frozen": "更新仅在打包(exe)模式下有效",
# Groups
"group": "分组",
"no_group": "无分组",
"ungrouped": "未分组",
"add_group": "添加分组",
"edit_group": "编辑分组",
"rename_group": "重命名",
"delete_group": "删除分组",
"delete_group_confirm": "删除分组 '{name}'? 服务器将变为未分组。",
"group_name": "分组名称",
"group_color": "颜色",
"group_name_required": "分组名称为必填项",
"move_to_group": "移动到分组",
"move_up": "上移",
"move_down": "下移",
"change_color": "更改颜色",
} }
_TRANSLATIONS = { _TRANSLATIONS = {

110
gui/group_dialog.py Normal file
View File

@@ -0,0 +1,110 @@
"""
Dialog for creating / editing server groups.
"""
import customtkinter as ctk
from typing import Optional
from core.i18n import t
GROUP_COLORS = [
"#ef4444", # red
"#f97316", # orange
"#f59e0b", # amber
"#22c55e", # green
"#3b82f6", # blue
"#6366f1", # indigo
"#a855f7", # purple
"#ec4899", # pink
]
class GroupDialog(ctk.CTkToplevel):
"""Small dialog: group name + color picker (8 circles)."""
def __init__(self, master, store, group: Optional[dict] = None):
super().__init__(master)
self.store = store
self._group = group # None = add, dict = edit
self.result: Optional[dict] = None
self.title(t("edit_group") if group else t("add_group"))
self.geometry("340x200")
self.resizable(False, False)
self.transient(master)
self.grab_set()
# ── Name ──
ctk.CTkLabel(self, text=t("group_name"), anchor="w").pack(
fill="x", padx=20, pady=(15, 2))
self._name_var = ctk.StringVar(value=group["name"] if group else "")
self._name_entry = ctk.CTkEntry(self, textvariable=self._name_var)
self._name_entry.pack(fill="x", padx=20)
self._name_entry.focus()
# ── Color picker ──
ctk.CTkLabel(self, text=t("group_color"), anchor="w").pack(
fill="x", padx=20, pady=(10, 2))
color_frame = ctk.CTkFrame(self, fg_color="transparent")
color_frame.pack(fill="x", padx=20)
self._selected_color = group["color"] if group else GROUP_COLORS[0]
self._color_buttons: list[ctk.CTkButton] = []
for color in GROUP_COLORS:
btn = ctk.CTkButton(
color_frame, text="", width=28, height=28,
fg_color=color, hover_color=color,
border_width=3,
border_color=color,
corner_radius=14,
command=lambda c=color: self._pick_color(c),
)
btn.pack(side="left", padx=2)
self._color_buttons.append(btn)
self._highlight_selected_color()
# ── Buttons ──
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.pack(fill="x", padx=20, pady=(15, 10))
ctk.CTkButton(btn_frame, text=t("cancel"), width=80,
fg_color="gray", command=self.destroy).pack(side="left")
ctk.CTkButton(btn_frame, text=t("save"), width=80,
command=self._save).pack(side="right")
# Enter to save
self.bind("<Return>", lambda e: self._save())
def _pick_color(self, color: str):
self._selected_color = color
self._highlight_selected_color()
def _highlight_selected_color(self):
for btn in self._color_buttons:
fg = btn.cget("fg_color")
if fg == self._selected_color:
btn.configure(border_color="white")
else:
btn.configure(border_color=fg)
def _save(self):
name = self._name_var.get().strip()
if not name:
self._name_entry.focus()
return
if self._group:
# Edit mode
self.store.update_group(
self._group["id"], name=name, color=self._selected_color)
self.result = {"id": self._group["id"], "name": name,
"color": self._selected_color}
else:
# Add mode
group = self.store.add_group(name, self._selected_color)
self.result = group
self.destroy()

View File

@@ -99,6 +99,33 @@ class ServerDialog(ctk.CTkToplevel):
self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port")) self.port_entry = ctk.CTkEntry(port_frame, placeholder_text=t("placeholder_port"))
self.port_entry.pack(fill="x") 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 ── # ── 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. # We use self as parent but wrap each field group in a frame for easy show/hide.
@@ -347,6 +374,12 @@ class ServerDialog(ctk.CTkToplevel):
} }
if totp_secret: if totp_secret:
server_data["totp_secret"] = 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(): if self.skip_check_var.get():
server_data["skip_check"] = True server_data["skip_check"] = True

View File

@@ -1,8 +1,9 @@
""" """
Sidebar — server list with search, add/edit/delete buttons, context menu. Sidebar — server list with groups, search, add/edit/delete buttons, context menu.
""" """
import tkinter as tk import tkinter as tk
from tkinter import messagebox
import customtkinter as ctk import customtkinter as ctk
from core.i18n import t from core.i18n import t
from core.icons import ( from core.icons import (
@@ -10,6 +11,10 @@ from core.icons import (
) )
from gui.widgets.status_badge import StatusBadge from gui.widgets.status_badge import StatusBadge
GROUP_COLORS = [
"#ef4444", "#f97316", "#f59e0b", "#22c55e",
"#3b82f6", "#6366f1", "#a855f7", "#ec4899",
]
# Context menu: type → list of (i18n_key, tab_key_or_None) # Context menu: type → list of (i18n_key, tab_key_or_None)
_CONTEXT_ACTIONS = { _CONTEXT_ACTIONS = {
@@ -37,6 +42,7 @@ class Sidebar(ctk.CTkFrame):
self._server_frames: dict[str, ctk.CTkFrame] = {} self._server_frames: dict[str, ctk.CTkFrame] = {}
self._badges: dict[str, StatusBadge] = {} self._badges: dict[str, StatusBadge] = {}
self._session_indicators: dict[str, ctk.CTkLabel] = {} self._session_indicators: dict[str, ctk.CTkLabel] = {}
self._group_headers: dict[str, ctk.CTkFrame] = {}
self.pack_propagate(False) self.pack_propagate(False)
@@ -44,11 +50,21 @@ class Sidebar(ctk.CTkFrame):
self.title_label = ctk.CTkLabel(self, text=t("servers"), font=ctk.CTkFont(size=18, weight="bold")) self.title_label = ctk.CTkLabel(self, text=t("servers"), font=ctk.CTkFont(size=18, weight="bold"))
self.title_label.pack(padx=15, pady=(15, 5)) self.title_label.pack(padx=15, pady=(15, 5))
# Search # Search + Add Group button
search_frame = ctk.CTkFrame(self, fg_color="transparent")
search_frame.pack(fill="x", padx=10, pady=(5, 10))
self.search_var = ctk.StringVar() self.search_var = ctk.StringVar()
self.search_var.trace_add("write", lambda *_: self._refresh_list()) self.search_var.trace_add("write", lambda *_: self._refresh_list())
self.search_entry = ctk.CTkEntry(self, placeholder_text=t("search"), textvariable=self.search_var) self.search_entry = ctk.CTkEntry(search_frame, placeholder_text=t("search"), textvariable=self.search_var)
self.search_entry.pack(fill="x", padx=10, pady=(5, 10)) self.search_entry.pack(side="left", fill="x", expand=True)
self._add_group_btn = ctk.CTkButton(
search_frame, text="+", width=30, height=30,
font=ctk.CTkFont(size=14, weight="bold"),
command=self._on_add_group,
)
self._add_group_btn.pack(side="right", padx=(5, 0))
# Server list # Server list
self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent") self.list_frame = ctk.CTkScrollableFrame(self, fg_color="transparent")
@@ -75,6 +91,7 @@ class Sidebar(ctk.CTkFrame):
self.add_callback = None self.add_callback = None
self.edit_callback = None self.edit_callback = None
self.delete_callback = None self.delete_callback = None
self.add_group_callback = None
self.open_tab_callback = None # (alias, tab_key) → select server + switch tab self.open_tab_callback = None # (alias, tab_key) → select server + switch tab
self.check_status_callback = None # (alias) → check single server self.check_status_callback = None # (alias) → check single server
self.open_browser_callback = None # (alias) → open server URL in browser self.open_browser_callback = None # (alias) → open server URL in browser
@@ -91,6 +108,8 @@ class Sidebar(ctk.CTkFrame):
self.del_btn.configure(text=icon_text("delete", t("delete"))) self.del_btn.configure(text=icon_text("delete", t("delete")))
self._update_sessions_label() self._update_sessions_label()
# ── Refresh / Render ──────────────────────────────
def _refresh_list(self): def _refresh_list(self):
# Clear # Clear
for widget in self.list_frame.winfo_children(): for widget in self.list_frame.winfo_children():
@@ -98,25 +117,117 @@ class Sidebar(ctk.CTkFrame):
self._server_frames.clear() self._server_frames.clear()
self._badges.clear() self._badges.clear()
self._session_indicators.clear() self._session_indicators.clear()
self._group_headers.clear()
# Get active sessions from pool active_aliases = self._get_active_aliases()
active_aliases = set()
if self.session_pool:
active_aliases = set(self.session_pool.get_active_sessions())
search = self.search_var.get().lower() search = self.search_var.get().lower()
servers = self.store.get_all() groups = self.store.get_groups()
if not groups:
# No groups — flat list (backward compatible)
self._render_server_list(self.store.get_all(), search, active_aliases)
else:
# Grouped layout
for group in groups:
group_servers = self.store.get_servers_in_group(group["id"])
filtered = self._filter_servers(group_servers, search)
# Skip empty groups when searching
if search and not filtered:
continue
self._render_group_header(group, len(group_servers))
# Show servers if not collapsed (or always when searching)
if not group.get("collapsed") or search:
self._render_server_list(filtered, search, active_aliases, indent=True)
# Ungrouped servers
ungrouped = self.store.get_servers_in_group(None)
filtered_ungrouped = self._filter_servers(ungrouped, search)
if filtered_ungrouped:
self._render_ungrouped_header(len(ungrouped))
self._render_server_list(filtered_ungrouped, search, active_aliases, indent=True)
self._highlight_selected()
self._update_sessions_label()
def _get_active_aliases(self) -> set:
if self.session_pool:
return set(self.session_pool.get_active_sessions())
return set()
def _filter_servers(self, servers: list[dict], search: str) -> list[dict]:
if not search:
return servers
return [s for s in servers
if search in s["alias"].lower() or search in s.get("ip", "").lower()]
def _render_group_header(self, group: dict, total_count: int):
"""Render a collapsible group header."""
frame = ctk.CTkFrame(self.list_frame, height=32,
fg_color=("gray90", "gray17"), cursor="hand2")
frame.pack(fill="x", padx=2, pady=(6, 1))
frame.pack_propagate(False)
# Collapse arrow
arrow_text = "\u25bc" if not group.get("collapsed") else "\u25b6"
arrow = ctk.CTkLabel(frame, text=arrow_text, width=16,
font=ctk.CTkFont(size=10), text_color="#9ca3af")
arrow.pack(side="left", padx=(8, 2))
# Color dot
color_dot = ctk.CTkLabel(frame, text="\u25cf", width=14,
font=ctk.CTkFont(size=12),
text_color=group.get("color", "#6b7280"))
color_dot.pack(side="left", padx=(0, 4))
# Group name
name_label = ctk.CTkLabel(frame, text=group["name"],
font=ctk.CTkFont(size=12, weight="bold"),
anchor="w")
name_label.pack(side="left", fill="x", expand=True)
# Count badge
count_label = ctk.CTkLabel(frame, text=f"({total_count})",
font=ctk.CTkFont(size=10),
text_color="#6b7280", width=30)
count_label.pack(side="right", padx=(0, 8))
# Click handlers
gid = group["id"]
for widget in [frame, arrow, color_dot, name_label, count_label]:
widget.bind("<Button-1>", lambda e, g=gid: self._toggle_group(g))
widget.bind("<Button-3>", lambda e, g=gid: self._show_group_context_menu(e, g))
self._group_headers[gid] = frame
def _render_ungrouped_header(self, total_count: int):
"""Render a non-collapsible 'Ungrouped' header."""
frame = ctk.CTkFrame(self.list_frame, height=28,
fg_color="transparent")
frame.pack(fill="x", padx=2, pady=(6, 1))
frame.pack_propagate(False)
ctk.CTkLabel(frame, text=t("ungrouped"),
font=ctk.CTkFont(size=11), text_color="#6b7280",
anchor="w").pack(side="left", padx=(10, 0))
ctk.CTkLabel(frame, text=f"({total_count})",
font=ctk.CTkFont(size=10), text_color="#6b7280",
width=30).pack(side="right", padx=(0, 8))
def _render_server_list(self, servers: list[dict], search: str,
active_aliases: set, indent: bool = False):
"""Render server items. If indent=True, add left padding for group nesting."""
pad_left = 12 if indent else 2
for server in servers: for server in servers:
alias = server["alias"] alias = server["alias"]
ip = server["ip"] ip = server.get("ip", "")
stype = server.get("type", "ssh") stype = server.get("type", "ssh")
if search and search not in alias.lower() and search not in ip.lower():
continue
frame = ctk.CTkFrame(self.list_frame, cursor="hand2", height=45) frame = ctk.CTkFrame(self.list_frame, cursor="hand2", height=45)
frame.pack(fill="x", padx=2, pady=2) frame.pack(fill="x", padx=(pad_left, 2), pady=2)
frame.pack_propagate(False) frame.pack_propagate(False)
# Status badge # Status badge
@@ -130,8 +241,7 @@ class Sidebar(ctk.CTkFrame):
type_badge = ctk.CTkLabel( type_badge = ctk.CTkLabel(
frame, text=type_label_text, frame, text=type_label_text,
font=ctk.CTkFont(size=9, weight="bold"), font=ctk.CTkFont(size=9, weight="bold"),
text_color=type_color, text_color=type_color, width=30
width=30
) )
type_badge.pack(side="left", padx=(0, 2), pady=10) type_badge.pack(side="left", padx=(0, 2), pady=10)
@@ -142,16 +252,20 @@ class Sidebar(ctk.CTkFrame):
) )
session_ind.pack(side="right", padx=(0, 8), pady=10) session_ind.pack(side="right", padx=(0, 8), pady=10)
if alias in active_aliases: if alias in active_aliases:
session_ind.configure(text="\u25cf", text_color="#22c55e") # green dot session_ind.configure(text="\u25cf", text_color="#22c55e")
self._session_indicators[alias] = session_ind self._session_indicators[alias] = session_ind
# Info # Info
info = ctk.CTkFrame(frame, fg_color="transparent") info = ctk.CTkFrame(frame, fg_color="transparent")
info.pack(side="left", fill="both", expand=True, padx=5) info.pack(side="left", fill="both", expand=True, padx=5)
name_label = ctk.CTkLabel(info, text=alias, font=ctk.CTkFont(size=13, weight="bold"), anchor="w") name_label = ctk.CTkLabel(info, text=alias,
font=ctk.CTkFont(size=13, weight="bold"),
anchor="w")
name_label.pack(fill="x") name_label.pack(fill="x")
detail_label = ctk.CTkLabel(info, text=ip, font=ctk.CTkFont(size=10), text_color="#9ca3af", anchor="w") detail_label = ctk.CTkLabel(info, text=ip,
font=ctk.CTkFont(size=10),
text_color="#9ca3af", anchor="w")
detail_label.pack(fill="x") detail_label.pack(fill="x")
# Click handlers # Click handlers
@@ -161,8 +275,94 @@ class Sidebar(ctk.CTkFrame):
self._server_frames[alias] = frame self._server_frames[alias] = frame
self._highlight_selected() # ── Group operations ──────────────────────────────
self._update_sessions_label()
def _toggle_group(self, group_id: str):
"""Toggle collapse/expand for a group."""
group = self.store.get_group(group_id)
if group:
self.store.update_group(group_id, collapsed=not group.get("collapsed", False))
def _on_add_group(self):
"""Open GroupDialog to create a new group."""
if self.add_group_callback:
self.add_group_callback()
def _show_group_context_menu(self, event, group_id: str):
"""Right-click context menu for a group header."""
group = self.store.get_group(group_id)
if not group:
return
menu = tk.Menu(self, tearoff=0, bg="#2d2d44", fg="#d3d7cf",
activebackground="#44447a", activeforeground="#ffffff",
font=("Segoe UI", 10))
menu.add_command(label=t("rename_group"),
command=lambda: self._rename_group(group_id))
# Color submenu
color_menu = tk.Menu(menu, tearoff=0, bg="#2d2d44", fg="#d3d7cf",
activebackground="#44447a", activeforeground="#ffffff",
font=("Segoe UI", 10))
for color in GROUP_COLORS:
# Show colored dot + hex
color_menu.add_command(
label=f"\u25cf {color}",
command=lambda c=color: self.store.update_group(group_id, color=c),
)
menu.add_cascade(label=t("change_color"), menu=color_menu)
menu.add_separator()
groups = self.store.get_groups()
idx = next((i for i, g in enumerate(groups) if g["id"] == group_id), -1)
if idx > 0:
menu.add_command(label=t("move_up"),
command=lambda: self._move_group(group_id, -1))
if idx < len(groups) - 1:
menu.add_command(label=t("move_down"),
command=lambda: self._move_group(group_id, 1))
menu.add_separator()
menu.add_command(label=t("delete_group"),
command=lambda: self._delete_group(group_id),
foreground="#ef4444")
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
def _rename_group(self, group_id: str):
"""Open GroupDialog in edit mode."""
from gui.group_dialog import GroupDialog
group = self.store.get_group(group_id)
if group:
dialog = GroupDialog(self.winfo_toplevel(), self.store, group=group)
self.winfo_toplevel().wait_window(dialog)
def _delete_group(self, group_id: str):
"""Delete group after confirmation."""
group = self.store.get_group(group_id)
if not group:
return
msg = t("delete_group_confirm").format(name=group["name"])
if messagebox.askyesno(t("delete_group"), msg):
self.store.remove_group(group_id)
def _move_group(self, group_id: str, direction: int):
"""Move group up (-1) or down (+1)."""
groups = self.store.get_groups()
ids = [g["id"] for g in groups]
idx = ids.index(group_id)
new_idx = idx + direction
if 0 <= new_idx < len(ids):
ids[idx], ids[new_idx] = ids[new_idx], ids[idx]
self.store.reorder_groups(ids)
# ── Server selection ──────────────────────────────
def _select(self, alias: str): def _select(self, alias: str):
self._selected_alias = alias self._selected_alias = alias
@@ -180,12 +380,13 @@ class Sidebar(ctk.CTkFrame):
def get_selected(self) -> str | None: def get_selected(self) -> str | None:
return self._selected_alias return self._selected_alias
# ── Status / sessions ─────────────────────────────
def update_statuses(self): def update_statuses(self):
for alias, badge in self._badges.items(): for alias, badge in self._badges.items():
badge.set_status(self.store.get_status(alias)) badge.set_status(self.store.get_status(alias))
def update_session_indicators(self): def update_session_indicators(self):
"""Update active session indicators from session pool."""
if not self.session_pool: if not self.session_pool:
return return
active_aliases = set(self.session_pool.get_active_sessions()) active_aliases = set(self.session_pool.get_active_sessions())
@@ -197,7 +398,6 @@ class Sidebar(ctk.CTkFrame):
self._update_sessions_label() self._update_sessions_label()
def _update_sessions_label(self): def _update_sessions_label(self):
"""Update the active sessions count label."""
if self.session_pool: if self.session_pool:
count = len(self.session_pool.get_active_sessions()) count = len(self.session_pool.get_active_sessions())
if count > 0: if count > 0:
@@ -207,6 +407,8 @@ class Sidebar(ctk.CTkFrame):
else: else:
self._sessions_label.configure(text="") self._sessions_label.configure(text="")
# ── Buttons ───────────────────────────────────────
def _on_add(self): def _on_add(self):
if self.add_callback: if self.add_callback:
self.add_callback() self.add_callback()
@@ -219,6 +421,8 @@ class Sidebar(ctk.CTkFrame):
if self.delete_callback and self._selected_alias: if self.delete_callback and self._selected_alias:
self.delete_callback(self._selected_alias) self.delete_callback(self._selected_alias)
# ── Context menu (server) ─────────────────────────
def _show_context_menu(self, event, alias: str): def _show_context_menu(self, event, alias: str):
"""Show right-click context menu for a server item.""" """Show right-click context menu for a server item."""
self._select(alias) self._select(alias)
@@ -255,6 +459,25 @@ class Sidebar(ctk.CTkFrame):
if actions: if actions:
menu.add_separator() menu.add_separator()
# "Move to Group" submenu
groups = self.store.get_groups()
if groups:
move_menu = tk.Menu(menu, tearoff=0, bg="#2d2d44", fg="#d3d7cf",
activebackground="#44447a", activeforeground="#ffffff",
font=("Segoe UI", 10))
for g in groups:
move_menu.add_command(
label=f"\u25cf {g['name']}",
command=lambda a=alias, gid=g["id"]: self.store.set_server_group(a, gid),
)
move_menu.add_separator()
move_menu.add_command(
label=t("no_group"),
command=lambda a=alias: self.store.set_server_group(a, None),
)
menu.add_cascade(label=t("move_to_group"), menu=move_menu)
menu.add_separator()
# Universal actions # Universal actions
menu.add_command( menu.add_command(
label=icon_text("status_check", t("ctx_check_status")), label=icon_text("status_check", t("ctx_check_status")),

414
plans/server-groups.md Normal file
View File

@@ -0,0 +1,414 @@
# Server Groups — полный план UI/UX и реализации
## Контекст
ServerManager хранит серверы в плоском списке. При 10+ серверах разных типов (SSH, SQL, Redis, VPN, API, тестовые) становится неудобно. Нужна организация по группам: "Production", "Testing", "VPN Servers", "Hosting" и т.д.
## Исследование рынка
| Инструмент | Подход |
|---|---|
| MobaXterm, mRemoteNG, SecureCRT | Дерево папок, вложенность, drag-and-drop |
| Termius | Группы (вложенные) + теги + цвета — золотой стандарт |
| Royal TS | Папки + наследование credentials от папки |
| AWS/Azure/GCP | Resource Groups + теги (key:value) |
| Zabbix, Datadog | Host Groups + теги, сервер в нескольких группах |
**Выбранный паттерн: Группы с цветами (как Termius)** — один уровень групп, цветовая кодировка, сворачивание/разворачивание.
---
## 1. Модель данных
### Группы хранятся в `servers.json` (тот же зашифрованный файл):
```json
{
"servers": [...],
"ssh_key": {...},
"groups": [
{
"id": "a1b2c3d4",
"name": "Production",
"color": "#ef4444",
"collapsed": false,
"order": 0
},
{
"id": "e5f6g7h8",
"name": "Testing",
"color": "#22c55e",
"collapsed": false,
"order": 1
}
]
}
```
### Сервер получает поле `group`:
```json
{
"alias": "my-prod-server",
"ip": "1.2.3.4",
"type": "ssh",
"group": "a1b2c3d4",
...
}
```
- `group` ссылается на `groups[].id`
- Без `group` или пустой = "Без группы"
- При удалении группы серверы становятся ungrouped
### Почему в `servers.json`, а не `settings.json`:
- Группы тесно связаны с серверами
- Одна атомарная операция save
- `ssh.py` просто игнорирует ключ `groups` — обратная совместимость
---
## 2. API в ServerStore
```python
# Группы CRUD
get_groups() -> list[dict] # Все группы, отсортированы по order
get_group(group_id) -> dict | None # Одна группа по ID
add_group(name, color) -> dict # Создать, вернуть dict
update_group(group_id, **kwargs) # Обновить name/color/collapsed/order
remove_group(group_id) # Удалить, серверы → ungrouped
reorder_groups(ordered_ids: list[str]) # Установить порядок
# Серверы в группах
set_server_group(alias, group_id|None) # Переместить сервер в группу
get_servers_in_group(group_id|None) # Серверы группы (None = ungrouped)
```
---
## 3. UI Sidebar — Сгруппированный вид
### Было (плоский список):
```
+-------------------------------------+
| Servers |
+-------------------------------------+
| [ Search... ] |
+-------------------------------------+
| ● SSH investor 1.2.3.4 |
| ● SSH main-ovh 5.6.7.8 |
| ● SSH API TOR... 9.10.11.12 |
| ● RDP 116 Windows 13.14.15.16 |
| ● RDS Reddis main 5.6.7.8 |
| ● MDB Maria Db... 5.6.7.8 |
| ● SSH sensey24.ru 17.18.19.20 |
| |
| Active: 2 |
| [+ Add] [Edit] [Delete] |
+-------------------------------------+
```
### Станет (группы):
```
+------------------------------------------+
| Servers |
+------------------------------------------+
| [ Search... ] [+ Grp] |
+------------------------------------------+
| |
| v ● Production (3) |
| +----------------------------------+ |
| | ○ SSH main-ovh 5.6.7.8 | |
| +----------------------------------+ |
| | ○ RDS Reddis main 5.6.7.8 | |
| +----------------------------------+ |
| | ○ MDB Maria Db 5.6.7.8 | |
| +----------------------------------+ |
| |
| > ● VPN/Proxy (2) |
| (свёрнуто — серверы скрыты) |
| |
| v ● Hosting (3) |
| +----------------------------------+ |
| | ○ SSH sensey24.ru 17.18.19.20 | |
| +----------------------------------+ |
| | ○ SSH git.sensey 17.18.19.20 | |
| +----------------------------------+ |
| | ○ SSH 1gb server 21.22.23.24 | |
| +----------------------------------+ |
| |
| v ● Testing (1) |
| +----------------------------------+ |
| | ○ SSH thehost 25.26.27.28 | |
| +----------------------------------+ |
| |
| Без группы (1) |
| +----------------------------------+ |
| | ○ RDP 116 Windows 13.14.15.16 | |
| +----------------------------------+ |
| |
| Active: 2 |
| [+ Add] [Edit] [Delete] |
+------------------------------------------+
```
### Заголовок группы (детально):
```
+----------------------------------------------+
| v ● Production (3) |
| ^ ^ ^ ^ |
| | | | | |
| | цвет название кол-во |
| | группы группы серверов |
| стрелка |
| свернуть/развернуть |
+----------------------------------------------+
```
- `v` / `>` — кликабельная стрелка toggle
- `●` — цветная точка (`\u25cf`) цветом группы
- Название — жирный шрифт
- `(3)` — серый, кол-во серверов
- Вся строка кликабельна для toggle
- ПКМ — контекстное меню группы
### Серверы внутри группы:
- Отступ `padx=(12, 2)` для визуальной вложенности
- Всё остальное как сейчас: StatusBadge, TypeBadge, Name, IP
### Поведение:
- **Нет групп** → плоский список (как сейчас, полная обратная совместимость)
- **Есть группы** → автоматически сгруппированный вид
- **Поиск** → все группы разворачиваются, пустые группы скрываются
- **"Без группы"** → только если есть группы И есть серверы без группы
---
## 4. Создание группы
### Кнопка `[+ Grp]` рядом с поиском:
```
| [ Search... ] [+ Grp] |
```
### GroupDialog (новый файл `gui/group_dialog.py`):
```
+----------------------------------+
| New Group |
+----------------------------------+
| |
| Name: |
| [ Production ] |
| |
| Color: |
| (●)(●)(●)(●)(●)(●)(●)(●) |
| red org amb grn blu ind pur pnk |
| |
| [ Cancel ] [ Save ] |
+----------------------------------+
```
Палитра из 8 цветов:
```python
GROUP_COLORS = [
"#ef4444", # красный
"#f97316", # оранжевый
"#f59e0b", # янтарный
"#22c55e", # зелёный
"#3b82f6", # синий
"#6366f1", # индиго
"#a855f7", # фиолетовый
"#ec4899", # розовый
]
```
Каждый цвет — кнопка-кружок. Выбранный — с рамкой.
---
## 5. Перемещение серверов между группами
### Способ 1: ПКМ на сервере → "Переместить в группу"
```
+-----------------------------+
| Open Terminal |
| Browse Files |
| Install Key |
|-----------------------------|
| Move to Group > |
| +---------------------+ |
| | ● Production | |
| | ● VPN/Proxy | |
| | ● Hosting | |
| | ● Testing | |
| |---------------------| |
| | Без группы | |
| +---------------------+ |
|-----------------------------|
| Check Status |
| Copy Alias |
|-----------------------------|
| Edit |
| Delete |
+-----------------------------+
```
### Способ 2: Поле "Группа" в ServerDialog (создание/редактирование)
```
+----------------------------------+
| Add Server |
+----------------------------------+
| Alias: [ ] |
| IP: [ ] |
| Type: [ SSH v ] |
| Port: [ 22 ] |
| Group: [ ● Production v ] |
| +-----------------------+|
| | No group ||
| | ● Production ||
| | ● VPN/Proxy ||
| | ● Hosting ||
| | ● Testing ||
| +-----------------------+|
| User: [ root ] |
| Pass: [ •••••••• [👁] ] |
| ... |
+----------------------------------+
```
- Dropdown появляется только когда есть группы
- По умолчанию "No group"
- При редактировании — предвыбрана текущая группа
### Drag-and-drop:
**НЕ реализуем.** CustomTkinter не имеет нативного DnD для ScrollableFrame. Реализация через низкоуровневый tkinter DnD = хрупко и сложно. ПКМ "Move to Group" — надёжнее и понятнее.
---
## 6. Контекстное меню группы (ПКМ на заголовке)
```
+------------------------+
| Rename |
| Change Color > |
| +------------------+ |
| | ● Red | |
| | ● Orange | |
| | ● Amber | |
| | ● Green | |
| | ● Blue | |
| | ● Indigo | |
| | ● Purple | |
| | ● Pink | |
| +------------------+ |
|------------------------|
| Move Up |
| Move Down |
|------------------------|
| Delete Group |
+------------------------+
```
- **Rename** → открывает GroupDialog в режиме редактирования
- **Change Color** → подменю с 8 цветами
- **Move Up/Down** → меняет `order` местами с соседней группой
- **Delete Group** → подтверждение, серверы → ungrouped
---
## 7. i18n ключи (~17 штук)
| Ключ | EN | RU | ZH |
|------|----|----|-----|
| group | Group | Группа | 分组 |
| groups | Groups | Группы | 分组 |
| no_group | No group | Без группы | 无分组 |
| ungrouped | Ungrouped | Без группы | 未分组 |
| add_group | Add Group | Добавить группу | 添加分组 |
| edit_group | Edit Group | Редактировать группу | 编辑分组 |
| rename_group | Rename | Переименовать | 重命名 |
| delete_group | Delete Group | Удалить группу | 删除分组 |
| delete_group_confirm | Delete '{name}'? Servers become ungrouped. | Удалить '{name}'? Серверы станут без группы. | 删除'{name}'?服务器将变为未分组。 |
| group_name | Group Name | Название группы | 分组名称 |
| group_color | Color | Цвет | 颜色 |
| group_name_required | Group name is required | Название обязательно | 名称为必填项 |
| move_to_group | Move to Group | Переместить в группу | 移动到分组 |
| move_up | Move Up | Вверх | 上移 |
| move_down | Move Down | Вниз | 下移 |
| change_color | Change Color | Изменить цвет | 更改颜色 |
| new_group | New Group | Новая группа | 新建分组 |
---
## 8. Файлы и изменения
| Файл | Что меняется |
|------|-------------|
| `core/server_store.py` | +8 методов для Groups CRUD, `get_servers_in_group()`, `set_server_group()` |
| `gui/sidebar.py` | Рефакторинг `_refresh_list()` на grouped layout, заголовки групп, контекстные меню, collapse/expand, кнопка "+ Grp", "Move to Group" submenu |
| `gui/group_dialog.py` | **НОВЫЙ** — диалог создания/редактирования группы (имя + палитра цветов) |
| `gui/server_dialog.py` | +dropdown "Group" (виден только когда есть группы) |
| `core/i18n.py` | +17 ключей EN/RU/ZH |
| `gui/app.py` | Привязка `sidebar.add_group_callback` к открытию GroupDialog |
---
## 9. Порядок реализации
### Фаза 1: Данные (без UI)
1. Методы GroupsCRUD в `server_store.py`
2. Проверить что `ssh.py` не ломается
### Фаза 2: Sidebar — grouped rendering
1. Рефакторинг `_refresh_list()` на helper-методы
2. `_render_group_header()` с collapse toggle
3. Отступ для серверов внутри групп
4. Поиск с автораскрытием групп
### Фаза 3: Управление группами
1. `group_dialog.py`
2. Кнопка "+ Grp" в sidebar
3. Контекстное меню группы (rename, delete, reorder, color)
### Фаза 4: Назначение серверов группам
1. "Move to Group" в контекстном меню сервера
2. Dropdown "Group" в ServerDialog
### Фаза 5: i18n + polish
1. Все переводы
2. Edge cases: пустые группы, все ungrouped, одна группа
---
## 10. Edge Cases
- **Нет групп** → плоский список, полная обратная совместимость
- **Удаление группы** → серверы переходят в "Без группы"
- **Группа ссылается на удалённый ID** → сервер отображается как ungrouped
- **Поиск** → все группы разворачиваются, пустые скрываются
- **Миграция** → старый `servers.json` без `groups``get_groups()` возвращает `[]`
- **ssh.py** → игнорирует `groups` и `group` поля, нулевой impact
---
## 11. Верификация
1. `python build.py` — собрать exe
2. Без групп — плоский список как раньше
3. Создать группу "Production" красным цветом
4. Создать группу "Testing" зелёным
5. Переместить серверы в группы через ПКМ
6. Свернуть/развернуть группу
7. Поиск — группы разворачиваются
8. Добавить новый сервер — выбрать группу в диалоге
9. Удалить группу — серверы стали ungrouped
10. Переименовать группу, сменить цвет
11. Move Up/Down — порядок меняется
12. Переключить язык — все строки переведены