testing claude code

This commit is contained in:
Geir Okkenhaug Jerstad 2025-06-25 16:36:30 +02:00
parent 1ad4663e6e
commit a7660d0b8d
66 changed files with 10837 additions and 457 deletions

View file

@ -1,47 +1,21 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
import TheWelcome from './components/TheWelcome.vue'
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
// Initialize authentication state on app mount
onMounted(() => {
authStore.initializeAuth()
})
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
</div>
</header>
<main>
<TheWelcome />
</main>
<div id="app">
<RouterView />
</div>
</template>
<style scoped>
header {
line-height: 1.5;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
}
/* App-specific styles can go here */
</style>

View file

@ -1,35 +1,5 @@
@import './base.css';
@import "tailwindcss";
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
height: 100vh;
}

View file

@ -0,0 +1,87 @@
<template>
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo and title -->
<div class="flex items-center">
<div class="flex-shrink-0 flex items-center">
<div class="h-8 w-8 bg-blue-600 rounded-lg flex items-center justify-center">
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
</div>
<h1 class="ml-3 text-xl font-semibold text-gray-900">
Home Lab Dashboard
</h1>
</div>
</div>
<!-- User menu -->
<div class="flex items-center space-x-4">
<!-- Refresh button -->
<button
type="button"
class="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
title="Refresh data"
@click="$emit('refresh')"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<!-- User info and logout -->
<div class="flex items-center space-x-3">
<div class="flex items-center space-x-2">
<div class="h-8 w-8 bg-gray-300 rounded-full flex items-center justify-center">
<span class="text-sm font-medium text-gray-700">
{{ userInitials }}
</span>
</div>
<div class="hidden md:block">
<p class="text-sm font-medium text-gray-900">{{ user.username }}</p>
<p class="text-xs text-gray-500">{{ user.email }}</p>
</div>
</div>
<button
type="button"
class="ml-3 inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
@click="$emit('logout')"
>
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign out
</button>
</div>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { User } from '@/stores/auth'
interface Props {
user: User
}
interface Emits {
(e: 'logout'): void
(e: 'refresh'): void
}
const props = defineProps<Props>()
defineEmits<Emits>()
const userInitials = computed(() => {
const names = props.user.username.split(' ')
if (names.length >= 2) {
return (names[0][0] + names[1][0]).toUpperCase()
}
return props.user.username.slice(0, 2).toUpperCase()
})
</script>

View file

@ -0,0 +1,217 @@
<template>
<aside class="fixed inset-y-0 left-0 z-50 w-64 bg-gray-900 overflow-y-auto">
<div class="flex flex-col h-full">
<!-- Sidebar header spacer -->
<div class="h-16 flex items-center px-4">
<!-- This matches the header height -->
</div>
<!-- Navigation -->
<nav class="flex-1 px-4 pb-4 space-y-2">
<div class="space-y-1">
<button
v-for="item in navigationItems"
:key="item.id"
type="button"
:class="[
'group flex items-center w-full px-2 py-2 text-sm font-medium rounded-md transition-colors',
activeSection === item.id
? 'bg-gray-800 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
]"
@click="$emit('section-change', item.id)"
>
<component
:is="item.icon"
:class="[
'mr-3 h-5 w-5 flex-shrink-0',
activeSection === item.id ? 'text-white' : 'text-gray-400 group-hover:text-white'
]"
/>
{{ item.name }}
<!-- Badge for notifications -->
<span
v-if="item.badge"
:class="[
'ml-auto inline-block py-0.5 px-2 text-xs rounded-full',
item.badgeColor || 'bg-red-100 text-red-800'
]"
>
{{ item.badge }}
</span>
</button>
</div>
<!-- Section divider -->
<div class="border-t border-gray-700 pt-4 mt-4">
<h3 class="px-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">
Quick Links
</h3>
<div class="mt-2 space-y-1">
<a
v-for="link in quickLinks"
:key="link.name"
:href="link.href"
target="_blank"
rel="noopener noreferrer"
class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
>
<component
:is="link.icon"
class="mr-3 h-5 w-5 flex-shrink-0 text-gray-400 group-hover:text-white"
/>
{{ link.name }}
<svg class="ml-auto h-4 w-4 text-gray-400 group-hover:text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" clip-rule="evenodd" />
<path fill-rule="evenodd" d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" clip-rule="evenodd" />
</svg>
</a>
</div>
</div>
</nav>
<!-- Footer -->
<div class="flex-shrink-0 px-4 py-4 border-t border-gray-700">
<p class="text-xs text-gray-400">
Last updated: {{ formatTime(new Date()) }}
</p>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { h } from 'vue'
interface Props {
activeSection: string
}
interface Emits {
(e: 'section-change', section: string): void
}
defineProps<Props>()
defineEmits<Emits>()
// Simple icon components using h() function
const DashboardIcon = () => h('svg', {
fill: 'none',
viewBox: '0 0 24 24',
stroke: 'currentColor'
}, [
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z'
}),
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M8 5v4M16 5v4'
})
])
const ServerIcon = () => h('svg', {
fill: 'none',
viewBox: '0 0 24 24',
stroke: 'currentColor'
}, [
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01'
})
])
const ChartIcon = () => h('svg', {
fill: 'none',
viewBox: '0 0 24 24',
stroke: 'currentColor'
}, [
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
})
])
const SettingsIcon = () => h('svg', {
fill: 'none',
viewBox: '0 0 24 24',
stroke: 'currentColor'
}, [
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z'
}),
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
})
])
const ExternalLinkIcon = () => h('svg', {
fill: 'none',
viewBox: '0 0 24 24',
stroke: 'currentColor'
}, [
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14'
})
])
const navigationItems = [
{
id: 'overview',
name: 'Overview',
icon: DashboardIcon
},
{
id: 'services',
name: 'Services',
icon: ServerIcon,
badge: '2',
badgeColor: 'bg-red-100 text-red-800'
},
{
id: 'monitoring',
name: 'Monitoring',
icon: ChartIcon
},
{
id: 'settings',
name: 'Settings',
icon: SettingsIcon
}
]
const quickLinks = [
{
name: 'Netdata',
href: 'http://localhost:19999',
icon: ExternalLinkIcon
},
{
name: 'Ollama',
href: 'http://localhost:11434',
icon: ExternalLinkIcon
}
]
const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
</script>

View file

@ -0,0 +1,46 @@
<template>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
Error
</h3>
<div class="mt-2 text-sm text-red-700">
<p>{{ message }}</p>
</div>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button
type="button"
class="inline-flex bg-red-50 rounded-md p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-red-50 focus:ring-red-600"
@click="$emit('dismiss')"
>
<span class="sr-only">Dismiss</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
message: string
}
interface Emits {
(e: 'dismiss'): void
}
defineProps<Props>()
defineEmits<Emits>()
</script>

View file

@ -0,0 +1,43 @@
<template>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Load Average</dt>
<dd class="text-lg font-medium text-gray-900">{{ load[0].toFixed(2) }}</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="grid grid-cols-3 gap-4 text-sm">
<div class="text-center">
<span class="block font-medium">{{ load[0].toFixed(2) }}</span>
<span class="text-gray-500">1m</span>
</div>
<div class="text-center">
<span class="block font-medium">{{ load[1].toFixed(2) }}</span>
<span class="text-gray-500">5m</span>
</div>
<div class="text-center">
<span class="block font-medium">{{ load[2].toFixed(2) }}</span>
<span class="text-gray-500">15m</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
load: readonly number[]
}
defineProps<Props>()
</script>

View file

@ -0,0 +1,13 @@
<template>
<div class="flex items-center justify-center space-x-2">
<svg class="animate-spin h-5 w-5 text-blue-500" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-gray-500">Loading...</span>
</div>
</template>
<script setup lang="ts">
// No props needed for this simple loading spinner
</script>

View file

@ -0,0 +1,124 @@
<template>
<form class="mt-8 space-y-6" @submit.prevent="handleSubmit">
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="username" class="sr-only">Username</label>
<input
id="username"
v-model="form.username"
name="username"
type="text"
autocomplete="username"
required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Username"
:disabled="isLoading"
>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<input
id="password"
v-model="form.password"
name="password"
type="password"
autocomplete="current-password"
required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
:disabled="isLoading"
>
</div>
</div>
<div v-if="error" class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
Login Failed
</h3>
<div class="mt-2 text-sm text-red-700">
<p>{{ error }}</p>
</div>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button
type="button"
class="inline-flex bg-red-50 rounded-md p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-red-50 focus:ring-red-600"
@click="$emit('clear-error')"
>
<span class="sr-only">Dismiss</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div class="text-sm">
<p class="text-gray-600">
Demo credentials: any username/password combination
</p>
</div>
</div>
<div>
<button
type="submit"
:disabled="isLoading || !form.username || !form.password"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
<svg v-if="!isLoading" class="h-5 w-5 text-blue-500 group-hover:text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
<svg v-else class="animate-spin h-5 w-5 text-blue-500" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
{{ isLoading ? 'Signing in...' : 'Sign in' }}
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
interface Props {
isLoading: boolean
error: string | null
}
interface Emits {
(e: 'submit', credentials: { username: string; password: string }): void
(e: 'clear-error'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const form = reactive({
username: '',
password: ''
})
const handleSubmit = () => {
if (form.username && form.password) {
emit('submit', {
username: form.username,
password: form.password
})
}
}
</script>

View file

@ -0,0 +1,143 @@
<template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<button
v-for="action in actions"
:key="action.id"
type="button"
:class="[
'relative flex items-center space-x-3 rounded-lg border border-gray-300 bg-white px-6 py-5 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
action.enabled
? 'hover:border-gray-400 cursor-pointer'
: 'opacity-50 cursor-not-allowed'
]"
:disabled="!action.enabled"
@click="handleAction(action.id)"
>
<div class="flex-shrink-0">
<component
:is="action.icon"
:class="[
'h-6 w-6',
action.enabled ? action.color : 'text-gray-400'
]"
/>
</div>
<div class="min-w-0 flex-1">
<span class="absolute inset-0" aria-hidden="true"></span>
<p class="text-sm font-medium text-gray-900">{{ action.name }}</p>
<p class="text-sm text-gray-500 truncate">{{ action.description }}</p>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
interface Emits {
(e: 'action', actionId: string): void
}
const emit = defineEmits<Emits>()
// Icon components
const RefreshIcon = () => h('svg', {
fill: 'none',
viewBox: '0 0 24 24',
stroke: 'currentColor'
}, [
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15'
})
])
const TerminalIcon = () => h('svg', {
fill: 'none',
viewBox: '0 0 24 24',
stroke: 'currentColor'
}, [
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z'
})
])
const ChartIcon = () => h('svg', {
fill: 'none',
viewBox: '0 0 24 24',
stroke: 'currentColor'
}, [
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z'
})
])
const SettingsIcon = () => h('svg', {
fill: 'none',
viewBox: '0 0 24 24',
stroke: 'currentColor'
}, [
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z'
}),
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z'
})
])
const actions = [
{
id: 'refresh',
name: 'Refresh All',
description: 'Update all service status',
icon: RefreshIcon,
color: 'text-blue-600',
enabled: true
},
{
id: 'netdata',
name: 'Open Netdata',
description: 'View detailed metrics',
icon: ChartIcon,
color: 'text-green-600',
enabled: true
},
{
id: 'terminal',
name: 'SSH Access',
description: 'Connect via terminal',
icon: TerminalIcon,
color: 'text-gray-600',
enabled: false
},
{
id: 'settings',
name: 'System Settings',
description: 'Configure services',
icon: SettingsIcon,
color: 'text-purple-600',
enabled: false
}
]
const handleAction = (actionId: string) => {
const action = actions.find(a => a.id === actionId)
if (action?.enabled) {
emit('action', actionId)
}
}
</script>

View file

@ -0,0 +1,46 @@
<template>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Service Status</dt>
<dd class="text-lg font-medium text-gray-900">{{ totalCount }} Services</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="grid grid-cols-3 gap-4 text-sm">
<div class="text-center">
<span class="block text-lg font-medium text-green-600">{{ runningCount }}</span>
<span class="text-gray-500">Running</span>
</div>
<div class="text-center">
<span class="block text-lg font-medium text-red-600">{{ errorCount }}</span>
<span class="text-gray-500">Errors</span>
</div>
<div class="text-center">
<span class="block text-lg font-medium text-yellow-600">{{ stoppedCount }}</span>
<span class="text-gray-500">Stopped</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
runningCount: number
stoppedCount: number
errorCount: number
totalCount: number
}
defineProps<Props>()
</script>

View file

@ -0,0 +1,107 @@
<template>
<div class="overflow-hidden">
<div class="flex justify-between items-center mb-4">
<h4 class="text-sm font-medium text-gray-900">Service Status</h4>
<button
type="button"
class="inline-flex items-center px-3 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
@click="$emit('refresh')"
>
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Service
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Port
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="service in services" :key="service.name">
<td class="px-6 py-4 whitespace-nowrap">
<div>
<div class="text-sm font-medium text-gray-900">{{ service.name }}</div>
<div v-if="service.description" class="text-sm text-gray-500">{{ service.description }}</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="getStatusClass(service.status)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
{{ service.status }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ service.port || '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<a
v-if="service.url"
:href="service.url"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-900 mr-3"
>
Open
</a>
<button
type="button"
class="text-gray-600 hover:text-gray-900"
@click="showServiceDetails(service)"
>
Details
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import type { ServiceStatus } from '@/stores/dashboard'
interface Props {
services: readonly ServiceStatus[]
}
interface Emits {
(e: 'refresh'): void
}
defineProps<Props>()
defineEmits<Emits>()
const getStatusClass = (status: string) => {
switch (status) {
case 'running':
return 'bg-green-100 text-green-800'
case 'stopped':
return 'bg-yellow-100 text-yellow-800'
case 'error':
return 'bg-red-100 text-red-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
const showServiceDetails = (service: ServiceStatus) => {
// Placeholder for service details functionality
console.log('Show details for:', service.name)
}
</script>

View file

@ -0,0 +1,43 @@
<template>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">System Information</dt>
<dd class="text-lg font-medium text-gray-900">{{ systemInfo.hostname }}</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Memory:</span>
<span class="ml-2 font-medium">{{ memoryUsage }}%</span>
</div>
<div>
<span class="text-gray-500">Disk:</span>
<span class="ml-2 font-medium">{{ diskUsage }}%</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { SystemInfo } from '@/stores/dashboard'
interface Props {
systemInfo: SystemInfo
memoryUsage: number
diskUsage: number
}
defineProps<Props>()
</script>

View file

@ -1,94 +0,0 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View file

@ -0,0 +1,33 @@
<template>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">System Uptime</dt>
<dd class="text-lg font-medium text-gray-900">{{ uptime }}</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<div class="text-sm text-gray-500">
Host: <span class="font-medium text-gray-900">{{ hostname }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
uptime: string
hostname: string
}
defineProps<Props>()
</script>

View file

@ -1,87 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View file

@ -0,0 +1,52 @@
<template>
<div class="min-h-screen bg-gray-50">
<DashboardHeader
v-if="user"
:user="user"
@logout="handleLogout"
/>
<div class="flex">
<DashboardSidebar
:active-section="activeSection"
@section-change="handleSectionChange"
/>
<main class="flex-1 p-6 ml-64">
<div class="max-w-7xl mx-auto">
<slot />
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import DashboardHeader from '@/components/DashboardHeader.vue'
import DashboardSidebar from '@/components/DashboardSidebar.vue'
import type { User } from '@/stores/auth'
interface Props {
user: User | null
}
interface Emits {
(e: 'logout'): void
(e: 'section-change', section: string): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
const activeSection = ref('overview')
const handleLogout = () => {
emit('logout')
}
const handleSectionChange = (section: string) => {
activeSection.value = section
emit('section-change', section)
}
</script>

View file

@ -2,10 +2,12 @@ import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View file

@ -0,0 +1,49 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
// Views
import LoginView from '@/views/LoginView.vue'
import DashboardView from '@/views/DashboardView.vue'
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/login'
},
{
path: '/login',
name: 'Login',
component: LoginView,
meta: {
requiresAuth: false
}
},
{
path: '/dashboard',
name: 'Dashboard',
component: DashboardView,
meta: {
requiresAuth: true
}
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// Navigation guard for authentication
router.beforeEach((to, from, next) => {
const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true'
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else if (to.name === 'Login' && isAuthenticated) {
next('/dashboard')
} else {
next()
}
})
export default router

View file

@ -0,0 +1,95 @@
import { defineStore } from 'pinia'
import { ref, computed, readonly } from 'vue'
export interface User {
id: string
username: string
email?: string
}
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null)
const isAuthenticated = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const error = ref<string | null>(null)
// Getters
const currentUser = computed(() => user.value)
const isLoggedIn = computed(() => isAuthenticated.value && user.value !== null)
// Actions
const login = async (username: string, password: string) => {
isLoading.value = true
error.value = null
try {
// Simulate API call - replace with actual authentication logic
await new Promise(resolve => setTimeout(resolve, 1000))
// For demo purposes, accept any username/password combination
if (username && password) {
user.value = {
id: '1',
username,
email: `${username}@homelab.local`
}
isAuthenticated.value = true
localStorage.setItem('isAuthenticated', 'true')
localStorage.setItem('user', JSON.stringify(user.value))
return { success: true }
} else {
throw new Error('Username and password are required')
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Login failed'
return { success: false, error: error.value }
} finally {
isLoading.value = false
}
}
const logout = () => {
user.value = null
isAuthenticated.value = false
error.value = null
localStorage.removeItem('isAuthenticated')
localStorage.removeItem('user')
}
const initializeAuth = () => {
const stored = localStorage.getItem('isAuthenticated')
const storedUser = localStorage.getItem('user')
if (stored === 'true' && storedUser) {
try {
user.value = JSON.parse(storedUser)
isAuthenticated.value = true
} catch {
logout()
}
}
}
const clearError = () => {
error.value = null
}
return {
// State
user: readonly(user),
isAuthenticated: readonly(isAuthenticated),
isLoading: readonly(isLoading),
error: readonly(error),
// Getters
currentUser,
isLoggedIn,
// Actions
login,
logout,
initializeAuth,
clearError
}
})

View file

@ -0,0 +1,172 @@
import { defineStore } from 'pinia'
import { ref, computed, readonly } from 'vue'
export interface SystemInfo {
hostname: string
uptime: string
load: readonly number[]
memory: {
total: number
used: number
free: number
}
disk: {
total: number
used: number
free: number
}
}
export interface ServiceStatus {
name: string
status: 'running' | 'stopped' | 'error' | 'unknown'
port?: number
url?: string
description?: string
}
export const useDashboardStore = defineStore('dashboard', () => {
// State
const systemInfo = ref<SystemInfo | null>(null)
const services = ref<ServiceStatus[]>([])
const isLoading = ref<boolean>(false)
const error = ref<string | null>(null)
const lastUpdated = ref<Date | null>(null)
// Getters
const runningServices = computed(() =>
services.value.filter(service => service.status === 'running')
)
const stoppedServices = computed(() =>
services.value.filter(service => service.status === 'stopped')
)
const errorServices = computed(() =>
services.value.filter(service => service.status === 'error')
)
const memoryUsagePercent = computed(() => {
if (!systemInfo.value) return 0
const { total, used } = systemInfo.value.memory
return Math.round((used / total) * 100)
})
const diskUsagePercent = computed(() => {
if (!systemInfo.value) return 0
const { total, used } = systemInfo.value.disk
return Math.round((used / total) * 100)
})
// Actions
const fetchSystemInfo = async () => {
isLoading.value = true
error.value = null
try {
// Simulate API call - replace with actual system info fetching
await new Promise(resolve => setTimeout(resolve, 500))
// Mock data for demonstration
systemInfo.value = {
hostname: 'congenital-optimist',
uptime: '5 days, 14 hours',
load: [0.15, 0.25, 0.30],
memory: {
total: 16384,
used: 8192,
free: 8192
},
disk: {
total: 1000000,
used: 450000,
free: 550000
}
}
lastUpdated.value = new Date()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch system info'
} finally {
isLoading.value = false
}
}
const fetchServices = async () => {
try {
// Simulate API call - replace with actual service status fetching
await new Promise(resolve => setTimeout(resolve, 300))
// Mock data for demonstration
services.value = [
{
name: 'Netdata',
status: 'running',
port: 19999,
url: 'http://localhost:19999',
description: 'Real-time performance monitoring'
},
{
name: 'Ollama',
status: 'running',
port: 11434,
url: 'http://localhost:11434',
description: 'Local LLM inference server'
},
{
name: 'SSH',
status: 'running',
port: 22,
description: 'Secure Shell access'
},
{
name: 'NFS',
status: 'running',
port: 2049,
description: 'Network File System'
},
{
name: 'Reverse Proxy',
status: 'error',
port: 80,
description: 'HTTP reverse proxy'
}
]
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch services'
}
}
const refreshData = async () => {
await Promise.all([
fetchSystemInfo(),
fetchServices()
])
}
const clearError = () => {
error.value = null
}
return {
// State
systemInfo: readonly(systemInfo),
services: readonly(services),
isLoading: readonly(isLoading),
error: readonly(error),
lastUpdated: readonly(lastUpdated),
// Getters
runningServices,
stoppedServices,
errorServices,
memoryUsagePercent,
diskUsagePercent,
// Actions
fetchSystemInfo,
fetchServices,
refreshData,
clearError
}
})

View file

@ -0,0 +1,149 @@
<template>
<DashboardLayout
:user="currentUser"
@logout="handleLogout"
@section-change="handleSectionChange"
>
<div class="space-y-6">
<!-- Dashboard Header -->
<div class="border-b border-gray-200 pb-5">
<h1 class="text-3xl font-bold leading-tight text-gray-900">
Dashboard Overview
</h1>
<p class="mt-2 max-w-4xl text-sm text-gray-500">
Real-time monitoring and status of your home lab infrastructure
</p>
</div>
<!-- Error Alert -->
<ErrorAlert
v-if="error"
:message="error"
@dismiss="clearError"
/>
<!-- Loading State -->
<LoadingSpinner v-if="isLoading && !systemInfo" />
<!-- Dashboard Content -->
<template v-else>
<!-- System Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<SystemCard
v-if="systemInfo"
:system-info="systemInfo"
:memory-usage="memoryUsagePercent"
:disk-usage="diskUsagePercent"
/>
<ServiceStatusCard
:running-count="runningServices.length"
:stopped-count="stoppedServices.length"
:error-count="errorServices.length"
:total-count="services.length"
/>
<UptimeCard
v-if="systemInfo"
:uptime="systemInfo.uptime"
:hostname="systemInfo.hostname"
/>
<LoadAverageCard
v-if="systemInfo"
:load="systemInfo.load"
/>
</div>
<!-- Services Table -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
Service Status
</h3>
<ServicesTable
:services="services"
@refresh="refreshData"
/>
</div>
</div>
<!-- Quick Actions -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
Quick Actions
</h3>
<QuickActions @action="handleQuickAction" />
</div>
</div>
</template>
</div>
</DashboardLayout>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { useDashboardStore } from '@/stores/dashboard'
// Layout and UI Components
import DashboardLayout from '@/layouts/DashboardLayout.vue'
import ErrorAlert from '@/components/ErrorAlert.vue'
import LoadingSpinner from '@/components/LoadingSpinner.vue'
// Dashboard Components
import SystemCard from '@/components/SystemCard.vue'
import ServiceStatusCard from '@/components/ServiceStatusCard.vue'
import UptimeCard from '@/components/UptimeCard.vue'
import LoadAverageCard from '@/components/LoadAverageCard.vue'
import ServicesTable from '@/components/ServicesTable.vue'
import QuickActions from '@/components/QuickActions.vue'
const router = useRouter()
const authStore = useAuthStore()
const dashboardStore = useDashboardStore()
const { currentUser } = storeToRefs(authStore)
const {
systemInfo,
services,
isLoading,
error,
runningServices,
stoppedServices,
errorServices,
memoryUsagePercent,
diskUsagePercent
} = storeToRefs(dashboardStore)
const handleLogout = () => {
authStore.logout()
router.push('/login')
}
const handleSectionChange = (section: string) => {
console.log('Section changed to:', section)
// Handle section changes - could trigger different data fetching
}
const refreshData = () => {
dashboardStore.refreshData()
}
const clearError = () => {
dashboardStore.clearError()
}
const handleQuickAction = (action: string) => {
console.log('Quick action:', action)
// Handle quick actions like restarting services, etc.
}
// Initialize dashboard data on mount
onMounted(() => {
dashboardStore.refreshData()
})
</script>

View file

@ -0,0 +1,49 @@
<template>
<div class="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<div class="mx-auto h-12 w-12 flex items-center justify-center bg-blue-600 rounded-lg">
<svg class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
</div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Home Lab Dashboard
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Sign in to access your infrastructure monitoring
</p>
</div>
<LoginForm
:is-loading="isLoading"
:error="error"
@submit="handleLogin"
@clear-error="clearError"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { storeToRefs } from 'pinia'
import LoginForm from '@/components/LoginForm.vue'
const router = useRouter()
const authStore = useAuthStore()
const { isLoading, error } = storeToRefs(authStore)
const handleLogin = async (credentials: { username: string; password: string }) => {
const result = await authStore.login(credentials.username, credentials.password)
if (result.success) {
router.push('/dashboard')
}
}
const clearError = () => {
authStore.clearError()
}
</script>