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:
@@ -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>
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-slate-100 text-slate-900 antialiased;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user