fix: SSH update log modal shows completion and full tail (v0.5.6)
Fetch final update_script.log tail after agent-update, update title/message on success, show log by default; auto-close after 30s unchanged.
This commit is contained in:
@@ -16,7 +16,12 @@ from app.services.agent_update import execute_agent_update_fallback
|
|||||||
from app.services.agent_update_types import AgentUpdateFallbackResult
|
from app.services.agent_update_types import AgentUpdateFallbackResult
|
||||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||||
from app.services.agent_update_settings import get_effective_agent_update_config
|
from app.services.agent_update_settings import get_effective_agent_update_config
|
||||||
from app.services.ssh_connect import iter_ssh_targets, run_ssh_monitor_update, SshCommandResult, tail_ssh_monitor_update_log
|
from app.services.ssh_connect import (
|
||||||
|
iter_ssh_targets,
|
||||||
|
run_ssh_monitor_update,
|
||||||
|
SshCommandResult,
|
||||||
|
tail_ssh_monitor_update_log,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -76,6 +81,61 @@ def _result_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _completion_title(base_title: str, result: SshCommandResult) -> str:
|
||||||
|
if result.ok:
|
||||||
|
return f"{base_title} — готово"
|
||||||
|
return f"{base_title} — ошибка"
|
||||||
|
|
||||||
|
|
||||||
|
def _completion_message(result: SshCommandResult) -> str:
|
||||||
|
if result.ok:
|
||||||
|
version = (result.agent_version or "").strip()
|
||||||
|
if version:
|
||||||
|
return f"Обновление завершено успешно. Версия агента: {version}"
|
||||||
|
return "Обновление завершено успешно"
|
||||||
|
return (result.message or "Обновление не удалось").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_ssh_update_log_tail(db: Session, host: Host, *, lines: int = 200) -> str:
|
||||||
|
cfg = get_effective_linux_admin_config(db)
|
||||||
|
if not cfg.configured:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
targets = iter_ssh_targets(host)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
for target in targets:
|
||||||
|
try:
|
||||||
|
tail = tail_ssh_monitor_update_log(
|
||||||
|
target=target,
|
||||||
|
user=cfg.user,
|
||||||
|
password=cfg.password,
|
||||||
|
lines=lines,
|
||||||
|
)
|
||||||
|
if tail:
|
||||||
|
return tail
|
||||||
|
except Exception:
|
||||||
|
logger.debug("final update log tail failed host_id=%s target=%s", host.id, target, exc_info=True)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_ssh_update_payload(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
base_title: str,
|
||||||
|
result: SshCommandResult,
|
||||||
|
previous_output: str,
|
||||||
|
log_tail: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload["title"] = _completion_title(base_title, result)
|
||||||
|
payload["message"] = _completion_message(result)
|
||||||
|
if log_tail:
|
||||||
|
payload["output"] = log_tail
|
||||||
|
elif previous_output.strip():
|
||||||
|
payload["output"] = previous_output
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _mark_running(db: Session, host: Host, *, title: str) -> str:
|
def _mark_running(db: Session, host: Host, *, title: str) -> str:
|
||||||
started_at = _utcnow().isoformat()
|
started_at = _utcnow().isoformat()
|
||||||
host.agent_update_state = "running"
|
host.agent_update_state = "running"
|
||||||
@@ -145,6 +205,7 @@ def _poll_ssh_update_log_loop(
|
|||||||
target=target,
|
target=target,
|
||||||
user=cfg.user,
|
user=cfg.user,
|
||||||
password=cfg.password,
|
password=cfg.password,
|
||||||
|
lines=200,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("update log tail failed host_id=%s target=%s", host_id, target, exc_info=True)
|
logger.debug("update log tail failed host_id=%s target=%s", host_id, target, exc_info=True)
|
||||||
@@ -205,9 +266,20 @@ def start_host_remote_action(
|
|||||||
row = session.get(Host, host_id)
|
row = session.get(Host, host_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
return
|
return
|
||||||
|
previous_output = (row.remote_action or {}).get("output") or ""
|
||||||
result = runner(session, row)
|
result = runner(session, row)
|
||||||
started = (row.remote_action or {}).get("started_at") or started_at
|
started = (row.remote_action or {}).get("started_at") or started_at
|
||||||
row.remote_action = _result_payload(result, title=title, started_at=started)
|
payload = _result_payload(result, title=title, started_at=started)
|
||||||
|
if poll_remote_log and isinstance(result, SshCommandResult):
|
||||||
|
log_tail = _fetch_ssh_update_log_tail(session, row)
|
||||||
|
payload = _enrich_ssh_update_payload(
|
||||||
|
payload,
|
||||||
|
base_title=title,
|
||||||
|
result=result,
|
||||||
|
previous_output=previous_output,
|
||||||
|
log_tail=log_tail,
|
||||||
|
)
|
||||||
|
row.remote_action = payload
|
||||||
if isinstance(result, SshCommandResult):
|
if isinstance(result, SshCommandResult):
|
||||||
_apply_ssh_update_result(session, row, result)
|
_apply_ssh_update_result(session, row, result)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.5.5"
|
APP_VERSION = "0.5.6"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -174,3 +174,51 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch)
|
|||||||
assert job["target"] == "ubabuba"
|
assert job["target"] == "ubabuba"
|
||||||
if "product_version" in job:
|
if "product_version" in job:
|
||||||
assert job["product_version"] is None or isinstance(job["product_version"], str)
|
assert job["product_version"] is None or isinstance(job["product_version"], str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_agent_update_job_shows_log_tail_and_completion_title(
|
||||||
|
jwt_headers, client, db_session, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import Host
|
||||||
|
from app.services.ssh_connect import SshCommandResult
|
||||||
|
from tests.test_agent_update import wait_remote_job
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
host = Host(hostname="router", os_family="linux", product="ssh-monitor", ipv4="10.0.0.1")
|
||||||
|
db_session.add(host)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(host)
|
||||||
|
|
||||||
|
log_tail = (
|
||||||
|
"2026-07-08 14:18:15 INFO: === Script update completed successfully ===\n"
|
||||||
|
"2026-07-08 14:18:15 INFO: завершено успешно (код 0). Итог см. выше\n"
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("app.services.host_remote_actions.run_ssh_monitor_update") as mock_update,
|
||||||
|
patch("app.services.host_remote_actions.tail_ssh_monitor_update_log") as mock_tail,
|
||||||
|
):
|
||||||
|
mock_update.return_value = SshCommandResult(
|
||||||
|
ok=True,
|
||||||
|
message="SSH OK (router), exit 0",
|
||||||
|
target="router",
|
||||||
|
stdout="",
|
||||||
|
exit_code=0,
|
||||||
|
agent_version="2.3.2-SAC",
|
||||||
|
)
|
||||||
|
mock_tail.return_value = log_tail
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/hosts/{host.id}/actions/agent-update",
|
||||||
|
headers=jwt_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
job = wait_remote_job(client, host.id, jwt_headers)
|
||||||
|
assert job["ok"] is True
|
||||||
|
assert "готово" in (job.get("title") or "")
|
||||||
|
assert "2.3.2-SAC" in (job.get("message") or "")
|
||||||
|
assert "Script update completed successfully" in (job.get("output") or "")
|
||||||
|
mock_tail.assert_called()
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
<div v-if="open" class="host-action-log-dock" aria-live="polite">
|
<div v-if="open" class="host-action-log-dock" aria-live="polite">
|
||||||
<div class="host-action-log-panel" role="dialog" :aria-label="title">
|
<div class="host-action-log-panel" role="dialog" :aria-label="title">
|
||||||
<div class="host-action-log-header">
|
<div class="host-action-log-header">
|
||||||
<h3>{{ title }}</h3>
|
<h3>
|
||||||
|
<span v-if="!loading && ok === true" class="host-action-log-mark host-action-log-mark-ok" aria-hidden="true">✓</span>
|
||||||
|
<span v-else-if="!loading && ok === false" class="host-action-log-mark host-action-log-mark-fail" aria-hidden="true">✗</span>
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="host-action-log-icon-btn"
|
class="host-action-log-icon-btn"
|
||||||
@@ -19,6 +23,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<p v-if="message && !loading" class="host-action-log-message" :class="messageClass">{{ message }}</p>
|
<p v-if="message && !loading" class="host-action-log-message" :class="messageClass">{{ message }}</p>
|
||||||
<p v-else-if="message && loading" class="muted host-action-log-sub host-action-log-message">{{ message }}</p>
|
<p v-else-if="message && loading" class="muted host-action-log-sub host-action-log-message">{{ message }}</p>
|
||||||
|
<p v-if="!loading && ok" class="muted host-action-log-sub">Окно закроется автоматически через ~30 с</p>
|
||||||
<pre v-if="logVisible" class="host-action-log-output">{{
|
<pre v-if="logVisible" class="host-action-log-output">{{
|
||||||
output || "Ожидание лога с хоста…\n(обновление /var/log/update_script.log)"
|
output || "Ожидание лога с хоста…\n(обновление /var/log/update_script.log)"
|
||||||
}}</pre>
|
}}</pre>
|
||||||
@@ -94,6 +99,22 @@ const messageClass = computed(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-action-log-mark {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-action-log-mark-ok {
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-action-log-mark-fail {
|
||||||
|
color: #f85149;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-action-log-icon-btn {
|
.host-action-log-icon-btn {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobSt
|
|||||||
entry.loading = false;
|
entry.loading = false;
|
||||||
entry.ok = job.ok ?? job.status === "success";
|
entry.ok = job.ok ?? job.status === "success";
|
||||||
applyJobToEntry(entry, job);
|
applyJobToEntry(entry, job);
|
||||||
|
entry.logVisible = true;
|
||||||
entry.open = true;
|
entry.open = true;
|
||||||
if (entry.ok) {
|
if (entry.ok) {
|
||||||
scheduleSuccessAutoClose(entry.id);
|
scheduleSuccessAutoClose(entry.id);
|
||||||
@@ -181,7 +182,7 @@ export async function pollHostRemoteAction(
|
|||||||
id: newLogId(),
|
id: newLogId(),
|
||||||
hostId,
|
hostId,
|
||||||
open: true,
|
open: true,
|
||||||
logVisible: false,
|
logVisible: true,
|
||||||
loading: true,
|
loading: true,
|
||||||
ok: null,
|
ok: null,
|
||||||
title,
|
title,
|
||||||
@@ -231,7 +232,7 @@ export async function runHostRemoteAction(
|
|||||||
id: newLogId(),
|
id: newLogId(),
|
||||||
hostId,
|
hostId,
|
||||||
open: true,
|
open: true,
|
||||||
logVisible: false,
|
logVisible: true,
|
||||||
loading: true,
|
loading: true,
|
||||||
ok: null,
|
ok: null,
|
||||||
title,
|
title,
|
||||||
|
|||||||
Reference in New Issue
Block a user