initial commit

This commit is contained in:
Ahmad Ardiansyah
2025-11-13 08:48:03 +07:00
commit af4bbe9b15
27 changed files with 3137 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

16
.prettierrc Normal file
View File

@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

190
README.md Normal file
View File

@@ -0,0 +1,190 @@
# K3S Management
A modern web-based management interface for K3S Kubernetes clusters built with SvelteKit 5.
## Features
- 🔐 **Authentication** - Secure login system
- 🖥️ **Cluster Management** - Create and manage K3S clusters
- 📦 **Node Management** - Add and monitor cluster nodes
- 🔑 **SSH Configuration** - Manage SSH connections to nodes
- ⚙️ **Settings** - Configure system preferences
- 💾 **Data Export/Import** - Backup and restore configurations
## Tech Stack
- **SvelteKit 5** - Frontend framework with modern runes syntax
- **TailwindCSS 4** - Utility-first CSS framework
- **TypeScript** - Type-safe development
- **K3sup** - Backend tool for K3S cluster installation (planned)
## Getting Started
### Prerequisites
- Node.js 18+ or Bun
- A modern web browser
### Installation
1. Clone the repository
2. Install dependencies:
```bash
bun install
# or
npm install
```
3. Start the development server:
```bash
bun run dev
# or
npm run dev
```
4. Open your browser and navigate to `http://localhost:5173`
### Default Login Credentials
- **Username**: `admin`
- **Password**: `admin`
⚠️ **Security Note**: Change these credentials in production!
## Project Structure
```
k3s-management/
├── src/
│ ├── lib/
│ │ └── stores/ # Svelte stores for state management
│ │ ├── auth.svelte.ts
│ │ └── clusters.svelte.ts
│ ├── routes/
│ │ ├── +page.svelte # Login page
│ │ └── admin/ # Admin dashboard
│ │ ├── +layout.svelte
│ │ ├── +page.svelte
│ │ ├── clusters/
│ │ ├── nodes/
│ │ ├── ssh/
│ │ └── settings/
│ └── app.css # Global styles
└── package.json
```
## Usage
### Creating a Cluster
1. Navigate to the **Clusters** page
2. Click **Create Cluster**
3. Fill in cluster information:
- Cluster name and description
- K3S version
- Server node SSH details (host, port, username, password)
- Node IP address
4. Click **Create Cluster**
### Adding Nodes
1. Navigate to the **Nodes** page
2. Click **Add Node**
3. Select the target cluster
4. Configure node details:
- Node name and role (Server/Agent)
- SSH connection details
- Node IP address
5. Click **Add Node**
### Managing SSH Configurations
1. Navigate to **SSH Configurations**
2. View all SSH connections
3. Test connections to verify connectivity
4. Copy connection strings for manual SSH access
### Settings
- Configure K3sup version
- Enable/disable automatic backups
- Set up notifications
- Export/import data
- Clear all data
## Development
### Building for Production
```bash
bun run build
# or
npm run build
```
### Preview Production Build
```bash
bun run preview
# or
npm run preview
```
### Code Formatting
```bash
bun run format
# or
npm run format
```
### Linting
```bash
bun run lint
# or
npm run lint
```
## Roadmap
- [ ] Implement actual K3sup backend integration
- [ ] Add real SSH key-based authentication
- [ ] Real-time cluster status monitoring
- [ ] Cluster logs viewer
- [ ] Multi-user support with roles
- [ ] API endpoints for external integrations
- [ ] Kubernetes resources management
- [ ] Helm charts deployment
- [ ] Cluster backup and restore
- [ ] Monitoring and alerting
## Security Considerations
⚠️ **Important**: This is a development/demo version. For production use:
1. Implement proper authentication (JWT, OAuth, etc.)
2. Use SSH keys instead of passwords
3. Encrypt sensitive data
4. Use HTTPS
5. Implement rate limiting
6. Add CSRF protection
7. Validate all inputs
8. Use environment variables for configuration
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
MIT
## Acknowledgments
- Built with [SvelteKit](https://kit.svelte.dev/)
- Styled with [TailwindCSS](https://tailwindcss.com/)
- Powered by [K3sup](https://github.com/alexellis/k3sup)
- Icons from [Heroicons](https://heroicons.com/)

BIN
bun.lockb Normal file

Binary file not shown.

41
eslint.config.js Normal file
View File

@@ -0,0 +1,41 @@
import prettier from 'eslint-config-prettier';
import { fileURLToPath } from 'node:url';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "k3s-management",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.38.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.14",
"@types/node": "^22",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^16.4.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"tailwindcss": "^4.1.14",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1",
"vite": "^7.1.10"
}
}

2
src/app.css Normal file
View File

@@ -0,0 +1,2 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';

13
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,69 @@
interface User {
username: string;
role: string;
}
interface AuthState {
isAuthenticated: boolean;
user: User | null;
}
class AuthStore {
private state = $state<AuthState>({
isAuthenticated: false,
user: null
});
get isAuthenticated() {
return this.state.isAuthenticated;
}
get user() {
return this.state.user;
}
login(username: string, password: string): boolean {
// Temporary simple auth - akan diganti dengan backend auth
if (username === 'admin' && password === 'admin') {
this.state.isAuthenticated = true;
this.state.user = {
username: username,
role: 'admin'
};
// Store in localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('auth', JSON.stringify(this.state.user));
}
return true;
}
return false;
}
logout() {
this.state.isAuthenticated = false;
this.state.user = null;
if (typeof window !== 'undefined') {
localStorage.removeItem('auth');
}
}
checkAuth() {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('auth');
if (stored) {
try {
const user = JSON.parse(stored);
this.state.isAuthenticated = true;
this.state.user = user;
} catch {
this.logout();
}
}
}
}
}
export const authStore = new AuthStore();

View File

@@ -0,0 +1,137 @@
export interface SSHConfig {
host: string;
port: number;
username: string;
password?: string;
privateKey?: string;
}
export interface ClusterNode {
id: string;
name: string;
role: 'server' | 'agent';
ssh: SSHConfig;
status: 'pending' | 'installing' | 'active' | 'failed' | 'stopped';
ip: string;
createdAt: Date;
}
export interface K3SCluster {
id: string;
name: string;
description: string;
status: 'creating' | 'active' | 'failed' | 'stopped';
nodes: ClusterNode[];
createdAt: Date;
k3sVersion?: string;
}
class ClustersStore {
private state = $state<{
clusters: K3SCluster[];
selectedCluster: K3SCluster | null;
loading: boolean;
}>({
clusters: [],
selectedCluster: null,
loading: false
});
get clusters() {
return this.state.clusters;
}
get selectedCluster() {
return this.state.selectedCluster;
}
get loading() {
return this.state.loading;
}
addCluster(cluster: K3SCluster) {
this.state.clusters.push(cluster);
this.saveToStorage();
}
updateCluster(id: string, updates: Partial<K3SCluster>) {
const index = this.state.clusters.findIndex((c) => c.id === id);
if (index !== -1) {
this.state.clusters[index] = { ...this.state.clusters[index], ...updates };
this.saveToStorage();
}
}
deleteCluster(id: string) {
this.state.clusters = this.state.clusters.filter((c) => c.id !== id);
if (this.state.selectedCluster?.id === id) {
this.state.selectedCluster = null;
}
this.saveToStorage();
}
selectCluster(id: string) {
this.state.selectedCluster = this.state.clusters.find((c) => c.id === id) || null;
}
addNodeToCluster(clusterId: string, node: ClusterNode) {
const cluster = this.state.clusters.find((c) => c.id === clusterId);
if (cluster) {
cluster.nodes.push(node);
this.saveToStorage();
}
}
updateNodeStatus(clusterId: string, nodeId: string, status: ClusterNode['status']) {
const cluster = this.state.clusters.find((c) => c.id === clusterId);
if (cluster) {
const node = cluster.nodes.find((n) => n.id === nodeId);
if (node) {
node.status = status;
this.saveToStorage();
}
}
}
removeNodeFromCluster(clusterId: string, nodeId: string) {
const cluster = this.state.clusters.find((c) => c.id === clusterId);
if (cluster) {
cluster.nodes = cluster.nodes.filter((n) => n.id !== nodeId);
this.saveToStorage();
}
}
setLoading(loading: boolean) {
this.state.loading = loading;
}
loadFromStorage() {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('k3s-clusters');
if (stored) {
try {
const data = JSON.parse(stored);
// Convert date strings back to Date objects
this.state.clusters = data.map((c: any) => ({
...c,
createdAt: new Date(c.createdAt),
nodes: c.nodes.map((n: any) => ({
...n,
createdAt: new Date(n.createdAt)
}))
}));
} catch (e) {
console.error('Failed to load clusters from storage:', e);
}
}
}
}
private saveToStorage() {
if (typeof window !== 'undefined') {
localStorage.setItem('k3s-clusters', JSON.stringify(this.state.clusters));
}
}
}
export const clustersStore = new ClustersStore();

12
src/routes/+layout.svelte Normal file
View 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
View 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>

View 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>

View 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>

View 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}

View 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}

View 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>

View 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}

3
static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

17
svelte.config.js Normal file
View File

@@ -0,0 +1,17 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

7
vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});