initial commit
This commit is contained in:
12
src/routes/+layout.svelte
Normal file
12
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>K3S Management</title>
|
||||
<meta name="description" content="Manage your K3S clusters with ease" />
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
169
src/routes/+page.svelte
Normal file
169
src/routes/+page.svelte
Normal file
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
authStore.checkAuth();
|
||||
if (authStore.isAuthenticated) {
|
||||
void goto('/admin');
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
loading = true;
|
||||
|
||||
// Simulate async operation
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const success = authStore.login(username, password);
|
||||
|
||||
if (success) {
|
||||
void goto('/admin');
|
||||
} else {
|
||||
error = 'Username atau password salah';
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex min-h-screen items-center justify-center bg-linear-to-br from-slate-900 via-slate-800 to-slate-900"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiMyMzNBNUYiIGZpbGwtb3BhY2l0eT0iMC4xIj48cGF0aCBkPSJNMzYgMzRjMC0yLjIxIDEuNzktNCA0LTRoMTZjMi4yMSAwIDQgMS43OSA0IDR2MTZjMCAyLjIxLTEuNzkgNC00IDRINDBjLTIuMjEgMC00LTEuNzktNC00VjM0em0wLTMwYzAtMi4yMSAxLjc5LTQgNC00aDE2YzIuMjEgMCA0IDEuNzkgNCA0djE2YzAgMi4yMS0xLjc5IDQtNCA0SDQwYy0yLjIxIDAtNC0xLjc5LTQtNFY0ek0wIDM0YzAtMi4yMSAxLjc5LTQgNC00aDE2YzIuMjEgMCA0IDEuNzkgNCA0djE2YzAgMi4yMS0xLjc5IDQtNCA0SDRjLTIuMjEgMC00LTEuNzktNC00VjM0ek0wIDRjMC0yLjIxIDEuNzktNCA0LTRoMTZjMi4yMSAwIDQgMS43OSA0IDR2MTZjMCAyLjIxLTEuNzkgNC00IDRINGMtMi4yMSAwLTQtMS43OS00LTRWNHoiLz48L2c+PC9nPjwvc3ZnPg==')] opacity-20"
|
||||
></div>
|
||||
|
||||
<div class="relative w-full max-w-md px-6">
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl border border-white/20 bg-white/10 shadow-2xl backdrop-blur-xl"
|
||||
>
|
||||
<div class="px-8 py-10">
|
||||
<!-- Logo/Header -->
|
||||
<div class="mb-8 text-center">
|
||||
<div
|
||||
class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-xl bg-linear-to-br from-blue-500 to-cyan-500 shadow-lg"
|
||||
>
|
||||
<svg class="h-10 w-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-bold text-white">K3S Management</h1>
|
||||
<p class="text-sm text-slate-300">Manage your Kubernetes clusters with ease</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form onsubmit={handleLogin} class="space-y-6">
|
||||
{#if error}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded-lg border border-red-500/50 bg-red-500/20 px-4 py-3 text-sm text-red-200"
|
||||
>
|
||||
<svg class="mt-0.5 h-5 w-5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<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>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="username" class="mb-2 block text-sm font-medium text-slate-200">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
disabled={loading}
|
||||
required
|
||||
class="w-full rounded-lg border border-white/20 bg-white/10 px-4 py-3 text-white placeholder-slate-400 transition-all focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Masukkan username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-2 block text-sm font-medium text-slate-200">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={loading}
|
||||
required
|
||||
class="w-full rounded-lg border border-white/20 bg-white/10 px-4 py-3 text-white placeholder-slate-400 transition-all focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Masukkan password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-linear-to-r from-blue-600 to-cyan-600 px-4 py-3 font-medium text-white shadow-lg transition-all duration-200 hover:from-blue-700 hover:to-cyan-700 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if loading}
|
||||
<svg class="h-5 w-5 animate-spin" fill="none" 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>Loading...</span>
|
||||
{:else}
|
||||
<span>Login</span>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="mt-6 border-t border-white/10 pt-6">
|
||||
<p class="text-center text-xs text-slate-400">
|
||||
Default credentials: <span class="font-mono text-slate-300">admin / admin</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="mt-6 text-center text-xs text-slate-400">Powered by K3sup & SvelteKit</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
193
src/routes/admin/+layout.svelte
Normal file
193
src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let sidebarOpen = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
authStore.checkAuth();
|
||||
if (!authStore.isAuthenticated) {
|
||||
void goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout();
|
||||
void goto('/');
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/admin',
|
||||
icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'
|
||||
},
|
||||
{
|
||||
name: 'Clusters',
|
||||
href: '/admin/clusters',
|
||||
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10'
|
||||
},
|
||||
{
|
||||
name: 'Nodes',
|
||||
href: '/admin/nodes',
|
||||
icon: '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'
|
||||
},
|
||||
{
|
||||
name: 'SSH Configurations',
|
||||
href: '/admin/ssh',
|
||||
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z'
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
href: '/admin/settings',
|
||||
icon: '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 M15 12a3 3 0 11-6 0 3 3 0 016 0z'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen bg-slate-950">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="flex flex-col border-r border-slate-800 bg-slate-900 transition-all duration-300 {sidebarOpen
|
||||
? 'w-64'
|
||||
: 'w-20'}"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex h-16 items-center justify-between border-b border-slate-800 px-4">
|
||||
{#if sidebarOpen}
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-linear-to-br from-blue-500 to-cyan-500"
|
||||
>
|
||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-lg font-bold text-white">K3S Manager</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-linear-to-br from-blue-500 to-cyan-500"
|
||||
>
|
||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 space-y-1 overflow-y-auto p-4">
|
||||
{#each navigation as item (item.href)}
|
||||
{@const isActive = $page.url.pathname === item.href}
|
||||
<a
|
||||
href={item.href}
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors {isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-800 hover:text-white'}"
|
||||
title={sidebarOpen ? '' : item.name}
|
||||
>
|
||||
<svg class="h-5 w-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
|
||||
</svg>
|
||||
{#if sidebarOpen}
|
||||
<span>{item.name}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="border-t border-slate-800 p-4">
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2.5 {sidebarOpen ? '' : 'justify-center'}"
|
||||
>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-700">
|
||||
<span class="text-sm font-medium text-white">
|
||||
{authStore.user?.username.charAt(0).toUpperCase() || 'A'}
|
||||
</span>
|
||||
</div>
|
||||
{#if sidebarOpen}
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<p class="truncate text-sm font-medium text-white">
|
||||
{authStore.user?.username || 'Admin'}
|
||||
</p>
|
||||
<p class="text-xs text-slate-400">{authStore.user?.role || 'Administrator'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-slate-300 transition-colors hover:bg-red-500/20 hover:text-red-400 {sidebarOpen
|
||||
? ''
|
||||
: 'justify-center'}"
|
||||
title={sidebarOpen ? '' : 'Logout'}
|
||||
>
|
||||
<svg class="h-5 w-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
{#if sidebarOpen}
|
||||
<span>Logout</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="flex h-16 items-center justify-between border-b border-slate-800 bg-slate-900 px-6"
|
||||
>
|
||||
<button
|
||||
onclick={toggleSidebar}
|
||||
class="rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-slate-400">Welcome back,</p>
|
||||
<p class="font-medium text-white">{authStore.user?.username || 'Admin'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main class="flex-1 overflow-y-auto bg-slate-950 p-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
273
src/routes/admin/+page.svelte
Normal file
273
src/routes/admin/+page.svelte
Normal file
@@ -0,0 +1,273 @@
|
||||
<script lang="ts">
|
||||
import { clustersStore } from '$lib/stores/clusters.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
clustersStore.loadFromStorage();
|
||||
});
|
||||
|
||||
const stats = $derived({
|
||||
totalClusters: clustersStore.clusters.length,
|
||||
activeClusters: clustersStore.clusters.filter((c) => c.status === 'active').length,
|
||||
totalNodes: clustersStore.clusters.reduce((acc, c) => acc + c.nodes.length, 0),
|
||||
activeNodes: clustersStore.clusters.reduce(
|
||||
(acc, c) => acc + c.nodes.filter((n) => n.status === 'active').length,
|
||||
0
|
||||
)
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">Dashboard</h1>
|
||||
<p class="mt-1 text-slate-400">Overview of your K3S infrastructure</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Total Clusters -->
|
||||
<div
|
||||
class="rounded-xl border border-slate-800 bg-linear-to-br from-slate-900 to-slate-800 p-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-400">Total Clusters</p>
|
||||
<p class="mt-2 text-3xl font-bold text-white">{stats.totalClusters}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-500/20 text-blue-400"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Clusters -->
|
||||
<div
|
||||
class="rounded-xl border border-slate-800 bg-linear-to-br from-slate-900 to-slate-800 p-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-400">Active Clusters</p>
|
||||
<p class="mt-2 text-3xl font-bold text-green-400">{stats.activeClusters}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-green-500/20 text-green-400"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Nodes -->
|
||||
<div
|
||||
class="rounded-xl border border-slate-800 bg-linear-to-br from-slate-900 to-slate-800 p-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-400">Total Nodes</p>
|
||||
<p class="mt-2 text-3xl font-bold text-white">{stats.totalNodes}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-500/20 text-purple-400"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Active Nodes -->
|
||||
<div
|
||||
class="rounded-xl border border-slate-800 bg-linear-to-br from-slate-900 to-slate-800 p-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-400">Active Nodes</p>
|
||||
<p class="mt-2 text-3xl font-bold text-cyan-400">{stats.activeNodes}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-500/20 text-cyan-400"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-xl">
|
||||
<h2 class="mb-4 text-xl font-bold text-white">Quick Actions</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<a
|
||||
href="/admin/clusters"
|
||||
class="flex items-center gap-4 rounded-lg border border-slate-700 bg-slate-800 p-4 transition-all hover:border-blue-500 hover:bg-slate-700"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/20">
|
||||
<svg class="h-5 w-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Create Cluster</p>
|
||||
<p class="text-sm text-slate-400">Setup new K3S cluster</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/nodes"
|
||||
class="flex items-center gap-4 rounded-lg border border-slate-700 bg-slate-800 p-4 transition-all hover:border-green-500 hover:bg-slate-700"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-green-500/20">
|
||||
<svg class="h-5 w-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<p class="font-medium text-white">Add Node</p>
|
||||
<p class="text-sm text-slate-400">Add node to cluster</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/ssh"
|
||||
class="flex items-center gap-4 rounded-lg border border-slate-700 bg-slate-800 p-4 transition-all hover:border-purple-500 hover:bg-slate-700"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/20">
|
||||
<svg class="h-5 w-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">SSH Config</p>
|
||||
<p class="text-sm text-slate-400">Manage SSH connections</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Clusters -->
|
||||
{#if clustersStore.clusters.length > 0}
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-xl">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-white">Recent Clusters</h2>
|
||||
<a href="/admin/clusters" class="text-sm font-medium text-blue-400 hover:text-blue-300">
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each clustersStore.clusters.slice(0, 5) as cluster}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-slate-700 bg-slate-800 p-4"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg {cluster.status ===
|
||||
'active'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: cluster.status === 'creating'
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-red-500/20 text-red-400'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">{cluster.name}</p>
|
||||
<p class="text-sm text-slate-400">
|
||||
{cluster.nodes.length} nodes • {cluster.status}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/admin/clusters"
|
||||
class="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-slate-600"
|
||||
>
|
||||
Manage
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-12 text-center shadow-xl">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-800"
|
||||
>
|
||||
<svg class="h-8 w-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-white">No Clusters Yet</h3>
|
||||
<p class="mb-6 text-sm text-slate-400">Get started by creating your first K3S cluster</p>
|
||||
<a
|
||||
href="/admin/clusters"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Create Your First Cluster</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
499
src/routes/admin/clusters/+page.svelte
Normal file
499
src/routes/admin/clusters/+page.svelte
Normal file
@@ -0,0 +1,499 @@
|
||||
<script lang="ts">
|
||||
import { clustersStore, type K3SCluster, type ClusterNode } from '$lib/stores/clusters.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let showDeleteModal = $state(false);
|
||||
let clusterToDelete = $state<K3SCluster | null>(null);
|
||||
|
||||
// Form states
|
||||
let clusterName = $state('');
|
||||
let clusterDescription = $state('');
|
||||
let k3sVersion = $state('v1.28.5+k3s1');
|
||||
|
||||
// Server node form
|
||||
let serverHost = $state('');
|
||||
let serverPort = $state(22);
|
||||
let serverUsername = $state('root');
|
||||
let serverPassword = $state('');
|
||||
|
||||
onMount(() => {
|
||||
clustersStore.loadFromStorage();
|
||||
});
|
||||
|
||||
function openCreateModal() {
|
||||
resetForm();
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
showCreateModal = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
clusterName = '';
|
||||
clusterDescription = '';
|
||||
k3sVersion = 'v1.28.5+k3s1';
|
||||
serverHost = '';
|
||||
serverPort = 22;
|
||||
serverUsername = 'root';
|
||||
serverPassword = '';
|
||||
}
|
||||
|
||||
async function handleCreateCluster(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
const serverNode: ClusterNode = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${clusterName}-server`,
|
||||
role: 'server',
|
||||
ssh: {
|
||||
host: serverHost,
|
||||
port: serverPort,
|
||||
username: serverUsername,
|
||||
password: serverPassword
|
||||
},
|
||||
status: 'pending',
|
||||
ip: serverHost,
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
const newCluster: K3SCluster = {
|
||||
id: crypto.randomUUID(),
|
||||
name: clusterName,
|
||||
description: clusterDescription,
|
||||
status: 'creating',
|
||||
nodes: [serverNode],
|
||||
createdAt: new Date(),
|
||||
k3sVersion: k3sVersion
|
||||
};
|
||||
|
||||
clustersStore.addCluster(newCluster);
|
||||
|
||||
// Simulate cluster creation
|
||||
setTimeout(() => {
|
||||
clustersStore.updateCluster(newCluster.id, { status: 'active' });
|
||||
clustersStore.updateNodeStatus(newCluster.id, serverNode.id, 'active');
|
||||
}, 2000);
|
||||
|
||||
closeCreateModal();
|
||||
}
|
||||
|
||||
function openDeleteModal(cluster: K3SCluster) {
|
||||
clusterToDelete = cluster;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
showDeleteModal = false;
|
||||
clusterToDelete = null;
|
||||
}
|
||||
|
||||
function handleDeleteCluster() {
|
||||
if (clusterToDelete) {
|
||||
clustersStore.deleteCluster(clusterToDelete.id);
|
||||
closeDeleteModal();
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: K3SCluster['status']) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-500/20 text-green-400 border-green-500/50';
|
||||
case 'creating':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50';
|
||||
case 'failed':
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/50';
|
||||
case 'stopped':
|
||||
return 'bg-gray-500/20 text-gray-400 border-gray-500/50';
|
||||
default:
|
||||
return 'bg-slate-500/20 text-slate-400 border-slate-500/50';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">K3S Clusters</h1>
|
||||
<p class="mt-1 text-slate-400">Manage your Kubernetes clusters</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={openCreateModal}
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span>Create Cluster</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Clusters List -->
|
||||
{#if clustersStore.clusters.length > 0}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
{#each clustersStore.clusters as cluster}
|
||||
<div
|
||||
class="rounded-xl border border-slate-800 bg-linear-to-br from-slate-900 to-slate-800 p-6 shadow-xl transition-all hover:border-slate-700"
|
||||
>
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex items-center gap-3">
|
||||
<h3 class="text-xl font-bold text-white">{cluster.name}</h3>
|
||||
<span
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium {getStatusColor(
|
||||
cluster.status
|
||||
)}"
|
||||
>
|
||||
{cluster.status}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400">{cluster.description}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => openDeleteModal(cluster)}
|
||||
class="rounded-lg p-2 text-slate-400 transition-colors hover:bg-red-500/20 hover:text-red-400"
|
||||
title="Delete cluster"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 grid grid-cols-2 gap-4 border-t border-slate-700 pt-4">
|
||||
<div>
|
||||
<p class="text-xs text-slate-500">Nodes</p>
|
||||
<p class="mt-1 text-lg font-semibold text-white">{cluster.nodes.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500">K3S Version</p>
|
||||
<p class="mt-1 text-lg font-semibold text-white">{cluster.k3sVersion || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500">Active Nodes</p>
|
||||
<p class="mt-1 text-lg font-semibold text-green-400">
|
||||
{cluster.nodes.filter((n) => n.status === 'active').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500">Created</p>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{cluster.createdAt.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nodes List -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium tracking-wide text-slate-500 uppercase">Nodes</p>
|
||||
{#each cluster.nodes as node}
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-slate-700 bg-slate-800/50 p-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg {node.role ===
|
||||
'server'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-purple-500/20 text-purple-400'}"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<p class="text-sm font-medium text-white">{node.name}</p>
|
||||
<p class="text-xs text-slate-400">{node.ip} • {node.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full border px-2 py-1 text-xs font-medium {node.status === 'active'
|
||||
? 'border-green-500/50 bg-green-500/20 text-green-400'
|
||||
: node.status === 'pending'
|
||||
? 'border-yellow-500/50 bg-yellow-500/20 text-yellow-400'
|
||||
: 'border-red-500/50 bg-red-500/20 text-red-400'}"
|
||||
>
|
||||
{node.status}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<a
|
||||
href="/admin/clusters/{cluster.id}"
|
||||
class="flex-1 rounded-lg border border-slate-700 bg-slate-800 px-4 py-2 text-center text-sm font-medium text-white transition-colors hover:bg-slate-700"
|
||||
>
|
||||
View Details
|
||||
</a>
|
||||
<a
|
||||
href="/admin/clusters/{cluster.id}/nodes"
|
||||
class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-center text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
Manage Nodes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-12 text-center shadow-xl">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-800"
|
||||
>
|
||||
<svg class="h-8 w-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-white">No Clusters Yet</h3>
|
||||
<p class="mb-6 text-sm text-slate-400">
|
||||
Create your first K3S cluster to get started with container orchestration
|
||||
</p>
|
||||
<button
|
||||
onclick={openCreateModal}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Create Cluster</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create Cluster Modal -->
|
||||
{#if showCreateModal}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
onclick={(e) => e.target === e.currentTarget && closeCreateModal()}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-2xl rounded-xl border border-slate-800 bg-slate-900 shadow-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="border-b border-slate-800 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold text-white">Create New Cluster</h2>
|
||||
<button
|
||||
onclick={closeCreateModal}
|
||||
class="rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleCreateCluster} class="p-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Cluster Info -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-white">Cluster Information</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="clusterName" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
Cluster Name <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="clusterName"
|
||||
type="text"
|
||||
bind:value={clusterName}
|
||||
required
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
placeholder="my-k3s-cluster"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="clusterDesc" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="clusterDesc"
|
||||
bind:value={clusterDescription}
|
||||
rows="2"
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
placeholder="Production K3S cluster for microservices"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="k3sVersion" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
K3S Version
|
||||
</label>
|
||||
<input
|
||||
id="k3sVersion"
|
||||
type="text"
|
||||
bind:value={k3sVersion}
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
placeholder="v1.28.5+k3s1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Node -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-white">Server Node (Master)</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="serverUsername" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
SSH Username <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="serverUsername"
|
||||
type="text"
|
||||
bind:value={serverUsername}
|
||||
required
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
placeholder="root"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="serverHost" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
SSH Host <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="serverHost"
|
||||
type="text"
|
||||
bind:value={serverHost}
|
||||
required
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="serverPort" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
SSH Port <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="serverPort"
|
||||
type="number"
|
||||
bind:value={serverPort}
|
||||
required
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
placeholder="22"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="serverPassword" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
SSH Password <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="serverPassword"
|
||||
type="password"
|
||||
bind:value={serverPassword}
|
||||
required
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Note: In production, consider using SSH keys instead of passwords
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3 border-t border-slate-800 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeCreateModal}
|
||||
class="flex-1 rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 font-medium text-white transition-colors hover:bg-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 rounded-lg bg-blue-600 px-4 py-2.5 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
Create Cluster
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if showDeleteModal && clusterToDelete}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
onclick={(e) => e.target === e.currentTarget && closeDeleteModal()}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md rounded-xl border border-slate-800 bg-slate-900 shadow-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="p-6">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-500/20"
|
||||
>
|
||||
<svg class="h-6 w-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 text-center text-xl font-bold text-white">Delete Cluster</h3>
|
||||
<p class="mb-6 text-center text-sm text-slate-400">
|
||||
Are you sure you want to delete cluster "<span class="font-medium text-white"
|
||||
>{clusterToDelete.name}</span
|
||||
>"? This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={closeDeleteModal}
|
||||
class="flex-1 rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 font-medium text-white transition-colors hover:bg-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleDeleteCluster}
|
||||
class="flex-1 rounded-lg bg-red-600 px-4 py-2.5 font-medium text-white transition-colors hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
562
src/routes/admin/nodes/+page.svelte
Normal file
562
src/routes/admin/nodes/+page.svelte
Normal file
@@ -0,0 +1,562 @@
|
||||
<script lang="ts">
|
||||
import { clustersStore, type ClusterNode } from '$lib/stores/clusters.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let showAddNodeModal = $state(false);
|
||||
let selectedClusterId = $state('');
|
||||
|
||||
// Form states
|
||||
let nodeName = $state('');
|
||||
let nodeRole = $state<'server' | 'agent'>('agent');
|
||||
let nodeHost = $state('');
|
||||
let nodePort = $state(22);
|
||||
let nodeUsername = $state('root');
|
||||
let nodePassword = $state('');
|
||||
|
||||
onMount(() => {
|
||||
clustersStore.loadFromStorage();
|
||||
if (clustersStore.clusters.length > 0) {
|
||||
selectedClusterId = clustersStore.clusters[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
const allNodes = $derived(
|
||||
clustersStore.clusters.flatMap((cluster) =>
|
||||
cluster.nodes.map((node) => ({
|
||||
...node,
|
||||
clusterName: cluster.name,
|
||||
clusterId: cluster.id,
|
||||
clusterStatus: cluster.status
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
function openAddNodeModal() {
|
||||
resetForm();
|
||||
showAddNodeModal = true;
|
||||
}
|
||||
|
||||
function closeAddNodeModal() {
|
||||
showAddNodeModal = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
nodeName = '';
|
||||
nodeRole = 'agent';
|
||||
nodeHost = '';
|
||||
nodePort = 22;
|
||||
nodeUsername = 'root';
|
||||
nodePassword = '';
|
||||
}
|
||||
|
||||
async function handleAddNode(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
const newNode: ClusterNode = {
|
||||
id: crypto.randomUUID(),
|
||||
name: nodeName,
|
||||
role: nodeRole,
|
||||
ssh: {
|
||||
host: nodeHost,
|
||||
port: nodePort,
|
||||
username: nodeUsername,
|
||||
password: nodePassword
|
||||
},
|
||||
status: 'pending',
|
||||
ip: nodeHost,
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
clustersStore.addNodeToCluster(selectedClusterId, newNode);
|
||||
|
||||
// Simulate node installation
|
||||
setTimeout(() => {
|
||||
clustersStore.updateNodeStatus(selectedClusterId, newNode.id, 'active');
|
||||
}, 2000);
|
||||
|
||||
closeAddNodeModal();
|
||||
}
|
||||
|
||||
function handleDeleteNode(clusterId: string, nodeId: string) {
|
||||
if (confirm('Are you sure you want to remove this node?')) {
|
||||
clustersStore.removeNodeFromCluster(clusterId, nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: ClusterNode['status']) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-500/20 text-green-400 border-green-500/50';
|
||||
case 'pending':
|
||||
case 'installing':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50';
|
||||
case 'failed':
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/50';
|
||||
case 'stopped':
|
||||
return 'bg-gray-500/20 text-gray-400 border-gray-500/50';
|
||||
default:
|
||||
return 'bg-slate-500/20 text-slate-400 border-slate-500/50';
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleColor(role: ClusterNode['role']) {
|
||||
return role === 'server'
|
||||
? 'bg-blue-500/20 text-blue-400 border-blue-500/50'
|
||||
: 'bg-purple-500/20 text-purple-400 border-purple-500/50';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">Nodes</h1>
|
||||
<p class="mt-1 text-slate-400">Manage nodes across all clusters</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={openAddNodeModal}
|
||||
disabled={clustersStore.clusters.length === 0}
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span>Add Node</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div
|
||||
class="rounded-xl border border-slate-800 bg-linear-to-br from-slate-900 to-slate-800 p-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-400">Total Nodes</p>
|
||||
<p class="mt-2 text-3xl font-bold text-white">{allNodes.length}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-500/20 text-purple-400"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-slate-800 bg-linear-to-br from-slate-900 to-slate-800 p-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-400">Active Nodes</p>
|
||||
<p class="mt-2 text-3xl font-bold text-green-400">
|
||||
{allNodes.filter((n) => n.status === 'active').length}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-green-500/20 text-green-400"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-slate-800 bg-linear-to-br from-slate-900 to-slate-800 p-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-400">Server Nodes</p>
|
||||
<p class="mt-2 text-3xl font-bold text-blue-400">
|
||||
{allNodes.filter((n) => n.role === 'server').length}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-500/20 text-blue-400"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-slate-800 bg-linear-to-br from-slate-900 to-slate-800 p-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-400">Agent Nodes</p>
|
||||
<p class="mt-2 text-3xl font-bold text-purple-400">
|
||||
{allNodes.filter((n) => n.role === 'agent').length}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-500/20 text-purple-400"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nodes List -->
|
||||
{#if allNodes.length > 0}
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 shadow-xl">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="border-b border-slate-800 bg-slate-800/50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wide text-slate-400 uppercase"
|
||||
>
|
||||
Node Name
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wide text-slate-400 uppercase"
|
||||
>
|
||||
Cluster
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wide text-slate-400 uppercase"
|
||||
>
|
||||
IP Address
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wide text-slate-400 uppercase"
|
||||
>
|
||||
Role
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wide text-slate-400 uppercase"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wide text-slate-400 uppercase"
|
||||
>
|
||||
Created
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wide text-slate-400 uppercase"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{#each allNodes as node}
|
||||
<tr class="transition-colors hover:bg-slate-800/50">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg {getRoleColor(
|
||||
node.role
|
||||
)}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<p class="font-medium text-white">{node.name}</p>
|
||||
<p class="text-sm text-slate-400">{node.ssh.username}@{node.ssh.host}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">{node.clusterName}</p>
|
||||
<p class="text-xs text-slate-400">{node.clusterStatus}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<p class="font-mono text-sm text-slate-300">{node.ip}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
class="inline-flex rounded-full border px-2.5 py-1 text-xs font-medium {getRoleColor(
|
||||
node.role
|
||||
)}"
|
||||
>
|
||||
{node.role}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
class="inline-flex rounded-full border px-2.5 py-1 text-xs font-medium {getStatusColor(
|
||||
node.status
|
||||
)}"
|
||||
>
|
||||
{node.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<p class="text-sm text-slate-400">{node.createdAt.toLocaleDateString()}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<button
|
||||
onclick={() => handleDeleteNode(node.clusterId, node.id)}
|
||||
class="rounded-lg p-2 text-slate-400 transition-colors hover:bg-red-500/20 hover:text-red-400"
|
||||
title="Remove node"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-12 text-center shadow-xl">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-800"
|
||||
>
|
||||
<svg class="h-8 w-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<h3 class="mb-2 text-lg font-semibold text-white">No Nodes Yet</h3>
|
||||
<p class="mb-6 text-sm text-slate-400">
|
||||
{#if clustersStore.clusters.length === 0}
|
||||
Create a cluster first before adding nodes
|
||||
{:else}
|
||||
Add nodes to your clusters to expand your infrastructure
|
||||
{/if}
|
||||
</p>
|
||||
{#if clustersStore.clusters.length > 0}
|
||||
<button
|
||||
onclick={openAddNodeModal}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Add Node</span>
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
href="/admin/clusters"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<span>Create Cluster</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Add Node Modal -->
|
||||
{#if showAddNodeModal}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
onclick={(e) => e.target === e.currentTarget && closeAddNodeModal()}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-2xl rounded-xl border border-slate-800 bg-slate-900 shadow-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="border-b border-slate-800 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold text-white">Add New Node</h2>
|
||||
<button
|
||||
onclick={closeAddNodeModal}
|
||||
class="rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleAddNode} class="p-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Cluster Selection -->
|
||||
<div>
|
||||
<label for="clusterId" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
Select Cluster <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="clusterId"
|
||||
bind:value={selectedClusterId}
|
||||
required
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
>
|
||||
{#each clustersStore.clusters as cluster}
|
||||
<option value={cluster.id}>{cluster.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Node Info -->
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="nodeName" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
Node Name <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="nodeName"
|
||||
type="text"
|
||||
bind:value={nodeName}
|
||||
required
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
placeholder="worker-node-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="nodeRole" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
Node Role <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="nodeRole"
|
||||
bind:value={nodeRole}
|
||||
required
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
>
|
||||
<option value="agent">Agent (Worker)</option>
|
||||
<option value="server">Server (Master)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH Configuration -->
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-white">SSH Configuration</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label for="nodeUsername" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
SSH Username <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="nodeUsername"
|
||||
type="text"
|
||||
bind:value={nodeUsername}
|
||||
required
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
placeholder="root"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="nodeHost" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
SSH Host <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="nodeHost"
|
||||
type="text"
|
||||
bind:value={nodeHost}
|
||||
required
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
placeholder="192.168.1.101"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="nodePort" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
SSH Port <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="nodePort"
|
||||
type="number"
|
||||
bind:value={nodePort}
|
||||
required
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
placeholder="22"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="nodePassword" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
SSH Password <span class="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="nodePassword"
|
||||
type="password"
|
||||
bind:value={nodePassword}
|
||||
required
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3 border-t border-slate-800 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeAddNodeModal}
|
||||
class="flex-1 rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 font-medium text-white transition-colors hover:bg-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 rounded-lg bg-blue-600 px-4 py-2.5 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
Add Node
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
360
src/routes/admin/settings/+page.svelte
Normal file
360
src/routes/admin/settings/+page.svelte
Normal file
@@ -0,0 +1,360 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { clustersStore } from '$lib/stores/clusters.svelte';
|
||||
|
||||
let k3supVersion = $state('v0.13.5');
|
||||
let autoBackup = $state(true);
|
||||
let backupInterval = $state(24);
|
||||
let notificationsEnabled = $state(true);
|
||||
let emailNotifications = $state('admin@example.com');
|
||||
let themeMode = $state<'dark' | 'light'>('dark');
|
||||
|
||||
let showSaveMessage = $state(false);
|
||||
|
||||
function handleSaveSettings(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
// Save settings to localStorage
|
||||
const settings = {
|
||||
k3supVersion,
|
||||
autoBackup,
|
||||
backupInterval,
|
||||
notificationsEnabled,
|
||||
emailNotifications,
|
||||
themeMode
|
||||
};
|
||||
|
||||
localStorage.setItem('k3s-settings', JSON.stringify(settings));
|
||||
|
||||
// Show save message
|
||||
showSaveMessage = true;
|
||||
setTimeout(() => {
|
||||
showSaveMessage = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function handleClearData() {
|
||||
if (
|
||||
confirm(
|
||||
'Are you sure you want to clear all data? This will remove all clusters, nodes, and configurations. This action cannot be undone.'
|
||||
)
|
||||
) {
|
||||
localStorage.removeItem('k3s-clusters');
|
||||
localStorage.removeItem('k3s-settings');
|
||||
localStorage.removeItem('auth');
|
||||
|
||||
// Reload clusters
|
||||
clustersStore.loadFromStorage();
|
||||
|
||||
alert('All data has been cleared successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportData() {
|
||||
const data = {
|
||||
clusters: clustersStore.clusters,
|
||||
settings: {
|
||||
k3supVersion,
|
||||
autoBackup,
|
||||
backupInterval,
|
||||
notificationsEnabled,
|
||||
emailNotifications,
|
||||
themeMode
|
||||
},
|
||||
exportDate: new Date().toISOString()
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `k3s-management-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function handleImportData() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'application/json';
|
||||
input.onchange = (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.target?.result as string);
|
||||
if (data.clusters) {
|
||||
localStorage.setItem('k3s-clusters', JSON.stringify(data.clusters));
|
||||
clustersStore.loadFromStorage();
|
||||
}
|
||||
if (data.settings) {
|
||||
localStorage.setItem('k3s-settings', JSON.stringify(data.settings));
|
||||
}
|
||||
alert('Data imported successfully!');
|
||||
} catch (error) {
|
||||
alert('Failed to import data. Please check the file format.');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">Settings</h1>
|
||||
<p class="mt-1 text-slate-400">Configure your K3S management system</p>
|
||||
</div>
|
||||
|
||||
<!-- Save Message -->
|
||||
{#if showSaveMessage}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-200"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Settings saved successfully!</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSaveSettings} class="space-y-6">
|
||||
<!-- K3sup Configuration -->
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-xl">
|
||||
<h2 class="mb-4 text-xl font-bold text-white">K3sup Configuration</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="k3supVersion" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
K3sup Version
|
||||
</label>
|
||||
<input
|
||||
id="k3supVersion"
|
||||
type="text"
|
||||
bind:value={k3supVersion}
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
placeholder="v0.13.5"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Version of k3sup to use for cluster installation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-slate-700 bg-slate-800/50 p-4">
|
||||
<h3 class="mb-2 text-sm font-medium text-white">About K3sup</h3>
|
||||
<p class="text-xs text-slate-400">
|
||||
k3sup is a light-weight utility to get from zero to KUBE with k3s on any local or
|
||||
remote VM. All you need is ssh access and the k3sup binary to get kubectl access
|
||||
immediately.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/alexellis/k3sup"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-2 inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<span>Learn more</span>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Settings -->
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-xl">
|
||||
<h2 class="mb-4 text-xl font-bold text-white">Backup Settings</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-white">Automatic Backup</p>
|
||||
<p class="text-sm text-slate-400">Automatically backup cluster configurations</p>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input type="checkbox" bind:checked={autoBackup} class="peer sr-only" />
|
||||
<div
|
||||
class="peer h-6 w-11 rounded-full bg-slate-700 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-slate-600 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500/50"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="backupInterval" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
Backup Interval (hours)
|
||||
</label>
|
||||
<input
|
||||
id="backupInterval"
|
||||
type="number"
|
||||
bind:value={backupInterval}
|
||||
disabled={!autoBackup}
|
||||
min="1"
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-xl">
|
||||
<h2 class="mb-4 text-xl font-bold text-white">Notifications</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-white">Enable Notifications</p>
|
||||
<p class="text-sm text-slate-400">Receive alerts about cluster events</p>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input type="checkbox" bind:checked={notificationsEnabled} class="peer sr-only" />
|
||||
<div
|
||||
class="peer h-6 w-11 rounded-full bg-slate-700 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-slate-600 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-500/50"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="emailNotifications" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
Email for Notifications
|
||||
</label>
|
||||
<input
|
||||
id="emailNotifications"
|
||||
type="email"
|
||||
bind:value={emailNotifications}
|
||||
disabled={!notificationsEnabled}
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white placeholder-slate-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance -->
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-xl">
|
||||
<h2 class="mb-4 text-xl font-bold text-white">Appearance</h2>
|
||||
<div>
|
||||
<label for="themeMode" class="mb-2 block text-sm font-medium text-slate-300">
|
||||
Theme Mode
|
||||
</label>
|
||||
<select
|
||||
id="themeMode"
|
||||
bind:value={themeMode}
|
||||
class="w-full rounded-lg border border-slate-700 bg-slate-800 px-4 py-2.5 text-white focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">Currently only dark mode is supported</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-2.5 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Save Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Data Management -->
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-xl">
|
||||
<h2 class="mb-4 text-xl font-bold text-white">Data Management</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<button
|
||||
onclick={handleExportData}
|
||||
class="flex items-center justify-center gap-2 rounded-lg border border-slate-700 bg-slate-800 px-4 py-3 font-medium text-white transition-colors hover:bg-slate-700"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Export Data</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={handleImportData}
|
||||
class="flex items-center justify-center gap-2 rounded-lg border border-slate-700 bg-slate-800 px-4 py-3 font-medium text-white transition-colors hover:bg-slate-700"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>Import Data</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={handleClearData}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-red-500/50 bg-red-500/10 px-4 py-3 font-medium text-red-400 transition-colors hover:bg-red-500/20"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
<span>Clear All Data</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Information -->
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-xl">
|
||||
<h2 class="mb-4 text-xl font-bold text-white">System Information</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between border-b border-slate-800 pb-3">
|
||||
<span class="text-sm text-slate-400">Application Version</span>
|
||||
<span class="text-sm font-medium text-white">v0.0.1</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-800 pb-3">
|
||||
<span class="text-sm text-slate-400">Total Clusters</span>
|
||||
<span class="text-sm font-medium text-white">{clustersStore.clusters.length}</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-800 pb-3">
|
||||
<span class="text-sm text-slate-400">Total Nodes</span>
|
||||
<span class="text-sm font-medium text-white">
|
||||
{clustersStore.clusters.reduce((acc, c) => acc + c.nodes.length, 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-slate-400">Current User</span>
|
||||
<span class="text-sm font-medium text-white">{authStore.user?.username || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
470
src/routes/admin/ssh/+page.svelte
Normal file
470
src/routes/admin/ssh/+page.svelte
Normal file
@@ -0,0 +1,470 @@
|
||||
<script lang="ts">
|
||||
import { clustersStore, type SSHConfig } from '$lib/stores/clusters.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let showTestModal = $state(false);
|
||||
let testingConfig = $state<{ host: string; username: string } | null>(null);
|
||||
let testResult = $state<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
clustersStore.loadFromStorage();
|
||||
});
|
||||
|
||||
const allSSHConfigs = $derived(
|
||||
clustersStore.clusters.flatMap((cluster) =>
|
||||
cluster.nodes.map((node) => ({
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
clusterName: cluster.name,
|
||||
clusterId: cluster.id,
|
||||
ssh: node.ssh,
|
||||
ip: node.ip,
|
||||
status: node.status
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
function testSSHConnection(config: {
|
||||
host: string;
|
||||
username: string;
|
||||
nodeName: string;
|
||||
clusterName: string;
|
||||
}) {
|
||||
testingConfig = { host: config.host, username: config.username };
|
||||
testResult = null;
|
||||
showTestModal = true;
|
||||
|
||||
// Simulate SSH test
|
||||
setTimeout(() => {
|
||||
testResult = {
|
||||
success: true,
|
||||
message: `Successfully connected to ${config.username}@${config.host}`
|
||||
};
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function closeTestModal() {
|
||||
showTestModal = false;
|
||||
testingConfig = null;
|
||||
testResult = null;
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'text-green-400';
|
||||
case 'pending':
|
||||
case 'installing':
|
||||
return 'text-yellow-400';
|
||||
case 'failed':
|
||||
return 'text-red-400';
|
||||
default:
|
||||
return 'text-slate-400';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">SSH Configurations</h1>
|
||||
<p class="mt-1 text-slate-400">Manage SSH connections to your nodes</p>
|
||||
</div>
|
||||
|
||||
<!-- Info Banner -->
|
||||
<div
|
||||
class="flex items-start gap-3 rounded-xl border border-blue-500/50 bg-blue-500/10 p-4 text-sm text-blue-200"
|
||||
>
|
||||
<svg class="mt-0.5 h-5 w-5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-medium">SSH Security Notice</p>
|
||||
<p class="mt-1 text-xs text-blue-300">
|
||||
For production environments, it's recommended to use SSH key-based authentication instead of
|
||||
passwords. Store your private keys securely and never commit them to version control.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid gap-6 sm:grid-cols-3">
|
||||
<div
|
||||
class="rounded-xl border border-slate-800 bg-linear-to-br from-slate-900 to-slate-800 p-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-400">Total Connections</p>
|
||||
<p class="mt-2 text-3xl font-bold text-white">{allSSHConfigs.length}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-500/20 text-purple-400"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-slate-800 bg-linear-to-br from-slate-900 to-slate-800 p-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-400">Active Connections</p>
|
||||
<p class="mt-2 text-3xl font-bold text-green-400">
|
||||
{allSSHConfigs.filter((c) => c.status === 'active').length}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-green-500/20 text-green-400"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-slate-800 bg-linear-to-br from-slate-900 to-slate-800 p-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-400">Unique Hosts</p>
|
||||
<p class="mt-2 text-3xl font-bold text-cyan-400">
|
||||
{new Set(allSSHConfigs.map((c) => c.ssh.host)).size}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-500/20 text-cyan-400"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH Configurations List -->
|
||||
{#if allSSHConfigs.length > 0}
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 shadow-xl">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="border-b border-slate-800 bg-slate-800/50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-400"
|
||||
>
|
||||
Node
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-400"
|
||||
>
|
||||
Cluster
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-400"
|
||||
>
|
||||
SSH Connection
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-400"
|
||||
>
|
||||
IP Address
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-400"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-400"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{#each allSSHConfigs as config}
|
||||
<tr class="transition-colors hover:bg-slate-800/50">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/20 text-purple-400"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<p class="font-medium text-white">{config.nodeName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<p class="text-sm text-slate-300">{config.clusterName}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="rounded bg-slate-800 px-2 py-1 text-sm text-cyan-400">
|
||||
{config.ssh.username}@{config.ssh.host}:{config.ssh.port}
|
||||
</code>
|
||||
<button
|
||||
onclick={() => copyToClipboard(`${config.ssh.username}@${config.ssh.host}`)}
|
||||
class="rounded p-1 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<code class="text-sm text-slate-300">{config.ip}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full {getStatusColor(config.status)}"></div>
|
||||
<span class="text-sm capitalize {getStatusColor(config.status)}">
|
||||
{config.status}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<button
|
||||
onclick={() =>
|
||||
testSSHConnection({
|
||||
host: config.ssh.host,
|
||||
username: config.ssh.username,
|
||||
nodeName: config.nodeName,
|
||||
clusterName: config.clusterName
|
||||
})}
|
||||
class="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-slate-700"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Test</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-12 text-center shadow-xl">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-800"
|
||||
>
|
||||
<svg class="h-8 w-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-white">No SSH Configurations</h3>
|
||||
<p class="mb-6 text-sm text-slate-400">
|
||||
Create a cluster and add nodes to see SSH configurations here
|
||||
</p>
|
||||
<a
|
||||
href="/admin/clusters"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<span>Go to Clusters</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- SSH Commands Reference -->
|
||||
<div class="rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-xl">
|
||||
<h2 class="mb-4 text-xl font-bold text-white">SSH Commands Reference</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-slate-300">Connect to a node via SSH:</p>
|
||||
<code
|
||||
class="block rounded-lg bg-slate-800 px-4 py-3 font-mono text-sm text-cyan-400 border border-slate-700"
|
||||
>
|
||||
ssh username@host -p port
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-slate-300">Connect using SSH key:</p>
|
||||
<code
|
||||
class="block rounded-lg bg-slate-800 px-4 py-3 font-mono text-sm text-cyan-400 border border-slate-700"
|
||||
>
|
||||
ssh -i /path/to/private_key username@host -p port
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-slate-300">Generate SSH key pair:</p>
|
||||
<code
|
||||
class="block rounded-lg bg-slate-800 px-4 py-3 font-mono text-sm text-cyan-400 border border-slate-700"
|
||||
>
|
||||
ssh-keygen -t ed25519 -C "your_email@example.com"
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-slate-300">Copy SSH key to remote host:</p>
|
||||
<code
|
||||
class="block rounded-lg bg-slate-800 px-4 py-3 font-mono text-sm text-cyan-400 border border-slate-700"
|
||||
>
|
||||
ssh-copy-id username@host
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test SSH Modal -->
|
||||
{#if showTestModal}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
onclick={(e) => e.target === e.currentTarget && closeTestModal()}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md rounded-xl border border-slate-800 bg-slate-900 shadow-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="border-b border-slate-800 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-white">Test SSH Connection</h2>
|
||||
<button
|
||||
onclick={closeTestModal}
|
||||
class="rounded-lg p-2 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
{#if testingConfig}
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-slate-400">Testing connection to:</p>
|
||||
<code class="mt-2 block rounded-lg bg-slate-800 px-4 py-2 text-sm text-cyan-400">
|
||||
{testingConfig.username}@{testingConfig.host}
|
||||
</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if testResult === null}
|
||||
<div class="flex flex-col items-center justify-center py-8">
|
||||
<svg class="h-12 w-12 animate-spin text-blue-500" fill="none" 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>
|
||||
<p class="mt-4 text-sm text-slate-400">Testing connection...</p>
|
||||
</div>
|
||||
{:else if testResult.success}
|
||||
<div
|
||||
class="rounded-lg border border-green-500/50 bg-green-500/10 p-4 text-sm text-green-200"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-medium">Connection Successful</p>
|
||||
<p class="mt-1 text-xs text-green-300">{testResult.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-red-500/50 bg-red-500/10 p-4 text-sm text-red-200">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<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>
|
||||
<p class="font-medium">Connection Failed</p>
|
||||
<p class="mt-1 text-xs text-red-300">{testResult.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if testResult !== null}
|
||||
<button
|
||||
onclick={closeTestModal}
|
||||
class="mt-6 w-full rounded-lg bg-slate-800 px-4 py-2.5 font-medium text-white transition-colors hover:bg-slate-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user