feat: agent updates from SAC, poll config, SSH/WinRM fallback (0.12.0)

Add agent update settings, pending self-update via poll, desired config per host, and automatic or manual SSH/WinRM fallback when agents do not report agent.update events.
This commit is contained in:
2026-06-20 15:52:10 +10:00
parent 75f4c475df
commit cd2f27792d
24 changed files with 1494 additions and 15 deletions
+220 -3
View File
@@ -20,7 +20,23 @@
</div>
<div class="detail-field">
<dt>Версия агента</dt>
<dd>{{ host.product_version || "—" }}</dd>
<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>
@@ -57,6 +73,25 @@
{{ testingWinRm ? "Проверка…" : "Проверить WinRM (admin)" }}
</button>
<p v-if="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" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
</div>
<div v-if="isLinuxHost" class="host-actions">
<div class="host-actions-row">
@@ -81,9 +116,26 @@
:disabled="updatingAgent"
@click="runAgentUpdate"
>
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor" }}
{{ 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" :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>
@@ -95,6 +147,29 @@
</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">
@@ -204,7 +279,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
import {
@@ -214,6 +289,9 @@ import {
testHostWinRm,
testHostSsh,
updateHostAgentViaSsh,
requestHostAgentUpdate,
runHostAgentUpdateFallback,
patchHostAgentConfig,
type EventListResponse,
type HostDetail,
} from "../api";
@@ -244,6 +322,17 @@ const sshTestOutput = ref("");
const agentUpdateMessage = ref("");
const agentUpdateOk = ref(false);
const agentUpdateOutput = 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 sshProbeSeq = 0;
let refreshingLive = false;
@@ -278,6 +367,23 @@ const sshVerifiedTitle = computed(() => {
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(() => {
@@ -467,9 +573,75 @@ function formatSshOutput(result: { stdout: string | null; stderr: string | null;
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 = "";
try {
const result = await runHostAgentUpdateFallback(hostId.value);
agentControlOk.value = result.ok;
agentControlMessage.value = result.message;
if (result.ok && result.product_version && host.value) {
host.value = { ...host.value, product_version: result.product_version };
emitHostPatch(host.value);
}
await loadHost();
} catch (e) {
agentControlOk.value = false;
agentControlMessage.value = e instanceof Error ? e.message : "Ошибка fallback-обновления";
} 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 = "";
@@ -636,4 +808,49 @@ watch(
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>