standkit 0.5.1__tar.gz → 0.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {standkit-0.5.1/standkit.egg-info → standkit-0.5.2}/PKG-INFO +2 -3
  2. {standkit-0.5.1 → standkit-0.5.2}/docs/HOSTING.md +28 -10
  3. {standkit-0.5.1 → standkit-0.5.2}/pyproject.toml +4 -4
  4. {standkit-0.5.1 → standkit-0.5.2}/standkit/__init__.py +1 -1
  5. {standkit-0.5.1 → standkit-0.5.2}/standkit/hosting.py +79 -16
  6. {standkit-0.5.1 → standkit-0.5.2}/standkit/logs.py +20 -5
  7. {standkit-0.5.1 → standkit-0.5.2/standkit.egg-info}/PKG-INFO +2 -3
  8. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/logs_browser.py +20 -4
  9. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/server.py +12 -2
  10. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/app.js +21 -1
  11. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/style.css +1 -0
  12. {standkit-0.5.1 → standkit-0.5.2}/tests/test_hosting.py +33 -0
  13. {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_logs_browser.py +33 -0
  14. {standkit-0.5.1 → standkit-0.5.2}/tests/test_logs.py +15 -0
  15. {standkit-0.5.1 → standkit-0.5.2}/LICENSE +0 -0
  16. {standkit-0.5.1 → standkit-0.5.2}/MANIFEST.in +0 -0
  17. {standkit-0.5.1 → standkit-0.5.2}/README.md +0 -0
  18. {standkit-0.5.1 → standkit-0.5.2}/SECURITY.md +0 -0
  19. {standkit-0.5.1 → standkit-0.5.2}/docs/ARCHITECTURE.md +0 -0
  20. {standkit-0.5.1 → standkit-0.5.2}/docs/REMOTE_STANDS.md +0 -0
  21. {standkit-0.5.1 → standkit-0.5.2}/docs/adr/0001-hosting-backends.md +0 -0
  22. {standkit-0.5.1 → standkit-0.5.2}/docs/adr/0002-k8s-backend.md +0 -0
  23. {standkit-0.5.1 → standkit-0.5.2}/projects.sample.json +0 -0
  24. {standkit-0.5.1 → standkit-0.5.2}/setup.cfg +0 -0
  25. {standkit-0.5.1 → standkit-0.5.2}/standkit/health.py +0 -0
  26. {standkit-0.5.1 → standkit-0.5.2}/standkit/lifecycle.py +0 -0
  27. {standkit-0.5.1 → standkit-0.5.2}/standkit/models.py +0 -0
  28. {standkit-0.5.1 → standkit-0.5.2}/standkit/platform.py +0 -0
  29. {standkit-0.5.1 → standkit-0.5.2}/standkit/registry.py +0 -0
  30. {standkit-0.5.1 → standkit-0.5.2}/standkit/secrets.py +0 -0
  31. {standkit-0.5.1 → standkit-0.5.2}/standkit.egg-info/SOURCES.txt +0 -0
  32. {standkit-0.5.1 → standkit-0.5.2}/standkit.egg-info/dependency_links.txt +0 -0
  33. {standkit-0.5.1 → standkit-0.5.2}/standkit.egg-info/entry_points.txt +0 -0
  34. {standkit-0.5.1 → standkit-0.5.2}/standkit.egg-info/requires.txt +0 -0
  35. {standkit-0.5.1 → standkit-0.5.2}/standkit.egg-info/top_level.txt +0 -0
  36. {standkit-0.5.1 → standkit-0.5.2}/standkit_agent/__init__.py +0 -0
  37. {standkit-0.5.1 → standkit-0.5.2}/standkit_agent/__main__.py +0 -0
  38. {standkit-0.5.1 → standkit-0.5.2}/standkit_agent/audit.py +0 -0
  39. {standkit-0.5.1 → standkit-0.5.2}/standkit_agent/security.py +0 -0
  40. {standkit-0.5.1 → standkit-0.5.2}/standkit_agent/server.py +0 -0
  41. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/__init__.py +0 -0
  42. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/__main__.py +0 -0
  43. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/agent_control.py +0 -0
  44. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/assets/bpmkit-icon.ico +0 -0
  45. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/assets/icon.png +0 -0
  46. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/client.py +0 -0
  47. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/config.py +0 -0
  48. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/redis_min.py +0 -0
  49. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/security.py +0 -0
  50. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/shortcut.py +0 -0
  51. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/bpmkit-logo-dark.svg +0 -0
  52. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/bpmkit-logo.svg +0 -0
  53. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/favicon.png +0 -0
  54. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/favicon.svg +0 -0
  55. {standkit-0.5.1 → standkit-0.5.2}/standkit_hub/web/index.html +0 -0
  56. {standkit-0.5.1 → standkit-0.5.2}/tests/test_agent_security.py +0 -0
  57. {standkit-0.5.1 → standkit-0.5.2}/tests/test_agent_server_integration.py +0 -0
  58. {standkit-0.5.1 → standkit-0.5.2}/tests/test_health.py +0 -0
  59. {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_agent_control.py +0 -0
  60. {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_config.py +0 -0
  61. {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_redis_min.py +0 -0
  62. {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_register.py +0 -0
  63. {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_server.py +0 -0
  64. {standkit-0.5.1 → standkit-0.5.2}/tests/test_hub_shortcut.py +0 -0
  65. {standkit-0.5.1 → standkit-0.5.2}/tests/test_lifecycle.py +0 -0
  66. {standkit-0.5.1 → standkit-0.5.2}/tests/test_models.py +0 -0
  67. {standkit-0.5.1 → standkit-0.5.2}/tests/test_registry.py +0 -0
  68. {standkit-0.5.1 → standkit-0.5.2}/tests/test_registry_resolver.py +0 -0
  69. {standkit-0.5.1 → standkit-0.5.2}/tests/test_secrets.py +0 -0
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: standkit
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit.
5
5
  Author: standkit contributors
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/thinkquattro/BPMkitStand
8
8
  Project-URL: Экосистема BPMkit, https://github.com/thinkquattro/BPMkit-dev
9
- Classifier: License :: OSI Approved :: MIT License
10
9
  Classifier: Programming Language :: Python :: 3
11
10
  Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Operating System :: OS Independent
@@ -113,12 +113,23 @@
113
113
  - **kestrel** — без изменений: `dotnet <stand_dll>` + pidfile
114
114
  (`standkit.lifecycle`/`standkit.platform`).
115
115
  - **iis** — через `appcmd.exe`
116
- (`%windir%\system32\inetsrv\appcmd.exe`):
117
- - старт: `start apppool /apppool.name:<pool>` и/или `start site /site.name:<site>`;
118
- - стоп: `stop apppool` / `stop site`;
119
- - рестарт: `recycle apppool /apppool.name:<pool>` (graceful) либо stop+start сайта;
120
- - проверка «жив»: `list apppool <pool> /text:state` `Started`; иначе по сайту;
121
- фолбэк открытый TCP-порт стенда.
116
+ (`%windir%\system32\inetsrv\appcmd.exe`). ⚠️ **Требует запуска диспетчера
117
+ «от имени администратора»** см. раздел «Требования».
118
+ «Стенд в IIS» = его **Site**: диспетчер управляет сайтом и намеренно **не
119
+ трогает App Pool** (пул может быть общим с другими приложениями, его остановка
120
+ положила бы и их). App Pool задействуется только когда `iis_site` не задан
121
+ вовсе (единственный хэндл стенда).
122
+ - старт: `start apppool /apppool.name:<pool>` (если задан, чтобы сайт мог
123
+ обслуживаться) + `start site /site.name:<site>`;
124
+ - стоп: `stop site /site.name:<site>` (**только сайт**; при отсутствии
125
+ `iis_site` — `stop apppool`);
126
+ - рестарт: `stop site` + `start site` (**App Pool не рециклится**; recycle
127
+ пула — только когда сайт не задан);
128
+ - проверка «жив»: состояние **сайта** `list site <site> /text:state` →
129
+ `Started` (при отсутствии `iis_site` — состояние пула); фолбэк на открытый
130
+ TCP-порт — только если `appcmd` не дал определённого состояния (порт держит
131
+ http.sys даже у остановленного сайта, поэтому открытый порт сам по себе
132
+ «живым» не считается).
122
133
  - **docker** — через CLI `docker` / `docker compose`:
123
134
  - одиночный контейнер: `docker start|stop|restart <container>`,
124
135
  `docker inspect -f "{{.State.Running}}" <container>`,
@@ -144,10 +155,17 @@ Health-проба «процесс» (`standkit.health.check_stand`) для `iis
144
155
 
145
156
  ## Требования
146
157
 
147
- - **IIS**: платформа — только Windows; агент/служба standkit должна иметь
148
- права на управление IIS (обычно — членство в группе `IIS_IUSRS` недостаточно,
149
- нужны права на `appcmd.exe`/WAS, см. документацию IIS по правам на удалённое
150
- администрирование). Живая приёмка на стенде с реальным IIS.
158
+ - **IIS**: платформа — только Windows. ⚠️ **Диспетчер (`standkit-hub`) должен
159
+ быть запущен «от имени администратора» (elevated).** `appcmd.exe` управляет
160
+ конфигурацией IIS и читает `%windir%\system32\inetsrv\config\redirection.config`,
161
+ что требует прав администратора; без elevation любая IIS-операция падает с
162
+ ошибкой прав (например, `код 1168` / «не удалось открыть файл конфигурации
163
+ из-за отсутствия необходимых разрешений»). Членства в группе `IIS_IUSRS`
164
+ **недостаточно**. Как запускать elevated: ярлык/консоль → правый клик →
165
+ «Запуск от имени администратора», либо из уже поднятой административной
166
+ консоли `standkit-hub`. Диспетчер в такой ситуации отдаёт понятную подсказку
167
+ «запустите от имени администратора» в тексте ошибки. Живая приёмка — на
168
+ стенде с реальным IIS.
151
169
  - **Docker**: установленный Docker Engine (`docker` в PATH процесса
152
170
  standkit); для compose-режима — плагин `docker compose` (Compose V2).
153
171
  - **Kubernetes**: установленный `kubectl` в PATH процесса standkit и
@@ -1,17 +1,17 @@
1
1
  [build-system]
2
- requires = ["setuptools>=68", "wheel"]
2
+ requires = ["setuptools>=77"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "standkit"
7
- version = "0.5.1"
7
+ version = "0.5.2"
8
8
  description = "Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit."
9
9
  readme = "README.md"
10
- license = { text = "MIT" }
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
11
12
  authors = [{ name = "standkit contributors" }]
12
13
  requires-python = ">=3.10"
13
14
  classifiers = [
14
- "License :: OSI Approved :: MIT License",
15
15
  "Programming Language :: Python :: 3",
16
16
  "Programming Language :: Python :: 3.10",
17
17
  "Operating System :: OS Independent",
@@ -6,6 +6,6 @@ standkit — свободное (MIT) ядро управления жизнен
6
6
  и docs/ARCHITECTURE.md в корне репозитория.
7
7
  """
8
8
 
9
- __version__ = "0.5.1"
9
+ __version__ = "0.5.2"
10
10
 
11
11
  __all__ = ["__version__"]
@@ -83,19 +83,66 @@ def get_backend(stand: Stand) -> HostingBackend:
83
83
  raise HostingError(f"host_kind={stand.host_kind.value!r} не поддерживается ядром standkit")
84
84
 
85
85
 
86
+ def _oem_encoding() -> str:
87
+ """Кодировка вывода консольных утилит Windows (appcmd и т.п. пишут в OEM,
88
+ не в UTF-8/ANSI). Вне Windows — utf-8."""
89
+ if sys.platform == "win32":
90
+ try:
91
+ import ctypes
92
+
93
+ return f"cp{ctypes.windll.kernel32.GetOEMCP()}" # обычно cp866 (RU)
94
+ except Exception:
95
+ return "cp866"
96
+ return "utf-8"
97
+
98
+
99
+ def _decode_console(data) -> str:
100
+ """Декодирует вывод внешней команды. Принимает bytes (реальный запуск) или
101
+ str (замоканный в тестах — отдаётся как есть). Для bytes перебирает
102
+ utf-8 → OEM (cp866) → cp1251, чтобы не превращать кириллицу appcmd в
103
+ кракозябры (баг «остановка IIS: непонятный текст ошибки»)."""
104
+ if data is None:
105
+ return ""
106
+ if isinstance(data, str):
107
+ return data
108
+ for enc in ("utf-8", _oem_encoding(), "cp1251"):
109
+ try:
110
+ return data.decode(enc)
111
+ except (UnicodeDecodeError, LookupError):
112
+ continue
113
+ return data.decode("utf-8", errors="replace")
114
+
115
+
86
116
  def _run(cmd: list[str], *, timeout: float = _DEFAULT_TIMEOUT) -> subprocess.CompletedProcess:
87
- """Выполняет внешнюю команду, оборачивая ошибки спавна/таймаута в ``HostingError``."""
117
+ """Выполняет внешнюю команду, оборачивая ошибки спавна/таймаута в ``HostingError``.
118
+ Захватывает вывод БАЙТАМИ и декодирует (см. ``_decode_console``) — надёжнее,
119
+ чем ``text=True, encoding='utf-8'``, для консольных утилит в OEM-кодировке."""
88
120
  try:
89
- return subprocess.run(
90
- cmd,
91
- capture_output=True,
92
- text=True,
93
- encoding="utf-8",
94
- errors="replace",
95
- timeout=timeout,
96
- )
121
+ proc = subprocess.run(cmd, capture_output=True, timeout=timeout)
97
122
  except (OSError, subprocess.SubprocessError) as exc:
98
123
  raise HostingError(f"Не удалось выполнить команду {cmd!r}: {exc}") from exc
124
+ return subprocess.CompletedProcess(
125
+ cmd, proc.returncode, _decode_console(proc.stdout), _decode_console(proc.stderr)
126
+ )
127
+
128
+
129
+ # Признаки того, что внешняя команда упала из-за НЕХВАТКИ ПРАВ (нужен запуск
130
+ # «от имени администратора»). Для appcmd это типично: чтение config/
131
+ # redirection.config в %windir%\system32\inetsrv требует elevation.
132
+ _ELEVATION_MARKERS = (
133
+ "redirection.config",
134
+ "access is denied",
135
+ "отказано в доступе",
136
+ "разрешени", # «…необходимых разрешений»
137
+ "0x80070005",
138
+ "(код 1168)",
139
+ "error ( message:", # общий appcmd-ERROR при неудачном открытии config
140
+ )
141
+
142
+
143
+ def _looks_like_elevation_error(text: str) -> bool:
144
+ low = text.lower()
145
+ return any(m in low for m in _ELEVATION_MARKERS)
99
146
 
100
147
 
101
148
  def _run_checked(cmd: list[str], *, timeout: float = _DEFAULT_TIMEOUT) -> subprocess.CompletedProcess:
@@ -107,6 +154,22 @@ def _run_checked(cmd: list[str], *, timeout: float = _DEFAULT_TIMEOUT) -> subpro
107
154
  return result
108
155
 
109
156
 
157
+ def _appcmd_checked(cmd: list[str], *, timeout: float = _DEFAULT_TIMEOUT) -> subprocess.CompletedProcess:
158
+ """``_run_checked`` для appcmd: при ошибке нехватки прав добавляет к тексту
159
+ понятную IIS-подсказку «запустите от имени администратора»."""
160
+ try:
161
+ return _run_checked(cmd, timeout=timeout)
162
+ except HostingError as exc:
163
+ if _looks_like_elevation_error(str(exc)):
164
+ raise HostingError(
165
+ str(exc)
166
+ + "\n\nПохоже, не хватает прав администратора: управление IIS через appcmd.exe "
167
+ "требует запуска диспетчера «от имени администратора» (elevated). Запустите "
168
+ "standkit-hub с правами администратора и повторите операцию."
169
+ ) from exc
170
+ raise
171
+
172
+
110
173
  def _tcp_fallback(stand: Stand) -> bool:
111
174
  """Фолбэк-проба «жив ли стенд» по открытому TCP-порту (см. ADR-0001)."""
112
175
  from standkit import health as _health # локальный импорт — избегаем цикла
@@ -209,9 +272,9 @@ class IisBackend:
209
272
  f"стенд '{stand.name}': host_kind=iis требует iis_site и/или iis_app_pool"
210
273
  )
211
274
  if stand.iis_app_pool:
212
- _run_checked([appcmd, "start", "apppool", f"/apppool.name:{stand.iis_app_pool}"])
275
+ _appcmd_checked([appcmd, "start", "apppool", f"/apppool.name:{stand.iis_app_pool}"])
213
276
  if stand.iis_site:
214
- _run_checked([appcmd, "start", "site", f"/site.name:{stand.iis_site}"])
277
+ _appcmd_checked([appcmd, "start", "site", f"/site.name:{stand.iis_site}"])
215
278
  return None
216
279
 
217
280
  def stop(self, stand: Stand, *, run_dir: Optional[Path] = None) -> bool:
@@ -225,9 +288,9 @@ class IisBackend:
225
288
  # положила бы и их (решение Владимира — гасить только стенд). App Pool
226
289
  # гасим лишь как единственный хэндл, когда сайт вообще не задан.
227
290
  if stand.iis_site:
228
- _run_checked([appcmd, "stop", "site", f"/site.name:{stand.iis_site}"])
291
+ _appcmd_checked([appcmd, "stop", "site", f"/site.name:{stand.iis_site}"])
229
292
  return True
230
- _run_checked([appcmd, "stop", "apppool", f"/apppool.name:{stand.iis_app_pool}"])
293
+ _appcmd_checked([appcmd, "stop", "apppool", f"/apppool.name:{stand.iis_app_pool}"])
231
294
  return True
232
295
 
233
296
  def restart(
@@ -238,10 +301,10 @@ class IisBackend:
238
301
  # трогаем/не рециклим — он может быть общим (см. stop). Recycle пула —
239
302
  # только когда сайт не задан (пул — единственный хэндл стенда).
240
303
  if stand.iis_site:
241
- _run_checked([appcmd, "stop", "site", f"/site.name:{stand.iis_site}"])
242
- _run_checked([appcmd, "start", "site", f"/site.name:{stand.iis_site}"])
304
+ _appcmd_checked([appcmd, "stop", "site", f"/site.name:{stand.iis_site}"])
305
+ _appcmd_checked([appcmd, "start", "site", f"/site.name:{stand.iis_site}"])
243
306
  elif stand.iis_app_pool:
244
- _run_checked([appcmd, "recycle", "apppool", f"/apppool.name:{stand.iis_app_pool}"])
307
+ _appcmd_checked([appcmd, "recycle", "apppool", f"/apppool.name:{stand.iis_app_pool}"])
245
308
  else:
246
309
  raise HostingError(
247
310
  f"стенд '{stand.name}': host_kind=iis требует iis_site и/или iis_app_pool"
@@ -109,7 +109,7 @@ def _decode_bytes(data: bytes) -> str:
109
109
  return data.decode("utf-8", errors="replace")
110
110
 
111
111
 
112
- def tail(log_path: Path, n: int = 100) -> list[str]:
112
+ def tail(log_path: Path, n: int = 100, *, max_bytes: Optional[int] = None) -> list[str]:
113
113
  """
114
114
  Возвращает последние ``n`` строк файла лога (без завершающих переводов строк).
115
115
 
@@ -117,14 +117,29 @@ def tail(log_path: Path, n: int = 100) -> list[str]:
117
117
  а не бросает исключение: отсутствие лога — обычное состояние свежей записи
118
118
  реестра.
119
119
 
120
- Реализация читает файл целиком (байтами, с "умным" декодом см.
121
- ``_decode_bytes``) для типичных per-stand логов (десятки МБ) этого
122
- достаточно; постраничное чтение с конца файла для очень больших логов
123
- (сотни МБ+)бэклог, см. docs/ARCHITECTURE.md.
120
+ ``max_bytes`` (опц.) для ОЧЕНЬ больших логов (IIS/.NET-хосты могут писать
121
+ сотни МБ в день) читать не весь файл, а только последние ``max_bytes`` байт
122
+ конца, через seek). Первая строка такого хвоста почти наверняка обрезана
123
+ посередине она отбрасывается. Без ``max_bytes`` прежнее поведение (файл
124
+ целиком).
124
125
  """
125
126
  p = Path(log_path)
126
127
  if not p.exists():
127
128
  return []
129
+ if max_bytes is not None and max_bytes > 0:
130
+ try:
131
+ size = p.stat().st_size
132
+ except OSError:
133
+ size = 0
134
+ if size > max_bytes:
135
+ with p.open("rb") as f:
136
+ f.seek(size - max_bytes)
137
+ data = f.read()
138
+ text = _decode_bytes(data)
139
+ lines = text.splitlines()
140
+ if lines:
141
+ lines = lines[1:] # обрезанная «половинка» первой строки
142
+ return lines[-n:] if n > 0 else lines
128
143
  data = p.read_bytes()
129
144
  text = _decode_bytes(data)
130
145
  lines = text.splitlines()
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: standkit
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: Свободное ядро (MIT) для управления жизненным циклом локальных и удалённых стендов BPMSoft: headless start/stop/restart, health-пробы, логи, реестр стендов. Часть экосистемы BPMkit.
5
5
  Author: standkit contributors
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/thinkquattro/BPMkitStand
8
8
  Project-URL: Экосистема BPMkit, https://github.com/thinkquattro/BPMkit-dev
9
- Classifier: License :: OSI Approved :: MIT License
10
9
  Classifier: Programming Language :: Python :: 3
11
10
  Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Operating System :: OS Independent
@@ -90,13 +90,26 @@ def resolve_logs_dir(stand: Stand, source: str = DEFAULT_LOG_SOURCE) -> Optional
90
90
  return p
91
91
 
92
92
 
93
- def list_log_files(logs_dir: Path) -> list[dict]:
93
+ def start_of_today_ts() -> float:
94
+ """Unix-timestamp локальной полуночи сегодняшнего дня (для отсечения
95
+ старых логов «старше сегодня» — логи IIS/.NET по дням бывают очень тяжёлыми)."""
96
+ import datetime as _dt
97
+
98
+ now = _dt.datetime.now()
99
+ return now.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
100
+
101
+
102
+ def list_log_files(logs_dir: Path, *, since_mtime: Optional[float] = None) -> list[dict]:
94
103
  """
95
104
  Список лог-файлов каталога, ВКЛЮЧАЯ вложенные подкаталоги: стенды BPMSoft
96
105
  (и .NET-хосты вообще) часто пишут логи не плоско, а в подпапки по датам,
97
106
  например ``logs/2026-07-24/*.log``. Без рекурсии верхний уровень содержал
98
107
  бы только папки, и хаб ошибочно сообщал «в каталоге нет файлов».
99
108
 
109
+ ``since_mtime`` (опц.) — не включать файлы, изменённые РАНЬШЕ этого unix-ts
110
+ (напр. ``start_of_today_ts()`` — «только за сегодня»): у IIS/.NET накапливаются
111
+ сотни тяжёлых дневных файлов, перечислять/читать всё — дорого.
112
+
100
113
  ``name`` — путь файла ОТНОСИТЕЛЬНО ``logs_dir`` в POSIX-форме (с прямыми
101
114
  слэшами, напр. ``2026-07-24/app.log``), совместимый с
102
115
  ``sanitize_log_filename`` на любой ОС. Размер в байтах, mtime (unix
@@ -110,15 +123,18 @@ def list_log_files(logs_dir: Path) -> list[dict]:
110
123
  st = child.stat()
111
124
  except OSError:
112
125
  continue
126
+ if since_mtime is not None and st.st_mtime < since_mtime:
127
+ continue
113
128
  rel = child.relative_to(logs_dir).as_posix()
114
129
  entries.append({"name": rel, "size": st.st_size, "mtime": st.st_mtime})
115
130
  entries.sort(key=lambda e: e["mtime"], reverse=True)
116
131
  return entries
117
132
 
118
133
 
119
- def pick_primary_log(logs_dir: Path) -> Optional[Path]:
120
- """Выбирает "основной" лог каталога — самый свежий по mtime файл."""
121
- files = list_log_files(logs_dir)
134
+ def pick_primary_log(logs_dir: Path, *, since_mtime: Optional[float] = None) -> Optional[Path]:
135
+ """Выбирает "основной" лог каталога — самый свежий по mtime файл. С
136
+ ``since_mtime`` учитывает только файлы не старше указанного момента."""
137
+ files = list_log_files(logs_dir, since_mtime=since_mtime)
122
138
  if not files:
123
139
  return None
124
140
  return logs_dir / files[0]["name"]
@@ -511,7 +511,15 @@ def make_handler(
511
511
  },
512
512
  )
513
513
  return
514
- primary = logs_browser.pick_primary_log(logs_dir)
514
+ # «Отрезаем всё старше сегодня»: логи IIS/.NET по дням бывают очень
515
+ # тяжёлыми — сначала берём самый свежий файл ЗА СЕГОДНЯ. Если сегодня
516
+ # логов нет (стенд не писал сегодня) — фолбэк на самый свежий вообще,
517
+ # чтобы всегда показать последнюю сессию.
518
+ primary = logs_browser.pick_primary_log(
519
+ logs_dir, since_mtime=logs_browser.start_of_today_ts()
520
+ )
521
+ if primary is None:
522
+ primary = logs_browser.pick_primary_log(logs_dir)
515
523
  if primary is None:
516
524
  self._send_json(
517
525
  200,
@@ -528,7 +536,9 @@ def make_handler(
528
536
  # до конца файла), даже если она сама по себе длинная — затем
529
537
  # extract_current_session() отрезает всё, что относится к прошлым
530
538
  # запускам, и уже результат капается до разумного размера для UI.
531
- raw_lines = _logs.tail(primary, 4000)
539
+ # Читаем только хвост файла (до 4 МБ) — даже дневной IIS-лог в сотни
540
+ # МБ не грузим целиком в память ради последних строк.
541
+ raw_lines = _logs.tail(primary, 4000, max_bytes=4_000_000)
532
542
  raw_text = "\n".join(raw_lines)
533
543
  session_text = _logs.extract_current_session(raw_text) if raw_text else ""
534
544
  if session_text:
@@ -553,6 +553,26 @@
553
553
  }
554
554
  }
555
555
 
556
+ // Ручное обновление по кнопке — с видимой обратной связью: кнопка
557
+ // блокируется и меняет подпись на «Обновление…», по завершении показывается
558
+ // подтверждение со временем (у автообновления по таймеру этого нет, чтобы не
559
+ // мигать постоянно).
560
+ async function refreshStandsWithFeedback() {
561
+ const btn = document.getElementById("refresh-stands-btn");
562
+ const orig = btn.textContent;
563
+ btn.disabled = true;
564
+ btn.textContent = "Обновление…";
565
+ try {
566
+ await refreshStands();
567
+ if (!document.getElementById("stands-error").textContent) {
568
+ showActionStatus(`Список обновлён • ${new Date().toLocaleTimeString()}`, false);
569
+ }
570
+ } finally {
571
+ btn.textContent = orig;
572
+ btn.disabled = false;
573
+ }
574
+ }
575
+
556
576
  function renderStands(stands) {
557
577
  const tbody = document.getElementById("stands-tbody");
558
578
  tbody.innerHTML = "";
@@ -952,7 +972,7 @@
952
972
  setupAgentTab();
953
973
  setupSettingsForm();
954
974
  setupStatePanel();
955
- document.getElementById("refresh-stands-btn").addEventListener("click", refreshStands);
975
+ document.getElementById("refresh-stands-btn").addEventListener("click", refreshStandsWithFeedback);
956
976
 
957
977
  refreshStands();
958
978
  refreshAgentStatus();
@@ -421,6 +421,7 @@ body {
421
421
  .modal-footer {
422
422
  display: flex;
423
423
  justify-content: flex-end;
424
+ gap: 8px; /* кнопки не должны слипаться */
424
425
  padding: 12px 18px;
425
426
  border-top: 1px solid var(--bpmkit-border);
426
427
  }
@@ -40,6 +40,39 @@ def _completed(cmd, returncode=0, stdout="", stderr=""):
40
40
  return subprocess.CompletedProcess(args=cmd, returncode=returncode, stdout=stdout, stderr=stderr)
41
41
 
42
42
 
43
+ # --------------------------------------------------------------------------
44
+ # Декодирование вывода консольных утилит + подсказка о правах администратора
45
+ # --------------------------------------------------------------------------
46
+
47
+
48
+ def test_decode_console_handles_str_bytes_and_oem(monkeypatch):
49
+ # str (замоканный вывод в тестах) и None — как есть; bytes декодируются.
50
+ assert hosting._decode_console("Started") == "Started"
51
+ assert hosting._decode_console(None) == ""
52
+ assert hosting._decode_console("Привет".encode("utf-8")) == "Привет"
53
+ # OEM-байты (cp866 у appcmd на RU-Windows) не превращаются в кракозябры.
54
+ monkeypatch.setattr(hosting, "_oem_encoding", lambda: "cp866")
55
+ assert hosting._decode_console("Ошибка".encode("cp866")) == "Ошибка"
56
+
57
+
58
+ def test_iis_stop_appcmd_permission_error_adds_admin_hint(monkeypatch, tmp_path):
59
+ # appcmd падает из-за нехватки прав (redirection.config) → к тексту ошибки
60
+ # добавляется понятная подсказка «запустите от имени администратора».
61
+ _prep_iis_windows(monkeypatch, tmp_path)
62
+
63
+ def _fake_run(cmd, **kw):
64
+ return _completed(
65
+ cmd,
66
+ returncode=1,
67
+ stderr="ERROR ( message: redirection.config ... необходимых разрешений )",
68
+ )
69
+
70
+ monkeypatch.setattr(subprocess, "run", _fake_run)
71
+ stand = _make_stand(host_kind=HostKind.IIS, iis_site="site1")
72
+ with pytest.raises(HostingError, match="администратор"):
73
+ IisBackend().stop(stand)
74
+
75
+
43
76
  # --------------------------------------------------------------------------
44
77
  # get_backend
45
78
  # --------------------------------------------------------------------------
@@ -266,3 +266,36 @@ def test_open_folder_uses_os_startfile_on_windows(tmp_path, monkeypatch):
266
266
  result = open_folder(tmp_path)
267
267
  assert result.ok is True
268
268
  assert calls["path"] == str(tmp_path)
269
+
270
+
271
+ def test_list_log_files_since_mtime_filters_older(tmp_path):
272
+ import os
273
+ import time
274
+
275
+ old = tmp_path / "old.log"
276
+ old.write_text("o", encoding="utf-8")
277
+ new = tmp_path / "new.log"
278
+ new.write_text("n", encoding="utf-8")
279
+ day_ago = time.time() - 86400
280
+ os.utime(old, (day_ago, day_ago))
281
+
282
+ cutoff = time.time() - 3600 # час назад
283
+ assert [f["name"] for f in list_log_files(tmp_path, since_mtime=cutoff)] == ["new.log"]
284
+ # без cutoff — оба
285
+ assert {f["name"] for f in list_log_files(tmp_path)} == {"old.log", "new.log"}
286
+
287
+
288
+ def test_pick_primary_log_since_mtime_ignores_old(tmp_path):
289
+ import os
290
+ import time
291
+
292
+ day = tmp_path / "2026-01-01"
293
+ day.mkdir()
294
+ old = day / "a.log"
295
+ old.write_text("o", encoding="utf-8")
296
+ day_ago = time.time() - 86400
297
+ os.utime(old, (day_ago, day_ago))
298
+
299
+ # только старый файл: с cutoff «за сегодня» → None; без cutoff → он.
300
+ assert pick_primary_log(tmp_path, since_mtime=time.time() - 3600) is None
301
+ assert pick_primary_log(tmp_path) == old
@@ -203,3 +203,18 @@ def test_extract_current_session_start_marker_wins_over_application_starting():
203
203
  result = extract_current_session(text)
204
204
  assert result.startswith("=== START pid=555")
205
205
  assert "старый запуск без маркера START" not in result
206
+
207
+
208
+ def test_tail_max_bytes_reads_only_end_and_drops_partial_first_line(tmp_path):
209
+ # Огромный лог (IIS/.NET) не читаем целиком: с max_bytes берём только хвост,
210
+ # обрезанную «половинку» первой строки выбрасываем.
211
+ from standkit.logs import tail
212
+
213
+ p = tmp_path / "big.log"
214
+ lines = [f"line{i:03d}" for i in range(100)]
215
+ p.write_text("\n".join(lines) + "\n", encoding="utf-8")
216
+ size = p.stat().st_size
217
+
218
+ assert tail(p, n=5, max_bytes=size // 2) == lines[-5:]
219
+ assert tail(p, n=3) == lines[-3:] # без max_bytes — как раньше
220
+ assert tail(p, n=2, max_bytes=size * 2) == lines[-2:] # лимит больше файла
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes