testing claude code
This commit is contained in:
parent
1ad4663e6e
commit
a7660d0b8d
66 changed files with 10837 additions and 457 deletions
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
87
packages/Dashboard/src/components/DashboardHeader.vue
Normal file
87
packages/Dashboard/src/components/DashboardHeader.vue
Normal 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>
|
217
packages/Dashboard/src/components/DashboardSidebar.vue
Normal file
217
packages/Dashboard/src/components/DashboardSidebar.vue
Normal 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>
|
46
packages/Dashboard/src/components/ErrorAlert.vue
Normal file
46
packages/Dashboard/src/components/ErrorAlert.vue
Normal 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>
|
43
packages/Dashboard/src/components/LoadAverageCard.vue
Normal file
43
packages/Dashboard/src/components/LoadAverageCard.vue
Normal 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>
|
13
packages/Dashboard/src/components/LoadingSpinner.vue
Normal file
13
packages/Dashboard/src/components/LoadingSpinner.vue
Normal 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>
|
124
packages/Dashboard/src/components/LoginForm.vue
Normal file
124
packages/Dashboard/src/components/LoginForm.vue
Normal 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>
|
143
packages/Dashboard/src/components/QuickActions.vue
Normal file
143
packages/Dashboard/src/components/QuickActions.vue
Normal 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>
|
46
packages/Dashboard/src/components/ServiceStatusCard.vue
Normal file
46
packages/Dashboard/src/components/ServiceStatusCard.vue
Normal 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>
|
107
packages/Dashboard/src/components/ServicesTable.vue
Normal file
107
packages/Dashboard/src/components/ServicesTable.vue
Normal 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>
|
43
packages/Dashboard/src/components/SystemCard.vue
Normal file
43
packages/Dashboard/src/components/SystemCard.vue
Normal 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>
|
|
@ -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>
|
||||
|
||||
Vue’s
|
||||
<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>
|
33
packages/Dashboard/src/components/UptimeCard.vue
Normal file
33
packages/Dashboard/src/components/UptimeCard.vue
Normal 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>
|
|
@ -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>
|
52
packages/Dashboard/src/layouts/DashboardLayout.vue
Normal file
52
packages/Dashboard/src/layouts/DashboardLayout.vue
Normal 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>
|
|
@ -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')
|
||||
|
|
49
packages/Dashboard/src/router/index.ts
Normal file
49
packages/Dashboard/src/router/index.ts
Normal 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
|
95
packages/Dashboard/src/stores/auth.ts
Normal file
95
packages/Dashboard/src/stores/auth.ts
Normal 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
|
||||
}
|
||||
})
|
172
packages/Dashboard/src/stores/dashboard.ts
Normal file
172
packages/Dashboard/src/stores/dashboard.ts
Normal 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
|
||||
}
|
||||
})
|
149
packages/Dashboard/src/views/DashboardView.vue
Normal file
149
packages/Dashboard/src/views/DashboardView.vue
Normal 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>
|
49
packages/Dashboard/src/views/LoginView.vue
Normal file
49
packages/Dashboard/src/views/LoginView.vue
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue