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:
Generated
+1
-65
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user