Hub-and-Spoke Umbau: Multi-Tenant Zentrale + Satellite-Agent

- Backend: Customer/Satellite Models, customer_id auf Server/Job/Audit
- Satellite-API: heartbeat, poll (atomares Claiming), logs, result,
  scan-result, health-report - Auth via X-Api-Key (SHA-256 gehasht)
- Job-Queue: pending/claimed/running/success/failed + Stale-Janitor
- Batch-Trigger: ein Job pro Server, Satellite arbeitet sequenziell ab
- Credentials bleiben lokal: nur symbolische credential_ref zentral
- Neues Paket satellite/: Pull-Loop, WinRM/SSH/CAU/Scanner, PyInstaller-tauglich
- Frontend: Kunden-Switcher, Satelliten-View, Polling statt WebSocket
- Entfernt: WebSocket/Socket.io, Redis, zentrale Credentials, JobRunner
- Docs: README/AGENTS/PROMPT auf neue Architektur aktualisiert
This commit is contained in:
B0rbor4d
2026-08-07 03:42:06 +00:00
parent b91dd66fee
commit cc4c3fcecb
72 changed files with 2759 additions and 1642 deletions
+1 -65
View File
@@ -15,7 +15,6 @@
"date-fns": "^3.6.0",
"lucide-vue-next": "^0.378.0",
"pinia": "^2.1.0",
"socket.io-client": "^4.7.0",
"tailwind-merge": "^2.2.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
@@ -1294,12 +1293,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz",
@@ -2741,28 +2734,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/engine.io-client": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -5300,34 +5271,6 @@
"node": ">=8"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6402,6 +6345,7 @@
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -6437,14 +6381,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
-1
View File
@@ -18,7 +18,6 @@
"pinia": "^2.1.0",
"@vueuse/core": "^10.9.0",
"axios": "^1.6.0",
"socket.io-client": "^4.7.0",
"date-fns": "^3.6.0",
"zod": "^3.22.0",
"@tanstack/vue-query": "^5.0.0",
-22
View File
@@ -1,22 +0,0 @@
import { io, type Socket } from 'socket.io-client'
let socket: Socket | null = null
export function getSocket(): Socket {
if (!socket) {
const url = import.meta.env.VITE_WS_URL || window.location.origin
socket = io(url, {
path: '/socket.io',
transports: ['websocket', 'polling'],
autoConnect: true,
})
}
return socket
}
export function disconnectSocket(): void {
if (socket) {
socket.disconnect()
socket = null
}
}
+32 -6
View File
@@ -1,17 +1,31 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useCustomersStore } from '@/stores/customers'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
const customersStore = useCustomersStore()
const navItems = [
{ name: 'dashboard', label: 'Dashboard' },
{ name: 'customers', label: 'Kunden' },
{ name: 'satellites', label: 'Satelliten' },
{ name: 'servers', label: 'Server' },
{ name: 'updates', label: 'Updates' },
{ name: 'audit', label: 'Audit' },
]
const selected = computed({
get: () => customersStore.selectedId,
set: (id: number | null) => {
if (id !== null) customersStore.select(id)
},
})
onMounted(() => customersStore.fetchCustomers())
function logout(): void {
auth.logout()
router.push({ name: 'login' })
@@ -35,12 +49,24 @@ function logout(): void {
{{ item.label }}
</router-link>
</div>
<button
class="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
@click="logout"
>
Abmelden
</button>
<div class="flex items-center gap-3">
<select
v-model="selected"
class="rounded bg-slate-700 px-2 py-1.5 text-sm text-white"
title="Aktiver Kunde"
>
<option :value="null" disabled>Kunde wählen</option>
<option v-for="c in customersStore.customers" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</select>
<button
class="rounded bg-slate-700 px-3 py-1.5 text-sm hover:bg-slate-600"
@click="logout"
>
Abmelden
</button>
</div>
</div>
</div>
</nav>
+10
View File
@@ -14,6 +14,16 @@ const router = createRouter({
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
},
{
path: '/customers',
name: 'customers',
component: () => import('@/views/CustomersView.vue'),
},
{
path: '/satellites',
name: 'satellites',
component: () => import('@/views/SatellitesView.vue'),
},
{
path: '/servers',
name: 'servers',
-2
View File
@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiClient } from '@/api/client'
import { disconnectSocket } from '@/api/socket'
const TOKEN_KEY = 'iu_token'
@@ -25,7 +24,6 @@ export const useAuthStore = defineStore('auth', () => {
token.value = null
username.value = null
localStorage.removeItem(TOKEN_KEY)
disconnectSocket()
}
return { token, username, isAuthenticated, login, logout }
+43
View File
@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import type { Customer } from '@/types'
export const useCustomersStore = defineStore('customers', () => {
const customers = ref<Customer[]>([])
const selectedId = ref<number | null>(null)
const loading = ref(false)
async function fetchCustomers(): Promise<void> {
loading.value = true
try {
const { data } = await apiClient.get<Customer[]>('/api/customers')
customers.value = data
if (selectedId.value === null && data.length > 0) {
selectedId.value = data[0].id
}
} finally {
loading.value = false
}
}
async function createCustomer(payload: { name: string; slug: string; notes?: string }): Promise<Customer> {
const { data } = await apiClient.post<Customer>('/api/customers', payload)
customers.value.push(data)
return data
}
async function deleteCustomer(id: number): Promise<void> {
await apiClient.delete(`/api/customers/${id}`)
customers.value = customers.value.filter((c) => c.id !== id)
if (selectedId.value === id) {
selectedId.value = customers.value[0]?.id ?? null
}
}
function select(id: number): void {
selectedId.value = id
}
return { customers, selectedId, loading, fetchCustomers, createCustomer, deleteCustomer, select }
})
+41
View File
@@ -0,0 +1,41 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import type { Satellite, SatelliteCreated } from '@/types'
export const useSatellitesStore = defineStore('satellites', () => {
const satellites = ref<Satellite[]>([])
const loading = ref(false)
async function fetchSatellites(customerId?: number): Promise<void> {
loading.value = true
try {
const params = customerId ? { customer_id: customerId } : {}
const { data } = await apiClient.get<Satellite[]>('/api/satellites', { params })
satellites.value = data
} finally {
loading.value = false
}
}
async function createSatellite(customerId: number, name: string): Promise<SatelliteCreated> {
const { data } = await apiClient.post<SatelliteCreated>('/api/satellites', {
customer_id: customerId,
name,
})
satellites.value.push(data)
return data
}
async function deleteSatellite(id: number): Promise<void> {
await apiClient.delete(`/api/satellites/${id}`)
satellites.value = satellites.value.filter((s) => s.id !== id)
}
async function rotateKey(id: number): Promise<SatelliteCreated> {
const { data } = await apiClient.post<SatelliteCreated>(`/api/satellites/${id}/rotate-key`)
return data
}
return { satellites, loading, fetchSatellites, createSatellite, deleteSatellite, rotateKey }
})
+5 -11
View File
@@ -1,17 +1,17 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import type { Server, HealthResult } from '@/types'
import type { Server } from '@/types'
export const useServersStore = defineStore('servers', () => {
const servers = ref<Server[]>([])
const loading = ref(false)
const healthResults = ref<Record<number, HealthResult>>({})
async function fetchServers(): Promise<void> {
async function fetchServers(customerId?: number | null): Promise<void> {
loading.value = true
try {
const { data } = await apiClient.get<Server[]>('/api/servers')
const params = customerId ? { customer_id: customerId } : {}
const { data } = await apiClient.get<Server[]>('/api/servers', { params })
servers.value = data
} finally {
loading.value = false
@@ -29,11 +29,5 @@ export const useServersStore = defineStore('servers', () => {
servers.value = servers.value.filter((s) => s.id !== id)
}
async function checkHealth(id: number): Promise<HealthResult> {
const { data } = await apiClient.get<HealthResult>(`/api/servers/${id}/health`)
healthResults.value[id] = data
return data
}
return { servers, loading, healthResults, fetchServers, createServer, deleteServer, checkHealth }
return { servers, loading, fetchServers, createServer, deleteServer }
})
+42 -59
View File
@@ -1,90 +1,73 @@
import { defineStore } from 'pinia'
import { ref, onUnmounted } from 'vue'
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import { getSocket } from '@/api/socket'
import type { UpdateJob, UpdateLogLine, JobType } from '@/types'
interface WsLogPayload {
job_id: number
line: string
level: string
timestamp: string
}
interface WsCompletePayload {
job_id: number
status: string
duration: number | null
}
export const useUpdatesStore = defineStore('updates', () => {
const jobs = ref<UpdateJob[]>([])
const loading = ref(false)
const liveLogs = ref<Record<number, UpdateLogLine[]>>({})
const logs = ref<Record<number, UpdateLogLine[]>>({})
async function fetchJobs(): Promise<void> {
async function fetchJobs(customerId?: number | null): Promise<void> {
loading.value = true
try {
const { data } = await apiClient.get<UpdateJob[]>('/api/updates')
const params = customerId ? { customer_id: customerId } : {}
const { data } = await apiClient.get<UpdateJob[]>('/api/updates', { params })
jobs.value = data
} finally {
loading.value = false
}
}
async function triggerUpdate(serverId: number, type: JobType): Promise<UpdateJob> {
async function triggerUpdate(
customerId: number,
serverId: number,
type: JobType,
rebootIfRequired = false,
): Promise<UpdateJob> {
const { data } = await apiClient.post<UpdateJob>('/api/updates/trigger', {
customer_id: customerId,
server_id: serverId,
type,
reboot_if_required: rebootIfRequired,
})
jobs.value.unshift(data)
return data
}
async function triggerScan(customerId: number, subnet: string): Promise<UpdateJob> {
const { data } = await apiClient.post<UpdateJob>('/api/updates/trigger', {
customer_id: customerId,
type: 'network_scan',
scan_subnet: subnet,
})
jobs.value.unshift(data)
return data
}
async function triggerBatch(
customerId: number,
type: JobType,
serverIds?: number[],
): Promise<number> {
const { data } = await apiClient.post<{ created: number }>('/api/updates/trigger-batch', {
customer_id: customerId,
type,
server_ids: serverIds ?? null,
})
return data.created
}
async function cancelJob(jobId: number): Promise<void> {
await apiClient.post(`/api/updates/${jobId}/cancel`)
const job = jobs.value.find((j) => j.id === jobId)
if (job) job.status = 'cancelled'
}
async function fetchLogs(jobId: number, afterId = 0): Promise<void> {
const { data } = await apiClient.get<UpdateLogLine[]>(`/api/updates/${jobId}/logs`, {
params: { after_id: afterId },
})
const existing = liveLogs.value[jobId] || []
liveLogs.value[jobId] = afterId === 0 ? data : [...existing, ...data]
async function fetchLogs(jobId: number): Promise<void> {
const { data } = await apiClient.get<UpdateLogLine[]>(`/api/updates/${jobId}/logs`)
logs.value[jobId] = data
}
function subscribeJob(jobId: number): void {
const socket = getSocket()
socket.emit('subscribe_job', { job_id: jobId })
socket.off('job:log')
socket.on('job:log', (payload: WsLogPayload) => {
const list = liveLogs.value[payload.job_id] || []
liveLogs.value[payload.job_id] = [
...list,
{ id: list.length + 1, job_id: payload.job_id, timestamp: payload.timestamp, level: payload.level, line: payload.line },
]
})
socket.off('job:complete')
socket.on('job:complete', (payload: WsCompletePayload) => {
const job = jobs.value.find((j) => j.id === payload.job_id)
if (job) {
job.status = payload.status as UpdateJob['status']
}
})
}
function unsubscribeJob(jobId: number): void {
const socket = getSocket()
socket.emit('unsubscribe_job', { job_id: jobId })
}
onUnmounted(() => {
const socket = getSocket()
socket.off('job:log')
socket.off('job:complete')
})
return { jobs, loading, liveLogs, fetchJobs, triggerUpdate, cancelJob, fetchLogs, subscribeJob, unsubscribeJob }
return { jobs, loading, logs, fetchJobs, triggerUpdate, triggerScan, triggerBatch, cancelJob, fetchLogs }
})
+37 -13
View File
@@ -1,30 +1,61 @@
export type ServerType = 'windows' | 'linux' | 'cau_cluster'
export type JobStatus = 'pending' | 'running' | 'success' | 'failed' | 'cancelled'
export type JobType = 'windows_update' | 'linux_update' | 'cau_run' | 'health_check'
export type JobStatus = 'pending' | 'claimed' | 'running' | 'success' | 'failed' | 'cancelled'
export type JobType = 'windows_update' | 'linux_update' | 'cau_run' | 'health_check' | 'network_scan'
export interface Customer {
id: number
name: string
slug: string
notes: string | null
created_at: string
updated_at: string
}
export interface Satellite {
id: number
customer_id: number
name: string
api_key_prefix: string
is_active: boolean
last_seen_at: string | null
version: string | null
hostname: string | null
created_at: string
}
export interface SatelliteCreated extends Satellite {
api_key: string
}
export interface Server {
id: number
customer_id: number
name: string
hostname: string
port: number
type: ServerType
description: string | null
tags: string | null
credential_id: number | null
credential_ref: string | null
last_health_at: string | null
last_health_ok: boolean | null
last_health_message: string | null
discovered_by_scan: boolean
created_at: string
updated_at: string
}
export interface UpdateJob {
id: number
server_id: number
customer_id: number
server_id: number | null
satellite_id: number | null
type: JobType
status: JobStatus
progress_percent: number
current_phase: string | null
started_by: string
created_by: string
claimed_at: string | null
started_at: string | null
finished_at: string | null
error: string | null
@@ -42,6 +73,7 @@ export interface UpdateLogLine {
export interface AuditEntry {
id: number
timestamp: string
customer_id: number | null
username: string
action: string
target: string | null
@@ -49,11 +81,3 @@ export interface AuditEntry {
details: string | null
ip_address: string | null
}
export interface HealthResult {
server_id: number
ok: boolean
latency_ms: number | null
message: string
checked_at: string
}
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useCustomersStore } from '@/stores/customers'
const store = useCustomersStore()
const showForm = ref(false)
const form = reactive({ name: '', slug: '', notes: '' })
onMounted(() => store.fetchCustomers())
async function submit(): Promise<void> {
await store.createCustomer({ ...form })
showForm.value = false
form.name = ''
form.slug = ''
form.notes = ''
}
async function remove(id: number): Promise<void> {
if (confirm('Kunde inkl. aller Server und Jobs wirklich löschen?')) {
await store.deleteCustomer(id)
}
}
</script>
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Kunden</h1>
<button
class="rounded bg-slate-800 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700"
@click="showForm = !showForm"
>
{{ showForm ? 'Abbrechen' : 'Kunde hinzufügen' }}
</button>
</div>
<div v-if="showForm" class="mb-6 rounded-lg bg-white p-5 shadow">
<form class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="submit">
<div>
<label class="mb-1 block text-sm font-medium">Name</label>
<input v-model="form.name" required class="w-full rounded border-slate-300" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Slug (a-z, 0-9, -)</label>
<input v-model="form.slug" required pattern="[a-z0-9-]+" class="w-full rounded border-slate-300" />
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-sm font-medium">Notizen</label>
<input v-model="form.notes" class="w-full rounded border-slate-300" />
</div>
<div class="sm:col-span-2">
<button type="submit" class="rounded bg-green-700 px-4 py-2 text-sm font-semibold text-white hover:bg-green-600">
Speichern
</button>
</div>
</form>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50">
<tr>
<th class="px-4 py-2 text-left font-medium">Name</th>
<th class="px-4 py-2 text-left font-medium">Slug</th>
<th class="px-4 py-2 text-left font-medium">Notizen</th>
<th class="px-4 py-2 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="customer in store.customers" :key="customer.id">
<td class="px-4 py-2 font-medium">{{ customer.name }}</td>
<td class="px-4 py-2 font-mono text-xs">{{ customer.slug }}</td>
<td class="px-4 py-2">{{ customer.notes }}</td>
<td class="px-4 py-2 text-right">
<button
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
@click="remove(customer.id)"
>
Löschen
</button>
</td>
</tr>
<tr v-if="store.customers.length === 0">
<td colspan="4" class="px-4 py-6 text-center text-slate-500">
Noch keine Kunden angelegt.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+102 -64
View File
@@ -1,93 +1,131 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useCustomersStore } from '@/stores/customers'
import { useServersStore } from '@/stores/servers'
import { useSatellitesStore } from '@/stores/satellites'
import { useUpdatesStore } from '@/stores/updates'
import { apiClient } from '@/api/client'
const customersStore = useCustomersStore()
const serversStore = useServersStore()
const satellitesStore = useSatellitesStore()
const updatesStore = useUpdatesStore()
const stats = ref<{ total: number; running: number; failed: number }>({
const stats = ref<{ total: number; running: number; failed: number; pending: number }>({
total: 0,
running: 0,
failed: 0,
pending: 0,
})
const customerId = computed(() => customersStore.selectedId)
const healthyCount = computed(
() => serversStore.servers.filter((s) => s.last_health_ok === true).length,
)
const onlineSatellites = computed(
() =>
satellitesStore.satellites.filter(
(s) => s.last_seen_at && Date.now() - new Date(s.last_seen_at).getTime() < 3 * 60 * 1000,
).length,
)
onMounted(async () => {
await Promise.all([
serversStore.fetchServers(),
updatesStore.fetchJobs(),
apiClient.get('/api/updates/stats/summary').then(({ data }) => (stats.value = data)),
])
await customersStore.fetchCustomers()
await load()
})
watch(customerId, load)
async function load(): Promise<void> {
if (!customerId.value) return
await Promise.all([
serversStore.fetchServers(customerId.value),
satellitesStore.fetchSatellites(customerId.value),
updatesStore.fetchJobs(customerId.value),
apiClient
.get('/api/updates/stats/summary', { params: { customer_id: customerId.value } })
.then(({ data }) => (stats.value = data)),
])
}
const statusColors: Record<string, string> = {
success: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
running: 'bg-blue-100 text-blue-800',
claimed: 'bg-amber-100 text-amber-800',
pending: 'bg-slate-100 text-slate-800',
cancelled: 'bg-slate-100 text-slate-500',
}
</script>
<template>
<div>
<h1 class="mb-6 text-2xl font-bold">Dashboard</h1>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Server gesamt</p>
<p class="mt-1 text-3xl font-bold">{{ serversStore.servers.length }}</p>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Erreichbar</p>
<p class="mt-1 text-3xl font-bold text-green-600">{{ healthyCount }}</p>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Jobs laufend</p>
<p class="mt-1 text-3xl font-bold text-blue-600">{{ stats.running }}</p>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Jobs fehlgeschlagen</p>
<p class="mt-1 text-3xl font-bold text-red-600">{{ stats.failed }}</p>
</div>
<div v-if="!customerId" class="rounded-lg bg-white p-8 text-center text-slate-500 shadow">
Noch kein Kunde angelegt - zuerst unter "Kunden" einen Kunden erstellen.
</div>
<h2 class="mb-3 mt-8 text-lg font-semibold">Letzte Jobs</h2>
<div class="overflow-hidden rounded-lg bg-white shadow">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50">
<tr>
<th class="px-4 py-2 text-left font-medium">ID</th>
<th class="px-4 py-2 text-left font-medium">Server</th>
<th class="px-4 py-2 text-left font-medium">Typ</th>
<th class="px-4 py-2 text-left font-medium">Status</th>
<th class="px-4 py-2 text-left font-medium">Gestartet von</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="job in updatesStore.jobs.slice(0, 10)" :key="job.id">
<td class="px-4 py-2">{{ job.id }}</td>
<td class="px-4 py-2">{{ job.server_id }}</td>
<td class="px-4 py-2">{{ job.type }}</td>
<td class="px-4 py-2">
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="{
'bg-green-100 text-green-800': job.status === 'success',
'bg-red-100 text-red-800': job.status === 'failed',
'bg-blue-100 text-blue-800': job.status === 'running',
'bg-slate-100 text-slate-800': job.status === 'pending' || job.status === 'cancelled',
}"
>
{{ job.status }}
</span>
</td>
<td class="px-4 py-2">{{ job.started_by }}</td>
</tr>
<tr v-if="updatesStore.jobs.length === 0">
<td colspan="5" class="px-4 py-6 text-center text-slate-500">
Noch keine Jobs vorhanden.
</td>
</tr>
</tbody>
</table>
</div>
<template v-else>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Satelliten online</p>
<p class="mt-1 text-3xl font-bold" :class="onlineSatellites > 0 ? 'text-green-600' : 'text-red-600'">
{{ onlineSatellites }}/{{ satellitesStore.satellites.length }}
</p>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Server</p>
<p class="mt-1 text-3xl font-bold">{{ serversStore.servers.length }}</p>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Jobs wartend</p>
<p class="mt-1 text-3xl font-bold text-amber-600">{{ stats.pending }}</p>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Jobs laufend</p>
<p class="mt-1 text-3xl font-bold text-blue-600">{{ stats.running }}</p>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<p class="text-sm text-slate-500">Jobs fehlgeschlagen</p>
<p class="mt-1 text-3xl font-bold text-red-600">{{ stats.failed }}</p>
</div>
</div>
<h2 class="mb-3 mt-8 text-lg font-semibold">Letzte Jobs</h2>
<div class="overflow-hidden rounded-lg bg-white shadow">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50">
<tr>
<th class="px-4 py-2 text-left font-medium">ID</th>
<th class="px-4 py-2 text-left font-medium">Typ</th>
<th class="px-4 py-2 text-left font-medium">Status</th>
<th class="px-4 py-2 text-left font-medium">Satellite</th>
<th class="px-4 py-2 text-left font-medium">Erstellt von</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="job in updatesStore.jobs.slice(0, 10)" :key="job.id">
<td class="px-4 py-2">{{ job.id }}</td>
<td class="px-4 py-2">{{ job.type }}</td>
<td class="px-4 py-2">
<span class="rounded-full px-2 py-0.5 text-xs font-semibold" :class="statusColors[job.status]">
{{ job.status }}
</span>
</td>
<td class="px-4 py-2">{{ job.satellite_id || '-' }}</td>
<td class="px-4 py-2">{{ job.created_by }}</td>
</tr>
<tr v-if="updatesStore.jobs.length === 0">
<td colspan="5" class="px-4 py-6 text-center text-slate-500">
Noch keine Jobs vorhanden.
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useCustomersStore } from '@/stores/customers'
import { useSatellitesStore } from '@/stores/satellites'
import type { SatelliteCreated } from '@/types'
const customersStore = useCustomersStore()
const store = useSatellitesStore()
const newName = ref('')
const newKey = ref<SatelliteCreated | null>(null)
const copied = ref(false)
const customerId = computed(() => customersStore.selectedId)
onMounted(async () => {
await customersStore.fetchCustomers()
if (customerId.value) await store.fetchSatellites(customerId.value)
})
watch(customerId, async (id) => {
if (id) await store.fetchSatellites(id)
})
async function create(): Promise<void> {
if (!customerId.value || !newName.value) return
newKey.value = await store.createSatellite(customerId.value, newName.value)
newName.value = ''
}
async function rotate(id: number): Promise<void> {
if (confirm('Neuen API-Key generieren? Der alte wird sofort ungültig.')) {
newKey.value = await store.rotateKey(id)
}
}
async function remove(id: number): Promise<void> {
if (confirm('Satellite wirklich löschen?')) {
await store.deleteSatellite(id)
}
}
async function copyKey(): Promise<void> {
if (!newKey.value) return
await navigator.clipboard.writeText(newKey.value.api_key)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
}
function isOnline(lastSeen: string | null): boolean {
if (!lastSeen) return false
return Date.now() - new Date(lastSeen).getTime() < 3 * 60 * 1000
}
</script>
<template>
<div>
<h1 class="mb-6 text-2xl font-bold">Satelliten</h1>
<div v-if="newKey" class="mb-6 rounded-lg border-2 border-amber-400 bg-amber-50 p-5">
<p class="mb-2 font-semibold text-amber-800">
API-Key für "{{ newKey.name }}" - wird nur einmal angezeigt!
</p>
<div class="flex items-center gap-2">
<code class="flex-1 overflow-x-auto rounded bg-white px-3 py-2 font-mono text-xs">{{ newKey.api_key }}</code>
<button class="rounded bg-slate-800 px-3 py-2 text-xs text-white" @click="copyKey">
{{ copied ? 'Kopiert!' : 'Kopieren' }}
</button>
<button class="rounded bg-slate-300 px-3 py-2 text-xs" @click="newKey = null">Schließen</button>
</div>
<p class="mt-2 text-xs text-amber-700">
In config.yaml auf dem Satellite eintragen: <code>api_key: "..."</code>
</p>
</div>
<div class="mb-6 flex items-end gap-3 rounded-lg bg-white p-5 shadow">
<div class="flex-1">
<label class="mb-1 block text-sm font-medium">Neuer Satellite (Kunde: im Header wählen)</label>
<input v-model="newName" placeholder="z.B. sat-hauptstandort" class="w-full rounded border-slate-300" />
</div>
<button
:disabled="!newName || !customerId"
class="rounded bg-green-700 px-4 py-2 text-sm font-semibold text-white hover:bg-green-600 disabled:opacity-50"
@click="create"
>
Anlegen
</button>
</div>
<div class="overflow-hidden rounded-lg bg-white shadow">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50">
<tr>
<th class="px-4 py-2 text-left font-medium">Name</th>
<th class="px-4 py-2 text-left font-medium">Status</th>
<th class="px-4 py-2 text-left font-medium">Version</th>
<th class="px-4 py-2 text-left font-medium">Hostname</th>
<th class="px-4 py-2 text-left font-medium">Key-Prefix</th>
<th class="px-4 py-2 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="sat in store.satellites" :key="sat.id">
<td class="px-4 py-2 font-medium">{{ sat.name }}</td>
<td class="px-4 py-2">
<span v-if="isOnline(sat.last_seen_at)" class="text-green-600">Online</span>
<span v-else class="text-slate-400">Offline</span>
</td>
<td class="px-4 py-2">{{ sat.version || '-' }}</td>
<td class="px-4 py-2">{{ sat.hostname || '-' }}</td>
<td class="px-4 py-2 font-mono text-xs">{{ sat.api_key_prefix }}...</td>
<td class="px-4 py-2 text-right">
<button
class="mr-2 rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-500"
@click="rotate(sat.id)"
>
Key rotieren
</button>
<button
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
@click="remove(sat.id)"
>
Löschen
</button>
</td>
</tr>
<tr v-if="store.satellites.length === 0">
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
Keine Satelliten für diesen Kunden.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+47 -11
View File
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useCustomersStore } from '@/stores/customers'
import { useServersStore } from '@/stores/servers'
import { useUpdatesStore } from '@/stores/updates'
import type { ServerType } from '@/types'
const customersStore = useCustomersStore()
const store = useServersStore()
const updatesStore = useUpdatesStore()
const showForm = ref(false)
const form = reactive({
@@ -11,6 +15,7 @@ const form = reactive({
hostname: '',
port: 5985,
type: 'windows' as ServerType,
credential_ref: '',
description: '',
})
@@ -20,15 +25,31 @@ const typeLabels: Record<ServerType, string> = {
cau_cluster: 'CAU Cluster',
}
onMounted(() => store.fetchServers())
const customerId = computed(() => customersStore.selectedId)
onMounted(async () => {
await customersStore.fetchCustomers()
if (customerId.value) await store.fetchServers(customerId.value)
})
watch(customerId, async (id) => {
if (id) await store.fetchServers(id)
})
async function submit(): Promise<void> {
await store.createServer({ ...form })
if (!customerId.value) return
await store.createServer({
...form,
customer_id: customerId.value,
credential_ref: form.credential_ref || null,
description: form.description || null,
})
showForm.value = false
form.name = ''
form.hostname = ''
form.port = 5985
form.type = 'windows'
form.credential_ref = ''
form.description = ''
}
@@ -37,6 +58,12 @@ async function remove(id: number): Promise<void> {
await store.deleteServer(id)
}
}
async function healthCheck(id: number): Promise<void> {
if (!customerId.value) return
await updatesStore.triggerUpdate(customerId.value, id, 'health_check')
alert('Health-Check Job wurde in die Queue gelegt. Der Satellite führt ihn aus.')
}
</script>
<template>
@@ -58,7 +85,7 @@ async function remove(id: number): Promise<void> {
<input v-model="form.name" required class="w-full rounded border-slate-300" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Hostname / FQDN</label>
<label class="mb-1 block text-sm font-medium">Hostname / IP</label>
<input v-model="form.hostname" required class="w-full rounded border-slate-300" />
</div>
<div>
@@ -73,7 +100,11 @@ async function remove(id: number): Promise<void> {
</option>
</select>
</div>
<div class="sm:col-span-2">
<div>
<label class="mb-1 block text-sm font-medium">Credential-Ref (Name aus credentials.yaml)</label>
<input v-model="form.credential_ref" placeholder="z.B. winrm-admin" class="w-full rounded border-slate-300" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Beschreibung</label>
<input v-model="form.description" class="w-full rounded border-slate-300" />
</div>
@@ -92,24 +123,29 @@ async function remove(id: number): Promise<void> {
<th class="px-4 py-2 text-left font-medium">Name</th>
<th class="px-4 py-2 text-left font-medium">Hostname</th>
<th class="px-4 py-2 text-left font-medium">Typ</th>
<th class="px-4 py-2 text-left font-medium">Credential</th>
<th class="px-4 py-2 text-left font-medium">Health</th>
<th class="px-4 py-2 text-right font-medium">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="server in store.servers" :key="server.id">
<td class="px-4 py-2 font-medium">{{ server.name }}</td>
<td class="px-4 py-2 font-medium">
{{ server.name }}
<span v-if="server.discovered_by_scan" class="ml-1 rounded bg-blue-100 px-1 text-xs text-blue-700">Scan</span>
</td>
<td class="px-4 py-2">{{ server.hostname }}:{{ server.port }}</td>
<td class="px-4 py-2">{{ typeLabels[server.type] }}</td>
<td class="px-4 py-2">
<td class="px-4 py-2 font-mono text-xs">{{ server.credential_ref || '-' }}</td>
<td class="px-4 py-2" :title="server.last_health_message || ''">
<span v-if="server.last_health_ok === true" class="text-green-600">OK</span>
<span v-else-if="server.last_health_ok === false" class="text-red-600">Fehler</span>
<span v-else class="text-slate-400"></span>
<span v-else class="text-slate-400">-</span>
</td>
<td class="px-4 py-2 text-right">
<button
class="mr-2 rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-500"
@click="store.checkHealth(server.id)"
@click="healthCheck(server.id)"
>
Health-Check
</button>
@@ -122,8 +158,8 @@ async function remove(id: number): Promise<void> {
</td>
</tr>
<tr v-if="store.servers.length === 0">
<td colspan="5" class="px-4 py-6 text-center text-slate-500">
Noch keine Server im Inventar.
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
Keine Server für diesen Kunden. Manuell anlegen oder Netzwerk-Scan starten.
</td>
</tr>
</tbody>
+122 -30
View File
@@ -1,14 +1,20 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useCustomersStore } from '@/stores/customers'
import { useServersStore } from '@/stores/servers'
import { useUpdatesStore } from '@/stores/updates'
import type { JobType } from '@/types'
const customersStore = useCustomersStore()
const serversStore = useServersStore()
const updatesStore = useUpdatesStore()
const selectedServerId = ref<number | null>(null)
const selectedJobId = ref<number | null>(null)
const rebootIfRequired = ref(false)
const scanSubnet = ref('')
const customerId = computed(() => customersStore.selectedId)
const jobTypeForServer = computed<JobType>(() => {
const server = serversStore.servers.find((s) => s.id === selectedServerId.value)
@@ -19,23 +25,76 @@ const jobTypeForServer = computed<JobType>(() => {
})
const selectedLogs = computed(() =>
selectedJobId.value ? updatesStore.liveLogs[selectedJobId.value] || [] : [],
selectedJobId.value ? updatesStore.logs[selectedJobId.value] || [] : [],
)
let refreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
await Promise.all([serversStore.fetchServers(), updatesStore.fetchJobs()])
await customersStore.fetchCustomers()
if (customerId.value) {
await Promise.all([
serversStore.fetchServers(customerId.value),
updatesStore.fetchJobs(customerId.value),
])
}
refreshTimer = setInterval(refresh, 10000)
})
async function trigger(): Promise<void> {
if (!selectedServerId.value) return
const job = await updatesStore.triggerUpdate(selectedServerId.value, jobTypeForServer.value)
watchJob(job.id)
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
watch(customerId, async (id) => {
if (id) {
selectedJobId.value = null
await Promise.all([serversStore.fetchServers(id), updatesStore.fetchJobs(id)])
}
})
async function refresh(): Promise<void> {
if (!customerId.value) return
await updatesStore.fetchJobs(customerId.value)
if (selectedJobId.value) await updatesStore.fetchLogs(selectedJobId.value)
}
async function watchJob(jobId: number): Promise<void> {
async function trigger(): Promise<void> {
if (!selectedServerId.value || !customerId.value) return
const job = await updatesStore.triggerUpdate(
customerId.value,
selectedServerId.value,
jobTypeForServer.value,
rebootIfRequired.value,
)
await viewJob(job.id)
}
async function triggerAll(): Promise<void> {
if (!customerId.value) return
if (!confirm('Update-Jobs für ALLE Server dieses Kunden anlegen?')) return
const created = await updatesStore.triggerBatch(customerId.value, 'windows_update')
alert(`${created} Jobs angelegt - der Satellite arbeitet sie der Reihe nach ab.`)
await refresh()
}
async function startScan(): Promise<void> {
if (!customerId.value || !scanSubnet.value) return
const job = await updatesStore.triggerScan(customerId.value, scanSubnet.value)
await viewJob(job.id)
}
async function viewJob(jobId: number): Promise<void> {
selectedJobId.value = jobId
await updatesStore.fetchLogs(jobId)
updatesStore.subscribeJob(jobId)
}
const statusColors: Record<string, string> = {
success: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
running: 'bg-blue-100 text-blue-800',
claimed: 'bg-amber-100 text-amber-800',
pending: 'bg-slate-100 text-slate-800',
cancelled: 'bg-slate-100 text-slate-500',
}
</script>
@@ -43,28 +102,54 @@ async function watchJob(jobId: number): Promise<void> {
<div>
<h1 class="mb-6 text-2xl font-bold">Updates</h1>
<div class="mb-6 flex items-end gap-3 rounded-lg bg-white p-5 shadow">
<div class="flex-1">
<label class="mb-1 block text-sm font-medium">Server auswählen</label>
<select v-model="selectedServerId" class="w-full rounded border-slate-300">
<option :value="null" disabled> bitte wählen </option>
<div class="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="rounded-lg bg-white p-5 shadow">
<h2 class="mb-3 font-semibold">Einzelnes Update</h2>
<label class="mb-1 block text-sm font-medium">Server</label>
<select v-model="selectedServerId" class="mb-3 w-full rounded border-slate-300">
<option :value="null" disabled>- bitte wählen -</option>
<option v-for="server in serversStore.servers" :key="server.id" :value="server.id">
{{ server.name }} ({{ server.hostname }})
</option>
</select>
<label class="mb-3 flex items-center gap-2 text-sm">
<input v-model="rebootIfRequired" type="checkbox" class="rounded" />
Reboot falls erforderlich
</label>
<button
:disabled="!selectedServerId"
class="rounded bg-green-700 px-4 py-2 text-sm font-semibold text-white hover:bg-green-600 disabled:opacity-50"
@click="trigger"
>
Update einreihen
</button>
</div>
<div class="rounded-lg bg-white p-5 shadow">
<h2 class="mb-3 font-semibold">Netz & Batch</h2>
<label class="mb-1 block text-sm font-medium">Netzwerk-Scan (Subnetz)</label>
<div class="mb-3 flex gap-2">
<input v-model="scanSubnet" placeholder="192.168.1.0/24" class="flex-1 rounded border-slate-300" />
<button
:disabled="!scanSubnet"
class="rounded bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 disabled:opacity-50"
@click="startScan"
>
Scan starten
</button>
</div>
<button
class="rounded bg-amber-600 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-500"
@click="triggerAll"
>
Alle Windows-Server updaten
</button>
</div>
<button
:disabled="!selectedServerId"
class="rounded bg-green-700 px-4 py-2 text-sm font-semibold text-white hover:bg-green-600 disabled:opacity-50"
@click="trigger"
>
Update starten
</button>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="overflow-hidden rounded-lg bg-white shadow">
<h2 class="border-b bg-slate-50 px-4 py-2 font-semibold">Jobs</h2>
<h2 class="border-b bg-slate-50 px-4 py-2 font-semibold">Jobs (auto-refresh 10s)</h2>
<table class="min-w-full divide-y divide-slate-200 text-sm">
<tbody class="divide-y divide-slate-100">
<tr
@@ -72,23 +157,28 @@ async function watchJob(jobId: number): Promise<void> {
:key="job.id"
class="cursor-pointer hover:bg-slate-50"
:class="{ 'bg-blue-50': job.id === selectedJobId }"
@click="watchJob(job.id)"
@click="viewJob(job.id)"
>
<td class="px-4 py-2">#{{ job.id }}</td>
<td class="px-4 py-2">{{ job.type }}</td>
<td class="px-4 py-2">{{ job.status }}</td>
<td class="px-4 py-2">
<span class="rounded-full px-2 py-0.5 text-xs font-semibold" :class="statusColors[job.status]">
{{ job.status }}
</span>
</td>
<td class="px-4 py-2 text-xs text-slate-500">{{ job.progress_percent }}%</td>
<td class="px-4 py-2 text-right">
<button
v-if="job.status === 'running' || job.status === 'pending'"
v-if="job.status === 'pending'"
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
@click.stop="updatesStore.cancelJob(job.id)"
>
Abbrechen
Stornieren
</button>
</td>
</tr>
<tr v-if="updatesStore.jobs.length === 0">
<td colspan="4" class="px-4 py-6 text-center text-slate-500">Keine Jobs.</td>
<td colspan="5" class="px-4 py-6 text-center text-slate-500">Keine Jobs.</td>
</tr>
</tbody>
</table>
@@ -96,13 +186,15 @@ async function watchJob(jobId: number): Promise<void> {
<div class="rounded-lg bg-slate-900 p-4 font-mono text-xs text-green-300 shadow">
<h2 class="mb-2 font-sans text-sm font-semibold text-slate-300">
Live-Log {{ selectedJobId ? `(Job #${selectedJobId})` : '' }}
Log {{ selectedJobId ? `(Job #${selectedJobId})` : '' }}
</h2>
<div class="max-h-96 overflow-y-auto whitespace-pre-wrap">
<p v-if="selectedLogs.length === 0" class="text-slate-500">
Kein Job ausgewählt klicke links einen Job an.
Kein Job ausgewählt - klicke links einen Job an.
</p>
<p v-for="log in selectedLogs" :key="log.id" :class="{ 'text-red-400': log.level === 'error' }">
{{ log.line }}
</p>
<p v-for="log in selectedLogs" :key="log.id">{{ log.line }}</p>
</div>
</div>
</div>