Initial scaffold: FastAPI backend + Vue 3 frontend + Docker setup

Backend: config/db/security/logging core, SQLAlchemy models (Server,
Credential, UpdateJob, UpdateLog, AuditLog, User), services (winrm, ssh,
cau, audit, job_runner), REST API (auth, servers, updates, audit),
Socket.io WebSocket layer.
Frontend: Vue 3 + TS + Pinia + Tailwind, Views (Dashboard, Servers,
Updates, Audit, Login), axios + socket.io-client, nginx prod config.
This commit is contained in:
B0rbor4d
2026-07-31 23:45:31 +00:00
commit cf7c29639c
72 changed files with 10610 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppLayout from '@/components/AppLayout.vue'
const route = useRoute()
const isLogin = computed(() => route.name === 'login')
</script>
<template>
<router-view v-if="isLogin" />
<AppLayout v-else>
<router-view />
</AppLayout>
</template>
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'
const baseURL = import.meta.env.VITE_API_URL || ''
export const apiClient = axios.create({
baseURL,
timeout: 30000,
})
apiClient.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.token) {
config.headers.Authorization = `Bearer ${auth.token}`
}
return config
})
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const auth = useAuthStore()
auth.logout()
router.push({ name: 'login' })
}
return Promise.reject(error)
},
)
+22
View File
@@ -0,0 +1,22 @@
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
}
}
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
const navItems = [
{ name: 'dashboard', label: 'Dashboard' },
{ name: 'servers', label: 'Server' },
{ name: 'updates', label: 'Updates' },
{ name: 'audit', label: 'Audit' },
]
function logout(): void {
auth.logout()
router.push({ name: 'login' })
}
</script>
<template>
<div class="min-h-screen">
<nav class="bg-slate-800 text-white shadow">
<div class="mx-auto max-w-7xl px-4">
<div class="flex h-14 items-center justify-between">
<div class="flex items-center gap-6">
<span class="text-lg font-bold">Insight Updater</span>
<router-link
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
class="rounded px-3 py-1.5 text-sm hover:bg-slate-700"
active-class="bg-slate-900 font-semibold"
>
{{ 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>
</div>
</nav>
<main class="mx-auto max-w-7xl px-4 py-6">
<slot />
</main>
</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+46
View File
@@ -0,0 +1,46 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
},
{
path: '/',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
},
{
path: '/servers',
name: 'servers',
component: () => import('@/views/ServersView.vue'),
},
{
path: '/updates',
name: 'updates',
component: () => import('@/views/UpdatesView.vue'),
},
{
path: '/audit',
name: 'audit',
component: () => import('@/views/AuditView.vue'),
},
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.name !== 'login' && !auth.isAuthenticated) {
return { name: 'login' }
}
if (to.name === 'login' && auth.isAuthenticated) {
return { name: 'dashboard' }
}
return true
})
export default router
+32
View File
@@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiClient } from '@/api/client'
import { disconnectSocket } from '@/api/socket'
const TOKEN_KEY = 'iu_token'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const username = ref<string | null>(null)
const isAuthenticated = computed(() => token.value !== null)
async function login(user: string, password: string): Promise<void> {
const { data } = await apiClient.post('/api/auth/login', {
username: user,
password,
})
token.value = data.access_token
username.value = user
localStorage.setItem(TOKEN_KEY, data.access_token)
}
function logout(): void {
token.value = null
username.value = null
localStorage.removeItem(TOKEN_KEY)
disconnectSocket()
}
return { token, username, isAuthenticated, login, logout }
})
+39
View File
@@ -0,0 +1,39 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiClient } from '@/api/client'
import type { Server, HealthResult } 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> {
loading.value = true
try {
const { data } = await apiClient.get<Server[]>('/api/servers')
servers.value = data
} finally {
loading.value = false
}
}
async function createServer(payload: Partial<Server>): Promise<Server> {
const { data } = await apiClient.post<Server>('/api/servers', payload)
servers.value.push(data)
return data
}
async function deleteServer(id: number): Promise<void> {
await apiClient.delete(`/api/servers/${id}`)
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 }
})
+90
View File
@@ -0,0 +1,90 @@
import { defineStore } from 'pinia'
import { ref, onUnmounted } 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[]>>({})
async function fetchJobs(): Promise<void> {
loading.value = true
try {
const { data } = await apiClient.get<UpdateJob[]>('/api/updates')
jobs.value = data
} finally {
loading.value = false
}
}
async function triggerUpdate(serverId: number, type: JobType): Promise<UpdateJob> {
const { data } = await apiClient.post<UpdateJob>('/api/updates/trigger', {
server_id: serverId,
type,
})
jobs.value.unshift(data)
return data
}
async function cancelJob(jobId: number): Promise<void> {
await apiClient.post(`/api/updates/${jobId}/cancel`)
}
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]
}
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 }
})
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-slate-100 text-slate-900 antialiased;
}
+59
View File
@@ -0,0 +1,59 @@
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 interface Server {
id: number
name: string
hostname: string
port: number
type: ServerType
description: string | null
tags: string | null
credential_id: number | null
last_health_at: string | null
last_health_ok: boolean | null
created_at: string
updated_at: string
}
export interface UpdateJob {
id: number
server_id: number
type: JobType
status: JobStatus
progress_percent: number
current_phase: string | null
started_by: string
started_at: string | null
finished_at: string | null
error: string | null
created_at: string
}
export interface UpdateLogLine {
id: number
job_id: number
timestamp: string
level: string
line: string
}
export interface AuditEntry {
id: number
timestamp: string
username: string
action: string
target: string | null
result: string
details: string | null
ip_address: string | null
}
export interface HealthResult {
server_id: number
ok: boolean
latency_ms: number | null
message: string
checked_at: string
}
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { apiClient } from '@/api/client'
import type { AuditEntry } from '@/types'
interface AuditPage {
items: AuditEntry[]
total: number
page: number
page_size: number
}
const entries = ref<AuditEntry[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 50
const loading = ref(false)
const error = ref<string | null>(null)
async function load(): Promise<void> {
loading.value = true
error.value = null
try {
const { data } = await apiClient.get<AuditPage>('/api/audit', {
params: { page: page.value, page_size: pageSize },
})
entries.value = data.items
total.value = data.total
} catch {
error.value = 'Audit-Log konnte nicht geladen werden (Admin-Rechte erforderlich).'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div>
<h1 class="mb-6 text-2xl font-bold">Audit-Log</h1>
<p v-if="error" class="mb-4 rounded bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
<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">Zeitpunkt</th>
<th class="px-4 py-2 text-left font-medium">Benutzer</th>
<th class="px-4 py-2 text-left font-medium">Aktion</th>
<th class="px-4 py-2 text-left font-medium">Ziel</th>
<th class="px-4 py-2 text-left font-medium">Ergebnis</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="entry in entries" :key="entry.id">
<td class="px-4 py-2">{{ new Date(entry.timestamp).toLocaleString('de-DE') }}</td>
<td class="px-4 py-2">{{ entry.username }}</td>
<td class="px-4 py-2">{{ entry.action }}</td>
<td class="px-4 py-2">{{ entry.target || '—' }}</td>
<td class="px-4 py-2">
<span :class="entry.result === 'success' ? 'text-green-600' : 'text-red-600'">
{{ entry.result }}
</span>
</td>
</tr>
<tr v-if="entries.length === 0 && !loading">
<td colspan="5" class="px-4 py-6 text-center text-slate-500">Keine Einträge.</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between text-sm">
<span>{{ total }} Einträge gesamt</span>
<div class="flex gap-2">
<button
:disabled="page <= 1"
class="rounded bg-slate-200 px-3 py-1 disabled:opacity-50"
@click="page--; load()"
>
Zurück
</button>
<button
:disabled="page * pageSize >= total"
class="rounded bg-slate-200 px-3 py-1 disabled:opacity-50"
@click="page++; load()"
>
Weiter
</button>
</div>
</div>
</div>
</template>
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useServersStore } from '@/stores/servers'
import { useUpdatesStore } from '@/stores/updates'
import { apiClient } from '@/api/client'
const serversStore = useServersStore()
const updatesStore = useUpdatesStore()
const stats = ref<{ total: number; running: number; failed: number }>({
total: 0,
running: 0,
failed: 0,
})
const healthyCount = computed(
() => serversStore.servers.filter((s) => s.last_health_ok === true).length,
)
onMounted(async () => {
await Promise.all([
serversStore.fetchServers(),
updatesStore.fetchJobs(),
apiClient.get('/api/updates/stats/summary').then(({ data }) => (stats.value = data)),
])
})
</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>
<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>
</div>
</template>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const username = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
async function submit(): Promise<void> {
error.value = null
loading.value = true
try {
await auth.login(username.value, password.value)
router.push({ name: 'dashboard' })
} catch {
error.value = 'Anmeldung fehlgeschlagen — Benutzername oder Passwort falsch.'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-slate-800">
<div class="w-full max-w-sm rounded-lg bg-white p-8 shadow-xl">
<h1 class="mb-6 text-center text-2xl font-bold">Insight Updater</h1>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="mb-1 block text-sm font-medium">Benutzername</label>
<input
v-model="username"
type="text"
required
class="w-full rounded border-slate-300"
autocomplete="username"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium">Passwort</label>
<input
v-model="password"
type="password"
required
class="w-full rounded border-slate-300"
autocomplete="current-password"
/>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<button
type="submit"
:disabled="loading"
class="w-full rounded bg-slate-800 py-2 font-semibold text-white hover:bg-slate-700 disabled:opacity-50"
>
{{ loading ? 'Anmelden…' : 'Anmelden' }}
</button>
</form>
</div>
</div>
</template>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useServersStore } from '@/stores/servers'
import type { ServerType } from '@/types'
const store = useServersStore()
const showForm = ref(false)
const form = reactive({
name: '',
hostname: '',
port: 5985,
type: 'windows' as ServerType,
description: '',
})
const typeLabels: Record<ServerType, string> = {
windows: 'Windows (WinRM)',
linux: 'Linux (SSH)',
cau_cluster: 'CAU Cluster',
}
onMounted(() => store.fetchServers())
async function submit(): Promise<void> {
await store.createServer({ ...form })
showForm.value = false
form.name = ''
form.hostname = ''
form.port = 5985
form.type = 'windows'
form.description = ''
}
async function remove(id: number): Promise<void> {
if (confirm('Server wirklich löschen?')) {
await store.deleteServer(id)
}
}
</script>
<template>
<div>
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">Server</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' : 'Server 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">Hostname / FQDN</label>
<input v-model="form.hostname" required class="w-full rounded border-slate-300" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Port</label>
<input v-model.number="form.port" type="number" required class="w-full rounded border-slate-300" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">Typ</label>
<select v-model="form.type" class="w-full rounded border-slate-300">
<option v-for="(label, value) in typeLabels" :key="value" :value="value">
{{ label }}
</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="mb-1 block text-sm font-medium">Beschreibung</label>
<input v-model="form.description" 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">Hostname</th>
<th class="px-4 py-2 text-left font-medium">Typ</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">{{ server.hostname }}:{{ server.port }}</td>
<td class="px-4 py-2">{{ typeLabels[server.type] }}</td>
<td class="px-4 py-2">
<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>
</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)"
>
Health-Check
</button>
<button
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-500"
@click="remove(server.id)"
>
Löschen
</button>
</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>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useServersStore } from '@/stores/servers'
import { useUpdatesStore } from '@/stores/updates'
import type { JobType } from '@/types'
const serversStore = useServersStore()
const updatesStore = useUpdatesStore()
const selectedServerId = ref<number | null>(null)
const selectedJobId = ref<number | null>(null)
const jobTypeForServer = computed<JobType>(() => {
const server = serversStore.servers.find((s) => s.id === selectedServerId.value)
if (!server) return 'windows_update'
if (server.type === 'linux') return 'linux_update'
if (server.type === 'cau_cluster') return 'cau_run'
return 'windows_update'
})
const selectedLogs = computed(() =>
selectedJobId.value ? updatesStore.liveLogs[selectedJobId.value] || [] : [],
)
onMounted(async () => {
await Promise.all([serversStore.fetchServers(), updatesStore.fetchJobs()])
})
async function trigger(): Promise<void> {
if (!selectedServerId.value) return
const job = await updatesStore.triggerUpdate(selectedServerId.value, jobTypeForServer.value)
watchJob(job.id)
}
async function watchJob(jobId: number): Promise<void> {
selectedJobId.value = jobId
await updatesStore.fetchLogs(jobId)
updatesStore.subscribeJob(jobId)
}
</script>
<template>
<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>
<option v-for="server in serversStore.servers" :key="server.id" :value="server.id">
{{ server.name }} ({{ server.hostname }})
</option>
</select>
</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>
<table class="min-w-full divide-y divide-slate-200 text-sm">
<tbody class="divide-y divide-slate-100">
<tr
v-for="job in updatesStore.jobs"
:key="job.id"
class="cursor-pointer hover:bg-slate-50"
:class="{ 'bg-blue-50': job.id === selectedJobId }"
@click="watchJob(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 text-right">
<button
v-if="job.status === 'running' || 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
</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>
</tr>
</tbody>
</table>
</div>
<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})` : '' }}
</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.
</p>
<p v-for="log in selectedLogs" :key="log.id">{{ log.line }}</p>
</div>
</div>
</div>
</div>
</template>