Поиск сеансов пользователя по домену и добавление баз прямо в них
При перемещаемых профилях базу нужно писать в профиль на той машине, где у пользователя открыт сеанс, иначе правку затрёт при выходе. Раньше, чтобы узнать, где человек сидит, приходилось отдельно лезть в Dameware. Что добавлено: - ad_sessions.py — обнаружение сеансов: список компьютеров из AD через ADSI (RSAT не нужен), отсев выключенных по порту 445, опрос WTS, разрешение реальной папки профиля через SID и реестр ProfileList, с фоллбэком на перебор имён папок, если служба RemoteRegistry отключена. - Панель «Сеансы пользователя в домене»: поиск по имени, выбор сеансов галочками и отдельная кнопка добавления баз в отмеченные сеансы. Старый режим записи по профилям остался для пользователей без активных сеансов. - Отключённые сеансы учитываются наравне с активными: они тоже держат профиль загруженным, и запись в серверную копию так же затрётся. - Фильтр по паре домен + пользователь, чтобы локальные учётки с тем же именем не смешивались с доменными. - Enter в полях ввода запускает соответствующее действие. Скорость (на парке в 316 машин опрос занимал около 90 секунд): - WTS вызывается через ctypes, а не pywin32: pywin32 не отпускает GIL на время нативного вызова, из-за чего опрос шёл строго последовательно и никакие потоки не помогали. На живом домене 21,4 с -> 1,3 с. - Проверка порта и опрос WTS слиты в один проход, потоков 256. - Машины, зависшие на RPC, не ждём дольше 4 секунд после того, как очередь разобрана: прервать вызов нельзя, но можно перестать его ждать. - Снимок домена кэшируется на 2 минуты, поэтому поиск следующего пользователя мгновенный; для принудительного обновления есть кнопка «Пересканировать». - COM инициализируется явно в каждом потоке: без этого повторное сканирование из нового потока падало с MK_E_SYNTAX. Безопасность записи: перед добавлением сеансы перепроверяются на живость, а если сеансов не нашлось и при этом часть машин не ответила — выводится предупреждение, чтобы базу не записали в перемещаемый профиль вслепую. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+313
-10
@@ -1,11 +1,22 @@
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, filedialog
|
||||
import pyodbc
|
||||
import uuid
|
||||
import sys
|
||||
|
||||
# Поиск сеансов по домену — необязательная часть: если pywin32 недоступен,
|
||||
# программа должна продолжать работать в режиме «по профилям».
|
||||
try:
|
||||
import ad_sessions
|
||||
SESSIONS_ERROR = ""
|
||||
except Exception as _exc:
|
||||
ad_sessions = None
|
||||
SESSIONS_ERROR = str(_exc)
|
||||
|
||||
_BANNER = ("""
|
||||
|
||||
|
||||
@@ -73,8 +84,8 @@ class RemoteFolderSelector(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("Добавление баз 1С пользователям")
|
||||
self.geometry("760x780")
|
||||
self.minsize(700, 640)
|
||||
self.geometry("1180x820")
|
||||
self.minsize(1040, 700)
|
||||
|
||||
# --- Состояние ---
|
||||
self.ip_address = None # None/"" => локальный ПК, иначе IP удалённого ПК
|
||||
@@ -82,27 +93,49 @@ class RemoteFolderSelector(tk.Tk):
|
||||
self.user_vars = {} # имя пользователя -> BooleanVar (галочка)
|
||||
self.db_vars = {} # имя базы из SQL -> BooleanVar (галочка)
|
||||
self.conn_vars = {} # имя подключённой базы активного пользователя -> BooleanVar
|
||||
self.session_vars = {} # подпись сеанса -> BooleanVar (галочка)
|
||||
self.sessions_by_key = {} # подпись сеанса -> словарь с данными сеанса
|
||||
self.scanning = False # идёт ли сейчас поиск сеансов
|
||||
self.scan_cache = None # последний снимок сеансов всего домена
|
||||
self.scan_cache_at = 0.0 # время снимка (time.monotonic)
|
||||
self.scan_cache_stats = {} # статистика последнего скана
|
||||
|
||||
# Компоновка окна: две колонки сверху, широкая кнопка "Добавить" снизу
|
||||
# Компоновка окна: три колонки сверху, кнопки действий снизу
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
self.grid_columnconfigure(1, weight=1)
|
||||
self.grid_columnconfigure(2, weight=1)
|
||||
self.grid_rowconfigure(0, weight=1)
|
||||
|
||||
self.frame_left = tk.Frame(self)
|
||||
self.frame_left.grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
|
||||
|
||||
self.frame_mid = tk.Frame(self)
|
||||
self.frame_mid.grid(row=0, column=1, padx=10, pady=10, sticky="nsew")
|
||||
|
||||
self.frame_right = tk.Frame(self)
|
||||
self.frame_right.grid(row=0, column=1, padx=10, pady=10, sticky="nsew")
|
||||
self.frame_right.grid(row=0, column=2, padx=10, pady=10, sticky="nsew")
|
||||
|
||||
self._build_left_panel()
|
||||
self._build_sessions_panel()
|
||||
self._build_right_panel()
|
||||
|
||||
# Большая кнопка добавления снизу, на всю ширину окна
|
||||
self.add_button = tk.Button(
|
||||
self, text="Добавить отмеченные базы отмеченным пользователям",
|
||||
font=("Segoe UI", 11, "bold"), command=self.add_database_to_ibases
|
||||
# Кнопки действий снизу, на всю ширину окна
|
||||
actions = tk.Frame(self)
|
||||
actions.grid(row=1, column=0, columnspan=3, padx=10, pady=(0, 12), sticky="ew")
|
||||
actions.grid_columnconfigure(0, weight=1)
|
||||
actions.grid_columnconfigure(1, weight=1)
|
||||
|
||||
self.add_session_button = tk.Button(
|
||||
actions, text="Добавить базы в отмеченные сеансы",
|
||||
font=("Segoe UI", 11, "bold"), command=self.add_bases_to_sessions
|
||||
)
|
||||
self.add_button.grid(row=1, column=0, columnspan=2, padx=10, pady=(0, 12), sticky="ew")
|
||||
self.add_session_button.grid(row=0, column=0, padx=(0, 5), sticky="ew")
|
||||
|
||||
self.add_button = tk.Button(
|
||||
actions, text="Добавить базы отмеченным пользователям (профили)",
|
||||
font=("Segoe UI", 11), command=self.add_database_to_ibases
|
||||
)
|
||||
self.add_button.grid(row=0, column=1, padx=(5, 0), sticky="ew")
|
||||
|
||||
# По умолчанию грузим пользователей локального ПК
|
||||
self.load_users()
|
||||
@@ -116,6 +149,7 @@ class RemoteFolderSelector(tk.Tk):
|
||||
tk.Label(f, text="IP-адрес (необязательно):").grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w")
|
||||
self.ip_entry = tk.Entry(f)
|
||||
self.ip_entry.grid(row=1, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||||
self.ip_entry.bind("<Return>", lambda e: self.load_users())
|
||||
|
||||
tk.Button(f, text="Загрузить пользователей", command=self.load_users)\
|
||||
.grid(row=2, column=0, padx=5, pady=5, sticky="ew")
|
||||
@@ -194,6 +228,10 @@ class RemoteFolderSelector(tk.Tk):
|
||||
self.password_entry = tk.Entry(f, show="*")
|
||||
self.password_entry.grid(row=5, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||||
|
||||
# Enter в любом из полей подключения — сразу загружаем базы
|
||||
for entry in (self.server_entry, self.login_entry, self.password_entry):
|
||||
entry.bind("<Return>", lambda e: self.load_databases())
|
||||
|
||||
tk.Button(f, text="Загрузить базы", command=self.load_databases)\
|
||||
.grid(row=6, column=0, padx=5, pady=5, sticky="ew")
|
||||
|
||||
@@ -218,6 +256,64 @@ class RemoteFolderSelector(tk.Tk):
|
||||
self.db_container.grid(row=10, column=0, padx=5, pady=5, sticky="nsew")
|
||||
f.grid_rowconfigure(10, weight=1)
|
||||
|
||||
def _build_sessions_panel(self):
|
||||
"""Панель поиска активных сеансов пользователя по домену."""
|
||||
f = self.frame_mid
|
||||
f.grid_columnconfigure(0, weight=1)
|
||||
|
||||
tk.Label(f, text="Сеансы пользователя в домене", font=("Segoe UI", 10, "bold"))\
|
||||
.grid(row=0, column=0, padx=5, pady=(5, 0), sticky="w")
|
||||
|
||||
tk.Label(f, text="Имя пользователя:").grid(row=1, column=0, padx=5, pady=(5, 0), sticky="w")
|
||||
self.session_user_var = tk.StringVar()
|
||||
self.session_user_entry = tk.Entry(f, textvariable=self.session_user_var)
|
||||
self.session_user_entry.grid(row=2, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||||
self.session_user_entry.bind("<Return>", lambda e: self.find_sessions())
|
||||
|
||||
scan_bar = tk.Frame(f)
|
||||
scan_bar.grid(row=3, column=0, padx=5, pady=5, sticky="ew")
|
||||
scan_bar.grid_columnconfigure(0, weight=3)
|
||||
scan_bar.grid_columnconfigure(1, weight=2)
|
||||
|
||||
self.scan_button = tk.Button(scan_bar, text="Найти сеансы", command=self.find_sessions)
|
||||
self.scan_button.grid(row=0, column=0, padx=(0, 4), sticky="ew")
|
||||
|
||||
# Принудительный повторный опрос домена, когда данные скана устарели
|
||||
self.rescan_button = tk.Button(scan_bar, text="Пересканировать",
|
||||
command=lambda: self.find_sessions(force=True))
|
||||
self.rescan_button.grid(row=0, column=1, sticky="ew")
|
||||
|
||||
# Отключённый сеанс тоже держит профиль загруженным, поэтому по умолчанию учитываем и его
|
||||
self.include_disconnected = tk.BooleanVar(value=True)
|
||||
tk.Checkbutton(f, text="Учитывать отключённые сеансы", variable=self.include_disconnected)\
|
||||
.grid(row=4, column=0, padx=5, pady=(0, 3), sticky="w")
|
||||
|
||||
self.scan_status = tk.Label(f, text="", anchor="w", justify="left",
|
||||
fg="#444", wraplength=320)
|
||||
self.scan_status.grid(row=5, column=0, padx=5, pady=(0, 3), sticky="ew")
|
||||
|
||||
bar = tk.Frame(f)
|
||||
bar.grid(row=6, column=0, padx=5, pady=(0, 3), sticky="w")
|
||||
tk.Button(bar, text="Отметить все",
|
||||
command=lambda: self._set_all(self.session_vars, "", True)).pack(side="left", padx=(0, 4))
|
||||
tk.Button(bar, text="Снять все",
|
||||
command=lambda: self._set_all(self.session_vars, "", False)).pack(side="left")
|
||||
|
||||
self.sess_container, self.sess_inner = self._create_scrollable_checklist(f, height=330)
|
||||
self.sess_container.grid(row=7, column=0, padx=5, pady=5, sticky="nsew")
|
||||
f.grid_rowconfigure(7, weight=1)
|
||||
|
||||
tk.Label(f, text="Базы добавляются в профиль на той машине,\nгде найден сеанс — "
|
||||
"чтобы правку не затёрло\nпри выходе из перемещаемого профиля.",
|
||||
anchor="w", justify="left", fg="#666")\
|
||||
.grid(row=8, column=0, padx=5, pady=(3, 0), sticky="ew")
|
||||
|
||||
if ad_sessions is None:
|
||||
self.scan_button.config(state="disabled")
|
||||
self.session_user_entry.config(state="disabled")
|
||||
self.scan_status.config(
|
||||
text=f"Поиск сеансов недоступен: не загружен модуль ({SESSIONS_ERROR})", fg="#a00")
|
||||
|
||||
def _create_scrollable_checklist(self, parent, width=220, height=180):
|
||||
"""Прокручиваемая область для чекбоксов. Возвращает (container, inner)."""
|
||||
container = tk.Frame(parent, borderwidth=1, relief="sunken")
|
||||
@@ -328,8 +424,11 @@ class RemoteFolderSelector(tk.Tk):
|
||||
self._render_checklist(self.db_inner, self.db_vars, self.db_search_var.get())
|
||||
|
||||
def on_user_click(self, user):
|
||||
"""Клик по пользователю — делаем его активным и показываем его подключённые базы."""
|
||||
"""Клик по пользователю — делаем его активным и показываем его подключённые базы.
|
||||
Заодно подставляем имя в поле поиска сеансов, чтобы не набирать вручную."""
|
||||
self.load_connected_bases(user)
|
||||
if ad_sessions is not None:
|
||||
self.session_user_var.set(user)
|
||||
|
||||
def load_connected_bases(self, user):
|
||||
"""Читаем ibases.v8i пользователя и показываем список его баз в панели."""
|
||||
@@ -571,6 +670,210 @@ class RemoteFolderSelector(tk.Tk):
|
||||
summary += f"\nУдалено в ZIP-профиле: {removed_zip}"
|
||||
self._report(summary, errors)
|
||||
|
||||
# ------------------------------------------------------- Сеансы в домене
|
||||
|
||||
# Сколько секунд снимок домена считается свежим. За это время поиск другого
|
||||
# пользователя отрабатывает мгновенно, без повторного опроса машин.
|
||||
SCAN_CACHE_TTL = 120
|
||||
|
||||
def find_sessions(self, force=False):
|
||||
"""Ищем сеансы пользователя. Если снимок домена свежий — фильтруем его
|
||||
мгновенно; иначе опрашиваем домен в фоне, чтобы окно не подвисало."""
|
||||
if ad_sessions is None or self.scanning:
|
||||
return
|
||||
|
||||
user = self.session_user_var.get().strip()
|
||||
if not user:
|
||||
messagebox.showerror("Ошибка", "Укажите имя пользователя для поиска сеансов.")
|
||||
return
|
||||
|
||||
domain = ad_sessions.current_domain()
|
||||
include_disc = self.include_disconnected.get()
|
||||
|
||||
age = time.monotonic() - self.scan_cache_at
|
||||
if not force and self.scan_cache is not None and age < self.SCAN_CACHE_TTL:
|
||||
matched = ad_sessions.filter_sessions(
|
||||
self.scan_cache, user=user, domain=domain,
|
||||
include_disconnected=include_disc)
|
||||
stats = dict(self.scan_cache_stats)
|
||||
stats["matched"] = len(matched)
|
||||
stats["from_cache"] = True
|
||||
stats["cache_age"] = int(age)
|
||||
self._on_sessions_found(user, matched, [], stats, None)
|
||||
return
|
||||
|
||||
self.scanning = True
|
||||
self.scan_button.config(state="disabled", text="Идёт поиск...")
|
||||
self.rescan_button.config(state="disabled")
|
||||
self.scan_status.config(text="Получаем список компьютеров из AD...", fg="#444")
|
||||
self.session_vars.clear()
|
||||
self.sessions_by_key.clear()
|
||||
self._render_checklist(self.sess_inner, self.session_vars, "")
|
||||
|
||||
def progress(stage, done, total):
|
||||
text = f"{stage} {done}/{total}" if total else stage
|
||||
self.after(0, lambda: self.scan_status.config(text=text))
|
||||
|
||||
def worker():
|
||||
try:
|
||||
# Снимаем сеансы всех пользователей разом и кладём в кэш —
|
||||
# следующий поиск по другому имени будет мгновенным
|
||||
everyone, errors, stats = ad_sessions.scan_domain(progress=progress)
|
||||
matched = ad_sessions.filter_sessions(
|
||||
everyone, user=user, domain=domain,
|
||||
include_disconnected=include_disc)
|
||||
stats["matched"] = len(matched)
|
||||
self.after(0, self._store_scan_cache, everyone, stats)
|
||||
self.after(0, self._on_sessions_found, user, matched, errors, stats, None)
|
||||
except Exception as exc:
|
||||
self.after(0, self._on_sessions_found, user, [], [], {},
|
||||
f"{type(exc).__name__}: {exc}")
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _store_scan_cache(self, sessions, stats):
|
||||
self.scan_cache = sessions
|
||||
self.scan_cache_at = time.monotonic()
|
||||
self.scan_cache_stats = dict(stats)
|
||||
|
||||
def _on_sessions_found(self, user, sessions, errors, stats, fatal):
|
||||
"""Обработка результата фонового сканирования (вызывается в потоке GUI)."""
|
||||
self.scanning = False
|
||||
self.scan_button.config(state="normal", text="Найти сеансы")
|
||||
self.rescan_button.config(state="normal")
|
||||
|
||||
if fatal:
|
||||
self.scan_status.config(text=f"Ошибка поиска: {fatal}", fg="#a00")
|
||||
messagebox.showerror("Ошибка", f"Не удалось выполнить поиск сеансов:\n{fatal}")
|
||||
return
|
||||
|
||||
# Чистим здесь, а не только перед сканированием: поиск по кэшу приходит
|
||||
# сюда напрямую, и без этого результаты прошлого поиска накапливались бы
|
||||
self.session_vars.clear()
|
||||
self.sessions_by_key.clear()
|
||||
|
||||
for sess in sessions:
|
||||
machine = ad_sessions.short_name(sess["machine"])
|
||||
key = f"{machine} (сеанс {sess['session_id']}, {sess['state_name']})"
|
||||
if sess.get("client"):
|
||||
key += f" ← {sess['client']}"
|
||||
self.sessions_by_key[key] = sess
|
||||
self.session_vars[key] = tk.BooleanVar(value=True) # найденное сразу отмечено
|
||||
|
||||
self._render_checklist(self.sess_inner, self.session_vars, "")
|
||||
|
||||
total = stats.get("total", 0)
|
||||
alive = stats.get("alive", 0)
|
||||
failed = stats.get("failed", 0)
|
||||
unfinished = stats.get("unfinished", 0)
|
||||
|
||||
if sessions:
|
||||
head = f"Найдено сеансов: {len(sessions)} у «{user}»."
|
||||
color = "#060"
|
||||
else:
|
||||
head = f"Сеансов пользователя «{user}» не найдено."
|
||||
color = "#a60"
|
||||
|
||||
if stats.get("from_cache"):
|
||||
tail = (f"Данные скана {stats.get('cache_age', 0)} с назад "
|
||||
f"(машин: {total}, с сеансами: {alive}). "
|
||||
f"Кнопка «Пересканировать» обновит.")
|
||||
else:
|
||||
tail = (f"Машин: {total}, ответили: {alive}, "
|
||||
f"выключено: {stats.get('offline', 0)}. "
|
||||
f"Время: {stats.get('scan_seconds', 0)} с "
|
||||
f"(AD {stats.get('ad_seconds', 0)} с).")
|
||||
if failed:
|
||||
tail += f" Ошибок: {failed}."
|
||||
if unfinished:
|
||||
tail += f" Не успели за отведённое время: {unfinished}."
|
||||
self.scan_status.config(text=head + " " + tail, fg=color)
|
||||
|
||||
# Если сеансов не нашлось, а часть машин не опрошена — предупреждаем явно.
|
||||
# Иначе админ решит, что пользователь нигде не залогинен, добавит базу
|
||||
# в перемещаемый профиль, и её затрёт при выходе из пропущенного сеанса.
|
||||
if not sessions and (failed or unfinished) and not stats.get("from_cache"):
|
||||
details = ""
|
||||
if errors:
|
||||
details = "\n\n" + "\n".join(f"{host}: {err}" for host, err in errors[:8])
|
||||
messagebox.showwarning(
|
||||
"Опрошены не все машины",
|
||||
f"Сеансов пользователя «{user}» не найдено, но "
|
||||
f"{failed + unfinished} машин(ы) не ответили.\n\n"
|
||||
"Пользователь может сидеть на одной из них. Прежде чем писать "
|
||||
"в перемещаемый профиль, нажмите «Пересканировать»."
|
||||
+ details)
|
||||
|
||||
def add_bases_to_sessions(self):
|
||||
"""Добавляем отмеченные базы в профили на машинах отмеченных сеансов."""
|
||||
if ad_sessions is None:
|
||||
messagebox.showerror("Ошибка", "Модуль поиска сеансов недоступен.")
|
||||
return
|
||||
|
||||
keys = self._get_checked(self.session_vars)
|
||||
bases = self._get_checked(self.db_vars)
|
||||
server = self.server_entry.get().strip()
|
||||
|
||||
if not keys:
|
||||
messagebox.showerror("Ошибка", "Не отмечен ни один сеанс!")
|
||||
return
|
||||
if not bases:
|
||||
messagebox.showerror("Ошибка", "Не отмечена ни одна база!")
|
||||
return
|
||||
if not server:
|
||||
messagebox.showerror("Ошибка", "Не указан SQL-сервер!")
|
||||
return
|
||||
|
||||
chosen = [self.sessions_by_key[key] for key in keys]
|
||||
|
||||
# Список сеансов мог быть снят пару минут назад — убеждаемся, что они ещё живы.
|
||||
# Машин тут единицы, проверка почти мгновенная.
|
||||
chosen, gone = ad_sessions.verify_sessions(chosen)
|
||||
if gone:
|
||||
names = "\n".join(
|
||||
f"{ad_sessions.short_name(s['machine'])} (сеанс {s['session_id']})"
|
||||
for s in gone)
|
||||
if not chosen:
|
||||
messagebox.showerror(
|
||||
"Сеансы завершены",
|
||||
"Выбранные сеансы уже завершены — пользователь вышел:\n\n"
|
||||
f"{names}\n\nНажмите «Пересканировать» и попробуйте снова.")
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Часть сеансов завершена",
|
||||
f"Эти сеансы уже завершены и будут пропущены:\n\n{names}\n\n"
|
||||
"Продолжить с остальными?"):
|
||||
return
|
||||
|
||||
# На одной машине у пользователя может быть несколько сеансов (например,
|
||||
# активный и отключённый), а профиль у них общий — пишем в машину один раз.
|
||||
by_machine = {}
|
||||
for sess in chosen:
|
||||
by_machine.setdefault(sess["machine"], sess)
|
||||
|
||||
added = skipped = 0
|
||||
done_machines = []
|
||||
errors = []
|
||||
for machine, sess in by_machine.items():
|
||||
try:
|
||||
path = ad_sessions.ibases_path_for_session(sess)
|
||||
a, s = self._add_entries_to_file(path, bases, server)
|
||||
added += a
|
||||
skipped += s
|
||||
done_machines.append(f"{ad_sessions.short_name(machine)}: +{a}, пропущено {s}")
|
||||
except Exception as exc:
|
||||
errors.append(f"{ad_sessions.short_name(machine)}: {exc}")
|
||||
|
||||
summary = (
|
||||
f"Пользователь: {self.session_user_var.get().strip()}\n"
|
||||
f"Машин обработано: {len(done_machines)} из {len(by_machine)}, "
|
||||
f"баз отмечено: {len(bases)}\n"
|
||||
f"Добавлено: {added}, пропущено (уже есть): {skipped}"
|
||||
)
|
||||
if done_machines:
|
||||
summary += "\n\n" + "\n".join(done_machines)
|
||||
self._report(summary, errors)
|
||||
|
||||
def _report(self, summary, errors):
|
||||
if errors:
|
||||
messagebox.showwarning("Готово с ошибками", summary + "\n\nОшибки:\n" + "\n".join(errors))
|
||||
|
||||
Reference in New Issue
Block a user