Files
security-alert-center/frontend/src/views/HostDetailView.vue
T
PTah b3d28cd5d8 fix(frontend): auto-close WinRM update success panel after 30s (0.20.24)
Remote action log dock closes on successful fallback; no duplicate inline message.
2026-06-21 20:41:42 +10:00

869 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<p><RouterLink to="/hosts"> Хосты</RouterLink></p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p>
<template v-else-if="host">
<h1>{{ host.display_name || host.hostname }}</h1>
<div class="card report-meta">
<dl class="detail-fields">
<div class="detail-field">
<dt>Hostname</dt>
<dd>{{ host.hostname }}</dd>
</div>
<div class="detail-field">
<dt>ID</dt>
<dd>{{ host.id }}</dd>
</div>
<div class="detail-field">
<dt>Агент</dt>
<dd><span :class="'agent-' + host.agent_status">{{ agentLabel(host.agent_status) }}</span></dd>
</div>
<div class="detail-field">
<dt>Версия агента</dt>
<dd>
<span :class="versionStatusClass">{{ host.product_version || "—" }}</span>
<span v-if="host.version_status === 'outdated'" class="muted version-hint"> (устарела)</span>
<span v-if="host.pending_agent_update" class="pending-update-badge">обновление запрошено</span>
</dd>
</div>
<div v-if="host.agent_update_state" class="detail-field">
<dt>Обновление</dt>
<dd>
{{ host.agent_update_state }}
<span v-if="host.pending_update_target_version"> {{ host.pending_update_target_version }}</span>
<span v-if="host.agent_update_last_at" class="muted"> ({{ formatDt(host.agent_update_last_at) }})</span>
</dd>
</div>
<div v-if="host.agent_update_last_error" class="detail-field">
<dt>Ошибка обновления</dt>
<dd class="error-inline">{{ host.agent_update_last_error }}</dd>
</div>
<div class="detail-field">
<dt>OS</dt>
<dd>{{ host.os_family }} {{ host.os_version || "" }}</dd>
</div>
<div class="detail-field">
<dt>IPv4</dt>
<dd>{{ host.ipv4 || "—" }}</dd>
</div>
<div class="detail-field">
<dt>Heartbeat</dt>
<dd>{{ host.last_heartbeat_at ? formatDt(host.last_heartbeat_at) : "—" }}</dd>
</div>
<div class="detail-field">
<dt>Last seen</dt>
<dd>{{ formatDt(host.last_seen_at) }}</dd>
</div>
<div class="detail-field">
<dt>Инвентаризация</dt>
<dd>{{ host.last_inventory_at ? formatDt(host.last_inventory_at) : "—" }}</dd>
</div>
<div class="detail-field">
<dt>Событий</dt>
<dd>{{ host.event_count ?? 0 }}</dd>
</div>
</dl>
<div v-if="isWindowsHost" class="host-actions">
<button
type="button"
class="secondary"
:disabled="testingWinRm"
@click="runWinRmTest"
>
{{ testingWinRm ? "Проверка…" : "Проверить WinRM (admin)" }}
</button>
<p v-if="showWinRmTestFeedback && winRmTestMessage" :class="winRmTestOk ? 'success' : 'error'">{{ winRmTestMessage }}</p>
<div class="host-actions-row">
<button
type="button"
class="secondary"
:disabled="requestingAgentUpdate"
@click="runRequestAgentUpdate"
>
{{ requestingAgentUpdate ? "Запрос…" : "Запросить обновление (SAC)" }}
</button>
<button
type="button"
class="secondary"
:disabled="runningAgentFallback"
@click="runAgentFallback"
>
{{ runningAgentFallback ? "Обновление…" : "Обновить через WinRM" }}
</button>
</div>
<p v-if="agentControlMessage && !hostRemoteActionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
</div>
<div v-if="isLinuxHost" class="host-actions">
<div class="host-actions-row">
<div class="host-action-item">
<button
type="button"
class="secondary"
:disabled="testingSsh"
@click="runSshTestManual"
>
{{ testingSsh ? "Проверка…" : "Проверить SSH" }}
</button>
<span
v-if="sshVerified"
class="host-action-ok"
:title="sshVerifiedTitle"
></span>
</div>
<button
type="button"
class="secondary"
:disabled="updatingAgent"
@click="runAgentUpdate"
>
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor (SSH)" }}
</button>
<button
type="button"
class="secondary"
:disabled="requestingAgentUpdate"
@click="runRequestAgentUpdate"
>
{{ requestingAgentUpdate ? "Запрос…" : "Запросить обновление (SAC)" }}
</button>
<button
type="button"
class="secondary"
:disabled="runningAgentFallback"
@click="runAgentFallback"
>
{{ runningAgentFallback ? "Fallback…" : "Fallback SSH" }}
</button>
</div>
<p v-if="agentControlMessage && !hostRemoteActionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
<div v-if="showSshTestFeedback && (sshTestMessage || sshTestOutput)" class="host-action-feedback">
<p :class="sshTestOk ? 'success' : 'error'">{{ sshTestMessage }}</p>
<pre v-if="sshTestOutput" class="host-ssh-output">{{ sshTestOutput }}</pre>
</div>
</div>
</div>
<section v-if="showAgentConfig" class="card host-agent-config">
<h2>Desired config (poll агента)</h2>
<p class="muted">Агент заберёт настройки при следующем poll. Revision: {{ host.agent_config_revision ?? 0 }}</p>
<form class="agent-config-form" @submit.prevent="saveAgentConfig">
<label v-if="isWindowsHost" class="config-field">
ServerDisplayName
<input v-model="configForm.serverDisplayName" type="text" />
</label>
<label v-if="isLinuxHost" class="config-field">
SERVER_DISPLAY_NAME
<input v-model="configForm.serverDisplayName" type="text" />
</label>
<label v-if="isWindowsHost" class="config-inline">
<input v-model="configForm.getInventory" type="checkbox" />
GetInventory
</label>
<button type="submit" :disabled="savingAgentConfig">
{{ savingAgentConfig ? "Сохранение…" : "Сохранить config" }}
</button>
<p v-if="agentConfigMessage" :class="agentConfigOk ? 'success' : 'error'">{{ agentConfigMessage }}</p>
</form>
</section>
<section v-if="inventory" class="host-inventory card">
<h2>Железо и ПО</h2>
<div v-if="windowsInfo" class="inv-block">
<h3>Windows</h3>
<dl class="detail-fields detail-fields-compact">
<div v-for="item in windowsFields" :key="item.label" class="detail-field">
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
</div>
<div v-if="computerName" class="inv-block">
<p><strong>Имя компьютера:</strong> {{ computerName }}</p>
</div>
<div v-if="processors.length" class="inv-block">
<h3>Процессор</h3>
<ul>
<li v-for="(p, i) in processors" :key="'cpu-' + i">
{{ p.name }}
<span v-if="p.cores != null"> {{ p.cores }} ядер / {{ p.logical }} потоков</span>
</li>
</ul>
</div>
<div v-if="motherboard" class="inv-block">
<h3>Материнская плата</h3>
<p>{{ motherboard }}</p>
</div>
<div v-if="memoryGb != null" class="inv-block">
<h3>Память</h3>
<p>{{ memoryGb }} GB</p>
</div>
<div v-if="disks.length" class="inv-block">
<h3>Диски</h3>
<table class="dash-table">
<thead>
<tr>
<th>Имя</th>
<th>Тип</th>
<th>Размер, GB</th>
</tr>
</thead>
<tbody>
<tr v-for="(d, i) in disks" :key="'disk-' + i">
<td>{{ d.friendly_name || "—" }}</td>
<td>{{ d.media_type || "—" }}</td>
<td>{{ d.size_gb ?? "—" }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="video.length" class="inv-block">
<h3>Видео</h3>
<ul>
<li v-for="(v, i) in video" :key="'gpu-' + i">{{ v.name }}</li>
</ul>
</div>
<div v-if="ipv4List.length" class="inv-block">
<h3>IPv4</h3>
<p>{{ ipv4List.join(", ") }}</p>
</div>
</section>
<p v-else class="muted">Инвентаризация ещё не поступала с агента (agent.inventory).</p>
<h2>События хоста</h2>
<p v-if="eventsLoading">Загрузка событий</p>
<p v-else-if="eventsError" class="error">{{ eventsError }}</p>
<template v-else>
<p>Всего: {{ events?.total ?? 0 }}</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Время</th>
<th>Severity</th>
<th>Type</th>
<th>Title</th>
</tr>
</thead>
<tbody>
<tr v-for="e in events?.items ?? []" :key="e.id">
<td>
<RouterLink :to="{ path: `/events/${e.id}`, query: eventsBackQuery }">{{ e.id }}</RouterLink>
</td>
<td>{{ formatDt(e.occurred_at) }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td><code>{{ e.type }}</code></td>
<td>{{ e.title }}</td>
</tr>
</tbody>
</table>
<div class="filters" style="margin-top: 1rem">
<button type="button" class="secondary" :disabled="eventsPage <= 1" @click="loadEvents(eventsPage - 1)">
Назад
</button>
<span>Стр. {{ eventsPage }}</span>
<button
type="button"
class="secondary"
:disabled="!events || eventsPage * eventsPageSize >= events.total"
@click="loadEvents(eventsPage + 1)"
>
Вперёд
</button>
</div>
</template>
</template>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
import {
hostRemoteActionLog,
runHostRemoteAction,
} from "../composables/useHostRemoteAction";
import {
apiFetch,
fetchHost,
fetchRecentEvents,
testHostWinRm,
testHostSsh,
requestHostAgentUpdate,
patchHostAgentConfig,
type EventListResponse,
type HostDetail,
} from "../api";
import { emitHostPatch } from "../utils/hostPatchBus";
const HOST_ACTION_FEEDBACK_MS = 30_000;
const props = defineProps<{ id: string }>();
const route = useRoute();
const host = ref<HostDetail | null>(null);
const events = ref<EventListResponse | null>(null);
const loading = ref(false);
const eventsLoading = ref(false);
const error = ref("");
const eventsError = ref("");
const eventsPage = ref(1);
const eventsPageSize = 50;
const testingWinRm = ref(false);
const winRmTestMessage = ref("");
const winRmTestOk = ref(false);
const showWinRmTestFeedback = ref(false);
const testingSsh = ref(false);
const updatingAgent = ref(false);
const showSshTestFeedback = ref(false);
const sshTestMessage = ref("");
const sshTestOk = ref(false);
const sshTestOutput = ref("");
const requestingAgentUpdate = ref(false);
const runningAgentFallback = ref(false);
const agentControlMessage = ref("");
const agentControlOk = ref(false);
const savingAgentConfig = ref(false);
const agentConfigMessage = ref("");
const agentConfigOk = ref(false);
const configForm = reactive({
serverDisplayName: "",
getInventory: true,
});
let sshTestHideTimer: ReturnType<typeof setTimeout> | null = null;
let winRmTestHideTimer: ReturnType<typeof setTimeout> | null = null;
let sshProbeSeq = 0;
let refreshingLive = false;
useSacLiveEventStream(() => {
void refreshFromLatestEvent();
});
const hostId = computed(() => parseInt(props.id, 10));
const isWindowsHost = computed(() => {
const h = host.value;
if (!h) return false;
if ((h.os_family || "").toLowerCase() === "windows") return true;
return h.product === "rdp-login-monitor";
});
const isLinuxHost = computed(() => {
const h = host.value;
if (!h) return false;
if ((h.os_family || "").toLowerCase() === "linux") return true;
return h.product === "ssh-monitor";
});
const sshVerified = computed(() => host.value?.ssh_admin_ok === true);
const sshVerifiedTitle = computed(() => {
const checkedAt = host.value?.ssh_admin_checked_at;
if (checkedAt) {
return `SSH доступен (проверено ${formatDt(checkedAt)})`;
}
return "SSH доступен";
});
const versionStatusClass = computed(() => {
const status = host.value?.version_status;
if (status === "outdated") return "agent-version-outdated";
if (status === "ok") return "agent-version-ok";
return "";
});
const showAgentConfig = computed(() => isWindowsHost.value || isLinuxHost.value);
function syncConfigFormFromHost(detail: HostDetail) {
const cfg = detail.agent_config ?? {};
configForm.serverDisplayName = String(
cfg.ServerDisplayName ?? cfg.SERVER_DISPLAY_NAME ?? "",
);
configForm.getInventory = cfg.GetInventory !== false;
}
const inventory = computed(() => host.value?.inventory ?? null);
const windowsFields = computed(() => {
const w = inventory.value?.windows as Record<string, string> | undefined;
if (!w) return [];
const labels: Record<string, string> = {
product_name: "Продукт",
version: "Версия",
os_hardware_abstraction_layer: "HAL",
};
return Object.entries(labels)
.map(([key, label]) => ({ label, value: w[key]?.trim() || "" }))
.filter((item) => item.value);
});
const windowsInfo = computed(() => windowsFields.value.length > 0);
const computerName = computed(() => {
const v = inventory.value?.computer_name;
return typeof v === "string" && v.trim() ? v : "";
});
const processors = computed(() => {
const raw = inventory.value?.processor;
if (!Array.isArray(raw)) return [];
return raw
.filter((p): p is Record<string, unknown> => p != null && typeof p === "object")
.map((p) => ({
name: String(p.name ?? "—"),
cores: typeof p.cores === "number" ? p.cores : null,
logical: typeof p.logical_processors === "number" ? p.logical_processors : null,
}));
});
const motherboard = computed(() => {
const m = inventory.value?.motherboard as Record<string, string> | undefined;
if (!m) return "";
return [m.manufacturer, m.product].filter(Boolean).join(" ");
});
const memoryGb = computed(() => {
const v = inventory.value?.memory_gb;
return typeof v === "number" ? v : null;
});
const disks = computed(() => {
const raw = inventory.value?.disks;
if (!Array.isArray(raw)) return [];
return raw.filter((d): d is Record<string, unknown> => d != null && typeof d === "object");
});
const video = computed(() => {
const raw = inventory.value?.video;
if (!Array.isArray(raw)) return [];
return raw.filter((v): v is Record<string, string> => v != null && typeof v === "object");
});
const ipv4List = computed(() => {
const raw = inventory.value?.ipv4;
if (!Array.isArray(raw)) return [];
return raw.map(String).filter(Boolean);
});
const eventsBackQuery = computed(() => ({ host_id: String(hostId.value) }));
function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU");
}
function agentLabel(status: string) {
if (status === "online") return "online";
if (status === "stale") return "stale";
return "unknown";
}
function clearSshTestFeedbackTimer() {
if (sshTestHideTimer != null) {
clearTimeout(sshTestHideTimer);
sshTestHideTimer = null;
}
}
function isLinuxHostDetail(detail: HostDetail): boolean {
if ((detail.os_family || "").toLowerCase() === "linux") return true;
return detail.product === "ssh-monitor";
}
function applySshAdminStatus(result: { ok: boolean; ssh_admin_ok?: boolean | null }) {
if (!host.value) return;
host.value = {
...host.value,
ssh_admin_ok: result.ssh_admin_ok ?? result.ok,
ssh_admin_checked_at: new Date().toISOString(),
};
}
function clearWinRmTestFeedbackTimer() {
if (winRmTestHideTimer != null) {
clearTimeout(winRmTestHideTimer);
winRmTestHideTimer = null;
}
}
function scheduleSshTestFeedbackHide() {
clearSshTestFeedbackTimer();
sshTestHideTimer = setTimeout(() => {
sshTestMessage.value = "";
sshTestOutput.value = "";
showSshTestFeedback.value = false;
sshTestHideTimer = null;
}, HOST_ACTION_FEEDBACK_MS);
}
function scheduleWinRmTestFeedbackHide() {
clearWinRmTestFeedbackTimer();
winRmTestHideTimer = setTimeout(() => {
winRmTestMessage.value = "";
showWinRmTestFeedback.value = false;
winRmTestHideTimer = null;
}, HOST_ACTION_FEEDBACK_MS);
}
async function runSshTest(options: { silent?: boolean } = {}) {
const silent = options.silent === true;
const probeId = ++sshProbeSeq;
if (!silent) {
testingSsh.value = true;
clearSshTestFeedbackTimer();
sshTestMessage.value = "";
sshTestOutput.value = "";
showSshTestFeedback.value = false;
}
try {
const result = await testHostSsh(hostId.value);
if (probeId !== sshProbeSeq) return;
applySshAdminStatus(result);
if (!silent) {
showSshTestFeedback.value = true;
sshTestOk.value = result.ok;
sshTestMessage.value = result.message;
sshTestOutput.value = formatSshOutput(result);
if (result.ok) {
scheduleSshTestFeedbackHide();
}
}
} catch (e) {
if (probeId !== sshProbeSeq) return;
if (host.value) {
host.value = { ...host.value, ssh_admin_ok: false, ssh_admin_checked_at: new Date().toISOString() };
}
if (!silent) {
showSshTestFeedback.value = true;
sshTestOk.value = false;
sshTestMessage.value = e instanceof Error ? e.message : "Ошибка проверки SSH";
}
} finally {
if (!silent && probeId === sshProbeSeq) {
testingSsh.value = false;
}
}
}
function runSshTestManual() {
void runSshTest({ silent: false });
}
async function probeSshOnOpen() {
await runSshTest({ silent: true });
}
async function runWinRmTest() {
testingWinRm.value = true;
clearWinRmTestFeedbackTimer();
winRmTestMessage.value = "";
showWinRmTestFeedback.value = false;
try {
const result = await testHostWinRm(hostId.value);
winRmTestOk.value = result.ok;
winRmTestMessage.value = result.message;
showWinRmTestFeedback.value = true;
if (result.ok) {
scheduleWinRmTestFeedbackHide();
}
} catch (e) {
winRmTestOk.value = false;
winRmTestMessage.value = e instanceof Error ? e.message : "Ошибка проверки WinRM";
showWinRmTestFeedback.value = true;
} finally {
testingWinRm.value = false;
}
}
function formatSshOutput(result: { stdout: string | null; stderr: string | null; exit_code: number | null }) {
const parts: string[] = [];
if (result.stdout?.trim()) {
parts.push(result.stdout.trim());
}
if (result.stderr?.trim()) {
parts.push(result.stderr.trim());
}
if (result.exit_code != null) {
parts.push(`exit code: ${result.exit_code}`);
}
return parts.join("\n\n");
}
function applyHostDetail(detail: HostDetail) {
host.value = detail;
syncConfigFormFromHost(detail);
emitHostPatch(detail);
}
async function runRequestAgentUpdate() {
requestingAgentUpdate.value = true;
agentControlMessage.value = "";
try {
const result = await requestHostAgentUpdate(hostId.value);
agentControlOk.value = true;
agentControlMessage.value = result.target_version
? `Запрошено обновление до ${result.target_version}`
: "Запрошено обновление агента";
await loadHost();
} catch (e) {
agentControlOk.value = false;
agentControlMessage.value = e instanceof Error ? e.message : "Не удалось запросить обновление";
} finally {
requestingAgentUpdate.value = false;
}
}
async function runAgentFallback() {
runningAgentFallback.value = true;
agentControlMessage.value = "";
const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)";
try {
const job = await runHostRemoteAction(hostId.value, title, "fallback");
if (job) {
agentControlOk.value = job.ok ?? false;
if (job.ok) {
agentControlMessage.value = "";
await loadHost();
} else {
agentControlMessage.value = job.message || "";
}
}
} finally {
runningAgentFallback.value = false;
}
}
async function saveAgentConfig() {
savingAgentConfig.value = true;
agentConfigMessage.value = "";
const settings: Record<string, unknown> = {};
if (isWindowsHost.value) {
if (configForm.serverDisplayName.trim()) {
settings.ServerDisplayName = configForm.serverDisplayName.trim();
}
settings.GetInventory = configForm.getInventory;
} else if (isLinuxHost.value) {
if (configForm.serverDisplayName.trim()) {
settings.SERVER_DISPLAY_NAME = configForm.serverDisplayName.trim();
}
}
try {
const result = await patchHostAgentConfig(hostId.value, settings);
agentConfigOk.value = true;
agentConfigMessage.value = `Config сохранён (revision ${result.config_revision})`;
await loadHost();
} catch (e) {
agentConfigOk.value = false;
agentConfigMessage.value = e instanceof Error ? e.message : "Ошибка сохранения config";
} finally {
savingAgentConfig.value = false;
}
}
async function loadHost() {
loading.value = true;
error.value = "";
winRmTestMessage.value = "";
clearWinRmTestFeedbackTimer();
showWinRmTestFeedback.value = false;
sshProbeSeq += 1;
clearSshTestFeedbackTimer();
showSshTestFeedback.value = false;
sshTestMessage.value = "";
sshTestOutput.value = "";
try {
const detail = await fetchHost(hostId.value);
applyHostDetail(detail);
if (isLinuxHostDetail(detail)) {
void probeSshOnOpen();
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки хоста";
} finally {
loading.value = false;
}
}
async function runAgentUpdate() {
updatingAgent.value = true;
try {
const job = await runHostRemoteAction(
hostId.value,
"Обновление ssh-monitor (SSH)",
"ssh-update",
);
if (job?.ok) {
const detail = await fetchHost(hostId.value);
if (job.product_version) {
detail.product_version = job.product_version;
}
applyHostDetail(detail);
}
} finally {
updatingAgent.value = false;
}
}
async function refreshFromLatestEvent() {
if (refreshingLive) return;
refreshingLive = true;
try {
const recent = await fetchRecentEvents(1);
const ev = recent[0];
if (!ev || ev.host_id !== hostId.value) return;
applyHostDetail(await fetchHost(hostId.value));
if (eventsPage.value === 1) {
await loadEvents(1);
}
} catch {
/* ignore transient errors */
} finally {
refreshingLive = false;
}
}
async function loadEvents(page = 1) {
eventsLoading.value = true;
eventsError.value = "";
eventsPage.value = page;
try {
const params = new URLSearchParams({
page: String(page),
page_size: String(eventsPageSize),
host_id: String(hostId.value),
});
events.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
} catch (e) {
eventsError.value = e instanceof Error ? e.message : "Ошибка загрузки событий";
} finally {
eventsLoading.value = false;
}
}
onMounted(async () => {
await loadHost();
await loadEvents(1);
});
onUnmounted(() => {
clearSshTestFeedbackTimer();
clearWinRmTestFeedbackTimer();
});
watch(
() => route.params.id,
async () => {
await loadHost();
await loadEvents(1);
},
);
</script>
<style scoped>
.host-inventory {
margin: 1rem 0 1.5rem;
}
.inv-block {
margin-bottom: 1rem;
}
.inv-block h3 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.muted {
color: #9aa4b2;
}
.host-actions {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.host-actions-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.host-action-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.host-action-ok {
color: #3fb950;
font-size: 1.15rem;
font-weight: 700;
line-height: 1;
}
.host-action-feedback {
width: 100%;
}
.host-ssh-output {
margin-top: 0.75rem;
max-height: 16rem;
overflow: auto;
padding: 0.75rem;
background: #0d1117;
border-radius: 6px;
font-size: 0.85rem;
white-space: pre-wrap;
}
.agent-version-outdated {
color: #f0883e;
}
.agent-version-ok {
color: #3fb950;
}
.pending-update-badge {
margin-left: 0.5rem;
font-size: 0.85rem;
color: #58a6ff;
}
.version-hint {
font-size: 0.9rem;
}
.error-inline {
color: #f85149;
}
.host-agent-config {
margin-top: 1rem;
}
.agent-config-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.config-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.config-inline {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>