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:
48
core/i18n.py
48
core/i18n.py
@@ -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
110
gui/group_dialog.py
Normal 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()
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
271
gui/sidebar.py
271
gui/sidebar.py
@@ -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
414
plans/server-groups.md
Normal 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. Переключить язык — все строки переведены
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user