diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index c862d50..890f0ce 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -20,6 +20,13 @@ from app.services.agent_commands import ( get_command_by_uuid, ) from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm +from app.services.host_sessions import ( + event_session_id, + event_supports_session_terminate, + terminate_session_for_event, +) +from app.services.linux_admin_settings import get_effective_linux_admin_config +from app.services.win_admin_settings import get_effective_win_admin_config from app.services.ingest import ingest_event from app.services.event_summary import event_to_summary from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter @@ -214,6 +221,64 @@ class LogoffActionBody(BaseModel): session_id: int +class EventSessionTerminateBody(BaseModel): + session_id: str | None = None + + +class EventSessionTerminateResponse(BaseModel): + ok: bool + message: str + target: str | None = None + stdout: str | None = None + stderr: str | None = None + + +@router.post("/{event_db_id}/actions/terminate-session", response_model=EventSessionTerminateResponse) +def post_event_terminate_session( + event_db_id: int, + body: EventSessionTerminateBody | None = Body(default=None), + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> EventSessionTerminateResponse: + event = db.scalar( + select(Event).options(joinedload(Event.host)).where(Event.id == event_db_id) + ) + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + if not event_supports_session_terminate(event): + raise HTTPException(status_code=400, detail="Event type does not support session terminate") + + linux_cfg = get_effective_linux_admin_config(db) + win_cfg = get_effective_win_admin_config(db) + sid = (body.session_id if body else None) or event_session_id(event) + + try: + result = terminate_session_for_event( + event, + linux_cfg=linux_cfg, + win_cfg=win_cfg, + session_id=sid, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if hasattr(result, "exit_code"): + return EventSessionTerminateResponse( + ok=result.ok, + message=result.message, + target=getattr(result, "target", None), + stdout=getattr(result, "stdout", None) or None, + stderr=getattr(result, "stderr", None) or None, + ) + return EventSessionTerminateResponse( + ok=result.ok, + message=result.message, + target=getattr(result, "target", None), + stdout=getattr(result, "stdout", None) or None, + stderr=getattr(result, "stderr", None) or None, + ) + + @router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse) def post_event_qwinsta( event_db_id: int, diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index 1d06fbf..a783b5a 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -29,6 +29,13 @@ from app.services.host_remote_actions import ( ) from app.services.agent_update_settings import get_effective_agent_update_config from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config +from app.services.host_sessions import ( + HostSessionRow, + list_linux_sessions, + list_windows_sessions, + terminate_linux_session, + terminate_windows_session, +) from app.services.host_delete import delete_host_and_related from app.services.linux_admin_settings import get_effective_linux_admin_config from app.services.win_admin_settings import get_effective_win_admin_config @@ -302,6 +309,48 @@ class HostRemoteActionJobResponse(BaseModel): finished_at: str | None = None +class HostSessionItem(BaseModel): + session_id: str + user: str + tty: str | None = None + state: str | None = None + source_ip: str | None = None + session_name: str | None = None + + +class HostSessionsResponse(BaseModel): + ok: bool + message: str + target: str | None = None + sessions: list[HostSessionItem] + + +class HostSessionTerminateBody(BaseModel): + session_id: str + + +class HostSessionTerminateResponse(BaseModel): + ok: bool + message: str + target: str | None = None + stdout: str | None = None + stderr: str | None = None + + +def _session_items(rows: list[HostSessionRow]) -> list[HostSessionItem]: + return [ + HostSessionItem( + session_id=r.session_id, + user=r.user, + tty=r.tty, + state=r.state, + source_ip=r.source_ip, + session_name=r.session_name, + ) + for r in rows + ] + + def _ssh_action_response( result: SshCommandResult, *, @@ -550,6 +599,115 @@ def get_host_remote_job( return HostRemoteActionJobResponse(**get_remote_action_status(host)) +@router.post("/{host_id}/actions/sessions/list", response_model=HostSessionsResponse) +def list_host_sessions( + host_id: int, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostSessionsResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + + from app.services.ssh_connect import is_linux_host + from app.services.winrm_connect import is_windows_host + + if is_linux_host(host): + cfg = get_effective_linux_admin_config(db) + if not cfg.configured: + raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") + try: + sessions, result = list_linux_sessions(host, cfg) + except SshHostNotLinuxError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except SshHostTargetMissingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return HostSessionsResponse( + ok=result.ok, + message=result.message, + target=result.target or None, + sessions=_session_items(sessions), + ) + + if is_windows_host(host): + cfg = get_effective_win_admin_config(db) + if not cfg.configured: + raise HTTPException(status_code=400, detail="Windows domain admin is not configured") + try: + sessions, result = list_windows_sessions(host, cfg) + except HostNotWindowsError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except HostTargetMissingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if result is None: + raise HTTPException(status_code=502, detail="WinRM qwinsta failed") + return HostSessionsResponse( + ok=result.ok, + message=result.message, + target=result.target or None, + sessions=_session_items(sessions), + ) + + raise HTTPException(status_code=400, detail="Host OS does not support live sessions") + + +@router.post("/{host_id}/actions/sessions/terminate", response_model=HostSessionTerminateResponse) +def terminate_host_session( + host_id: int, + body: HostSessionTerminateBody, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> HostSessionTerminateResponse: + host = db.get(Host, host_id) + if host is None: + raise HTTPException(status_code=404, detail="Host not found") + + from app.services.ssh_connect import is_linux_host + from app.services.winrm_connect import is_windows_host + + sid = body.session_id.strip() + if not sid: + raise HTTPException(status_code=422, detail="session_id is required") + + if is_linux_host(host): + cfg = get_effective_linux_admin_config(db) + if not cfg.configured: + raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") + try: + result = terminate_linux_session(host, cfg, sid) + except SshHostNotLinuxError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except SshHostTargetMissingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return HostSessionTerminateResponse( + ok=result.ok, + message=result.message, + target=result.target or None, + stdout=result.stdout or None, + stderr=result.stderr or None, + ) + + if is_windows_host(host): + cfg = get_effective_win_admin_config(db) + if not cfg.configured: + raise HTTPException(status_code=400, detail="Windows domain admin is not configured") + try: + result = terminate_windows_session(host, cfg, sid) + except HostNotWindowsError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if result is None: + raise HTTPException(status_code=502, detail="WinRM logoff failed") + return HostSessionTerminateResponse( + ok=result.ok, + message=result.message, + target=result.target or None, + stdout=result.stdout or None, + stderr=result.stderr or None, + ) + + raise HTTPException(status_code=400, detail="Host OS does not support session terminate") + + @router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse) def patch_host_agent_config( host_id: int, diff --git a/backend/app/services/host_sessions.py b/backend/app/services/host_sessions.py new file mode 100644 index 0000000..cabd1c6 --- /dev/null +++ b/backend/app/services/host_sessions.py @@ -0,0 +1,294 @@ +"""Live user sessions on hosts (Linux loginctl / Windows qwinsta) via SSH or WinRM.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from app.models import Event, Host +from app.services.linux_admin_settings import LinuxAdminConfig +from app.services.ssh_connect import ( + HostNotLinuxError as SshHostNotLinuxError, + HostTargetMissingError as SshHostTargetMissingError, + SshCommandResult, + is_linux_host, + iter_ssh_targets, + run_ssh_command, +) +from app.services.win_admin_settings import WinAdminConfig +from app.services.winrm_connect import ( + HostNotWindowsError, + HostTargetMissingError, + WinRmCmdResult, + is_windows_host, + run_winrm_logoff, + run_winrm_on_host_targets, + run_winrm_qwinsta, +) + +SESSION_EVENT_TYPES_LINUX = frozenset( + { + "session.logind.new", + "ssh.login.success", + "privilege.sudo.command", + } +) +SESSION_EVENT_TYPES_WINDOWS = frozenset({"rdp.login.success"}) + + +@dataclass(frozen=True) +class HostSessionRow: + session_id: str + user: str + tty: str | None = None + state: str | None = None + source_ip: str | None = None + session_name: str | None = None + + +def _details_dict(event: Event) -> dict: + raw = event.details + return raw if isinstance(raw, dict) else {} + + +def event_session_id(event: Event) -> str | None: + details = _details_dict(event) + for key in ("session_id", "sid"): + val = details.get(key) + if val is not None and str(val).strip(): + return str(val).strip() + return None + + +def event_supports_session_terminate(event: Event) -> bool: + if event.type in SESSION_EVENT_TYPES_LINUX: + return is_linux_host_event(event) + if event.type in SESSION_EVENT_TYPES_WINDOWS: + return is_windows_host_event(event) + return False + + +def is_linux_host_event(event: Event) -> bool: + host = event.host + return host is not None and is_linux_host(host) + + +def is_windows_host_event(event: Event) -> bool: + host = event.host + return host is not None and is_windows_host(host) + + +def parse_loginctl_sessions(stdout: str) -> list[HostSessionRow]: + rows: list[HostSessionRow] = [] + for line in stdout.splitlines(): + text = line.strip() + if not text: + continue + parts = text.split() + if len(parts) < 3: + continue + sid, user = parts[0], parts[2] + tty = parts[4] if len(parts) > 4 and parts[4] != "-" else None + state = parts[5] if len(parts) > 5 else None + rows.append( + HostSessionRow( + session_id=sid, + user=user, + tty=tty, + state=state, + ) + ) + return rows + + +def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]: + rows: list[HostSessionRow] = [] + norm_filter = (filter_user or "").strip().lower() + + def norm_user(value: str) -> str: + return value.replace("B26\\", "").replace("b26\\", "").lower() + + for line in stdout.splitlines(): + text = line.strip() + if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"): + continue + parts = text.split() + if len(parts) < 4: + continue + session_name = parts[0].lstrip(">") + user_name = parts[1] + try: + sid = int(parts[2]) + except ValueError: + continue + state = " ".join(parts[3:]) + if norm_filter and norm_filter not in norm_user(user_name): + continue + rows.append( + HostSessionRow( + session_id=str(sid), + user=user_name, + session_name=session_name, + state=state, + ) + ) + + if rows or not norm_filter: + return rows + + return parse_qwinsta_sessions(stdout, filter_user=None) + + +def list_linux_sessions(host: Host, cfg: LinuxAdminConfig) -> tuple[list[HostSessionRow], SshCommandResult]: + if not is_linux_host(host): + raise SshHostNotLinuxError("Host is not Linux") + targets = iter_ssh_targets(host) + cmd = "loginctl list-sessions --no-legend --no-pager 2>/dev/null || who -u" + last: SshCommandResult | None = None + for target in targets: + result = run_ssh_command( + target=target, + user=cfg.user, + password=cfg.password, + remote_cmd=cmd, + connect_timeout_sec=15, + command_timeout_sec=45, + need_root=True, + ) + last = result + if result.ok and result.stdout.strip(): + return parse_loginctl_sessions(result.stdout), result + assert last is not None + return [], last + + +def terminate_linux_session( + host: Host, + cfg: LinuxAdminConfig, + session_id: str, +) -> SshCommandResult: + sid = session_id.strip() + if not sid: + return SshCommandResult(ok=False, message="session_id is required", target="") + if not is_linux_host(host): + raise SshHostNotLinuxError("Host is not Linux") + targets = iter_ssh_targets(host) + remote_cmd = f"loginctl terminate-session {_shell_quote(sid)}" + last: SshCommandResult | None = None + for target in targets: + result = run_ssh_command( + target=target, + user=cfg.user, + password=cfg.password, + remote_cmd=remote_cmd, + connect_timeout_sec=15, + command_timeout_sec=45, + need_root=True, + ) + last = result + if result.ok: + return result + assert last is not None + return last + + +def _shell_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def list_windows_sessions(host: Host, cfg: WinAdminConfig) -> tuple[list[HostSessionRow], WinRmCmdResult | None]: + if not is_windows_host(host): + raise HostNotWindowsError("Host is not Windows") + + def action(target: str) -> WinRmCmdResult: + return run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password) + + result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action) + if result is None or not result.ok: + return [], result + return parse_qwinsta_sessions(result.stdout), result + + +def terminate_windows_session( + host: Host, + cfg: WinAdminConfig, + session_id: str, +) -> WinRmCmdResult | None: + try: + sid = int(session_id.strip()) + except ValueError: + return WinRmCmdResult(ok=False, message="Invalid Windows session_id", target="") + + def action(target: str) -> WinRmCmdResult: + return run_winrm_logoff( + target=target, + user=cfg.user, + password=cfg.password, + session_id=sid, + ) + + result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action) + return result + + +def terminate_session_for_event( + event: Event, + *, + linux_cfg: LinuxAdminConfig, + win_cfg: WinAdminConfig, + session_id: str | None = None, +) -> SshCommandResult | WinRmCmdResult: + host = event.host + if host is None: + raise ValueError("Event has no host") + + sid = (session_id or event_session_id(event) or "").strip() + if is_linux_host(host): + if not sid: + user = (event.actor_user or _details_dict(event).get("user") or "").strip() + if user: + return _terminate_linux_user_sessions(host, linux_cfg, user) + raise ValueError("session_id is required for this event") + return terminate_linux_session(host, linux_cfg, sid) + + if is_windows_host(host): + if not sid: + user = (event.actor_user or _details_dict(event).get("user") or "").strip() + sessions, qwinsta = list_windows_sessions(host, win_cfg) + if not qwinsta or not qwinsta.ok: + raise ValueError(qwinsta.message if qwinsta else "qwinsta failed") + matched = [s for s in sessions if user.lower() in s.user.lower()] if user else sessions + if len(matched) == 1: + sid = matched[0].session_id + elif not matched: + raise ValueError("No matching Windows session for user") + else: + raise ValueError("Multiple sessions; specify session_id") + result = terminate_windows_session(host, win_cfg, sid) + if result is None: + raise ValueError("WinRM logoff failed") + return result + + raise ValueError("Unsupported host OS for session terminate") + + +def _terminate_linux_user_sessions(host: Host, cfg: LinuxAdminConfig, user: str) -> SshCommandResult: + safe_user = _shell_quote(user.strip()) + remote_cmd = f"loginctl terminate-user {safe_user}" + targets = iter_ssh_targets(host) + last: SshCommandResult | None = None + for target in targets: + result = run_ssh_command( + target=target, + user=cfg.user, + password=cfg.password, + remote_cmd=remote_cmd, + connect_timeout_sec=15, + command_timeout_sec=45, + need_root=True, + ) + last = result + if result.ok: + return result + assert last is not None + return last diff --git a/backend/app/version.py b/backend/app/version.py index 4a89ee8..f33a786 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.20.27" +APP_VERSION = "0.20.28" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 744bec3..0b4e9e9 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.20.27" + assert APP_VERSION == "0.20.28" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.20.27" + assert APP_VERSION_LABEL == "Security Alert Center v.0.20.28" diff --git a/backend/tests/test_host_sessions.py b/backend/tests/test_host_sessions.py new file mode 100644 index 0000000..93f2560 --- /dev/null +++ b/backend/tests/test_host_sessions.py @@ -0,0 +1,41 @@ +"""Tests for host session list/parse helpers.""" + +from app.services.host_sessions import ( + event_supports_session_terminate, + parse_loginctl_sessions, + parse_qwinsta_sessions, +) + + +def test_parse_loginctl_sessions(): + stdout = "c1 1000 alice seat0 pts/0 active -\n 2 1001 bob - tty2 active -\n" + rows = parse_loginctl_sessions(stdout) + assert len(rows) == 2 + assert rows[0].session_id == "c1" + assert rows[0].user == "alice" + assert rows[0].tty == "pts/0" + + +def test_parse_qwinsta_sessions_filters_user(): + stdout = """SESSIONNAME USERNAME ID STATE + console Administrator 1 Active + rdp-tcp#0 B26\\alice 2 Active +""" + all_rows = parse_qwinsta_sessions(stdout) + assert len(all_rows) == 2 + filtered = parse_qwinsta_sessions(stdout, filter_user="alice") + assert len(filtered) == 1 + assert filtered[0].session_id == "2" + + +def test_event_supports_session_terminate_types(): + class HostStub: + os_family = "linux" + product = "ssh-monitor" + + class EventStub: + def __init__(self, event_type: str): + self.type = event_type + self.host = HostStub() + + assert event_supports_session_terminate(EventStub("ssh.login.success")) is True diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5978b67..90209a2 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -573,6 +573,56 @@ export function fetchHostRemoteJob(hostId: number): Promise(`/api/v1/hosts/${hostId}/actions/remote-job`); } +export interface HostSessionItem { + session_id: string; + user: string; + tty?: string | null; + state?: string | null; + source_ip?: string | null; + session_name?: string | null; +} + +export interface HostSessionsResponse { + ok: boolean; + message: string; + target?: string | null; + sessions: HostSessionItem[]; +} + +export interface HostSessionTerminateResponse { + ok: boolean; + message: string; + target?: string | null; + stdout?: string | null; + stderr?: string | null; +} + +export function fetchHostSessions(hostId: number): Promise { + return apiFetch(`/api/v1/hosts/${hostId}/actions/sessions/list`, { + method: "POST", + }); +} + +export function terminateHostSession( + hostId: number, + sessionId: string, +): Promise { + return apiFetch(`/api/v1/hosts/${hostId}/actions/sessions/terminate`, { + method: "POST", + body: JSON.stringify({ session_id: sessionId }), + }); +} + +export function postEventTerminateSession( + eventId: number, + sessionId?: string, +): Promise { + return apiFetch(`/api/v1/events/${eventId}/actions/terminate-session`, { + method: "POST", + body: JSON.stringify(sessionId ? { session_id: sessionId } : {}), + }); +} + export interface HostAgentConfigResponse { config_revision: number; settings: Record; diff --git a/frontend/src/components/HostSessionsPanel.vue b/frontend/src/components/HostSessionsPanel.vue new file mode 100644 index 0000000..8dea143 --- /dev/null +++ b/frontend/src/components/HostSessionsPanel.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/frontend/src/utils/sessionActions.ts b/frontend/src/utils/sessionActions.ts new file mode 100644 index 0000000..6055905 --- /dev/null +++ b/frontend/src/utils/sessionActions.ts @@ -0,0 +1,16 @@ +import type { EventSummary } from "../api"; + +const LINUX_SESSION_EVENT_TYPES = new Set([ + "session.logind.new", + "ssh.login.success", + "privilege.sudo.command", +]); + +const WINDOWS_SESSION_EVENT_TYPES = new Set(["rdp.login.success"]); + +export function eventSupportsSessionTerminate(event: Pick): boolean { + return ( + LINUX_SESSION_EVENT_TYPES.has(event.type) || + WINDOWS_SESSION_EVENT_TYPES.has(event.type) + ); +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index b95b923..847a3fe 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.20.27"; +export const APP_VERSION = "0.20.28"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/EventsView.vue b/frontend/src/views/EventsView.vue index 1198ed3..4a002be 100644 --- a/frontend/src/views/EventsView.vue +++ b/frontend/src/views/EventsView.vue @@ -67,6 +67,15 @@ > qwinsta + @@ -95,7 +104,7 @@ import { computed, ref, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useSacLiveEventStream } from "../composables/useSacLiveEventStream"; -import { apiFetch, type EventListResponse } from "../api"; +import { apiFetch, postEventTerminateSession, type EventListResponse, type EventSummary } from "../api"; import RdgFlapBadge from "../components/RdgFlapBadge.vue"; import RdgQwinstaModal from "../components/RdgQwinstaModal.vue"; import { useRdgQwinsta } from "../composables/useRdgQwinsta"; @@ -108,10 +117,12 @@ import { type EventsListState, } from "../utils/eventsListQuery"; import { formatServerName } from "../utils/hostDisplay"; +import { eventSupportsSessionTerminate } from "../utils/sessionActions"; const route = useRoute(); const router = useRouter(); const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta(); +const sessionTerminateLoadingId = ref(null); const data = ref(null); const loading = ref(false); @@ -205,6 +216,22 @@ useSacLiveEventStream(() => { void load(undefined, { syncUrl: false, silent: true }); }); +async function runSessionTerminate(event: EventSummary) { + const label = event.actor_user || event.title; + if (!window.confirm(`Завершить сессию пользователя ${label}?`)) return; + sessionTerminateLoadingId.value = event.id; + try { + const res = await postEventTerminateSession(event.id); + if (!res.ok) { + window.alert(res.message); + } + } catch (e) { + window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии"); + } finally { + sessionTerminateLoadingId.value = null; + } +} + function applyFilters() { load(1); } diff --git a/frontend/src/views/HostDetailView.vue b/frontend/src/views/HostDetailView.vue index aeabe66..b54521e 100644 --- a/frontend/src/views/HostDetailView.vue +++ b/frontend/src/views/HostDetailView.vue @@ -166,6 +166,8 @@ + +

Железо и ПО

@@ -294,6 +296,7 @@ import { type HostDetail, } from "../api"; import { emitHostPatch } from "../utils/hostPatchBus"; +import HostSessionsPanel from "../components/HostSessionsPanel.vue"; const HOST_ACTION_FEEDBACK_MS = 30_000;