Compare commits

...

13 Commits

Author SHA1 Message Date
Paul Payne
2684c46de4 Remove launch configuration for PoC 2025-11-04 17:17:53 +00:00
Paul Payne
9e7c035153 Remove experiments since we are building them now. 2025-10-22 22:32:29 +00:00
Paul Payne
19dcccf183 Update dev env. 2025-10-11 21:43:38 +00:00
Paul Payne
343f33173a Confiurable arch. 2025-10-11 16:47:32 +00:00
Paul Payne
e56d981d74 Experimental daemon. 2025-10-04 08:43:12 -07:00
Paul Payne
f94f80cd2a Use direnv for automatic env config. 2025-10-04 08:30:02 -07:00
Paul Payne
d06e27931c Cert-manager setup reliability. 2025-10-04 08:28:43 -07:00
Paul Payne
748ae1a70b Script fixes. 2025-10-04 08:28:01 -07:00
Paul Payne
482cebc603 cert-manager setup improvements. idempotent. checks and cleans up failures. converges to reliable state. 2025-10-01 09:54:41 -07:00
Paul Payne
fe6857e888 Fixes ingress refs. 2025-10-01 05:19:55 -07:00
Paul Payne
395bdff2a6 Updates docs. 2025-10-01 05:19:37 -07:00
Paul Payne
d21eb18dc9 Eliminates wild-app-fetch 2025-10-01 04:39:50 -07:00
Paul Payne
ecdb2f2916 Revise wild-setup-cluster to use a single wild-node-setup to replace node-patch-generate and node-up. 2025-10-01 03:52:16 -07:00
135 changed files with 997 additions and 12970 deletions

View File

@@ -7,6 +7,7 @@ containo
controlplane
coredns
crds
direnv
dnsmasq
envsubst
externaldns
@@ -19,6 +20,7 @@ ipxe
Jellyfin
keepalives
KUBECONFIG
kubelet
kubernetescrd
kustomization
letsencrypt
@@ -39,9 +41,11 @@ pgvector
rcode
restic
SAMEORIGIN
talosconfig
talosctl
TALOSCTL
traefik
urandom
USEPATH
vxlan
websecure

69
.vscode/launch.json vendored
View File

@@ -1,69 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Daemon",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/experimental/daemon/main.go",
"console": "integratedTerminal",
"env": {
"GO_ENV": "development"
},
"args": [],
"cwd": "${workspaceFolder}",
"stopOnEntry": false,
"showLog": true
},
{
"name": "Daemon (Debug)",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/experimental/daemon/main.go",
"console": "integratedTerminal",
"env": {
"GO_ENV": "development"
},
"args": [],
"cwd": "${workspaceFolder}",
"stopOnEntry": false,
"showLog": true
},
{
"name": "App",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
],
"cwd": "${workspaceFolder}/experimental/app",
"console": "integratedTerminal",
"env": {
"BROWSER": "none"
},
"autoAttachChildProcesses": true,
"skipFiles": [
"<node_internals>/**"
]
}
],
"compounds": [
{
"name": "Full Stack (Daemon + App)",
"configurations": [
"Daemon",
"App"
],
"stopAll": true,
"presentation": {
"hidden": false,
"group": "",
"order": 1
}
}
]
}

View File

@@ -220,8 +220,7 @@ This approach prevents naming conflicts between apps and makes secret keys more
Apps in Wild Cloud are managed by operators using a set of commands run from their Wild Cloud home directory.
- `wild-apps-list`: Lists all available apps.
- `wild-app-fetch <app-name>`: Fetches the latest app files from the Wild Cloud repository and stores them in your Wild Cloud cache.
- `wild-app-add <app-name>`: Adds the app manifest to your Wild Cloud home `apps` directory, updates missing values in `config.yaml` and `secrets.yaml` with the app's default configurations, and compiles the app's Kustomize files.
- `wild-app-add <app-name>`: Reads the app from the Wild Cloud repository, adds the app manifest to your Wild Cloud home `apps` directory, updates missing values in `config.yaml` and `secrets.yaml` with the app's default configurations, and compiles the app's Kustomize files.
- `wild-app-deploy <app-name>`: Deploys the app to your Wild Cloud.
## Contributing

View File

@@ -5,8 +5,7 @@ metadata:
namespace: gitea
annotations:
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/target: "{{ .apps.gitea.domain }}"
external-dns.alpha.kubernetes.io/target: "{{ .cluster.externalDns.target}}"
external-dns.alpha.kubernetes.io/target: "{{ .cloud.domain }}"
spec:
rules:
- host: "{{ .apps.gitea.domain }}"

View File

@@ -4,7 +4,7 @@ kind: Ingress
metadata:
name: immich-public
annotations:
external-dns.alpha.kubernetes.io/target: "{{ .apps.immich.domain }}"
external-dns.alpha.kubernetes.io/target: "{{ .cloud.domain }}"
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
spec:
rules:

View File

@@ -8,23 +8,23 @@ UPDATE=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--update)
--force)
UPDATE=true
shift
;;
-h|--help)
echo "Usage: $0 <app_name> [--update]"
echo "Usage: $0 <app_name> [--force]"
echo ""
echo "Configure an app by applying templates and merging configuration."
echo ""
echo "Options:"
echo " --update Overwrite existing app files without confirmation"
echo " --force Overwrite existing app files without confirmation"
echo " -h, --help Show this help message"
exit 0
;;
-*)
echo "Unknown option $1"
echo "Usage: $0 <app_name> [--update]"
echo "Usage: $0 <app_name> [--force]"
exit 1
;;
*)
@@ -32,7 +32,7 @@ while [[ $# -gt 0 ]]; do
APP_NAME="$1"
else
echo "Too many arguments"
echo "Usage: $0 <app_name> [--update]"
echo "Usage: $0 <app_name> [--force]"
exit 1
fi
shift
@@ -41,7 +41,7 @@ while [[ $# -gt 0 ]]; do
done
if [ -z "${APP_NAME}" ]; then
echo "Usage: $0 <app_name> [--update]"
echo "Usage: $0 <app_name> [--force]"
exit 1
fi
@@ -71,21 +71,14 @@ if [ ! -f "${SECRETS_FILE}" ]; then
echo "" >> "${SECRETS_FILE}"
fi
# Check if app is cached, if not fetch it first
CACHE_APP_DIR="${WC_HOME}/.wildcloud/cache/apps/${APP_NAME}"
if [ ! -d "${CACHE_APP_DIR}" ]; then
echo "Cache directory for app '${APP_NAME}' not found at '${CACHE_APP_DIR}'."
echo "Please fetch the app first using 'wild-app-fetch ${APP_NAME}'."
# Check if app exists in repository
SOURCE_APP_DIR="${WC_ROOT}/apps/${APP_NAME}"
if [ ! -d "${SOURCE_APP_DIR}" ]; then
echo "Error: App '${APP_NAME}' not found at ${SOURCE_APP_DIR}"
echo "Available apps:"
ls -1 "${WC_ROOT}/apps" | grep -v README.md | sed 's/^/ - /'
exit 1
fi
if [ ! -d "${CACHE_APP_DIR}" ]; then
echo "App '${APP_NAME}' not found in cache, fetching..."
if [ "${UPDATE}" = true ]; then
./bin/wild-app-fetch "${APP_NAME}" --update
else
./bin/wild-app-fetch "${APP_NAME}"
fi
fi
APPS_DIR="${WC_HOME}/apps"
if [ ! -d "${APPS_DIR}" ]; then
@@ -113,12 +106,12 @@ else
fi
mkdir -p "${DEST_APP_DIR}"
# Step 1: Copy only manifest.yaml from cache first
MANIFEST_FILE="${CACHE_APP_DIR}/manifest.yaml"
# Step 1: Copy manifest.yaml from repository first
MANIFEST_FILE="${SOURCE_APP_DIR}/manifest.yaml"
if [ -f "${MANIFEST_FILE}" ]; then
# manifest.yaml is allowed to have gomplate variables in the defaultConfig and requiredSecrets sections.
# We need to use gomplate to process these variables before using yq.
echo "Copying app manifest from cache."
echo "Processing app manifest."
DEST_MANIFEST="${DEST_APP_DIR}/manifest.yaml"
if [ -f "${SECRETS_FILE}" ]; then
gomplate_cmd="gomplate -c .=${CONFIG_FILE} -c secrets=${SECRETS_FILE} -f ${MANIFEST_FILE} -o ${DEST_MANIFEST}"
@@ -130,7 +123,7 @@ if [ -f "${MANIFEST_FILE}" ]; then
exit 1
fi
else
echo "Warning: App manifest not found in cache."
echo "Error: App manifest not found at ${MANIFEST_FILE}"
exit 1
fi
@@ -185,10 +178,10 @@ if yq eval '.requiredSecrets' "${DEST_MANIFEST}" | grep -q -v '^null$'; then
echo "Required secrets declared in app manifest added to '${SECRETS_FILE}'."
fi
# Step 3: Copy and compile all other files from cache to app directory
echo "Copying and compiling remaining files from cache."
# Step 3: Copy and compile all files from repository to app directory
echo "Copying and compiling app files."
cp -r "${CACHE_APP_DIR}/." "${DEST_APP_DIR}/"
cp -r "${SOURCE_APP_DIR}/." "${DEST_APP_DIR}/"
find "${DEST_APP_DIR}" -type f | while read -r dest_file; do
rel_path="${dest_file#${DEST_APP_DIR}/}"

View File

@@ -1,109 +0,0 @@
#!/bin/bash
set -e
set -o pipefail
UPDATE=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--update)
UPDATE=true
shift
;;
-h|--help)
echo "Usage: $0 <app_name> [--update]"
echo ""
echo "Fetch an app template from the Wild Cloud repository to cache."
echo ""
echo "Options:"
echo " --update Overwrite existing cached files without confirmation"
echo " -h, --help Show this help message"
exit 0
;;
-*)
echo "Unknown option $1"
echo "Usage: $0 <app_name> [--update]"
exit 1
;;
*)
if [ -z "${APP_NAME}" ]; then
APP_NAME="$1"
else
echo "Too many arguments"
echo "Usage: $0 <app_name> [--update]"
exit 1
fi
shift
;;
esac
done
if [ -z "${APP_NAME}" ]; then
echo "Usage: $0 <app_name> [--update]"
exit 1
fi
# Initialize Wild Cloud environment
if [ -z "${WC_ROOT}" ]; then
echo "WC_ROOT is not set."
exit 1
else
source "${WC_ROOT}/scripts/common.sh"
init_wild_env
fi
SOURCE_APP_DIR="${WC_ROOT}/apps/${APP_NAME}"
if [ ! -d "${SOURCE_APP_DIR}" ]; then
echo "Error: App '${APP_NAME}' not found at ${SOURCE_APP_DIR}"
exit 1
fi
CACHE_APP_DIR=".wildcloud/cache/apps/${APP_NAME}"
mkdir -p ".wildcloud/cache/apps"
if [ -d "${CACHE_APP_DIR}" ]; then
if [ "${UPDATE}" = true ]; then
echo "Updating cached app '${APP_NAME}'"
rm -rf "${CACHE_APP_DIR}"
else
echo "Warning: Cache directory ${CACHE_APP_DIR} already exists"
read -p "Do you want to overwrite it? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Fetch cancelled"
exit 1
fi
rm -rf "${CACHE_APP_DIR}"
fi
fi
echo "Fetching app '${APP_NAME}' from ${SOURCE_APP_DIR} to ${CACHE_APP_DIR}"
# Create destination directory
mkdir -p "${CACHE_APP_DIR}"
# Copy directory structure and files (no template processing)
find "${SOURCE_APP_DIR}" -type d | while read -r src_dir; do
rel_path="${src_dir#${SOURCE_APP_DIR}}"
rel_path="${rel_path#/}" # Remove leading slash if present
if [ -n "${rel_path}" ]; then
mkdir -p "${CACHE_APP_DIR}/${rel_path}"
fi
done
find "${SOURCE_APP_DIR}" -type f | while read -r src_file; do
rel_path="${src_file#${SOURCE_APP_DIR}}"
rel_path="${rel_path#/}" # Remove leading slash if present
dest_file="${CACHE_APP_DIR}/${rel_path}"
# Ensure destination directory exists
dest_dir=$(dirname "${dest_file}")
mkdir -p "${dest_dir}"
# Simple copy without template processing
cp "${src_file}" "${dest_file}"
done
echo "Successfully fetched app '${APP_NAME}' to cache"

View File

@@ -186,8 +186,7 @@ elif [ "${OUTPUT_FORMAT}" = "table" ]; then
echo "Total installable apps: ${app_count}"
echo ""
echo "Usage:"
echo " wild-app-fetch <app> # Fetch app template to cache"
echo " wild-app-config <app> # Configure app with your settings"
echo " wild-app-add <app> # Configure app with your settings"
echo " wild-app-deploy <app> # Deploy app to Kubernetes"
fi

View File

@@ -62,7 +62,7 @@ prompt_if_unset_config "cluster.nodes.talos.version" "Talos version" "v1.11.0"
TALOS_VERSION=$(wild-config "cluster.nodes.talos.version")
# Talos schematic ID
prompt_if_unset_config "cluster.nodes.talos.schematicId" "Talos schematic ID" "56774e0894c8a3a3a9834a2aea65f24163cacf9506abbcbdc3ba135eaca4953f"
prompt_if_unset_config "cluster.nodes.talos.schematicId" "Talos schematic ID" "434a0300db532066f1098e05ac068159371d00f0aba0a3103a0e826e83825c82"
SCHEMATIC_ID=$(wild-config "cluster.nodes.talos.schematicId")
print_info "Creating custom Talos installer image..."

View File

@@ -1,170 +0,0 @@
#!/bin/bash
set -e
set -o pipefail
# Usage function
usage() {
echo "Usage: wild-cluster-node-patch-generate <node-name>"
echo ""
echo "Generate Talos machine configuration patches for a specific registered node."
echo ""
echo "Arguments:"
echo " node-name Name of the registered node"
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " wild-cluster-node-patch-generate control-1"
echo " wild-cluster-node-patch-generate worker-1"
echo ""
echo "This script will:"
echo " - Compile patch templates for the specified node"
echo " - Generate node-specific patch files in WC_HOME/setup/cluster-nodes/patch/"
echo " - Use hardware details from the node registration"
echo ""
echo "Requirements:"
echo " - Must be run from a wild-cloud directory"
echo " - Node must be registered (hardware detected) first"
echo " - Basic cluster configuration must be completed"
echo " - Patch templates must exist in WC_ROOT/setup/cluster-nodes/"
}
# Parse arguments
NODE_NAME=""
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
usage
exit 0
;;
-*)
echo "Unknown option $1"
usage
exit 1
;;
*)
if [ -z "$NODE_NAME" ]; then
NODE_NAME="$1"
else
echo "Unexpected argument: $1"
usage
exit 1
fi
shift
;;
esac
done
# Check if node name was provided
if [ -z "$NODE_NAME" ]; then
echo "Error: Node name is required"
usage
exit 1
fi
# Initialize Wild Cloud environment
if [ -z "${WC_ROOT}" ]; then
print "WC_ROOT is not set."
exit 1
else
source "${WC_ROOT}/scripts/common.sh"
init_wild_env
fi
prompt_if_unset_config "cluster.name" "Cluster name" "local.example.com"
# Function to ensure required directories exist in WC_HOME
ensure_required_directories() {
# Create output directories in WC_HOME for patch configs
mkdir -p "${WC_HOME}/setup/cluster-nodes/patch"
}
# =============================================================================
# PATCH GENERATION
# =============================================================================
print_header "Talos Machine Config Patch Generation"
# Ensure required directories exist in WC_HOME
ensure_required_directories
# Define directories
TEMPLATE_SOURCE_DIR="${WC_ROOT}/setup/cluster-nodes"
NODE_SETUP_DIR="${WC_HOME}/setup/cluster-nodes"
# Check if cluster has been initialized
if [ ! -f "${NODE_SETUP_DIR}/generated/secrets.yaml" ]; then
print_error "Cluster not initialized. Base cluster configuration is required."
print_info "Run 'wild-cluster-config-generate' first to generate cluster secrets and base configs"
exit 1
fi
# Get cluster configuration from config.yaml
CLUSTER_NAME=$(wild-config cluster.name)
print_info "Generating patch for node: $NODE_NAME"
print_info "Cluster: $CLUSTER_NAME"
# Check if the specified node is registered
NODE_INTERFACE=$(yq eval ".cluster.nodes.active.\"${NODE_NAME}\".interface" "${WC_HOME}/config.yaml" 2>/dev/null)
NODE_DISK=$(yq eval ".cluster.nodes.active.\"${NODE_NAME}\".disk" "${WC_HOME}/config.yaml" 2>/dev/null)
NODE_ROLE=$(yq eval ".cluster.nodes.active.\"${NODE_NAME}\".role" "${WC_HOME}/config.yaml" 2>/dev/null)
NODE_CURRENT_IP=$(yq eval ".cluster.nodes.active.\"${NODE_NAME}\".currentIp" "${WC_HOME}/config.yaml" 2>/dev/null)
if [ -z "$NODE_INTERFACE" ] || [ "$NODE_INTERFACE" = "null" ]; then
print_error "Node $NODE_NAME is not registered in config.yaml"
print_info "Please register the node first by running node hardware detection"
print_info "Or run 'wild-setup-cluster' to register nodes interactively"
exit 1
fi
# Get current IP for the node
if [ -z "$NODE_CURRENT_IP" ] || [ "$NODE_CURRENT_IP" = "null" ]; then
print_error "Node $NODE_NAME has no current IP address set"
exit 1
fi
# Determine node type
if [ "$NODE_ROLE" = "controlplane" ]; then
NODE_TYPE="control"
print_success "Registered control plane node: $NODE_NAME"
else
NODE_TYPE="worker"
print_success "Registered worker node: $NODE_NAME"
fi
print_info "Node details:"
print_info " - Name: $NODE_NAME"
print_info " - Current IP: $NODE_CURRENT_IP"
print_info " - Interface: $NODE_INTERFACE"
print_info " - Disk: $NODE_DISK"
print_info " - Type: $NODE_TYPE"
# Compile patch template for the specified node
print_info "Compiling patch template for $NODE_TYPE node $NODE_NAME..."
if [ "$NODE_TYPE" = "control" ]; then
TEMPLATE_FILE="${TEMPLATE_SOURCE_DIR}/patch.templates/controlplane.yaml"
else
TEMPLATE_FILE="${TEMPLATE_SOURCE_DIR}/patch.templates/worker.yaml"
fi
# Use node name as the patch name
PATCH_FILE="${NODE_SETUP_DIR}/patch/${NODE_NAME}.yaml"
# Create a temporary template with the node name and IP for gomplate processing
TEMP_TEMPLATE="/tmp/${NODE_NAME//\//_}-$(date +%s).yaml"
sed -e "s/{{NODE_NAME}}/${NODE_NAME}/g" -e "s/{{NODE_IP}}/${NODE_CURRENT_IP}/g" "$TEMPLATE_FILE" > "$TEMP_TEMPLATE"
cat "$TEMP_TEMPLATE" | wild-compile-template > "$PATCH_FILE"
rm -f "$TEMP_TEMPLATE"
print_success "Patch generated successfully!"
echo ""
print_info "Generated patch file:"
print_info " - $PATCH_FILE"
echo ""
print_info "Template used: ${TEMPLATE_FILE}"
print_success "Patch generation completed!"

View File

@@ -1,267 +0,0 @@
#!/bin/bash
set -e
set -o pipefail
# Usage function
usage() {
echo "Usage: wild-cluster-node-up <node-name> [options]"
echo ""
echo "Apply Talos machine configuration to a registered node."
echo ""
echo "Arguments:"
echo " node-name Name of the registered node"
echo ""
echo "Options:"
echo " -i, --insecure Apply configuration in insecure mode (for maintenance mode nodes)"
echo " --force Force regeneration of final config even if it exists"
echo " --dry-run Show the command that would be executed without running it"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " wild-cluster-node-up control-1"
echo " wild-cluster-node-up worker-1 --insecure"
echo " wild-cluster-node-up worker-2 --skip-patch"
echo " wild-cluster-node-up control-2 --force"
echo " wild-cluster-node-up control-1 --dry-run"
echo ""
echo "This script will:"
echo " - Verify the node is registered in config.yaml"
echo " - Generate final machine configuration if needed"
echo " - Apply the configuration using talosctl apply-config"
echo " - Use insecure mode for nodes in maintenance mode"
echo ""
echo "Requirements:"
echo " - Must be run from a wild-cloud directory"
echo " - Node must be registered (hardware detected) first"
echo " - Base cluster configuration and patch file must exist for the node"
}
# Parse arguments
NODE_NAME=""
INSECURE_MODE=false
DRY_RUN=false
SKIP_PATCH=false
FORCE_REGENERATE=false
while [[ $# -gt 0 ]]; do
case $1 in
-i|--insecure)
INSECURE_MODE=true
shift
;;
--force)
FORCE_REGENERATE=true
shift
;;
--dry-run)
DRY_RUN=true
shift
;;
-h|--help)
usage
exit 0
;;
-*)
echo "Unknown option $1"
usage
exit 1
;;
*)
if [ -z "$NODE_NAME" ]; then
NODE_NAME="$1"
else
echo "Unexpected argument: $1"
usage
exit 1
fi
shift
;;
esac
done
# Check if node name was provided
if [ -z "$NODE_NAME" ]; then
echo "Error: Node name is required"
usage
exit 1
fi
# Initialize Wild Cloud environment
if [ -z "${WC_ROOT}" ]; then
print "WC_ROOT is not set."
exit 1
else
source "${WC_ROOT}/scripts/common.sh"
init_wild_env
fi
print_header "Talos node configuration"
# Check if the specified node is registered
NODE_INTERFACE=$(yq eval ".cluster.nodes.active.\"${NODE_NAME}\".interface" "${WC_HOME}/config.yaml" 2>/dev/null)
NODE_DISK=$(yq eval ".cluster.nodes.active.\"${NODE_NAME}\".disk" "${WC_HOME}/config.yaml" 2>/dev/null)
NODE_ROLE=$(yq eval ".cluster.nodes.active.\"${NODE_NAME}\".role" "${WC_HOME}/config.yaml" 2>/dev/null)
NODE_CURRENT_IP=$(yq eval ".cluster.nodes.active.\"${NODE_NAME}\".currentIp" "${WC_HOME}/config.yaml" 2>/dev/null)
MAINTENANCE_IP=$(yq eval ".cluster.nodes.active.\"${NODE_NAME}\".maintenanceIp" "${WC_HOME}/config.yaml" 2>/dev/null)
if [ -z "$NODE_INTERFACE" ] || [ "$NODE_INTERFACE" = "null" ]; then
print_error "Node $NODE_NAME is not registered in config.yaml"
print_info "Please register the node first by running:"
print_info "Or run 'wild-setup-cluster' to register nodes interactively"
exit 1
fi
# Get current IP for the node
if [ -z "$NODE_CURRENT_IP" ] || [ "$NODE_CURRENT_IP" = "null" ]; then
print_error "Node $NODE_NAME has no current IP address set"
exit 1
fi
# Determine node type
if [ "$NODE_ROLE" = "controlplane" ]; then
NODE_TYPE="control plane"
else
NODE_TYPE="worker"
fi
# Determine the target IP for applying configuration
if [ -n "$MAINTENANCE_IP" ] && [ "$MAINTENANCE_IP" != "null" ]; then
TARGET_IP="$MAINTENANCE_IP"
print_info "Applying configuration to $NODE_TYPE node: $NODE_NAME ($NODE_CURRENT_IP) via maintenance IP: $MAINTENANCE_IP"
# Auto-enable insecure mode when using maintenance IP (unless explicitly overridden)
if [ "$INSECURE_MODE" = false ]; then
INSECURE_MODE=true
print_info "Auto-enabling insecure mode for maintenance IP"
fi
else
TARGET_IP="$NODE_CURRENT_IP"
print_info "Applying configuration to $NODE_TYPE node: $NODE_NAME ($NODE_CURRENT_IP)"
fi
print_info "Node details:"
print_info " - Name: $NODE_NAME"
print_info " - Current IP: $NODE_CURRENT_IP"
print_info " - Interface: $NODE_INTERFACE"
print_info " - Disk: $NODE_DISK"
print_info " - Type: $NODE_TYPE"
if [ -n "$MAINTENANCE_IP" ] && [ "$MAINTENANCE_IP" != "null" ]; then
print_info " - Maintenance IP: $MAINTENANCE_IP"
fi
# Check if machine config exists, generate if needed
NODE_SETUP_DIR="${WC_HOME}/setup/cluster-nodes"
CONFIG_FILE="${NODE_SETUP_DIR}/final/${NODE_NAME}.yaml"
PATCH_FILE="${NODE_SETUP_DIR}/patch/${NODE_NAME}.yaml"
# Check if patch file exists
if [ ! -f "$PATCH_FILE" ]; then
wild-cluster-node-patch-generate "$NODE_NAME"
fi
# Determine base config file
if [ "$NODE_ROLE" = "controlplane" ]; then
BASE_CONFIG="${NODE_SETUP_DIR}/generated/controlplane.yaml"
else
BASE_CONFIG="${NODE_SETUP_DIR}/generated/worker.yaml"
fi
# Check if base config exists
if [ ! -f "$BASE_CONFIG" ]; then
print_error "Base configuration not found: $BASE_CONFIG"
print_info "Generate base cluster configuration first:"
print_info " wild-cluster-config-generate"
exit 1
fi
# Check if we should skip regeneration
if [ ! -f "$CONFIG_FILE" ] || [ "$FORCE_REGENERATE" = true ]; then
# Need to generate/regenerate the final config
if [ "$FORCE_REGENERATE" = true ]; then
print_info "Force regeneration requested: regenerating machine configuration..."
else
print_info "Machine configuration not found: $CONFIG_FILE"
print_info "Generating final machine configuration..."
fi
# Create final config directory if it doesn't exist
mkdir -p "${NODE_SETUP_DIR}/final"
# Generate final machine config
print_info "Generating final machine configuration from patch..."
talosctl machineconfig patch "$BASE_CONFIG" --patch @"$PATCH_FILE" -o "$CONFIG_FILE"
print_success "Generated machine configuration: $CONFIG_FILE"
else
print_success "Found existing machine configuration: $CONFIG_FILE"
fi
# Build talosctl command
TALOSCTL_CMD="talosctl apply-config"
if [ "$INSECURE_MODE" = true ]; then
TALOSCTL_CMD="$TALOSCTL_CMD --insecure"
print_info "Using insecure mode (for maintenance mode nodes)"
fi
TALOSCTL_CMD="$TALOSCTL_CMD --nodes $TARGET_IP --file $CONFIG_FILE"
# Show the command
echo ""
print_info "Command to execute:"
echo " $TALOSCTL_CMD"
echo ""
if [ "$DRY_RUN" = true ]; then
print_info "Dry run mode - command shown above but not executed"
exit 0
fi
# Apply the configuration
print_info "Applying machine configuration..."
echo ""
if eval "$TALOSCTL_CMD"; then
print_success "Machine configuration applied successfully!"
# Update talosctl context to this node
print_info "Updating talosctl context..."
talosctl config node "$NODE_CURRENT_IP"
print_success "Updated talosctl context to node $NODE_NAME ($NODE_CURRENT_IP)"
echo ""
if [ "$NODE_ROLE" = "controlplane" ]; then
print_info "Next steps for control plane node:"
echo " 1. Wait for the node to reboot and come up with the new configuration"
echo " 2. If this is your first control plane node, bootstrap it:"
echo " talosctl bootstrap --nodes $NODE_CURRENT_IP"
echo " 3. Get kubeconfig when cluster is ready:"
echo " talosctl kubeconfig"
else
print_info "Next steps for worker node:"
echo " 1. Wait for the node to reboot and come up with the new configuration"
echo " 2. Node will join the cluster automatically"
echo " 3. Verify the node appears in the cluster:"
echo " kubectl get nodes"
fi
echo ""
print_info "Monitor node status with:"
echo " talosctl --nodes $NODE_CURRENT_IP dmesg"
echo " talosctl --nodes $NODE_CURRENT_IP get members"
else
print_error "Failed to apply machine configuration"
echo ""
print_info "Troubleshooting tips:"
if [ -n "$MAINTENANCE_IP" ] && [ "$MAINTENANCE_IP" != "null" ]; then
echo " - Ensure the node is accessible at maintenance IP $MAINTENANCE_IP"
else
echo " - Ensure the node is accessible at $NODE_CURRENT_IP"
fi
echo " - For nodes in maintenance mode, use --insecure flag"
echo " - Check network connectivity and firewall settings"
echo " - Verify the machine configuration file is valid"
exit 1
fi
print_success "Node configuration completed!"

View File

@@ -32,13 +32,17 @@ else
init_wild_env
fi
# ---
# Config
prompt_if_unset_config "cloud.dns.ip" "The IP address of your wild cloud DNS server" ""
prompt_if_unset_config "cloud.dnsmasq.interface" "The network interface for your wild cloud DNS server" "eth0"
prompt_if_unset_config "cluster.loadBalancerIp" "The IP address for your cluster load balancer" ""
prompt_if_unset_config "cloud.router.ip" "The IP address for your LAN router" "192.168.8.1"
prompt_if_unset_config "cloud.dhcpRange" "The DHCP range for your wild cloud network" ""
SOURCE_DIR="${WC_ROOT}/setup/dnsmasq"
DNSMASQ_SETUP_DIR="${WC_HOME}/setup/dnsmasq"
BUNDLE_DIR="${DNSMASQ_SETUP_DIR}/setup-bundle"
mkdir -p "${BUNDLE_DIR}"
# Create local templates.
@@ -58,6 +62,7 @@ if [ -d "${DNSMASQ_SETUP_DIR}" ]; then
echo "Successfully created dnsmasq setup files from templates."
fi
else
mkdir -p "${BUNDLE_DIR}"
cp -r "${SOURCE_DIR}" "${DNSMASQ_SETUP_DIR}"
find "${DNSMASQ_SETUP_DIR}" -type f \( -name "*.yaml" -o -name "*.ipxe" -o -name "*.conf" \) | while read -r file; do
echo "Processing: ${file}"
@@ -65,6 +70,7 @@ else
done
echo "Successfully created dnsmasq setup files from templates."
fi
mkdir -p "${BUNDLE_DIR}"
# Create setup bundle.

View File

@@ -102,14 +102,15 @@ prompt_if_unset_config "cloud.internalDomain" "Your internal cloud domain" "inte
prompt_if_unset_config "cloud.backup.root" "Existing path to save backups to" ""
# Derive cluster name from domain if not already set
current_cluster_name=$(wild-config "cluster.name")
if [ -z "$current_cluster_name" ] || [ "$current_cluster_name" = "null" ]; then
if wild-config "cluster.name" --check; then
echo "Cluster name: $(wild-config "cluster.name")"
else
echo "Cluster name is already set to: $(wild-config "cluster.name")"
cluster_name=$(echo "${domain}" | tr '.' '-' | tr '[:upper:]' '[:lower:]')
wild-config-set "cluster.name" "${cluster_name}"
print_info "Set cluster name to: ${cluster_name}"
fi
# =============================================================================
# COPY SCAFFOLD
# =============================================================================

View File

@@ -26,7 +26,7 @@ usage() {
echo " - Return JSON with hardware information"
echo ""
echo "Output JSON format:"
echo ' {"interface": "eth0", "disks": ["/dev/sda", "/dev/nvme0n1"], "selected_disk": "/dev/sda"}'
echo ' {"interface": "eth0", "disks": ["/dev/sda", "/dev/nvme0n1"], "selected_disk": "/dev/sda", "maintenance_mode": true}'
}
# Parse arguments
@@ -152,12 +152,19 @@ echo "✅ Discovered $(echo "$AVAILABLE_DISKS" | jq -r 'length') suitable disks"
echo "✅ Selected disk: $SELECTED_DISK" >&2
# Output JSON to stdout
MAINTENANCE_MODE_BOOL="false"
if [ "$TALOS_MODE" = "insecure" ]; then
MAINTENANCE_MODE_BOOL="true"
fi
jq -n \
--arg interface "$ACTIVE_INTERFACE" \
--argjson disks "$AVAILABLE_DISKS" \
--arg selected_disk "$SELECTED_DISK" \
--argjson maintenance_mode "$MAINTENANCE_MODE_BOOL" \
'{
interface: $interface,
disks: $disks,
selected_disk: $selected_disk
selected_disk: $selected_disk,
maintenance_mode: $maintenance_mode
}'

313
bin/wild-node-setup Executable file
View File

@@ -0,0 +1,313 @@
#!/bin/bash
# Set up configuration variables.
# Generate Talos machine configuration
# Apply configuration to node
set -e
set -o pipefail
# Usage function
usage() {
echo "Usage: wild-node-setup <node-name> [options]"
echo ""
echo "Complete node lifecycle management - configure → patch → deploy"
echo ""
echo "Arguments:"
echo " node-name Name of the node to setup"
echo ""
echo "Options:"
echo " --reconfigure Force node reconfiguration"
echo " --no-deploy Generate Talos machine configuration only, skip deployment"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " wild-node-setup control-1"
echo " wild-node-setup worker-1 --reconfigure"
echo " wild-node-setup control-2 --no-deploy"
echo ""
echo "This script handles the complete node setup lifecycle:"
echo " 1. Node configuration (if needed or --reconfigure specified)"
echo " 2. Generate node-specific configuration patch"
echo " 3. Create final machine configuration"
echo " 4. Deploy configuration to node (unless --no-deploy)"
echo ""
echo "Requirements:"
echo " - Must be run from a Wild Cloud home directory"
echo " - Cluster must be initialized (wild-cluster-config-generate)"
echo " - Node must be accessible for configuration"
}
# Parse arguments
NODE_NAME=""
FORCE_CONFIG=false
NO_DEPLOY=false
while [[ $# -gt 0 ]]; do
case $1 in
--reconfigure)
FORCE_CONFIG=true
shift
;;
--no-deploy)
NO_DEPLOY=true
shift
;;
-h|--help)
usage
exit 0
;;
-*)
echo "Unknown option $1"
usage
exit 1
;;
*)
if [ -z "$NODE_NAME" ]; then
NODE_NAME="$1"
else
echo "Unexpected argument: $1"
usage
exit 1
fi
shift
;;
esac
done
# Initialize Wild Cloud environment
if [ -z "${WC_ROOT}" ]; then
echo "ERROR: WC_ROOT is not set."
exit 1
else
source "${WC_ROOT}/scripts/common.sh"
init_wild_env
fi
# Check if node name was provided
if [ -z "$NODE_NAME" ]; then
print_error "Node name is required"
usage
exit 1
fi
print_header "Wild Cloud Node Setup: $NODE_NAME"
# =============================================================================
# PREREQUISITES
# =============================================================================
# Check if cluster has been initialized
NODE_SETUP_DIR="${WC_HOME}/setup/cluster-nodes"
if [ ! -f "${NODE_SETUP_DIR}/generated/secrets.yaml" ]; then
print_error "Cluster not initialized. Run 'wild-cluster-config-generate' first"
exit 1
fi
# Get cluster configuration
CLUSTER_NAME=$(wild-config cluster.name)
print_info "Cluster: $CLUSTER_NAME"
# =============================================================================
# NODE DETECTION
# =============================================================================
print_info "Detecting node: $NODE_NAME"
# Get target IP for detection
if wild-config --check "cluster.nodes.active.${NODE_NAME}.targetIp"; then
TARGET_IP=$(wild-config "cluster.nodes.active.${NODE_NAME}.targetIp")
else
read -p "Enter target IP address for node $NODE_NAME: " -r TARGET_IP
if [ -z "$TARGET_IP" ]; then
print_error "IP address is required for node detection"
exit 1
fi
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".targetIp" "$TARGET_IP"
fi
# Try detection at target IP, fallback to current IP if needed
if NODE_INFO=$(wild-node-detect "$TARGET_IP" 2>/dev/null); then
DETECTION_IP="$TARGET_IP"
else
read -p "Enter current IP for this node (maintenance mode): " -r CURRENT_IP
if [ -z "$CURRENT_IP" ]; then
print_error "Current IP is required for maintenance mode detection"
exit 1
fi
if NODE_INFO=$(wild-node-detect "$CURRENT_IP" 2>/dev/null); then
DETECTION_IP="$CURRENT_IP"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".currentIp" "$CURRENT_IP"
else
print_error "Failed to detect node"
exit 1
fi
fi
# Parse node information
MAINTENANCE_MODE=$(echo "$NODE_INFO" | jq -r '.maintenance_mode')
# =============================================================================
# NODE CONFIGURATION
# =============================================================================
if [ "$FORCE_CONFIG" = true ] || \
! wild-config --check "cluster.nodes.active.${NODE_NAME}.interface" || \
! wild-config --check "cluster.nodes.active.${NODE_NAME}.disk"; then
print_header "Node Configuration: $NODE_NAME"
# Parse hardware information and select disk
INTERFACE=$(echo "$NODE_INFO" | jq -r '.interface')
SELECTED_DISK=$(echo "$NODE_INFO" | jq -r '.selected_disk')
# Find default disk number
DEFAULT_NUM=$(echo "$NODE_INFO" | jq -r --arg disk "$SELECTED_DISK" '.disks | to_entries | map(select(.value.path == $disk)) | .[0].key // empty')
DEFAULT_NUM=$((DEFAULT_NUM + 1))
echo ""
echo "Available disks:"
echo "$NODE_INFO" | jq -r '.disks[] | "\(.path) (\((.size / 1000000000) | floor)GB)"' | nl -w2 -s') '
while true; do
read -p "Select disk [default: $DEFAULT_NUM]: " -r disk_num
if [ -z "$disk_num" ]; then
disk_num=$DEFAULT_NUM
fi
SELECTED_DISK=$(echo "$NODE_INFO" | jq -r ".disks[$((disk_num-1))].path")
if [ "$SELECTED_DISK" != "null" ] && [ -n "$SELECTED_DISK" ]; then
break
fi
echo "Invalid selection. Please enter a number from the list above."
done
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".interface" "$INTERFACE"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".disk" "$SELECTED_DISK"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".currentIp" "$TARGET_IP"
# Set node defaults if not configured
if ! wild-config --check "cluster.nodes.active.${NODE_NAME}.role"; then
wild-config-set "cluster.nodes.active.${NODE_NAME}.role" "worker"
fi
if ! wild-config --check "cluster.nodes.active.${NODE_NAME}.version"; then
default_version=$(wild-config "cluster.nodes.talos.version")
wild-config-set "cluster.nodes.active.${NODE_NAME}.version" "$default_version"
fi
if ! wild-config --check "cluster.nodes.active.${NODE_NAME}.schematicId"; then
default_schematic_id=$(wild-config "cluster.nodes.talos.schematicId")
wild-config-set "cluster.nodes.active.${NODE_NAME}.schematicId" "$default_schematic_id"
fi
fi
# =============================================================================
# CONFIGURATION GENERATION
# =============================================================================
print_header "Configuration Generation: $NODE_NAME"
# Get node configuration
NODE_ROLE=$(wild-config "cluster.nodes.active.${NODE_NAME}.role")
NODE_IP=$(wild-config "cluster.nodes.active.${NODE_NAME}.targetIp")
NODE_INTERFACE=$(wild-config "cluster.nodes.active.${NODE_NAME}.interface")
NODE_DISK=$(wild-config "cluster.nodes.active.${NODE_NAME}.disk")
NODE_VERSION=$(wild-config "cluster.nodes.active.${NODE_NAME}.version")
NODE_SCHEMATIC_ID=$(wild-config "cluster.nodes.active.${NODE_NAME}.schematicId")
print_info "Node configuration:"
print_info " - Name: $NODE_NAME"
print_info " - Role: $NODE_ROLE"
print_info " - IP: $NODE_IP"
print_info " - Interface: $NODE_INTERFACE"
print_info " - Disk: $NODE_DISK"
print_info " - Talos Version: $NODE_VERSION"
print_info " - Schematic ID: $NODE_SCHEMATIC_ID"
# Determine base configuration file
if [ "$NODE_ROLE" = "controlplane" ]; then
BASE_CONFIG="${NODE_SETUP_DIR}/generated/controlplane.yaml"
TEMPLATE_FILE="${WC_ROOT}/setup/cluster-nodes/patch.templates/controlplane.yaml"
else
BASE_CONFIG="${NODE_SETUP_DIR}/generated/worker.yaml"
TEMPLATE_FILE="${WC_ROOT}/setup/cluster-nodes/patch.templates/worker.yaml"
fi
# Check if base config exists
if [ ! -f "$BASE_CONFIG" ]; then
print_error "Base configuration not found: $BASE_CONFIG"
print_info "Run 'wild-cluster-config-generate' first"
exit 1
fi
# Generate patch file
print_info "Generating node-specific patch..."
mkdir -p "${NODE_SETUP_DIR}/patch"
PATCH_FILE="${NODE_SETUP_DIR}/patch/${NODE_NAME}.yaml"
TEMP_TEMPLATE="/tmp/${NODE_NAME//\//_}-$(date +%s).yaml"
# Apply variable substitutions to template
sed -e "s/{{NODE_NAME}}/${NODE_NAME}/g" \
-e "s/{{NODE_IP}}/${NODE_IP}/g" \
-e "s/{{SCHEMATIC_ID}}/${NODE_SCHEMATIC_ID}/g" \
-e "s/{{VERSION}}/${NODE_VERSION}/g" "$TEMPLATE_FILE" > "$TEMP_TEMPLATE"
# Process template with gomplate
if ! cat "$TEMP_TEMPLATE" | wild-compile-template > "$PATCH_FILE"; then
rm -f "$TEMP_TEMPLATE"
print_error "Failed to compile patch template for $NODE_NAME"
exit 1
fi
rm -f "$TEMP_TEMPLATE"
print_success "Generated patch file: $PATCH_FILE"
# Generate final machine configuration
print_info "Generating final machine configuration..."
mkdir -p "${NODE_SETUP_DIR}/final"
CONFIG_FILE="${NODE_SETUP_DIR}/final/${NODE_NAME}.yaml"
if ! talosctl machineconfig patch "$BASE_CONFIG" --patch @"$PATCH_FILE" -o "$CONFIG_FILE"; then
print_error "Failed to generate final machine configuration"
exit 1
fi
print_success "Generated final configuration: $CONFIG_FILE"
# =============================================================================
# DEPLOYMENT
# =============================================================================
if [ "$NO_DEPLOY" = true ]; then
print_success "Configuration generated (--no-deploy specified)"
exit 0
fi
print_header "Configuration Deployment: $NODE_NAME"
# Apply configuration using detected node information
TALOSCTL_CMD="talosctl apply-config --nodes $DETECTION_IP --file $CONFIG_FILE"
if [ "$MAINTENANCE_MODE" = "true" ]; then
TALOSCTL_CMD="$TALOSCTL_CMD --insecure"
fi
if eval "$TALOSCTL_CMD"; then
print_success "Configuration applied successfully to $NODE_NAME"
else
print_error "Failed to apply machine configuration"
exit 1
fi
print_info "Waiting 10 seconds for node to stabilize..."
sleep 10
if talosctl config node "$TARGET_IP"; then
print_success "Node setup completed for $NODE_NAME!"
else
print_error "Node setup failed for $NODE_NAME!"
exit 1
fi
exit 0

View File

@@ -5,15 +5,10 @@ set -o pipefail
# Parse arguments
SKIP_INSTALLER=false
SKIP_HARDWARE=false
while [[ $# -gt 0 ]]; do
case $1 in
--skip-installer)
SKIP_INSTALLER=true
shift
;;
--skip-hardware)
SKIP_HARDWARE=true
shift
@@ -26,7 +21,6 @@ while [[ $# -gt 0 ]]; do
echo "Control Options:"
echo " --skip-installer Skip Installer image generation"
echo " --skip-hardware Skip Node hardware detection"
echo " --skip-configs Skip Machine config generation"
echo " -h, --help Show this help message"
echo ""
echo "Prerequisites:"
@@ -54,7 +48,7 @@ done
# Initialize Wild Cloud environment
if [ -z "${WC_ROOT}" ]; then
print "WC_ROOT is not set."
echo "ERROR: WC_ROOT is not set."
exit 1
else
source "${WC_ROOT}/scripts/common.sh"
@@ -136,310 +130,280 @@ fi
# =============================================================================
if [ "${SKIP_HARDWARE}" = false ]; then
print_header "Control node registration"
print_header "Control Plane Node Setup"
# Automatically configure the first three IPs after VIP for control plane nodes
vip_last_octet=$(echo "$vip" | cut -d. -f4)
vip_prefix=$(echo "$vip" | cut -d. -f1-3)
# Process each control plane node
# Set up control plane nodes
for i in 1 2 3; do
NODE_NAME="${HOSTNAME_PREFIX}control-${i}"
TARGET_IP="${vip_prefix}.$(( vip_last_octet + i ))"
print_info "Checking for control plane node: $NODE_NAME (IP: $TARGET_IP)"
if wild-config --check "cluster.nodes.active.${NODE_NAME}.interface"; then
print_success "Node $NODE_NAME already registered."
continue
fi
print_info "Setting up control plane node: $NODE_NAME (IP: $TARGET_IP)"
if ! wild-config --check "cluster.nodes.active.${NODE_NAME}.role"; then
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".role" "controlplane"
fi
if ! wild-config --check "cluster.nodes.active.${NODE_NAME}.targetIp"; then
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".targetIp" "$TARGET_IP"
fi
print_info "${NODE_NAME} not found. Please ensure the node is powered on and running Talos in maintenance mode."
read -p "Is $NODE_NAME in maintenance mode now? (Y/n): " -r register_node
if [[ $register_node =~ ^[Nn]$ ]]; then
print_info "Skipping bringing up node $NODE_NAME registration"
continue
fi
# Detect node hardware
print_info "Attempting detection at target IP $TARGET_IP..."
DETECTION_IP="$TARGET_IP"
NODE_INFO=""
if wild-node-detect "$TARGET_IP" >/dev/null 2>&1; then
NODE_INFO=$(wild-node-detect "$TARGET_IP")
print_success "Node detected at target IP $TARGET_IP"
else
# Fall back to current IP
print_warning "Node not accessible at target IP $TARGET_IP"
read -p "Enter current IP for this node: " -r CURRENT_IP
if [ -z "$CURRENT_IP" ]; then
print_warning "Skipping node $NODE_NAME registration"
continue
fi
print_info "Attempting detection at current IP $CURRENT_IP..."
if wild-node-detect "$CURRENT_IP" >/dev/null 2>&1; then
NODE_INFO=$(wild-node-detect "$CURRENT_IP")
DETECTION_IP="$CURRENT_IP"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".currentIp" "$CURRENT_IP"
print_success "Node detected at current IP $CURRENT_IP"
else
print_error "Failed to detect node at $CURRENT_IP"
continue
fi
fi
if ! [ -n "$NODE_INFO" ]; then
print_error "No hardware information received from node"
continue
fi
INTERFACE=$(echo "$NODE_INFO" | jq -r '.interface')
SELECTED_DISK=$(echo "$NODE_INFO" | jq -r '.selected_disk')
AVAILABLE_DISKS=$(echo "$NODE_INFO" | jq -r '.disks[] | "\(.path) (\((.size / 1000000000) | floor)GB)"' | paste -sd, -)
print_success "Hardware detected:"
print_info " - Interface: $INTERFACE"
print_info " - Available disks: $AVAILABLE_DISKS"
print_info " - Selected disk: $SELECTED_DISK"
# User system disk selection
echo ""
read -p "Use selected disk '$SELECTED_DISK'? (Y/n): " -r use_disk
if [[ $use_disk =~ ^[Nn]$ ]]; then
echo "Available disks:"
echo "$NODE_INFO" | jq -r '.disks[] | "\(.path) (\((.size / 1000000000) | floor)GB)"' | nl -w2 -s') '
read -p "Enter disk number: " -r disk_num
SELECTED_DISK=$(echo "$NODE_INFO" | jq -r ".disks[$((disk_num-1))].path")
if [ "$SELECTED_DISK" = "null" ] || [ -z "$SELECTED_DISK" ]; then
print_error "Invalid disk selection"
continue
fi
print_info "Selected disk: $SELECTED_DISK"
fi
# Update config.yaml with hardware info.
print_info "Updating configuration for $NODE_NAME..."
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".interface" "$INTERFACE"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".disk" "$SELECTED_DISK"
# Copy current Talos version and schematic ID to this node
# Pre-configure node role and target IP
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".role" "controlplane"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".targetIp" "$TARGET_IP"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".version" "$talos_version"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".schematicId" "$schematic_id"
# The node is now configured. Bring it up.
echo ""
read -p "Bring node $NODE_NAME ($TARGET_IP) up now? (Y/n): " -r apply_config
if [[ ! $apply_config =~ ^[Nn]$ ]]; then
if [ "$DETECTION_IP" != "$TARGET_IP" ]; then
# Node is in maintenance mode, use insecure flag
print_info "Applying configuration in insecure mode (maintenance mode)..."
wild-cluster-node-up "$NODE_NAME" --insecure
else
# Node is already up, no insecure flag needed
print_info "Applying configuration..."
wild-cluster-node-up "$NODE_NAME" --force
fi
# Bootstrap the cluster after the first node is up.
if [ "$i" -eq 1 ]; then
read -p "The cluster should be bootstrapped after the first control node is ready. Is it ready? (Y/n): " -r is_ready
if [[ ! $is_ready =~ ^[Nn]$ ]]; then
print_info "Bootstrapping control plane node $TARGET_IP..."
talosctl config endpoint "$TARGET_IP"
# Attempt to bootstrap the cluster
if talosctl bootstrap --nodes "$TARGET_IP" 2>&1 | tee /tmp/bootstrap_output.log; then
print_success "Control plane node $TARGET_IP bootstrapped successfully!"
else
# Check if the error is because it's already bootstrapped
if grep -q "etcd data directory is not empty\|AlreadyExists" /tmp/bootstrap_output.log; then
print_info "Cluster is already bootstrapped on $TARGET_IP"
else
print_error "Failed to bootstrap control plane node $TARGET_IP"
print_info "Bootstrap output:"
cat /tmp/bootstrap_output.log
rm -f /tmp/bootstrap_output.log
continue
fi
fi
rm -f /tmp/bootstrap_output.log
# Wait for VIP to become available before using it
print_info "Waiting for VIP $vip to become available..."
max_attempts=30
attempt=1
vip_ready=false
while [ $attempt -le $max_attempts ]; do
if ping -c 1 -W 2 "$vip" >/dev/null 2>&1; then
# VIP responds to ping, now test Talos API
if talosctl -e "$vip" -n "$vip" version >/dev/null 2>&1; then
print_success "VIP $vip is ready (attempt $attempt/$max_attempts)"
vip_ready=true
break
fi
fi
print_info "VIP not ready, waiting... (attempt $attempt/$max_attempts)"
sleep 2
attempt=$((attempt + 1))
done
if [ "$vip_ready" = true ]; then
talosctl config endpoint "$vip"
print_info "Talos endpoint set to control plane VIP: $vip"
if talosctl kubeconfig "$vip"; then
print_success "Talos kubeconfig updated for control plane VIP: $vip"
else
print_error "Failed to get kubeconfig from VIP: $vip"
print_info "You can try again later with: talosctl kubeconfig $vip"
fi
else
print_error "VIP $vip did not become available after $max_attempts attempts"
print_warning "Falling back to direct node access"
print_info "Talos endpoint remains set to: $TARGET_IP"
print_info "You can try switching to VIP later with: talosctl config endpoint $vip"
fi
# Check if node is already configured
if wild-config --check "cluster.nodes.active.${NODE_NAME}.interface"; then
print_success "Node $NODE_NAME already configured"
echo ""
read -p "Re-deploy node $NODE_NAME? (y/N): " -r redeploy_node
if [[ $redeploy_node =~ ^[Yy]$ ]]; then
if ! wild-node-setup "$NODE_NAME"; then
print_error "Failed to set up node $NODE_NAME"
continue
fi
else
continue
fi
else
# Node needs initial setup
print_info "Node $NODE_NAME requires hardware detection and setup"
echo ""
read -p "Set up node $NODE_NAME now? (Y/n): " -r setup_node
if [[ $setup_node =~ ^[Nn]$ ]]; then
print_info "Skipping node $NODE_NAME setup"
continue
fi
else
print_info "Configuration not applied. You can apply it later with:"
print_info " wild-cluster-node-up $NODE_NAME --insecure"
# Run complete node setup
if ! wild-node-setup "$NODE_NAME"; then
print_error "Failed to set up node $NODE_NAME"
print_info "You can retry later with: wild-node-setup $NODE_NAME"
continue
fi
fi
# Bootstrap the cluster after the first node is up
if [ "$i" -eq 1 ]; then
echo ""
read -p "Bootstrap the cluster on $NODE_NAME? (Y/n): " -r bootstrap_cluster
if [[ ! $bootstrap_cluster =~ ^[Nn]$ ]]; then
print_header "Bootstrapping Cluster: $NODE_NAME"
talosctl config endpoint "$TARGET_IP"
if talosctl bootstrap --nodes "$TARGET_IP" 2>&1 | tee /tmp/bootstrap_output.log; then
print_success "Cluster bootstrap initiated successfully."
else
if grep -q "etcd data directory is not empty\|AlreadyExists" /tmp/bootstrap_output.log; then
print_info "Cluster is already bootstrapped."
else
print_error "Failed to bootstrap cluster."
print_info "Bootstrap output:"
cat /tmp/bootstrap_output.log
rm -f /tmp/bootstrap_output.log
continue
fi
fi
mv -f /tmp/bootstrap_output.log /tmp/bootstrap_output_success.log
# Step 1: Verify etcd cluster health
print_info -n "Step 1/6: Verifying etcd cluster health."
max_attempts=30
for attempt in $(seq 1 $max_attempts); do
if talosctl -n "$TARGET_IP" etcd status >/dev/null 2>&1; then
echo ""
print_success "etcd cluster is healthy."
break
fi
if [ $attempt -eq $max_attempts ]; then
echo ""
print_error "etcd cluster not healthy after $max_attempts attempts."
print_info "Troubleshooting steps:"
print_info " 1. Check etcd service: talosctl -n $TARGET_IP service etcd"
print_info " 2. Check etcd logs: talosctl -n $TARGET_IP logs etcd"
print_info " 3. Check etcd status details: talosctl -n $TARGET_IP etcd status"
print_info " 4. Verify bootstrap completed: talosctl -n $TARGET_IP get members"
exit 1
fi
printf "."
sleep 10
done
# Step 2: Wait for VIP to be assigned to interface
print_info -n "Step 2/6: Waiting for VIP $vip to be assigned to interface."
max_attempts=90
for attempt in $(seq 1 $max_attempts); do
if talosctl -n "$TARGET_IP" get addresses | grep -q "$vip/32"; then
echo ""
print_success "VIP $vip assigned to interface."
break
fi
if [ $attempt -eq $max_attempts ]; then
echo ""
print_error "VIP $vip was not assigned to interface after $max_attempts attempts"
print_info "Troubleshooting steps:"
print_info " 1. Check VIP controller logs: talosctl -n $TARGET_IP logs controller-runtime | grep vip"
print_info " 2. Check network configuration: talosctl -n $TARGET_IP get addresses"
print_info " 3. Verify VIP is within node's network range"
exit 1
fi
printf "."
sleep 10
done
# Step 3: Wait for control plane components to start
print_info -n "Step 3/6: Waiting for control plane components to start."
max_attempts=60
for attempt in $(seq 1 $max_attempts); do
# Check if all three control plane components are running
apiserver_running=$(talosctl -n "$TARGET_IP" containers -k | grep -c "kube-apiserver.*CONTAINER_RUNNING" || true)
controller_running=$(talosctl -n "$TARGET_IP" containers -k | grep -c "kube-controller-manager.*CONTAINER_RUNNING" || true)
scheduler_running=$(talosctl -n "$TARGET_IP" containers -k | grep -c "kube-scheduler.*CONTAINER_RUNNING" || true)
if [ "$apiserver_running" -gt 0 ] && [ "$controller_running" -gt 0 ] && [ "$scheduler_running" -gt 0 ]; then
echo ""
print_success "All control plane components are running (attempt $attempt)."
break
fi
if [ $attempt -eq $max_attempts ]; then
echo ""
print_error "Control plane components not all running after $max_attempts attempts."
print_info "Troubleshooting steps:"
print_info " 1. Check kubelet logs: talosctl -n $TARGET_IP logs kubelet"
print_info " 2. Check static pod status: talosctl -n $TARGET_IP containers -k | grep kube-"
print_info " 3. Restart kubelet if needed: talosctl -n $TARGET_IP service kubelet restart"
print_info "Current status:"
print_info " API Server running: $apiserver_running"
print_info " Controller Manager running: $controller_running"
print_info " Scheduler running: $scheduler_running"
exit 1
fi
# Restart kubelet every 40 attempts to refresh static pod creation
if [ $((attempt % 40)) -eq 0 ]; then
echo ""
print_info "Restarting kubelet to refresh static pod creation (attempt $attempt)..."
talosctl -n "$TARGET_IP" service kubelet restart > /dev/null 2>&1
print_info -n "Waiting for control plane components after kubelet restart."
sleep 30 # Give kubelet time to restart and create pods
fi
printf "."
sleep 10
done
# Step 4: Wait for API server to respond on VIP
print_info -n "Step 4/6: Waiting for API server to respond on VIP $vip."
max_attempts=60
for attempt in $(seq 1 $max_attempts); do
if curl -k -s --max-time 5 "https://$vip:6443/healthz" >/dev/null 2>&1; then
echo ""
print_success "API server responding on VIP."
break
fi
if [ $attempt -eq $max_attempts ]; then
echo ""
print_error "API server not responding on VIP $vip after $max_attempts attempts."
print_info "Troubleshooting steps:"
print_info " 1. Check API server logs: talosctl -n $TARGET_IP logs kubelet | grep apiserver"
print_info " 2. Check if API server is running: talosctl -n $TARGET_IP containers -k | grep apiserver"
print_info " 3. Test API server on node IP: curl -k https://$TARGET_IP:6443/healthz"
exit 1
fi
# Attempt kubelet restart every 15 attempts to refresh certificates
if [ $((attempt % 15)) -eq 0 ]; then
echo ""
print_info "Restarting kubelet to refresh API container setup (attempt $attempt)..."
talosctl -n "$TARGET_IP" service kubelet restart > /dev/null 2>&1
print_info -n "Waiting for API server to respond after kubelet restart."
sleep 30 # Give kubelet time to restart
fi
printf "."
sleep 10
done
# Step 5: Configure talosctl endpoint and get kubeconfig
print_info "Step 5/6: Configuring cluster access..."
talosctl config endpoint "$vip"
if ! talosctl kubeconfig --nodes "$vip"; then
print_error "Failed to get kubeconfig via VIP."
print_info "Troubleshooting steps:"
print_info " 1. Check API server logs: talosctl -n $TARGET_IP logs kube-apiserver"
print_info " 2. Test API server on node IP: curl -k https://$TARGET_IP:6443/healthz"
print_info " 3. Verify network connectivity to VIP"
exit 1
else
print_success "Kubeconfig retrieved via VIP."
fi
# Step 6: Verify node registration
print_info -n "Step 6/6: Verifying node registration."
for reg_attempt in $(seq 1 10); do
if kubectl get nodes 2>/dev/null | grep -q "Ready\|NotReady"; then
echo ""
print_success "Node registered with API server."
break
fi
echo -n "."
sleep 10
done
if ! kubectl get nodes 2>/dev/null | grep -q "Ready\|NotReady"; then
echo ""
print_error "Node did not register with API server after multiple attempts."
print_info "Troubleshooting steps:"
print_info " 1. Check kubelet logs: talosctl -n $TARGET_IP logs kubelet"
print_info " 2. Check API server logs: talosctl -n $TARGET_IP logs kube-apiserver"
print_info " 3. Verify network connectivity between node and VIP"
exit 1
fi
print_success "Cluster bootstrap completed!"
fi
fi
done
# Register worker nodes
# Worker node setup
echo ""
print_info "Configure worker nodes (optional):"
print_header "Worker Node Setup (Optional)"
WORKER_COUNT=1
while true; do
echo ""
read -p "Do you want to register a worker node? (y/N): " -r register_worker
if [[ $register_worker =~ ^[Yy]$ ]]; then
# Find first available worker number
while [ -n "$(wild-config "cluster.nodes.active.\"${HOSTNAME_PREFIX}worker-${WORKER_COUNT}\".role" 2>/dev/null)" ] && [ "$(wild-config "cluster.nodes.active.\"${HOSTNAME_PREFIX}worker-${WORKER_COUNT}\".role" 2>/dev/null)" != "null" ]; do
read -p "Set up a worker node? (y/N): " -r setup_worker
if [[ $setup_worker =~ ^[Yy]$ ]]; then
# Find next available worker number
while wild-config --check "cluster.nodes.active.${HOSTNAME_PREFIX}worker-${WORKER_COUNT}.role" 2>/dev/null; do
WORKER_COUNT=$((WORKER_COUNT + 1))
done
NODE_NAME="${HOSTNAME_PREFIX}worker-${WORKER_COUNT}"
read -p "Enter current IP for worker node $NODE_NAME: " -r WORKER_IP
read -p "Enter IP address for worker node $NODE_NAME: " -r WORKER_IP
if [ -z "$WORKER_IP" ]; then
print_warning "No IP provided, skipping worker node"
continue
fi
print_info "Running wild-node-detect for worker node $NODE_NAME ($WORKER_IP)..."
# Run detection and capture both output and stderr for debugging
DETECTION_OUTPUT=$(mktemp)
DETECTION_ERROR=$(mktemp)
if wild-node-detect "$WORKER_IP" >"$DETECTION_OUTPUT" 2>"$DETECTION_ERROR"; then
WORKER_INFO=$(cat "$DETECTION_OUTPUT")
print_success "Worker node $NODE_NAME detected at IP $WORKER_IP"
rm -f "$DETECTION_OUTPUT" "$DETECTION_ERROR"
# Pre-configure worker node
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".role" "worker"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".targetIp" "$WORKER_IP"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".version" "$talos_version"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".schematicId" "$schematic_id"
# Run complete node setup
if wild-node-setup "$NODE_NAME"; then
print_success "Worker node $NODE_NAME setup completed"
WORKER_COUNT=$((WORKER_COUNT + 1))
else
print_error "Failed to detect hardware for worker node $NODE_NAME ($WORKER_IP)"
print_info "Detection error output:"
cat "$DETECTION_ERROR" >&2
print_info "Make sure the node is running in maintenance mode and accessible"
rm -f "$DETECTION_OUTPUT" "$DETECTION_ERROR"
continue
print_error "Failed to set up worker node $NODE_NAME"
print_info "You can retry later with: wild-node-setup $NODE_NAME"
fi
if [ -n "$WORKER_INFO" ]; then
# Parse JSON response
INTERFACE=$(echo "$WORKER_INFO" | jq -r '.interface')
SELECTED_DISK=$(echo "$WORKER_INFO" | jq -r '.selected_disk')
AVAILABLE_DISKS=$(echo "$WORKER_INFO" | jq -r '.disks[] | "\(.path) (\((.size / 1000000000) | floor)GB)"' | paste -sd, -)
print_success "Hardware detected for worker node $NODE_NAME:"
print_info " - Interface: $INTERFACE"
print_info " - Available disks: $AVAILABLE_DISKS"
print_info " - Selected disk: $SELECTED_DISK"
# Allow user to override disk selection
echo ""
read -p "Use selected disk '$SELECTED_DISK'? (Y/n): " -r use_disk
if [[ $use_disk =~ ^[Nn]$ ]]; then
echo "Available disks:"
echo "$WORKER_INFO" | jq -r '.disks[] | "\(.path) (\((.size / 1000000000) | floor)GB)"' | nl -w2 -s') '
read -p "Enter disk number: " -r disk_num
SELECTED_DISK=$(echo "$WORKER_INFO" | jq -r ".disks[$((disk_num-1))].path")
if [ "$SELECTED_DISK" = "null" ] || [ -z "$SELECTED_DISK" ]; then
print_error "Invalid disk selection"
continue
fi
print_info "Selected disk: $SELECTED_DISK"
fi
# Update config.yaml with worker hardware info
print_info "Updating config.yaml for worker node $NODE_NAME..."
# Store under unified cluster.nodes.active.<node-name>
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".role" "worker"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".currentIp" "$WORKER_IP"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".targetIp" "$WORKER_IP"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".interface" "$INTERFACE"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".disk" "$SELECTED_DISK"
# Copy current Talos version and schematic ID to this node
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".version" "$talos_version"
wild-config-set "cluster.nodes.active.\"${NODE_NAME}\".schematicId" "$schematic_id"
print_success "Worker node $NODE_NAME registered successfully:"
print_info " - Name: $NODE_NAME"
print_info " - IP: $WORKER_IP"
print_info " - Interface: $INTERFACE"
print_info " - Disk: $SELECTED_DISK"
# Generate machine config immediately
print_info "Generating machine configuration for $NODE_NAME..."
if wild-cluster-node-patch-generate "$NODE_NAME"; then
print_success "Machine configuration generated for $NODE_NAME"
# Ask if user wants to apply the configuration now
echo ""
read -p "Apply configuration to worker node $NODE_NAME now? (Y/n): " -r apply_config
if [[ $apply_config =~ ^[Yy]$ ]] || [[ -z "$apply_config" ]]; then
# Worker nodes are typically in maintenance mode during setup
print_info "Applying configuration in insecure mode (maintenance mode)..."
wild-cluster-node-up "$NODE_NAME" --insecure
else
print_info "Configuration not applied. You can apply it later with:"
print_info " wild-cluster-node-up $NODE_NAME --insecure"
fi
else
print_warning "Failed to generate machine configuration for $NODE_NAME"
fi
else
print_error "Failed to detect hardware for worker node $NODE_NAME"
continue
fi
WORKER_COUNT=$((WORKER_COUNT + 1))
else
break
fi
done
print_success "Completed Node hardware detection"
echo ""
print_success "Node setup phase completed"
else
print_info "Skipping Node Hardware Detection"
print_info "Skipping node setup (--skip-hardware specified)"
fi
# =============================================================================
@@ -450,3 +414,15 @@ print_header "Wild Cloud Cluster Setup Complete!"
print_success "Cluster infrastructure setup completed!"
echo ""
print_info "Next steps:"
echo " 1. Run 'wild-setup-services' to install cluster services"
echo " 2. Verify nodes are ready: kubectl get nodes"
echo " 3. Check cluster health: wild-health"
echo ""
print_info "Individual node management:"
echo " - Setup additional nodes: wild-node-setup <node-name>"
echo " - Re-detect hardware: wild-node-setup <node-name> --detect"
echo " - Configuration only: wild-node-setup <node-name> --no-deploy"
echo ""
print_success "Wild Cloud cluster setup completed!"

View File

@@ -1,116 +0,0 @@
#!/bin/bash
# Talos schematic management script
# This script manages Talos Image Factory schematics centrally
# Usage: wild-talos-schema [--force]
set -euo pipefail
# Initialize Wild Cloud environment
if [ -z "${WC_ROOT}" ]; then
print "WC_ROOT is not set."
exit 1
else
source "${WC_ROOT}/scripts/common.sh"
init_wild_env
fi
CONFIG_FILE="${WC_HOME}/config.yaml"
FORCE_UPLOAD=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--force)
FORCE_UPLOAD=true
shift
;;
-h|--help)
echo "Usage: wild-talos-schema [--force]"
echo ""
echo "Manages Talos Image Factory schematics centrally."
echo ""
echo "Options:"
echo " --force Force re-upload even if schematicId already exists"
echo " -h, --help Show this help message"
echo ""
echo "This script:"
echo " 1. Reads schematic from config.yaml (.cluster.nodes.talos.schematic)"
echo " 2. Uploads it to Image Factory if needed"
echo " 3. Stores the schematicId in config.yaml (.cluster.nodes.talos.schematicId)"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
echo "Managing Talos schematic for wildcloud..."
# Check if schematic exists in config.yaml
if ! yq eval '.cluster.nodes.talos.schematic' "$CONFIG_FILE" | grep -v "null" >/dev/null 2>&1; then
echo "Error: No schematic found in config.yaml at .cluster.nodes.talos.schematic"
echo "Expected schematic configuration with systemExtensions"
exit 1
fi
# Check if schematicId already exists (unless force)
EXISTING_ID=$(yq eval '.cluster.nodes.talos.schematicId // ""' "$CONFIG_FILE")
if [ -n "$EXISTING_ID" ] && [ "$FORCE_UPLOAD" = false ]; then
echo "✅ Schematic ID already exists: $EXISTING_ID"
echo "Use --force to re-upload and generate a new ID"
exit 0
fi
echo "Extracting schematic from config.yaml..."
# Create temporary schematic file
TEMP_SCHEMATIC=$(mktemp)
trap "rm -f $TEMP_SCHEMATIC" EXIT
# Extract schematic from config.yaml
yq eval '.cluster.nodes.talos.schematic' "$CONFIG_FILE" > "$TEMP_SCHEMATIC"
echo "Schematic contents:"
cat "$TEMP_SCHEMATIC"
echo ""
# Upload schematic to Image Factory
echo "Uploading schematic to Talos Image Factory..."
SCHEMATIC_RESPONSE=$(curl -s -X POST --data-binary @"$TEMP_SCHEMATIC" https://factory.talos.dev/schematics)
if [ -z "$SCHEMATIC_RESPONSE" ]; then
echo "Error: Failed to upload schematic to Image Factory"
exit 1
fi
# Parse schematic ID from JSON response
SCHEMATIC_ID=$(echo "$SCHEMATIC_RESPONSE" | sed 's/.*"id":"\([^"]*\)".*/\1/')
if [ -z "$SCHEMATIC_ID" ] || [ "$SCHEMATIC_ID" = "$SCHEMATIC_RESPONSE" ]; then
echo "Error: Failed to parse schematic ID from response: $SCHEMATIC_RESPONSE"
exit 1
fi
echo "✅ Schematic uploaded successfully!"
echo "Schematic ID: $SCHEMATIC_ID"
# Update config.yaml with schematic ID
echo "Updating config.yaml with schematic ID..."
yq eval ".cluster.nodes.talos.schematicId = \"$SCHEMATIC_ID\"" -i "$CONFIG_FILE"
echo ""
echo "🎉 Schematic management complete!"
echo ""
echo "Schematic ID: $SCHEMATIC_ID"
echo "Saved to: config.yaml (.cluster.nodes.talos.schematicId)"
echo ""
echo "This schematic includes:"
yq eval '.cluster.nodes.talos.schematic.customization.systemExtensions.officialExtensions[]' "$CONFIG_FILE" | sed 's/^/ - /'
echo ""
echo "Other scripts can now use this schematicId:"
echo " - setup/dnsmasq/bin/create-setup-bundle.sh (PXE boot assets)"
echo " - setup/cluster-nodes/create-installer-image.sh (custom installer)"

View File

@@ -166,22 +166,13 @@ Shows:
- Installation status
- Required configuration
### 2. Fetching Phase
**Command**: `wild-app-fetch <app-name>`
Downloads app templates to local cache:
- Copies app directory from Wild Cloud repository
- Stores in `.wildcloud/cache/apps/`
- Options: `--update` to overwrite existing cache
- Required before configuration or deployment
### 3. Configuration Phase
### 2. Configuration Phase
**Command**: `wild-app-add <app-name>`
Processes app templates and prepares for deployment:
**What it does**:
1. Reads app manifest from cache
1. Reads app manifest directly from Wild Cloud repository
2. Merges default configuration with existing `config.yaml`
3. Generates required secrets automatically
4. Compiles templates with gomplate using your configuration
@@ -193,7 +184,7 @@ Processes app templates and prepares for deployment:
- App-specific configuration merged into your `config.yaml`
- Required secrets added to your `secrets.yaml`
### 4. Deployment Phase
### 3. Deployment Phase
**Command**: `wild-app-deploy <app-name>`
Deploys the app to your Kubernetes cluster:
@@ -210,7 +201,7 @@ Deploys the app to your Kubernetes cluster:
- `--force` - Overwrite existing resources
- `--dry-run` - Preview changes without applying
### 5. Operations Phase
### 4. Operations Phase
**Monitoring**: `wild-app-doctor <app-name>`
- Runs app-specific diagnostic tests
@@ -218,6 +209,7 @@ Deploys the app to your Kubernetes cluster:
- Options: `--keep`, `--follow`, `--timeout`
**Updates**: Re-run `wild-app-add` then `wild-app-deploy`
- Use `--force` flag to overwrite existing configuration
- Updates configuration changes
- Handles image updates
- Preserves persistent data

View File

@@ -56,9 +56,9 @@ Wild Cloud provides 34+ command-line tools (all prefixed with `wild-`) for manag
### 🏗️ Cluster Infrastructure Management
**`wild-setup-cluster`** - Complete cluster setup (Phases 1-3)
- Interactive node registration and hardware detection
- Configures Talos control plane and worker nodes
- Options: `--skip-installer`, `--skip-hardware`
- Automated control plane node setup and bootstrapping
- Configures Talos control plane nodes using wild-node-setup
- Options: `--skip-hardware`
- **Usage**: `wild-setup-cluster [options]`
- **Requires**: `wild-init` completed first
@@ -67,20 +67,21 @@ Wild Cloud provides 34+ command-line tools (all prefixed with `wild-`) for manag
- Generates cluster secrets using `talosctl gen config`
- **Usage**: `wild-cluster-config-generate`
**`wild-node-detect`** - Hardware detection for nodes
**`wild-node-setup`** - Complete node lifecycle management
- Handles detect → configure → patch → deploy for individual nodes
- Automatically detects maintenance mode and handles IP transitions
- Options: `--reconfigure`, `--no-deploy`
- **Usage**: `wild-node-setup <node-name> [options]`
- **Examples**:
- `wild-node-setup control-1` (complete setup)
- `wild-node-setup worker-1 --reconfigure` (force node reconfiguration)
- `wild-node-setup control-2 --no-deploy` (configuration only)
**`wild-node-detect`** - Hardware detection utility
- Discovers network interfaces and disks from maintenance mode
- Returns JSON with hardware specifications
- Returns JSON with hardware specifications and maintenance mode status
- **Usage**: `wild-node-detect <node-ip>`
**`wild-cluster-node-patch-generate`** - Generate node-specific configs
- Creates patches based on hardware detection
- Uses templates with node-specific variables
- **Usage**: `wild-cluster-node-patch-generate <node-name>`
**`wild-cluster-node-up`** - Apply Talos configuration to nodes
- Options: `--insecure`, `--force`, `--dry-run`
- Generates final config from base + patch
- **Usage**: `wild-cluster-node-up <node-name> [options]`
- **Note**: Primarily used internally by `wild-node-setup`
**`wild-cluster-node-ip`** - Get node IP addresses
- Sources: config.yaml, kubectl, or talosctl
@@ -89,8 +90,8 @@ Wild Cloud provides 34+ command-line tools (all prefixed with `wild-`) for manag
### 🔧 Cluster Services Management
**`wild-setup-services`** - Install cluster services (Phase 4)
- Manages MetalLB, Traefik, cert-manager, etc.
**`wild-setup-services`** - Set up all cluster services (Phase 4)
- Manages MetalLB, Traefik, cert-manager, etc. in dependency order
- Options: `--fetch` for fresh templates, `--no-deploy` for config-only
- **Usage**: `wild-setup-services [options]`
- **Requires**: Working Kubernetes cluster
@@ -119,15 +120,11 @@ Wild Cloud provides 34+ command-line tools (all prefixed with `wild-`) for manag
- Options: `--verbose`, `--json`, `--yaml`
- **Usage**: `wild-apps-list [options]`
**`wild-app-fetch`** - Download app templates to cache
- Options: `--update` to overwrite existing
- **Usage**: `wild-app-fetch <app-name> [--update]`
**`wild-app-add`** - Configure app from cache
**`wild-app-add`** - Configure app from repository
- Processes manifest.yaml with configuration
- Generates required secrets automatically
- Options: `--update` to overwrite existing app files
- **Usage**: `wild-app-add <app-name> [--update]`
- Options: `--force` to overwrite existing app files
- **Usage**: `wild-app-add <app-name> [--force]`
**`wild-app-deploy`** - Deploy application to cluster
- Creates namespaces, handles dependencies
@@ -165,8 +162,10 @@ Wild Cloud provides 34+ command-line tools (all prefixed with `wild-`) for manag
### 🔍 Utilities & Helpers
**`wild-health`** - System health checks
- Basic health monitoring for components
**`wild-health`** - Comprehensive infrastructure validation
- Validates core components (MetalLB, Traefik, CoreDNS)
- Checks installed services (cert-manager, ExternalDNS, Kubernetes Dashboard)
- Tests DNS resolution, routing, certificates, and storage systems
- **Usage**: `wild-health`
**`wild-talos-schema`** - Talos schema management
@@ -211,7 +210,6 @@ wild-setup-services # Resume full setup if needed
### Application Management
```bash
wild-apps-list # See available apps
wild-app-fetch ghost # Download app templates
wild-app-add ghost # Configure app
wild-app-deploy ghost # Deploy to cluster
wild-app-doctor ghost # Troubleshoot issues
@@ -251,14 +249,14 @@ wild-health # Check system health
### App Deployment Pipeline
1. `wild-apps-list` → discover applications
2. `wild-app-fetch` → cache app templates
3. `wild-app-add` → prepare configuration
4. `wild-app-deploy` → deploy to cluster
2. `wild-app-add` → configure and prepare application
3. `wild-app-deploy` → deploy to cluster
### Node Management Flow
1. `wild-cluster-config-generate` → base configurations
2. `wild-node-detect` → discover hardware
3. `wild-cluster-node-patch-generate` → node-specific configs
4. `wild-cluster-node-up` → apply configurations
2. `wild-node-setup <node-name>` → atomic node operations (detect → patch → deploy)
- Internally uses `wild-node-detect` for hardware discovery
- Generates node-specific patches and final configurations
- Deploys configuration to target node
All scripts are designed to work together as a cohesive Infrastructure as Code system for personal Kubernetes deployments.

View File

@@ -153,7 +153,6 @@ wild-setup-services # Install core services
wild-apps-list
# Deploy a blog
wild-app-fetch ghost
wild-app-add ghost
wild-app-deploy ghost
@@ -215,10 +214,9 @@ storage: 10Gi
### Application Lifecycle
1. **Discovery**: `wild-apps-list` - Browse available apps
2. **Fetching**: `wild-app-fetch app-name` - Download templates
3. **Configuration**: `wild-app-add app-name` - Process and configure
4. **Deployment**: `wild-app-deploy app-name` - Deploy to cluster
5. **Operations**: `wild-app-doctor app-name` - Monitor and troubleshoot
2. **Configuration**: `wild-app-add app-name` - Configure and prepare application
3. **Deployment**: `wild-app-deploy app-name` - Deploy to cluster
4. **Operations**: `wild-app-doctor app-name` - Monitor and troubleshoot
## Available Applications
@@ -259,7 +257,6 @@ wild-setup-services # Deploy cluster services only
### Application Management
```bash
wild-apps-list # List available applications
wild-app-fetch <app> # Download app templates
wild-app-add <app> # Configure application
wild-app-deploy <app> # Deploy to cluster
wild-app-delete <app> # Remove application
@@ -317,7 +314,6 @@ wild-app-backup <app> # Backup specific application
### Personal Blog/Website
```bash
# Deploy Ghost blog with custom domain
wild-app-fetch ghost
wild-config-set apps.ghost.domain "blog.yourdomain.com"
wild-app-add ghost
wild-app-deploy ghost
@@ -326,7 +322,6 @@ wild-app-deploy ghost
### Photo Management
```bash
# Deploy Immich for photo backup and management
wild-app-fetch immich postgresql
wild-app-add postgresql immich
wild-app-deploy postgresql immich
```
@@ -334,7 +329,6 @@ wild-app-deploy postgresql immich
### Development Environment
```bash
# Set up Git hosting and container registry
wild-app-fetch gitea docker-registry
wild-app-add gitea docker-registry
wild-app-deploy gitea docker-registry
```
@@ -342,7 +336,6 @@ wild-app-deploy gitea docker-registry
### AI/ML Workloads
```bash
# Deploy vLLM for local AI inference
wild-app-fetch vllm
wild-config-set apps.vllm.model "Qwen/Qwen2.5-7B-Instruct"
wild-app-add vllm
wild-app-deploy vllm

View File

@@ -227,11 +227,10 @@ cluster:
### From Repository to Deployment
1. **Template Storage**: Templates stored in repository with placeholder variables
2. **Template Fetching**: `wild-app-fetch` copies templates to user cache
3. **Configuration Merge**: `wild-app-add` merges app defaults with user config
4. **Template Compilation**: gomplate processes templates with user data
5. **Manifest Generation**: Final Kubernetes manifests created in user directory
6. **Deployment**: `wild-app-deploy` applies manifests to cluster
2. **Configuration Merge**: `wild-app-add` reads templates directly from repository and merges app defaults with user config
3. **Template Compilation**: gomplate processes templates with user data
4. **Manifest Generation**: Final Kubernetes manifests created in user directory
5. **Deployment**: `wild-app-deploy` applies manifests to cluster
### Template Variables
@@ -344,7 +343,6 @@ wild-setup # Deploy infrastructure
**Daily Operations**:
```bash
wild-apps-list # Browse available apps
wild-app-fetch ghost # Download app templates
wild-app-add ghost # Configure app
wild-app-deploy ghost # Deploy to cluster
```

View File

@@ -86,30 +86,22 @@ network:
- Creates cluster secrets using `talosctl gen config`
- Establishes foundation for all node configurations
#### 2. Hardware Detection
**Script**: `wild-node-detect`
#### 2. Node Setup (Atomic Operations)
**Script**: `wild-node-setup <node-name> [options]`
Interactive process for each node:
- Boots nodes into maintenance mode via PXE
- Detects network interfaces and storage devices
- Returns JSON specification of hardware capabilities
- Records node-specific configuration data
**Complete Node Lifecycle Management**:
- **Hardware Detection**: Discovers network interfaces and storage devices
- **Configuration Generation**: Creates node-specific patches and final configs
- **Deployment**: Applies Talos configuration to the node
#### 3. Node-Specific Configuration
**Script**: `wild-cluster-node-patch-generate`
**Options**:
- `--detect`: Force hardware re-detection
- `--no-deploy`: Generate configuration only, skip deployment
- Generates patches for individual nodes
- Uses templates with detected hardware specifications
- Creates node-specific machine configurations
- Handles IP addresses, interfaces, and disk layout
#### 4. Node Deployment
**Script**: `wild-cluster-node-up`
- Applies Talos configurations to nodes
- Supports `--insecure` for maintenance mode
- Generates final configs from base + patches
- Deploys both control plane and worker nodes
**Integration with Cluster Setup**:
- `wild-setup-cluster` automatically calls `wild-node-setup` for each node
- Individual node failures don't break cluster setup
- Clear retry instructions for failed nodes
### Cluster Architecture
@@ -363,8 +355,9 @@ wild-setup-services # Cluster services only
### Individual Operations
```bash
wild-cluster-config-generate # Generate base configs
wild-node-detect <ip> # Hardware detection
wild-cluster-node-up <node> # Deploy single node
wild-node-setup <node-name> # Complete node setup (detect → configure → deploy)
wild-node-setup <node-name> --detect # Force hardware re-detection
wild-node-setup <node-name> --no-deploy # Configuration only
wild-dashboard-token # Get dashboard access
wild-health # System health check
```

View File

@@ -220,8 +220,7 @@ This approach prevents naming conflicts between apps and makes secret keys more
Apps in Wild Cloud are managed by operators using a set of commands run from their Wild Cloud home directory.
- `wild-apps-list`: Lists all available apps.
- `wild-app-fetch <app-name>`: Fetches the latest app files from the Wild Cloud repository and stores them in your Wild Cloud cache.
- `wild-app-add <app-name>`: Adds the app manifest to your Wild Cloud home `apps` directory, updates missing values in `config.yaml` and `secrets.yaml` with the app's default configurations, and compiles the app's Kustomize files.
- `wild-app-add <app-name>`: Reads the app from the Wild Cloud repository, adds the app manifest to your Wild Cloud home `apps` directory, updates missing values in `config.yaml` and `secrets.yaml` with the app's default configurations, and compiles the app's Kustomize files.
- `wild-app-deploy <app-name>`: Deploys the app to your Wild Cloud.
## Contributing

View File

@@ -14,16 +14,10 @@ To list all available apps:
wild-apps-list
```
To fetch an app template to cache:
To configure an app (reads directly from repository):
```bash
wild-app-fetch <app>
```
To apply your configuration to a cached app (automatically fetches if not cached):
```bash
wild-app-config <app>
wild-app-add <app>
```
To deploy a configured app to Kubernetes:

View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,8 +0,0 @@
{
"hash": "0e2daab1",
"configHash": "9fbff803",
"lockfileHash": "e3b0c442",
"browserHash": "04872398",
"optimized": {},
"chunks": {}
}

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

View File

@@ -1,54 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,28 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

View File

@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Wild Cloud Central Management"
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Wild Cloud Central</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,53 +0,0 @@
{
"name": "wild-cloud-central",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"type-check": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"build:css": "tailwindcss -i src/index.css -o ./output.css --config ./tailwind.config.js"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.10",
"@tanstack/react-query": "^5.62.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.516.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.58.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",
"zod": "^3.25.67"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/node": "^24.0.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"tw-animate-css": "^1.3.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
},
"packageManager": "pnpm@10.9.0+sha512.0486e394640d3c1fb3c9d43d49cf92879ff74f8516959c235308f5a8f62e2e19528a65cdc2a3058f587cde71eba3d5b56327c8c33a97e4c4051ca48a10ca2d5f"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,140 +0,0 @@
import { useEffect, useState } from 'react';
import { useConfig } from './hooks';
import {
Advanced,
ErrorBoundary
} from './components';
import { CloudComponent } from './components/CloudComponent';
import { CentralComponent } from './components/CentralComponent';
import { DnsComponent } from './components/DnsComponent';
import { DhcpComponent } from './components/DhcpComponent';
import { PxeComponent } from './components/PxeComponent';
import { ClusterNodesComponent } from './components/ClusterNodesComponent';
import { ClusterServicesComponent } from './components/ClusterServicesComponent';
import { AppsComponent } from './components/AppsComponent';
import { AppSidebar } from './components/AppSidebar';
import { SidebarProvider, SidebarInset, SidebarTrigger } from './components/ui/sidebar';
import type { Phase, Tab } from './components/AppSidebar';
function App() {
const [currentTab, setCurrentTab] = useState<Tab>('cloud');
const [completedPhases, setCompletedPhases] = useState<Phase[]>([]);
const { config } = useConfig();
// Update phase state from config when it changes
useEffect(() => {
console.log('Config changed:', config);
console.log('config?.wildcloud:', config?.wildcloud);
if (config?.wildcloud?.currentPhase) {
console.log('Setting currentTab to:', config.wildcloud.currentPhase);
setCurrentTab(config.wildcloud.currentPhase as Phase);
}
if (config?.wildcloud?.completedPhases) {
console.log('Setting completedPhases to:', config.wildcloud.completedPhases);
setCompletedPhases(config.wildcloud.completedPhases as Phase[]);
}
}, [config]);
const handlePhaseComplete = (phase: Phase) => {
if (!completedPhases.includes(phase)) {
setCompletedPhases(prev => [...prev, phase]);
}
// Auto-advance to next phase (excluding advanced)
const phases: Phase[] = ['setup', 'infrastructure', 'cluster', 'apps'];
const currentIndex = phases.indexOf(phase);
if (currentIndex < phases.length - 1) {
setCurrentTab(phases[currentIndex + 1]);
}
};
const renderCurrentTab = () => {
switch (currentTab) {
case 'cloud':
return (
<ErrorBoundary>
<CloudComponent />
</ErrorBoundary>
);
case 'central':
return (
<ErrorBoundary>
<CentralComponent />
</ErrorBoundary>
);
case 'dns':
return (
<ErrorBoundary>
<DnsComponent />
</ErrorBoundary>
);
case 'dhcp':
return (
<ErrorBoundary>
<DhcpComponent />
</ErrorBoundary>
);
case 'pxe':
return (
<ErrorBoundary>
<PxeComponent />
</ErrorBoundary>
);
case 'setup':
case 'infrastructure':
return (
<ErrorBoundary>
<ClusterNodesComponent onComplete={() => handlePhaseComplete('infrastructure')} />
</ErrorBoundary>
);
case 'cluster':
return (
<ErrorBoundary>
<ClusterServicesComponent onComplete={() => handlePhaseComplete('cluster')} />
</ErrorBoundary>
);
case 'apps':
return (
<ErrorBoundary>
<AppsComponent onComplete={() => handlePhaseComplete('apps')} />
</ErrorBoundary>
);
case 'advanced':
return (
<ErrorBoundary>
<Advanced />
</ErrorBoundary>
);
default:
return (
<ErrorBoundary>
<CloudComponent />
</ErrorBoundary>
);
}
};
return (
<SidebarProvider>
<AppSidebar
currentTab={currentTab}
onTabChange={setCurrentTab}
completedPhases={completedPhases}
/>
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold">Dashboard</h1>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4">
{renderCurrentTab()}
</div>
</SidebarInset>
</SidebarProvider>
);
}
export default App;

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,114 +0,0 @@
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { ConfigEditor } from "./ConfigEditor";
import { Button, Input, Label } from "./ui";
import { Check, Edit2, HelpCircle, X } from "lucide-react";
export function Advanced() {
const [upstreamValue, setUpstreamValue] = useState("https://mywildcloud.org");
const [editingUpstream, setEditingUpstream] = useState(false);
const [tempUpstream, setTempUpstream] = useState(upstreamValue);
const handleUpstreamEdit = () => {
setTempUpstream(upstreamValue);
setEditingUpstream(true);
};
const handleUpstreamSave = () => {
setUpstreamValue(tempUpstream);
setEditingUpstream(false);
};
const handleUpstreamCancel = () => {
setTempUpstream(upstreamValue);
setEditingUpstream(false);
};
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Advanced Configuration</CardTitle>
<CardDescription>
Advanced settings and system configuration options
</CardDescription>
</CardHeader>
<CardContent>
<div>
<h3 className="text-sm font-medium mb-2">
Configuration Management
</h3>
<p className="text-sm text-muted-foreground mb-4">
Edit the raw YAML configuration file directly. This provides full
access to all configuration options.
</p>
<ConfigEditor />
</div>
</CardContent>
</Card>
{/* Upstream Section */}
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium">Upstream Configuration</h3>
<p className="text-sm text-muted-foreground">
External service endpoint
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<HelpCircle className="h-4 w-4" />
</Button>
{!editingUpstream && (
<Button variant="outline" size="sm" onClick={handleUpstreamEdit}>
<Edit2 className="h-4 w-4 mr-1" />
Edit
</Button>
)}
</div>
</div>
{editingUpstream ? (
<div className="space-y-3">
<div>
<Label htmlFor="upstream-edit">Upstream URL</Label>
<Input
id="upstream-edit"
value={tempUpstream}
onChange={(e) => setTempUpstream(e.target.value)}
placeholder="https://example.com"
className="mt-1"
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleUpstreamSave}>
<Check className="h-4 w-4 mr-1" />
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={handleUpstreamCancel}
>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
</div>
</div>
) : (
<div>
<Label>Upstream URL</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{upstreamValue}
</div>
</div>
)}
</Card>
</div>
);
}

View File

@@ -1,416 +0,0 @@
import { CheckCircle, Lock, Server, Play, Container, AppWindow, Settings, CloudLightning, Sun, Moon, Monitor, ChevronDown, Globe, Wifi, HardDrive } from 'lucide-react';
import { cn } from '../lib/utils';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail,
} from './ui/sidebar';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { useTheme } from '../contexts/ThemeContext';
export type Phase = 'setup' | 'infrastructure' | 'cluster' | 'apps';
export type Tab = Phase | 'advanced' | 'cloud' | 'central' | 'dns' | 'dhcp' | 'pxe';
interface AppSidebarProps {
currentTab: Tab;
onTabChange: (tab: Tab) => void;
completedPhases: Phase[];
}
export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSidebarProps) {
const { theme, setTheme } = useTheme();
const cycleTheme = () => {
if (theme === 'light') {
setTheme('dark');
} else if (theme === 'dark') {
setTheme('system');
} else {
setTheme('light');
}
};
const getThemeIcon = () => {
switch (theme) {
case 'light':
return <Sun className="h-4 w-4" />;
case 'dark':
return <Moon className="h-4 w-4" />;
default:
return <Monitor className="h-4 w-4" />;
}
};
const getThemeLabel = () => {
switch (theme) {
case 'light':
return 'Light mode';
case 'dark':
return 'Dark mode';
default:
return 'System theme';
}
};
const getTabStatus = (tab: Tab) => {
// Non-phase tabs (like advanced and cloud) are always available
if (tab === 'advanced' || tab === 'cloud') {
return 'available';
}
// Central sub-tabs are available if setup phase is available or completed
if (tab === 'central' || tab === 'dns' || tab === 'dhcp' || tab === 'pxe') {
if (completedPhases.includes('setup')) {
return 'completed';
}
return 'available';
}
// For phase tabs, check completion status
if (completedPhases.includes(tab as Phase)) {
return 'completed';
}
// Allow access to the first phase always
if (tab === 'setup') {
return 'available';
}
// Allow access to the next phase if the previous phase is completed
if (tab === 'infrastructure' && completedPhases.includes('setup')) {
return 'available';
}
if (tab === 'cluster' && completedPhases.includes('infrastructure')) {
return 'available';
}
if (tab === 'apps' && completedPhases.includes('cluster')) {
return 'available';
}
return 'locked';
};
return (
<Sidebar variant="sidebar" collapsible="icon">
<SidebarHeader>
<div className="flex items-center gap-2 px-2">
<div className="p-1 bg-primary/10 rounded-lg">
<CloudLightning className="h-6 w-6 text-primary" />
</div>
<div className="group-data-[collapsible=icon]:hidden">
<h2 className="text-lg font-bold text-foreground">Wild Cloud</h2>
<p className="text-sm text-muted-foreground">Central</p>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={currentTab === 'cloud'}
onClick={() => {
const status = getTabStatus('cloud');
if (status !== 'locked') onTabChange('cloud');
}}
disabled={getTabStatus('cloud') === 'locked'}
tooltip="Configure cloud settings and domains"
className={cn(
"transition-colors",
getTabStatus('cloud') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'cloud' && "bg-primary/10",
getTabStatus('cloud') === 'locked' && "bg-muted"
)}>
<CloudLightning className={cn(
"h-4 w-4",
currentTab === 'cloud' && "text-primary",
currentTab !== 'cloud' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cloud</span>
</SidebarMenuButton>
</SidebarMenuItem>
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
<Server className="h-4 w-4" />
Central
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'central'}
onClick={() => {
const status = getTabStatus('central');
if (status !== 'locked') onTabChange('central');
}}
className={cn(
"transition-colors",
getTabStatus('central') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'central' && "bg-primary/10",
getTabStatus('central') === 'locked' && "bg-muted"
)}>
<Server className={cn(
"h-4 w-4",
currentTab === 'central' && "text-primary",
currentTab !== 'central' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Central</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'dns'}
onClick={() => {
const status = getTabStatus('dns');
if (status !== 'locked') onTabChange('dns');
}}
className={cn(
"transition-colors",
getTabStatus('dns') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'dns' && "bg-primary/10",
getTabStatus('dns') === 'locked' && "bg-muted"
)}>
<Globe className={cn(
"h-4 w-4",
currentTab === 'dns' && "text-primary",
currentTab !== 'dns' && "text-muted-foreground"
)} />
</div>
<span className="truncate">DNS</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'dhcp'}
onClick={() => {
const status = getTabStatus('dhcp');
if (status !== 'locked') onTabChange('dhcp');
}}
className={cn(
"transition-colors",
getTabStatus('dhcp') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'dhcp' && "bg-primary/10",
getTabStatus('dhcp') === 'locked' && "bg-muted"
)}>
<Wifi className={cn(
"h-4 w-4",
currentTab === 'dhcp' && "text-primary",
currentTab !== 'dhcp' && "text-muted-foreground"
)} />
</div>
<span className="truncate">DHCP</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'pxe'}
onClick={() => {
const status = getTabStatus('pxe');
if (status !== 'locked') onTabChange('pxe');
}}
className={cn(
"transition-colors",
getTabStatus('pxe') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'pxe' && "bg-primary/10",
getTabStatus('pxe') === 'locked' && "bg-muted"
)}>
<HardDrive className={cn(
"h-4 w-4",
currentTab === 'pxe' && "text-primary",
currentTab !== 'pxe' && "text-muted-foreground"
)} />
</div>
<span className="truncate">PXE</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
<Container className="h-4 w-4" />
Cluster
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'infrastructure'}
onClick={() => {
const status = getTabStatus('infrastructure');
if (status !== 'locked') onTabChange('infrastructure');
}}
className={cn(
"transition-colors",
getTabStatus('infrastructure') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'infrastructure' && "bg-primary/10",
getTabStatus('infrastructure') === 'locked' && "bg-muted"
)}>
<Play className={cn(
"h-4 w-4",
currentTab === 'infrastructure' && "text-primary",
currentTab !== 'infrastructure' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cluster Nodes</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'cluster'}
onClick={() => {
const status = getTabStatus('cluster');
if (status !== 'locked') onTabChange('cluster');
}}
className={cn(
"transition-colors",
getTabStatus('cluster') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'cluster' && "bg-primary/10",
getTabStatus('cluster') === 'locked' && "bg-muted"
)}>
<Container className={cn(
"h-4 w-4",
currentTab === 'cluster' && "text-primary",
currentTab !== 'cluster' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cluster Services</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
<SidebarMenuItem>
<SidebarMenuButton
isActive={currentTab === 'apps'}
onClick={() => {
const status = getTabStatus('apps');
if (status !== 'locked') onTabChange('apps');
}}
disabled={getTabStatus('apps') === 'locked'}
tooltip="Install and manage applications"
className={cn(
"transition-colors",
getTabStatus('apps') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'apps' && "bg-primary/10",
getTabStatus('apps') === 'locked' && "bg-muted"
)}>
<AppWindow className={cn(
"h-4 w-4",
currentTab === 'apps' && "text-primary",
currentTab !== 'apps' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Apps</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={currentTab === 'advanced'}
onClick={() => {
const status = getTabStatus('advanced');
if (status !== 'locked') onTabChange('advanced');
}}
disabled={getTabStatus('advanced') === 'locked'}
tooltip="Advanced settings and system configuration"
className={cn(
"transition-colors",
getTabStatus('advanced') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'advanced' && "bg-primary/10",
getTabStatus('advanced') === 'locked' && "bg-muted"
)}>
<Settings className={cn(
"h-4 w-4",
currentTab === 'advanced' && "text-primary",
currentTab !== 'advanced' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Advanced</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={cycleTheme}
tooltip={`Current: ${getThemeLabel()}. Click to cycle themes.`}
>
{getThemeIcon()}
<span>{getThemeLabel()}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail/>
</Sidebar>
);
}

View File

@@ -1,394 +0,0 @@
import { useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import {
AppWindow,
Database,
Globe,
Shield,
BarChart3,
MessageSquare,
Plus,
Search,
Settings,
ExternalLink,
CheckCircle,
AlertCircle,
Clock,
Download,
Trash2,
BookOpen
} from 'lucide-react';
interface AppsComponentProps {
onComplete?: () => void;
}
interface Application {
id: string;
name: string;
description: string;
category: 'database' | 'web' | 'security' | 'monitoring' | 'communication' | 'storage';
status: 'available' | 'installing' | 'running' | 'error' | 'stopped';
version?: string;
namespace?: string;
replicas?: number;
resources?: {
cpu: string;
memory: string;
};
urls?: string[];
}
export function AppsComponent({ onComplete }: AppsComponentProps) {
const [applications, setApplications] = useState<Application[]>([
{
id: 'postgres',
name: 'PostgreSQL',
description: 'Reliable, high-performance SQL database',
category: 'database',
status: 'running',
version: 'v15.4',
namespace: 'default',
replicas: 1,
resources: { cpu: '500m', memory: '1Gi' },
urls: ['postgres://postgres.wildcloud.local:5432'],
},
{
id: 'redis',
name: 'Redis',
description: 'In-memory data structure store',
category: 'database',
status: 'running',
version: 'v7.2',
namespace: 'default',
replicas: 1,
resources: { cpu: '250m', memory: '512Mi' },
},
{
id: 'traefik-dashboard',
name: 'Traefik Dashboard',
description: 'Load balancer and reverse proxy dashboard',
category: 'web',
status: 'running',
version: 'v3.0',
namespace: 'kube-system',
urls: ['https://traefik.wildcloud.local'],
},
{
id: 'grafana',
name: 'Grafana',
description: 'Monitoring and observability dashboards',
category: 'monitoring',
status: 'installing',
version: 'v10.2',
namespace: 'monitoring',
},
{
id: 'prometheus',
name: 'Prometheus',
description: 'Time-series monitoring and alerting',
category: 'monitoring',
status: 'running',
version: 'v2.45',
namespace: 'monitoring',
replicas: 1,
resources: { cpu: '1000m', memory: '2Gi' },
},
{
id: 'vault',
name: 'HashiCorp Vault',
description: 'Secrets management and encryption',
category: 'security',
status: 'available',
version: 'v1.15',
},
{
id: 'minio',
name: 'MinIO',
description: 'High-performance object storage',
category: 'storage',
status: 'available',
version: 'RELEASE.2023-12-07',
},
]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const getStatusIcon = (status: Application['status']) => {
switch (status) {
case 'running':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'installing':
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
case 'stopped':
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
default:
return <Download className="h-5 w-5 text-muted-foreground" />;
}
};
const getStatusBadge = (status: Application['status']) => {
const variants = {
available: 'secondary',
installing: 'default',
running: 'success',
error: 'destructive',
stopped: 'warning',
} as const;
const labels = {
available: 'Available',
installing: 'Installing',
running: 'Running',
error: 'Error',
stopped: 'Stopped',
};
return (
<Badge variant={variants[status] as any}>
{labels[status]}
</Badge>
);
};
const getCategoryIcon = (category: Application['category']) => {
switch (category) {
case 'database':
return <Database className="h-4 w-4" />;
case 'web':
return <Globe className="h-4 w-4" />;
case 'security':
return <Shield className="h-4 w-4" />;
case 'monitoring':
return <BarChart3 className="h-4 w-4" />;
case 'communication':
return <MessageSquare className="h-4 w-4" />;
case 'storage':
return <Database className="h-4 w-4" />;
default:
return <AppWindow className="h-4 w-4" />;
}
};
const handleAppAction = (appId: string, action: 'install' | 'start' | 'stop' | 'delete' | 'configure') => {
console.log(`${action} app: ${appId}`);
};
const categories = ['all', 'database', 'web', 'security', 'monitoring', 'communication', 'storage'];
const filteredApps = applications.filter(app => {
const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === 'all' || app.category === selectedCategory;
return matchesSearch && matchesCategory;
});
const runningApps = applications.filter(app => app.status === 'running').length;
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-pink-50 to-rose-50 dark:from-pink-950/20 dark:to-rose-950/20 border-pink-200 dark:border-pink-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-pink-100 dark:bg-pink-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-pink-600 dark:text-pink-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-pink-900 dark:text-pink-100 mb-2">
What are Apps in your Personal Cloud?
</h3>
<p className="text-pink-800 dark:text-pink-200 mb-3 leading-relaxed">
Apps are the useful programs that make your personal cloud valuable - like having a personal Netflix
(media server), Google Drive (file storage), or Gmail (email server) running on your own hardware.
Instead of relying on big tech companies, you control your data and services.
</p>
<p className="text-pink-700 dark:text-pink-300 mb-4 text-sm">
Your cluster can run databases, web servers, photo galleries, password managers, backup services, and much more.
Each app runs in its own secure container, so they don't interfere with each other and can be easily managed.
</p>
<Button variant="outline" size="sm" className="text-pink-700 border-pink-300 hover:bg-pink-100 dark:text-pink-300 dark:border-pink-700 dark:hover:bg-pink-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about self-hosted applications
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<AppWindow className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">App Management</h2>
<p className="text-muted-foreground">
Install and manage applications on your Kubernetes cluster
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search applications..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background"
/>
</div>
<div className="flex gap-2">
{categories.map(category => (
<Button
key={category}
variant={selectedCategory === category ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedCategory(category)}
className="capitalize"
>
{category}
</Button>
))}
</div>
</div>
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-muted-foreground">
{runningApps} applications running • {applications.length} total available
</div>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add App
</Button>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredApps.map((app) => (
<Card key={app.id} className="p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded-lg">
{getCategoryIcon(app.category)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{app.name}</h3>
{app.version && (
<Badge variant="outline" className="text-xs">
{app.version}
</Badge>
)}
{getStatusIcon(app.status)}
</div>
<p className="text-sm text-muted-foreground mb-2">{app.description}</p>
{app.status === 'running' && (
<div className="space-y-1 text-xs text-muted-foreground">
{app.namespace && (
<div>Namespace: {app.namespace}</div>
)}
{app.replicas && (
<div>Replicas: {app.replicas}</div>
)}
{app.resources && (
<div>Resources: {app.resources.cpu} CPU, {app.resources.memory} RAM</div>
)}
{app.urls && app.urls.length > 0 && (
<div className="flex items-center gap-1">
<span>URLs:</span>
{app.urls.map((url, index) => (
<Button
key={index}
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className="h-3 w-3 mr-1" />
Access
</Button>
))}
</div>
)}
</div>
)}
</div>
<div className="flex flex-col gap-2">
{getStatusBadge(app.status)}
<div className="flex gap-1">
{app.status === 'available' && (
<Button
size="sm"
onClick={() => handleAppAction(app.id, 'install')}
>
Install
</Button>
)}
{app.status === 'running' && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app.id, 'configure')}
>
<Settings className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app.id, 'stop')}
>
Stop
</Button>
</>
)}
{app.status === 'stopped' && (
<Button
size="sm"
onClick={() => handleAppAction(app.id, 'start')}
>
Start
</Button>
)}
{(app.status === 'running' || app.status === 'stopped') && (
<Button
size="sm"
variant="destructive"
onClick={() => handleAppAction(app.id, 'delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</Card>
))}
</div>
{filteredApps.length === 0 && (
<Card className="p-8 text-center">
<AppWindow className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No applications found</h3>
<p className="text-muted-foreground mb-4">
{searchTerm || selectedCategory !== 'all'
? 'Try adjusting your search or category filter'
: 'Install your first application to get started'
}
</p>
<Button>
<Plus className="h-4 w-4 mr-2" />
Browse App Catalog
</Button>
</Card>
)}
</div>
);
}

View File

@@ -1,114 +0,0 @@
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Server, Network, Settings, Clock, HelpCircle, CheckCircle, BookOpen, ExternalLink } from 'lucide-react';
import { Input, Label } from './ui';
export function CentralComponent() {
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
What is the Central Service?
</h3>
<p className="text-blue-800 dark:text-blue-200 mb-3 leading-relaxed">
The Central Service is the "brain" of your personal cloud. It acts as the main coordinator that manages
all the different services running on your network. Think of it like the control tower at an airport -
it keeps track of what's happening, routes traffic between services, and ensures everything works together smoothly.
</p>
<p className="text-blue-700 dark:text-blue-300 mb-4 text-sm">
This service handles configuration management, service discovery, and provides the web interface you're using right now.
</p>
<Button variant="outline" size="sm" className="text-blue-700 border-blue-300 hover:bg-blue-100 dark:text-blue-300 dark:border-blue-700 dark:hover:bg-blue-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about service orchestration
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Server className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Central Service</h2>
<p className="text-muted-foreground">
Monitor and manage the central server service
</p>
</div>
</div>
<div>
<h3 className="text-lg font-medium mb-4">Service Status</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">IP Address: 192.168.8.50</span>
</div>
<div className="flex items-center gap-2">
<Network className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Network: 192.168.8.0/24</span>
</div>
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Version: 1.0.0 (update available)</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Age: 12s</span>
</div>
<div className="flex items-center gap-2">
<HelpCircle className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Platform: ARM</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-sm text-green-500">File permissions: Good</span>
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="ip">IP</Label>
<div className="flex w-full items-center mt-1">
<Input id="ip" value="192.168.5.80"/>
<Button variant="ghost">
<HelpCircle/>
</Button>
</div>
</div>
<div>
<Label htmlFor="interface">Interface</Label>
<div className="flex w-full items-center mt-1">
<Input id="interface" value="eth0"/>
<Button variant="ghost">
<HelpCircle/>
</Button>
</div>
</div>
</div>
<div className="flex gap-2 justify-end mt-4">
<Button onClick={() => console.log('Update service')}>
Update
</Button>
<Button onClick={() => console.log('Restart service')}>
Restart
</Button>
<Button onClick={() => console.log('View log')}>
View log
</Button>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -1,137 +0,0 @@
import { useState } from "react";
import { Card } from "./ui/card";
import { Button } from "./ui/button";
import { Cloud, HelpCircle, Edit2, Check, X } from "lucide-react";
import { Input, Label } from "./ui";
export function CloudComponent() {
const [domainValue, setDomainValue] = useState("cloud.payne.io");
const [internalDomainValue, setInternalDomainValue] = useState(
"internal.cloud.payne.io"
);
const [editingDomains, setEditingDomains] = useState(false);
const [tempDomain, setTempDomain] = useState(domainValue);
const [tempInternalDomain, setTempInternalDomain] =
useState(internalDomainValue);
const handleDomainsEdit = () => {
setTempDomain(domainValue);
setTempInternalDomain(internalDomainValue);
setEditingDomains(true);
};
const handleDomainsSave = () => {
setDomainValue(tempDomain);
setInternalDomainValue(tempInternalDomain);
setEditingDomains(false);
};
const handleDomainsCancel = () => {
setTempDomain(domainValue);
setTempInternalDomain(internalDomainValue);
setEditingDomains(false);
};
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Cloud className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Cloud Configuration</h2>
<p className="text-muted-foreground">
Configure top-level cloud settings and domains
</p>
</div>
</div>
<div className="space-y-6">
{/* Domains Section */}
<Card className="p-4 border-l-4 border-l-green-500">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium">Domain Configuration</h3>
<p className="text-sm text-muted-foreground">
Public and internal domain settings
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<HelpCircle className="h-4 w-4" />
</Button>
{!editingDomains && (
<Button
variant="outline"
size="sm"
onClick={handleDomainsEdit}
>
<Edit2 className="h-4 w-4 mr-1" />
Edit
</Button>
)}
</div>
</div>
{editingDomains ? (
<div className="space-y-3">
<div>
<Label htmlFor="domain-edit">Public Domain</Label>
<Input
id="domain-edit"
value={tempDomain}
onChange={(e) => setTempDomain(e.target.value)}
placeholder="example.com"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="internal-domain-edit">Internal Domain</Label>
<Input
id="internal-domain-edit"
value={tempInternalDomain}
onChange={(e) => setTempInternalDomain(e.target.value)}
placeholder="internal.example.com"
className="mt-1"
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleDomainsSave}>
<Check className="h-4 w-4 mr-1" />
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDomainsCancel}
>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<div>
<Label>Public Domain</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{domainValue}
</div>
</div>
<div>
<Label>Internal Domain</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{internalDomainValue}
</div>
</div>
</div>
)}
</Card>
</div>
</Card>
</div>
);
}

View File

@@ -1,378 +0,0 @@
import { useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Cpu, HardDrive, Network, Monitor, Plus, CheckCircle, AlertCircle, Clock, BookOpen, ExternalLink } from 'lucide-react';
interface ClusterNodesComponentProps {
onComplete?: () => void;
}
interface Node {
id: string;
name: string;
type: 'controller' | 'worker' | 'unassigned';
status: 'pending' | 'connecting' | 'connected' | 'healthy' | 'error';
ipAddress?: string;
macAddress: string;
osVersion?: string;
specs: {
cpu: string;
memory: string;
storage: string;
};
}
export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps) {
const [currentOsVersion, setCurrentOsVersion] = useState('v13.0.5');
const [nodes, setNodes] = useState<Node[]>([
{
id: 'controller-1',
name: 'Controller Node 1',
type: 'controller',
status: 'healthy',
macAddress: '00:1A:2B:3C:4D:5E',
osVersion: 'v13.0.4',
specs: {
cpu: '4 cores',
memory: '8GB RAM',
storage: '120GB SSD',
},
},
{
id: 'worker-1',
name: 'Worker Node 1',
type: 'worker',
status: 'healthy',
macAddress: '00:1A:2B:3C:4D:5F',
osVersion: 'v13.0.5',
specs: {
cpu: '8 cores',
memory: '16GB RAM',
storage: '500GB SSD',
},
},
{
id: 'worker-2',
name: 'Worker Node 2',
type: 'worker',
status: 'healthy',
macAddress: '00:1A:2B:3C:4D:60',
osVersion: 'v13.0.4',
specs: {
cpu: '8 cores',
memory: '16GB RAM',
storage: '500GB SSD',
},
},
{
id: 'node-1',
name: 'Node 1',
type: 'unassigned',
status: 'pending',
macAddress: '00:1A:2B:3C:4D:5E',
osVersion: 'v13.0.5',
specs: {
cpu: '4 cores',
memory: '8GB RAM',
storage: '120GB SSD',
},
},
{
id: 'node-2',
name: 'Node 2',
type: 'unassigned',
status: 'pending',
macAddress: '00:1A:2B:3C:4D:5F',
osVersion: 'v13.0.5',
specs: {
cpu: '8 cores',
memory: '16GB RAM',
storage: '500GB SSD',
},
},
]);
const getStatusIcon = (status: Node['status']) => {
switch (status) {
case 'connected':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'connecting':
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
default:
return <Monitor className="h-5 w-5 text-muted-foreground" />;
}
};
const getStatusBadge = (status: Node['status']) => {
const variants = {
pending: 'secondary',
connecting: 'default',
connected: 'success',
healthy: 'success',
error: 'destructive',
} as const;
const labels = {
pending: 'Pending',
connecting: 'Connecting',
connected: 'Connected',
healthy: 'Healthy',
error: 'Error',
};
return (
<Badge variant={variants[status] as any}>
{labels[status]}
</Badge>
);
};
const getTypeIcon = (type: Node['type']) => {
return type === 'controller' ? (
<Cpu className="h-4 w-4" />
) : (
<HardDrive className="h-4 w-4" />
);
};
const handleNodeAction = (nodeId: string, action: 'connect' | 'retry' | 'upgrade_node') => {
console.log(`${action} node: ${nodeId}`);
};
const connectedNodes = nodes.filter(node => node.status === 'connected').length;
const assignedNodes = nodes.filter(node => node.type !== 'unassigned');
const unassignedNodes = nodes.filter(node => node.type === 'unassigned');
const totalNodes = nodes.length;
const isComplete = connectedNodes === totalNodes;
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-950/20 dark:to-blue-950/20 border-cyan-200 dark:border-cyan-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-cyan-100 dark:bg-cyan-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-cyan-600 dark:text-cyan-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-cyan-900 dark:text-cyan-100 mb-2">
What are Cluster Nodes?
</h3>
<p className="text-cyan-800 dark:text-cyan-200 mb-3 leading-relaxed">
Think of cluster nodes as the "workers" in your personal cloud factory. Each node is a separate computer
that contributes its processing power, memory, and storage to the overall cluster. Some nodes are "controllers"
(like managers) that coordinate the work, while others are "workers" that do the heavy lifting.
</p>
<p className="text-cyan-700 dark:text-cyan-300 mb-4 text-sm">
By connecting multiple computers together as nodes, you create a powerful, resilient system where if one
computer fails, the others can pick up the work. This is how you scale your personal cloud from one machine to many.
</p>
<Button variant="outline" size="sm" className="text-cyan-700 border-cyan-300 hover:bg-cyan-100 dark:text-cyan-300 dark:border-cyan-700 dark:hover:bg-cyan-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about distributed computing
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Network className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Cluster Nodes</h2>
<p className="text-muted-foreground">
Connect machines to your wild-cloud
</p>
</div>
</div>
<div className="space-y-4">
<h2 className="text-lg font-medium mb-4">Assigned Nodes ({assignedNodes.length}/{totalNodes})</h2>
{assignedNodes.map((node) => (
<Card key={node.id} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
{getTypeIcon(node.type)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{node.name}</h4>
<Badge variant="outline" className="text-xs">
{node.type}
</Badge>
{getStatusIcon(node.status)}
</div>
<div className="text-sm text-muted-foreground mb-2">
MAC: {node.macAddress}
{node.ipAddress && ` • IP: ${node.ipAddress}`}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{node.specs.cpu}
</span>
<span className="flex items-center gap-1">
<Monitor className="h-3 w-3" />
{node.specs.memory}
</span>
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{node.specs.storage}
</span>
{node.osVersion && (
<span className="flex items-center gap-1">
<Badge variant="outline" className="text-xs">
OS: {node.osVersion}
</Badge>
</span>
)}
</div>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(node.status)}
{node.osVersion !== currentOsVersion && (
<Button
size="sm"
onClick={() => handleNodeAction(node.id, 'upgrade_node')}
>
Upgrade OS
</Button>
)}
{node.status === 'error' && (
<Button
size="sm"
variant="outline"
onClick={() => handleNodeAction(node.id, 'retry')}
>
Retry
</Button>
)}
</div>
</div>
</Card>
))}
</div>
<h2 className="text-lg font-medium mb-4 mt-6">Unassigned Nodes ({unassignedNodes.length}/{totalNodes})</h2>
<div className="space-y-4">
{unassignedNodes.map((node) => (
<Card key={node.id} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
{getTypeIcon(node.type)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{node.name}</h4>
<Badge variant="outline" className="text-xs">
{node.type}
</Badge>
{getStatusIcon(node.status)}
</div>
<div className="text-sm text-muted-foreground mb-2">
MAC: {node.macAddress}
{node.ipAddress && ` • IP: ${node.ipAddress}`}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{node.specs.cpu}
</span>
<span className="flex items-center gap-1">
<Monitor className="h-3 w-3" />
{node.specs.memory}
</span>
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{node.specs.storage}
</span>
</div>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(node.status)}
{node.status === 'pending' && (
<Button
size="sm"
onClick={() => handleNodeAction(node.id, 'connect')}
>
Assign
</Button>
)}
{node.status === 'error' && (
<Button
size="sm"
variant="outline"
onClick={() => handleNodeAction(node.id, 'retry')}
>
Retry
</Button>
)}
</div>
</div>
</Card>
))}
</div>
{isComplete && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<h3 className="font-medium text-green-800 dark:text-green-200">
Infrastructure Ready!
</h3>
</div>
<p className="text-sm text-green-700 dark:text-green-300 mb-3">
All nodes are connected and ready for Kubernetes installation.
</p>
<Button onClick={onComplete} className="bg-green-600 hover:bg-green-700">
Continue to Kubernetes Installation
</Button>
</div>
)}
</Card>
<Card className="p-6">
<h3 className="text-lg font-medium mb-4">PXE Boot Instructions</h3>
<div className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
1
</div>
<div>
<p className="font-medium">Power on your nodes</p>
<p className="text-muted-foreground">
Ensure network boot (PXE) is enabled in BIOS/UEFI settings
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
2
</div>
<div>
<p className="font-medium">Connect to the wild-cloud network</p>
<p className="text-muted-foreground">
Nodes will automatically receive IP addresses via DHCP
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
3
</div>
<div>
<p className="font-medium">Boot Talos Linux</p>
<p className="text-muted-foreground">
Nodes will automatically download and boot Talos Linux via PXE
</p>
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -1,299 +0,0 @@
import { useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Container, Shield, Network, Database, CheckCircle, AlertCircle, Clock, Terminal, FileText, BookOpen, ExternalLink } from 'lucide-react';
interface ClusterServicesComponentProps {
onComplete?: () => void;
}
interface ClusterComponent {
id: string;
name: string;
description: string;
status: 'pending' | 'installing' | 'ready' | 'error';
version?: string;
logs?: string[];
}
export function ClusterServicesComponent({ onComplete }: ClusterServicesComponentProps) {
const [components, setComponents] = useState<ClusterComponent[]>([
{
id: 'talos-config',
name: 'Talos Configuration',
description: 'Generate and apply Talos cluster configuration',
status: 'pending',
},
{
id: 'kubernetes-bootstrap',
name: 'Kubernetes Bootstrap',
description: 'Initialize Kubernetes control plane',
status: 'pending',
version: 'v1.29.0',
},
{
id: 'cni-plugin',
name: 'Container Network Interface',
description: 'Install and configure Cilium CNI',
status: 'pending',
version: 'v1.14.5',
},
{
id: 'storage-class',
name: 'Storage Classes',
description: 'Configure persistent volume storage',
status: 'pending',
},
{
id: 'ingress-controller',
name: 'Ingress Controller',
description: 'Install Traefik ingress controller',
status: 'pending',
version: 'v3.0.0',
},
{
id: 'monitoring',
name: 'Cluster Monitoring',
description: 'Deploy Prometheus and Grafana stack',
status: 'pending',
},
]);
const [showLogs, setShowLogs] = useState<string | null>(null);
const getStatusIcon = (status: ClusterComponent['status']) => {
switch (status) {
case 'ready':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'installing':
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
default:
return null;
}
};
const getStatusBadge = (status: ClusterComponent['status']) => {
const variants = {
pending: 'secondary',
installing: 'default',
ready: 'success',
error: 'destructive',
} as const;
const labels = {
pending: 'Pending',
installing: 'Installing',
ready: 'Ready',
error: 'Error',
};
return (
<Badge variant={variants[status] as any}>
{labels[status]}
</Badge>
);
};
const getComponentIcon = (id: string) => {
switch (id) {
case 'talos-config':
return <FileText className="h-5 w-5" />;
case 'kubernetes-bootstrap':
return <Container className="h-5 w-5" />;
case 'cni-plugin':
return <Network className="h-5 w-5" />;
case 'storage-class':
return <Database className="h-5 w-5" />;
case 'ingress-controller':
return <Shield className="h-5 w-5" />;
case 'monitoring':
return <Terminal className="h-5 w-5" />;
default:
return <Container className="h-5 w-5" />;
}
};
const handleComponentAction = (componentId: string, action: 'install' | 'retry') => {
console.log(`${action} component: ${componentId}`);
};
const readyComponents = components.filter(component => component.status === 'ready').length;
const totalComponents = components.length;
const isComplete = readyComponents === totalComponents;
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-indigo-950/20 dark:to-purple-950/20 border-indigo-200 dark:border-indigo-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-indigo-900 dark:text-indigo-100 mb-2">
What are Cluster Services?
</h3>
<p className="text-indigo-800 dark:text-indigo-200 mb-3 leading-relaxed">
Cluster services are like the "essential utilities" that make your personal cloud actually work. Just like a city
needs electricity, water, and roads, your cluster needs networking, storage, monitoring, and security services.
These services run automatically in the background to keep everything functioning smoothly.
</p>
<p className="text-indigo-700 dark:text-indigo-300 mb-4 text-sm">
Services like Kubernetes orchestration, container networking, ingress routing, and monitoring work together to
create a robust platform where you can easily deploy and manage your applications.
</p>
<Button variant="outline" size="sm" className="text-indigo-700 border-indigo-300 hover:bg-indigo-100 dark:text-indigo-300 dark:border-indigo-700 dark:hover:bg-indigo-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about Kubernetes services
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Container className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Cluster Services</h2>
<p className="text-muted-foreground">
Install and configure essential cluster services
</p>
</div>
</div>
<div className="flex items-center justify-between mb-4">
<pre className="text-xs text-muted-foreground bg-muted p-2 rounded-lg">
endpoint: civil<br/>
endpointIp: 192.168.8.240<br/>
kubernetes:<br/>
config: /home/payne/.kube/config<br/>
context: default<br/>
loadBalancerRange: 192.168.8.240-192.168.8.250<br/>
dashboard:<br/>
adminUsername: admin<br/>
certManager:<br/>
namespace: cert-manager<br/>
cloudflare:<br/>
domain: payne.io<br/>
ownerId: cloud-payne-io-cluster<br/>
</pre>
</div>
<div className="space-y-4">
{components.map((component) => (
<div key={component.id}>
<div className="flex items-center gap-4 p-4 rounded-lg border bg-card">
<div className="p-2 bg-muted rounded-lg">
{getComponentIcon(component.id)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium">{component.name}</h3>
{component.version && (
<Badge variant="outline" className="text-xs">
{component.version}
</Badge>
)}
{getStatusIcon(component.status)}
</div>
<p className="text-sm text-muted-foreground">{component.description}</p>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(component.status)}
{(component.status === 'installing' || component.status === 'error') && (
<Button
size="sm"
variant="outline"
onClick={() => setShowLogs(showLogs === component.id ? null : component.id)}
>
<Terminal className="h-4 w-4 mr-1" />
Logs
</Button>
)}
{component.status === 'pending' && (
<Button
size="sm"
onClick={() => handleComponentAction(component.id, 'install')}
>
Install
</Button>
)}
{component.status === 'error' && (
<Button
size="sm"
variant="outline"
onClick={() => handleComponentAction(component.id, 'retry')}
>
Retry
</Button>
)}
</div>
</div>
{showLogs === component.id && (
<Card className="mt-2 p-4 bg-black text-green-400 font-mono text-sm">
<div className="max-h-40 overflow-y-auto">
<div>Installing {component.name}...</div>
<div> Checking prerequisites</div>
<div> Downloading manifests</div>
{component.status === 'installing' && (
<div className="animate-pulse"> Applying configuration...</div>
)}
{component.status === 'error' && (
<div className="text-red-400"> Installation failed: timeout waiting for pods</div>
)}
</div>
</Card>
)}
</div>
))}
</div>
{isComplete && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<h3 className="font-medium text-green-800 dark:text-green-200">
Kubernetes Cluster Ready!
</h3>
</div>
<p className="text-sm text-green-700 dark:text-green-300 mb-3">
Your Kubernetes cluster is fully configured and ready for application deployment.
</p>
<Button onClick={onComplete} className="bg-green-600 hover:bg-green-700">
Continue to App Management
</Button>
</div>
)}
</Card>
<Card className="p-6">
<h3 className="text-lg font-medium mb-4">Cluster Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<div className="font-medium mb-2">Control Plane</div>
<div className="space-y-1 text-muted-foreground">
<div> API Server: https://cluster.wildcloud.local:6443</div>
<div> Nodes: 1 controller, 2 workers</div>
<div> Version: Kubernetes v1.29.0</div>
</div>
</div>
<div>
<div className="font-medium mb-2">Network Configuration</div>
<div className="space-y-1 text-muted-foreground">
<div> Pod CIDR: 10.244.0.0/16</div>
<div> Service CIDR: 10.96.0.0/12</div>
<div> CNI: Cilium v1.14.5</div>
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -1,125 +0,0 @@
import { useState, useEffect } from 'react';
import { Settings, Save, X } from 'lucide-react';
import { useConfigYaml } from '../hooks';
import { Button, Textarea } from './ui';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger} from '@/components/ui/dialog';
export function ConfigEditor() {
const { yamlContent, isLoading, error, isEndpointMissing, updateYaml, refetch } = useConfigYaml();
const [editedContent, setEditedContent] = useState('');
const [hasChanges, setHasChanges] = useState(false);
// Update edited content when YAML content changes
useEffect(() => {
if (yamlContent) {
setEditedContent(yamlContent);
setHasChanges(false);
}
}, [yamlContent]);
// Track changes
useEffect(() => {
setHasChanges(editedContent !== yamlContent);
}, [editedContent, yamlContent]);
const handleSave = () => {
if (!hasChanges) return;
updateYaml(editedContent, {
onSuccess: () => {
setHasChanges(false);
},
onError: (err) => {
console.error('Failed to update config:', err);
}
});
};
const handleOpenChange = (open: boolean) => {
if (!open && hasChanges) {
if (!window.confirm('You have unsaved changes. Close anyway?')) {
return;
}
}
if (open) {
refetch();
}
};
return (
<Dialog onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button>
<Settings className="h-4 w-4" />
Config
</Button>
</DialogTrigger>
<DialogContent className="max-w-6xl w-full max-h-[80vh] h-full flex flex-col">
<DialogHeader>
<DialogTitle>
Configuration Editor
</DialogTitle>
<DialogDescription>
Edit the raw YAML configuration file. This provides direct access to all configuration options.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col flex-1">
{error && error instanceof Error && error.message && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-sm text-red-800 dark:text-red-200">
Error: {error.message}
</p>
</div>
)}
{isEndpointMissing && (
<div className="p-3 bg-orange-50 dark:bg-orange-950 border border-orange-200 dark:border-orange-800 rounded-md">
<p className="text-sm text-orange-800 dark:text-orange-200">
Backend endpoints missing. Raw YAML editing not available.
</p>
</div>
)}
<Textarea
value={editedContent}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setEditedContent(e.target.value)}
placeholder={isLoading ? "Loading YAML configuration..." : "No configuration found"}
disabled={isLoading || !!isEndpointMissing}
className="font-mono text-sm w-full flex-1 min-h-0 resize-none"
/>
{hasChanges && (
<div className="text-sm text-orange-600 dark:text-orange-400">
You have unsaved changes
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">
Cancel
</Button>
</DialogClose>
<Button
onClick={handleSave}
disabled={!hasChanges || isLoading || !!isEndpointMissing}
>
Update Config
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,297 +0,0 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FileText, Check, AlertCircle, Loader2 } from 'lucide-react';
import { useConfig, useMessages } from '../hooks';
import { configFormSchema, defaultConfigValues, type ConfigFormData } from '../schemas/config';
import {
Card,
CardHeader,
CardTitle,
CardContent,
Button,
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
Input,
} from './ui';
export const ConfigurationForm = () => {
const {
config,
isConfigured,
showConfigSetup,
isLoading,
isCreating,
error,
createConfig,
refetch
} = useConfig();
const form = useForm<ConfigFormData>({
resolver: zodResolver(configFormSchema),
defaultValues: defaultConfigValues,
});
const onSubmit = (data: ConfigFormData) => {
createConfig(data);
};
return (
<Card>
<CardHeader>
<CardTitle>Configuration (With Form Validation)</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<Button onClick={() => refetch()} disabled={isLoading} variant="outline">
<FileText className="mr-2 h-4 w-4" />
{isLoading ? 'Loading...' : 'Reload Configuration'}
</Button>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Configuration Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
</div>
</div>
)}
{showConfigSetup && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Initial Configuration Setup</h3>
<p className="text-sm text-muted-foreground">Configure your wild-cloud central server settings with real-time validation.</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Server Configuration */}
<div className="space-y-4">
<h4 className="text-md font-medium text-foreground">Server Configuration</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="server.host"
render={({ field }) => (
<FormItem>
<FormLabel>Server Host</FormLabel>
<FormControl>
<Input placeholder="0.0.0.0" {...field} />
</FormControl>
<FormDescription>
The host address the server will bind to
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="server.port"
render={({ field }) => (
<FormItem>
<FormLabel>Server Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="5055"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormDescription>
The port the server will listen on
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Cloud Configuration */}
<div className="space-y-4">
<h4 className="text-md font-medium text-foreground">Cloud Configuration</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="cloud.domain"
render={({ field }) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input placeholder="wildcloud.local" {...field} />
</FormControl>
<FormDescription>
The main domain for your wild-cloud
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.internalDomain"
render={({ field }) => (
<FormItem>
<FormLabel>Internal Domain</FormLabel>
<FormControl>
<Input placeholder="cluster.local" {...field} />
</FormControl>
<FormDescription>
The internal cluster domain
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.dns.ip"
render={({ field }) => (
<FormItem>
<FormLabel>DNS Server IP</FormLabel>
<FormControl>
<Input placeholder="192.168.8.50" {...field} />
</FormControl>
<FormDescription>
The IP address of the DNS server
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.router.ip"
render={({ field }) => (
<FormItem>
<FormLabel>Router IP</FormLabel>
<FormControl>
<Input placeholder="192.168.8.1" {...field} />
</FormControl>
<FormDescription>
The IP address of the network router
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.dhcpRange"
render={({ field }) => (
<FormItem>
<FormLabel>DHCP Range</FormLabel>
<FormControl>
<Input placeholder="192.168.8.100,192.168.8.200" {...field} />
</FormControl>
<FormDescription>
DHCP IP range in format: start_ip,end_ip
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.dnsmasq.interface"
render={({ field }) => (
<FormItem>
<FormLabel>Network Interface</FormLabel>
<FormControl>
<Input placeholder="eth0" {...field} />
</FormControl>
<FormDescription>
The network interface for dnsmasq to use
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Cluster Configuration */}
<div className="space-y-4">
<h4 className="text-md font-medium text-foreground">Cluster Configuration</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="cluster.endpointIp"
render={({ field }) => (
<FormItem>
<FormLabel>Cluster Endpoint IP</FormLabel>
<FormControl>
<Input placeholder="192.168.8.60" {...field} />
</FormControl>
<FormDescription>
The IP address of the cluster endpoint
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cluster.nodes.talos.version"
render={({ field }) => (
<FormItem>
<FormLabel>Talos Version</FormLabel>
<FormControl>
<Input placeholder="v1.8.0" {...field} />
</FormControl>
<FormDescription>
The version of Talos Linux to use
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button type="submit" disabled={isCreating} className="w-full">
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating Configuration...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Create Configuration
</>
)}
</Button>
</form>
</Form>
</div>
)}
{config && isConfigured && (
<div className="space-y-2">
<div className="p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md">
<p className="text-sm text-green-800 dark:text-green-200">
Configuration loaded successfully
</p>
</div>
<pre className="p-4 bg-muted rounded-md text-sm overflow-auto max-h-96">
{JSON.stringify(config, null, 2)}
</pre>
</div>
)}
<div className="text-xs text-muted-foreground">
Form Validation Status: {form.formState.isValid ? '✓ Valid' : '⚠ Has Errors'} |
Errors: {Object.keys(form.formState.errors).length}
</div>
</CardContent>
</Card>
);
};

View File

@@ -1,156 +0,0 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FileText, Check, AlertCircle, Loader2 } from 'lucide-react';
import { useConfig, useMessages } from '../hooks';
import { configFormSchema, defaultConfigValues, type ConfigFormData } from '../schemas/config';
import { Message } from './Message';
import { Card, CardHeader, CardTitle, CardContent, Button, Form, FormField, FormItem, FormLabel, FormControl, FormMessage, Input } from './ui';
export const ConfigurationSection = () => {
const {
config,
isConfigured,
showConfigSetup,
isLoading,
isCreating,
error,
createConfig,
refetch
} = useConfig();
const { messages } = useMessages();
const form = useForm<ConfigFormData>({
resolver: zodResolver(configFormSchema),
defaultValues: defaultConfigValues,
});
const onSubmit = (data: ConfigFormData) => {
createConfig(data);
};
return (
<Card>
<CardHeader>
<CardTitle>Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Button onClick={() => refetch()} disabled={isLoading} variant="outline">
<FileText className="mr-2 h-4 w-4" />
{isLoading ? 'Loading...' : 'Reload Configuration'}
</Button>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Configuration Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
</div>
</div>
)}
<Message message={messages.config} />
{showConfigSetup && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium">Initial Configuration Setup</h3>
<p className="text-sm text-muted-foreground">Configure key settings for your wild-cloud central server:</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="cloud.domain"
render={({ field }) => (
<FormItem>
<FormLabel>Cloud Domain</FormLabel>
<FormControl>
<Input placeholder="wildcloud.local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.dns.ip"
render={({ field }) => (
<FormItem>
<FormLabel>DNS Server IP</FormLabel>
<FormControl>
<Input placeholder="192.168.8.50" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.router.ip"
render={({ field }) => (
<FormItem>
<FormLabel>Router IP</FormLabel>
<FormControl>
<Input placeholder="192.168.8.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.dnsmasq.interface"
render={({ field }) => (
<FormItem>
<FormLabel>Network Interface</FormLabel>
<FormControl>
<Input placeholder="eth0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" disabled={isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Create Configuration
</>
)}
</Button>
</form>
</Form>
</div>
)}
{config && isConfigured && (
<div className="space-y-2">
<div className="p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md">
<p className="text-sm text-green-800 dark:text-green-200">
Configuration loaded successfully
</p>
</div>
<pre className="p-4 bg-muted rounded-md text-sm overflow-auto max-h-96">
{JSON.stringify(config, null, 2)}
</pre>
</div>
)}
{/* Debug info */}
<div className="text-xs text-muted-foreground">
React Query Status: isLoading={isLoading.toString()}, isConfigured={isConfigured.toString()}, showSetup={showConfigSetup.toString()}
</div>
</CardContent>
</Card>
);
};

View File

@@ -1,77 +0,0 @@
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Wifi, HelpCircle, BookOpen, ExternalLink } from 'lucide-react';
import { Input, Label } from './ui';
export function DhcpComponent() {
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-purple-50 to-violet-50 dark:from-purple-950/20 dark:to-violet-950/20 border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-purple-900 dark:text-purple-100 mb-2">
What is DHCP?
</h3>
<p className="text-purple-800 dark:text-purple-200 mb-3 leading-relaxed">
DHCP (Dynamic Host Configuration Protocol) is like an automatic "address assignment system" for your network.
When a device joins your network, DHCP automatically gives it an IP address, tells it how to connect to the internet,
and provides other network settings - no manual configuration needed!
</p>
<p className="text-purple-700 dark:text-purple-300 mb-4 text-sm">
Without DHCP, you'd need to manually assign IP addresses to every device. DHCP makes it so you can just connect
a phone, laptop, or smart device and it automatically gets everything it needs to work on your network.
</p>
<Button variant="outline" size="sm" className="text-purple-700 border-purple-300 hover:bg-purple-100 dark:text-purple-300 dark:border-purple-700 dark:hover:bg-purple-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about DHCP
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Wifi className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">DHCP Configuration</h2>
<p className="text-muted-foreground">
Manage DHCP settings and IP address allocation
</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<span className="text-sm font-medium">Status:</span>
<span className="text-sm text-green-600">Active</span>
</div>
<div>
<Label htmlFor="dhcpRange">IP Range</Label>
<div className="flex w-full items-center mt-1">
<Input id="dhcpRange" value="192.168.8.100,192.168.8.239"/>
<Button variant="ghost">
<HelpCircle/>
</Button>
</div>
</div>
<div className="flex gap-2 justify-end mt-4">
<Button variant="outline" onClick={() => console.log('View DHCP clients')}>
View Clients
</Button>
<Button onClick={() => console.log('Configure DHCP')}>
Configure
</Button>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -1,73 +0,0 @@
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Globe, CheckCircle, BookOpen, ExternalLink } from 'lucide-react';
export function DnsComponent() {
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-green-100 dark:bg-green-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-green-900 dark:text-green-100 mb-2">
What is DNS?
</h3>
<p className="text-green-800 dark:text-green-200 mb-3 leading-relaxed">
DNS (Domain Name System) is like the "phone book" of the internet. Instead of remembering complex IP addresses
like "192.168.1.100", you can use friendly names like "my-server.local". When you type a name, DNS translates
it to the correct IP address so your devices can find each other.
</p>
<p className="text-green-700 dark:text-green-300 mb-4 text-sm">
Your personal cloud runs its own DNS service so devices can easily find services like "photos.home" or "media.local"
without needing to remember numbers.
</p>
<Button variant="outline" size="sm" className="text-green-700 border-green-300 hover:bg-green-100 dark:text-green-300 dark:border-green-700 dark:hover:bg-green-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about DNS
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Globe className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">DNS Configuration</h2>
<p className="text-muted-foreground">
Manage DNS settings and domain resolution
</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-sm">Local resolution: Active</span>
</div>
<div className="mt-4">
<h4 className="font-medium mb-2">DNS Status</h4>
<p className="text-sm text-muted-foreground">
DNS service is running and resolving domains correctly.
</p>
</div>
<div className="flex gap-2 justify-end mt-4">
<Button variant="outline" onClick={() => console.log('Test DNS')}>
Test DNS
</Button>
<Button onClick={() => console.log('Configure DNS')}>
Configure
</Button>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -1,86 +0,0 @@
import { Settings, RotateCcw, AlertCircle } from 'lucide-react';
import { useDnsmasq, useMessages } from '../hooks';
import { Message } from './Message';
import { Card, CardHeader, CardTitle, CardContent, Button } from './ui';
export const DnsmasqSection = () => {
const {
dnsmasqConfig,
generateConfig,
isGenerating,
generateError,
restart,
isRestarting,
restartError,
restartData
} = useDnsmasq();
const { messages, setMessage } = useMessages();
// Handle success/error messaging
if (generateError) {
setMessage('dnsmasq', `Failed to generate dnsmasq config: ${generateError.message}`, 'error');
} else if (dnsmasqConfig) {
setMessage('dnsmasq', 'Dnsmasq config generated successfully', 'success');
}
if (restartError) {
setMessage('dnsmasq', `Failed to restart dnsmasq: ${restartError.message}`, 'error');
} else if (restartData) {
setMessage('dnsmasq', `Dnsmasq restart: ${restartData.status}`, 'success');
}
return (
<Card>
<CardHeader>
<CardTitle>DNS/DHCP Management</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Button onClick={() => generateConfig()} disabled={isGenerating} variant="outline">
<Settings className="mr-2 h-4 w-4" />
{isGenerating ? 'Generating...' : 'Generate Dnsmasq Config'}
</Button>
<Button onClick={() => restart()} disabled={isRestarting} variant="outline">
<RotateCcw className={`mr-2 h-4 w-4 ${isRestarting ? 'animate-spin' : ''}`} />
{isRestarting ? 'Restarting...' : 'Restart Dnsmasq'}
</Button>
</div>
{generateError && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Generation Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{generateError.message}</p>
</div>
</div>
)}
{restartError && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Restart Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{restartError.message}</p>
</div>
</div>
)}
{restartData && (
<div className="p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md">
<p className="text-sm text-green-800 dark:text-green-200">
Dnsmasq restart: {restartData.status}
</p>
</div>
)}
<Message message={messages.dnsmasq} />
{dnsmasqConfig && (
<pre className="p-4 bg-muted rounded-md text-sm overflow-auto max-h-96">
{dnsmasqConfig}
</pre>
)}
</CardContent>
</Card>
);
};

View File

@@ -1,166 +0,0 @@
import React, { Component as ReactComponent, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import { Button } from './ui/button';
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
interface Props {
children?: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error?: Error;
errorInfo?: ErrorInfo;
}
export class ErrorBoundary extends ReactComponent<Props, State> {
public state: State = {
hasError: false,
};
public static getDerivedStateFromError(error: Error): State {
// Update state so the next render will show the fallback UI
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({
error,
errorInfo,
});
// Call optional error handler
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
}
private handleReset = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
};
private handleReload = () => {
window.location.reload();
};
public render() {
if (this.state.hasError) {
// If a custom fallback is provided, use it
if (this.props.fallback) {
return this.props.fallback;
}
// Default error UI
return <ErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo}
onReset={this.handleReset}
onReload={this.handleReload}
/>;
}
return this.props.children;
}
}
interface ErrorFallbackProps {
error?: Error;
errorInfo?: ErrorInfo;
onReset: () => void;
onReload: () => void;
}
export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
error,
errorInfo,
onReset,
onReload
}) => {
const isDev = process.env.NODE_ENV === 'development';
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg">
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
</div>
<div>
<CardTitle className="text-red-800 dark:text-red-200">
Something went wrong
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
The application encountered an unexpected error
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Don't worry, your data is safe. You can try the following options:
</p>
<div className="flex gap-2">
<Button onClick={onReset} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
<Button onClick={onReload} variant="outline" size="sm">
<Home className="h-4 w-4 mr-2" />
Reload Page
</Button>
</div>
</div>
{isDev && error && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-red-800 dark:text-red-200">
Error Details (Development Mode)
</h4>
<div className="space-y-2">
<div>
<p className="text-xs font-medium text-muted-foreground">Error Message:</p>
<p className="text-xs bg-red-50 p-2 rounded border font-mono">
{error.message}
</p>
</div>
{error.stack && (
<div>
<p className="text-xs font-medium text-muted-foreground">Stack Trace:</p>
<pre className="text-xs p-2 rounded border overflow-auto max-h-40 font-mono">
{error.stack}
</pre>
</div>
)}
{errorInfo?.componentStack && (
<div>
<p className="text-xs font-medium text-muted-foreground">Component Stack:</p>
<pre className="text-xs p-2 rounded border overflow-auto max-h-40 font-mono">
{errorInfo.componentStack}
</pre>
</div>
)}
</div>
</div>
)}
{!isDev && (
<div className="p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md">
<p className="text-sm text-blue-800 dark:text-blue-200">
If this problem persists, please contact support with details about what you were doing when the error occurred.
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -1,47 +0,0 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
import { AlertTriangle } from 'lucide-react';
// Component that can trigger errors for testing
export const ErrorTester = () => {
const [shouldThrow, setShouldThrow] = useState(false);
if (shouldThrow) {
throw new Error('Test error: This is a simulated component crash for testing the error boundary.');
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-600" />
Error Boundary Tester
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
This component can be used to test the error boundary functionality in development.
</p>
<div className="p-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-md">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
Warning: Clicking the button below will intentionally crash this component to test error handling.
</p>
</div>
<Button
onClick={() => setShouldThrow(true)}
variant="destructive"
size="sm"
>
Trigger Error
</Button>
<div className="text-xs text-muted-foreground">
Development tool - remove from production builds
</div>
</CardContent>
</Card>
);
};

View File

@@ -1,43 +0,0 @@
import { AlertCircle, CheckCircle, Info } from 'lucide-react';
import type { Message as MessageType } from '../types';
import { cn } from '@/lib/utils';
interface MessageProps {
message?: MessageType;
}
export const Message = ({ message }: MessageProps) => {
if (!message) return null;
const getIcon = () => {
switch (message.type) {
case 'error':
return <AlertCircle className="h-4 w-4" />;
case 'success':
return <CheckCircle className="h-4 w-4" />;
default:
return <Info className="h-4 w-4" />;
}
};
const getVariantStyles = () => {
switch (message.type) {
case 'error':
return 'border-destructive/50 text-destructive bg-destructive/10';
case 'success':
return 'border-green-500/50 text-green-700 bg-green-50 dark:bg-green-950 dark:text-green-400';
default:
return 'border-blue-500/50 text-blue-700 bg-blue-50 dark:bg-blue-950 dark:text-blue-400';
}
};
return (
<div className={cn(
'flex items-center gap-2 p-3 rounded-md border text-sm',
getVariantStyles()
)}>
{getIcon()}
<span>{message.message}</span>
</div>
);
};

View File

@@ -1,49 +0,0 @@
import { Download, AlertCircle } from 'lucide-react';
import { useAssets, useMessages } from '../hooks';
import { Message } from './Message';
import { Card, CardHeader, CardTitle, CardContent, Button } from './ui';
export const PxeAssetsSection = () => {
const { downloadAssets, isDownloading, error, data } = useAssets();
const { messages, setMessage } = useMessages();
// Handle success/error messaging
if (error) {
setMessage('assets', `Failed to download assets: ${error.message}`, 'error');
} else if (data) {
setMessage('assets', `PXE Assets: ${data.status}`, 'success');
}
return (
<Card>
<CardHeader>
<CardTitle>PXE Boot Assets</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Button onClick={() => downloadAssets()} disabled={isDownloading} variant="outline">
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Downloading...' : 'Download/Update PXE Assets'}
</Button>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Download Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
</div>
</div>
)}
{data && (
<div className="p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md">
<p className="text-sm text-green-800 dark:text-green-200">
PXE Assets: {data.status}
</p>
</div>
)}
<Message message={messages.assets} />
</CardContent>
</Card>
);
};

View File

@@ -1,73 +0,0 @@
import { Card } from './ui/card';
import { Button } from './ui/button';
import { HardDrive, BookOpen, ExternalLink } from 'lucide-react';
export function PxeComponent() {
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-950/20 dark:to-amber-950/20 border-orange-200 dark:border-orange-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-orange-900 dark:text-orange-100 mb-2">
What is PXE Boot?
</h3>
<p className="text-orange-800 dark:text-orange-200 mb-3 leading-relaxed">
PXE (Preboot Execution Environment) is like having a "network installer" that can set up computers without
needing USB drives or DVDs. When you turn on a computer, instead of booting from its hard drive, it can boot
from the network and automatically install an operating system or run diagnostics.
</p>
<p className="text-orange-700 dark:text-orange-300 mb-4 text-sm">
This is especially useful for setting up multiple computers in your cloud infrastructure. PXE can automatically
install and configure the same operating system on many machines, making it easy to expand your personal cloud.
</p>
<Button variant="outline" size="sm" className="text-orange-700 border-orange-300 hover:bg-orange-100 dark:text-orange-300 dark:border-orange-700 dark:hover:bg-orange-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about network booting
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<HardDrive className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">PXE Configuration</h2>
<p className="text-muted-foreground">
Manage PXE boot assets and network boot configuration
</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<span className="text-sm font-medium">Status:</span>
<span className="text-sm text-green-600">Active</span>
</div>
<div>
<h4 className="font-medium mb-2">Boot Assets</h4>
<p className="text-sm text-muted-foreground mb-4">
Manage Talos Linux boot images and iPXE configurations for network booting.
</p>
</div>
<div className="flex gap-2 justify-end mt-4">
<Button variant="outline" onClick={() => console.log('View assets')}>
View Assets
</Button>
<Button onClick={() => console.log('Download PXE assets')}>
Download Assets
</Button>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -1,72 +0,0 @@
import { Server, RefreshCw } from 'lucide-react';
import { useStatus } from '../hooks';
import { Card, CardHeader, CardTitle, CardContent, Button } from './ui';
export const StatusSection = () => {
const { data: status, isLoading, error, refetch } = useStatus();
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Server Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span>Current Status</span>
<Button
onClick={() => refetch()}
disabled={isLoading}
variant="outline"
size="sm"
>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
{isLoading ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-sm text-red-800 dark:text-red-200">
Failed to fetch status: {error.message}
</p>
</div>
)}
{status && (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-sm font-medium">Status</span>
<p className="text-muted-foreground">{status.status}</p>
</div>
<div>
<span className="text-sm font-medium">Version</span>
<p className="text-muted-foreground">{status.version}</p>
</div>
</div>
{status.uptime && (
<div>
<span className="text-sm font-medium">Uptime</span>
<p className="text-muted-foreground">{status.uptime}</p>
</div>
)}
<pre className="p-4 bg-muted rounded-md text-sm overflow-auto max-h-48">
{JSON.stringify(status, null, 2)}
</pre>
</div>
)}
{isLoading && !status && (
<div className="flex items-center justify-center p-8">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -1,94 +0,0 @@
import { RefreshCw, Activity, AlertCircle } from 'lucide-react';
import { useStatus, useHealth, useMessages } from '../hooks';
import { formatTimestamp } from '../utils/formatters';
import { Message } from './Message';
import { Card, CardHeader, CardTitle, CardContent, Button, Badge } from './ui';
export const SystemStatus = () => {
const { data: status, isLoading: statusLoading, error: statusError, refetch } = useStatus();
const { mutate: checkHealth, isPending: healthLoading, error: healthError, data: healthData } = useHealth();
const { messages, setMessage } = useMessages();
// Handle health check messaging
if (healthError) {
setMessage('health', `Health check failed: ${healthError.message}`, 'error');
} else if (healthData) {
setMessage('health', `Service: ${healthData.service} - Status: ${healthData.status}`, 'success');
}
return (
<Card>
<CardHeader>
<CardTitle>System Status</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Button onClick={() => refetch()} disabled={statusLoading} variant="outline">
<RefreshCw className={`mr-2 h-4 w-4 ${statusLoading ? 'animate-spin' : ''}`} />
{statusLoading ? 'Checking...' : 'Refresh Status'}
</Button>
<Button onClick={() => checkHealth()} disabled={healthLoading} variant="outline">
<Activity className="mr-2 h-4 w-4" />
{healthLoading ? 'Checking...' : 'Check Health'}
</Button>
</div>
{statusError && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Status Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{statusError.message}</p>
</div>
</div>
)}
{healthError && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Health Check Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{healthError.message}</p>
</div>
</div>
)}
{healthData && (
<div className="p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md">
<p className="text-sm text-green-800 dark:text-green-200">
Service: {healthData.service} - Status: {healthData.status}
</p>
</div>
)}
<Message message={messages.health} />
{status && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Status</p>
<Badge
variant={status.status === 'running' ? 'default' : 'destructive'}
className={`text-xs font-medium ${status.status === 'running' ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
>
<div className={`w-2 h-2 rounded-full mr-2 ${status.status === 'running' ? 'bg-emerald-200' : 'bg-red-200'}`} />
{status.status}
</Badge>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Version</p>
<p className="text-sm">{status.version}</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Uptime</p>
<p className="text-sm">{status.uptime}</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Last Updated</p>
<p className="text-sm">{formatTimestamp(status.timestamp)}</p>
</div>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -1,52 +0,0 @@
import { Moon, Sun, Monitor } from 'lucide-react';
import { Button } from './ui/button';
import { useTheme } from '../contexts/ThemeContext';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const cycleTheme = () => {
if (theme === 'light') {
setTheme('dark');
} else if (theme === 'dark') {
setTheme('system');
} else {
setTheme('light');
}
};
const getIcon = () => {
switch (theme) {
case 'light':
return <Sun className="h-4 w-4" />;
case 'dark':
return <Moon className="h-4 w-4" />;
default:
return <Monitor className="h-4 w-4" />;
}
};
const getLabel = () => {
switch (theme) {
case 'light':
return 'Light mode';
case 'dark':
return 'Dark mode';
default:
return 'System theme';
}
};
return (
<Button
variant="ghost"
size="sm"
onClick={cycleTheme}
title={`Current: ${getLabel()}. Click to cycle themes.`}
className="gap-2"
>
{getIcon()}
<span className="text-xs font-medium">{getLabel()}</span>
</Button>
);
}

View File

@@ -1,19 +0,0 @@
export { Message } from './Message';
export { SystemStatus } from './SystemStatus';
export { ConfigurationSection } from './ConfigurationSection';
export { ConfigurationForm } from './ConfigurationForm';
export { StatusSection } from './StatusSection';
export { DnsmasqSection } from './DnsmasqSection';
export { PxeAssetsSection } from './PxeAssetsSection';
export { AppSidebar } from './AppSidebar';
export { Advanced } from './Advanced';
export { ConfigEditor } from './ConfigEditor';
export { ErrorBoundary, ErrorFallback } from './ErrorBoundary';
export { CloudComponent } from './CloudComponent';
export { CentralComponent } from './CentralComponent';
export { DnsComponent } from './DnsComponent';
export { DhcpComponent } from './DhcpComponent';
export { PxeComponent } from './PxeComponent';
export { ClusterNodesComponent } from './ClusterNodesComponent';
export { ClusterServicesComponent } from './ClusterServicesComponent';
export { AppsComponent } from './AppsComponent';

View File

@@ -1,46 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -1,59 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -1,31 +0,0 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -1,141 +0,0 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -1,167 +0,0 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -1,27 +0,0 @@
export { Button, buttonVariants } from './button';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './card';
export { Badge, badgeVariants } from './badge';
export { Input } from './input';
export { Label } from './label';
export { Textarea } from './textarea';
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from './dialog';
export {
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
} from './form';

View File

@@ -1,21 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -1,22 +0,0 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -1,28 +0,0 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -1,137 +0,0 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -1,727 +0,0 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -1,59 +0,0 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,73 +0,0 @@
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'system';
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'wild-central-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider');
return context;
};

View File

@@ -1,165 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { useConfig } from '../useConfig';
import { apiService } from '../../services/api';
// Mock the API service
vi.mock('../../services/api', () => ({
apiService: {
getConfig: vi.fn(),
createConfig: vi.fn(),
},
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
React.createElement(QueryClientProvider, { client: queryClient }, children)
);
};
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should fetch config successfully when configured', async () => {
const mockConfigResponse = {
configured: true,
config: {
server: { host: '0.0.0.0', port: 5055 },
cloud: {
domain: 'wildcloud.local',
internalDomain: 'cluster.local',
dhcpRange: '192.168.8.100,192.168.8.200',
dns: { ip: '192.168.8.50' },
router: { ip: '192.168.8.1' },
dnsmasq: { interface: 'eth0' },
},
cluster: {
endpointIp: '192.168.8.60',
nodes: { talos: { version: 'v1.8.0' } },
},
},
};
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.showConfigSetup).toBe(false);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.config).toEqual(mockConfigResponse.config);
expect(result.current.isConfigured).toBe(true);
expect(result.current.showConfigSetup).toBe(false);
expect(result.current.error).toBeNull();
});
it('should show config setup when not configured', async () => {
const mockConfigResponse = {
configured: false,
message: 'No configuration found',
};
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.config).toBeNull();
expect(result.current.isConfigured).toBe(false);
expect(result.current.showConfigSetup).toBe(true);
});
it('should create config successfully', async () => {
const mockConfigResponse = {
configured: false,
message: 'No configuration found',
};
const mockCreateResponse = {
status: 'Configuration created successfully',
};
const newConfig = {
server: { host: '0.0.0.0', port: 5055 },
cloud: {
domain: 'wildcloud.local',
internalDomain: 'cluster.local',
dhcpRange: '192.168.8.100,192.168.8.200',
dns: { ip: '192.168.8.50' },
router: { ip: '192.168.8.1' },
dnsmasq: { interface: 'eth0' },
},
cluster: {
endpointIp: '192.168.8.60',
nodes: { talos: { version: 'v1.8.0' } },
},
};
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
vi.mocked(apiService.createConfig).mockResolvedValue(mockCreateResponse);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.showConfigSetup).toBe(true);
// Create config
await act(async () => {
result.current.createConfig(newConfig);
});
await waitFor(() => {
expect(result.current.isCreating).toBe(false);
});
expect(apiService.createConfig).toHaveBeenCalledWith(newConfig);
});
it('should handle error when fetching config fails', async () => {
const mockError = new Error('Network error');
vi.mocked(apiService.getConfig).mockRejectedValue(mockError);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toEqual(mockError);
expect(result.current.config).toBeNull();
});
});

View File

@@ -1,127 +0,0 @@
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMessages } from '../useMessages';
describe('useMessages', () => {
it('should initialize with empty messages', () => {
const { result } = renderHook(() => useMessages());
expect(result.current.messages).toEqual({});
});
it('should set a message', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test', 'Test message', 'success');
});
expect(result.current.messages).toEqual({
test: { message: 'Test message', type: 'success' }
});
});
it('should set multiple messages', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('success', 'Success message', 'success');
result.current.setMessage('error', 'Error message', 'error');
result.current.setMessage('info', 'Info message', 'info');
});
expect(result.current.messages).toEqual({
success: { message: 'Success message', type: 'success' },
error: { message: 'Error message', type: 'error' },
info: { message: 'Info message', type: 'info' },
});
});
it('should update existing message', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test', 'First message', 'info');
});
expect(result.current.messages.test).toEqual({
message: 'First message',
type: 'info'
});
act(() => {
result.current.setMessage('test', 'Updated message', 'error');
});
expect(result.current.messages.test).toEqual({
message: 'Updated message',
type: 'error'
});
});
it('should clear a specific message', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test1', 'Message 1', 'info');
result.current.setMessage('test2', 'Message 2', 'success');
});
expect(Object.keys(result.current.messages)).toHaveLength(2);
act(() => {
result.current.clearMessage('test1');
});
expect(result.current.messages).toEqual({
test2: { message: 'Message 2', type: 'success' }
});
});
it('should clear message by setting to null', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test', 'Test message', 'info');
});
expect(result.current.messages.test).toBeDefined();
act(() => {
result.current.setMessage('test', null);
});
expect(result.current.messages.test).toBeUndefined();
});
it('should clear all messages', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test1', 'Message 1', 'info');
result.current.setMessage('test2', 'Message 2', 'success');
result.current.setMessage('test3', 'Message 3', 'error');
});
expect(Object.keys(result.current.messages)).toHaveLength(3);
act(() => {
result.current.clearAllMessages();
});
expect(result.current.messages).toEqual({});
});
it('should default to info type when type not specified', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test', 'Test message');
});
expect(result.current.messages.test).toEqual({
message: 'Test message',
type: 'info'
});
});
});

View File

@@ -1,104 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { useStatus } from '../useStatus';
import { apiService } from '../../services/api';
// Mock the API service
vi.mock('../../services/api', () => ({
apiService: {
getStatus: vi.fn(),
},
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
React.createElement(QueryClientProvider, { client: queryClient }, children)
);
};
describe('useStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should fetch status successfully', async () => {
const mockStatus = {
status: 'running',
version: '1.0.0',
uptime: '2 hours',
timestamp: '2024-01-01T00:00:00Z',
};
vi.mocked(apiService.getStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useStatus(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeUndefined();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual(mockStatus);
expect(result.current.error).toBeNull();
expect(apiService.getStatus).toHaveBeenCalledTimes(1);
});
it('should handle error when fetching status fails', async () => {
const mockError = new Error('Network error');
vi.mocked(apiService.getStatus).mockRejectedValue(mockError);
const { result } = renderHook(() => useStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeUndefined();
expect(result.current.error).toEqual(mockError);
});
it('should refetch data when refetch is called', async () => {
const mockStatus = {
status: 'running',
version: '1.0.0',
uptime: '2 hours',
timestamp: '2024-01-01T00:00:00Z',
};
vi.mocked(apiService.getStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(apiService.getStatus).toHaveBeenCalledTimes(1);
// Trigger refetch
result.current.refetch();
await waitFor(() => {
expect(apiService.getStatus).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -1,7 +0,0 @@
export { useMessages } from './useMessages';
export { useStatus } from './useStatus';
export { useHealth } from './useHealth';
export { useConfig } from './useConfig';
export { useConfigYaml } from './useConfigYaml';
export { useDnsmasq } from './useDnsmasq';
export { useAssets } from './useAssets';

View File

@@ -1,19 +0,0 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -1,19 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { apiService } from '../services/api';
interface AssetsResponse {
status: string;
}
export const useAssets = () => {
const downloadMutation = useMutation<AssetsResponse>({
mutationFn: apiService.downloadPXEAssets,
});
return {
downloadAssets: downloadMutation.mutate,
isDownloading: downloadMutation.isPending,
error: downloadMutation.error,
data: downloadMutation.data,
};
};

View File

@@ -1,52 +0,0 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiService } from '../services/api';
import type { Config } from '../types';
interface ConfigResponse {
configured: boolean;
config?: Config;
message?: string;
}
interface CreateConfigResponse {
status: string;
}
export const useConfig = () => {
const queryClient = useQueryClient();
const [showConfigSetup, setShowConfigSetup] = useState(false);
const configQuery = useQuery<ConfigResponse>({
queryKey: ['config'],
queryFn: () => apiService.getConfig(),
});
// Update showConfigSetup based on query data
useEffect(() => {
if (configQuery.data) {
setShowConfigSetup(configQuery.data.configured === false);
}
}, [configQuery.data]);
const createConfigMutation = useMutation<CreateConfigResponse, Error, Config>({
mutationFn: apiService.createConfig,
onSuccess: () => {
// Invalidate and refetch config after successful creation
queryClient.invalidateQueries({ queryKey: ['config'] });
setShowConfigSetup(false);
},
});
return {
config: configQuery.data?.config || null,
isConfigured: configQuery.data?.configured || false,
showConfigSetup,
setShowConfigSetup,
isLoading: configQuery.isLoading,
isCreating: createConfigMutation.isPending,
error: configQuery.error || createConfigMutation.error,
createConfig: createConfigMutation.mutate,
refetch: configQuery.refetch,
};
};

View File

@@ -1,40 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiService } from '../services/api';
export const useConfigYaml = () => {
const queryClient = useQueryClient();
const configYamlQuery = useQuery({
queryKey: ['config', 'yaml'],
queryFn: () => apiService.getConfigYaml(),
staleTime: 30000, // Consider data fresh for 30 seconds
retry: true,
});
const updateConfigYamlMutation = useMutation({
mutationFn: (data: string) => apiService.updateConfigYaml(data),
onSuccess: () => {
// Invalidate both YAML and JSON config queries
queryClient.invalidateQueries({ queryKey: ['config'] });
},
});
// Check if error is 404 (endpoint doesn't exist)
const isEndpointMissing = configYamlQuery.error &&
configYamlQuery.error instanceof Error &&
configYamlQuery.error.message.includes('404');
// Only pass through real errors
const actualError = (configYamlQuery.error instanceof Error ? configYamlQuery.error : null) ||
(updateConfigYamlMutation.error instanceof Error ? updateConfigYamlMutation.error : null);
return {
yamlContent: configYamlQuery.data || '',
isLoading: configYamlQuery.isLoading,
error: actualError,
isEndpointMissing,
isUpdating: updateConfigYamlMutation.isPending,
updateYaml: updateConfigYamlMutation.mutate,
refetch: configYamlQuery.refetch,
};
};

View File

@@ -1,33 +0,0 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { apiService } from '../services/api';
interface DnsmasqResponse {
status: string;
}
export const useDnsmasq = () => {
const [dnsmasqConfig, setDnsmasqConfig] = useState<string>('');
const generateConfigMutation = useMutation<string>({
mutationFn: apiService.getDnsmasqConfig,
onSuccess: (data) => {
setDnsmasqConfig(data);
},
});
const restartMutation = useMutation<DnsmasqResponse>({
mutationFn: apiService.restartDnsmasq,
});
return {
dnsmasqConfig,
generateConfig: generateConfigMutation.mutate,
isGenerating: generateConfigMutation.isPending,
generateError: generateConfigMutation.error,
restart: restartMutation.mutate,
isRestarting: restartMutation.isPending,
restartError: restartMutation.error,
restartData: restartMutation.data,
};
};

View File

@@ -1,13 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { apiService } from '../services/api';
interface HealthResponse {
service: string;
status: string;
}
export const useHealth = () => {
return useMutation<HealthResponse>({
mutationFn: apiService.getHealth,
});
};

View File

@@ -1,33 +0,0 @@
import { useState } from 'react';
import type { Messages } from '../types';
export const useMessages = () => {
const [messages, setMessages] = useState<Messages>({});
const setMessage = (key: string, message: string | null, type: 'info' | 'success' | 'error' = 'info') => {
if (message === null) {
setMessages(prev => {
const newMessages = { ...prev };
delete newMessages[key];
return newMessages;
});
} else {
setMessages(prev => ({ ...prev, [key]: { message, type } }));
}
};
const clearMessage = (key: string) => {
setMessage(key, null);
};
const clearAllMessages = () => {
setMessages({});
};
return {
messages,
setMessage,
clearMessage,
clearAllMessages,
};
};

View File

@@ -1,11 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { apiService } from '../services/api';
import type { Status } from '../types';
export const useStatus = () => {
return useQuery<Status>({
queryKey: ['status'],
queryFn: apiService.getStatus,
refetchInterval: 30000, // Refetch every 30 seconds
});
};

View File

@@ -1,120 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(50.59% 0.12582 244.557);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,15 +0,0 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
},
mutations: {
retry: 1,
},
},
});

View File

@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,24 +0,0 @@
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import './index.css';
import App from './App';
import { ThemeProvider } from './contexts/ThemeContext';
import { queryClient } from './lib/queryClient';
import { ErrorBoundary } from './components/ErrorBoundary';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light" storageKey="wild-central-theme">
<App />
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
);

View File

@@ -1,330 +0,0 @@
import { describe, it, expect } from 'vitest';
import { configFormSchema, defaultConfigValues } from '../config';
describe('config schema validation', () => {
describe('valid configurations', () => {
it('should validate default configuration', () => {
const result = configFormSchema.safeParse(defaultConfigValues);
expect(result.success).toBe(true);
});
it('should validate complete configuration', () => {
const validConfig = {
server: {
host: '0.0.0.0',
port: 5055,
},
cloud: {
domain: 'wildcloud.local',
internalDomain: 'cluster.local',
dhcpRange: '192.168.8.100,192.168.8.200',
dns: { ip: '192.168.8.50' },
router: { ip: '192.168.8.1' },
dnsmasq: { interface: 'eth0' },
},
cluster: {
endpointIp: '192.168.8.60',
nodes: { talos: { version: 'v1.8.0' } },
},
};
const result = configFormSchema.safeParse(validConfig);
expect(result.success).toBe(true);
});
});
describe('server validation', () => {
it('should reject empty host', () => {
const config = {
...defaultConfigValues,
server: { ...defaultConfigValues.server, host: '' },
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors[0].path).toEqual(['server', 'host']);
expect(result.error.errors[0].message).toBe('Host is required');
}
});
it('should reject invalid port ranges', () => {
const invalidPorts = [0, -1, 65536, 99999];
invalidPorts.forEach(port => {
const config = {
...defaultConfigValues,
server: { ...defaultConfigValues.server, port },
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(false);
});
});
it('should accept valid port ranges', () => {
const validPorts = [1, 80, 443, 5055, 65535];
validPorts.forEach(port => {
const config = {
...defaultConfigValues,
server: { ...defaultConfigValues.server, port },
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
});
describe('IP address validation', () => {
it('should reject invalid IP addresses', () => {
const invalidIPs = [
'256.1.1.1',
'192.168.1',
'192.168.1.256',
'not-an-ip',
'192.168.1.1.1',
'',
];
invalidIPs.forEach(ip => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dns: { ip },
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(false);
});
});
it('should accept valid IP addresses', () => {
const validIPs = [
'192.168.1.1',
'10.0.0.1',
'172.16.0.1',
'127.0.0.1',
'0.0.0.0',
'255.255.255.255',
];
validIPs.forEach(ip => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dns: { ip },
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
});
describe('domain validation', () => {
it('should reject invalid domains', () => {
const invalidDomains = [
'',
'.com',
'domain.',
'domain..com',
'domain-.com',
'-domain.com',
'domain.c',
'very-long-domain-name-that-exceeds-the-maximum-allowed-length-for-a-domain-label.com',
];
invalidDomains.forEach(domain => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
domain,
},
};
const result = configFormSchema.safeParse(config);
expect(result.success, `Domain "${domain}" should be invalid but passed validation`).toBe(false);
});
});
it('should accept valid domains', () => {
const validDomains = [
'wildcloud.local',
'example.com',
'sub.domain.com',
'localhost',
'test123.example.org',
'my-domain.net',
];
validDomains.forEach(domain => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
domain,
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
});
describe('DHCP range validation', () => {
it('should reject invalid DHCP ranges', () => {
const invalidRanges = [
'',
'192.168.1.1',
'192.168.1.1,',
',192.168.1.200',
'192.168.1.1-192.168.1.200',
'192.168.1.1,192.168.1.256',
'start,end',
];
invalidRanges.forEach(dhcpRange => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dhcpRange,
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(false);
});
});
it('should accept valid DHCP ranges', () => {
const validRanges = [
'192.168.1.100,192.168.1.200',
'10.0.0.10,10.0.0.100',
'172.16.1.1,172.16.1.254',
];
validRanges.forEach(dhcpRange => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dhcpRange,
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
});
describe('version validation', () => {
it('should reject invalid versions', () => {
const invalidVersions = [
'',
'1.8.0',
'v1.8',
'v1.8.0.1',
'version1.8.0',
'v1.8.0-beta',
];
invalidVersions.forEach(version => {
const config = {
...defaultConfigValues,
cluster: {
...defaultConfigValues.cluster,
nodes: {
talos: { version },
},
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(false);
});
});
it('should accept valid versions', () => {
const validVersions = [
'v1.8.0',
'v1.0.0',
'v10.20.30',
'v0.0.1',
];
validVersions.forEach(version => {
const config = {
...defaultConfigValues,
cluster: {
...defaultConfigValues.cluster,
nodes: {
talos: { version },
},
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
});
describe('network interface validation', () => {
it('should reject invalid interfaces', () => {
const invalidInterfaces = [
'',
'eth-0',
'eth.0',
'eth 0',
'eth/0',
];
invalidInterfaces.forEach(interfaceName => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dnsmasq: { interface: interfaceName },
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(false);
});
});
it('should accept valid interfaces', () => {
const validInterfaces = [
'eth0',
'eth1',
'enp0s3',
'wlan0',
'lo',
'br0',
];
validInterfaces.forEach(interfaceName => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dnsmasq: { interface: interfaceName },
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
});
});

View File

@@ -1,184 +0,0 @@
import { z } from 'zod';
// Network validation helpers
const ipAddressSchema = z.string().regex(
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
'Must be a valid IP address'
);
const domainSchema = z.string().regex(
/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9]))*$/,
'Must be a valid domain name'
);
const dhcpRangeSchema = z.string().regex(
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?),(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
'Must be in format: start_ip,end_ip (e.g., 192.168.1.100,192.168.1.200)'
);
const interfaceSchema = z.string().regex(
/^[a-zA-Z0-9]+$/,
'Must be a valid network interface name (e.g., eth0, enp0s3)'
);
const versionSchema = z.string().regex(
/^v\d+\.\d+\.\d+$/,
'Must be a valid version format (e.g., v1.8.0)'
);
// Server configuration schema
const serverConfigSchema = z.object({
host: z.string().min(1, 'Host is required').default('0.0.0.0'),
port: z.number()
.int('Port must be an integer')
.min(1, 'Port must be at least 1')
.max(65535, 'Port must be at most 65535')
.default(5055),
});
// Cloud DNS configuration schema
const cloudDnsSchema = z.object({
ip: ipAddressSchema,
});
// Cloud router configuration schema
const cloudRouterSchema = z.object({
ip: ipAddressSchema,
});
// Cloud dnsmasq configuration schema
const cloudDnsmasqSchema = z.object({
interface: interfaceSchema,
});
// Cloud configuration schema
const cloudConfigSchema = z.object({
domain: domainSchema,
internalDomain: domainSchema,
dhcpRange: dhcpRangeSchema,
dns: cloudDnsSchema,
router: cloudRouterSchema,
dnsmasq: cloudDnsmasqSchema,
});
// Talos configuration schema
const talosConfigSchema = z.object({
version: versionSchema,
});
// Nodes configuration schema
const nodesConfigSchema = z.object({
talos: talosConfigSchema,
});
// Cluster configuration schema
const clusterConfigSchema = z.object({
endpointIp: ipAddressSchema,
nodes: nodesConfigSchema,
});
// Wildcloud configuration schema (optional)
const wildcloudConfigSchema = z.object({
repository: z.string().min(1, 'Repository is required'),
currentPhase: z.enum(['setup', 'infrastructure', 'cluster', 'apps']).optional(),
completedPhases: z.array(z.enum(['setup', 'infrastructure', 'cluster', 'apps'])).optional(),
}).optional();
// Main configuration schema
export const configSchema = z.object({
server: serverConfigSchema,
cloud: cloudConfigSchema,
cluster: clusterConfigSchema,
wildcloud: wildcloudConfigSchema,
});
// Form schema for creating new configurations (some fields can be optional for partial updates)
export const configFormSchema = z.object({
server: z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number()
.int('Port must be an integer')
.min(1, 'Port must be at least 1')
.max(65535, 'Port must be at most 65535'),
}),
cloud: z.object({
domain: z.string().min(1, 'Domain is required').refine(
(val) => domainSchema.safeParse(val).success,
'Must be a valid domain name'
),
internalDomain: z.string().min(1, 'Internal domain is required').refine(
(val) => domainSchema.safeParse(val).success,
'Must be a valid domain name'
),
dhcpRange: z.string().min(1, 'DHCP range is required').refine(
(val) => dhcpRangeSchema.safeParse(val).success,
'Must be in format: start_ip,end_ip'
),
dns: z.object({
ip: z.string().min(1, 'DNS IP is required').refine(
(val) => ipAddressSchema.safeParse(val).success,
'Must be a valid IP address'
),
}),
router: z.object({
ip: z.string().min(1, 'Router IP is required').refine(
(val) => ipAddressSchema.safeParse(val).success,
'Must be a valid IP address'
),
}),
dnsmasq: z.object({
interface: z.string().min(1, 'Interface is required').refine(
(val) => interfaceSchema.safeParse(val).success,
'Must be a valid network interface name'
),
}),
}),
cluster: z.object({
endpointIp: z.string().min(1, 'Endpoint IP is required').refine(
(val) => ipAddressSchema.safeParse(val).success,
'Must be a valid IP address'
),
nodes: z.object({
talos: z.object({
version: z.string().min(1, 'Talos version is required').refine(
(val) => versionSchema.safeParse(val).success,
'Must be a valid version format (e.g., v1.8.0)'
),
}),
}),
}),
});
// Type exports
export type Config = z.infer<typeof configSchema>;
export type ConfigFormData = z.infer<typeof configFormSchema>;
// Default values for the form
export const defaultConfigValues: ConfigFormData = {
server: {
host: '0.0.0.0',
port: 5055,
},
cloud: {
domain: 'wildcloud.local',
internalDomain: 'cluster.local',
dhcpRange: '192.168.8.100,192.168.8.200',
dns: {
ip: '192.168.8.50',
},
router: {
ip: '192.168.8.1',
},
dnsmasq: {
interface: 'eth0',
},
},
cluster: {
endpointIp: '192.168.8.60',
nodes: {
talos: {
version: 'v1.8.0',
},
},
},
};

View File

@@ -1,92 +0,0 @@
import type { Status, ConfigResponse, Config, HealthResponse, StatusResponse } from '../types';
const API_BASE = 'http://localhost:5055';
class ApiService {
private baseUrl: string;
constructor(baseUrl: string = API_BASE) {
this.baseUrl = baseUrl;
}
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
private async requestText(endpoint: string, options?: RequestInit): Promise<string> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
}
async getStatus(): Promise<Status> {
return this.request<Status>('/api/status');
}
async getHealth(): Promise<HealthResponse> {
return this.request<HealthResponse>('/api/v1/health');
}
async getConfig(): Promise<ConfigResponse> {
return this.request<ConfigResponse>('/api/v1/config');
}
async getConfigYaml(): Promise<string> {
return this.requestText('/api/v1/config/yaml');
}
async updateConfigYaml(yamlContent: string): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config/yaml', {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: yamlContent
});
}
async createConfig(config: Config): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async updateConfig(config: Config): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async getDnsmasqConfig(): Promise<string> {
return this.requestText('/api/v1/dnsmasq/config');
}
async restartDnsmasq(): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/dnsmasq/restart', {
method: 'POST'
});
}
async downloadPXEAssets(): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/pxe/assets', {
method: 'POST'
});
}
}
export const apiService = new ApiService();
export default ApiService;

View File

@@ -1,23 +0,0 @@
import '@testing-library/jest-dom/vitest';
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));

View File

@@ -1,35 +0,0 @@
import React, { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../contexts/ThemeContext';
// Custom render function that includes providers
const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light" storageKey="test-theme">
{children}
</ThemeProvider>
</QueryClientProvider>
);
};
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>,
) => render(ui, { wrapper: AllTheProviders, ...options });
export * from '@testing-library/react';
export { customRender as render };

View File

@@ -1,86 +0,0 @@
export interface Status {
status: string;
version: string;
uptime: string;
timestamp: string;
}
export interface ServerConfig {
host: string;
port: number;
}
export interface CloudDns {
ip: string;
}
export interface CloudRouter {
ip: string;
}
export interface CloudDnsmasq {
interface: string;
}
export interface CloudConfig {
domain: string;
internalDomain: string;
dhcpRange: string;
dns: CloudDns;
router: CloudRouter;
dnsmasq: CloudDnsmasq;
}
export interface TalosConfig {
version: string;
}
export interface NodesConfig {
talos: TalosConfig;
}
export interface ClusterConfig {
endpointIp: string;
nodes: NodesConfig;
}
export interface WildcloudConfig {
repository: string;
currentPhase?: 'setup' | 'infrastructure' | 'cluster' | 'apps';
completedPhases?: ('setup' | 'infrastructure' | 'cluster' | 'apps')[];
}
export interface Config {
server: ServerConfig;
cloud: CloudConfig;
cluster: ClusterConfig;
wildcloud?: WildcloudConfig;
}
export interface ConfigResponse {
configured: boolean;
config?: Config;
message?: string;
}
export interface Message {
message: string;
type: 'info' | 'success' | 'error';
}
export interface LoadingState {
[key: string]: boolean;
}
export interface Messages {
[key: string]: Message;
}
export interface HealthResponse {
service: string;
status: string;
}
export interface StatusResponse {
status: string;
}

View File

@@ -1,3 +0,0 @@
export const formatTimestamp = (timestamp: string): string => {
return new Date(timestamp).toLocaleString();
};

View File

@@ -1,61 +0,0 @@
import { Config } from '../types';
// Simple YAML to JSON parser for basic configuration
export const parseSimpleYaml = (yamlText: string): Config => {
const config: Config = {
cloud: {
domain: '',
internalDomain: '',
dhcpRange: '',
dns: { ip: '' },
router: { ip: '' },
dnsmasq: { interface: '' }
},
cluster: {
endpointIp: '',
nodes: { talos: { version: '' } }
},
server: { host: '', port: 0 }
};
const lines = yamlText.split('\n');
let currentSection: 'cloud' | 'cluster' | 'server' | null = null;
let currentSubsection: string | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (trimmed.startsWith('cloud:')) currentSection = 'cloud';
else if (trimmed.startsWith('cluster:')) currentSection = 'cluster';
else if (trimmed.startsWith('server:')) currentSection = 'server';
else if (trimmed.startsWith('dns:')) currentSubsection = 'dns';
else if (trimmed.startsWith('router:')) currentSubsection = 'router';
else if (trimmed.startsWith('dnsmasq:')) currentSubsection = 'dnsmasq';
else if (trimmed.startsWith('nodes:')) currentSubsection = 'nodes';
else if (trimmed.startsWith('talos:')) currentSubsection = 'talos';
else if (trimmed.includes(':')) {
const [key, value] = trimmed.split(':').map(s => s.trim());
const cleanValue = value.replace(/"/g, '');
if (currentSection === 'cloud') {
if (currentSubsection === 'dns') (config.cloud.dns as any)[key] = cleanValue;
else if (currentSubsection === 'router') (config.cloud.router as any)[key] = cleanValue;
else if (currentSubsection === 'dnsmasq') (config.cloud.dnsmasq as any)[key] = cleanValue;
else (config.cloud as any)[key] = cleanValue;
} else if (currentSection === 'cluster') {
if (currentSubsection === 'nodes') {
// Skip nodes level
} else if (currentSubsection === 'talos') {
(config.cluster.nodes.talos as any)[key] = cleanValue;
} else {
(config.cluster as any)[key] = cleanValue;
}
} else if (currentSection === 'server') {
(config.server as any)[key] = key === 'port' ? parseInt(cleanValue) : cleanValue;
}
}
}
return config;
};

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

Some files were not shown because too many files have changed in this diff Show More