Compare commits
No commits in common. "main" and "fresh-main" have entirely different histories.
main
...
fresh-main
|
@ -0,0 +1,35 @@
|
|||
# Handle symbolic links as real content
|
||||
public/blog/* !symlink
|
||||
src/content/* !symlink
|
||||
|
||||
# Treat these directories as regular directories even if they're symlinks
|
||||
public/blog/configs/ -symlink
|
||||
public/blog/images/ -symlink
|
||||
public/blog/infrastructure/ -symlink
|
||||
public/blog/posts/ -symlink
|
||||
src/content/posts/ -symlink
|
||||
src/content/projects/ -symlink
|
||||
src/content/configurations/ -symlink
|
||||
src/content/external-posts/ -symlink
|
||||
|
||||
# Set text files to automatically normalize line endings
|
||||
* text=auto
|
||||
|
||||
# Markdown files
|
||||
*.md text
|
||||
*.mdx text
|
||||
|
||||
# Source code
|
||||
*.ts text
|
||||
*.js text
|
||||
*.json text
|
||||
*.astro text
|
||||
*.css text
|
||||
*.html text
|
||||
|
||||
# Images should be treated as binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg text
|
|
@ -1,27 +1,30 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
vendor/
|
||||
|
||||
# Build outputs
|
||||
# build output
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
.output/
|
||||
|
||||
# Environment variables
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS generated files
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Local development
|
||||
.vscode/
|
||||
*.local
|
||||
|
||||
# Obsidian files
|
||||
.obsidian/
|
||||
node_modules
|
||||
|
||||
# Astro build cache and types
|
||||
.astro/
|
||||
|
|
11
README.md
|
@ -1,11 +0,0 @@
|
|||
# ArgoBox Portfolio
|
||||
|
||||
This repository contains the portfolio project for ArgoBox.
|
||||
|
||||
## Description
|
||||
|
||||
Add your project description here.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Instructions for setting up and running the project will go here.
|
|
@ -0,0 +1,43 @@
|
|||
# Automated Git Hooks Setup
|
||||
|
||||
## Quick Start
|
||||
```bash
|
||||
# Clone and initialize the blog repository
|
||||
git clone https://gitea.argobox.com/InovinLabs/argobox.git # Updated URL
|
||||
cd argobox # Updated directory name
|
||||
# ./scripts/init-blog-repo.sh # This script might need review/removal depending on its purpose
|
||||
```
|
||||
|
||||
## What This Does
|
||||
|
||||
The initialization script (`init-blog-repo.sh`) automatically:
|
||||
1. Configures Git to handle symbolic links properly
|
||||
2. Creates all necessary directories
|
||||
3. Sets up symbolic links to your Obsidian content
|
||||
4. Installs Git hooks that handle content conversion
|
||||
5. Makes all scripts executable
|
||||
|
||||
## How Content Syncing Works
|
||||
|
||||
1. **Writing Content**
|
||||
- Edit content normally in Obsidian
|
||||
- Changes appear instantly in the blog repository through symbolic links
|
||||
|
||||
2. **Committing Changes**
|
||||
- The pre-commit hook automatically converts symbolic links to real content
|
||||
- Git commits the actual content
|
||||
- The post-commit hook restores symbolic links automatically
|
||||
|
||||
3. **No Manual Steps Required**
|
||||
- All conversions happen automatically
|
||||
- No need to remember any special commands
|
||||
- Works the same way for all contributors
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If commits seem to hang:
|
||||
1. Check that `core.symlinks` is enabled: `git config core.symlinks`
|
||||
2. Verify symbolic links are correct: `ls -la src/content/ public/blog/`
|
||||
3. Run `./scripts/init-blog-repo.sh` to reset the setup
|
||||
|
||||
The initialization script can be run multiple times safely - it will fix any broken symbolic links or missing configuration.
|
18
_headers
|
@ -1,18 +0,0 @@
|
|||
/*
|
||||
Cache-Control: no-cache, no-store, must-revalidate
|
||||
Pragma: no-cache
|
||||
Expires: 0
|
||||
|
||||
# Ensure assets like CSS and JS are allowed to be cached but not for too long
|
||||
*.css
|
||||
Cache-Control: public, max-age=3600
|
||||
*.js
|
||||
Cache-Control: public, max-age=3600
|
||||
|
||||
# Allow caching for static resources like images
|
||||
/images/*
|
||||
Cache-Control: public, max-age=86400
|
||||
|
||||
# Don't cache PDF files to ensure they're always up to date
|
||||
*.pdf
|
||||
Cache-Control: no-cache, no-store, must-revalidate
|
|
@ -1,5 +0,0 @@
|
|||
# Redirect default Netlify subdomain to primary domain
|
||||
https://argobox.pages.dev/* https://argobox.com/:splat 301!
|
||||
|
||||
# Handle trailing slashes consistently
|
||||
/*/ /:splat 301
|
|
@ -1,995 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ansible Sandbox Documentation | Argobox</title>
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="Comprehensive documentation for the Ansible Sandbox playbooks. Learn about infrastructure automation, deployment patterns, and best practices.">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22256%22 height=%22256%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2220%22 fill=%22%230f172a%22></rect><path fill=%22%233b82f6%22 d=%22M30 40L50 20L70 40L50 60L30 40Z%22></path><path fill=%22%233b82f6%22 d=%22M50 60L70 40L70 70L50 90L30 70L30 40L50 60Z%22 fill-opacity=%220.6%22></path></svg>">
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- FontAwesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary-bg: #0f172a;
|
||||
--secondary-bg: #1e293b;
|
||||
--accent: #3b82f6;
|
||||
--accent-darker: #2563eb;
|
||||
--accent-gradient: linear-gradient(135deg, #2563eb, #3b82f6);
|
||||
--accent-glow: 0 0 15px rgba(59, 130, 246, 0.5);
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-accent: #3b82f6;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--info: #0ea5e9;
|
||||
--border: rgba(71, 85, 105, 0.5);
|
||||
--card-bg: rgba(30, 41, 59, 0.8);
|
||||
--sidebar-bg: rgba(15, 23, 42, 0.95);
|
||||
--card-hover-bg: rgba(30, 41, 59, 0.95);
|
||||
--card-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
--transition-normal: 0.3s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 35%, rgba(29, 78, 216, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 60%, rgba(14, 165, 233, 0.1) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.docs-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background-color: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
padding: 2rem 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 0 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--accent-darker);
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidebar-nav-item {
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.sidebar-nav-link {
|
||||
display: block;
|
||||
padding: 0.5rem 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-normal);
|
||||
position: relative;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.sidebar-nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.sidebar-nav-link.active {
|
||||
color: var(--accent);
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.sidebar-nav-link.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 3px;
|
||||
background: var(--accent-gradient);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 280px;
|
||||
padding: 3rem;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.docs-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.docs-section {
|
||||
margin-bottom: 3rem;
|
||||
scroll-margin-top: 2rem;
|
||||
}
|
||||
|
||||
.docs-section-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
position: relative;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.docs-subsection {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.docs-subsection-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.docs-text {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.docs-list {
|
||||
list-style-type: disc;
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.docs-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.docs-code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background-color: var(--sidebar-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.docs-code pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.docs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.docs-table th {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--sidebar-bg);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.docs-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.docs-note {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border-left: 4px solid var(--accent);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.docs-note-title {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.docs-warning {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
border-left: 4px solid var(--warning);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.docs-warning-title {
|
||||
font-weight: 600;
|
||||
color: var(--warning);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.docs-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-normal);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.docs-button:hover {
|
||||
background-color: var(--accent-darker);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.docs-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 3rem;
|
||||
padding-top: 2rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 20;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.sidebar.active {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.contact-info p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.contact-email {
|
||||
color: #60a5fa; /* Lighter blue color for better readability */
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="docs-container">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1 class="sidebar-title">Ansible Documentation</h1>
|
||||
<a href="ansible-sandbox.html" class="back-link">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>Back to Sandbox</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h2 class="sidebar-section-title">Getting Started</h2>
|
||||
<ul class="sidebar-nav">
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#introduction" class="sidebar-nav-link active">Introduction</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#sandbox-overview" class="sidebar-nav-link">Sandbox Overview</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#infrastructure" class="sidebar-nav-link">Infrastructure Design</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h2 class="sidebar-section-title">Playbooks</h2>
|
||||
<ul class="sidebar-nav">
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#web-server" class="sidebar-nav-link">Web Server Deployment</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#docker-compose" class="sidebar-nav-link">Docker Compose Stack</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#k3s-cluster" class="sidebar-nav-link">K3s Kubernetes Cluster</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#lamp-stack" class="sidebar-nav-link">LAMP Stack</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#security-hardening" class="sidebar-nav-link">Security Hardening</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h2 class="sidebar-section-title">Advanced Topics</h2>
|
||||
<ul class="sidebar-nav">
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#best-practices" class="sidebar-nav-link">Ansible Best Practices</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#roles" class="sidebar-nav-link">Working with Roles</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#variables" class="sidebar-nav-link">Variables & Templates</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h2 class="sidebar-section-title">Resources</h2>
|
||||
<ul class="sidebar-nav">
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#downloads" class="sidebar-nav-link">Downloads</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#references" class="sidebar-nav-link">External References</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="docs-header">
|
||||
<h1 class="docs-title">Ansible Sandbox Documentation</h1>
|
||||
<p class="docs-subtitle">
|
||||
Comprehensive guide to infrastructure automation with Ansible playbooks
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section id="introduction" class="docs-section">
|
||||
<h2 class="docs-section-title">Introduction</h2>
|
||||
|
||||
<p class="docs-text">
|
||||
Welcome to the Argobox Ansible Sandbox documentation. This guide provides detailed information about the infrastructure automation playbooks available in the sandbox environment. Whether you're new to Ansible or looking to enhance your infrastructure-as-code skills, this documentation will help you understand the concepts and implementations demonstrated in the sandbox.
|
||||
</p>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">What is Ansible?</h3>
|
||||
|
||||
<p class="docs-text">
|
||||
Ansible is an open-source automation tool that simplifies infrastructure management, application deployment, and task automation. It uses a declarative language to describe system configurations and a push-based architecture to apply those configurations to managed nodes.
|
||||
</p>
|
||||
|
||||
<p class="docs-text">
|
||||
Key features of Ansible include:
|
||||
</p>
|
||||
|
||||
<ul class="docs-list">
|
||||
<li>Agentless architecture - no need to install special software on managed nodes</li>
|
||||
<li>YAML-based playbooks that are human-readable and version-controllable</li>
|
||||
<li>Extensive module library for managing virtually any IT system</li>
|
||||
<li>Idempotent execution - safely run the same playbook multiple times</li>
|
||||
<li>Built-in parallelism for scaling across large environments</li>
|
||||
<li>Integration with cloud providers, network devices, and container platforms</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Why Infrastructure as Code?</h3>
|
||||
|
||||
<p class="docs-text">
|
||||
Infrastructure as Code (IaC) treats infrastructure configuration as software code, allowing you to:
|
||||
</p>
|
||||
|
||||
<ul class="docs-list">
|
||||
<li>Version control your infrastructure configurations</li>
|
||||
<li>Automate deployment and reduce human error</li>
|
||||
<li>Create consistent, repeatable environments</li>
|
||||
<li>Enable collaboration among team members</li>
|
||||
<li>Implement testing and validation for infrastructure changes</li>
|
||||
<li>Scale infrastructure management efficiently</li>
|
||||
</ul>
|
||||
|
||||
<p class="docs-text">
|
||||
The Ansible Sandbox demonstrates these principles by providing real-world examples of infrastructure deployments that you can examine and execute in a safe, isolated environment.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="sandbox-overview" class="docs-section">
|
||||
<h2 class="docs-section-title">Sandbox Overview</h2>
|
||||
|
||||
<p class="docs-text">
|
||||
The Ansible Sandbox is a controlled environment that allows you to explore infrastructure automation without affecting production systems. Each sandbox deployment creates isolated virtual machines to safely execute Ansible playbooks.
|
||||
</p>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Sandbox Architecture</h3>
|
||||
|
||||
<p class="docs-text">
|
||||
The sandbox uses a combination of technologies to provide a realistic yet isolated environment:
|
||||
</p>
|
||||
|
||||
<ul class="docs-list">
|
||||
<li><strong>Proxmox Virtualization:</strong> Backend hypervisor for creating lightweight VMs</li>
|
||||
<li><strong>Isolated Network:</strong> Private network segments for each sandbox deployment</li>
|
||||
<li><strong>Ansible Control Node:</strong> Preconfigured with necessary collections and modules</li>
|
||||
<li><strong>Ephemeral Storage:</strong> Non-persistent storage that resets between sessions</li>
|
||||
<li><strong>Resource Limits:</strong> CPU, memory, and time boundaries to ensure fair usage</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Available Playbooks</h3>
|
||||
|
||||
<p class="docs-text">
|
||||
The sandbox includes several playbooks of varying complexity:
|
||||
</p>
|
||||
|
||||
<table class="docs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Playbook</th>
|
||||
<th>Complexity</th>
|
||||
<th>VMs</th>
|
||||
<th>Est. Runtime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Web Server Deployment</td>
|
||||
<td>Basic</td>
|
||||
<td>1</td>
|
||||
<td>~3 min</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Docker Compose Stack</td>
|
||||
<td>Intermediate</td>
|
||||
<td>1</td>
|
||||
<td>~5 min</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>K3s Kubernetes Cluster</td>
|
||||
<td>Advanced</td>
|
||||
<td>3</td>
|
||||
<td>~8 min</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>LAMP Stack</td>
|
||||
<td>Intermediate</td>
|
||||
<td>1</td>
|
||||
<td>~4 min</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Security Hardening</td>
|
||||
<td>Advanced</td>
|
||||
<td>1</td>
|
||||
<td>~6 min</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="docs-text">
|
||||
Each playbook demonstrates different aspects of infrastructure automation, from basic web server setup to more complex multi-node deployments.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Sandbox Limitations</h3>
|
||||
|
||||
<div class="docs-warning">
|
||||
<div class="docs-warning-title">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span>Important Constraints</span>
|
||||
</div>
|
||||
<p>
|
||||
The sandbox environment has the following limitations:
|
||||
</p>
|
||||
<ul style="margin-top: 0.5rem; margin-left: 1.5rem;">
|
||||
<li>30-minute time limit per session</li>
|
||||
<li>Limited outbound internet access</li>
|
||||
<li>Maximum of 3 VMs per deployment</li>
|
||||
<li>No persistent storage between sessions</li>
|
||||
<li>Resource caps (4 vCPU, 4GB RAM per deployment)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="infrastructure" class="docs-section">
|
||||
<h2 class="docs-section-title">Infrastructure Design</h2>
|
||||
|
||||
<p class="docs-text">
|
||||
The sandbox uses a modular infrastructure design to isolate each deployment while providing a realistic environment for Ansible automation.
|
||||
</p>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">VM Templates</h3>
|
||||
|
||||
<p class="docs-text">
|
||||
All sandbox playbooks use lightweight VM templates that boot quickly and consume minimal resources:
|
||||
</p>
|
||||
|
||||
<ul class="docs-list">
|
||||
<li><strong>Ubuntu 22.04 LTS:</strong> Modern, long-term support Linux distribution</li>
|
||||
<li><strong>Debian 11:</strong> Stable, minimal distribution ideal for server deployments</li>
|
||||
<li><strong>CentOS Stream 9:</strong> Enterprise-focused distribution for RHEL compatibility</li>
|
||||
</ul>
|
||||
|
||||
<p class="docs-text">
|
||||
These templates are pre-optimized with:
|
||||
</p>
|
||||
|
||||
<ul class="docs-list">
|
||||
<li>Cloud-init support for dynamic provisioning</li>
|
||||
<li>Python 3 pre-installed for Ansible compatibility</li>
|
||||
<li>Minimal package set for faster deployment</li>
|
||||
<li>SSH key-based authentication</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Network Architecture</h3>
|
||||
|
||||
<p class="docs-text">
|
||||
Each sandbox deployment gets a dedicated private network segment with the following characteristics:
|
||||
</p>
|
||||
|
||||
<ul class="docs-list">
|
||||
<li>Private 192.168.122.0/24 subnet</li>
|
||||
<li>Isolated from other sandbox deployments</li>
|
||||
<li>NAT for limited outbound connectivity</li>
|
||||
<li>DNS resolution for package installations</li>
|
||||
<li>No inbound external access</li>
|
||||
</ul>
|
||||
|
||||
<div class="docs-note">
|
||||
<div class="docs-note-title">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>Network Access</span>
|
||||
</div>
|
||||
<p>
|
||||
While your deployed services won't be accessible from the public internet, you'll be able to interact with them through the sandbox interface, which provides proxied access to web applications and services deployed in your environment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="web-server" class="docs-section">
|
||||
<h2 class="docs-section-title">Web Server Deployment Playbook</h2>
|
||||
|
||||
<p class="docs-text">
|
||||
The Web Server Deployment playbook demonstrates how to automate the installation and configuration of an Nginx web server with a custom website.
|
||||
</p>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Playbook Overview</h3>
|
||||
|
||||
<p class="docs-text">
|
||||
This basic playbook covers:
|
||||
</p>
|
||||
|
||||
<ul class="docs-list">
|
||||
<li>Package installation and management</li>
|
||||
<li>Service configuration and startup</li>
|
||||
<li>File and directory management</li>
|
||||
<li>Template-based configuration</li>
|
||||
<li>Handlers for service restarts</li>
|
||||
</ul>
|
||||
|
||||
<p class="docs-text">
|
||||
The playbook deploys a simple but fully functional web server with a customizable theme and domain configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Code Structure</h3>
|
||||
|
||||
<p class="docs-text">
|
||||
The playbook consists of three main components:
|
||||
</p>
|
||||
|
||||
<ol class="docs-list">
|
||||
<li><strong>Main Playbook:</strong> web-server.yml - Defines tasks for installation and configuration</li>
|
||||
<li><strong>Website Template:</strong> templates/index.html.j2 - Jinja2 template for the sample website</li>
|
||||
<li><strong>Nginx Config Template:</strong> templates/nginx.conf.j2 - Virtual host configuration</li>
|
||||
</ol>
|
||||
|
||||
<div class="docs-code">
|
||||
<pre>
|
||||
# Directory structure
|
||||
web-server/
|
||||
├── web-server.yml
|
||||
└── templates/
|
||||
├── index.html.j2
|
||||
└── nginx.conf.j2</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Key Variables</h3>
|
||||
|
||||
<p class="docs-text">
|
||||
The playbook uses several configurable variables:
|
||||
</p>
|
||||
|
||||
<table class="docs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Variable</th>
|
||||
<th>Default</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>web_domain</td>
|
||||
<td>example.local</td>
|
||||
<td>Domain name for the Nginx virtual host</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>web_root</td>
|
||||
<td>/var/www/html</td>
|
||||
<td>Directory path for website files</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>enable_https</td>
|
||||
<td>false</td>
|
||||
<td>Whether to configure SSL/TLS</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>web_color</td>
|
||||
<td>blue</td>
|
||||
<td>Theme color for the sample website</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Implementation Details</h3>
|
||||
|
||||
<p class="docs-text">
|
||||
The playbook follows these steps:
|
||||
</p>
|
||||
|
||||
<ol class="docs-list">
|
||||
<li>Updates the package repository cache</li>
|
||||
<li>Installs Nginx and required packages</li>
|
||||
<li>Creates the web root directory</li>
|
||||
<li>Deploys a sample website using a Jinja2 template</li>
|
||||
<li>Configures the Nginx virtual host</li>
|
||||
<li>Enables the site configuration</li>
|
||||
<li>Starts and enables the Nginx service</li>
|
||||
</ol>
|
||||
|
||||
<p class="docs-text">
|
||||
The playbook also includes a handler for restarting Nginx when configuration changes are made, demonstrating the handler notification pattern in Ansible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="#" class="docs-button" onclick="alert('In a real implementation, this would download the playbook files.')">
|
||||
<i class="fas fa-download"></i>
|
||||
<span>Download Web Server Playbook</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- Additional playbook sections would follow the same pattern -->
|
||||
|
||||
<section id="best-practices" class="docs-section">
|
||||
<h2 class="docs-section-title">Ansible Best Practices</h2>
|
||||
|
||||
<p class="docs-text">
|
||||
The sandbox playbooks demonstrate several Ansible best practices that you can apply to your own automation projects.
|
||||
</p>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Organization</h3>
|
||||
|
||||
<ul class="docs-list">
|
||||
<li><strong>Modular Design:</strong> Breaking complex deployments into discrete tasks</li>
|
||||
<li><strong>Clear Naming:</strong> Using descriptive names for tasks, variables, and files</li>
|
||||
<li><strong>Consistent Structure:</strong> Following a standard directory layout for playbooks and roles</li>
|
||||
<li><strong>Separation of Concerns:</strong> Keeping variables, tasks, and templates properly organized</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Code Quality</h3>
|
||||
|
||||
<ul class="docs-list">
|
||||
<li><strong>Idempotence:</strong> Ensuring tasks can run multiple times without negative effects</li>
|
||||
<li><strong>Error Handling:</strong> Including proper failure conditions and recovery options</li>
|
||||
<li><strong>Validation:</strong> Checking inputs and preconditions before making changes</li>
|
||||
<li><strong>Documentation:</strong> Adding clear comments and documentation within playbooks</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Security Considerations</h3>
|
||||
|
||||
<ul class="docs-list">
|
||||
<li><strong>Least Privilege:</strong> Using minimal permissions necessary for tasks</li>
|
||||
<li><strong>Secret Management:</strong> Properly handling sensitive data</li>
|
||||
<li><strong>Secure Defaults:</strong> Starting with secure configuration baselines</li>
|
||||
<li><strong>Hardening:</strong> Including security enhancements as part of deployment</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-info">
|
||||
<div class="footer-logo">
|
||||
<span class="logo-text-glow">ArgoBox</span>
|
||||
<span class="logo-dot-glow">.com</span>
|
||||
</div>
|
||||
<p class="footer-description">
|
||||
Enterprise-grade home lab environment for DevOps experimentation, infrastructure automation, and containerized application deployment.
|
||||
</p>
|
||||
<div class="footer-evolution">
|
||||
<i class="fas fa-code-branch evolution-icon"></i>
|
||||
<span>Continuously evolving since 2011</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-links">
|
||||
<div class="footer-links-column">
|
||||
<h3 class="footer-heading">Platforms</h3>
|
||||
<a href="https://dashboard.argobox.com" class="footer-link">Dashboard</a>
|
||||
<a href="ansible-sandbox.html" class="footer-link">Ansible Sandbox</a>
|
||||
<a href="https://git.argobox.com" class="footer-link">Gitea</a>
|
||||
<a href="https://ai.argobox.com" class="footer-link">OpenWebUI</a>
|
||||
</div>
|
||||
|
||||
<div class="footer-links-column">
|
||||
<h3 class="footer-heading">Documentation</h3>
|
||||
<a href="construction.html" class="footer-link">Architecture</a>
|
||||
<a href="construction.html" class="footer-link">Kubernetes</a>
|
||||
<a href="construction.html" class="footer-link">Ansible</a>
|
||||
<a href="construction.html" class="footer-link">Network</a>
|
||||
</div>
|
||||
|
||||
<div class="footer-links-column">
|
||||
<h3 class="footer-heading">Resources</h3>
|
||||
<a href="construction.html" class="footer-link">Ansible Playbooks</a>
|
||||
<a href="construction.html" class="footer-link">K8s Manifests</a>
|
||||
<a href="construction.html" class="footer-link">Shell Scripts</a>
|
||||
<a href="construction.html" class="footer-link">Configuration Files</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<p>© <span id="current-year"></span> All rights reserved. Inovin LLC</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<button class="mobile-menu-toggle">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Current year
|
||||
document.getElementById('current-year').textContent = new Date().getFullYear();
|
||||
|
||||
// Mobile menu toggle
|
||||
const menuToggle = document.querySelector('.mobile-menu-toggle');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
|
||||
menuToggle.addEventListener('click', function() {
|
||||
sidebar.classList.toggle('active');
|
||||
|
||||
// Change icon based on sidebar state
|
||||
const icon = this.querySelector('i');
|
||||
if (sidebar.classList.contains('active')) {
|
||||
icon.classList.remove('fa-bars');
|
||||
icon.classList.add('fa-times');
|
||||
} else {
|
||||
icon.classList.remove('fa-times');
|
||||
icon.classList.add('fa-bars');
|
||||
}
|
||||
});
|
||||
|
||||
// Active link highlighting
|
||||
const navLinks = document.querySelectorAll('.sidebar-nav-link');
|
||||
|
||||
function setActiveLink() {
|
||||
const sections = document.querySelectorAll('.docs-section');
|
||||
let currentSection = '';
|
||||
|
||||
sections.forEach(section => {
|
||||
const sectionTop = section.offsetTop;
|
||||
const sectionHeight = section.offsetHeight;
|
||||
|
||||
if (window.scrollY >= sectionTop - 100 && window.scrollY < sectionTop + sectionHeight - 100) {
|
||||
currentSection = section.getAttribute('id');
|
||||
}
|
||||
});
|
||||
|
||||
navLinks.forEach(link => {
|
||||
link.classList.remove('active');
|
||||
const href = link.getAttribute('href').replace('#', '');
|
||||
|
||||
if (href === currentSection) {
|
||||
link.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', setActiveLink);
|
||||
setActiveLink();
|
||||
|
||||
// Smooth scrolling for anchor links
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
// Close mobile menu when a link is clicked
|
||||
if (window.innerWidth < 992) {
|
||||
sidebar.classList.remove('active');
|
||||
const icon = menuToggle.querySelector('i');
|
||||
icon.classList.remove('fa-times');
|
||||
icon.classList.add('fa-bars');
|
||||
}
|
||||
|
||||
// Don't use smooth scroll if user prefers reduced motion
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
e.preventDefault();
|
||||
const targetId = this.getAttribute('href').substring(1);
|
||||
const targetElement = document.getElementById(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
window.scrollTo({
|
||||
top: targetElement.offsetTop - 20,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
// Update URL without smooth scrolling
|
||||
history.pushState(null, null, `#${targetId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,762 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ansible Sandbox Help | Argobox</title>
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="Help guide for using the Ansible Sandbox environment. Learn how to deploy infrastructure as code demonstrations.">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22256%22 height=%22256%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2220%22 fill=%22%230f172a%22></rect><path fill=%22%233b82f6%22 d=%22M30 40L50 20L70 40L50 60L30 40Z%22></path><path fill=%22%233b82f6%22 d=%22M50 60L70 40L70 70L50 90L30 70L30 40L50 60Z%22 fill-opacity=%220.6%22></path></svg>">
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- FontAwesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary-bg: #0f172a;
|
||||
--secondary-bg: #1e293b;
|
||||
--accent: #3b82f6;
|
||||
--accent-darker: #2563eb;
|
||||
--accent-gradient: linear-gradient(135deg, #2563eb, #3b82f6);
|
||||
--accent-glow: 0 0 15px rgba(59, 130, 246, 0.5);
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-accent: #3b82f6;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--info: #0ea5e9;
|
||||
--border: rgba(71, 85, 105, 0.5);
|
||||
--card-bg: rgba(30, 41, 59, 0.8);
|
||||
--card-hover-bg: rgba(30, 41, 59, 0.95);
|
||||
--card-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
--transition-normal: 0.3s ease;
|
||||
--glass-effect: blur(10px);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--primary-bg);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 35%, rgba(29, 78, 216, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 60%, rgba(14, 165, 233, 0.1) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 5rem 1.5rem;
|
||||
}
|
||||
|
||||
.help-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.help-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.help-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2rem;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--accent-darker);
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
|
||||
.help-section {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.help-section-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--accent);
|
||||
position: relative;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.help-section-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 50px;
|
||||
height: 3px;
|
||||
background: var(--accent-gradient);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.step-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 1.5rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
grid-column: span 2;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.step-content p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.step-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.step-image {
|
||||
grid-column: span 2;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.step-image img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tip {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border-left: 4px solid var(--accent);
|
||||
padding: 1rem;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.tip-title {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.faq-question {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.key-term {
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.help-footer {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.help-footer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.help-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.step-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.step-title, .step-content, .step-image {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: rgba(15, 23, 42, 0.9);
|
||||
backdrop-filter: var(--glass-effect);
|
||||
-webkit-backdrop-filter: var(--glass-effect);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
padding: 1.25rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.navbar .container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.logo a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
background: var(--accent-gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logo-dot {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-normal);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.dashboard-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-toggle i {
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.nav-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--secondary-bg);
|
||||
padding: 2rem 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.footer-social a {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2rem;
|
||||
margin-right: 0.5rem;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.footer-social a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--success);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.live-indicator.offline {
|
||||
background-color: var(--error);
|
||||
}
|
||||
|
||||
.live-indicator::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background-color: var(--success);
|
||||
animation: pulse 2s infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.live-indicator.offline::after {
|
||||
background-color: var(--error);
|
||||
}
|
||||
|
||||
.logo-text-glow, .logo-dot-glow {
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.logo-text-glow {
|
||||
text-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.logo-dot-glow {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.top-links {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.laforceit-link {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.signin-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.signin-button:hover {
|
||||
background: rgba(30, 41, 59, 1);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation Bar -->
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<a href="index.html">
|
||||
<span class="logo-text-glow">ArgoBox</span><span class="logo-dot-glow">.com</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-menu">
|
||||
<a href="index.html#home" class="nav-link">Home</a>
|
||||
<a href="index.html#technologies" class="nav-link">Technologies</a>
|
||||
<a href="index.html#services" class="nav-link">Services</a>
|
||||
<a href="index.html#projects" class="nav-link">Projects</a>
|
||||
<a href="index.html#dashboards" class="nav-link">Dashboards</a>
|
||||
<a href="index.html#contact" class="nav-link">Contact</a>
|
||||
</div>
|
||||
<div class="nav-buttons">
|
||||
<a href="https://dashboard.argobox.com" class="dashboard-link" target="_blank">
|
||||
<span class="live-indicator offline"></span>
|
||||
<span>Live Dashboard</span>
|
||||
</a>
|
||||
<button class="menu-toggle" aria-label="Toggle menu">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Top Links -->
|
||||
<div class="top-links">
|
||||
<a href="https://laforceit.com" class="laforceit-link">
|
||||
<span class="logo-text-glow">LaForceIT</span><span class="logo-dot-glow">.com</span>
|
||||
</a>
|
||||
<a href="construction.html" class="signin-button" target="_blank">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
<span>Sign In</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="help-header">
|
||||
<a href="ansible-sandbox.html" class="back-link">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>Back to Sandbox</span>
|
||||
</a>
|
||||
<h1 class="help-title">Ansible Sandbox Help</h1>
|
||||
<p class="help-subtitle">
|
||||
Learn how to use the Ansible Sandbox environment to explore infrastructure automation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2 class="help-section-title">Getting Started</h2>
|
||||
|
||||
<div class="step-grid">
|
||||
<div class="step-number">1</div>
|
||||
<div class="step-title">Select a Playbook</div>
|
||||
<div class="step-content">
|
||||
<p>The sandbox offers various Ansible playbooks showcasing different infrastructure automation scenarios. Choose one from the left panel based on your interests.</p>
|
||||
<p>Each playbook comes with a description and information about its complexity level and typical run time.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-grid">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-title">Explore the Playbook Code</div>
|
||||
<div class="step-content">
|
||||
<p>View the Ansible YAML code to understand how the automation works. The code is syntax-highlighted for readability.</p>
|
||||
<p>Hover over different sections to see what each part of the playbook does.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-grid">
|
||||
<div class="step-number">3</div>
|
||||
<div class="step-title">Configure Parameters</div>
|
||||
<div class="step-content">
|
||||
<p>Switch to the "Configuration" tab to customize various parameters for your deployment. These might include:</p>
|
||||
<ul style="list-style-type: disc; margin-left: 1.5rem;">
|
||||
<li>Domain names</li>
|
||||
<li>Directory paths</li>
|
||||
<li>Feature toggles</li>
|
||||
<li>VM template selection</li>
|
||||
<li>Resource allocation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-grid">
|
||||
<div class="step-number">4</div>
|
||||
<div class="step-title">Deploy the Environment</div>
|
||||
<div class="step-content">
|
||||
<p>Click the "Deploy" button to launch the automation process. The system will:</p>
|
||||
<ol style="list-style-type: decimal; margin-left: 1.5rem;">
|
||||
<li>Create necessary virtual machines</li>
|
||||
<li>Run the selected Ansible playbook</li>
|
||||
<li>Configure all services</li>
|
||||
<li>Provide access to the deployed environment</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-grid">
|
||||
<div class="step-number">5</div>
|
||||
<div class="step-title">Monitor Progress</div>
|
||||
<div class="step-content">
|
||||
<p>Watch the deployment process in real-time on the "Output" tab, which shows the Ansible execution log.</p>
|
||||
<p>Once deployment completes, you'll see a success message with details on how to access the deployed environment.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-grid">
|
||||
<div class="step-number">6</div>
|
||||
<div class="step-title">Explore the Environment</div>
|
||||
<div class="step-content">
|
||||
<p>After deployment, you can:</p>
|
||||
<ul style="list-style-type: disc; margin-left: 1.5rem;">
|
||||
<li>View the VM status and details in the "VM Status" tab</li>
|
||||
<li>Access deployed applications via provided URLs</li>
|
||||
<li>See resource utilization metrics</li>
|
||||
<li>Monitor the environment's remaining active time</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip">
|
||||
<div class="tip-title">
|
||||
<i class="fas fa-lightbulb"></i>
|
||||
<span>Pro Tip</span>
|
||||
</div>
|
||||
<p>
|
||||
Sandbox environments automatically shut down after 30 minutes to conserve resources. You'll see a countdown timer showing the remaining time. Make sure to complete your exploration before time runs out!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2 class="help-section-title">Frequently Asked Questions</h2>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">What is an Ansible playbook?</div>
|
||||
<div class="faq-answer">
|
||||
An Ansible playbook is a YAML file that describes a set of tasks to be executed on remote servers. Playbooks define the desired state of systems and can configure applications, deploy software, and orchestrate advanced IT workflows.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">Is the sandbox environment isolated?</div>
|
||||
<div class="faq-answer">
|
||||
Yes, each sandbox environment is completely isolated. Your deployments and configurations won't affect other users or any production systems. This provides a safe space to experiment with infrastructure automation.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">Can I save or download the playbooks?</div>
|
||||
<div class="faq-answer">
|
||||
Yes, you can copy the playbook code to use in your own environments. Each code segment can be selected and copied to your clipboard. For a more organized approach, visit the Documentation page for downloadable versions of all playbooks.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">What happens if I need more time with an environment?</div>
|
||||
<div class="faq-answer">
|
||||
Currently, all sandbox environments are limited to 30 minutes. If you need more time, you can always redeploy the environment after it expires, which will give you a fresh 30-minute window to continue your exploration.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">Can I modify the playbooks?</div>
|
||||
<div class="faq-answer">
|
||||
The playbook code displayed is read-only to ensure consistent and reliable deployments. However, you can customize many aspects of the deployment through the Configuration tab, which allows you to adjust key parameters without modifying the underlying code.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">What if I encounter an error during deployment?</div>
|
||||
<div class="faq-answer">
|
||||
If an error occurs during deployment, the Output tab will display the specific error message from Ansible. You can use the "Reset" button to clear the environment and try again, potentially with different configuration options.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2 class="help-section-title">Key Terms</h2>
|
||||
|
||||
<p style="margin-bottom: 1.5rem;">
|
||||
<span class="key-term">Playbook</span> - A YAML file defining a series of Ansible tasks and configurations.
|
||||
</p>
|
||||
|
||||
<p style="margin-bottom: 1.5rem;">
|
||||
<span class="key-term">Task</span> - An individual action Ansible executes on a managed host, such as installing a package or creating a file.
|
||||
</p>
|
||||
|
||||
<p style="margin-bottom: 1.5rem;">
|
||||
<span class="key-term">Role</span> - A reusable, self-contained unit of tasks, variables, files, templates, and modules that can be shared across playbooks.
|
||||
</p>
|
||||
|
||||
<p style="margin-bottom: 1.5rem;">
|
||||
<span class="key-term">Inventory</span> - A list of hosts that Ansible will manage, grouped logically for targeted execution.
|
||||
</p>
|
||||
|
||||
<p style="margin-bottom: 1.5rem;">
|
||||
<span class="key-term">Handler</span> - A special type of task that only runs when notified by another task that made a change.
|
||||
</p>
|
||||
|
||||
<p style="margin-bottom: 1.5rem;">
|
||||
<span class="key-term">Variable</span> - A value that can be set and referenced in playbooks, making them more flexible and reusable.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="key-term">Template</span> - A text file that uses variables to create dynamic configuration files.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-info">
|
||||
<div class="footer-logo">
|
||||
<span class="logo-text-glow">ArgoBox</span>
|
||||
<span class="logo-dot-glow">.com</span>
|
||||
</div>
|
||||
<p class="footer-description">
|
||||
Enterprise-grade home lab environment for DevOps experimentation, infrastructure automation, and containerized application deployment.
|
||||
</p>
|
||||
<div class="footer-evolution">
|
||||
<i class="fas fa-code-branch evolution-icon"></i>
|
||||
<span>Continuously evolving since 2011</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-links">
|
||||
<div class="footer-links-column">
|
||||
<h3 class="footer-heading">Platforms</h3>
|
||||
<a href="https://dashboard.argobox.com" class="footer-link">Dashboard</a>
|
||||
<a href="ansible-sandbox.html" class="footer-link">Ansible Sandbox</a>
|
||||
<a href="https://git.argobox.com" class="footer-link">Gitea</a>
|
||||
<a href="https://ai.argobox.com" class="footer-link">OpenWebUI</a>
|
||||
</div>
|
||||
|
||||
<div class="footer-links-column">
|
||||
<h3 class="footer-heading">Documentation</h3>
|
||||
<a href="construction.html" class="footer-link">Architecture</a>
|
||||
<a href="construction.html" class="footer-link">Kubernetes</a>
|
||||
<a href="construction.html" class="footer-link">Ansible</a>
|
||||
<a href="construction.html" class="footer-link">Network</a>
|
||||
</div>
|
||||
|
||||
<div class="footer-links-column">
|
||||
<h3 class="footer-heading">Resources</h3>
|
||||
<a href="construction.html" class="footer-link">Ansible Playbooks</a>
|
||||
<a href="construction.html" class="footer-link">K8s Manifests</a>
|
||||
<a href="construction.html" class="footer-link">Shell Scripts</a>
|
||||
<a href="construction.html" class="footer-link">Configuration Files</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<p>© <span id="current-year"></span> All rights reserved. Inovin LLC</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set current year in footer
|
||||
document.getElementById('current-year').textContent = new Date().getFullYear();
|
||||
|
||||
// Mobile menu toggle
|
||||
const menuToggle = document.querySelector('.menu-toggle');
|
||||
const navMenu = document.querySelector('.nav-menu');
|
||||
|
||||
menuToggle.addEventListener('click', function() {
|
||||
navMenu.classList.toggle('show');
|
||||
const icon = menuToggle.querySelector('i');
|
||||
|
||||
if (navMenu.classList.contains('show')) {
|
||||
icon.classList.remove('fa-bars');
|
||||
icon.classList.add('fa-times');
|
||||
} else {
|
||||
icon.classList.remove('fa-times');
|
||||
icon.classList.add('fa-bars');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
2909
ansible-sandbox.html
|
@ -0,0 +1,30 @@
|
|||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import mdx from '@astrojs/mdx';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import cloudflare from '@astrojs/cloudflare'; // Import cloudflare adapter
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://argobox.com', // Keep original site URL
|
||||
output: 'static',
|
||||
adapter: cloudflare(), // Enable cloudflare adapter
|
||||
integrations: [
|
||||
mdx(),
|
||||
sitemap(),
|
||||
tailwind(),
|
||||
],
|
||||
markdown: { // Add markdown config
|
||||
shikiConfig: {
|
||||
theme: 'one-dark-pro',
|
||||
wrap: true
|
||||
},
|
||||
remarkPlugins: [],
|
||||
rehypePlugins: []
|
||||
},
|
||||
compressHTML: false, // Disable HTML compression to avoid parsing errors
|
||||
build: { // Add build format
|
||||
format: 'file',
|
||||
}
|
||||
});
|
|
@ -0,0 +1,36 @@
|
|||
|
||||
FullName
|
||||
--------
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\@astrojs\prism\Prism.astro
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\ClientRouter.astro
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\Code.astro
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\Debug.astro
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\Font.astro
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\Image.astro
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\Picture.astro
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\ResponsiveImage.astro
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\ResponsivePicture.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\components\DigitalGardenGraph.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\components\Footer.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\components\Header.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\components\Newsletter.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\components\PostCard.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\layouts\BaseLayout.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\layouts\BlogPost.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\layouts\BlogPostLayout.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\configurations.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\index.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\search.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\stack.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\subscription-confirmed.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\unsubscribe.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\blog\[slug].astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\blog\index.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\categories\[category].astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\categories\index.astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\configurations\[slug].astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\posts\[slug].astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\projects\[slug].astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\tag\[tag].astro
|
||||
C:\Projects\--Repo\laforceit-blog\src\pages\tags\index.astro
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Under Construction | ArgoBox</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
.construction-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.construction-content {
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
padding: 2rem;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.construction-title {
|
||||
font-size: 3rem;
|
||||
color: #00ff9d;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 0 0 10px rgba(0, 255, 157, 0.5);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.construction-subtitle {
|
||||
font-size: 1.5rem;
|
||||
color: #ffffff;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.construction-status {
|
||||
background: rgba(0, 255, 157, 0.1);
|
||||
border: 1px solid rgba(0, 255, 157, 0.3);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.status-line {
|
||||
color: #00ff9d;
|
||||
margin: 0.5rem 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.status-line::before {
|
||||
content: '> ';
|
||||
color: #00ff9d;
|
||||
}
|
||||
|
||||
.construction-progress {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
margin: 2rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00ff9d, #00b8ff);
|
||||
width: 0;
|
||||
animation: progress 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% { width: 0; }
|
||||
50% { width: 100%; }
|
||||
100% { width: 0; }
|
||||
}
|
||||
|
||||
.construction-back {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.construction-back:hover {
|
||||
color: #00ff9d;
|
||||
}
|
||||
|
||||
.binary-rain {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
opacity: 0.1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="construction-container">
|
||||
<div class="binary-rain" id="binary-rain"></div>
|
||||
<div class="construction-content">
|
||||
<h1 class="construction-title">UNDER CONSTRUCTION</h1>
|
||||
<p class="construction-subtitle">This service is currently being deployed and configured</p>
|
||||
|
||||
<div class="construction-status">
|
||||
<div class="status-line">Initializing deployment sequence...</div>
|
||||
<div class="status-line">Configuring infrastructure components...</div>
|
||||
<div class="status-line">Deploying service containers...</div>
|
||||
<div class="status-line">Establishing secure connections...</div>
|
||||
<div class="status-line">Optimizing performance parameters...</div>
|
||||
</div>
|
||||
|
||||
<div class="construction-progress">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
|
||||
<a href="https://argobox.com" class="construction-back">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
Return to ArgoBox
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Create binary rain effect
|
||||
const binaryRain = document.getElementById('binary-rain');
|
||||
const chars = '01';
|
||||
|
||||
function createBinaryRain() {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
span.style.position = 'absolute';
|
||||
span.style.left = Math.random() * 100 + '%';
|
||||
span.style.top = '-20px';
|
||||
span.style.color = '#00ff9d';
|
||||
span.style.opacity = Math.random() * 0.5 + 0.5;
|
||||
span.style.fontSize = Math.random() * 10 + 10 + 'px';
|
||||
span.style.fontFamily = 'JetBrains Mono, monospace';
|
||||
|
||||
binaryRain.appendChild(span);
|
||||
|
||||
let pos = -20;
|
||||
const fall = setInterval(() => {
|
||||
pos += 2;
|
||||
span.style.top = pos + 'px';
|
||||
|
||||
if (pos > window.innerHeight) {
|
||||
clearInterval(fall);
|
||||
span.remove();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
setInterval(createBinaryRain, 100);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,58 @@
|
|||
|
||||
FullName
|
||||
--------
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\@astrojs\tailwind\base.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\image.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\viewtransitions.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\autolinker\prism-autolinker.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\autolinker\prism-autolinker.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\command-line\prism-command-line.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\command-line\prism-command-line.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\diff-highlight\prism-diff-highlight.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\diff-highlight\prism-diff-highlight.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\inline-color\prism-inline-color.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\inline-color\prism-inline-color.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\line-highlight\prism-line-highlight.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\line-highlight\prism-line-highlight.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\line-numbers\prism-line-numbers.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\line-numbers\prism-line-numbers.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\match-braces\prism-match-braces.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\match-braces\prism-match-braces.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\previewers\prism-previewers.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\previewers\prism-previewers.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\show-invisibles\prism-show-invisibles.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\show-invisibles\prism-show-invisibles.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\toolbar\prism-toolbar.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\toolbar\prism-toolbar.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\treeview\prism-treeview.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\treeview\prism-treeview.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\unescaped-markup\prism-unescaped-markup.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\unescaped-markup\prism-unescaped-markup.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\wpd\prism-wpd.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\wpd\prism-wpd.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-coy.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-coy.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-dark.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-dark.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-funky.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-funky.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-okaidia.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-okaidia.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-solarizedlight.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-solarizedlight.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-tomorrow.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-tomorrow.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-twilight.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-twilight.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism.min.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\base.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\components.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\screens.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\tailwind.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\utilities.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\variants.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\lib\css\preflight.css
|
||||
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\src\css\preflight.css
|
||||
C:\Projects\--Repo\laforceit-blog\src\styles\global.css
|
||||
|
2209
dashboard.html
|
@ -1,63 +0,0 @@
|
|||
export async function onRequestPost(context) {
|
||||
try {
|
||||
const { name, email, subject, message } = await context.request.json();
|
||||
|
||||
// Validate inputs
|
||||
if (!name || !email || !subject || !message) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'All fields are required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Send email using Resend
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${context.env.RESEND_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: {
|
||||
email: email,
|
||||
name: name
|
||||
},
|
||||
to: [
|
||||
{
|
||||
email: "daniel@laforceit.com",
|
||||
name: "Daniel LaForce"
|
||||
}
|
||||
],
|
||||
subject: `ArgoBox Contact Form: ${subject}`,
|
||||
html: `
|
||||
<h3>New Contact Message on ArgoBox.com</h3>
|
||||
<p><strong>Name:</strong> ${name}</p>
|
||||
<p><strong>Email:</strong> ${email}</p>
|
||||
<p><strong>Subject:</strong> ${subject}</p>
|
||||
<h4>Message:</h4>
|
||||
<p>${message}</p>
|
||||
`,
|
||||
reply_to: [
|
||||
{
|
||||
email,
|
||||
name
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send email');
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ message: 'Message sent successfully!' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to send message' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 153 KiB |
Before Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 757 B |
Before Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 15 KiB |
|
@ -1 +0,0 @@
|
|||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
949
index.html
|
@ -1,949 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>ArgoBox | Enterprise-Grade Home Lab Environment</title>
|
||||
<meta name="description" content="ArgoBox - A production-grade Kubernetes homelab for DevOps experimentation, infrastructure automation, and containerized application deployment.">
|
||||
<meta name="keywords" content="kubernetes, k3s, homelab, devops, ansible, proxmox, automation, infrastructure, containerization, zero trust">
|
||||
<meta name="author" content="Daniel LaForce">
|
||||
|
||||
<!-- Open Graph / Social Media Meta Tags -->
|
||||
<meta property="og:title" content="ArgoBox | Enterprise-Grade Home Lab Environment">
|
||||
<meta property="og:description" content="A production-grade Kubernetes homelab with zero trust architecture, complete automation, and enterprise-class infrastructure.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://argobox.com">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="images/favicon.ico" type="image/x-icon">
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
/* Add the CSS for the offline indicator */
|
||||
.live-indicator.offline {
|
||||
background-color: #ef4444 !important; /* Red color */
|
||||
}
|
||||
|
||||
.live-indicator.offline::after {
|
||||
background-color: #ef4444 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation Bar -->
|
||||
<nav class="navbar">
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<a href="#home">
|
||||
<span class="logo-text-glow">ArgoBox</span><span class="logo-dot-glow">.com</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-menu">
|
||||
<a href="#home" class="nav-link">Home</a>
|
||||
<a href="#technologies" class="nav-link">Technologies</a>
|
||||
<a href="#services" class="nav-link">Services</a>
|
||||
<a href="#projects" class="nav-link">Projects</a>
|
||||
<a href="#dashboards" class="nav-link">Dashboards</a>
|
||||
<a href="#contact" class="nav-link">Contact</a>
|
||||
</div>
|
||||
<div class="nav-buttons">
|
||||
<a href="https://dashboard.argobox.com" class="dashboard-link" target="_blank">
|
||||
<span class="live-indicator offline"></span>
|
||||
<span>Live Dashboard</span>
|
||||
</a>
|
||||
<button class="menu-toggle" aria-label="Toggle menu">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Top Links -->
|
||||
<div class="top-links">
|
||||
<a href="https://laforceit.com" class="laforceit-link">
|
||||
<span class="logo-text-glow">LaForceIT</span><span class="logo-dot-glow">.com</span>
|
||||
</a>
|
||||
<a href="construction.html" class="signin-button" target="_blank">
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
<span>Sign In</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section id="home" class="hero">
|
||||
<div class="particles-container" id="particles-container"></div>
|
||||
<div class="container">
|
||||
<div class="hero-content">
|
||||
<div class="hero-text">
|
||||
<h1 class="hero-title">
|
||||
Enterprise-Grade <span class="highlight">Home Lab</span> Environment
|
||||
</h1>
|
||||
<p class="hero-description">
|
||||
A production-ready infrastructure platform for DevOps experimentation, distributed systems, and automating everything with code.
|
||||
</p>
|
||||
<div class="hero-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon"><i class="fas fa-microchip"></i></div>
|
||||
<div class="stat-detail">
|
||||
<div class="stat-value">32+</div>
|
||||
<div class="stat-name">CPU Cores</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon"><i class="fas fa-memory"></i></div>
|
||||
<div class="stat-detail">
|
||||
<div class="stat-value">64GB</div>
|
||||
<div class="stat-name">RAM</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon"><i class="fas fa-hdd"></i></div>
|
||||
<div class="stat-detail">
|
||||
<div class="stat-value">12TB</div>
|
||||
<div class="stat-name">Storage</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon"><i class="fas fa-server"></i></div>
|
||||
<div class="stat-detail">
|
||||
<div class="stat-value">16+</div>
|
||||
<div class="stat-name">Services</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cta-buttons">
|
||||
<a href="ansible-sandbox.html" class="btn btn-danger" target="_blank" id="ansible-sandbox-btn">
|
||||
<i class="fab fa-ansible btn-icon"></i>
|
||||
<span class="btn-text">Try Ansible Sandbox</span>
|
||||
<span class="offline-badge">Offline</span>
|
||||
</a>
|
||||
<a href="#architecture" class="btn btn-outline">
|
||||
<i class="fas fa-network-wired btn-icon"></i>
|
||||
<span class="btn-text">Explore Architecture</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-terminal">
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-buttons">
|
||||
<span class="terminal-btn close"></span>
|
||||
<span class="terminal-btn minimize"></span>
|
||||
<span class="terminal-btn maximize"></span>
|
||||
</div>
|
||||
<div class="terminal-title">argobox ~ k8s-status</div>
|
||||
</div>
|
||||
<div class="terminal-body">
|
||||
<div class="terminal-line">$ kubectl get nodes</div>
|
||||
<div class="terminal-line output">NAME STATUS ROLES AGE VERSION</div>
|
||||
<div class="terminal-line output">argobox Ready control-plane,master 154d v1.25.16+k3s1</div>
|
||||
<div class="terminal-line output">argobox-lite Ready worker 154d v1.25.16+k3s1</div>
|
||||
<div class="terminal-line blank"> </div>
|
||||
<div class="terminal-line">$ kubectl get pods -A | grep Running | wc -l</div>
|
||||
<div class="terminal-line output">32</div>
|
||||
<div class="terminal-line blank"> </div>
|
||||
<div class="terminal-line">$ uptime</div>
|
||||
<div class="terminal-line output">14:30:25 up 154 days, 23:12, 1 user, load average: 0.22, 0.18, 0.15</div>
|
||||
<div class="terminal-line blank"> </div>
|
||||
<div class="terminal-line">$ ansible-playbook status.yml</div>
|
||||
<div class="terminal-line output">PLAY [Check system status] *******************************************</div>
|
||||
<div class="terminal-line output">TASK [Gathering Facts] **********************************************</div>
|
||||
<div class="terminal-line output success">ok: [argobox]</div>
|
||||
<div class="terminal-line output success">ok: [argobox-lite]</div>
|
||||
<div class="terminal-line output">TASK [Check service status] *****************************************</div>
|
||||
<div class="terminal-line output success">ok: [argobox]</div>
|
||||
<div class="terminal-line output success">ok: [argobox-lite]</div>
|
||||
<div class="terminal-line output">PLAY RECAP **********************************************************</div>
|
||||
<div class="terminal-line output success">argobox : ok=2 changed=0 unreachable=0 failed=0 skipped=0</div>
|
||||
<div class="terminal-line output success">argobox-lite: ok=2 changed=0 unreachable=0 failed=0 skipped=0</div>
|
||||
<div class="terminal-line typing">$ <span class="cursor">|</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Architecture Section -->
|
||||
<section id="architecture" class="architecture">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Infrastructure Architecture</h2>
|
||||
<p class="section-description">
|
||||
Enterprise-grade network topology with redundancy, virtualization, and secure segmentation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="architecture-diagram">
|
||||
<div class="diagram-wrapper">
|
||||
<div class="diagram-node isp">
|
||||
<div class="node-icon"><i class="fas fa-globe"></i></div>
|
||||
<div class="node-label">ISP Modem</div>
|
||||
</div>
|
||||
|
||||
<div class="diagram-tier router-tier">
|
||||
<div class="diagram-node router primary">
|
||||
<div class="node-icon"><i class="fas fa-shield-alt"></i></div>
|
||||
<div class="node-label">OPNsense VM</div>
|
||||
<div class="node-detail">Primary Router</div>
|
||||
</div>
|
||||
<div class="diagram-node router secondary">
|
||||
<div class="node-icon"><i class="fas fa-shield-alt"></i></div>
|
||||
<div class="node-label">OpenWrt RPi4</div>
|
||||
<div class="node-detail">Failover Router</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diagram-node switch">
|
||||
<div class="node-icon"><i class="fas fa-network-wired"></i></div>
|
||||
<div class="node-label">Core Switch</div>
|
||||
<div class="node-detail">MikroTik Layer 3</div>
|
||||
</div>
|
||||
|
||||
<div class="diagram-tier infra-tier">
|
||||
<div class="diagram-node server">
|
||||
<div class="node-icon"><i class="fas fa-server"></i></div>
|
||||
<div class="node-label">Proxmox Host</div>
|
||||
<div class="node-detail">argobox/argobox-lite</div>
|
||||
</div>
|
||||
<div class="diagram-node storage">
|
||||
<div class="node-icon"><i class="fas fa-database"></i></div>
|
||||
<div class="node-label">NAS Systems</div>
|
||||
<div class="node-detail">redcone/casablanca</div>
|
||||
</div>
|
||||
<div class="diagram-node clients">
|
||||
<div class="node-icon"><i class="fas fa-laptop"></i></div>
|
||||
<div class="node-label">Client Devices</div>
|
||||
<div class="node-detail">Workstations/IoT</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="architecture-details">
|
||||
<div class="detail-card">
|
||||
<div class="detail-icon"><i class="fas fa-shield-alt"></i></div>
|
||||
<h3 class="detail-title">Network Security</h3>
|
||||
<p class="detail-description">
|
||||
Enterprise firewall with network segmentation using VLANs and strict access controls. Redundant routing with automatic failover between OPNsense and OpenWrt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div class="detail-icon"><i class="fas fa-cloud"></i></div>
|
||||
<h3 class="detail-title">Virtualization</h3>
|
||||
<p class="detail-description">
|
||||
Proxmox virtualization platform with ZFS storage pools in RAID10 configuration. Optimized storage pools for VMs and containers with proper resource allocation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div class="detail-icon"><i class="fas fa-route"></i></div>
|
||||
<h3 class="detail-title">High Availability</h3>
|
||||
<p class="detail-description">
|
||||
Full redundancy with failover routing, replicated storage, and resilient services. Automatic service recovery and load balancing across nodes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Technologies Section -->
|
||||
<section id="technologies" class="technologies">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Core Technologies</h2>
|
||||
<p class="section-description">
|
||||
The ArgoBox lab leverages cutting-edge open source technologies to create a powerful, flexible infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="tech-grid">
|
||||
<div class="tech-card">
|
||||
<div class="tech-icon"><i class="fas fa-dharmachakra"></i></div>
|
||||
<h3 class="tech-title">Kubernetes (K3s)</h3>
|
||||
<p class="tech-description">
|
||||
Lightweight Kubernetes distribution running across multiple nodes for container orchestration. Powers all microservices and applications.
|
||||
</p>
|
||||
<div class="tech-features">
|
||||
<span class="tech-feature">Multi-node cluster</span>
|
||||
<span class="tech-feature">Persistent volumes</span>
|
||||
<span class="tech-feature">Traefik ingress</span>
|
||||
<span class="tech-feature">Auto-healing</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tech-card featured">
|
||||
<div class="tech-icon"><i class="fab fa-ansible"></i></div>
|
||||
<h3 class="tech-title">Ansible Automation</h3>
|
||||
<p class="tech-description">
|
||||
Infrastructure as code platform for automated provisioning, configuration management, and application deployment across the entire environment.
|
||||
</p>
|
||||
<div class="tech-features">
|
||||
<span class="tech-feature">Playbook library</span>
|
||||
<span class="tech-feature">Role-based configs</span>
|
||||
<span class="tech-feature">Interactive sandbox</span>
|
||||
<span class="tech-feature">Idempotent workflows</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tech-card">
|
||||
<div class="tech-icon"><i class="fas fa-server"></i></div>
|
||||
<h3 class="tech-title">Proxmox</h3>
|
||||
<p class="tech-description">
|
||||
Enterprise-class virtualization platform running virtual machines and containers with ZFS storage backend for data integrity.
|
||||
</p>
|
||||
<div class="tech-features">
|
||||
<span class="tech-feature">ZFS storage</span>
|
||||
<span class="tech-feature">Resource balancing</span>
|
||||
<span class="tech-feature">Live migration</span>
|
||||
<span class="tech-feature">Hardware passthrough</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tech-card">
|
||||
<div class="tech-icon"><i class="fas fa-shield-alt"></i></div>
|
||||
<h3 class="tech-title">Zero Trust Security</h3>
|
||||
<p class="tech-description">
|
||||
Comprehensive security architecture with Cloudflare tunnels, network segmentation, and authentication at all service boundaries.
|
||||
</p>
|
||||
<div class="tech-features">
|
||||
<span class="tech-feature">Cloudflare tunnels</span>
|
||||
<span class="tech-feature">OPNsense firewall</span>
|
||||
<span class="tech-feature">VLAN segmentation</span>
|
||||
<span class="tech-feature">WireGuard VPN</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tech-card">
|
||||
<div class="tech-icon"><i class="fas fa-database"></i></div>
|
||||
<h3 class="tech-title">PostgreSQL</h3>
|
||||
<p class="tech-description">
|
||||
Enterprise database cluster for application data storage with automated backups, replication, and performance optimization.
|
||||
</p>
|
||||
<div class="tech-features">
|
||||
<span class="tech-feature">Automated backups</span>
|
||||
<span class="tech-feature">Connection pooling</span>
|
||||
<span class="tech-feature">Optimized for K8s</span>
|
||||
<span class="tech-feature">Multi-app support</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tech-card">
|
||||
<div class="tech-icon"><i class="fas fa-chart-line"></i></div>
|
||||
<h3 class="tech-title">Monitoring Stack</h3>
|
||||
<p class="tech-description">
|
||||
Comprehensive monitoring with Prometheus, Grafana, and AlertManager for real-time visibility into all infrastructure components.
|
||||
</p>
|
||||
<div class="tech-features">
|
||||
<span class="tech-feature">Prometheus metrics</span>
|
||||
<span class="tech-feature">Grafana dashboards</span>
|
||||
<span class="tech-feature">Automated alerts</span>
|
||||
<span class="tech-feature">Historical data</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Services Section -->
|
||||
<section id="services" class="services">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Available Services</h2>
|
||||
<p class="section-description">
|
||||
Explore the various services and applications hosted in the ArgoBox environment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="services-info-banner">
|
||||
<i class="fas fa-info-circle info-icon"></i>
|
||||
<p>Some services require authentication and are restricted to authorized users. Available public services are highlighted and clickable.</p>
|
||||
</div>
|
||||
|
||||
<div class="services-grid">
|
||||
<!-- Development Tools -->
|
||||
<div class="services-category">
|
||||
<h3 class="category-title">
|
||||
<i class="fas fa-code category-icon"></i>
|
||||
Development Tools
|
||||
</h3>
|
||||
<div class="service-items">
|
||||
<a href="https://git.argobox.com" class="service-item available" target="_blank">
|
||||
<div class="service-icon"><i class="fas fa-code-branch"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Git Repository</h4>
|
||||
<p class="service-description">Gitea-powered Git service for code hosting and collaboration</p>
|
||||
</div>
|
||||
<div class="service-status live">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Public</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-code"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Dev Environment</h4>
|
||||
<p class="service-description">VS Code Server-powered development environment</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Knowledge & Content -->
|
||||
<div class="services-category">
|
||||
<h3 class="category-title">
|
||||
<i class="fas fa-book category-icon"></i>
|
||||
Knowledge & Content
|
||||
</h3>
|
||||
<div class="service-items">
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-brain"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Knowledge Base</h4>
|
||||
<p class="service-description">Obsidian-powered knowledge management system</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
<a href="https://blog.argobox.com" class="service-item available" target="_blank">
|
||||
<div class="service-icon"><i class="fas fa-rss"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Blog</h4>
|
||||
<p class="service-description">Obsidian-Powered technical articles and project documentation</p>
|
||||
</div>
|
||||
<div class="service-status live">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Public</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="https://notes.argobox.com" class="service-item available" target="_blank">
|
||||
<div class="service-icon"><i class="fas fa-sticky-note"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Notes</h4>
|
||||
<p class="service-description">Obsidian-Powered technical notes and snippets</p>
|
||||
</div>
|
||||
<div class="service-status live">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Public</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-book-open"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Documentation Portal</h4>
|
||||
<p class="service-description">GitBook-Powered comprehensive system documentation and guides</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI & Automation -->
|
||||
<div class="services-category">
|
||||
<h3 class="category-title">
|
||||
<i class="fas fa-robot category-icon"></i>
|
||||
AI & Automation
|
||||
</h3>
|
||||
<div class="service-items">
|
||||
<a href="https://ai.argobox.com" class="service-item available" target="_blank">
|
||||
<div class="service-icon"><i class="fas fa-brain"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">AI Assistant</h4>
|
||||
<p class="service-description">OpenWebUI and Ollama-powered AI language models</p>
|
||||
</div>
|
||||
<div class="service-status live">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Public</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="ansible-sandbox.html" class="service-item available" target="_blank">
|
||||
<div class="service-icon"><i class="fab fa-ansible"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Ansible Sandbox</h4>
|
||||
<p class="service-description">Interactive environment for infrastructure automation</p>
|
||||
</div>
|
||||
<div class="service-status offline">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Offline</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fab fa-git-alt"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">GitHub Actions</h4>
|
||||
<p class="service-description">Self-hosted GitHub Actions runners for CI/CD</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Files & Storage -->
|
||||
<div class="services-category">
|
||||
<h3 class="category-title">
|
||||
<i class="fas fa-folder-open category-icon"></i>
|
||||
Files & Storage
|
||||
</h3>
|
||||
<div class="service-items">
|
||||
<a href="https://files.argobox.com" class="service-item available" target="_blank">
|
||||
<div class="service-icon"><i class="fas fa-file-alt"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">File Browser</h4>
|
||||
<p class="service-description">Web-based file browser for shared resources</p>
|
||||
</div>
|
||||
<div class="service-status live">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Public</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-cloud"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Synology Drive</h4>
|
||||
<p class="service-description">Enterprise file sync and collaboration platform</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media & Downloads -->
|
||||
<div class="services-category">
|
||||
<h3 class="category-title">
|
||||
<i class="fas fa-download category-icon"></i>
|
||||
Media & Downloads
|
||||
</h3>
|
||||
<div class="service-items">
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-download"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">ruTorrent Instance 1</h4>
|
||||
<p class="service-description">Torrent-based download service</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-download"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">ruTorrent Instance 2</h4>
|
||||
<p class="service-description">Torrent-based download service</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-film"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Plex Media Server</h4>
|
||||
<p class="service-description">Premium streaming platform for movies, TV shows, and music</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-film"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Jellyfin</h4>
|
||||
<p class="service-description">Open source media server alternative</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monitoring & Management -->
|
||||
<div class="services-category">
|
||||
<h3 class="category-title">
|
||||
<i class="fas fa-chart-line category-icon"></i>
|
||||
Monitoring & Management
|
||||
</h3>
|
||||
<div class="service-items">
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-tachometer-alt"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Grafana Dashboards</h4>
|
||||
<p class="service-description">Comprehensive system monitoring and visualization</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-bell"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Alertmanager</h4>
|
||||
<p class="service-description">System alerts and notifications</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-sitemap"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Rancher</h4>
|
||||
<p class="service-description">Kubernetes management platform</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-network-wired"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Network Monitor</h4>
|
||||
<p class="service-description">Traffic analysis and network monitoring</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Experimental Services -->
|
||||
<div class="services-category">
|
||||
<h3 class="category-title">
|
||||
<i class="fas fa-flask category-icon"></i>
|
||||
Experimental Services
|
||||
</h3>
|
||||
<div class="service-items">
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-calendar-alt"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Calendar System</h4>
|
||||
<p class="service-description">Shared calendar and scheduling platform</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-tasks"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Kanban Board</h4>
|
||||
<p class="service-description">Task management and workflow visualization</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-item">
|
||||
<div class="service-icon"><i class="fas fa-satellite-dish"></i></div>
|
||||
<div class="service-info">
|
||||
<h4 class="service-name">Home Automation</h4>
|
||||
<p class="service-description">Smart home control and automation hub</p>
|
||||
</div>
|
||||
<div class="service-status restricted">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Restricted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Projects Section -->
|
||||
<section id="projects" class="projects">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Featured Projects</h2>
|
||||
<p class="section-description">
|
||||
A showcase of technical solutions I've built and deployed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="projects-grid">
|
||||
<div class="project-card">
|
||||
<div class="project-icon">
|
||||
<i class="fas fa-map-marked-alt"></i>
|
||||
</div>
|
||||
<h3 class="project-title">TerraTracer</h3>
|
||||
<p class="project-description">
|
||||
A GIS mapping tool for prospectors to automate mining claim boundary plotting, terrain analysis, and compliance with BLM/state regulations.
|
||||
</p>
|
||||
<div class="tech-badges">
|
||||
<span class="tech-badge">Python</span>
|
||||
<span class="tech-badge">Node.js</span>
|
||||
<span class="tech-badge">JavaScript</span>
|
||||
<span class="tech-badge">GIS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-card">
|
||||
<div class="project-icon">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</div>
|
||||
<h3 class="project-title">Zero Trust Lab</h3>
|
||||
<p class="project-description">
|
||||
A secure home lab infrastructure using Cloudflare Zero Trust tunnels, network segmentation, and security best practices.
|
||||
</p>
|
||||
<div class="tech-badges">
|
||||
<span class="tech-badge">Cloudflare</span>
|
||||
<span class="tech-badge">OPNsense</span>
|
||||
<span class="tech-badge">VLAN</span>
|
||||
<span class="tech-badge">VPN</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-card">
|
||||
<div class="project-icon">
|
||||
<i class="fas fa-robot"></i>
|
||||
</div>
|
||||
<h3 class="project-title">Ansible Sandbox</h3>
|
||||
<p class="project-description">
|
||||
An interactive demo environment where users can spin up preconfigured services using Ansible automation.
|
||||
</p>
|
||||
<div class="tech-badges">
|
||||
<span class="tech-badge">Ansible</span>
|
||||
<span class="tech-badge">Proxmox</span>
|
||||
<span class="tech-badge">Python</span>
|
||||
<span class="tech-badge">Docker</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Dashboards Section -->
|
||||
<section id="dashboards" class="dashboards">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Live Dashboards</h2>
|
||||
<p class="section-description">
|
||||
Real-time monitoring and management interfaces for the ArgoBox infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="dashboard-card">
|
||||
<div class="dashboard-preview infrastructure">
|
||||
<div class="dashboard-overlay">
|
||||
<div class="overlay-content">
|
||||
<i class="fas fa-chart-line overlay-icon"></i>
|
||||
<span class="overlay-text">View Dashboard</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-info">
|
||||
<h3 class="dashboard-title">Infrastructure Metrics</h3>
|
||||
<p class="dashboard-description">
|
||||
Real-time performance metrics for all infrastructure components.
|
||||
</p>
|
||||
<div class="dashboard-cta">
|
||||
<a href="https://dashboard.argobox.com/infrastructure" class="btn btn-sm">Open Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<div class="dashboard-preview kubernetes">
|
||||
<div class="dashboard-overlay">
|
||||
<div class="overlay-content">
|
||||
<i class="fas fa-dharmachakra overlay-icon"></i>
|
||||
<span class="overlay-text">View Dashboard</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-info">
|
||||
<h3 class="dashboard-title">Kubernetes Status</h3>
|
||||
<p class="dashboard-description">
|
||||
Monitoring dashboard for K3s cluster health and metrics.
|
||||
</p>
|
||||
<div class="dashboard-cta">
|
||||
<a href="https://dashboard.argobox.com/kubernetes" class="btn btn-sm">Open Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<div class="dashboard-preview network">
|
||||
<div class="dashboard-overlay">
|
||||
<div class="overlay-content">
|
||||
<i class="fas fa-network-wired overlay-icon"></i>
|
||||
<span class="overlay-text">View Dashboard</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-info">
|
||||
<h3 class="dashboard-title">Network Monitor</h3>
|
||||
<p class="dashboard-description">
|
||||
Network traffic analysis and monitoring.
|
||||
</p>
|
||||
<div class="dashboard-cta">
|
||||
<a href="https://dashboard.argobox.com/network" class="btn btn-sm">Open Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<div class="dashboard-preview services">
|
||||
<div class="dashboard-overlay">
|
||||
<div class="overlay-content">
|
||||
<i class="fas fa-th overlay-icon"></i>
|
||||
<span class="overlay-text">View Dashboard</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-info">
|
||||
<h3 class="dashboard-title">Service Portal</h3>
|
||||
<p class="dashboard-description">
|
||||
Centralized access to all deployed services.
|
||||
</p>
|
||||
<div class="dashboard-cta">
|
||||
<a href="https://dashboard.argobox.com/portal" class="btn btn-sm">Open Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Contact Section -->
|
||||
<section id="contact" class="contact">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Let's Connect</h2>
|
||||
<p class="section-description">
|
||||
Have a project in mind? Reach out to discuss how I can help.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="contact-grid">
|
||||
<div class="contact-info">
|
||||
<div class="contact-item">
|
||||
<div class="contact-icon">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</div>
|
||||
<h3 class="contact-title">Email</h3>
|
||||
<p><a href="mailto:daniel@argobox.com">daniel@argobox.com</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact-form-container">
|
||||
<form id="contact-form" class="contact-form">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subject">Subject</label>
|
||||
<input type="text" id="subject" name="subject" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" name="message" rows="5" required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-paper-plane btn-icon"></i>
|
||||
Send Message
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-info">
|
||||
<div class="footer-logo">
|
||||
<span class="logo-text-glow">ArgoBox</span>
|
||||
<span class="logo-dot-glow">.com</span>
|
||||
</div>
|
||||
<p class="footer-description">
|
||||
Enterprise-grade home lab environment for DevOps experimentation, infrastructure automation, and containerized application deployment.
|
||||
</p>
|
||||
<div class="footer-evolution">
|
||||
<i class="fas fa-code-branch evolution-icon"></i>
|
||||
<span>Continuously evolving since 2011</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-links">
|
||||
<div class="footer-links-column">
|
||||
<h3 class="footer-heading">Platforms</h3>
|
||||
<a href="https://dashboard.argobox.com" class="footer-link">Dashboard</a>
|
||||
<a href="ansible-sandbox.html" class="footer-link">Ansible Sandbox</a>
|
||||
<a href="https://git.argobox.com" class="footer-link">Gitea</a>
|
||||
<a href="https://ai.argobox.com" class="footer-link">OpenWebUI</a>
|
||||
</div>
|
||||
|
||||
<div class="footer-links-column">
|
||||
<h3 class="footer-heading">Documentation</h3>
|
||||
<a href="construction.html" class="footer-link">Architecture</a>
|
||||
<a href="construction.html" class="footer-link">Kubernetes</a>
|
||||
<a href="construction.html" class="footer-link">Ansible</a>
|
||||
<a href="construction.html" class="footer-link">Network</a>
|
||||
</div>
|
||||
|
||||
<div class="footer-links-column">
|
||||
<h3 class="footer-heading">Resources</h3>
|
||||
<a href="construction.html" class="footer-link">Ansible Playbooks</a>
|
||||
<a href="construction.html" class="footer-link">K8s Manifests</a>
|
||||
<a href="construction.html" class="footer-link">Shell Scripts</a>
|
||||
<a href="construction.html" class="footer-link">Configuration Files</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<p>© <span id="current-year">2025</span> All rights reserved. Inovin LLC</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
30
package.json
|
@ -1,19 +1,25 @@
|
|||
{
|
||||
"name": "argobox-portfolio",
|
||||
"version": "1.0.0",
|
||||
"description": "Portfolio website with contact form functionality",
|
||||
"main": "server.js",
|
||||
"name": "argobox-astro",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"nodemailer": "^6.9.7"
|
||||
"@astrojs/cloudflare": "12.5.0",
|
||||
"@astrojs/mdx": "4.2.4",
|
||||
"@astrojs/rss": "4.0.11",
|
||||
"@astrojs/sitemap": "3.3.0",
|
||||
"@astrojs/tailwind": "6.0.2",
|
||||
"astro": "5.7.4",
|
||||
"nodemailer": "^6.10.1",
|
||||
"resend": "^4.4.1",
|
||||
"tailwindcss": "3.4.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
"@tailwindcss/typography": "^0.5.16"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 117 KiB |
After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 2.4 MiB |
After Width: | Height: | Size: 2.5 MiB |
After Width: | Height: | Size: 2.6 MiB |
After Width: | Height: | Size: 2.7 MiB |
After Width: | Height: | Size: 1.9 MiB |
After Width: | Height: | Size: 2.2 MiB |
After Width: | Height: | Size: 2.5 MiB |
After Width: | Height: | Size: 2.4 MiB |
After Width: | Height: | Size: 696 B |
After Width: | Height: | Size: 1.6 KiB |
After Width: | Height: | Size: 15 KiB |
|
@ -0,0 +1 @@
|
|||
data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAEAAAAAAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcGBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCABAAIADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD9/KKKKACiiigAooooAKKKKACiiigAorH8Y+MdH8DaRLqWt6nY6PpluAZ7y/uEt7eEE4G+R2CqM9MkDNfOPx3/AOCt/wAFvhPdXFnJ4ys/EGoxlkjs/DsL37sR6soWEeu6ZW9qAPpeivgHXf8AgrT4nv8AxDLpujfCS61HTwcJfS6zNazjP8bQLZTBlHbcN3WvMPGH/BZ3xlcSyx+Gvh5pVn5RKrJq2oT3HL9SsQtgV4+6d2e9AH6F/Gj44eFvgJ4JuPEXizV7fRdIhkSFZpAzO8jsAFREBZ2JIAAAJJHv18x+F/8AgqX8KfFfiGz0uPU9Us7q9lEMU2oaFe2lsGPTfM8YjQY7yMoycZAJFfnd8U/jl8Qv2hdQ87xV4q1XW7GcmS1tJ5/KsR/dhgTCKRnoQDgc5ry7WNXS2UbGywUFztyAT3Jzzn+lAH9H+k6pa61p8N5ZzxXlncIJIpreQSRyKRkFWUkMD6EVPXwD/wAEh/jnqOrXGq+AtWupL6yt7dNR02aViXgQuskiAnkDcHwPRwOmK++aACiiigAooooAKKKKACvI/wBpz9rHwX+zD4ca91+9aXULiPfZaRZESX14OwRCRtTIBLsFUZ6kkA+uV+bP7UF/N+07+0lqlxbyFtKhuJIYPlyqWsSny/woGwvGdzkscmgDpvit/wAFNPiB8TI5YvDceneELA/LHLBCLrUZPRvNf5ExzjbHGcfxGvnXWdWvfEGotdX93cX93KAHmuZmllfHbcxyeB7/AErOTQY7eXft+Vdwww5BIwOB0B7jPervh7Ql1jWvJkb5TncVyDsHXA7ZwQCeOv0oA5/UpRGRFEGxyNmcDjqT9O30p9jpdxeXCxwRySO/AVBn8/T/APXXvNn8P9NigZYdOhMgwySPl2HHJDMc5+o49sVoQ+HbaJVX7PCoXJGIgMnBGeD3z+VSpsrlOCPL/D3wuuLaaNZreSSRyNwVSFX1z7fXB7ZzXrfhvwFc2JWTy2ji6KWUgj6Ajg+x59K6bRbGKLBjWP5ecCIHnrz6Y/l7V0Vrb71VchvU46Y9+MdfpUOpJkxopHOaH4eElu0Yjhj3LjBQY9D06H37fTnlv2atZn+B37Wfgm9h3Nb6hew6fOoOcxzkRZBx1BkTOMdO9eo6FY7djtAyY+YbCQwGexGeORkVyPx48Iv4q8H2ur20bS32hyi5TZ96SLOJFx6qu8gdtoHvVRlcbR+p9FYvgDxnb+O/A+ja9a7/ACNWsYL6LcMNtkjWRcj0O4VtVRIUUUUAFFFFABX5bfteeEvEXgf426ja6kZLiO6aWS3vQMLdRsSGDD+E4IKnqOCMMpx+pNcf8dPhLY/Gj4Y6toV1tSa4jL2tw6bvs9yo3RyAdSAwww/iVmGRmgD8sdN0+bUJ1WFZHkY5VQM7vbA6mvZPBHw7ijsnmvIlkknXeI+uF4HUdMAfzruPh7+zlNoWrTR3Fq9vcK5ErBgJOP73HzDHbjHSvXvDvwUW/wBMWTaMYBEecHdng5PQ+nH0PeoY0eU+BvBNxJcRlo9ix4AVQFwPZQAMDPsK9o8OeE4okUNGFVRnkYb6evTHPtxXc+GPg3b2e3zE24xnYpBx3x9Pp0rd1HwhHZWrNGvzAZHG7PoMetJ6DR50mi/ZmZnbyyyhdoUqGI6dOh6/h+VEulP5O7btZt2OcgY4HH4V0d5pTWzKdo27iMhhnkY6dCe351mzxKDjaoTg85wMdTk9KmwGfJ5i4X5V2nI6jH8umOD7da4H4y6QniD4da1CNu5baScZGQXtw0y/+QxXpl1aGRTtKsVGMg//AFqqmCOaIwyIsse4hlPVXQncD7gqQfqKpCZ5t+xh8SJvFnwjl0e6kaS88O3BtgW+9JbSfPC3pxlx9EFe111P7K/gC5+H3we+yx/ubrUWN9dJn/VNJgJGf9pI0jU+6muWrbkCiiikAUUUUAFFFFAHhH7YHwcj8XeHj4ksYFGraSgNwUXDXNuDg5x1KElfoWxnbzg/hF8fLjwzeR6fdzNNBOyqyP8Ae4Gc47HtnPQD3I/TuvlD9qH9lddS1abxJocYjulYzXltENqSrzukT0cZJI7gkHndjzsNbldzow83J8sj3P4ZfG601nTI8OskbKu5JBuVh3B9P0r1fRfiCt9b7lLbh1z1H+fw+or8xNC8Wa18MNbhmtpJFUOA8bNmOVScgg/hzX0N4G+OcN1bxs0jNIADJG5wy5HX+h9wazlTa2LVSMtGfV9/rkdy5kb5pOcZ/wD1d6pXl4u0/MWYdeDj3//XxXkWh/F+3vYY2jm3Y6g88Ht9e3QdK3o/Ho8tflYL94/Nz/n86i7NIxdrt2OrvAJFba3yqN2DnJ96yri5aGRlAZcfMNowSep9x+FZMvjpYtzbvl6q24kevHrWfeeNY4wrNIHyecHAHua0i2c1SSN9tTCx8MCw3dMEHsR7dv8AGsa2mmS7/dv5cyK2CF2hieOQM8/j0/Osm68Yx6hKy75G29H/AMMj86h0vxR9mvGlhbejR7cE5yOnPseK1S1Mmz7Q+GPhweFfAmkabuWR9PsobdpE+7IY41Usp9CQSPaugoooNCkFFFFAgooooAKKKKACiiigDH8VeB9H8Z2n2fVtNs9St/4UuoFk2H1U4O0+6kGvHfFf7Cuhak7zaVqF3pMjZIiY/aYc9wA21lHsWb/dHavdqKAPjjxp+x94i8P3DfYbCS+iVsbI3EM49MJJgMfZWPGBk15Z4k+CniSNW3aHdSRgFnEUfmKwxwAEJAGMnjAwRntX6HZGM1BdWUV9D5c0SSxtw0cihlYehB60XYrI/OG/8Iahp0zeY11as+Su+Jo2HPbcAD07E844qZLW4W2aSaG4dOSRsdmJ+gBP5V9++IfhH4X8SCSOS0FlNztuLQiNh7nGFYehBrx3xT+x7d2Lc2kbr03QyhkPqdk21hjtljx6UlMd0fJQsZbyRWjjboc71O32HHOM/StLTdJlK42yMVOflGcH07/5+le06n+zdf2J33VtuUZxujUOvuI3UuPcHGf4cGsPUPg/HZYZ9PvLbPXzIfPGPUSL+6/HK5q+cxdI2v2XfDUmufEizkk3C10+N7qZs4BIIWNQO/ztuPspr7Rqj4V8K2fgzw7Z6TYo0dpaJ5a7xhm5JLMe5JJJPua0BRYQtQooopgFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFR3FvHcxNHLGssbjDI6hlYeoI6iiigCrL4X06b79haP8A71sivt61Avhfw9b/APHhpVqnH/LHYmPyGKKKaXYTbJYYEgXbGiRr2VFCgfgBUlFFMQUUUUAFFFFABRRRQAUUUUAf/9k=
|
After Width: | Height: | Size: 2.2 MiB |
|
@ -0,0 +1 @@
|
|||
data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAEAAAAAAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcGBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCABAAIADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD9/KKKKACiiigAooooAKKKKACiiigAorH8Y+MdH8DaRLqWt6nY6PpluAZ7y/uEt7eEE4G+R2CqM9MkDNfOPx3/AOCt/wAFvhPdXFnJ4ys/EGoxlkjs/DsL37sR6soWEeu6ZW9qAPpeivgHXf8AgrT4nv8AxDLpujfCS61HTwcJfS6zNazjP8bQLZTBlHbcN3WvMPGH/BZ3xlcSyx+Gvh5pVn5RKrJq2oT3HL9SsQtgV4+6d2e9AH6F/Gj44eFvgJ4JuPEXizV7fRdIhkSFZpAzO8jsAFREBZ2JIAAAJJHv18x+F/8AgqX8KfFfiGz0uPU9Us7q9lEMU2oaFe2lsGPTfM8YjQY7yMoycZAJFfnd8U/jl8Qv2hdQ87xV4q1XW7GcmS1tJ5/KsR/dhgTCKRnoQDgc5ry7WNXS2UbGywUFztyAT3Jzzn+lAH9H+k6pa61p8N5ZzxXlncIJIpreQSRyKRkFWUkMD6EVPXwD/wAEh/jnqOrXGq+AtWupL6yt7dNR02aViXgQuskiAnkDcHwPRwOmK++aACiiigAooooAKKKKACvI/wBpz9rHwX+zD4ca91+9aXULiPfZaRZESX14OwRCRtTIBLsFUZ6kkA+uV+bP7UF/N+07+0lqlxbyFtKhuJIYPlyqWsSny/woGwvGdzkscmgDpvit/wAFNPiB8TI5YvDceneELA/LHLBCLrUZPRvNf5ExzjbHGcfxGvnXWdWvfEGotdX93cX93KAHmuZmllfHbcxyeB7/AErOTQY7eXft+Vdwww5BIwOB0B7jPervh7Ql1jWvJkb5TncVyDsHXA7ZwQCeOv0oA5/UpRGRFEGxyNmcDjqT9O30p9jpdxeXCxwRySO/AVBn8/T/APXXvNn8P9NigZYdOhMgwySPl2HHJDMc5+o49sVoQ+HbaJVX7PCoXJGIgMnBGeD3z+VSpsrlOCPL/D3wuuLaaNZreSSRyNwVSFX1z7fXB7ZzXrfhvwFc2JWTy2ji6KWUgj6Ajg+x59K6bRbGKLBjWP5ecCIHnrz6Y/l7V0Vrb71VchvU46Y9+MdfpUOpJkxopHOaH4eElu0Yjhj3LjBQY9D06H37fTnlv2atZn+B37Wfgm9h3Nb6hew6fOoOcxzkRZBx1BkTOMdO9eo6FY7djtAyY+YbCQwGexGeORkVyPx48Iv4q8H2ur20bS32hyi5TZ96SLOJFx6qu8gdtoHvVRlcbR+p9FYvgDxnb+O/A+ja9a7/ACNWsYL6LcMNtkjWRcj0O4VtVRIUUUUAFFFFABX5bfteeEvEXgf426ja6kZLiO6aWS3vQMLdRsSGDD+E4IKnqOCMMpx+pNcf8dPhLY/Gj4Y6toV1tSa4jL2tw6bvs9yo3RyAdSAwww/iVmGRmgD8sdN0+bUJ1WFZHkY5VQM7vbA6mvZPBHw7ijsnmvIlkknXeI+uF4HUdMAfzruPh7+zlNoWrTR3Fq9vcK5ErBgJOP73HzDHbjHSvXvDvwUW/wBMWTaMYBEecHdng5PQ+nH0PeoY0eU+BvBNxJcRlo9ix4AVQFwPZQAMDPsK9o8OeE4okUNGFVRnkYb6evTHPtxXc+GPg3b2e3zE24xnYpBx3x9Pp0rd1HwhHZWrNGvzAZHG7PoMetJ6DR50mi/ZmZnbyyyhdoUqGI6dOh6/h+VEulP5O7btZt2OcgY4HH4V0d5pTWzKdo27iMhhnkY6dCe351mzxKDjaoTg85wMdTk9KmwGfJ5i4X5V2nI6jH8umOD7da4H4y6QniD4da1CNu5baScZGQXtw0y/+QxXpl1aGRTtKsVGMg//AFqqmCOaIwyIsse4hlPVXQncD7gqQfqKpCZ5t+xh8SJvFnwjl0e6kaS88O3BtgW+9JbSfPC3pxlx9EFe111P7K/gC5+H3we+yx/ubrUWN9dJn/VNJgJGf9pI0jU+6muWrbkCiiikAUUUUAFFFFAHhH7YHwcj8XeHj4ksYFGraSgNwUXDXNuDg5x1KElfoWxnbzg/hF8fLjwzeR6fdzNNBOyqyP8Ae4Gc47HtnPQD3I/TuvlD9qH9lddS1abxJocYjulYzXltENqSrzukT0cZJI7gkHndjzsNbldzow83J8sj3P4ZfG601nTI8OskbKu5JBuVh3B9P0r1fRfiCt9b7lLbh1z1H+fw+or8xNC8Wa18MNbhmtpJFUOA8bNmOVScgg/hzX0N4G+OcN1bxs0jNIADJG5wy5HX+h9wazlTa2LVSMtGfV9/rkdy5kb5pOcZ/wD1d6pXl4u0/MWYdeDj3//XxXkWh/F+3vYY2jm3Y6g88Ht9e3QdK3o/Ho8tflYL94/Nz/n86i7NIxdrt2OrvAJFba3yqN2DnJ96yri5aGRlAZcfMNowSep9x+FZMvjpYtzbvl6q24kevHrWfeeNY4wrNIHyecHAHua0i2c1SSN9tTCx8MCw3dMEHsR7dv8AGsa2mmS7/dv5cyK2CF2hieOQM8/j0/Osm68Yx6hKy75G29H/AMMj86h0vxR9mvGlhbejR7cE5yOnPseK1S1Mmz7Q+GPhweFfAmkabuWR9PsobdpE+7IY41Usp9CQSPaugoooNCkFFFFAgooooAKKKKACiiigDH8VeB9H8Z2n2fVtNs9St/4UuoFk2H1U4O0+6kGvHfFf7Cuhak7zaVqF3pMjZIiY/aYc9wA21lHsWb/dHavdqKAPjjxp+x94i8P3DfYbCS+iVsbI3EM49MJJgMfZWPGBk15Z4k+CniSNW3aHdSRgFnEUfmKwxwAEJAGMnjAwRntX6HZGM1BdWUV9D5c0SSxtw0cihlYehB60XYrI/OG/8Iahp0zeY11as+Su+Jo2HPbcAD07E844qZLW4W2aSaG4dOSRsdmJ+gBP5V9++IfhH4X8SCSOS0FlNztuLQiNh7nGFYehBrx3xT+x7d2Lc2kbr03QyhkPqdk21hjtljx6UlMd0fJQsZbyRWjjboc71O32HHOM/StLTdJlK42yMVOflGcH07/5+le06n+zdf2J33VtuUZxujUOvuI3UuPcHGf4cGsPUPg/HZYZ9PvLbPXzIfPGPUSL+6/HK5q+cxdI2v2XfDUmufEizkk3C10+N7qZs4BIIWNQO/ztuPspr7Rqj4V8K2fgzw7Z6TYo0dpaJ5a7xhm5JLMe5JJJPua0BRYQtQooopgFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFR3FvHcxNHLGssbjDI6hlYeoI6iiigCrL4X06b79haP8A71sivt61Avhfw9b/APHhpVqnH/LHYmPyGKKKaXYTbJYYEgXbGiRr2VFCgfgBUlFFMQUUUUAFFFFABRRRQAUUUUAf/9k=
|
After Width: | Height: | Size: 2.5 MiB |
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="1200" height="600" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#050a18;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0d1529;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="600" fill="url(#grad1)"/>
|
||||
<text x="600" y="250" font-family="Arial" font-size="50" fill="#e2e8f0" text-anchor="middle">K3s Kubernetes</text>
|
||||
<text x="600" y="320" font-family="Arial" font-size="30" fill="#3b82f6" text-anchor="middle">Lightweight Kubernetes for Edge and IoT</text>
|
||||
<g transform="translate(550,150) scale(0.6)">
|
||||
<path d="M 50,50 L 150,50 L 150,150 L 50,150 Z" fill="none" stroke="#06b6d4" stroke-width="10"/>
|
||||
<path d="M 75,75 L 175,75 L 175,175 L 75,175 Z" fill="none" stroke="#3b82f6" stroke-width="10"/>
|
||||
<path d="M 100,100 L 200,100 L 200,200 L 100,200 Z" fill="none" stroke="#8b5cf6" stroke-width="10"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 2.4 MiB |
260
script.js
|
@ -1,260 +0,0 @@
|
|||
/**
|
||||
* Main JavaScript file for argobox.com
|
||||
* Handles animations, interactions, and dynamic content
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize all website functionality
|
||||
initNavigation();
|
||||
initParticlesAndIcons();
|
||||
initTerminalTyping();
|
||||
updateYear();
|
||||
});
|
||||
|
||||
/**
|
||||
* Set up navigation functionality - mobile menu and scroll spy
|
||||
*/
|
||||
function initNavigation() {
|
||||
const menuToggle = document.querySelector('.menu-toggle');
|
||||
const navMenu = document.querySelector('.nav-menu');
|
||||
const navLinks = document.querySelectorAll('.nav-link');
|
||||
|
||||
if (menuToggle && navMenu) {
|
||||
// Toggle mobile menu
|
||||
menuToggle.addEventListener('click', () => {
|
||||
toggleMobileMenu();
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (navMenu.classList.contains('show') &&
|
||||
!navMenu.contains(e.target) &&
|
||||
!menuToggle.contains(e.target)) {
|
||||
toggleMobileMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile menu when link is clicked
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 768 && navMenu.classList.contains('show')) {
|
||||
toggleMobileMenu();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle resize events
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth > 768 && navMenu.classList.contains('show')) {
|
||||
navMenu.classList.remove('show');
|
||||
updateMenuIcon(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle function for mobile menu
|
||||
function toggleMobileMenu() {
|
||||
navMenu.classList.toggle('show');
|
||||
updateMenuIcon(navMenu.classList.contains('show'));
|
||||
|
||||
// Prevent body scrolling when menu is open
|
||||
if (navMenu.classList.contains('show')) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Update menu icon based on menu state
|
||||
function updateMenuIcon(isOpen) {
|
||||
const icon = menuToggle.querySelector('i');
|
||||
if (isOpen) {
|
||||
icon.classList.remove('fa-bars');
|
||||
icon.classList.add('fa-times');
|
||||
} else {
|
||||
icon.classList.remove('fa-times');
|
||||
icon.classList.add('fa-bars');
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll spy for navigation
|
||||
window.addEventListener('scroll', () => {
|
||||
const sections = document.querySelectorAll('section');
|
||||
|
||||
let current = '';
|
||||
sections.forEach(section => {
|
||||
const sectionTop = section.offsetTop;
|
||||
if (window.scrollY >= sectionTop - 100) {
|
||||
current = section.getAttribute('id');
|
||||
}
|
||||
});
|
||||
|
||||
navLinks.forEach(link => {
|
||||
link.classList.remove('active');
|
||||
if (link.getAttribute('href').substring(1) === current) {
|
||||
link.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create background particles and floating tech icons
|
||||
*/
|
||||
function initParticlesAndIcons() {
|
||||
createBackgroundParticles();
|
||||
createFloatingIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize images for better mobile performance
|
||||
*/
|
||||
function optimizeImagesForMobile() {
|
||||
if (window.innerWidth <= 768) {
|
||||
// Reduce particles count on mobile for better performance
|
||||
createBackgroundParticles(15); // Fewer particles
|
||||
} else {
|
||||
createBackgroundParticles(50); // Normal number of particles
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create animated background particles
|
||||
*/
|
||||
function createBackgroundParticles(count = 50) {
|
||||
const particlesContainer = document.getElementById('particles-container');
|
||||
|
||||
if (!particlesContainer) return;
|
||||
|
||||
// Clear existing particles
|
||||
particlesContainer.innerHTML = '';
|
||||
|
||||
// Create particles
|
||||
for (let i = 0; i < count; i++) {
|
||||
const particle = document.createElement('div');
|
||||
particle.classList.add('particle');
|
||||
|
||||
// Random size between 2 and 6px
|
||||
const size = Math.random() * 4 + 2;
|
||||
particle.style.width = `${size}px`;
|
||||
particle.style.height = `${size}px`;
|
||||
|
||||
// Random position
|
||||
particle.style.left = `${Math.random() * 100}%`;
|
||||
particle.style.top = `${Math.random() * 100}%`;
|
||||
|
||||
// Random opacity between 0.1 and 0.3
|
||||
particle.style.opacity = (Math.random() * 0.2 + 0.1).toString();
|
||||
|
||||
// Animation properties
|
||||
particle.style.animation = `float-particle ${Math.random() * 20 + 10}s linear infinite`;
|
||||
particle.style.animationDelay = `${Math.random() * 10}s`;
|
||||
|
||||
particlesContainer.appendChild(particle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize terminal typing animation
|
||||
*/
|
||||
function initTerminalTyping() {
|
||||
const cursor = document.querySelector('.cursor');
|
||||
if (cursor) {
|
||||
setInterval(() => {
|
||||
cursor.style.opacity = cursor.style.opacity === '0' ? '1' : '0';
|
||||
}, 600);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update copyright year in the footer
|
||||
*/
|
||||
function updateYear() {
|
||||
const yearElement = document.getElementById('current-year');
|
||||
if (yearElement) {
|
||||
yearElement.textContent = new Date().getFullYear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and initialize floating icons in the hero section
|
||||
*/
|
||||
function createFloatingIcons() {
|
||||
// Implemented if needed for mobile
|
||||
}
|
||||
|
||||
// Initialize responsive behaviors
|
||||
window.addEventListener('resize', () => {
|
||||
optimizeImagesForMobile();
|
||||
});
|
||||
|
||||
// Call once on page load
|
||||
optimizeImagesForMobile();
|
||||
|
||||
// Contact Form Handler
|
||||
const contactForm = document.getElementById('contact-form');
|
||||
if (contactForm) {
|
||||
contactForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const submitButton = contactForm.querySelector('button[type="submit"]');
|
||||
const originalButtonText = submitButton.innerHTML;
|
||||
|
||||
try {
|
||||
submitButton.disabled = true;
|
||||
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Sending...';
|
||||
|
||||
const formData = new FormData(contactForm);
|
||||
const data = {
|
||||
name: formData.get('name'),
|
||||
email: formData.get('email'),
|
||||
subject: formData.get('subject'),
|
||||
message: formData.get('message')
|
||||
};
|
||||
|
||||
const response = await fetch('/contact', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Show success message
|
||||
const successMessage = document.createElement('div');
|
||||
successMessage.className = 'alert alert-success';
|
||||
successMessage.innerHTML = '<i class="fas fa-check-circle"></i> Message sent successfully! We\'ll get back to you soon.';
|
||||
contactForm.insertBefore(successMessage, submitButton);
|
||||
contactForm.reset();
|
||||
|
||||
// Remove success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
successMessage.remove();
|
||||
}, 5000);
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to send message');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
|
||||
// Show error message
|
||||
const errorMessage = document.createElement('div');
|
||||
errorMessage.className = 'alert alert-error';
|
||||
errorMessage.innerHTML = '<i class="fas fa-exclamation-circle"></i> Failed to send message. Please try again later.';
|
||||
contactForm.insertBefore(errorMessage, submitButton);
|
||||
|
||||
// Remove error message after 5 seconds
|
||||
setTimeout(() => {
|
||||
errorMessage.remove();
|
||||
}, 5000);
|
||||
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.innerHTML = originalButtonText;
|
||||
}
|
||||
});
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Initialize Blog Repository Setup
|
||||
echo "Initializing blog repository setup..."
|
||||
|
||||
# Set up Git configuration
|
||||
git config core.symlinks true
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p src/content public/blog scripts .git/hooks
|
||||
|
||||
# Create symbolic links for src/content
|
||||
echo "Creating symbolic links for src/content..."
|
||||
ln -sf /mnt/synology/obsidian/Public/Blog/posts src/content/posts
|
||||
ln -sf /mnt/synology/obsidian/Public/Blog/projects src/content/projects
|
||||
ln -sf /mnt/synology/obsidian/Public/Blog/configurations src/content/configurations
|
||||
ln -sf /mnt/synology/obsidian/Public/Blog/external-posts src/content/external-posts
|
||||
|
||||
# Create symbolic links for public/blog
|
||||
echo "Creating symbolic links for public/blog..."
|
||||
ln -sf /mnt/synology/obsidian/Public/Blog/configs public/blog/configs
|
||||
ln -sf /mnt/synology/obsidian/Public/Blog/images public/blog/images
|
||||
ln -sf /mnt/synology/obsidian/Public/Blog/infrastructure public/blog/infrastructure
|
||||
ln -sf /mnt/synology/obsidian/Public/Blog/posts public/blog/posts
|
||||
|
||||
# Create the pre-commit hook
|
||||
cat > .git/hooks/pre-commit << 'EOF'
|
||||
#!/bin/bash
|
||||
# Pre-commit hook to process symbolic links
|
||||
|
||||
echo "Running pre-commit hook for blog content..."
|
||||
|
||||
# Get the absolute path to the script
|
||||
SCRIPT_PATH="$(git rev-parse --show-toplevel)/scripts/process-content-links.sh"
|
||||
|
||||
# Check if the script exists and is executable
|
||||
if [ -x "$SCRIPT_PATH" ]; then
|
||||
bash "$SCRIPT_PATH"
|
||||
# Add any new or changed files resulting from the script
|
||||
git add -A
|
||||
else
|
||||
echo "Error: Content processing script not found or not executable at $SCRIPT_PATH"
|
||||
echo "Please ensure the script exists and has execute permissions"
|
||||
exit 1
|
||||
fi
|
||||
EOF
|
||||
|
||||
# Create the post-commit hook
|
||||
cat > .git/hooks/post-commit << 'EOF'
|
||||
#!/bin/bash
|
||||
# Post-commit hook to restore symbolic links
|
||||
|
||||
echo "Running post-commit hook to restore symbolic links..."
|
||||
|
||||
# Array of content directories and their targets
|
||||
declare -A SYMLINK_TARGETS=(
|
||||
["src/content/posts"]="/mnt/synology/obsidian/Public/Blog/posts"
|
||||
["src/content/projects"]="/mnt/synology/obsidian/Public/Blog/projects"
|
||||
["src/content/configurations"]="/mnt/synology/obsidian/Public/Blog/configurations"
|
||||
["src/content/external-posts"]="/mnt/synology/obsidian/Public/Blog/external-posts"
|
||||
["public/blog/configs"]="/mnt/synology/obsidian/Public/Blog/configs"
|
||||
["public/blog/images"]="/mnt/synology/obsidian/Public/Blog/images"
|
||||
["public/blog/infrastructure"]="/mnt/synology/obsidian/Public/Blog/infrastructure"
|
||||
["public/blog/posts"]="/mnt/synology/obsidian/Public/Blog/posts"
|
||||
)
|
||||
|
||||
for dir_path in "${!SYMLINK_TARGETS[@]}"; do
|
||||
target="${SYMLINK_TARGETS[$dir_path]}"
|
||||
if [ -d "$target" ]; then
|
||||
echo "Restoring symlink for $dir_path -> $target"
|
||||
rm -rf "$dir_path"
|
||||
mkdir -p "$(dirname "$dir_path")"
|
||||
ln -s "$target" "$dir_path"
|
||||
else
|
||||
echo "Warning: Target directory $target does not exist"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Symbolic links restored!"
|
||||
EOF
|
||||
|
||||
# Make hooks executable
|
||||
chmod +x .git/hooks/pre-commit .git/hooks/post-commit
|
||||
|
||||
# Create process-content-links.sh
|
||||
cat > scripts/process-content-links.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
# Script to handle symbolic links before commit
|
||||
echo "Processing symbolic links for content..."
|
||||
|
||||
# Array of content directories to process
|
||||
declare -A CONTENT_PATHS
|
||||
# src/content directories
|
||||
CONTENT_PATHS["posts"]="src/content/posts"
|
||||
CONTENT_PATHS["projects"]="src/content/projects"
|
||||
CONTENT_PATHS["configurations"]="src/content/configurations"
|
||||
CONTENT_PATHS["external-posts"]="src/content/external-posts"
|
||||
# public/blog directories
|
||||
CONTENT_PATHS["configs"]="public/blog/configs"
|
||||
CONTENT_PATHS["images"]="public/blog/images"
|
||||
CONTENT_PATHS["infrastructure"]="public/blog/infrastructure"
|
||||
CONTENT_PATHS["blog-posts"]="public/blog/posts"
|
||||
|
||||
for dir_name in "${!CONTENT_PATHS[@]}"; do
|
||||
dir_path="${CONTENT_PATHS[$dir_name]}"
|
||||
if [ -L "$dir_path" ]; then
|
||||
echo "Processing $dir_path..."
|
||||
target=$(readlink "$dir_path")
|
||||
rm "$dir_path"
|
||||
mkdir -p "$(dirname "$dir_path")"
|
||||
cp -r "$target" "$dir_path"
|
||||
git add "$dir_path"
|
||||
echo "Processed $dir_path -> $target"
|
||||
else
|
||||
echo "Skipping $dir_path (not a symbolic link)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Content processing complete!"
|
||||
EOF
|
||||
|
||||
# Make process-content-links.sh executable
|
||||
chmod +x scripts/process-content-links.sh
|
||||
|
||||
echo "Setup complete! The repository is now configured for automatic symbolic link handling."
|
|
@ -0,0 +1,58 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
BLOG_CONTENT_PATH="src/content"
|
||||
ASSETS_PATH="public/assets"
|
||||
BLOG_SOURCE="src/content/blog"
|
||||
|
||||
# Create necessary directories if they don't exist
|
||||
mkdir -p "$BLOG_CONTENT_PATH/posts"
|
||||
mkdir -p "$BLOG_CONTENT_PATH/configurations"
|
||||
mkdir -p "$BLOG_CONTENT_PATH/projects"
|
||||
mkdir -p "$ASSETS_PATH/images"
|
||||
|
||||
# Function to process markdown files
|
||||
process_markdown() {
|
||||
local file="$1"
|
||||
local filename=$(basename "$file")
|
||||
|
||||
# Convert Obsidian wiki-links to markdown links
|
||||
sed -i 's/\[\[/[/g; s/\]\]/]/g' "$file"
|
||||
|
||||
# Update image paths
|
||||
sed -i 's/\(!\[.*\](\)attachments\//\1\/assets\/images\//g' "$file"
|
||||
|
||||
echo "Processed $filename"
|
||||
}
|
||||
|
||||
# Function to categorize content
|
||||
categorize_content() {
|
||||
local file="$1"
|
||||
local filename=$(basename "$file")
|
||||
|
||||
# Read the frontmatter to determine the category
|
||||
if grep -q "category: configuration" "$file"; then
|
||||
cp "$file" "$BLOG_CONTENT_PATH/configurations/"
|
||||
process_markdown "$BLOG_CONTENT_PATH/configurations/$filename"
|
||||
elif grep -q "category: project" "$file"; then
|
||||
cp "$file" "$BLOG_CONTENT_PATH/projects/"
|
||||
process_markdown "$BLOG_CONTENT_PATH/projects/$filename"
|
||||
else
|
||||
cp "$file" "$BLOG_CONTENT_PATH/posts/"
|
||||
process_markdown "$BLOG_CONTENT_PATH/posts/$filename"
|
||||
fi
|
||||
}
|
||||
|
||||
# Process all markdown files in the blog directory
|
||||
for file in "$BLOG_SOURCE"/*.md; do
|
||||
if [ -f "$file" ]; then
|
||||
categorize_content "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
# Ensure images are in place
|
||||
if [ -d "$BLOG_SOURCE/images" ]; then
|
||||
cp -r "$BLOG_SOURCE/images/"* "$ASSETS_PATH/images/"
|
||||
fi
|
||||
|
||||
echo "Content processing complete"
|
63
server.js
|
@ -1,63 +0,0 @@
|
|||
const express = require('express');
|
||||
const nodemailer = require('nodemailer');
|
||||
const cors = require('cors');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Email transporter
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS
|
||||
}
|
||||
});
|
||||
|
||||
// Contact form endpoint
|
||||
app.post('/api/contact', async (req, res) => {
|
||||
try {
|
||||
const { name, email, subject, message } = req.body;
|
||||
|
||||
// Email options
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
to: 'daniel.laforce@argobox.com',
|
||||
subject: `Contact Form: ${subject}`,
|
||||
text: `
|
||||
Name: ${name}
|
||||
Email: ${email}
|
||||
Subject: ${subject}
|
||||
|
||||
Message:
|
||||
${message}
|
||||
`,
|
||||
html: `
|
||||
<h3>New Contact Form Submission</h3>
|
||||
<p><strong>Name:</strong> ${name}</p>
|
||||
<p><strong>Email:</strong> ${email}</p>
|
||||
<p><strong>Subject:</strong> ${subject}</p>
|
||||
<h4>Message:</h4>
|
||||
<p>${message}</p>
|
||||
`
|
||||
};
|
||||
|
||||
// Send email
|
||||
await transporter.sendMail(mailOptions);
|
||||
|
||||
res.status(200).json({ message: 'Message sent successfully!' });
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
res.status(500).json({ message: 'Failed to send message. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on port ${port}`);
|
||||
});
|
|
@ -0,0 +1,372 @@
|
|||
---
|
||||
// 404.astro - Custom 404 error page
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import Terminal from '../components/Terminal.astro';
|
||||
|
||||
// Terminal commands for the 404 page
|
||||
const terminalCommands = [
|
||||
{
|
||||
prompt: "[system@argobox]$ ",
|
||||
command: "find /var/www/ArgoBox -name \"requested-page\"",
|
||||
output: ["find: No matches found"]
|
||||
},
|
||||
{
|
||||
prompt: "[system@argobox]$ ",
|
||||
command: "grep -r \"requested-page\" /var/www/ArgoBox",
|
||||
output: ["No matches found in site content"]
|
||||
},
|
||||
{
|
||||
prompt: "[system@argobox]$ ",
|
||||
command: "echo $?",
|
||||
output: ["1"]
|
||||
},
|
||||
{
|
||||
prompt: "[system@argobox]$ ",
|
||||
command: "suggest_pages",
|
||||
output: [
|
||||
"Running site diagnostics...",
|
||||
"Suggested pages:",
|
||||
" - <a href='/'>Home</a>",
|
||||
" - <a href='/blog'>Blog</a>",
|
||||
" - <a href='/resources'>Resources</a>",
|
||||
" - <a href='/projects'>Projects</a>"
|
||||
]
|
||||
},
|
||||
{
|
||||
prompt: "[system@argobox]$ ",
|
||||
command: "return --code=404",
|
||||
}
|
||||
];
|
||||
|
||||
// Random tech error messages
|
||||
const errorMessages = [
|
||||
"Connection terminated unexpectedly.",
|
||||
"Resource allocation failed: Page not found.",
|
||||
"Route resolution error: Destination unreachable.",
|
||||
"404: The requested URL does not exist on this server.",
|
||||
"Network path not found: Check your request and try again."
|
||||
];
|
||||
|
||||
// Get a random error message
|
||||
const randomError = errorMessages[Math.floor(Math.random() * errorMessages.length)];
|
||||
---
|
||||
|
||||
<BaseLayout title="404 - Page Not Found | ArgoBox" description="The requested page was not found on the ArgoBox tech blog.">
|
||||
<Header slot="header" />
|
||||
|
||||
<main class="error-container">
|
||||
<div class="error-content">
|
||||
<div class="error-code">
|
||||
<span class="error-digit">4</span>
|
||||
<div class="error-digit-middle">
|
||||
<div class="error-spinner"></div>
|
||||
<span>0</span>
|
||||
</div>
|
||||
<span class="error-digit">4</span>
|
||||
</div>
|
||||
|
||||
<h1 class="error-title">Page Not Found</h1>
|
||||
<p class="error-message">{randomError}</p>
|
||||
|
||||
<div class="error-actions">
|
||||
<a href="/" class="primary-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
||||
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
||||
</svg>
|
||||
<span>Return Home</span>
|
||||
</a>
|
||||
<a href="/blog" class="secondary-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
<span>Browse Articles</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-terminal">
|
||||
<Terminal commands={terminalCommands} title="system-error-trace" />
|
||||
</div>
|
||||
|
||||
<div class="error-bg"></div>
|
||||
<div class="glitch-effect"></div>
|
||||
</main>
|
||||
|
||||
<Footer slot="footer" />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.error-container {
|
||||
min-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.error-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 35%, rgba(239, 68, 68, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 15%, rgba(6, 182, 212, 0.05) 0%, transparent 45%),
|
||||
radial-gradient(circle at 85% 70%, rgba(139, 92, 246, 0.05) 0%, transparent 40%);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.error-bg::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(rgba(226, 232, 240, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(226, 232, 240, 0.03) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.error-digit {
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-tertiary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: 0 5px 15px rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
|
||||
.error-digit-middle {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 130px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.error-spinner {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 4px solid rgba(59, 130, 246, 0.2);
|
||||
border-top: 4px solid var(--accent-secondary);
|
||||
border-radius: 50%;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 2rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
padding: 0.8rem 1.5rem;
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||
color: var(--bg-primary);
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 20px rgba(6, 182, 212, 0.4);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
padding: 0.8rem 1.5rem;
|
||||
background: rgba(226, 232, 240, 0.1);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.secondary-button:hover {
|
||||
background: rgba(226, 232, 240, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.error-terminal {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.glitch-effect {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(45deg,
|
||||
rgba(255, 0, 0, 0) 45%,
|
||||
rgba(255, 0, 0, 0.03) 50%,
|
||||
rgba(255, 0, 0, 0) 55%
|
||||
);
|
||||
background-size: 200% 200%;
|
||||
pointer-events: none;
|
||||
animation: glitch-scan 4s ease-in-out infinite;
|
||||
z-index: 2;
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
@keyframes glitch-scan {
|
||||
0%, 100% {
|
||||
background-position: 0% 0%;
|
||||
opacity: 0;
|
||||
}
|
||||
25%, 75% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 100%;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-code {
|
||||
font-size: 5rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.error-digit-middle {
|
||||
width: 70px;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Add some randomized glitch effects to the 404 page
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const errorContainer = document.querySelector('.error-container');
|
||||
const errorDigit = document.querySelectorAll('.error-digit');
|
||||
|
||||
// Random glitch effect for the error digits
|
||||
function glitchEffect() {
|
||||
const randomDigit = errorDigit[Math.floor(Math.random() * errorDigit.length)];
|
||||
|
||||
randomDigit.style.opacity = '0.8';
|
||||
randomDigit.style.transform = `translateX(${Math.random() * 5 - 2.5}px)`;
|
||||
|
||||
setTimeout(() => {
|
||||
randomDigit.style.opacity = '1';
|
||||
randomDigit.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Create data corruption effect in background
|
||||
function createCorruptionEffect() {
|
||||
const corruptionLine = document.createElement('div');
|
||||
corruptionLine.classList.add('corruption-line');
|
||||
|
||||
// Random positioning and styling
|
||||
const top = Math.random() * 100;
|
||||
const width = Math.random() * 100;
|
||||
const duration = Math.random() * 2 + 0.5;
|
||||
|
||||
corruptionLine.style.cssText = `
|
||||
position: absolute;
|
||||
top: ${top}%;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
width: ${width}%;
|
||||
background: rgba(6, 182, 212, 0.4);
|
||||
z-index: 0;
|
||||
transform: translateX(-100%);
|
||||
animation: slideRight ${duration}s forwards ease-out;
|
||||
`;
|
||||
|
||||
errorContainer.appendChild(corruptionLine);
|
||||
|
||||
// Remove after animation is complete
|
||||
setTimeout(() => {
|
||||
corruptionLine.remove();
|
||||
}, duration * 1000);
|
||||
}
|
||||
|
||||
// Set intervals for effects
|
||||
setInterval(glitchEffect, 3000);
|
||||
setInterval(createCorruptionEffect, 2000);
|
||||
|
||||
// Add keyframe animation dynamically
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = `
|
||||
@keyframes slideRight {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100vw); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,656 @@
|
|||
---
|
||||
// src/components/Footer.astro
|
||||
// High-quality footer with navigation, social links and additional elements
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// Define categories for footer links
|
||||
const categories = [
|
||||
{
|
||||
title: 'Technology',
|
||||
links: [
|
||||
{ name: 'Kubernetes', path: '/categories/kubernetes' },
|
||||
{ name: 'Docker', path: '/categories/docker' },
|
||||
{ name: 'DevOps', path: '/categories/devops' },
|
||||
{ name: 'Networking', path: '/categories/networking' },
|
||||
{ name: 'Storage', path: '/categories/storage' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Resources',
|
||||
links: [
|
||||
{ name: 'K8s Configurations', path: '/resources/kubernetes' },
|
||||
{ name: 'Docker Compose', path: '/resources/docker-compose' },
|
||||
{ name: 'Configuration Files', path: '/resources/config-files' },
|
||||
{ name: 'Infrastructure Code', path: '/resources/iac' },
|
||||
{ name: 'Tutorials', path: '/resources/tutorials' },
|
||||
{ name: 'All Resources', path: '/resources' } // Added Resources link
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Projects',
|
||||
links: [
|
||||
{ name: 'HomeLab Setup', path: '/homelab' },
|
||||
{ name: 'Tech Stack', path: '/tech-stack' }, // Updated Tech Stack link
|
||||
{ name: 'Github Repos', path: '/projects/github' },
|
||||
{ name: 'Live Services', path: '/projects/services' },
|
||||
{ name: 'Obsidian Templates', path: '/projects/obsidian' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Social links
|
||||
const socialLinks = [
|
||||
{
|
||||
name: 'GitHub',
|
||||
url: 'https://github.com/KeyArgo/',
|
||||
icon: '<path fill-rule="evenodd" clip-rule="evenodd" d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385c.6.105.825-.255.825-.57c0-.285-.015-1.23-.015-2.235c-3.015.555-3.795-.735-4.035-1.41c-.135-.345-.72-1.41-1.23-1.695c-.42-.225-1.02-.78-.015-.795c.945-.015 1.62.87 1.845 1.23c1.08 1.815 2.805 1.305 3.495.99c.105-.78.42-1.305.765-1.605c-2.67-.3-5.46-1.335-5.46-5.925c0-1.305.465-2.385 1.23-3.225c-.12-.3-.54-1.53.12-3.18c0 0 1.005-.315 3.3 1.23c.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23c.66 1.65.24 2.88.12 3.18c.765.84 1.23 1.905 1.23 3.225c0 4.605-2.805 5.625-5.475 5.925c.435.375.81 1.095.81 2.22c0 1.605-.015 2.895-.015 3.3c0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />'
|
||||
},
|
||||
{
|
||||
name: 'Twitter',
|
||||
url: 'https://www.x.com/danlaforce', // Updated Twitter URL
|
||||
icon: '<path d="M23.643 4.937c-.835.37-1.732.62-2.675.733a4.67 4.67 0 0 0 2.048-2.578 9.3 9.3 0 0 1-2.958 1.13 4.66 4.66 0 0 0-7.938 4.25 13.229 13.229 0 0 1-9.602-4.868c-.4.69-.63 1.49-.63 2.342A4.66 4.66 0 0 0 3.96 9.824a4.647 4.647 0 0 1-2.11-.583v.06a4.66 4.66 0 0 0 3.737 4.568 4.692 4.692 0 0 1-2.104.08 4.661 4.661 0 0 0 4.352 3.234 9.348 9.348 0 0 1-5.786 1.995 9.5 9.5 0 0 1-1.112-.065 13.175 13.175 0 0 0 7.14 2.093c8.57 0 13.255-7.098 13.255-13.254 0-.2-.005-.402-.014-.602a9.47 9.47 0 0 0 2.323-2.41l.002-.003z" />'
|
||||
},
|
||||
{
|
||||
name: 'LinkedIn',
|
||||
url: 'https://www.linkedin.com/in/danlaforce', // Updated LinkedIn URL
|
||||
icon: '<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z" />'
|
||||
},
|
||||
{
|
||||
name: 'RSS Feed',
|
||||
url: '/rss.xml',
|
||||
icon: '<path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16M6 19a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"></path>'
|
||||
}
|
||||
];
|
||||
|
||||
// Server status for homelab services
|
||||
const services = [
|
||||
{ name: 'ArgoBox', status: 'active' },
|
||||
{ name: 'Git Server', status: 'active' },
|
||||
{ name: 'Kubernetes', status: 'active' },
|
||||
{ name: 'Media Server', status: 'active' }
|
||||
];
|
||||
---
|
||||
|
||||
<footer class="site-footer">
|
||||
<!-- Network Lines Animation -->
|
||||
<div class="network-lines"></div>
|
||||
|
||||
<!-- Floating Gradient Elements -->
|
||||
<div class="footer-gradients">
|
||||
<div class="gradient-circle circle-1"></div>
|
||||
<div class="gradient-circle circle-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Main Footer Content -->
|
||||
<div class="footer-content">
|
||||
<!-- Brand Section -->
|
||||
<div class="footer-brand">
|
||||
<div class="footer-logo">
|
||||
<div class="logo-symbol">
|
||||
<span class="logo-text">LF</span>
|
||||
<div class="logo-glow"></div>
|
||||
</div>
|
||||
<span class="footer-brand-name">ArgoBox</span>
|
||||
</div>
|
||||
<p class="footer-tagline">
|
||||
Enterprise-grade home lab infrastructure, Kubernetes deployments, and DevOps automation for the modern tech enthusiast.
|
||||
</p>
|
||||
|
||||
<!-- Homelab Services Status -->
|
||||
<div class="service-status">
|
||||
<h4 class="status-title">Services Status</h4>
|
||||
<div class="status-grid">
|
||||
{services.map(service => (
|
||||
<div class="status-item">
|
||||
<span class={`status-indicator ${service.status}`}></span>
|
||||
<span class="status-name">{service.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social Links -->
|
||||
<div class="social-links">
|
||||
{socialLinks.map(social => (
|
||||
<a href={social.url} class="social-link" aria-label={social.name} target="_blank" rel="noopener">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
{/* Render SVG paths conditionally based on name */}
|
||||
{social.name === 'GitHub' && <path fill-rule="evenodd" clip-rule="evenodd" d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385c.6.105.825-.255.825-.57c0-.285-.015-1.23-.015-2.235c-3.015.555-3.795-.735-4.035-1.41c-.135-.345-.72-1.41-1.23-1.695c-.42-.225-1.02-.78-.015-.795c.945-.015 1.62.87 1.845 1.23c1.08 1.815 2.805 1.305 3.495.99c.105-.78.42-1.305.765-1.605c-2.67-.3-5.46-1.335-5.46-5.925c0-1.305.465-2.385 1.23-3.225c-.12-.3-.54-1.53.12-3.18c0 0 1.005-.315 3.3 1.23c.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23c.66 1.65.24 2.88.12 3.18c.765.84 1.23 1.905 1.23 3.225c0 4.605-2.805 5.625-5.475 5.925c.435.375.81 1.095.81 2.22c0 1.605-.015 2.895-.015 3.3c0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />}
|
||||
{social.name === 'Twitter' && <path d="M23.643 4.937c-.835.37-1.732.62-2.675.733a4.67 4.67 0 0 0 2.048-2.578 9.3 9.3 0 0 1-2.958 1.13 4.66 4.66 0 0 0-7.938 4.25 13.229 13.229 0 0 1-9.602-4.868c-.4.69-.63 1.49-.63 2.342A4.66 4.66 0 0 0 3.96 9.824a4.647 4.647 0 0 1-2.11-.583v.06a4.66 4.66 0 0 0 3.737 4.568 4.692 4.692 0 0 1-2.104.08 4.661 4.661 0 0 0 4.352 3.234 9.348 9.348 0 0 1-5.786 1.995 9.5 9.5 0 0 1-1.112-.065 13.175 13.175 0 0 0 7.14 2.093c8.57 0 13.255-7.098 13.255-13.254 0-.2-.005-.402-.014-.602a9.47 9.47 0 0 0 2.323-2.41l.002-.003z" />}
|
||||
{social.name === 'LinkedIn' && <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z" />}
|
||||
{social.name === 'RSS Feed' && <path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16M6 19a1 1 0 1 1 0-2 1 1 0 0 1 0 2z" />}
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Links -->
|
||||
<div class="footer-links-container">
|
||||
{categories.map(category => (
|
||||
<div class="footer-col">
|
||||
<h4 class="footer-col-title">{category.title}</h4>
|
||||
<ul class="footer-links">
|
||||
{category.links.map(link => (
|
||||
<li>
|
||||
<a
|
||||
href={link.url || link.path}
|
||||
class="footer-link"
|
||||
target={link.url ? "_blank" : undefined}
|
||||
rel={link.url ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<!-- Newsletter Signup -->
|
||||
<div class="footer-col">
|
||||
<h4 class="footer-col-title">Newsletter</h4>
|
||||
<p class="footer-newsletter-text">
|
||||
Subscribe to get updates on new articles, resources, and projects.
|
||||
</p>
|
||||
<form class="newsletter-form">
|
||||
<div class="newsletter-input-group">
|
||||
<input type="email" placeholder="Enter your email" class="newsletter-input" required />
|
||||
<button type="submit" class="newsletter-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Bottom -->
|
||||
<div class="footer-bottom">
|
||||
<div class="copyright">
|
||||
© {currentYear} ArgoBox by Daniel LaForce. All rights reserved.
|
||||
</div>
|
||||
<div class="footer-meta-links">
|
||||
<a href="/privacy" class="meta-link">Privacy Policy</a>
|
||||
<span class="link-divider">|</span>
|
||||
<a href="/terms" class="meta-link">Terms of Use</a>
|
||||
<span class="link-divider">|</span>
|
||||
<a href="/contact" class="meta-link">Contact</a>
|
||||
<span class="link-divider">|</span>
|
||||
<a href="/sitemap.xml" class="meta-link">Sitemap</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.site-footer {
|
||||
background: linear-gradient(0deg, var(--bg-secondary), var(--bg-primary));
|
||||
padding: 5rem 0 2rem;
|
||||
position: relative;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Network Lines Animation */
|
||||
.network-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.network-lines::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent 0%, var(--accent-primary) 50%, transparent 100%);
|
||||
animation: network-scan 8s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes network-scan {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Floating Gradient Elements */
|
||||
.footer-gradients {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gradient-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(70px);
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.circle-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: var(--accent-primary);
|
||||
top: -100px;
|
||||
left: -150px;
|
||||
}
|
||||
|
||||
.circle-2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: var(--accent-tertiary);
|
||||
bottom: -50px;
|
||||
right: -100px;
|
||||
}
|
||||
|
||||
/* Main Footer Content */
|
||||
.footer-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 3rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Brand Section */
|
||||
.footer-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.logo-symbol {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
color: var(--bg-primary);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.logo-glow {
|
||||
position: absolute;
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
background: radial-gradient(circle, var(--accent-primary) 0%, transparent 70%);
|
||||
opacity: 0.5;
|
||||
filter: blur(15px);
|
||||
z-index: 1;
|
||||
animation: pulse 4s infinite alternate ease-in-out;
|
||||
}
|
||||
|
||||
.footer-brand-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.3rem;
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.footer-tagline {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
/* Service Status */
|
||||
.service-status {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
border: 1px solid var(--border-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-indicator.active {
|
||||
background: #10b981; /* Green */
|
||||
}
|
||||
|
||||
.status-indicator.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
opacity: 0.5;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.warning {
|
||||
background: #f59e0b; /* Amber */
|
||||
}
|
||||
|
||||
.status-indicator.offline {
|
||||
background: #ef4444; /* Red */
|
||||
}
|
||||
|
||||
.status-name {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Social Links */
|
||||
.social-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.social-link {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(226, 232, 240, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 5px 15px var(--glow-primary);
|
||||
}
|
||||
|
||||
/* Footer Links */
|
||||
.footer-links-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.footer-col-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.footer-col-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -0.5rem;
|
||||
width: 30px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-links li {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: var(--accent-primary);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.footer-link::before {
|
||||
content: '→';
|
||||
position: absolute;
|
||||
left: -18px;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.footer-link:hover::before {
|
||||
opacity: 1;
|
||||
left: -15px;
|
||||
}
|
||||
|
||||
/* Newsletter */
|
||||
.footer-newsletter-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.newsletter-input-group {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.newsletter-input {
|
||||
width: 100%;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid var(--border-secondary);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.newsletter-input:focus {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 2px var(--glow-primary);
|
||||
}
|
||||
|
||||
.newsletter-button {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
color: var(--bg-primary);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.newsletter-button:hover {
|
||||
background: var(--accent-secondary);
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
}
|
||||
|
||||
/* Footer Bottom */
|
||||
.footer-bottom {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.footer-meta-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.meta-link {
|
||||
color: var(--text-tertiary);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.meta-link:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.link-divider {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 1024px) {
|
||||
.footer-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
.footer-links-container {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.footer-tagline {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.site-footer {
|
||||
padding: 3rem 0 2rem;
|
||||
}
|
||||
|
||||
.footer-links-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Create animated nodes in footer
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const footer = document.querySelector('.site-footer');
|
||||
|
||||
if (footer) {
|
||||
// Add network node animations
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'footer-node';
|
||||
node.style.cssText = `
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
background: rgba(226, 232, 240, 0.2);
|
||||
border-radius: 50%;
|
||||
left: ${Math.random() * 100}%;
|
||||
top: ${Math.random() * 100}%;
|
||||
animation: pulse ${4 + Math.random() * 4}s infinite alternate ease-in-out;
|
||||
animation-delay: ${Math.random() * 5}s;
|
||||
`;
|
||||
footer.appendChild(node);
|
||||
}
|
||||
|
||||
// Newsletter Form Submission
|
||||
const newsletterForm = document.querySelector('.newsletter-form');
|
||||
if (newsletterForm) {
|
||||
newsletterForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const emailInput = newsletterForm.querySelector('input[type="email"]');
|
||||
if (emailInput && emailInput.value) {
|
||||
alert(`Thank you for subscribing with ${emailInput.value}! We'll send you updates soon.`);
|
||||
emailInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,536 @@
|
|||
---
|
||||
// src/components/Header.astro
|
||||
import ThemeToggler from './ThemeToggler.astro';
|
||||
|
||||
// Define navigation items with proper URLs
|
||||
const navItems = [
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Blog', url: '/blog' },
|
||||
{ name: 'Projects', url: '/projects' },
|
||||
{ name: 'Tech Stack', url: '/tech-stack' },
|
||||
{ name: 'Home Lab', url: '/homelab' }, // Updated URL
|
||||
{ name: 'Resources', url: '/resources' },
|
||||
{ name: 'About', url: 'https://ArgoBox.com' },
|
||||
{ name: 'Contact', url: '/contact' }
|
||||
];
|
||||
|
||||
// Get current URL path for active nav item highlighting
|
||||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<header class="site-header">
|
||||
<div class="container header-container">
|
||||
<div class="logo-container">
|
||||
<a href="/" class="logo-link">
|
||||
<div class="logo">LF</div>
|
||||
<div class="site-name">
|
||||
<span class="site-title">ArgoBox</span>
|
||||
<span class="site-subtitle">Infrastructure & Automation</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav class="main-nav">
|
||||
<ul class="nav-list">
|
||||
{navItems.map(item => (
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href={item.url}
|
||||
class={`nav-link ${currentPath === item.url ||
|
||||
(currentPath.startsWith(item.url) && item.url !== '/') ? 'active' : ''}`}
|
||||
target={item.url.startsWith('http') ? '_blank' : undefined}
|
||||
rel={item.url.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<div class="search-container">
|
||||
<button class="search-toggle" aria-label="Toggle search">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="search-dropdown">
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
type="search"
|
||||
id="header-search"
|
||||
placeholder="Search blog posts..."
|
||||
class="search-input"
|
||||
/>
|
||||
<button class="search-submit">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-results" id="search-results">
|
||||
<!-- Results will be populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeToggler />
|
||||
</div>
|
||||
|
||||
<button class="mobile-menu-toggle" aria-label="Toggle menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 0.5rem 0;
|
||||
background: rgba(var(--bg-primary-rgb), 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--container-padding);
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
color: var(--bg-primary);
|
||||
font-weight: bold;
|
||||
border-radius: 8px;
|
||||
margin-right: 0.75rem;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.site-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.site-subtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--accent-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
height: 2px;
|
||||
background: var(--accent-primary);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
/* Search Dropdown Styles */
|
||||
.search-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.search-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
width: 300px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
padding: 0.75rem;
|
||||
z-index: 10;
|
||||
transform-origin: top right;
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s cubic-bezier(0.5, 0, 0, 1.25);
|
||||
}
|
||||
|
||||
.search-dropdown.active {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 2.5rem 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px var(--glow-primary);
|
||||
}
|
||||
|
||||
.search-submit {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.search-submit:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background: rgba(var(--bg-tertiary-rgb), 0.5);
|
||||
}
|
||||
|
||||
.search-result-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.search-result-snippet {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Mobile Menu Toggle */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 1024px) {
|
||||
.nav-link {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-nav {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-secondary);
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.main-nav.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.nav-link.active::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-dropdown {
|
||||
width: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Mobile menu toggle
|
||||
const mobileMenuToggle = document.querySelector('.mobile-menu-toggle');
|
||||
const mainNav = document.querySelector('.main-nav');
|
||||
|
||||
mobileMenuToggle?.addEventListener('click', () => {
|
||||
mainNav?.classList.toggle('active');
|
||||
});
|
||||
|
||||
// Search dropdown toggle
|
||||
const searchToggle = document.querySelector('.search-toggle');
|
||||
const searchDropdown = document.querySelector('.search-dropdown');
|
||||
const searchInput = document.querySelector('#header-search');
|
||||
|
||||
searchToggle?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
searchDropdown?.classList.toggle('active');
|
||||
|
||||
if (searchDropdown?.classList.contains('active')) {
|
||||
// Focus the search input when dropdown is shown
|
||||
setTimeout(() => {
|
||||
searchInput?.focus();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Close search dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.search-container')) {
|
||||
searchDropdown?.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Search functionality - client-side site-wide filtering (User provided version)
|
||||
const searchResults = document.getElementById('search-results'); // Assuming this ID exists in your dropdown HTML
|
||||
|
||||
// Function to perform search
|
||||
const performSearch = async (query) => {
|
||||
if (!query || query.length < 2) {
|
||||
// Clear results if query is too short
|
||||
if (searchResults) {
|
||||
searchResults.innerHTML = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the search index that contains all site content
|
||||
const response = await fetch('/search-index.json'); // Ensure this path is correct based on your build output
|
||||
if (!response.ok) throw new Error('Failed to fetch search data');
|
||||
|
||||
const allContent = await response.json();
|
||||
const results = allContent.filter(item => {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(lowerQuery) ||
|
||||
item.description?.toLowerCase().includes(lowerQuery) ||
|
||||
item.tags?.some(tag => tag.toLowerCase().includes(lowerQuery)) ||
|
||||
item.category?.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}).slice(0, 8); // Limit to 8 results for better UI
|
||||
|
||||
// Display results
|
||||
if (searchResults) {
|
||||
if (results.length > 0) {
|
||||
searchResults.innerHTML = results.map(item => {
|
||||
// Create type badge
|
||||
let typeBadge = '';
|
||||
switch(item.type) {
|
||||
case 'post':
|
||||
typeBadge = '<span class="result-type post">Blog</span>';
|
||||
break;
|
||||
case 'project':
|
||||
typeBadge = '<span class="result-type project">Project</span>';
|
||||
break;
|
||||
case 'configuration':
|
||||
typeBadge = '<span class="result-type config">Config</span>';
|
||||
break;
|
||||
case 'external':
|
||||
typeBadge = '<span class="result-type external">External</span>';
|
||||
break;
|
||||
default:
|
||||
typeBadge = '<span class="result-type">Content</span>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="search-result-item" data-url="${item.url}">
|
||||
<div class="search-result-header">
|
||||
<div class="search-result-title">${item.title}</div>
|
||||
${typeBadge}
|
||||
</div>
|
||||
<div class="search-result-snippet">${item.description || ''}</div>
|
||||
${item.tags && item.tags.length > 0 ?
|
||||
`<div class="search-result-tags">
|
||||
${item.tags.slice(0, 3).map(tag => `<span class="search-tag">${tag}</span>`).join('')}
|
||||
</div>` : ''
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add click handlers to results
|
||||
document.querySelectorAll('.search-result-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
window.location.href = item.dataset.url; // Navigate to the item's URL
|
||||
});
|
||||
});
|
||||
} else {
|
||||
searchResults.innerHTML = '<div class="no-results">No matching content found</div>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
if (searchResults) {
|
||||
searchResults.innerHTML = '<div class="no-results">Error performing search</div>';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Search input event handler with debounce
|
||||
let searchTimeout;
|
||||
searchInput?.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
performSearch(e.target.value);
|
||||
}, 300); // Debounce to avoid too many searches while typing
|
||||
});
|
||||
|
||||
// Handle search form submission (if your input is inside a form)
|
||||
const searchForm = searchInput?.closest('form');
|
||||
searchForm?.addEventListener('submit', (e) => {
|
||||
e.preventDefault(); // Prevent default form submission
|
||||
performSearch(searchInput.value);
|
||||
});
|
||||
|
||||
// Handle search-submit button click (if you have a separate submit button)
|
||||
const searchSubmit = document.querySelector('.search-submit'); // Adjust selector if needed
|
||||
searchSubmit?.addEventListener('click', () => {
|
||||
performSearch(searchInput?.value || '');
|
||||
});
|
||||
|
||||
}); // End of DOMContentLoaded
|
||||
</script>
|
|
@ -0,0 +1,361 @@
|
|||
---
|
||||
// MiniKnowledgeGraph.astro - Inline version that replaces the Tags section
|
||||
// Designed to work within the existing sidebar structure
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'post' | 'tag' | 'category';
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
type: 'post-tag' | 'post-post';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
currentPost?: any; // Made optional
|
||||
relatedPosts?: any[];
|
||||
graphData?: { nodes: GraphNode[], edges: GraphEdge[] }; // New optional prop for pre-defined graph data
|
||||
height?: string; // Optional height prop for customizing the graph height
|
||||
}
|
||||
|
||||
const { currentPost, relatedPosts = [], graphData: initialGraphData, height = "200px" } = Astro.props;
|
||||
|
||||
// Generate unique ID for the graph container
|
||||
const graphId = `mini-cy-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Prepare graph data
|
||||
let nodes: GraphNode[] = [];
|
||||
let edges: GraphEdge[] = [];
|
||||
const addedTagIds = new Set<string>();
|
||||
const addedPostIds = new Set<string>();
|
||||
|
||||
// Check if we have pre-defined graph data
|
||||
if (initialGraphData && initialGraphData.nodes && initialGraphData.edges) {
|
||||
// Use the provided graph data
|
||||
nodes = initialGraphData.nodes;
|
||||
edges = initialGraphData.edges;
|
||||
} else if (currentPost) {
|
||||
// Generate graph data from currentPost and relatedPosts
|
||||
// Ensure currentPost has necessary properties
|
||||
const safeCurrentPost = {
|
||||
id: currentPost.slug || 'current-post',
|
||||
title: currentPost.data?.title || 'Current Post',
|
||||
tags: currentPost.data?.tags || [],
|
||||
category: currentPost.data?.category || 'Uncategorized',
|
||||
};
|
||||
|
||||
// Add current post node
|
||||
nodes.push({
|
||||
id: safeCurrentPost.id,
|
||||
label: safeCurrentPost.title,
|
||||
type: 'post',
|
||||
url: `/posts/${safeCurrentPost.id}/`
|
||||
});
|
||||
addedPostIds.add(safeCurrentPost.id);
|
||||
|
||||
// Add tags from current post
|
||||
safeCurrentPost.tags.forEach((tag: string) => {
|
||||
const tagId = `tag-${tag}`;
|
||||
|
||||
// Only add if not already added
|
||||
if (!addedTagIds.has(tagId)) {
|
||||
nodes.push({
|
||||
id: tagId,
|
||||
label: tag,
|
||||
type: 'tag',
|
||||
url: `/tag/${tag}/`
|
||||
});
|
||||
addedTagIds.add(tagId);
|
||||
}
|
||||
|
||||
// Add edge from current post to tag
|
||||
edges.push({
|
||||
source: safeCurrentPost.id,
|
||||
target: tagId,
|
||||
type: 'post-tag'
|
||||
});
|
||||
});
|
||||
|
||||
// Add related posts and their connections
|
||||
if (relatedPosts && relatedPosts.length > 0) {
|
||||
relatedPosts.forEach(post => {
|
||||
if (!post) return;
|
||||
|
||||
const postId = post.slug || `post-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Skip if already added or is the current post
|
||||
if (addedPostIds.has(postId) || postId === safeCurrentPost.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add related post node
|
||||
nodes.push({
|
||||
id: postId,
|
||||
label: post.data?.title || 'Related Post',
|
||||
type: 'post',
|
||||
url: `/posts/${postId}/`
|
||||
});
|
||||
addedPostIds.add(postId);
|
||||
|
||||
// Add edge from current post to related post
|
||||
edges.push({
|
||||
source: safeCurrentPost.id,
|
||||
target: postId,
|
||||
type: 'post-post'
|
||||
});
|
||||
|
||||
// Add shared tags and their connections
|
||||
const postTags = post.data?.tags || [];
|
||||
postTags.forEach((tag: string) => {
|
||||
// Only add connections for tags that the current post also has
|
||||
if (safeCurrentPost.tags.includes(tag)) {
|
||||
const tagId = `tag-${tag}`;
|
||||
|
||||
// Add edge from related post to shared tag
|
||||
edges.push({
|
||||
source: postId,
|
||||
target: tagId,
|
||||
type: 'post-tag'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate final graph data
|
||||
const graphData = { nodes, edges };
|
||||
|
||||
// Determine if we have a valid central node (first node)
|
||||
const hasCentralNode = nodes.length > 0;
|
||||
---
|
||||
|
||||
<div class="sidebar-card knowledge-graph-card">
|
||||
<h3 class="sidebar-title">Post Connections</h3>
|
||||
<div class="mini-knowledge-graph">
|
||||
<div id={graphId} class="mini-cy" style={`height: ${height};`}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.knowledge-graph-card {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mini-knowledge-graph {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto; /* Height will be set by the inline style */
|
||||
}
|
||||
|
||||
.mini-cy {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--card-border, rgba(56, 189, 248, 0.2));
|
||||
background: rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script define:vars={{ graphId, graphData, hasCentralNode }}>
|
||||
// Initialize the miniature knowledge graph
|
||||
function initializeMiniGraph() {
|
||||
// Ensure Cytoscape is available
|
||||
if (typeof cytoscape === 'undefined') {
|
||||
console.error('[MiniKnowledgeGraph] Cytoscape library not loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the container
|
||||
const container = document.getElementById(graphId);
|
||||
if (!container) {
|
||||
console.error(`[MiniKnowledgeGraph] Container #${graphId} not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if we have any nodes to display
|
||||
if (!graphData.nodes || graphData.nodes.length === 0) {
|
||||
console.warn('[MiniKnowledgeGraph] No nodes to display.');
|
||||
container.innerHTML = '<div style="display:flex;height:100%;align-items:center;justify-content:center;color:var(--text-secondary);">No connections available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the style array with base styles
|
||||
const styleArray = [
|
||||
// Node styling
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
'background-color': '#3B82F6', // Default blue for posts
|
||||
'label': 'data(label)',
|
||||
'width': 15,
|
||||
'height': 15,
|
||||
'font-size': '8px',
|
||||
'color': '#E2E8F0',
|
||||
'text-valign': 'bottom',
|
||||
'text-halign': 'center',
|
||||
'text-margin-y': 4,
|
||||
'text-wrap': 'ellipsis',
|
||||
'text-max-width': '60px',
|
||||
'border-width': 1,
|
||||
'border-color': '#0F1219',
|
||||
'border-opacity': 0.8
|
||||
}
|
||||
},
|
||||
// Post node specific styles
|
||||
{
|
||||
selector: 'node[type="post"]',
|
||||
style: {
|
||||
'background-color': '#3B82F6', // Blue for posts
|
||||
'shape': 'ellipse',
|
||||
'width': 18,
|
||||
'height': 18
|
||||
}
|
||||
},
|
||||
// Tag node specific styles
|
||||
{
|
||||
selector: 'node[type="tag"]',
|
||||
style: {
|
||||
'background-color': '#10B981', // Green for tags
|
||||
'shape': 'diamond',
|
||||
'width': 15,
|
||||
'height': 15
|
||||
}
|
||||
},
|
||||
// Edge styles
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'width': 1,
|
||||
'line-color': 'rgba(226, 232, 240, 0.4)',
|
||||
'curve-style': 'bezier',
|
||||
'opacity': 0.6
|
||||
}
|
||||
},
|
||||
// Post-tag edge specific styles
|
||||
{
|
||||
selector: 'edge[type="post-tag"]',
|
||||
style: {
|
||||
'line-color': 'rgba(16, 185, 129, 0.6)', // Green
|
||||
'line-style': 'dashed'
|
||||
}
|
||||
},
|
||||
// Post-post edge specific styles
|
||||
{
|
||||
selector: 'edge[type="post-post"]',
|
||||
style: {
|
||||
'line-color': 'rgba(59, 130, 246, 0.6)', // Blue
|
||||
'line-style': 'solid',
|
||||
'width': 1.5
|
||||
}
|
||||
},
|
||||
// Hover styles
|
||||
{
|
||||
selector: 'node:hover',
|
||||
style: {
|
||||
'background-color': '#F59E0B', // Amber on hover
|
||||
'border-color': '#FFFFFF',
|
||||
'border-width': 2,
|
||||
'cursor': 'pointer'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Only add the central node styling if we have a valid central node
|
||||
// (This addresses the optional refinement in the requirements)
|
||||
if (hasCentralNode) {
|
||||
styleArray.push({
|
||||
selector: `#${graphData.nodes[0]?.id}`,
|
||||
style: {
|
||||
'background-color': '#06B6D4', // Cyan for current post
|
||||
'width': 25,
|
||||
'height': 25,
|
||||
'border-width': 2,
|
||||
'border-color': '#E2E8F0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Cytoscape with improved layout parameters for small space
|
||||
const cy = cytoscape({
|
||||
container,
|
||||
elements: [
|
||||
...graphData.nodes.map(node => ({
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
url: node.url
|
||||
}
|
||||
})),
|
||||
...graphData.edges.map((edge, index) => ({
|
||||
data: {
|
||||
id: `e${index}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edge.type
|
||||
}
|
||||
}))
|
||||
],
|
||||
style: styleArray,
|
||||
// Use a compact layout for sidebar
|
||||
layout: {
|
||||
name: 'cose',
|
||||
animate: false,
|
||||
fit: true,
|
||||
padding: 5,
|
||||
nodeRepulsion: function(node) {
|
||||
return 10000; // Stronger repulsion to prevent overlap in small space
|
||||
},
|
||||
idealEdgeLength: 50,
|
||||
edgeElasticity: 0.45,
|
||||
nestingFactor: 0.1,
|
||||
gravity: 0.25,
|
||||
numIter: 1500,
|
||||
initialTemp: 1000,
|
||||
coolingFactor: 0.99,
|
||||
minTemp: 1.0
|
||||
}
|
||||
});
|
||||
|
||||
// Add click event for nodes
|
||||
cy.on('tap', 'node', function(evt) {
|
||||
const node = evt.target;
|
||||
const url = node.data('url');
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
});
|
||||
|
||||
// Center the graph
|
||||
cy.fit(undefined, 10);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[MiniKnowledgeGraph] Error initializing Cytoscape:', error);
|
||||
container.innerHTML = '<div style="padding:10px;color:var(--text-secondary);">Error loading graph</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for DOM to be ready and ensure proper initialization
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Delay initialization slightly to ensure container has dimensions
|
||||
setTimeout(initializeMiniGraph, 100);
|
||||
});
|
||||
|
||||
// Also handle the case where the script loads after DOMContentLoaded
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
setTimeout(initializeMiniGraph, 100);
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
date: string;
|
||||
category: string;
|
||||
image: string;
|
||||
url: string;
|
||||
readTime?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
excerpt,
|
||||
date,
|
||||
category,
|
||||
image,
|
||||
url,
|
||||
readTime = "5 min read"
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<article class="post-card">
|
||||
<a href={url} aria-label={`Read more about ${title}`}>
|
||||
<img src={image} alt="" class="post-image" width="720" height="360" loading="lazy" /> {/* Added alt="", loading */}
|
||||
</a>
|
||||
<div class="post-content">
|
||||
<div class="post-meta">
|
||||
<span class="post-date">{date}</span>
|
||||
{category && <span class="post-category">{category}</span>}
|
||||
</div>
|
||||
<h3 class="post-title">
|
||||
<a href={url}>{title}</a>
|
||||
</h3>
|
||||
<p class="post-excerpt">
|
||||
{excerpt}
|
||||
</p>
|
||||
<div class="post-footer">
|
||||
<span class="post-read-time">{readTime}</span>
|
||||
<a href={url} class="read-more">Read More</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
/* Styles are in global.css */
|
||||
.post-card a:has(img) { /* Ensure image link covers image */
|
||||
display: block;
|
||||
}
|
||||
.post-image {
|
||||
background-color: var(--bg-secondary); /* Add bg color for loading */
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,395 @@
|
|||
---
|
||||
// Terminal.astro
|
||||
// A component that displays terminal-like interface with animated commands and outputs
|
||||
|
||||
export interface Props {
|
||||
title?: string;
|
||||
height?: string;
|
||||
showTitleBar?: boolean;
|
||||
showPrompt?: boolean;
|
||||
commands?: {
|
||||
prompt: string;
|
||||
command: string;
|
||||
output?: string[];
|
||||
delay?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
const {
|
||||
title = "terminal",
|
||||
height = "auto",
|
||||
showTitleBar = true,
|
||||
showPrompt = true,
|
||||
commands = []
|
||||
} = Astro.props;
|
||||
|
||||
// Make the last command have the typing effect
|
||||
const lastIndex = commands.length - 1;
|
||||
---
|
||||
|
||||
<div class="terminal-box">
|
||||
{showTitleBar && (
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-dots">
|
||||
<div class="terminal-dot terminal-dot-red"></div>
|
||||
<div class="terminal-dot terminal-dot-yellow"></div>
|
||||
<div class="terminal-dot terminal-dot-green"></div>
|
||||
</div>
|
||||
<div class="terminal-title">{title}</div>
|
||||
<div class="terminal-actions">
|
||||
<button class="terminal-button terminal-button-minimize" aria-label="Minimize">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="terminal-button terminal-button-maximize" aria-label="Maximize">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<polyline points="9 21 3 21 3 15"></polyline>
|
||||
<line x1="21" y1="3" x2="14" y2="10"></line>
|
||||
<line x1="3" y1="21" x2="10" y2="14"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="terminal-content" style={`height: ${height};`}>
|
||||
{commands.map((cmd, index) => (
|
||||
<div class="terminal-block">
|
||||
<div class="terminal-line">
|
||||
<span class="terminal-prompt">{cmd.prompt}</span>
|
||||
<span class={index === lastIndex ? "terminal-command terminal-typing" : "terminal-command"} data-delay={cmd.delay || 50}>
|
||||
{cmd.command}
|
||||
</span>
|
||||
</div>
|
||||
{cmd.output && cmd.output.length > 0 && (
|
||||
<div class="terminal-output">
|
||||
{cmd.output.map((line) => (
|
||||
<div class="terminal-output-line" set:html={line} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div class="terminal-cursor"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.terminal-box {
|
||||
width: 100%;
|
||||
height: 340px;
|
||||
background: var(--bg-secondary, #0d1529);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-primary, rgba(255, 255, 255, 0.1));
|
||||
box-shadow: 0 0 30px rgba(6, 182, 212, 0.1);
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-secondary, rgba(255, 255, 255, 0.05));
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.terminal-dots {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.terminal-dot-red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.terminal-dot-yellow {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.terminal-dot-green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
color: var(--text-secondary, #a0aec0);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.terminal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary, #a0aec0);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.terminal-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.terminal-content {
|
||||
flex: 1;
|
||||
color: var(--text-secondary, #a0aec0);
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 1.5rem 1.5rem;
|
||||
opacity: 0.9;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.terminal-block {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.terminal-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.terminal-prompt {
|
||||
color: var(--accent-primary, #06b6d4);
|
||||
margin-right: 0.5rem;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-command {
|
||||
color: var(--text-primary, #e2e8f0);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 1.25rem;
|
||||
color: var(--text-secondary, #a0aec0);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.terminal-output-line {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Highlight syntax in output */
|
||||
.terminal-output :global(.highlight) {
|
||||
color: var(--accent-primary, #06b6d4);
|
||||
}
|
||||
|
||||
.terminal-output :global(.success) {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.terminal-output :global(.warning) {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.terminal-output :global(.error) {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Blinking cursor */
|
||||
.terminal-cursor {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
background: var(--accent-primary, #06b6d4);
|
||||
animation: blink 1s infinite;
|
||||
bottom: 2rem;
|
||||
left: calc(1.5rem + 200px); /* Adjustable using JS */
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Typing effect */
|
||||
.terminal-typing {
|
||||
position: relative;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.terminal-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.terminal-content::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.terminal-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(226, 232, 240, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.terminal-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(226, 232, 240, 0.3);
|
||||
}
|
||||
|
||||
/* Fixed dot hovered appearance */
|
||||
.terminal-box:hover .terminal-dot-red {
|
||||
background: #f87171;
|
||||
}
|
||||
|
||||
.terminal-box:hover .terminal-dot-yellow {
|
||||
background: #fbbf24;
|
||||
}
|
||||
|
||||
.terminal-box:hover .terminal-dot-green {
|
||||
background: #34d399;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Terminal typing effect
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Typing effect for commands
|
||||
const typingElements = document.querySelectorAll('.terminal-typing');
|
||||
|
||||
typingElements.forEach((typingElement, elementIndex) => {
|
||||
const text = typingElement.textContent || '';
|
||||
const delay = parseInt(typingElement.getAttribute('data-delay') || '50', 10);
|
||||
|
||||
// Clear the element
|
||||
typingElement.textContent = '';
|
||||
|
||||
let i = 0;
|
||||
|
||||
// Random delay before starting to type (sequential if there are multiple)
|
||||
setTimeout(() => {
|
||||
function typeWriter() {
|
||||
if (i < text.length) {
|
||||
typingElement.textContent += text.charAt(i);
|
||||
i++;
|
||||
|
||||
// Random typing speed for realistic effect
|
||||
const randomVariation = Math.random() * 30 - 15; // -15 to +15ms variation
|
||||
const speed = delay + randomVariation;
|
||||
|
||||
setTimeout(typeWriter, speed);
|
||||
} else {
|
||||
// When done typing, scroll terminal content to bottom
|
||||
const terminalContent = typingElement.closest('.terminal-content');
|
||||
if (terminalContent) {
|
||||
terminalContent.scrollTop = terminalContent.scrollHeight;
|
||||
}
|
||||
|
||||
// Add blinking cursor after the last command
|
||||
if (elementIndex === typingElements.length - 1) {
|
||||
const cursor = typingElement.closest('.terminal-box').querySelector('.terminal-cursor');
|
||||
if (cursor) {
|
||||
const rect = typingElement.getBoundingClientRect();
|
||||
if (terminalContent) {
|
||||
const parentRect = terminalContent.getBoundingClientRect();
|
||||
|
||||
// Position cursor after the last character
|
||||
cursor.style.opacity = '1';
|
||||
cursor.style.left = `${rect.left - parentRect.left + typingElement.offsetWidth}px`;
|
||||
cursor.style.top = `${rect.top - parentRect.top}px`;
|
||||
cursor.style.height = `${rect.height}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typeWriter();
|
||||
}, 1000 * elementIndex); // Sequential delay for multiple typing elements
|
||||
});
|
||||
|
||||
// Button interactions
|
||||
const minButtons = document.querySelectorAll('.terminal-button-minimize');
|
||||
const maxButtons = document.querySelectorAll('.terminal-button-maximize');
|
||||
|
||||
minButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const terminalBox = button.closest('.terminal-box');
|
||||
if (terminalBox) {
|
||||
terminalBox.classList.toggle('minimized');
|
||||
|
||||
if (terminalBox.classList.contains('minimized')) {
|
||||
const content = terminalBox.querySelector('.terminal-content');
|
||||
if (content) {
|
||||
terminalBox.dataset.prevHeight = terminalBox.style.height;
|
||||
terminalBox.style.height = '40px';
|
||||
content.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
const content = terminalBox.querySelector('.terminal-content');
|
||||
if (content) {
|
||||
terminalBox.style.height = terminalBox.dataset.prevHeight || '340px';
|
||||
content.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
maxButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const terminalBox = button.closest('.terminal-box');
|
||||
if (terminalBox) {
|
||||
terminalBox.classList.toggle('maximized');
|
||||
|
||||
if (terminalBox.classList.contains('maximized')) {
|
||||
const content = terminalBox.querySelector('.terminal-content');
|
||||
terminalBox.dataset.prevHeight = terminalBox.style.height;
|
||||
terminalBox.dataset.prevWidth = terminalBox.style.width;
|
||||
terminalBox.dataset.prevPosition = terminalBox.style.position;
|
||||
|
||||
terminalBox.style.position = 'fixed';
|
||||
terminalBox.style.top = '0';
|
||||
terminalBox.style.left = '0';
|
||||
terminalBox.style.width = '100%';
|
||||
terminalBox.style.height = '100%';
|
||||
terminalBox.style.zIndex = '9999';
|
||||
terminalBox.style.borderRadius = '0';
|
||||
} else {
|
||||
terminalBox.style.position = terminalBox.dataset.prevPosition || 'relative';
|
||||
terminalBox.style.width = terminalBox.dataset.prevWidth || '100%';
|
||||
terminalBox.style.height = terminalBox.dataset.prevHeight || '340px';
|
||||
terminalBox.style.zIndex = 'auto';
|
||||
terminalBox.style.borderRadius = '10px';
|
||||
terminalBox.style.top = 'auto';
|
||||
terminalBox.style.left = 'auto';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,96 @@
|
|||
---
|
||||
// ThemeToggler.astro
|
||||
// A component to toggle between light and dark themes
|
||||
---
|
||||
|
||||
<button id="theme-toggle" aria-label="Toggle dark mode" class="theme-toggle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="sun-icon">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="moon-icon">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease, background-color 0.3s ease;
|
||||
position: relative;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sun-icon, .moon-icon {
|
||||
position: absolute;
|
||||
transition: transform 0.5s ease, opacity 0.5s ease;
|
||||
}
|
||||
|
||||
:root:not(.light-mode) .sun-icon {
|
||||
opacity: 0;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
:root:not(.light-mode) .moon-icon {
|
||||
opacity: 1;
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
:root.light-mode .sun-icon {
|
||||
opacity: 1;
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
:root.light-mode .moon-icon {
|
||||
opacity: 0;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Theme toggling logic
|
||||
// Theme toggling logic - only handles clicks now
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
|
||||
// Function to set theme class and save preference
|
||||
const setTheme = (isLight) => {
|
||||
if (isLight) {
|
||||
document.documentElement.classList.add('light-mode');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.remove('light-mode');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
};
|
||||
|
||||
// Theme toggle click handler
|
||||
themeToggle?.addEventListener('click', () => {
|
||||
// Check the current state directly when clicked
|
||||
const isCurrentlyLight = document.documentElement.classList.contains('light-mode');
|
||||
// Toggle to the opposite theme
|
||||
setTheme(!isCurrentlyLight);
|
||||
});
|
||||
|
||||
// No initial theme setting here anymore - moved to BaseLayout <head>
|
||||
</script>
|
|
@ -0,0 +1,414 @@
|
|||
/**
|
||||
* Terminal Configuration
|
||||
* Central configuration for the Terminal component across the site
|
||||
*/
|
||||
|
||||
// Default terminal prompt settings
|
||||
export const TERMINAL_DEFAULTS = {
|
||||
promptPrefix: "[user@argobox]",
|
||||
title: "argobox:~/blog",
|
||||
theme: "dark", // Default theme (dark or light)
|
||||
height: "auto",
|
||||
showTitleBar: true,
|
||||
showPrompt: true
|
||||
};
|
||||
|
||||
// Commonly used commands
|
||||
export const COMMON_COMMANDS = [
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "ls -la ./infrastructure",
|
||||
output: [
|
||||
"total 20",
|
||||
"drwxr-xr-x 5 ArgoBox users 4096 Apr 23 09:15 <span class='highlight'>kubernetes/</span>",
|
||||
"drwxr-xr-x 3 ArgoBox users 4096 Apr 20 17:22 <span class='highlight'>docker/</span>",
|
||||
"drwxr-xr-x 2 ArgoBox users 4096 Apr 19 14:30 <span class='highlight'>networking/</span>",
|
||||
"drwxr-xr-x 4 ArgoBox users 4096 Apr 22 21:10 <span class='highlight'>monitoring/</span>",
|
||||
"drwxr-xr-x 3 ArgoBox users 4096 Apr 21 16:45 <span class='highlight'>storage/</span>",
|
||||
]
|
||||
},
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "grep -r \"kubernetes\" --include=\"*.md\" ./posts | wc -l",
|
||||
output: ["7 matches found"]
|
||||
},
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "kubectl get nodes",
|
||||
output: [
|
||||
"NAME STATUS ROLES AGE VERSION",
|
||||
"argobox-cp1 Ready control-plane,master 92d v1.27.3",
|
||||
"argobox-cp2 Ready control-plane,master 92d v1.27.3",
|
||||
"argobox-cp3 Ready control-plane,master 92d v1.27.3",
|
||||
"argobox-node1 Ready worker 92d v1.27.3",
|
||||
"argobox-node2 Ready worker 92d v1.27.3"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Advanced blog search command sequence
|
||||
export const BLOG_SEARCH_SEQUENCE = [
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "cd ./posts && grep -r \"homelab\" --include=\"*.md\" | sort | head -5",
|
||||
output: [
|
||||
"<span class='term-green'>homelab-essentials.md</span>:<span class='term-blue'>title:</span> \"Essential Tools for Your Home Lab Setup\"",
|
||||
"<span class='term-green'>homelab-essentials.md</span>:<span class='term-blue'>description:</span> \"A curated list of must-have tools for building your home lab infrastructure\"",
|
||||
"<span class='term-green'>kubernetes-at-home.md</span>:<span class='term-blue'>title:</span> \"Running Kubernetes in Your Homelab\"",
|
||||
"<span class='term-green'>proxmox-cluster.md</span>:<span class='term-blue'>description:</span> \"Building a resilient homelab foundation with Proxmox VE cluster\"",
|
||||
"<span class='term-green'>storage-solutions.md</span>:<span class='term-blue'>body:</span> \"...affordable homelab storage solutions for a growing collection of VMs and containers...\""
|
||||
]
|
||||
},
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "find ./posts -type f -name \"*.md\" | xargs wc -l | sort -nr | head -3",
|
||||
output: [
|
||||
"2567 total",
|
||||
" 842 ./posts/kubernetes-the-hard-way.md",
|
||||
" 756 ./posts/home-automation-guide.md",
|
||||
" 523 ./posts/proxmox-cluster.md"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// System monitoring sequence
|
||||
export const SYSTEM_MONITOR_SEQUENCE = [
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "htop",
|
||||
output: [
|
||||
"<span class='term-purple'>Tasks:</span> <span class='term-cyan'>143</span> total, <span class='term-green'>4</span> running, <span class='term-yellow'>139</span> sleeping, <span class='term-red'>0</span> stopped, <span class='term-red'>0</span> zombie",
|
||||
"<span class='term-purple'>%Cpu(s):</span> <span class='term-green'>12.5</span> us, <span class='term-blue'>4.2</span> sy, <span class='term-cyan'>0.0</span> ni, <span class='term-green'>82.3</span> id, <span class='term-yellow'>0.7</span> wa, <span class='term-red'>0.0</span> hi, <span class='term-red'>0.3</span> si, <span class='term-cyan'>0.0</span> st",
|
||||
"<span class='term-purple'>MiB Mem:</span> <span class='term-cyan'>32102.3</span> total, <span class='term-green'>12023.4</span> free, <span class='term-yellow'>10654.8</span> used, <span class='term-blue'>9424.1</span> buff/cache",
|
||||
"<span class='term-purple'>MiB Swap:</span> <span class='term-cyan'>16384.0</span> total, <span class='term-green'>16384.0</span> free, <span class='term-yellow'>0.0</span> used. <span class='term-green'>20223.3</span> avail Mem",
|
||||
"",
|
||||
" <span class='term-cyan'>PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND</span>",
|
||||
"<span class='term-yellow'> 23741 laforcei 20 0 4926.0m 257.9m 142.1m S 25.0 0.8 42:36.76 node</span>",
|
||||
" 22184 root 20 0 743.9m 27.7m 17.6m S 6.2 0.1 27:57.21 dockerd",
|
||||
" 15532 root 20 0 1735.9m 203.5m 122.1m S 6.2 0.6 124:29.93 k3s-server",
|
||||
" 1126 prometheu 20 0 1351.5m 113.9m 41.3m S 0.0 0.4 3:12.52 prometheus"
|
||||
]
|
||||
},
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "df -h",
|
||||
output: [
|
||||
"Filesystem Size Used Avail Use% Mounted on",
|
||||
"/dev/nvme0n1p2 932G 423G 462G 48% /",
|
||||
"/dev/nvme1n1 1.8T 1.1T 638G 64% /data",
|
||||
"tmpfs 16G 12M 16G 1% /run",
|
||||
"tmpfs 32G 0 32G 0% /dev/shm"
|
||||
]
|
||||
},
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "docker stats --no-stream",
|
||||
output: [
|
||||
"CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS",
|
||||
"7d9915b1f946 blog-site 0.15% 145.6MiB / 32GiB 0.44% 648kB / 4.21MB 12.3MB / 0B 24",
|
||||
"c7823beac704 prometheus 2.33% 175.2MiB / 32GiB 0.53% 15.5MB / 25.4MB 29.6MB / 12.4MB 15",
|
||||
"db9d8512f471 postgres 0.03% 96.45MiB / 32GiB 0.29% 85.1kB / 106kB 21.9MB / 63.5MB 11",
|
||||
"f3b1c9e2a147 grafana 0.42% 78.32MiB / 32GiB 0.24% 5.42MB / 12.7MB 86.4MB / 1.21MB 13"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Blog deployment sequence
|
||||
export const BLOG_DEPLOYMENT_SEQUENCE = [
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "git status",
|
||||
output: [
|
||||
"On branch <span class='term-cyan'>main</span>",
|
||||
"Your branch is up to date with 'origin/main'.",
|
||||
"",
|
||||
"Changes not staged for commit:",
|
||||
" (use \"git add <file>...\" to update what will be committed)",
|
||||
" (use \"git restore <file>...\" to discard changes in working directory)",
|
||||
" <span class='term-red'>modified: src/content/posts/kubernetes-at-home.md</span>",
|
||||
" <span class='term-red'>modified: src/components/Terminal.astro</span>",
|
||||
"",
|
||||
"Untracked files:",
|
||||
" (use \"git add <file>...\" to include in what will be committed)",
|
||||
" <span class='term-red'>src/content/posts/new-homelab-upgrades.md</span>",
|
||||
"",
|
||||
"no changes added to commit (use \"git add\" and/or \"git commit -a\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "git add . && git commit -m \"feat: add new post about homelab upgrades\"",
|
||||
output: [
|
||||
"[main <span class='term-green'>f92d47a</span>] <span class='term-cyan'>feat: add new post about homelab upgrades</span>",
|
||||
" 3 files changed, 214 insertions(+), 12 deletions(-)",
|
||||
" create mode 100644 src/content/posts/new-homelab-upgrades.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "npm run build && npm run deploy",
|
||||
output: [
|
||||
"<span class='term-green'>✓</span> Building for production...",
|
||||
"<span class='term-green'>✓</span> Generating static routes",
|
||||
"<span class='term-green'>✓</span> Client side rendering with hydration",
|
||||
"<span class='term-green'>✓</span> Applying optimizations",
|
||||
"<span class='term-green'>✓</span> Complete! 187 pages generated in 43.2 seconds",
|
||||
"",
|
||||
"<span class='term-blue'>Deploying to production environment...</span>",
|
||||
"<span class='term-green'>✓</span> Upload complete",
|
||||
"<span class='term-green'>✓</span> CDN cache invalidated",
|
||||
"<span class='term-green'>✓</span> DNS configuration verified",
|
||||
"<span class='term-green'>✓</span> Blog is live at https://ArgoBox.com!"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Kubernetes operation sequence
|
||||
export const K8S_OPERATION_SEQUENCE = [
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "kubectl create namespace blog-prod",
|
||||
output: [
|
||||
"namespace/blog-prod created"
|
||||
]
|
||||
},
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "kubectl apply -f kubernetes/blog-deployment.yaml",
|
||||
output: [
|
||||
"deployment.apps/blog-frontend created",
|
||||
"service/blog-frontend created",
|
||||
"configmap/blog-config created",
|
||||
"secret/blog-secrets created"
|
||||
]
|
||||
},
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "kubectl get pods -n blog-prod",
|
||||
output: [
|
||||
"NAME READY STATUS RESTARTS AGE",
|
||||
"blog-frontend-7d9b5c7b8d-2xprm 1/1 Running 0 35s",
|
||||
"blog-frontend-7d9b5c7b8d-8bkpl 1/1 Running 0 35s",
|
||||
"blog-frontend-7d9b5c7b8d-f9j7s 1/1 Running 0 35s"
|
||||
]
|
||||
},
|
||||
{
|
||||
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
|
||||
command: "kubectl get ingress -n blog-prod",
|
||||
output: [
|
||||
"NAME CLASS HOSTS ADDRESS PORTS AGE",
|
||||
"blog-ingress <none> blog.ArgoBox.com 192.168.1.50 80, 443 42s"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Predefined terminal content blocks
|
||||
export const TERMINAL_CONTENT = {
|
||||
fileExplorer: `<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">ls -la</span>
|
||||
|
||||
total 42
|
||||
drwxr-xr-x 6 ArgoBox users 4096 Nov 7 22:15 .
|
||||
drwxr-xr-x 12 ArgoBox users 4096 Nov 7 20:32 ..
|
||||
-rw-r--r-- 1 ArgoBox users 182 Nov 7 22:15 .astro
|
||||
drwxr-xr-x 2 ArgoBox users 4096 Nov 7 21:03 components
|
||||
drwxr-xr-x 3 ArgoBox users 4096 Nov 7 21:14 content
|
||||
drwxr-xr-x 4 ArgoBox users 4096 Nov 7 21:42 layouts
|
||||
drwxr-xr-x 5 ArgoBox users 4096 Nov 7 22:10 pages
|
||||
-rw-r--r-- 1 ArgoBox users 1325 Nov 7 22:12 package.json`,
|
||||
|
||||
tags: `<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">cat ./content/tags.txt</span>
|
||||
|
||||
cloudflare
|
||||
coding
|
||||
containers
|
||||
devops
|
||||
digital-garden
|
||||
docker
|
||||
file-management
|
||||
filebrowser
|
||||
flux
|
||||
git
|
||||
gitea
|
||||
gitops
|
||||
grafana
|
||||
homelab
|
||||
infrastructure
|
||||
k3s
|
||||
knowledge-management
|
||||
kubernetes
|
||||
learning-in-public
|
||||
monitoring
|
||||
networking
|
||||
observability
|
||||
obsidian
|
||||
prometheus
|
||||
proxmox
|
||||
quartz
|
||||
rancher
|
||||
remote-development
|
||||
security
|
||||
self-hosted
|
||||
terraform
|
||||
test
|
||||
tunnels
|
||||
tutorial
|
||||
virtualization
|
||||
vscode`,
|
||||
|
||||
blogDeployment: `<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">git add src/content/posts/kubernetes-monitoring.md</span>
|
||||
|
||||
<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">git commit -m "feat: add new article on Kubernetes monitoring"</span>
|
||||
[main <span class="term-green">8fd43a9</span>] <span class="term-cyan">feat: add new article on Kubernetes monitoring</span>
|
||||
1 file changed, 147 insertions(+)
|
||||
create mode 100644 src/content/posts/kubernetes-monitoring.md
|
||||
|
||||
<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">git push origin main</span>
|
||||
Enumerating objects: 8, done.
|
||||
Counting objects: 100% (8/8), done.
|
||||
Delta compression using up to 8 threads
|
||||
Compressing objects: 100% (5/5), done.
|
||||
Writing objects: 100% (5/5), 2.12 KiB | 2.12 MiB/s, done.
|
||||
Total 5 (delta 3), reused 0 (delta 0), pack-reused 0
|
||||
remote: Resolving deltas: 100% (3/3), completed with 3 local objects.
|
||||
<span class="term-green">✓</span> Deployed to https://ArgoBox.com
|
||||
<span class="term-green">✓</span> Article published successfully`,
|
||||
|
||||
k8sInstall: `<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">curl -sfL https://get.k3s.io | sh -</span>
|
||||
[INFO] Finding release for channel stable
|
||||
[INFO] Using v1.27.4+k3s1 as release
|
||||
[INFO] Downloading hash https://github.com/k3s-io/k3s/releases/download/v1.27.4+k3s1/sha256sum-amd64.txt
|
||||
[INFO] Downloading binary https://github.com/k3s-io/k3s/releases/download/v1.27.4+k3s1/k3s
|
||||
[INFO] Verifying binary download
|
||||
[INFO] Installing k3s to /usr/local/bin/k3s
|
||||
[INFO] Creating /usr/local/bin/kubectl symlink to k3s
|
||||
[INFO] Creating /usr/local/bin/crictl symlink to k3s
|
||||
[INFO] Creating /usr/local/bin/ctr symlink to k3s
|
||||
[INFO] Creating killall script /usr/local/bin/k3s-killall.sh
|
||||
[INFO] Creating uninstall script /usr/local/bin/k3s-uninstall.sh
|
||||
[INFO] env: Creating environment file /etc/systemd/system/k3s.service.env
|
||||
[INFO] systemd: Creating service file /etc/systemd/system/k3s.service
|
||||
[INFO] systemd: Enabling k3s unit
|
||||
Created symlink /etc/systemd/system/multi-user.target.wants/k3s.service → /etc/systemd/system/k3s.service.
|
||||
[INFO] systemd: Starting k3s
|
||||
<span class="term-green">✓</span> K3s has been installed successfully
|
||||
|
||||
<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">kubectl get pods -A</span>
|
||||
NAMESPACE NAME READY STATUS RESTARTS AGE
|
||||
kube-system helm-install-traefik-crd-k7gxl 0/1 Completed 0 2m43s
|
||||
kube-system helm-install-traefik-pvvhg 0/1 Completed 1 2m43s
|
||||
kube-system metrics-server-67c658dc48-mxnxp 1/1 Running 0 2m43s
|
||||
kube-system local-path-provisioner-7b7dc8d6f5-q99nl 1/1 Running 0 2m43s
|
||||
kube-system coredns-b96499967-nkvnz 1/1 Running 0 2m43s
|
||||
kube-system svclb-traefik-bd0bfb17-ht8gq 2/2 Running 0 96s
|
||||
kube-system traefik-7d586bdc47-d6lzr 1/1 Running 0 96s`,
|
||||
|
||||
dockerCompose: `<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">cat docker-compose.yaml</span>
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
blog:
|
||||
image: node:18-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./:/app
|
||||
working_dir: /app
|
||||
command: sh -c "npm install && npm run dev"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
|
||||
db:
|
||||
image: postgres:14-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=secure_password
|
||||
- POSTGRES_USER=bloguser
|
||||
- POSTGRES_DB=blogdb
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">docker-compose up -d</span>
|
||||
Creating network "ArgoBox-blog_default" with the default driver
|
||||
Creating volume "ArgoBox-blog_postgres_data" with default driver
|
||||
Pulling blog (node:18-alpine)...
|
||||
Pulling db (postgres:14-alpine)...
|
||||
Creating ArgoBox-blog_db_1 ... done
|
||||
Creating ArgoBox-blog_blog_1 ... done`
|
||||
};
|
||||
|
||||
// Helper function to create terminal presets
|
||||
export function createTerminalPreset(type) {
|
||||
switch (type) {
|
||||
case 'blog-search':
|
||||
return BLOG_SEARCH_SEQUENCE[Math.floor(Math.random() * BLOG_SEARCH_SEQUENCE.length)];
|
||||
|
||||
case 'system-monitor':
|
||||
return SYSTEM_MONITOR_SEQUENCE[Math.floor(Math.random() * SYSTEM_MONITOR_SEQUENCE.length)];
|
||||
|
||||
case 'blog-deploy':
|
||||
return BLOG_DEPLOYMENT_SEQUENCE[Math.floor(Math.random() * BLOG_DEPLOYMENT_SEQUENCE.length)];
|
||||
|
||||
case 'k8s-ops':
|
||||
return K8S_OPERATION_SEQUENCE[Math.floor(Math.random() * K8S_OPERATION_SEQUENCE.length)];
|
||||
|
||||
case 'k8s':
|
||||
return {
|
||||
title: "argobox:~/kubernetes",
|
||||
command: "kubectl get pods -A",
|
||||
output: `NAMESPACE NAME READY STATUS RESTARTS AGE
|
||||
kube-system coredns-66bff467f8-8p7z2 1/1 Running 0 15d
|
||||
kube-system coredns-66bff467f8-v68vr 1/1 Running 0 15d
|
||||
kube-system etcd-control-plane 1/1 Running 0 15d
|
||||
kube-system kube-apiserver-control-plane 1/1 Running 0 15d
|
||||
kube-system kube-controller-manager-control-plane 1/1 Running 0 15d
|
||||
kube-system kube-proxy-c84qf 1/1 Running 0 15d
|
||||
kube-system kube-scheduler-control-plane 1/1 Running 0 15d`
|
||||
};
|
||||
|
||||
case 'docker':
|
||||
return {
|
||||
title: "argobox:~/docker",
|
||||
command: "docker ps",
|
||||
output: `CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES
|
||||
d834f0efcf2f nginx:latest "/docker-entrypoint.…" Up 2 days 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp web
|
||||
0b292940b4c0 postgres:13 "docker-entrypoint.s…" Up 2 days 0.0.0.0:5432->5432/tcp db
|
||||
a834fa3ede06 redis:6 "docker-entrypoint.s…" Up 2 days 0.0.0.0:6379->6379/tcp cache`
|
||||
};
|
||||
|
||||
case 'search':
|
||||
return {
|
||||
title: "argobox:~/blog",
|
||||
command: "grep -r \"kubernetes\" --include=\"*.md\" ./posts | wc -l",
|
||||
output: "7 matches found"
|
||||
};
|
||||
|
||||
case 'random-cool':
|
||||
// Pick a random sequence for a cool effect
|
||||
const sequences = [
|
||||
TERMINAL_CONTENT.k8sInstall,
|
||||
TERMINAL_CONTENT.blogDeployment,
|
||||
TERMINAL_CONTENT.dockerCompose,
|
||||
...BLOG_SEARCH_SEQUENCE.map(item => `<div class="term-blue">${item.prompt}</div><span>$</span> <span class="term-bold">${item.command}</span>\n${item.output.join('\n')}`),
|
||||
...SYSTEM_MONITOR_SEQUENCE.map(item => `<div class="term-blue">${item.prompt}</div><span>$</span> <span class="term-bold">${item.command}</span>\n${item.output.join('\n')}`),
|
||||
...BLOG_DEPLOYMENT_SEQUENCE.map(item => `<div class="term-blue">${item.prompt}</div><span>$</span> <span class="term-bold">${item.command}</span>\n${item.output.join('\n')}`),
|
||||
...K8S_OPERATION_SEQUENCE.map(item => `<div class="term-blue">${item.prompt}</div><span>$</span> <span class="term-bold">${item.command}</span>\n${item.output.join('\n')}`)
|
||||
];
|
||||
return {
|
||||
title: "argobox:~/cool-stuff",
|
||||
content: sequences[Math.floor(Math.random() * sequences.length)]
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
title: TERMINAL_DEFAULTS.title,
|
||||
command: "echo 'Hello from ArgoBox Terminal'",
|
||||
output: "Hello from ArgoBox Terminal"
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
title: 'Getting Started with Infrastructure as Code'
|
||||
description: 'Learn the basics of Infrastructure as Code and how to start using it in your projects.'
|
||||
pubDate: '2023-11-15'
|
||||
heroImage: '/images/placeholders/infrastructure.jpg'
|
||||
categories: ['Infrastructure', 'DevOps']
|
||||
tags: ['terraform', 'infrastructure', 'cloud', 'automation']
|
||||
minutesRead: '5 min'
|
||||
---
|
||||
|
||||
# Getting Started with Infrastructure as Code
|
||||
|
||||
Infrastructure as Code (IaC) is a key DevOps practice that involves managing and provisioning infrastructure through code instead of manual processes. This approach brings the same rigor, transparency, and version control to infrastructure that developers have long applied to application code.
|
||||
|
||||
## Why Infrastructure as Code?
|
||||
|
||||
IaC offers numerous benefits for modern DevOps teams:
|
||||
|
||||
- **Consistency**: Infrastructure deployments become reproducible and standardized
|
||||
- **Version Control**: Track changes to your infrastructure just like application code
|
||||
- **Automation**: Reduce manual errors and increase deployment speed
|
||||
- **Documentation**: Your code becomes self-documenting
|
||||
- **Testing**: Infrastructure can be tested before deployment
|
||||
|
||||
## Popular IaC Tools
|
||||
|
||||
There are several powerful tools for implementing IaC:
|
||||
|
||||
1. **Terraform**: Cloud-agnostic, works with multiple providers
|
||||
2. **AWS CloudFormation**: Specific to AWS infrastructure
|
||||
3. **Azure Resource Manager**: Microsoft's native IaC solution
|
||||
4. **Google Cloud Deployment Manager**: For Google Cloud resources
|
||||
5. **Pulumi**: Uses general-purpose programming languages
|
||||
|
||||
## Basic Terraform Example
|
||||
|
||||
Here's a simple example of Terraform code that provisions an AWS EC2 instance:
|
||||
|
||||
```hcl
|
||||
provider "aws" {
|
||||
region = "us-west-2"
|
||||
}
|
||||
|
||||
resource "aws_instance" "web_server" {
|
||||
ami = "ami-0c55b159cbfafe1f0"
|
||||
instance_type = "t2.micro"
|
||||
|
||||
tags = {
|
||||
Name = "Web Server"
|
||||
Environment = "Development"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
To begin your IaC journey:
|
||||
|
||||
1. Choose a tool that fits your infrastructure needs
|
||||
2. Start small with a simple resource
|
||||
3. Learn about state management
|
||||
4. Implement CI/CD for your infrastructure code
|
||||
5. Consider using modules for reusability
|
||||
|
||||
Infrastructure as Code transforms how teams provision and manage resources, enabling more reliable, consistent deployments while reducing overhead and errors.
|
|
@ -0,0 +1,83 @@
|
|||
// src/content/config.ts
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
// Define the post collection schema
|
||||
const postsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
pubDate: z.coerce.date(),
|
||||
updatedDate: z.coerce.date().optional(),
|
||||
heroImage: z.string().optional(),
|
||||
|
||||
// Support both single category and categories array
|
||||
category: z.string().optional(),
|
||||
categories: z.array(z.string()).optional(),
|
||||
|
||||
// Tags as an array
|
||||
tags: z.array(z.string()).default([]),
|
||||
|
||||
// Author and reading time
|
||||
author: z.string().optional(),
|
||||
readTime: z.string().optional(),
|
||||
|
||||
// Draft status
|
||||
draft: z.boolean().optional().default(false),
|
||||
|
||||
// Related posts by slug
|
||||
related_posts: z.array(z.string()).optional(),
|
||||
|
||||
// Additional metadata
|
||||
featured: z.boolean().optional().default(false),
|
||||
technologies: z.array(z.string()).optional(),
|
||||
complexity: z.enum(['beginner', 'intermediate', 'advanced']).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the configurations collection (for config files)
|
||||
const configurationsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
pubDate: z.coerce.date(),
|
||||
updatedDate: z.coerce.date().optional(),
|
||||
heroImage: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
categories: z.array(z.string()).optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
technologies: z.array(z.string()).optional(),
|
||||
complexity: z.enum(['beginner', 'intermediate', 'advanced']).optional(),
|
||||
draft: z.boolean().optional().default(false),
|
||||
version: z.string().optional(),
|
||||
github: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the projects collection
|
||||
const projectsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
pubDate: z.coerce.date(),
|
||||
updatedDate: z.coerce.date().optional(),
|
||||
heroImage: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
categories: z.array(z.string()).optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
technologies: z.array(z.string()).optional(),
|
||||
github: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
status: z.enum(['concept', 'in-progress', 'completed', 'maintained']).optional(),
|
||||
draft: z.boolean().optional().default(false),
|
||||
}),
|
||||
});
|
||||
|
||||
// Export the collections
|
||||
export const collections = {
|
||||
posts: postsCollection,
|
||||
configurations: configurationsCollection,
|
||||
projects: projectsCollection,
|
||||
};
|
|
@ -0,0 +1,79 @@
|
|||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
// Define custom date validator that handles mm/dd/yyyy format
|
||||
const customDateParser = (dateString: string | Date) => {
|
||||
// If date is already a Date object, return it
|
||||
if (dateString instanceof Date) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
// Try to parse the date as is
|
||||
let date = new Date(dateString);
|
||||
|
||||
// Check if it's in mm/dd/yyyy format (like 04/19/2024)
|
||||
if (isNaN(date.getTime()) || typeof dateString !== 'string') {
|
||||
return new Date(0); // Return a default date
|
||||
}
|
||||
|
||||
// For mm/dd/yyyy format, extra handling
|
||||
if (dateString.match(/^\d{2}\/\d{2}\/\d{4}$/)) {
|
||||
const [month, day, year] = dateString.split('/').map(Number);
|
||||
date = new Date(year, month - 1, day); // Month is 0-indexed in JS
|
||||
}
|
||||
|
||||
return date;
|
||||
};
|
||||
|
||||
// Define the base schema for all content
|
||||
const baseSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
pubDate: z.union([z.string(), z.date()]).transform(customDateParser),
|
||||
updatedDate: z.union([z.string(), z.date()]).optional().transform(val => val ? customDateParser(val) : undefined),
|
||||
heroImage: z.string().optional(),
|
||||
category: z.string().optional().default('Uncategorized'),
|
||||
tags: z.array(z.string()).default([]),
|
||||
draft: z.boolean().optional().default(false),
|
||||
readTime: z.string().optional(),
|
||||
image: z.string().optional(),
|
||||
excerpt: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
github: z.string().optional(),
|
||||
live: z.string().optional(),
|
||||
technologies: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// Define collections using the same base schema
|
||||
const postsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: baseSchema,
|
||||
});
|
||||
|
||||
const configurationsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: baseSchema,
|
||||
});
|
||||
|
||||
const projectsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: baseSchema,
|
||||
});
|
||||
|
||||
const externalPostsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: baseSchema,
|
||||
});
|
||||
|
||||
const blogCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: baseSchema,
|
||||
});
|
||||
|
||||
// Export the collections
|
||||
export const collections = {
|
||||
'posts': postsCollection,
|
||||
'configurations': configurationsCollection,
|
||||
'projects': projectsCollection,
|
||||
'external-posts': externalPostsCollection,
|
||||
'blog': blogCollection,
|
||||
};
|
|
@ -0,0 +1,287 @@
|
|||
---
|
||||
title: Git Symbolic Links Setup for Blog Content
|
||||
pubDate: 2023-11-01
|
||||
description: A comprehensive guide to setting up Git with symbolic links for efficient content management between Obsidian and your blog codebase
|
||||
author: LaForce IT
|
||||
heroImage: /images/git-symlinks-hero.jpg
|
||||
category: Development
|
||||
tags:
|
||||
- git
|
||||
- obsidian
|
||||
- workflow
|
||||
- automation
|
||||
---
|
||||
|
||||
# Git Symbolic Links Setup for Blog Content
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains the setup that allows the blog content to be managed in Obsidian while being properly versioned in Git. The setup uses Git hooks to automatically handle symbolic links, ensuring that actual content is committed while maintaining symbolic links for local development.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Obsidian Vault] -->|Symbolic Links| B[Blog Repository]
|
||||
B -->|Pre-commit Hook| C[Convert to Content]
|
||||
C -->|Commit| D[Git Repository]
|
||||
D -->|Post-commit Hook| E[Restore Symlinks]
|
||||
E -->|Local Development| B
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### Obsidian Content Location
|
||||
```
|
||||
/mnt/synology/obsidian/Public/Blog/
|
||||
├── posts/
|
||||
├── projects/
|
||||
├── configurations/
|
||||
├── external-posts/
|
||||
├── configs/
|
||||
├── images/
|
||||
├── infrastructure/
|
||||
└── content/
|
||||
```
|
||||
|
||||
### Blog Repository Structure
|
||||
```
|
||||
ArgoBox-blog/
|
||||
├── src/content/
|
||||
│ ├── posts -> /mnt/synology/obsidian/Public/Blog/posts
|
||||
│ ├── projects -> /mnt/synology/obsidian/Public/Blog/projects
|
||||
│ ├── configurations -> /mnt/synology/obsidian/Public/Blog/configurations
|
||||
│ └── external-posts -> /mnt/synology/obsidian/Public/Blog/external-posts
|
||||
└── public/blog/
|
||||
├── configs -> /mnt/synology/obsidian/Public/Blog/configs
|
||||
├── images -> /mnt/synology/obsidian/Public/Blog/images
|
||||
├── infrastructure -> /mnt/synology/obsidian/Public/Blog/infrastructure
|
||||
└── posts -> /mnt/synology/obsidian/Public/Blog/posts
|
||||
```
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
1. Clone the Blog Repository
|
||||
```bash
|
||||
git clone https://git.argobox.com/KeyArgo/ArgoBox-blog.git
|
||||
cd ArgoBox-blog
|
||||
```
|
||||
|
||||
2. Create the Scripts Directory
|
||||
```bash
|
||||
mkdir -p scripts
|
||||
```
|
||||
|
||||
3. Create the Content Processing Script
|
||||
Create `scripts/process-content-links.sh`:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
# Script to handle symbolic links before commit
|
||||
echo "Processing symbolic links for content..."
|
||||
|
||||
# Array of content directories to process
|
||||
declare -A CONTENT_PATHS
|
||||
# src/content directories
|
||||
CONTENT_PATHS["posts"]="src/content/posts"
|
||||
CONTENT_PATHS["projects"]="src/content/projects"
|
||||
CONTENT_PATHS["configurations"]="src/content/configurations"
|
||||
CONTENT_PATHS["external-posts"]="src/content/external-posts"
|
||||
# public/blog directories
|
||||
CONTENT_PATHS["configs"]="public/blog/configs"
|
||||
CONTENT_PATHS["images"]="public/blog/images"
|
||||
CONTENT_PATHS["infrastructure"]="public/blog/infrastructure"
|
||||
CONTENT_PATHS["blog-posts"]="public/blog/posts"
|
||||
|
||||
for dir_name in "${!CONTENT_PATHS[@]}"; do
|
||||
dir_path="${CONTENT_PATHS[$dir_name]}"
|
||||
if [ -L "$dir_path" ]; then
|
||||
echo "Processing $dir_path..."
|
||||
target=$(readlink "$dir_path")
|
||||
rm "$dir_path"
|
||||
mkdir -p "$(dirname "$dir_path")"
|
||||
cp -r "$target" "$dir_path"
|
||||
git add "$dir_path"
|
||||
echo "Processed $dir_path -> $target"
|
||||
else
|
||||
echo "Skipping $dir_path (not a symbolic link)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Content processing complete!"
|
||||
```
|
||||
|
||||
4. Create Git Hooks
|
||||
|
||||
### Pre-commit Hook
|
||||
Create `.git/hooks/pre-commit`:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Pre-commit hook to process symbolic links
|
||||
|
||||
echo "Running pre-commit hook for blog content..."
|
||||
|
||||
# Get the absolute path to the script
|
||||
SCRIPT_PATH="$(git rev-parse --show-toplevel)/scripts/process-content-links.sh"
|
||||
|
||||
# Check if the script exists and is executable
|
||||
if [ -x "$SCRIPT_PATH" ]; then
|
||||
bash "$SCRIPT_PATH"
|
||||
# Add any new or changed files resulting from the script
|
||||
git add -A
|
||||
else
|
||||
echo "Error: Content processing script not found or not executable at $SCRIPT_PATH"
|
||||
echo "Please ensure the script exists and has execute permissions"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Post-commit Hook
|
||||
Create `.git/hooks/post-commit`:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Post-commit hook to restore symbolic links
|
||||
|
||||
echo "Running post-commit hook to restore symbolic links..."
|
||||
|
||||
# Array of content directories and their targets
|
||||
declare -A SYMLINK_TARGETS=(
|
||||
["src/content/posts"]="/mnt/synology/obsidian/Public/Blog/posts"
|
||||
["src/content/projects"]="/mnt/synology/obsidian/Public/Blog/projects"
|
||||
["src/content/configurations"]="/mnt/synology/obsidian/Public/Blog/configurations"
|
||||
["src/content/external-posts"]="/mnt/synology/obsidian/Public/Blog/external-posts"
|
||||
["public/blog/configs"]="/mnt/synology/obsidian/Public/Blog/configs"
|
||||
["public/blog/images"]="/mnt/synology/obsidian/Public/Blog/images"
|
||||
["public/blog/infrastructure"]="/mnt/synology/obsidian/Public/Blog/infrastructure"
|
||||
["public/blog/posts"]="/mnt/synology/obsidian/Public/Blog/posts"
|
||||
)
|
||||
|
||||
for dir_path in "${!SYMLINK_TARGETS[@]}"; do
|
||||
target="${SYMLINK_TARGETS[$dir_path]}"
|
||||
if [ -d "$target" ]; then
|
||||
echo "Restoring symlink for $dir_path -> $target"
|
||||
rm -rf "$dir_path"
|
||||
mkdir -p "$(dirname "$dir_path")"
|
||||
ln -s "$target" "$dir_path"
|
||||
else
|
||||
echo "Warning: Target directory $target does not exist"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Symbolic links restored!"
|
||||
```
|
||||
|
||||
5. Set Proper Permissions
|
||||
```bash
|
||||
chmod +x scripts/process-content-links.sh
|
||||
chmod +x .git/hooks/pre-commit
|
||||
chmod +x .git/hooks/post-commit
|
||||
```
|
||||
|
||||
6. Create Symbolic Links
|
||||
```bash
|
||||
# Create necessary directories
|
||||
mkdir -p src/content public/blog
|
||||
|
||||
# Create symbolic links for src/content
|
||||
ln -s /mnt/synology/obsidian/Public/Blog/posts src/content/posts
|
||||
ln -s /mnt/synology/obsidian/Public/Blog/projects src/content/projects
|
||||
ln -s /mnt/synology/obsidian/Public/Blog/configurations src/content/configurations
|
||||
ln -s /mnt/synology/obsidian/Public/Blog/external-posts src/content/external-posts
|
||||
|
||||
# Create symbolic links for public/blog
|
||||
ln -s /mnt/synology/obsidian/Public/Blog/configs public/blog/configs
|
||||
ln -s /mnt/synology/obsidian/Public/Blog/images public/blog/images
|
||||
ln -s /mnt/synology/obsidian/Public/Blog/infrastructure public/blog/infrastructure
|
||||
ln -s /mnt/synology/obsidian/Public/Blog/posts public/blog/posts
|
||||
```
|
||||
|
||||
## Git Configuration
|
||||
|
||||
1. Configure Git to Handle Symbolic Links
|
||||
```bash
|
||||
git config core.symlinks true
|
||||
```
|
||||
|
||||
2. Create `.gitattributes`
|
||||
```
|
||||
# Handle symbolic links as real content
|
||||
public/blog/* !symlink
|
||||
src/content/* !symlink
|
||||
|
||||
# Treat these directories as regular directories even if they're symlinks
|
||||
public/blog/configs/ -symlink
|
||||
public/blog/images/ -symlink
|
||||
public/blog/infrastructure/ -symlink
|
||||
public/blog/posts/ -symlink
|
||||
src/content/posts/ -symlink
|
||||
src/content/projects/ -symlink
|
||||
src/content/configurations/ -symlink
|
||||
src/content/external-posts/ -symlink
|
||||
|
||||
# Set text files to automatically normalize line endings
|
||||
* text=auto
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Normal Development**
|
||||
- Content is edited in Obsidian
|
||||
- Blog repository uses symbolic links to this content
|
||||
- Local development works seamlessly
|
||||
|
||||
2. **During Commits**
|
||||
- Pre-commit hook activates
|
||||
- Symbolic links are converted to actual content
|
||||
- Content is committed to the repository
|
||||
|
||||
3. **After Commits**
|
||||
- Post-commit hook activates
|
||||
- Symbolic links are restored
|
||||
- Local development continues normally
|
||||
|
||||
## Viewing in Gitea
|
||||
|
||||
Yes, you can view this setup in Gitea:
|
||||
1. The actual content will be visible in the repository
|
||||
2. The `.git/hooks` directory and scripts will be visible
|
||||
3. The symbolic links will appear as regular directories in Gitea
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **Symbolic Links Not Working**
|
||||
- Check file permissions
|
||||
- Verify Obsidian vault location
|
||||
- Ensure Git symlinks are enabled
|
||||
|
||||
2. **Hooks Not Executing**
|
||||
- Check execute permissions on scripts
|
||||
- Verify hook files are in `.git/hooks`
|
||||
- Check script paths are correct
|
||||
|
||||
3. **Content Not Committing**
|
||||
- Check Git configuration
|
||||
- Verify pre-commit hook execution
|
||||
- Check file permissions
|
||||
|
||||
## Maintenance
|
||||
|
||||
1. **Adding New Content Directories**
|
||||
- Add to `CONTENT_PATHS` in process-content-links.sh
|
||||
- Add to `SYMLINK_TARGETS` in post-commit hook
|
||||
- Create corresponding symbolic links
|
||||
|
||||
2. **Changing Obsidian Location**
|
||||
- Update paths in post-commit hook
|
||||
- Update existing symbolic links
|
||||
|
||||
3. **Backup Considerations**
|
||||
- Both Obsidian content and blog repository should be backed up
|
||||
- Keep hooks and scripts in version control
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. Keep sensitive content outside of public blog directories
|
||||
2. Review content before commits
|
||||
3. Use `.gitignore` for private files
|
||||
4. Consider access permissions on Obsidian vault
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
title: "Blog Posts Collection"
|
||||
description: "Documentation for blog posts"
|
||||
pubDate: 2025-04-18
|
||||
draft: true
|
||||
---
|
||||
|
||||
# Blog Posts Collection
|
||||
|
||||
This directory contains blog posts for the ArgoBox digital garden.
|
||||
|
||||
## Content Guidelines
|
||||
|
||||
- All posts should include proper frontmatter
|
||||
- Use Markdown for formatting content
|
||||
- Images should be placed in the public/blog/images directory
|
||||
|
||||
## Frontmatter Requirements
|
||||
|
||||
Every post needs at minimum:
|
||||
|
||||
```
|
||||
---
|
||||
title: "Post Title"
|
||||
pubDate: YYYY-MM-DD
|
||||
---
|
||||
```
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
title: This is a test
|
||||
description: How to set up Cloudflare Tunnels for secure remote access to your home lab services
|
||||
pubDate: Jul 22 2023
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
category: networking
|
||||
tags:
|
||||
- Tag A
|
||||
- Tag B
|
||||
- Tag C
|
||||
readTime: "7 min read"
|
||||
---
|
|
@ -0,0 +1,180 @@
|
|||
---
|
||||
title: Secure Remote Access with Cloudflare Tunnels
|
||||
description: How to set up Cloudflare Tunnels for secure remote access to your home lab services
|
||||
pubDate: 2025-04-19
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
category: networking
|
||||
tags:
|
||||
- cloudflare
|
||||
- networking
|
||||
- security
|
||||
- homelab
|
||||
- tunnels
|
||||
readTime: 7 min read
|
||||
---
|
||||
|
||||
# Secure Remote Access with Cloudflare Tunnels
|
||||
|
||||
Cloudflare Tunnels provide a secure way to expose your locally hosted applications and services to the internet without opening ports on your firewall or requiring a static IP address. This guide will show you how to set up Cloudflare Tunnels to securely access your home lab services from anywhere.
|
||||
|
||||
## Why Use Cloudflare Tunnels?
|
||||
|
||||
- **Security**: No need to open ports on your firewall
|
||||
- **Simplicity**: Works behind CGNAT, dynamic IPs, and complex network setups
|
||||
- **Performance**: Traffic routed through Cloudflare's global network
|
||||
- **Zero Trust**: Integrate with Cloudflare Access for authentication
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Cloudflare account
|
||||
- A domain managed by Cloudflare
|
||||
- Docker installed (for containerized deployment)
|
||||
- Services you want to expose (e.g., web apps, SSH, etc.)
|
||||
|
||||
## Setting Up Cloudflare Tunnels
|
||||
|
||||
### 1. Install cloudflared
|
||||
|
||||
You can install cloudflared using Docker:
|
||||
|
||||
```bash
|
||||
docker pull cloudflare/cloudflared:latest
|
||||
```
|
||||
|
||||
Or directly on your system:
|
||||
|
||||
```bash
|
||||
# For Debian/Ubuntu
|
||||
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
|
||||
sudo dpkg -i cloudflared.deb
|
||||
|
||||
# For other systems, visit: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation
|
||||
```
|
||||
|
||||
### 2. Authenticate cloudflared
|
||||
|
||||
Run the following command to authenticate:
|
||||
|
||||
```bash
|
||||
cloudflared tunnel login
|
||||
```
|
||||
|
||||
This will open a browser window where you'll need to log in to your Cloudflare account and select the domain you want to use with the tunnel.
|
||||
|
||||
### 3. Create a Tunnel
|
||||
|
||||
Create a new tunnel with a meaningful name:
|
||||
|
||||
```bash
|
||||
cloudflared tunnel create homelab
|
||||
```
|
||||
|
||||
This will generate a tunnel ID and credentials file at `~/.cloudflared/`.
|
||||
|
||||
### 4. Configure your Tunnel
|
||||
|
||||
Create a config file at `~/.cloudflared/config.yml`:
|
||||
|
||||
```yaml
|
||||
tunnel: <TUNNEL_ID>
|
||||
credentials-file: /root/.cloudflared/<TUNNEL_ID>.json
|
||||
|
||||
ingress:
|
||||
# Dashboard application
|
||||
- hostname: dashboard.yourdomain.com
|
||||
service: http://localhost:8080
|
||||
|
||||
# Grafana service
|
||||
- hostname: grafana.yourdomain.com
|
||||
service: http://localhost:3000
|
||||
|
||||
# SSH service
|
||||
- hostname: ssh.yourdomain.com
|
||||
service: ssh://localhost:22
|
||||
|
||||
# Catch-all rule, which responds with 404
|
||||
- service: http_status:404
|
||||
```
|
||||
|
||||
### 5. Route Traffic to Your Tunnel
|
||||
|
||||
Configure DNS records to route traffic to your tunnel:
|
||||
|
||||
```bash
|
||||
cloudflared tunnel route dns homelab dashboard.yourdomain.com
|
||||
cloudflared tunnel route dns homelab grafana.yourdomain.com
|
||||
cloudflared tunnel route dns homelab ssh.yourdomain.com
|
||||
```
|
||||
|
||||
### 6. Start the Tunnel
|
||||
|
||||
Run the tunnel:
|
||||
|
||||
```bash
|
||||
cloudflared tunnel run homelab
|
||||
```
|
||||
|
||||
For production deployments, you'll want to set up cloudflared as a service:
|
||||
|
||||
```bash
|
||||
# For systemd-based systems
|
||||
sudo cloudflared service install
|
||||
sudo systemctl start cloudflared
|
||||
```
|
||||
|
||||
## Docker Compose Example
|
||||
|
||||
For a containerized deployment, create a `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
container_name: cloudflared
|
||||
restart: unless-stopped
|
||||
command: tunnel run
|
||||
environment:
|
||||
- TUNNEL_TOKEN=your_tunnel_token
|
||||
volumes:
|
||||
- ~/.cloudflared:/etc/cloudflared
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Store your credentials file safely; it provides full access to your tunnel
|
||||
- Consider using Cloudflare Access for additional authentication
|
||||
- Regularly rotate credentials and update cloudflared
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Zero Trust Access
|
||||
|
||||
You can integrate Cloudflare Tunnels with Cloudflare Access to require authentication:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
- hostname: dashboard.yourdomain.com
|
||||
service: http://localhost:8080
|
||||
originRequest:
|
||||
noTLSVerify: true
|
||||
```
|
||||
|
||||
Then, create an Access application in the Cloudflare Zero Trust dashboard to protect this hostname.
|
||||
|
||||
### Health Checks
|
||||
|
||||
Configure health checks to ensure your services are running:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
- hostname: dashboard.yourdomain.com
|
||||
service: http://localhost:8080
|
||||
originRequest:
|
||||
healthCheckEnabled: true
|
||||
healthCheckPath: /health
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Cloudflare Tunnels provide a secure, reliable way to access your home lab services remotely without exposing your home network to the internet. With the setup described in this guide, you can securely access your services from anywhere in the world.
|
|
@ -0,0 +1,206 @@
|
|||
---
|
||||
title: Setting Up FileBrowser for Self-Hosted File Management
|
||||
description: A step-by-step guide to deploying and configuring FileBrowser for secure, user-friendly file management in your home lab environment.
|
||||
pubDate: 2025-04-19
|
||||
updatedDate: 2025-04-18
|
||||
category: Services
|
||||
tags:
|
||||
- filebrowser
|
||||
- self-hosted
|
||||
- kubernetes
|
||||
- docker
|
||||
- file-management
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
---
|
||||
|
||||
I've said it before, and I'll say it again - the journey to a well-organized digital life begins with proper file management. If you're like me, you've got files scattered across multiple devices, cloud services, and servers. What if I told you there's a lightweight, sleek solution that puts you back in control without relying on third-party services?
|
||||
|
||||
Enter [FileBrowser](https://filebrowser.org/), a simple yet powerful self-hosted file management interface that I've been using in my home lab for the past few months. Let me show you how to set it up and some cool ways I'm using it.
|
||||
|
||||
## What is FileBrowser?
|
||||
|
||||
FileBrowser is an open-source, single binary file manager with a clean web interface that lets you:
|
||||
|
||||
- Access and manage files from any device with a browser
|
||||
- Share files with customizable permissions
|
||||
- Edit files directly in the browser
|
||||
- Perform basic file operations (copy, move, delete, upload, download)
|
||||
- Search through your files and folders
|
||||
|
||||
The best part? It's lightweight (< 20MB), written in Go, and runs on pretty much anything - from a Raspberry Pi to your Kubernetes cluster.
|
||||
|
||||
## Getting Started with FileBrowser
|
||||
|
||||
### Option 1: Docker Deployment
|
||||
|
||||
For the Docker enthusiasts (like me), here's how to get FileBrowser up and running in seconds:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name filebrowser \
|
||||
-v /path/to/your/files:/srv \
|
||||
-v /path/to/filebrowser/database:/database \
|
||||
-e PUID=$(id -u) \
|
||||
-e PGID=$(id -g) \
|
||||
-p 8080:80 \
|
||||
filebrowser/filebrowser:latest
|
||||
```
|
||||
|
||||
This will start FileBrowser on port 8080, with your files mounted at `/srv` inside the container.
|
||||
|
||||
### Option 2: Kubernetes Deployment with Helm
|
||||
|
||||
For my fellow Kubernetes fanatics, here's a simple Helm chart deployment:
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: filebrowser
|
||||
labels:
|
||||
app: filebrowser
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: filebrowser
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: filebrowser
|
||||
spec:
|
||||
containers:
|
||||
- name: filebrowser
|
||||
image: filebrowser/filebrowser:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /database
|
||||
- name: data
|
||||
mountPath: /srv
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
claimName: filebrowser-config
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: filebrowser-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: filebrowser
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
selector:
|
||||
app: filebrowser
|
||||
```
|
||||
|
||||
Don't forget to create the necessary PVCs for your configuration and data.
|
||||
|
||||
## Configuring FileBrowser
|
||||
|
||||
Once you have FileBrowser running, you can access it at `http://your-server:8080`. The default credentials are:
|
||||
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
**Pro tip**: Change these immediately! You can do this through the UI or by using the FileBrowser CLI.
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
You can customize FileBrowser by modifying the configuration file. Here's what my config looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 80,
|
||||
"baseURL": "",
|
||||
"address": "",
|
||||
"log": "stdout",
|
||||
"database": "/database/filebrowser.db",
|
||||
"root": "/srv",
|
||||
"auth": {
|
||||
"method": "json",
|
||||
"header": ""
|
||||
},
|
||||
"branding": {
|
||||
"name": "ArgoBox Files",
|
||||
"disableExternal": false,
|
||||
"files": "",
|
||||
"theme": "dark"
|
||||
},
|
||||
"cors": {
|
||||
"enabled": false,
|
||||
"credentials": false,
|
||||
"allowedHosts": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Securing FileBrowser
|
||||
|
||||
Security is crucial, especially when hosting a file manager. Here's how I secure my FileBrowser instance:
|
||||
|
||||
1. **Reverse Proxy**: I put FileBrowser behind a reverse proxy (Traefik) with SSL encryption.
|
||||
|
||||
2. **Authentication**: I've integrated with my Authelia setup for SSO across my services.
|
||||
|
||||
3. **User Isolation**: I create separate users with their own root directories to keep things isolated.
|
||||
|
||||
Here's a sample Traefik configuration for FileBrowser:
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.containo.us/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: filebrowser
|
||||
namespace: default
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`files.yourdomain.com`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: filebrowser
|
||||
port: 80
|
||||
middlewares:
|
||||
- name: auth-middleware
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
```
|
||||
|
||||
## My Top 5 FileBrowser Use Cases
|
||||
|
||||
1. **Home Media Management**: I organize my photos, music, and video collections.
|
||||
|
||||
2. **Document Repository**: A central place for important documents that I can access from anywhere.
|
||||
|
||||
3. **Code Snippet Library**: I keep commonly used code snippets organized by language and project.
|
||||
|
||||
4. **Backup Verification**: An easy way to browse my automated backups to verify they're working.
|
||||
|
||||
5. **Sharing Files**: When I need to share large files with friends or family, I create a temporary user with limited access.
|
||||
|
||||
## Power User Tips
|
||||
|
||||
Here are some tricks I've learned along the way:
|
||||
|
||||
- **Keyboard Shortcuts**: Press `?` in the UI to see all available shortcuts.
|
||||
- **Custom Branding**: Personalize the look and feel by setting a custom name and logo in the config.
|
||||
- **Multiple Instances**: Run multiple instances for different purposes (e.g., one for media, one for documents).
|
||||
- **Command Runner**: Use the built-in command runner to execute shell scripts on your server.
|
||||
|
||||
## Wrapping Up
|
||||
|
||||
FileBrowser has become an essential part of my home lab setup. It's lightweight, fast, and just gets the job done without unnecessary complexity. Whether you're a home lab enthusiast or just looking for a simple way to manage your files, FileBrowser is worth checking out.
|
||||
|
||||
What file management solution are you using? Let me know in the comments!
|
||||
|
||||
---
|
||||
|
||||
_This post was last updated on December 15, 2023 with the latest FileBrowser configuration options and security recommendations._
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
title: 'Getting Started with Infrastructure as Code'
|
||||
description: 'Learn the basics of Infrastructure as Code and how to start using it in your projects.'
|
||||
pubDate: '2023-11-15'
|
||||
heroImage: '/images/placeholders/infrastructure.jpg'
|
||||
categories: ['Infrastructure', 'DevOps']
|
||||
tags: ['terraform', 'infrastructure', 'cloud', 'automation']
|
||||
minutesRead: '5 min'
|
||||
---
|
||||
|
||||
# Getting Started with Infrastructure as Code
|
||||
|
||||
Infrastructure as Code (IaC) is a key DevOps practice that involves managing and provisioning infrastructure through code instead of manual processes. This approach brings the same rigor, transparency, and version control to infrastructure that developers have long applied to application code.
|
||||
|
||||
## Why Infrastructure as Code?
|
||||
|
||||
IaC offers numerous benefits for modern DevOps teams:
|
||||
|
||||
- **Consistency**: Infrastructure deployments become reproducible and standardized
|
||||
- **Version Control**: Track changes to your infrastructure just like application code
|
||||
- **Automation**: Reduce manual errors and increase deployment speed
|
||||
- **Documentation**: Your code becomes self-documenting
|
||||
- **Testing**: Infrastructure can be tested before deployment
|
||||
|
||||
## Popular IaC Tools
|
||||
|
||||
There are several powerful tools for implementing IaC:
|
||||
|
||||
1. **Terraform**: Cloud-agnostic, works with multiple providers
|
||||
2. **AWS CloudFormation**: Specific to AWS infrastructure
|
||||
3. **Azure Resource Manager**: Microsoft's native IaC solution
|
||||
4. **Google Cloud Deployment Manager**: For Google Cloud resources
|
||||
5. **Pulumi**: Uses general-purpose programming languages
|
||||
|
||||
## Basic Terraform Example
|
||||
|
||||
Here's a simple example of Terraform code that provisions an AWS EC2 instance:
|
||||
|
||||
```hcl
|
||||
provider "aws" {
|
||||
region = "us-west-2"
|
||||
}
|
||||
|
||||
resource "aws_instance" "web_server" {
|
||||
ami = "ami-0c55b159cbfafe1f0"
|
||||
instance_type = "t2.micro"
|
||||
|
||||
tags = {
|
||||
Name = "Web Server"
|
||||
Environment = "Development"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
To begin your IaC journey:
|
||||
|
||||
1. Choose a tool that fits your infrastructure needs
|
||||
2. Start small with a simple resource
|
||||
3. Learn about state management
|
||||
4. Implement CI/CD for your infrastructure code
|
||||
5. Consider using modules for reusability
|
||||
|
||||
Infrastructure as Code transforms how teams provision and manage resources, enabling more reliable, consistent deployments while reducing overhead and errors.
|
|
@ -0,0 +1,332 @@
|
|||
---
|
||||
title: "Self-Hosting Git with Gitea: Your Own GitHub Alternative"
|
||||
description: A comprehensive guide to setting up Gitea - a lightweight, self-hosted Git service that gives you full control over your code repositories.
|
||||
pubDate: 2025-04-19
|
||||
updatedDate: 2025-04-18
|
||||
category: Services
|
||||
tags:
|
||||
- gitea
|
||||
- git
|
||||
- self-hosted
|
||||
- devops
|
||||
- kubernetes
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
---
|
||||
|
||||
If you're a developer like me who values ownership and privacy, you've probably wondered if there's a way to get the convenience of GitHub or GitLab without handing over your code to a third party. Enter Gitea - a painless, self-hosted Git service written in Go that I've been using for my personal projects for the past year.
|
||||
|
||||
Let me walk you through setting up your own Gitea instance and show you why it might be the perfect addition to your development workflow.
|
||||
|
||||
## Why Gitea?
|
||||
|
||||
First, let's talk about why you might want to run your own Git server:
|
||||
|
||||
- **Complete control**: Your code, your server, your rules.
|
||||
- **Privacy**: Keep sensitive projects completely private.
|
||||
- **No limits**: Create as many private repositories as you want.
|
||||
- **Lightweight**: Gitea runs smoothly on minimal hardware (even a Raspberry Pi).
|
||||
- **GitHub-like experience**: Familiar interface with issues, pull requests, and more.
|
||||
|
||||
I've tried several self-hosted Git solutions, but Gitea strikes the perfect balance between features and simplicity. It's like the Goldilocks of Git servers - not too heavy, not too light, just right.
|
||||
|
||||
## Getting Started with Gitea
|
||||
|
||||
### Option 1: Docker Installation
|
||||
|
||||
The easiest way to get started with Gitea is using Docker. Here's a simple `docker-compose.yml` file to get you up and running:
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:latest
|
||||
container_name: gitea
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
- GITEA__database__DB_TYPE=postgres
|
||||
- GITEA__database__HOST=db:5432
|
||||
- GITEA__database__NAME=gitea
|
||||
- GITEA__database__USER=gitea
|
||||
- GITEA__database__PASSWD=gitea_password
|
||||
restart: always
|
||||
volumes:
|
||||
- ./gitea:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "222:22"
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- gitea
|
||||
|
||||
db:
|
||||
image: postgres:14
|
||||
container_name: gitea-db
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=gitea
|
||||
- POSTGRES_PASSWORD=gitea_password
|
||||
- POSTGRES_DB=gitea
|
||||
volumes:
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
networks:
|
||||
- gitea
|
||||
|
||||
networks:
|
||||
gitea:
|
||||
external: false
|
||||
```
|
||||
|
||||
Save this file and run:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Your Gitea instance will be available at `http://localhost:3000`.
|
||||
|
||||
### Option 2: Kubernetes Deployment
|
||||
|
||||
For those running a Kubernetes cluster (like me), here's a basic manifest to deploy Gitea:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: gitea-data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gitea
|
||||
labels:
|
||||
app: gitea
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: gitea
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: gitea
|
||||
spec:
|
||||
containers:
|
||||
- name: gitea
|
||||
image: gitea/gitea:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
- containerPort: 22
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
env:
|
||||
- name: USER_UID
|
||||
value: "1000"
|
||||
- name: USER_GID
|
||||
value: "1000"
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: gitea-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gitea
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
name: web
|
||||
- port: 22
|
||||
targetPort: 22
|
||||
name: ssh
|
||||
selector:
|
||||
app: gitea
|
||||
```
|
||||
|
||||
Apply it with:
|
||||
|
||||
```bash
|
||||
kubectl apply -f gitea.yaml
|
||||
```
|
||||
|
||||
## Initial Configuration
|
||||
|
||||
After installation, you'll be greeted with Gitea's setup page. Here are the settings I recommend:
|
||||
|
||||
1. **Database Settings**: If you followed the Docker Compose example, your database is already configured.
|
||||
|
||||
2. **General Settings**:
|
||||
- Set your site title (e.g., "ArgoBox Git")
|
||||
- Disable user registration unless you're hosting for multiple people
|
||||
- Enable caching to improve performance
|
||||
|
||||
3. **Admin Account**: Create your admin user with a strong password.
|
||||
|
||||
My configuration looks something like this:
|
||||
|
||||
```ini
|
||||
[server]
|
||||
DOMAIN = git.laforce.it
|
||||
SSH_DOMAIN = git.laforce.it
|
||||
ROOT_URL = https://git.laforce.it/
|
||||
DISABLE_SSH = false
|
||||
SSH_PORT = 22
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = true
|
||||
REQUIRE_SIGNIN_VIEW = true
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
```
|
||||
|
||||
## Integrating with Your Development Workflow
|
||||
|
||||
Now that Gitea is running, here's how I integrate it into my workflow:
|
||||
|
||||
### 1. Adding Your SSH Key
|
||||
|
||||
First, add your SSH key to Gitea:
|
||||
|
||||
1. Go to Settings > SSH / GPG Keys
|
||||
2. Click "Add Key"
|
||||
3. Paste your public key and give it a name
|
||||
|
||||
### 2. Creating Your First Repository
|
||||
|
||||
1. Click the "+" button in the top right
|
||||
2. Select "New Repository"
|
||||
3. Fill in the details and initialize with a README if desired
|
||||
|
||||
### 3. Working with Your Repository
|
||||
|
||||
To clone your new repository:
|
||||
|
||||
```bash
|
||||
git clone git@your-gitea-server:username/repo-name.git
|
||||
```
|
||||
|
||||
Now you can work with it just like any Git repository:
|
||||
|
||||
```bash
|
||||
cd repo-name
|
||||
echo "# My awesome project" > README.md
|
||||
git add README.md
|
||||
git commit -m "Update README"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## Advanced Gitea Features
|
||||
|
||||
Gitea isn't just a basic Git server - it has several powerful features that I use daily:
|
||||
|
||||
### CI/CD with Gitea Actions
|
||||
|
||||
Gitea recently added support for Actions, which are compatible with GitHub Actions workflows. Here's a simple example:
|
||||
|
||||
```yaml
|
||||
name: Go Build
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.20'
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
- name: Test
|
||||
run: go test -v ./...
|
||||
```
|
||||
|
||||
### Webhooks for Integration
|
||||
|
||||
I use webhooks to integrate Gitea with my deployment pipeline. Here's how to set up a simple webhook:
|
||||
|
||||
1. Navigate to your repository
|
||||
2. Go to Settings > Webhooks > Add Webhook
|
||||
3. Select "Gitea" or "Custom" depending on your needs
|
||||
4. Enter the URL of your webhook receiver
|
||||
5. Choose which events trigger the webhook
|
||||
|
||||
### Mirror Repositories
|
||||
|
||||
One of my favorite features is repository mirroring. I use this to keep a backup of important GitHub repositories:
|
||||
|
||||
1. Create a new repository
|
||||
2. Go to Settings > Mirror Settings
|
||||
3. Enter the URL of the repository you want to mirror
|
||||
4. Set the sync interval
|
||||
|
||||
## Security Considerations
|
||||
|
||||
When self-hosting any service, security is a top priority. Here's how I secure my Gitea instance:
|
||||
|
||||
1. **Reverse Proxy**: I put Gitea behind Traefik with automatic SSL certificates.
|
||||
|
||||
2. **2FA**: Enable two-factor authentication for your admin account.
|
||||
|
||||
3. **Regular Backups**: I back up both the Gitea data directory and the database daily.
|
||||
|
||||
4. **Updates**: Keep Gitea updated to the latest version to get security fixes.
|
||||
|
||||
Here's a sample Traefik configuration for Gitea:
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.containo.us/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: gitea
|
||||
namespace: default
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`git.yourdomain.com`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: gitea
|
||||
port: 3000
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
```
|
||||
|
||||
## Why I Switched from GitHub to Gitea
|
||||
|
||||
People often ask me why I bother with self-hosting when GitHub offers so much for free. Here are my reasons:
|
||||
|
||||
1. **Ownership**: No sudden changes in terms of service affecting my workflow.
|
||||
2. **Privacy**: Some projects aren't meant for public hosting.
|
||||
3. **Learning**: Managing my own services teaches me valuable skills.
|
||||
4. **Integration**: It fits perfectly with my other self-hosted services.
|
||||
5. **Performance**: Local Git operations are lightning-fast.
|
||||
|
||||
## Wrapping Up
|
||||
|
||||
Gitea has been a fantastic addition to my self-hosted infrastructure. It's reliable, lightweight, and provides all the features I need without the complexity of larger solutions like GitLab.
|
||||
|
||||
Whether you're a privacy enthusiast, a homelab tinkerer, or just someone who wants complete control over your code, Gitea is worth considering. The setup is straightforward, and the rewards are significant.
|
||||
|
||||
What about you? Are you self-hosting any of your development tools? Let me know in the comments!
|
||||
|
||||
---
|
||||
|
||||
_This post was last updated on January 18, 2024 with information about Gitea Actions and the latest configuration options._
|
|
@ -0,0 +1,169 @@
|
|||
---
|
||||
title: GitOps with Flux CD
|
||||
description: Implementing GitOps workflows on Kubernetes using Flux CD
|
||||
pubDate: 2025-04-19
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
category: devops
|
||||
tags:
|
||||
- kubernetes
|
||||
- gitops
|
||||
- flux
|
||||
- ci-cd
|
||||
- automation
|
||||
readTime: 10 min read
|
||||
---
|
||||
|
||||
# GitOps with Flux CD
|
||||
|
||||
GitOps is revolutionizing the way teams deploy and manage applications on Kubernetes. This guide will walk you through implementing a GitOps workflow using Flux CD, an open-source continuous delivery tool.
|
||||
|
||||
## What is GitOps?
|
||||
|
||||
GitOps is an operational framework that takes DevOps best practices used for application development such as version control, collaboration, compliance, and CI/CD, and applies them to infrastructure automation.
|
||||
|
||||
With GitOps:
|
||||
- Git is the single source of truth for the desired state of your infrastructure
|
||||
- Changes to the desired state are declarative and version controlled
|
||||
- Approved changes are automatically applied to your infrastructure
|
||||
|
||||
## Why Flux CD?
|
||||
|
||||
Flux CD is a GitOps tool that ensures that your Kubernetes cluster matches the desired state specified in a Git repository. Key features include:
|
||||
|
||||
- Automated sync between your Git repository and cluster state
|
||||
- Support for Kustomize, Helm, and plain Kubernetes manifests
|
||||
- Multi-tenancy via RBAC
|
||||
- Strong security practices, including image verification
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A Kubernetes cluster (K3s, Kind, or any other distribution)
|
||||
- kubectl configured to access your cluster
|
||||
- A GitHub (or GitLab/Bitbucket) account and repository
|
||||
|
||||
### Installing Flux
|
||||
|
||||
1. Install the Flux CLI:
|
||||
|
||||
```bash
|
||||
curl -s https://fluxcd.io/install.sh | sudo bash
|
||||
```
|
||||
|
||||
2. Export your GitHub personal access token:
|
||||
|
||||
```bash
|
||||
export GITHUB_TOKEN=<your-token>
|
||||
```
|
||||
|
||||
3. Bootstrap Flux:
|
||||
|
||||
```bash
|
||||
flux bootstrap github \
|
||||
--owner=<your-github-username> \
|
||||
--repository=<repository-name> \
|
||||
--path=clusters/my-cluster \
|
||||
--personal
|
||||
```
|
||||
|
||||
## Setting Up Your First Application
|
||||
|
||||
1. Create a basic directory structure in your Git repository:
|
||||
|
||||
```
|
||||
└── clusters/
|
||||
└── my-cluster/
|
||||
├── flux-system/ # Created by bootstrap
|
||||
└── apps/
|
||||
└── podinfo/
|
||||
├── namespace.yaml
|
||||
├── deployment.yaml
|
||||
└── service.yaml
|
||||
```
|
||||
|
||||
2. Create a Flux Kustomization to deploy your app:
|
||||
|
||||
```yaml
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: podinfo
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 5m0s
|
||||
path: ./clusters/my-cluster/apps/podinfo
|
||||
prune: true
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
```
|
||||
|
||||
3. Commit and push your changes, and Flux will automatically deploy your application!
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Automated Image Updates
|
||||
|
||||
Flux can automatically update your deployments when new images are available:
|
||||
|
||||
```yaml
|
||||
apiVersion: image.toolkit.fluxcd.io/v1beta1
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: podinfo
|
||||
namespace: flux-system
|
||||
spec:
|
||||
image: ghcr.io/stefanprodan/podinfo
|
||||
interval: 1m0s
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1beta1
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: podinfo
|
||||
namespace: flux-system
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: podinfo
|
||||
policy:
|
||||
semver:
|
||||
range: 6.x.x
|
||||
```
|
||||
|
||||
### Working with Helm Charts
|
||||
|
||||
Flux makes it easy to manage Helm releases:
|
||||
|
||||
```yaml
|
||||
apiVersion: source.toolkit.fluxcd.io/v1beta2
|
||||
kind: HelmRepository
|
||||
metadata:
|
||||
name: bitnami
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 30m
|
||||
url: https://charts.bitnami.com/bitnami
|
||||
---
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2beta1
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 5m
|
||||
chart:
|
||||
spec:
|
||||
chart: redis
|
||||
version: "16.x"
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: bitnami
|
||||
values:
|
||||
architecture: standalone
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Flux CD provides a powerful, secure, and flexible platform for implementing GitOps workflows. By following this guide, you'll be well on your way to managing your Kubernetes infrastructure using GitOps principles.
|
||||
|
||||
Stay tuned for more advanced GitOps patterns and best practices!
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
title: "Setting Up a K3s Kubernetes Cluster"
|
||||
description: "A comprehensive guide to setting up a K3s cluster for your home lab or edge environment, with high availability and persistent storage."
|
||||
pubDate: "2023-11-15"
|
||||
heroImage: "/blog/images/posts/k3installation.png"
|
||||
category: "Kubernetes"
|
||||
tags: ["kubernetes", "k3s", "homelab", "infrastructure"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Setting Up a K3s Kubernetes Cluster
|
||||
|
||||
K3s is a lightweight, certified Kubernetes distribution designed for resource-constrained environments like edge devices, IoT, and home labs. This guide will walk you through setting up a production-ready K3s cluster.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- At least two machines (physical or virtual) for high availability
|
||||
- Ubuntu 20.04 LTS or newer
|
||||
- At least 2GB RAM per node
|
||||
- 20GB+ storage per node
|
||||
- Network connectivity between all nodes
|
||||
|
||||
## Installing the Server Node
|
||||
|
||||
First, let's install the primary server node:
|
||||
|
||||
```bash
|
||||
curl -sfL https://get.k3s.io | sh -s - server \
|
||||
--cluster-init \
|
||||
--tls-san=server-ip-or-hostname \
|
||||
--disable traefik \
|
||||
--disable servicelb
|
||||
```
|
||||
|
||||
This initializes the cluster with:
|
||||
- HA enabled with `--cluster-init`
|
||||
- Custom TLS SAN for API server
|
||||
- Disabled default traefik ingress (we'll use Nginx)
|
||||
- Disabled default ServiceLB (we'll use MetalLB)
|
||||
|
||||
## Installing Agent Nodes
|
||||
|
||||
On each worker node, run:
|
||||
|
||||
```bash
|
||||
curl -sfL https://get.k3s.io | K3S_URL=https://server-ip:6443 K3S_TOKEN=node-token sh -
|
||||
```
|
||||
|
||||
Replace `server-ip` with your server's IP and get the token from `/var/lib/rancher/k3s/server/node-token` on the server.
|
||||
|
||||
## Adding High Availability
|
||||
|
||||
For HA, add additional server nodes:
|
||||
|
||||
```bash
|
||||
curl -sfL https://get.k3s.io | sh -s - server \
|
||||
--server https://first-server-ip:6443 \
|
||||
--token node-token \
|
||||
--tls-san=this-server-ip \
|
||||
--disable traefik \
|
||||
--disable servicelb
|
||||
```
|
||||
|
||||
## Setting Up Persistent Storage
|
||||
|
||||
We'll use Longhorn for distributed storage:
|
||||
|
||||
```bash
|
||||
kubectl apply -f https://raw.githubusercontent.com/longhorn/longhorn/master/deploy/longhorn.yaml
|
||||
```
|
||||
|
||||
Longhorn provides replicated block storage across your nodes for high availability.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After setting up your cluster, you might want to:
|
||||
|
||||
1. Install a proper ingress controller (Nginx, Traefik)
|
||||
2. Set up a load balancer (MetalLB)
|
||||
3. Configure monitoring with Prometheus and Grafana
|
||||
4. Implement GitOps with Flux or ArgoCD
|
||||
|
||||
Stay tuned for detailed guides on each of these topics!
|
||||
|
||||
---
|
||||
|
||||
This guide provides a starting point for your journey with K3s Kubernetes. In future posts, we'll dive deeper into advanced configurations.
|
|
@ -0,0 +1,90 @@
|
|||
---
|
||||
title: K3s Installation Guide
|
||||
description: A comprehensive guide to installing and configuring K3s for your home lab
|
||||
pubDate: 2025-04-19
|
||||
heroImage: /images/posts/k3installation.png
|
||||
category: kubernetes
|
||||
tags:
|
||||
- kubernetes
|
||||
- k3s
|
||||
- homelab
|
||||
- tutorial
|
||||
readTime: 8 min read
|
||||
---
|
||||
|
||||
# K3s Installation Guide
|
||||
|
||||
K3s is a lightweight Kubernetes distribution designed for resource-constrained environments, perfect for home labs and edge computing. This guide will walk you through the installation process from start to finish.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A machine running Linux (Ubuntu 20.04+ recommended)
|
||||
- Minimum 1GB RAM (2GB+ recommended for multi-workload clusters)
|
||||
- 20GB+ of disk space
|
||||
- Root access or sudo privileges
|
||||
|
||||
## Basic Installation
|
||||
|
||||
The simplest way to install K3s is using the installation script:
|
||||
|
||||
```bash
|
||||
curl -sfL https://get.k3s.io | sh -
|
||||
```
|
||||
|
||||
This will install K3s as a server and start the service. To verify it's running:
|
||||
|
||||
```bash
|
||||
sudo k3s kubectl get node
|
||||
```
|
||||
|
||||
## Advanced Installation Options
|
||||
|
||||
### Master Node with Custom Options
|
||||
|
||||
For more control, you can use environment variables:
|
||||
|
||||
```bash
|
||||
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable=traefik" sh -
|
||||
```
|
||||
|
||||
### Adding Worker Nodes
|
||||
|
||||
1. On your master node, get the node token:
|
||||
|
||||
```bash
|
||||
sudo cat /var/lib/rancher/k3s/server/node-token
|
||||
```
|
||||
|
||||
2. On each worker node, run:
|
||||
|
||||
```bash
|
||||
curl -sfL https://get.k3s.io | K3S_URL=https://<master-node-ip>:6443 K3S_TOKEN=<node-token> sh -
|
||||
```
|
||||
|
||||
## Accessing the Cluster
|
||||
|
||||
After installation, the kubeconfig file is written to `/etc/rancher/k3s/k3s.yaml`. To use kubectl externally:
|
||||
|
||||
1. Copy this file to your local machine
|
||||
2. Set the KUBECONFIG environment variable:
|
||||
|
||||
```bash
|
||||
export KUBECONFIG=/path/to/k3s.yaml
|
||||
```
|
||||
|
||||
## Uninstalling K3s
|
||||
|
||||
If you need to uninstall:
|
||||
|
||||
- On server nodes: `/usr/local/bin/k3s-uninstall.sh`
|
||||
- On agent nodes: `/usr/local/bin/k3s-agent-uninstall.sh`
|
||||
|
||||
## Next Steps
|
||||
|
||||
Now that you have K3s running, you might want to:
|
||||
|
||||
- Deploy your first application
|
||||
- Set up persistent storage
|
||||
- Configure ingress for external access
|
||||
|
||||
Stay tuned for more guides on these topics!
|
|
@ -0,0 +1,226 @@
|
|||
---
|
||||
title: "Monitoring Your Kubernetes Cluster with Prometheus and Grafana"
|
||||
description: "A comprehensive guide to setting up a robust monitoring solution for your Kubernetes cluster using Prometheus and Grafana."
|
||||
pubDate: "2023-09-25"
|
||||
heroImage: "/blog/images/posts/prometheus-dashboard.svg"
|
||||
category: "Monitoring"
|
||||
tags: ["kubernetes", "prometheus", "grafana", "monitoring", "observability"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Monitoring Your Kubernetes Cluster with Prometheus and Grafana
|
||||
|
||||
In today's complex Kubernetes environments, having a robust monitoring solution is not just nice to have—it's essential. This guide will walk you through setting up Prometheus and Grafana to monitor your K3s or any other Kubernetes cluster.
|
||||
|
||||
## Why Prometheus and Grafana?
|
||||
|
||||
- **Prometheus**: An open-source systems monitoring and alerting toolkit that collects and stores metrics as time series data
|
||||
- **Grafana**: A multi-platform open-source analytics and interactive visualization web application that provides charts, graphs, and alerts when connected to supported data sources
|
||||
|
||||
Together, they form a powerful monitoring stack that provides insights into your cluster's health and performance.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before we begin, ensure you have:
|
||||
|
||||
- A running Kubernetes cluster (this guide uses K3s)
|
||||
- `kubectl` configured to communicate with your cluster
|
||||
- Helm 3 installed
|
||||
|
||||
## Installation using Helm
|
||||
|
||||
The easiest way to install Prometheus and Grafana is using the kube-prometheus-stack Helm chart, which includes:
|
||||
|
||||
- Prometheus Operator
|
||||
- Prometheus instance
|
||||
- Alertmanager
|
||||
- Grafana
|
||||
- Node Exporter
|
||||
- Kube State Metrics
|
||||
|
||||
Let's create a namespace and install the stack:
|
||||
|
||||
```bash
|
||||
# Create a dedicated namespace
|
||||
kubectl create namespace monitoring
|
||||
|
||||
# Add the Prometheus community Helm repository
|
||||
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
|
||||
helm repo update
|
||||
|
||||
# Install the kube-prometheus-stack
|
||||
helm install prometheus prometheus-community/kube-prometheus-stack \
|
||||
--namespace monitoring \
|
||||
--set grafana.adminPassword=your-strong-password
|
||||
```
|
||||
|
||||
Replace `your-strong-password` with a secure password for the Grafana admin user.
|
||||
|
||||
## Accessing the Dashboards
|
||||
|
||||
By default, the services are not exposed outside the cluster. To access them, you can use port-forwarding:
|
||||
|
||||
### Grafana
|
||||
|
||||
```bash
|
||||
kubectl port-forward -n monitoring svc/prometheus-grafana 3000:80
|
||||
```
|
||||
|
||||
Then access Grafana at http://localhost:3000 with username `admin` and the password you specified during installation.
|
||||
|
||||
### Prometheus
|
||||
|
||||
```bash
|
||||
kubectl port-forward -n monitoring svc/prometheus-operated 9090:9090
|
||||
```
|
||||
|
||||
Access the Prometheus UI at http://localhost:9090.
|
||||
|
||||
## Setting Up Ingress (Optional)
|
||||
|
||||
For production environments, you'll want to set up proper ingress. Here's an example using Nginx ingress:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: grafana-ingress
|
||||
namespace: monitoring
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
spec:
|
||||
rules:
|
||||
- host: grafana.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: prometheus-grafana
|
||||
port:
|
||||
number: 80
|
||||
tls:
|
||||
- hosts:
|
||||
- grafana.example.com
|
||||
secretName: grafana-tls
|
||||
```
|
||||
|
||||
Apply this with `kubectl apply -f ingress.yaml` after replacing `grafana.example.com` with your domain.
|
||||
|
||||
## Important Dashboards for Kubernetes
|
||||
|
||||
Grafana comes with several pre-installed dashboards, but here are some essential ones you should import:
|
||||
|
||||
1. **Kubernetes Cluster Overview** (ID: 10856)
|
||||
2. **Node Exporter Full** (ID: 1860)
|
||||
3. **Kubernetes Resource Requests** (ID: 13770)
|
||||
|
||||
To import a dashboard:
|
||||
|
||||
1. Go to Grafana UI
|
||||
2. Click on "+" icon in the sidebar
|
||||
3. Select "Import"
|
||||
4. Enter the dashboard ID
|
||||
5. Click "Load"
|
||||
6. Select the Prometheus data source
|
||||
7. Click "Import"
|
||||
|
||||
## Setting Up Alerts
|
||||
|
||||
Let's set up a basic alert for node CPU usage:
|
||||
|
||||
1. In Grafana, go to Alerting > Alert Rules
|
||||
2. Click "New Alert Rule"
|
||||
3. Configure the query: `instance:node_cpu_utilisation:rate5m > 0.8`
|
||||
4. Set the condition to: `IS ABOVE 0.8`
|
||||
5. Set evaluation interval: `1m`
|
||||
6. Set "For": `5m` (alert will fire if condition is true for 5 minutes)
|
||||
7. Add labels and annotations as needed
|
||||
8. Save the rule
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Resource Limits**: Set appropriate resource requests and limits for Prometheus and Grafana
|
||||
2. **Retention Period**: Configure the retention period based on your storage capacity
|
||||
3. **Persistent Storage**: Use persistent volumes for Prometheus data
|
||||
4. **Federation**: For large clusters, consider Prometheus federation
|
||||
5. **Custom Metrics**: Set up custom metrics for your applications using client libraries
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
For a production environment, you'll want to customize the Helm values. Create a `values.yaml` file:
|
||||
|
||||
```yaml
|
||||
prometheus:
|
||||
prometheusSpec:
|
||||
retention: 15d
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: 500m
|
||||
limits:
|
||||
memory: 4Gi
|
||||
cpu: 1000m
|
||||
storageSpec:
|
||||
volumeClaimTemplate:
|
||||
spec:
|
||||
storageClassName: standard
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 50Gi
|
||||
|
||||
grafana:
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 10Gi
|
||||
resources:
|
||||
requests:
|
||||
memory: 256Mi
|
||||
cpu: 100m
|
||||
limits:
|
||||
memory: 512Mi
|
||||
cpu: 200m
|
||||
```
|
||||
|
||||
Then update your Helm release:
|
||||
|
||||
```bash
|
||||
helm upgrade prometheus prometheus-community/kube-prometheus-stack \
|
||||
--namespace monitoring \
|
||||
-f values.yaml
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Insufficient Resources**: If pods are crashing, check if they have enough resources allocated
|
||||
2. **Connectivity Issues**: Ensure services can communicate with each other
|
||||
3. **Data Retention**: If Prometheus is losing data, check the storage configuration
|
||||
4. **Target Scraping**: If metrics aren't appearing, check Prometheus targets status
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
# Check pod status
|
||||
kubectl get pods -n monitoring
|
||||
|
||||
# Check Prometheus targets
|
||||
kubectl port-forward -n monitoring svc/prometheus-operated 9090:9090
|
||||
# Then visit http://localhost:9090/targets
|
||||
|
||||
# View Prometheus logs
|
||||
kubectl logs -n monitoring deploy/prometheus-operator
|
||||
|
||||
# View Grafana logs
|
||||
kubectl logs -n monitoring deploy/prometheus-grafana
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
You now have a robust monitoring solution for your Kubernetes cluster. With Prometheus collecting metrics and Grafana visualizing them, you'll have deep insights into your cluster's performance and health.
|
||||
|
||||
In future articles, we'll explore more advanced topics like custom exporters, alert integrations, and high availability setups for your monitoring stack.
|
|
@ -0,0 +1,191 @@
|
|||
---
|
||||
title: Complete Proxmox VE Setup Guide
|
||||
description: A step-by-step guide to setting up Proxmox VE for your home lab virtualization needs
|
||||
pubDate: 2025-04-19
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
category: infrastructure
|
||||
tags:
|
||||
- proxmox
|
||||
- virtualization
|
||||
- homelab
|
||||
- infrastructure
|
||||
readTime: 12 min read
|
||||
---
|
||||
|
||||
# Complete Proxmox VE Setup Guide
|
||||
|
||||
Proxmox Virtual Environment (VE) is a complete open-source server management platform for enterprise virtualization. It tightly integrates KVM hypervisor and LXC containers, software-defined storage and networking functionality, on a single platform. This guide will walk you through installing and configuring Proxmox VE for your home lab.
|
||||
|
||||
## Why Choose Proxmox VE?
|
||||
|
||||
- **Open Source**: Free to use with optional paid enterprise support
|
||||
- **Full-featured**: Combines KVM hypervisor and LXC containers
|
||||
- **Web Interface**: Easy-to-use management interface
|
||||
- **Clustering**: Built-in high availability features
|
||||
- **Storage Flexibility**: Support for local, SAN, NFS, Ceph, and more
|
||||
|
||||
## Hardware Requirements
|
||||
|
||||
- 64-bit CPU with virtualization extensions (Intel VT-x/AMD-V)
|
||||
- At least 2GB RAM (8GB+ recommended)
|
||||
- Hard drive for OS installation (SSD recommended)
|
||||
- Additional storage for VMs and containers
|
||||
- Network interface card
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Prepare for Installation
|
||||
|
||||
1. Download the Proxmox VE ISO from [proxmox.com/downloads](https://www.proxmox.com/downloads)
|
||||
2. Create a bootable USB drive using tools like Rufus, Etcher, or dd
|
||||
3. Ensure virtualization is enabled in your BIOS/UEFI
|
||||
|
||||
### 2. Install Proxmox VE
|
||||
|
||||
1. Boot from the USB drive
|
||||
2. Select "Install Proxmox VE"
|
||||
3. Accept the EULA
|
||||
4. Select the target hard drive (this will erase all data on the drive)
|
||||
5. Configure country, time zone, and keyboard layout
|
||||
6. Set a strong root password and provide an email address
|
||||
7. Configure network settings:
|
||||
- Enter a hostname (FQDN format: proxmox.yourdomain.local)
|
||||
- IP address, netmask, gateway
|
||||
- DNS server
|
||||
8. Review the settings and confirm to start the installation
|
||||
9. Once completed, remove the USB drive and reboot
|
||||
|
||||
### 3. Initial Configuration
|
||||
|
||||
Access the web interface by navigating to `https://<your-proxmox-ip>:8006` in your browser. Log in with:
|
||||
- Username: root
|
||||
- Password: (the one you set during installation)
|
||||
|
||||
## Post-Installation Tasks
|
||||
|
||||
### 1. Update Proxmox VE
|
||||
|
||||
```bash
|
||||
apt update
|
||||
apt dist-upgrade
|
||||
```
|
||||
|
||||
### 2. Remove Subscription Notice (Optional)
|
||||
|
||||
For home lab use, you can remove the subscription notice:
|
||||
|
||||
```bash
|
||||
echo "DPkg::Post-Invoke { \"dpkg -V proxmox-widget-toolkit | grep -q '/proxmoxlib\.js$'; if [ \$? -eq 1 ]; then { echo 'Removing subscription nag from UI...'; sed -i '/.*data\.status.*subscription.*/d' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js; }; fi\"; };" > /etc/apt/apt.conf.d/no-subscription-warning
|
||||
```
|
||||
|
||||
### 3. Configure Storage
|
||||
|
||||
#### Local Storage
|
||||
|
||||
By default, Proxmox VE creates several storage locations:
|
||||
|
||||
- **local**: For ISO images, container templates, and snippets
|
||||
- **local-lvm**: For VM disk images
|
||||
|
||||
To add a new storage, go to Datacenter > Storage > Add:
|
||||
|
||||
- For local directories: Select "Directory"
|
||||
- For network storage: Select "NFS" or "CIFS"
|
||||
- For block storage: Select "LVM", "LVM-Thin", or "ZFS"
|
||||
|
||||
#### ZFS Storage Pool (Recommended)
|
||||
|
||||
ZFS offers excellent performance and data protection:
|
||||
|
||||
```bash
|
||||
# Create a ZFS pool using available disks
|
||||
zpool create -f rpool /dev/sdb /dev/sdc
|
||||
|
||||
# Add the pool to Proxmox
|
||||
pvesm add zfspool rpool -pool rpool
|
||||
```
|
||||
|
||||
### 4. Set Up Networking
|
||||
|
||||
#### Network Bridges
|
||||
|
||||
Proxmox VE creates a default bridge (vmbr0) during installation. To add more:
|
||||
|
||||
1. Go to Node > Network > Create > Linux Bridge
|
||||
2. Configure the bridge:
|
||||
- Name: vmbr1
|
||||
- IP address/CIDR: 192.168.1.1/24 (or leave empty for unmanaged bridge)
|
||||
- Bridge ports: (physical interface, e.g., eth1)
|
||||
|
||||
#### VLAN Configuration
|
||||
|
||||
For VLAN support:
|
||||
|
||||
1. Ensure the bridge has VLAN awareness enabled
|
||||
2. In VM network settings, specify VLAN tags
|
||||
|
||||
## Creating Virtual Machines and Containers
|
||||
|
||||
### Virtual Machines (KVM)
|
||||
|
||||
1. Go to Create VM
|
||||
2. Fill out the wizard:
|
||||
- General: Name, Resource Pool
|
||||
- OS: ISO image, type, and version
|
||||
- System: BIOS/UEFI, Machine type
|
||||
- Disks: Size, format, storage location
|
||||
- CPU: Cores, type
|
||||
- Memory: RAM size
|
||||
- Network: Bridge, model
|
||||
3. Click Finish to create the VM
|
||||
|
||||
### Containers (LXC)
|
||||
|
||||
1. Go to Create CT
|
||||
2. Fill out the wizard:
|
||||
- General: Hostname, Password
|
||||
- Template: Select from available templates
|
||||
- Disks: Size, storage location
|
||||
- CPU: Cores
|
||||
- Memory: RAM size
|
||||
- Network: IP address, bridge
|
||||
- DNS: DNS servers
|
||||
3. Click Finish to create the container
|
||||
|
||||
## Backup Configuration
|
||||
|
||||
### Setting Up Backups
|
||||
|
||||
1. Go to Datacenter > Backup
|
||||
2. Add a new backup job:
|
||||
- Select storage location
|
||||
- Set schedule (daily, weekly, etc.)
|
||||
- Choose VMs/containers to back up
|
||||
- Configure compression and mode
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### CPU
|
||||
|
||||
For VMs that need consistent performance:
|
||||
|
||||
- Set CPU type to "host" for best performance
|
||||
- Reserve CPU cores for critical VMs
|
||||
- Use CPU pinning for high-performance workloads
|
||||
|
||||
### Memory
|
||||
|
||||
- Enable KSM (Kernel Same-page Merging) for better memory usage
|
||||
- Set appropriate memory ballooning for VMs
|
||||
|
||||
### Storage
|
||||
|
||||
- Use SSDs for VM disks when possible
|
||||
- Enable write-back caching for improved performance
|
||||
- Consider ZFS for important data with appropriate RAM allocation
|
||||
|
||||
## Conclusion
|
||||
|
||||
Proxmox VE is a powerful, flexible virtualization platform perfect for home labs. With its combination of virtual machines and containers, you can build a versatile lab environment for testing, development, and running production services.
|
||||
|
||||
After following this guide, you should have a fully functional Proxmox VE server ready to host your virtual infrastructure. In future articles, we'll explore advanced topics like clustering, high availability, and integration with other infrastructure tools.
|
|
@ -0,0 +1,416 @@
|
|||
---
|
||||
title: Building a Digital Garden with Quartz, Obsidian, and Astro
|
||||
description: How to transform your Obsidian notes into a beautiful, connected public digital garden using Quartz and Astro
|
||||
pubDate: 2025-04-18
|
||||
updatedDate: 2025-04-19
|
||||
category: Services
|
||||
tags:
|
||||
- quartz
|
||||
- obsidian
|
||||
- digital-garden
|
||||
- knowledge-management
|
||||
- astro
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
---
|
||||
|
||||
I've been taking digital notes for decades now. From simple `.txt` files to OneNote, Evernote, Notion, and now Obsidian. But for years, I've been wrestling with a question: how do I share my knowledge with others in a way that preserves the connections between ideas?
|
||||
|
||||
Enter [Quartz](https://quartz.jzhao.xyz/) - an open-source static site generator designed specifically for transforming Obsidian vaults into interconnected digital gardens. I've been using it with Astro to power this very blog, and today I want to show you how you can do the same.
|
||||
|
||||
## What is a Digital Garden?
|
||||
|
||||
Before we dive into the technical stuff, let's talk about what a digital garden actually is. Unlike traditional blogs organized chronologically, a digital garden is more like... well, a garden! It's a collection of interconnected notes that grow over time.
|
||||
|
||||
Think of each note as a plant. Some are seedlings (early ideas), some are in full bloom (well-developed thoughts), and others might be somewhere in between. The beauty of a digital garden is that it evolves organically, with connections forming between ideas just as they do in our brains.
|
||||
|
||||
## Why Quartz + Obsidian + Astro?
|
||||
|
||||
I settled on this stack after trying numerous solutions, and here's why:
|
||||
|
||||
- **Obsidian**: Provides a fantastic editing experience with bi-directional linking, graph views, and markdown support.
|
||||
- **Quartz**: Transforms Obsidian vaults into interconnected websites with minimal configuration.
|
||||
- **Astro**: Adds flexibility for custom layouts, components, and integrations not available in Quartz alone.
|
||||
|
||||
It's a match made in heaven - I get the best note-taking experience with Obsidian, the connection-preserving features of Quartz, and the full power of a modern web framework with Astro.
|
||||
|
||||
## Setting Up Your Digital Garden
|
||||
|
||||
Let's walk through how to set this up step by step.
|
||||
|
||||
### Step 1: Install Obsidian and Create a Vault
|
||||
|
||||
If you haven't already, [download Obsidian](https://obsidian.md/) and create a new vault for your digital garden content. I recommend having a separate vault for public content to keep it clean.
|
||||
|
||||
### Step 2: Set Up Quartz
|
||||
|
||||
Quartz is essentially a template for your Obsidian content. Here's how to get it running:
|
||||
|
||||
```bash
|
||||
# Clone the Quartz repository
|
||||
git clone https://github.com/jackyzha0/quartz.git
|
||||
cd quartz
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Copy your Obsidian vault content to the content folder
|
||||
# (or symlink it, which is what I do)
|
||||
ln -s /path/to/your/obsidian/vault content
|
||||
```
|
||||
|
||||
Once installed, you can customize your Quartz configuration in the `quartz.config.ts` file. Here's a snippet of mine:
|
||||
|
||||
```typescript
|
||||
// quartz.config.ts
|
||||
const config: QuartzConfig = {
|
||||
configuration: {
|
||||
pageTitle: "ArgoBox Digital Garden",
|
||||
enableSPA: true,
|
||||
enablePopovers: true,
|
||||
analytics: {
|
||||
provider: "plausible",
|
||||
},
|
||||
baseUrl: "blog.ArgoBox.com",
|
||||
ignorePatterns: ["private", "templates", ".obsidian"],
|
||||
theme: {
|
||||
typography: {
|
||||
header: "Space Grotesk",
|
||||
body: "Space Grotesk",
|
||||
code: "JetBrains Mono",
|
||||
},
|
||||
colors: {
|
||||
lightMode: {
|
||||
light: "#fafafa",
|
||||
lightgray: "#e5e5e5",
|
||||
gray: "#b8b8b8",
|
||||
darkgray: "#4e4e4e",
|
||||
dark: "#2b2b2b",
|
||||
secondary: "#284b63",
|
||||
tertiary: "#84a59d",
|
||||
highlight: "rgba(143, 159, 169, 0.15)",
|
||||
},
|
||||
darkMode: {
|
||||
light: "#050a18",
|
||||
lightgray: "#0f172a",
|
||||
gray: "#1e293b",
|
||||
darkgray: "#94a3b8",
|
||||
dark: "#e2e8f0",
|
||||
secondary: "#06b6d4",
|
||||
tertiary: "#3b82f6",
|
||||
highlight: "rgba(6, 182, 212, 0.15)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
transformers: [
|
||||
// plugins here
|
||||
],
|
||||
filters: [
|
||||
// filters here
|
||||
],
|
||||
emitters: [
|
||||
// emitters here
|
||||
],
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Integrating with Astro
|
||||
|
||||
Now, where Quartz ends and Astro begins is where the magic really happens. Here's how I integrated them:
|
||||
|
||||
1. Create a new Astro project:
|
||||
|
||||
```bash
|
||||
npm create astro@latest my-digital-garden
|
||||
cd my-digital-garden
|
||||
```
|
||||
|
||||
2. Set up your Astro project structure:
|
||||
|
||||
```
|
||||
my-digital-garden/
|
||||
├── public/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ ├── layouts/
|
||||
│ ├── pages/
|
||||
│ │ └── index.astro
|
||||
│ └── content/ (this will point to your Quartz content)
|
||||
├── astro.config.mjs
|
||||
└── package.json
|
||||
```
|
||||
|
||||
3. Configure Astro to work with Quartz's output:
|
||||
|
||||
```typescript
|
||||
// astro.config.mjs
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
// Your integrations here
|
||||
],
|
||||
markdown: {
|
||||
remarkPlugins: [
|
||||
// The same remark plugins used in Quartz
|
||||
],
|
||||
rehypePlugins: [
|
||||
// The same rehype plugins used in Quartz
|
||||
]
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
4. Create a component for the Obsidian graph view (similar to what I use on this blog):
|
||||
|
||||
```astro
|
||||
---
|
||||
// Graph.astro
|
||||
---
|
||||
|
||||
<div class="graph-container">
|
||||
<div class="graph-visualization">
|
||||
<div class="graph-overlay"></div>
|
||||
<div class="graph-nodes" id="obsidian-graph"></div>
|
||||
</div>
|
||||
<div class="graph-caption">Interactive visualization of interconnected notes</div>
|
||||
</div>
|
||||
|
||||
<script src="/blog/scripts/neural-network.js" defer></script>
|
||||
|
||||
<style>
|
||||
.graph-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
position: relative;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
}
|
||||
|
||||
.graph-visualization {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.graph-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
transparent 0%,
|
||||
rgba(15, 23, 42, 0.5) 100%
|
||||
);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.graph-nodes {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.graph-caption {
|
||||
position: absolute;
|
||||
bottom: 0.75rem;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: rgba(148, 163, 184, 0.8);
|
||||
font-size: 0.875rem;
|
||||
z-index: 3;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Writing Content for Your Digital Garden
|
||||
|
||||
Now that you have the technical setup, let's talk about how to structure your content. Here's how I approach it:
|
||||
|
||||
### 1. Use Meaningful Frontmatter
|
||||
|
||||
Each note should have frontmatter that helps organize and categorize it:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Building a Digital Garden"
|
||||
description: "How to create a connected knowledge base with Obsidian and Quartz"
|
||||
pubDate: 2023-11-05
|
||||
updatedDate: 2024-02-10
|
||||
category: "Knowledge Management"
|
||||
tags: ["digital-garden", "obsidian", "quartz", "notes"]
|
||||
---
|
||||
```
|
||||
|
||||
### 2. Create Meaningful Connections
|
||||
|
||||
The power of a digital garden comes from connections. Use Obsidian's `[[wiki-links]]` liberally to connect related concepts:
|
||||
|
||||
```markdown
|
||||
I'm using the [[quartz-digital-garden|Quartz framework]] to power my digital garden. It works well with [[obsidian-setup|my Obsidian workflow]].
|
||||
```
|
||||
|
||||
### 3. Use Consistent Structure for Your Notes
|
||||
|
||||
I follow a template for most of my notes to maintain consistency:
|
||||
|
||||
- Brief introduction/definition
|
||||
- Why it matters
|
||||
- Key concepts
|
||||
- Examples or code snippets
|
||||
- References to related notes
|
||||
- External resources
|
||||
|
||||
### 4. Leverage Obsidian Features
|
||||
|
||||
Make use of Obsidian's unique features that Quartz preserves:
|
||||
|
||||
- **Callouts** for highlighting important information
|
||||
- **Dataview** for creating dynamic content (if using the Dataview plugin)
|
||||
- **Graphs** to visualize connections
|
||||
|
||||
## Deploying Your Digital Garden
|
||||
|
||||
Here's how I deploy my digital garden to Cloudflare Pages:
|
||||
|
||||
1. **Build Configuration**:
|
||||
|
||||
```javascript
|
||||
// Build command
|
||||
astro build
|
||||
|
||||
// Output directory
|
||||
dist
|
||||
```
|
||||
|
||||
2. **Automated Deployment from Git**:
|
||||
|
||||
I have a GitHub action that publishes my content whenever I push changes:
|
||||
|
||||
```yaml
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: digital-garden
|
||||
directory: dist
|
||||
```
|
||||
|
||||
## My Digital Garden Workflow
|
||||
|
||||
Here's my actual workflow for maintaining my digital garden:
|
||||
|
||||
1. **Daily note-taking** in Obsidian (private vault)
|
||||
2. **Weekly review** where I refine notes and connections
|
||||
3. **Publishing prep** where I move polished notes to my public vault
|
||||
4. **Git commit and push** which triggers the deployment
|
||||
|
||||
The beauty of this system is that my private thinking and public sharing use the same tools and formats, reducing friction between capturing thoughts and sharing them.
|
||||
|
||||
## Adding Interactive Elements
|
||||
|
||||
One of my favorite parts of using Astro with Quartz is that I can add interactive elements to my digital garden. For example, the neural graph visualization you see on this blog:
|
||||
|
||||
```javascript
|
||||
// Simplified version of the neural network graph code
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const container = document.querySelector('.graph-nodes');
|
||||
if (!container) return;
|
||||
|
||||
// Configuration
|
||||
const CONNECTION_DISTANCE = 30;
|
||||
const MIN_NODE_SIZE = 4;
|
||||
const MAX_NODE_SIZE = 12;
|
||||
|
||||
// Fetch blog data from the same origin
|
||||
async function fetchQuartzData() {
|
||||
// In production, fetch from your actual API
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
id: 'digital-garden',
|
||||
title: 'Digital Garden',
|
||||
tags: ['knowledge-management', 'notes'],
|
||||
category: 'concept'
|
||||
},
|
||||
// More nodes here
|
||||
],
|
||||
links: [
|
||||
{ source: 'digital-garden', target: 'obsidian' },
|
||||
// More links here
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Create the visualization
|
||||
fetchQuartzData().then(graphData => {
|
||||
// Create nodes and connections
|
||||
// (implementation details omitted for brevity)
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Tips for a Successful Digital Garden
|
||||
|
||||
After maintaining my digital garden for over a year, here are my top tips:
|
||||
|
||||
1. **Start small** - Begin with a few well-connected notes rather than trying to publish everything at once.
|
||||
|
||||
2. **Focus on connections** - The value is in the links between notes, not just the notes themselves.
|
||||
|
||||
3. **Embrace imperfection** - Digital gardens are meant to grow and evolve; they're never "finished."
|
||||
|
||||
4. **Build in public** - Share your process and learnings as you go.
|
||||
|
||||
5. **Use consistent formatting** - Makes it easier for readers to navigate your garden.
|
||||
|
||||
## The Impact of My Digital Garden
|
||||
|
||||
Since starting my digital garden, I've experienced several unexpected benefits:
|
||||
|
||||
- **Clearer thinking** - Writing for an audience forces me to clarify my thoughts.
|
||||
- **Unexpected connections** - I've discovered relationships between ideas I hadn't noticed before.
|
||||
- **Community building** - I've connected with others interested in the same topics.
|
||||
- **Learning accountability** - Publishing regularly motivates me to keep learning.
|
||||
|
||||
## Wrapping Up
|
||||
|
||||
Building a digital garden with Quartz, Obsidian, and Astro has transformed how I learn and share knowledge. It's become so much more than a blog - it's a living representation of my thinking that grows more valuable with each connection I make.
|
||||
|
||||
If you're considering starting your own digital garden, I hope this guide gives you a solid foundation. Remember, the best garden is the one you actually tend to, so start simple and let it grow naturally over time.
|
||||
|
||||
What about you? Are you maintaining a digital garden or thinking about starting one? I'd love to hear about your experience in the comments!
|
||||
|
||||
---
|
||||
|
||||
_This post was last updated on February 10, 2024 with information about the latest Quartz configuration options and integration with Astro._
|
|
@ -0,0 +1,328 @@
|
|||
---
|
||||
title: "Managing Kubernetes with Rancher: The Home Lab Way"
|
||||
description: How to set up, configure, and get the most out of Rancher for managing your home Kubernetes clusters
|
||||
pubDate: 2025-04-19
|
||||
updatedDate: 2025-04-18
|
||||
category: Services
|
||||
tags:
|
||||
- rancher
|
||||
- kubernetes
|
||||
- k3s
|
||||
- devops
|
||||
- containers
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
---
|
||||
|
||||
I've been running Kubernetes at home for years now, and I've tried just about every management tool out there. From kubectl and a bunch of YAML files to various dashboards and UIs, I've experimented with it all. But the one tool that's been a constant in my home lab journey is [Rancher](https://rancher.com/) - a complete container management platform that makes Kubernetes management almost... dare I say it... enjoyable?
|
||||
|
||||
Today, I want to walk you through setting up Rancher in your home lab and show you some of the features that have made it indispensable for me.
|
||||
|
||||
## What is Rancher and Why Should You Care?
|
||||
|
||||
Rancher is an open-source platform for managing Kubernetes clusters. Think of it as mission control for all your container workloads. It gives you:
|
||||
|
||||
- A unified interface for managing multiple clusters (perfect if you're running different K8s distros)
|
||||
- Simplified deployment of applications via apps & marketplace
|
||||
- Built-in monitoring, logging, and alerting
|
||||
- User management and role-based access control
|
||||
- A clean, intuitive UI that's actually useful (rare in the Kubernetes world!)
|
||||
|
||||
If you're running even a single Kubernetes cluster at home, Rancher can save you countless hours of typing `kubectl` commands and editing YAML files by hand.
|
||||
|
||||
## Setting Up Rancher in Your Home Lab
|
||||
|
||||
There are several ways to deploy Rancher, but I'll focus on two approaches that work well for home labs.
|
||||
|
||||
### Option 1: Docker Deployment (Quickstart)
|
||||
|
||||
The fastest way to get up and running is with Docker:
|
||||
|
||||
```bash
|
||||
docker run -d --restart=unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
--privileged \
|
||||
rancher/rancher:latest
|
||||
```
|
||||
|
||||
That's it! Navigate to `https://your-server-ip` and you'll be prompted to set a password and server URL.
|
||||
|
||||
But while this method is quick, I prefer the next approach for a more production-like setup.
|
||||
|
||||
### Option 2: Installing Rancher on K3s
|
||||
|
||||
My preferred method is to run Rancher on a lightweight Kubernetes distribution like K3s. This gives you better reliability and easier upgrades.
|
||||
|
||||
First, install K3s:
|
||||
|
||||
```bash
|
||||
curl -sfL https://get.k3s.io | sh -
|
||||
```
|
||||
|
||||
Next, install cert-manager (required for Rancher to manage certificates):
|
||||
|
||||
```bash
|
||||
kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.12.2/cert-manager.yaml
|
||||
```
|
||||
|
||||
Then, install Rancher using Helm:
|
||||
|
||||
```bash
|
||||
helm repo add rancher-stable https://releases.rancher.com/server-charts/stable
|
||||
helm repo update
|
||||
|
||||
kubectl create namespace cattle-system
|
||||
|
||||
helm install rancher rancher-stable/rancher \
|
||||
--namespace cattle-system \
|
||||
--set hostname=rancher.yourdomain.com \
|
||||
--set bootstrapPassword=admin
|
||||
```
|
||||
|
||||
Depending on your home lab setup, you might want to use a load balancer or ingress controller. I use Traefik, which comes pre-installed with K3s:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: rancher
|
||||
namespace: cattle-system
|
||||
spec:
|
||||
rules:
|
||||
- host: rancher.yourdomain.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: rancher
|
||||
port:
|
||||
number: 80
|
||||
tls:
|
||||
- hosts:
|
||||
- rancher.yourdomain.com
|
||||
secretName: rancher-tls
|
||||
```
|
||||
|
||||
## Importing Your Existing Clusters
|
||||
|
||||
Once Rancher is running, you can import your existing Kubernetes clusters. This is my favorite part because it doesn't require you to rebuild anything.
|
||||
|
||||
1. In the Rancher UI, go to "Cluster Management"
|
||||
2. Click "Import Existing"
|
||||
3. Choose a name for your cluster
|
||||
4. Copy the provided kubectl command and run it on your existing cluster
|
||||
|
||||
Rancher will install its agent on your cluster and begin managing it. Magic!
|
||||
|
||||
## Setting Up Monitoring
|
||||
|
||||
Rancher makes it dead simple to deploy Prometheus and Grafana for monitoring:
|
||||
|
||||
1. From your cluster's dashboard, go to "Apps"
|
||||
2. Select "Monitoring" from the Charts
|
||||
3. Install with default settings (or customize as needed)
|
||||
|
||||
In minutes, you'll have a full monitoring stack with pre-configured dashboards for nodes, pods, workloads, and more.
|
||||
|
||||
Here's what my Grafana dashboard looks like for my home K8s cluster:
|
||||
|
||||

|
||||
|
||||
## Creating Deployments Through the UI
|
||||
|
||||
While I'm a big fan of GitOps and declarative deployments, sometimes you just want to quickly spin up a container without writing YAML. Rancher makes this painless:
|
||||
|
||||
1. Go to your cluster
|
||||
2. Select "Workload > Deployments"
|
||||
3. Click "Create"
|
||||
4. Fill in the form with your container details
|
||||
|
||||
You get a nice UI for setting environment variables, volumes, health checks, and more. Once you're happy with it, Rancher generates and applies the YAML behind the scenes.
|
||||
|
||||
## Rancher Fleet for GitOps
|
||||
|
||||
One of the newer features I love is Fleet, Rancher's GitOps engine. It allows you to manage deployments across clusters using Git repositories:
|
||||
|
||||
```yaml
|
||||
# Example fleet.yaml
|
||||
defaultNamespace: monitoring
|
||||
helm:
|
||||
releaseName: prometheus
|
||||
repo: https://prometheus-community.github.io/helm-charts
|
||||
chart: kube-prometheus-stack
|
||||
version: 39.4.0
|
||||
values:
|
||||
grafana:
|
||||
adminPassword: ${GRAFANA_PASSWORD}
|
||||
targets:
|
||||
- name: prod
|
||||
clusterSelector:
|
||||
matchLabels:
|
||||
environment: production
|
||||
- name: dev
|
||||
clusterSelector:
|
||||
matchLabels:
|
||||
environment: development
|
||||
helm:
|
||||
values:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1Gi
|
||||
requests:
|
||||
memory: 512Mi
|
||||
```
|
||||
|
||||
With Fleet, I maintain a Git repository with all my deployments, and Rancher automatically applies them to the appropriate clusters. When I push changes, they're automatically deployed - proper GitOps!
|
||||
|
||||
## Rancher for Projects and Teams
|
||||
|
||||
If you're working with a team or want to compartmentalize your applications, Rancher's projects feature is fantastic:
|
||||
|
||||
1. Create different projects within a cluster (e.g., "Media," "Home Automation," "Development")
|
||||
2. Assign namespaces to projects
|
||||
3. Set resource quotas for each project
|
||||
4. Create users and assign them to projects with specific permissions
|
||||
|
||||
This way, you can give friends or family members access to specific applications without worrying about them breaking your critical services.
|
||||
|
||||
## Advanced: Custom Cluster Templates
|
||||
|
||||
As my home lab grew, I started using Rancher's cluster templates to ensure consistency across my Kubernetes installations:
|
||||
|
||||
```yaml
|
||||
apiVersion: management.cattle.io/v3
|
||||
kind: ClusterTemplate
|
||||
metadata:
|
||||
name: homelab-standard
|
||||
spec:
|
||||
displayName: HomeStack Standard
|
||||
revisionName: homelab-standard-v1
|
||||
members:
|
||||
- accessType: owner
|
||||
userPrincipalName: user-abc123
|
||||
template:
|
||||
spec:
|
||||
rancherKubernetesEngineConfig:
|
||||
services:
|
||||
etcd:
|
||||
backupConfig:
|
||||
enabled: true
|
||||
intervalHours: 12
|
||||
retention: 6
|
||||
kubeApi:
|
||||
auditLog:
|
||||
enabled: true
|
||||
network:
|
||||
plugin: canal
|
||||
monitoring:
|
||||
provider: metrics-server
|
||||
addons: |-
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: cert-manager
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: ingress-nginx
|
||||
```
|
||||
|
||||
## My Top Rancher Tips
|
||||
|
||||
After years of using Rancher, here are my top tips:
|
||||
|
||||
1. **Use the Rancher CLI**: For repetitive tasks, the CLI is faster than the UI:
|
||||
```bash
|
||||
rancher login https://rancher.yourdomain.com --token token-abc123
|
||||
rancher kubectl get nodes
|
||||
```
|
||||
|
||||
2. **Set Up External Authentication**: Connect Rancher to your identity provider (I use GitHub):
|
||||
```yaml
|
||||
# Sample GitHub auth config
|
||||
apiVersion: management.cattle.io/v3
|
||||
kind: AuthConfig
|
||||
metadata:
|
||||
name: github
|
||||
type: githubConfig
|
||||
properties:
|
||||
enabled: true
|
||||
clientId: your-github-client-id
|
||||
clientSecret: your-github-client-secret
|
||||
allowedPrincipals:
|
||||
- github_user://your-github-username
|
||||
- github_org://your-github-org
|
||||
```
|
||||
|
||||
3. **Create Node Templates**: If you're using RKE, save node templates for quick cluster expansion.
|
||||
|
||||
4. **Use App Templates**: Save your common applications as templates for quick deployment.
|
||||
|
||||
5. **Set Up Alerts**: Configure alerts for node health, pod failures, and resource constraints.
|
||||
|
||||
## Dealing with Common Rancher Issues
|
||||
|
||||
Even the best tools have their quirks. Here are some issues I've encountered and how I solved them:
|
||||
|
||||
### Issue: Rancher UI Becomes Slow
|
||||
|
||||
If your Rancher UI starts lagging, check your browser's local storage. The Rancher UI caches a lot of data, which can build up over time:
|
||||
|
||||
```javascript
|
||||
// Run this in your browser console while on the Rancher page
|
||||
localStorage.clear()
|
||||
```
|
||||
|
||||
### Issue: Certificate Errors After DNS Changes
|
||||
|
||||
If you change your domain or DNS settings, Rancher certificates might need to be regenerated:
|
||||
|
||||
```bash
|
||||
kubectl -n cattle-system delete secret tls-rancher-ingress
|
||||
kubectl -n cattle-system delete secret tls-ca
|
||||
```
|
||||
|
||||
Then restart the Rancher pods:
|
||||
|
||||
```bash
|
||||
kubectl -n cattle-system rollout restart deploy/rancher
|
||||
```
|
||||
|
||||
### Issue: Stuck Cluster Imports
|
||||
|
||||
If a cluster gets stuck during import, clean up the agent resources and try again:
|
||||
|
||||
```bash
|
||||
kubectl delete clusterrole cattle-admin cluster-owner
|
||||
kubectl delete clusterrolebinding cattle-admin-binding cluster-owner
|
||||
kubectl delete namespace cattle-system
|
||||
```
|
||||
|
||||
## The Future of Rancher
|
||||
|
||||
With SUSE's acquisition of Rancher Labs, the future looks bright. The latest Rancher updates have added:
|
||||
|
||||
- Better integration with cloud providers
|
||||
- Improved security features
|
||||
- Enhanced multi-cluster management
|
||||
- Lower resource requirements (great for home labs)
|
||||
|
||||
My wish list for future versions includes:
|
||||
|
||||
- Native GitOps for everything (not just workloads)
|
||||
- Better templating for one-click deployments
|
||||
- More pre-configured monitoring dashboards
|
||||
|
||||
## Wrapping Up
|
||||
|
||||
Rancher has transformed how I manage my home Kubernetes clusters. What used to be a complex, time-consuming task is now almost... fun? If you're running Kubernetes at home and haven't tried Rancher yet, you're missing out on one of the best tools in the Kubernetes ecosystem.
|
||||
|
||||
Sure, you could manage everything with kubectl and YAML files (and I still do that sometimes), but having a well-designed UI for management, monitoring, and troubleshooting saves countless hours and reduces the learning curve for those just getting started with Kubernetes.
|
||||
|
||||
Are you using Rancher or another tool to manage your Kubernetes clusters? What's been your experience? Let me know in the comments!
|
||||
|
||||
---
|
||||
|
||||
_This post was last updated on March 5, 2024 with information about Rancher v2.7 features and Fleet GitOps capabilities._
|
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
title: "Starting My Digital Garden"
|
||||
description: "How and why I'm approaching this blog as a digital garden rather than a traditional chronological blog."
|
||||
pubDate: "2023-10-05"
|
||||
heroImage: "/blog/images/placeholders/default.jpg"
|
||||
category: "Meta"
|
||||
tags: ["digital-garden", "knowledge-management", "learning-in-public"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
# Starting My Digital Garden
|
||||
|
||||
Instead of creating yet another chronological blog, I've decided to structure this site as a "digital garden" - a place where ideas grow and evolve over time.
|
||||
|
||||
## What is a Digital Garden?
|
||||
|
||||
A digital garden is a collection of notes, articles, and resources that aren't organized strictly by date, but rather by topic and interconnectedness. Unlike traditional blogs where posts are published once and rarely updated, digital gardens embrace the idea of continuous growth and refinement.
|
||||
|
||||
Key characteristics of digital gardens include:
|
||||
|
||||
- **Living documents**: Content is regularly revisited and updated as my understanding evolves
|
||||
- **Non-linear**: Ideas are interconnected through links rather than presented in strict chronology
|
||||
- **Varying levels of completion**: Some notes are polished essays, others are rough sketches of ideas
|
||||
- **Learning in public**: Sharing my learning process, not just the finished product
|
||||
|
||||
## Why a Digital Garden?
|
||||
|
||||
The digital garden approach aligns perfectly with how I learn about and work with technology:
|
||||
|
||||
1. **Technology evolves**: My guides and tutorials will evolve alongside the technologies they describe
|
||||
2. **Connection between concepts**: Infrastructure, automation, and deployment are deeply interconnected
|
||||
3. **Continuous improvement**: I can revisit and enhance articles as I discover better approaches
|
||||
4. **Lower barrier to publishing**: I can share work-in-progress ideas without waiting for "perfection"
|
||||
|
||||
## How This Works
|
||||
|
||||
On this site, you'll find:
|
||||
|
||||
- **Posts**: Longer-form articles that explain concepts, provide tutorials, or share insights
|
||||
- **Configurations**: Specific configuration guides and setup instructions
|
||||
- **Projects**: Documentation of my homelab and technical projects
|
||||
|
||||
Content will be interconnected through links and the visualization graph on the homepage. Every post has a "Last Updated" date so you can see how recently the information was reviewed.
|
||||
|
||||
## A Note on "Maturity"
|
||||
|
||||
Not all content in a digital garden has the same level of completeness. I'll be using these general states:
|
||||
|
||||
- **Seedlings**: Early ideas, rough notes, or work-in-progress
|
||||
- **Budding**: Structured content with the main points established but still developing
|
||||
- **Evergreen**: Well-developed, comprehensive resources that are regularly maintained
|
||||
|
||||
I look forward to growing this digital garden over time and hope you find the content useful on your own technical journey!
|
||||
|
||||
---
|
||||
|
||||
*This introduction to my digital garden concept was last updated on October 5, 2023.*
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
title: Test Post
|
||||
pubDate: 2024-03-20
|
||||
description: This is a test post to verify the blog setup
|
||||
category: Test
|
||||
tags:
|
||||
- test
|
||||
draft: true
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
---
|
||||
|
||||
# Test Post
|
||||
|
||||
This is a test post to verify that the blog setup is working correctly.
|
|
@ -0,0 +1,517 @@
|
|||
---
|
||||
title: Setting Up VS Code Server for Remote Development Anywhere
|
||||
description: How to set up and configure VS Code Server for seamless remote development from any device
|
||||
pubDate: 2023-04-18
|
||||
updatedDate: 2024-04-19
|
||||
category: Services
|
||||
tags:
|
||||
- vscode
|
||||
- remote-development
|
||||
- self-hosted
|
||||
- coding
|
||||
- homelab
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
---
|
||||
|
||||
If you're like me, you probably find yourself coding on multiple devices - maybe a desktop at home, a laptop when traveling, or even occasionally on a tablet. For years, keeping development environments in sync was a pain point. Enter [VS Code Server](https://code.visualstudio.com/docs/remote/vscode-server), the solution that has completely transformed my development workflow.
|
||||
|
||||
Today, I want to show you how to set up your own self-hosted VS Code Server that lets you code from literally any device with a web browser, all while using your powerful home server for the heavy lifting.
|
||||
|
||||
## Why VS Code Server?
|
||||
|
||||
Before we dive into the setup, let's talk about why you might want this:
|
||||
|
||||
- **Consistent environment**: The same development setup, extensions, and configurations regardless of which device you're using.
|
||||
- **Resource optimization**: Run resource-intensive tasks (builds, tests) on your powerful server instead of your laptop.
|
||||
- **Work from anywhere**: Access your development environment from any device with a browser, even an iPad or a borrowed computer.
|
||||
- **Seamless switching**: Start working on one device and continue on another without missing a beat.
|
||||
|
||||
I've been using this setup for months now, and it's been a game-changer for my productivity. Let's get into the setup!
|
||||
|
||||
## Setting Up VS Code Server
|
||||
|
||||
There are a few ways to run VS Code Server. I'll cover the official method and my preferred Docker approach.
|
||||
|
||||
### Option 1: Official CLI Installation
|
||||
|
||||
The VS Code team provides a CLI for setting up the server:
|
||||
|
||||
```bash
|
||||
# Download and install the CLI
|
||||
curl -fsSL https://code.visualstudio.com/sha/download?build=stable&os=cli-alpine-x64 -o vscode_cli.tar.gz
|
||||
tar -xf vscode_cli.tar.gz
|
||||
sudo mv code /usr/local/bin/
|
||||
|
||||
# Start the server
|
||||
code serve-web --accept-server-license-terms --host 0.0.0.0
|
||||
```
|
||||
|
||||
This method is straightforward but requires you to manage the process yourself.
|
||||
|
||||
### Option 2: Docker Installation (My Preference)
|
||||
|
||||
I prefer using Docker for easier updates and management. Here's my `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
services:
|
||||
code-server:
|
||||
image: linuxserver/code-server:latest
|
||||
container_name: code-server
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=America/Los_Angeles
|
||||
- PASSWORD=your_secure_password # Consider using Docker secrets instead
|
||||
- SUDO_PASSWORD=your_sudo_password # Optional
|
||||
- PROXY_DOMAIN=code.yourdomain.com # Optional
|
||||
volumes:
|
||||
- ./config:/config
|
||||
- /path/to/your/projects:/projects
|
||||
- /path/to/your/home:/home/coder
|
||||
ports:
|
||||
- 8443:8443
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Run it with:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Your VS Code Server will be available at `https://your-server-ip:8443`.
|
||||
|
||||
### Option 3: Kubernetes Deployment
|
||||
|
||||
For those running Kubernetes (like me), here's a YAML manifest:
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: vscode-server
|
||||
namespace: development
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: vscode-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: vscode-server
|
||||
spec:
|
||||
containers:
|
||||
- name: vscode-server
|
||||
image: linuxserver/code-server:latest
|
||||
env:
|
||||
- name: PUID
|
||||
value: "1000"
|
||||
- name: PGID
|
||||
value: "1000"
|
||||
- name: TZ
|
||||
value: "America/Los_Angeles"
|
||||
- name: PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: vscode-secrets
|
||||
key: password
|
||||
ports:
|
||||
- containerPort: 8443
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
- name: projects
|
||||
mountPath: /projects
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
claimName: vscode-config
|
||||
- name: projects
|
||||
persistentVolumeClaim:
|
||||
claimName: vscode-projects
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: vscode-server
|
||||
namespace: development
|
||||
spec:
|
||||
selector:
|
||||
app: vscode-server
|
||||
ports:
|
||||
- port: 8443
|
||||
targetPort: 8443
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: vscode-server
|
||||
namespace: development
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- code.yourdomain.com
|
||||
secretName: vscode-tls
|
||||
rules:
|
||||
- host: code.yourdomain.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: vscode-server
|
||||
port:
|
||||
number: 8443
|
||||
```
|
||||
|
||||
## Accessing VS Code Server Securely
|
||||
|
||||
You don't want to expose your development environment directly to the internet without proper security. Here are my recommendations:
|
||||
|
||||
### 1. Use a Reverse Proxy with SSL
|
||||
|
||||
I use Traefik as a reverse proxy with automatic SSL certificate generation:
|
||||
|
||||
```yaml
|
||||
# traefik.yml dynamic config
|
||||
http:
|
||||
routers:
|
||||
vscode:
|
||||
rule: "Host(`code.yourdomain.com`)"
|
||||
service: "vscode"
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
services:
|
||||
vscode:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://localhost:8443"
|
||||
```
|
||||
|
||||
### 2. Set Up Authentication
|
||||
|
||||
The LinuxServer image already includes basic authentication, but you can add another layer with something like Authelia:
|
||||
|
||||
```yaml
|
||||
# authelia configuration.yml
|
||||
access_control:
|
||||
default_policy: deny
|
||||
rules:
|
||||
- domain: code.yourdomain.com
|
||||
policy: two_factor
|
||||
subject: "group:developers"
|
||||
```
|
||||
|
||||
### 3. Use Cloudflare Tunnel
|
||||
|
||||
For ultimate security, I use a Cloudflare Tunnel to avoid exposing any ports:
|
||||
|
||||
```yaml
|
||||
# cloudflared config.yml
|
||||
tunnel: your-tunnel-id
|
||||
credentials-file: /etc/cloudflared/creds.json
|
||||
|
||||
ingress:
|
||||
- hostname: code.yourdomain.com
|
||||
service: http://localhost:8443
|
||||
originRequest:
|
||||
noTLSVerify: true
|
||||
- service: http_status:404
|
||||
```
|
||||
|
||||
## Configuring Your VS Code Server Environment
|
||||
|
||||
Once your server is running, it's time to set it up for optimal productivity:
|
||||
|
||||
### 1. Install Essential Extensions
|
||||
|
||||
Here are the must-have extensions I install first:
|
||||
|
||||
```bash
|
||||
# From the VS Code terminal
|
||||
code-server --install-extension ms-python.python
|
||||
code-server --install-extension ms-azuretools.vscode-docker
|
||||
code-server --install-extension dbaeumer.vscode-eslint
|
||||
code-server --install-extension esbenp.prettier-vscode
|
||||
code-server --install-extension github.copilot
|
||||
code-server --install-extension golang.go
|
||||
```
|
||||
|
||||
Or you can install them through the Extensions marketplace in the UI.
|
||||
|
||||
### 2. Configure Settings Sync
|
||||
|
||||
To keep your settings in sync between instances:
|
||||
|
||||
1. Open the Command Palette (Ctrl+Shift+P)
|
||||
2. Search for "Settings Sync: Turn On"
|
||||
3. Sign in with your GitHub or Microsoft account
|
||||
|
||||
### 3. Set Up Git Authentication
|
||||
|
||||
For seamless Git operations:
|
||||
|
||||
```bash
|
||||
# Generate a new SSH key if needed
|
||||
ssh-keygen -t ed25519 -C "your_email@example.com"
|
||||
|
||||
# Add to your GitHub/GitLab account
|
||||
cat ~/.ssh/id_ed25519.pub
|
||||
|
||||
# Configure Git
|
||||
git config --global user.name "Your Name"
|
||||
git config --global user.email "your_email@example.com"
|
||||
```
|
||||
|
||||
## Power User Features
|
||||
|
||||
Now let's look at some advanced configurations that make VS Code Server even more powerful:
|
||||
|
||||
### 1. Workspace Launcher
|
||||
|
||||
I created a simple HTML page that lists all my projects for quick access:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>ArgoBox Workspace Launcher</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.project-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.project-card {
|
||||
background-color: #252526;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.project-card:hover {
|
||||
transform: translateY(-5px);
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
a {
|
||||
color: #569cd6;
|
||||
text-decoration: none;
|
||||
}
|
||||
h1 { color: #569cd6; }
|
||||
h2 { color: #4ec9b0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>ArgoBox Workspace Launcher</h1>
|
||||
<div class="project-list">
|
||||
<div class="project-card">
|
||||
<h2>ArgoBox</h2>
|
||||
<p>My Kubernetes home lab platform</p>
|
||||
<a href="/projects/argobox">Open Workspace</a>
|
||||
</div>
|
||||
<div class="project-card">
|
||||
<h2>Blog</h2>
|
||||
<p>ArgoBox blog and digital garden</p>
|
||||
<a href="/projects/blog">Open Workspace</a>
|
||||
</div>
|
||||
<!-- Add more projects as needed -->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 2. Custom Terminal Profile
|
||||
|
||||
Add this to your `settings.json` for a better terminal experience:
|
||||
|
||||
```json
|
||||
"terminal.integrated.profiles.linux": {
|
||||
"bash": {
|
||||
"path": "bash",
|
||||
"icon": "terminal-bash",
|
||||
"args": ["-l"]
|
||||
},
|
||||
"zsh": {
|
||||
"path": "zsh"
|
||||
},
|
||||
"customProfile": {
|
||||
"path": "bash",
|
||||
"args": ["-c", "neofetch && bash -l"],
|
||||
"icon": "terminal-bash",
|
||||
"overrideName": true
|
||||
}
|
||||
},
|
||||
"terminal.integrated.defaultProfile.linux": "customProfile"
|
||||
```
|
||||
|
||||
### 3. Persistent Development Containers
|
||||
|
||||
I use Docker-in-Docker to enable VS Code Dev Containers:
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
services:
|
||||
code-server:
|
||||
# ... other config from above
|
||||
volumes:
|
||||
# ... other volumes
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
# ... other env vars
|
||||
- DOCKER_HOST=unix:///var/run/docker.sock
|
||||
```
|
||||
|
||||
## Real-World Examples: How I Use VS Code Server
|
||||
|
||||
Let me share a few real-world examples of how I use this setup:
|
||||
|
||||
### Example 1: Coding on iPad During Travel
|
||||
|
||||
When traveling with just my iPad, I connect to my VS Code Server to work on my projects. With a Bluetooth keyboard and the amazing iPad screen, it's a surprisingly good experience. The heavy compilation happens on my server back home, so battery life on the iPad remains excellent.
|
||||
|
||||
### Example 2: Pair Programming Sessions
|
||||
|
||||
When helping friends debug issues, I can generate a temporary access link to my VS Code Server:
|
||||
|
||||
```bash
|
||||
# Create a time-limited token
|
||||
TEMP_TOKEN=$(openssl rand -hex 16)
|
||||
echo "Token: $TEMP_TOKEN" > /tmp/temp_token
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"token\":\"$TEMP_TOKEN\", \"expiresIn\":\"2h\"}" \
|
||||
http://localhost:8443/api/auth/temporary
|
||||
```
|
||||
|
||||
### Example 3: Switching Between Devices
|
||||
|
||||
I often start coding on my desktop, then switch to my laptop when moving to another room. With VS Code Server, I just close the browser on one device and open it on another - my entire session, including unsaved changes and terminal state, remains intact.
|
||||
|
||||
## Monitoring and Maintaining Your VS Code Server
|
||||
|
||||
To keep your server running smoothly:
|
||||
|
||||
### 1. Set Up Health Checks
|
||||
|
||||
I use Uptime Kuma to monitor my VS Code Server:
|
||||
|
||||
```yaml
|
||||
# Docker Compose snippet for Uptime Kuma
|
||||
uptime-kuma:
|
||||
image: louislam/uptime-kuma:latest
|
||||
container_name: uptime-kuma
|
||||
volumes:
|
||||
- ./uptime-kuma:/app/data
|
||||
ports:
|
||||
- 3001:3001
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Add a monitor that checks `https://code.yourdomain.com/healthz` endpoint.
|
||||
|
||||
### 2. Regular Backups
|
||||
|
||||
Set up a cron job to back up your VS Code Server configuration:
|
||||
|
||||
```bash
|
||||
# /etc/cron.daily/backup-vscode-server
|
||||
#!/bin/bash
|
||||
tar -czf /backups/vscode-server-$(date +%Y%m%d).tar.gz /path/to/config
|
||||
```
|
||||
|
||||
### 3. Update Script
|
||||
|
||||
Create a script for easy updates:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# update-vscode-server.sh
|
||||
cd /path/to/docker-compose
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
docker system prune -f
|
||||
```
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
Here are solutions to some issues I've encountered:
|
||||
|
||||
### Issue: Extensions Not Installing
|
||||
|
||||
If extensions fail to install, try:
|
||||
|
||||
```bash
|
||||
# Clear the extensions cache
|
||||
rm -rf ~/.vscode-server/extensions/*
|
||||
|
||||
# Restart the server
|
||||
docker-compose restart code-server
|
||||
```
|
||||
|
||||
### Issue: Performance Problems
|
||||
|
||||
If you're experiencing lag:
|
||||
|
||||
```bash
|
||||
# Adjust memory limits in docker-compose.yml
|
||||
services:
|
||||
code-server:
|
||||
# ... other config
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
reservations:
|
||||
memory: 1G
|
||||
```
|
||||
|
||||
### Issue: Git Integration Not Working
|
||||
|
||||
For Git authentication issues:
|
||||
|
||||
```bash
|
||||
# Make sure your SSH key is properly set up
|
||||
eval "$(ssh-agent -s)"
|
||||
ssh-add ~/.ssh/id_ed25519
|
||||
|
||||
# Test your connection
|
||||
ssh -T git@github.com
|
||||
```
|
||||
|
||||
## Why I Prefer Self-Hosted Over GitHub Codespaces
|
||||
|
||||
People often ask why I don't just use GitHub Codespaces. Here's my take:
|
||||
|
||||
1. **Cost**: Self-hosted is essentially free (if you already have a server).
|
||||
2. **Privacy**: All my code remains on my hardware.
|
||||
3. **Customization**: Complete control over the environment.
|
||||
4. **Performance**: My server has 64GB RAM and a 12-core CPU - better than most cloud options.
|
||||
5. **Availability**: Works even when GitHub is down or when I have limited internet.
|
||||
|
||||
## Wrapping Up
|
||||
|
||||
VS Code Server has truly changed how I approach development. The ability to have a consistent, powerful environment available from any device has increased my productivity and eliminated the friction of context-switching between machines.
|
||||
|
||||
Whether you're a solo developer or part of a team, having a centralized development environment that's accessible from anywhere is incredibly powerful. And the best part? It uses the familiar VS Code interface that millions of developers already know and love.
|
||||
|
||||
Have you tried VS Code Server or similar remote development solutions? I'd love to hear about your setup and experiences in the comments!
|
||||
|
||||
---
|
||||
|
||||
_This post was last updated on March 10, 2024 with information about the latest VS Code Server features and Docker image updates._
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
title: ArgoBox
|
||||
pubDate: 2023-10-15
|
||||
description: A homelab server setup with Docker, Kubernetes, and various self-hosted services
|
||||
author: LaForce IT
|
||||
heroImage: /images/argobox-hero.jpg
|
||||
category: Infrastructure
|
||||
technologies:
|
||||
- Docker
|
||||
- Kubernetes
|
||||
- Self-hosted
|
||||
- Proxmox
|
||||
- K3s
|
||||
github: https://github.com/KeyArgo/homelab-infrastructure
|
||||
draft: false
|
||||
---
|
||||
|
||||
# ArgoBox
|
||||
|
||||
ArgoBox is a comprehensive homelab infrastructure project that combines virtualization, containerization, and automation to create a robust home server environment.
|
||||
|
||||
## Overview
|
||||
|
||||
This project serves as the foundation for all my self-hosted services, including:
|
||||
|
||||
- K3s Kubernetes cluster
|
||||
- GitOps with Flux CD
|
||||
- Monitoring with Prometheus and Grafana
|
||||
- Home automation tools
|
||||
- Media services
|
||||
- Development environment
|
||||
|
||||
## Technical Stack
|
||||
|
||||
- **Hypervisor**: Proxmox VE
|
||||
- **Container Orchestration**: K3s Kubernetes
|
||||
- **Configuration Management**: Ansible
|
||||
- **GitOps**: Flux CD
|
||||
- **Storage**: Longhorn, NFS
|
||||
- **Networking**: MetalLB, Cloudflare Tunnels
|
||||
- **Monitoring**: Prometheus, Grafana, Loki
|
||||
|
||||
## Project Goals
|
||||
|
||||
- Create a reliable, self-healing infrastructure
|
||||
- Implement GitOps practices for declarative configuration
|
||||
- Enable easy deployment of new services
|
||||
- Provide comprehensive monitoring and alerting
|
||||
- Document the setup for reproducibility
|
||||
|
||||
## Current Status
|
||||
|
||||
The project is continuously evolving with new services and improvements being added regularly. Check the GitHub repository for the latest updates and documentation.
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
title: "Projects Collection"
|
||||
description: "A placeholder document for the projects collection"
|
||||
heroImage: "/blog/images/placeholders/default.jpg"
|
||||
pubDate: 2025-04-18
|
||||
status: "concept" # Changed from 'planning' to match schema
|
||||
tech: ["astro", "markdown"]
|
||||
---
|
||||
|
||||
# Projects Collection
|
||||
|
||||
This is a placeholder file for the projects collection.
|
|
@ -0,0 +1 @@
|
|||
/// <reference path="../.astro/types.d.ts" />
|
|
@ -0,0 +1,476 @@
|
|||
---
|
||||
// BaseLayout.astro
|
||||
// Primary layout component that provides the fundamental structure and styles for the site
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = "ArgoBox Blog - Home Lab & DevOps Insights",
|
||||
image = "/images/og-image.jpg" // Make sure this image exists in public/images/
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{/* Inline script to set initial theme and prevent FOUC */}
|
||||
<script is:inline>
|
||||
const getInitialTheme = () => {
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||
return localStorage.getItem('theme');
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||
return 'light';
|
||||
}
|
||||
return 'dark'; // Default to dark if no preference found
|
||||
};
|
||||
|
||||
const theme = getInitialTheme();
|
||||
|
||||
if (theme === 'light') {
|
||||
document.documentElement.classList.add('light-mode');
|
||||
} else {
|
||||
// No need to add 'dark-mode' class if dark is the default via CSS
|
||||
// document.documentElement.classList.add('dark-mode');
|
||||
}
|
||||
|
||||
// Optional: Set data attribute for easier CSS targeting if needed
|
||||
// document.documentElement.setAttribute('data-theme', theme);
|
||||
</script>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Theme initialization - Must be inline -->
|
||||
<script is:inline>
|
||||
// Initialize theme before page loads to prevent flash
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'light' || (!savedTheme && !prefersDark)) {
|
||||
document.documentElement.classList.add('light-mode');
|
||||
} else {
|
||||
document.documentElement.classList.remove('light-mode');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- OpenGraph/Social Media Meta Tags -->
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={Astro.site ? new URL(image, Astro.site).href : image} /> <!-- Use absolute URL -->
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
<!-- Twitter Card data -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content={title}>
|
||||
<meta name="twitter:description" content={description}>
|
||||
<meta name="twitter:image" content={Astro.site ? new URL(image, Astro.site).href : image}> <!-- Use absolute URL -->
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100;300;400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<!-- Theme CSS -->
|
||||
<link rel="stylesheet" href="/src/styles/theme.css" />
|
||||
|
||||
<!-- Cytoscape Library for Knowledge Graph -->
|
||||
<script src="https://unpkg.com/cytoscape@3.25.0/dist/cytoscape.min.js" is:inline></script>
|
||||
|
||||
<!-- Schema.org markup for Google -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "ArgoBox Blog",
|
||||
"url": Astro.site ? new URL(Astro.url.pathname, Astro.site).href : Astro.url.href, // Use absolute URL
|
||||
"description": description,
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Daniel LaForce"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Global CSS Variables & Base Styles -->
|
||||
<style is:global>
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--bg-primary: #0f1219;
|
||||
--bg-secondary: #161a24;
|
||||
--bg-tertiary: #1e2330;
|
||||
--bg-code: #1a1e2a;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #a0aec0;
|
||||
--text-tertiary: #718096;
|
||||
|
||||
/* Accent Colors */
|
||||
--accent-primary: #06b6d4; /* Cyan */
|
||||
--accent-secondary: #3b82f6; /* Blue */
|
||||
--accent-tertiary: #8b5cf6; /* Violet */
|
||||
|
||||
/* Glow Effects */
|
||||
--glow-primary: rgba(6, 182, 212, 0.2);
|
||||
--glow-secondary: rgba(59, 130, 246, 0.2);
|
||||
--glow-tertiary: rgba(139, 92, 246, 0.2);
|
||||
|
||||
/* Border Colors */
|
||||
--border-primary: rgba(255, 255, 255, 0.1);
|
||||
--border-secondary: rgba(255, 255, 255, 0.05);
|
||||
|
||||
/* Card Background */
|
||||
--card-bg: rgba(24, 28, 44, 0.5); /* Slightly different from original */
|
||||
--card-border: rgba(56, 189, 248, 0.2); /* Cyan border */
|
||||
|
||||
/* UI Element Colors */
|
||||
--ui-element: #1e293b;
|
||||
--ui-element-hover: #334155;
|
||||
|
||||
/* Container Paddings */
|
||||
--container-padding: clamp(1rem, 5vw, 3rem);
|
||||
|
||||
/* Font Sizes */
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-md: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
--font-size-3xl: 1.875rem;
|
||||
--font-size-4xl: 2.25rem;
|
||||
--font-size-5xl: 3rem;
|
||||
|
||||
/* Font Families */
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
/* Reset Styles */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 35%, rgba(6, 182, 212, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 15%, rgba(59, 130, 246, 0.05) 0%, transparent 45%),
|
||||
radial-gradient(circle at 85% 70%, rgba(139, 92, 246, 0.05) 0%, transparent 40%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Grid overlay effect */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(rgba(226, 232, 240, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(226, 232, 240, 0.03) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block; /* Prevent bottom space */
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary); /* Ensure headings use primary text color */
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-secondary); /* Use secondary for paragraphs */
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--container-padding);
|
||||
}
|
||||
|
||||
/* Neuronal nodes animation */
|
||||
.neural-nodes { /* Changed from ID to class */
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.neural-node {
|
||||
position: absolute; /* Changed from fixed */
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
background: rgba(226, 232, 240, 0.2);
|
||||
border-radius: 50%;
|
||||
animation: pulse 4s infinite alternate ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Floating gradient shapes */
|
||||
.floating-shapes {
|
||||
position: fixed; /* Changed from absolute */
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: -1; /* Ensure it's behind content */
|
||||
}
|
||||
|
||||
.floating-shape {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.05;
|
||||
filter: blur(30px);
|
||||
}
|
||||
|
||||
.shape-1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: var(--accent-primary);
|
||||
top: 20%;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.shape-2 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--accent-secondary);
|
||||
bottom: 10%;
|
||||
left: 10%;
|
||||
}
|
||||
|
||||
.shape-3 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: var(--accent-tertiary);
|
||||
top: 70%;
|
||||
right: 20%;
|
||||
}
|
||||
|
||||
/* Main Header (Example - Adapt if using Header.astro component) */
|
||||
.site-header {
|
||||
background: linear-gradient(180deg, var(--bg-secondary), transparent);
|
||||
padding: 1.5rem 0;
|
||||
position: relative; /* Changed from sticky if needed */
|
||||
z-index: 10;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--container-padding);
|
||||
}
|
||||
|
||||
/* Footer (Example - Adapt if using Footer.astro component) */
|
||||
.site-footer {
|
||||
background: var(--bg-secondary);
|
||||
padding: 4rem 0 2rem;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
margin-top: 4rem; /* Add space above footer */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Neural Nodes Animation Container -->
|
||||
<div class="neural-nodes"></div>
|
||||
|
||||
<!-- Floating Gradient Shapes Container -->
|
||||
<div class="floating-shapes">
|
||||
<div class="floating-shape shape-1"></div>
|
||||
<div class="floating-shape shape-2"></div>
|
||||
<div class="floating-shape shape-3"></div>
|
||||
</div>
|
||||
|
||||
<slot name="header" />
|
||||
|
||||
<main>
|
||||
<slot /> <!-- Default slot for page content -->
|
||||
</main>
|
||||
|
||||
<slot name="footer" />
|
||||
|
||||
<!-- JavaScript for animations -->
|
||||
<script>
|
||||
// Create neural network nodes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const nodesContainer = document.querySelector('.neural-nodes');
|
||||
if (nodesContainer) { // Check if container exists
|
||||
const nodeCount = 30; // Adjust count as needed
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const node = document.createElement('div');
|
||||
node.classList.add('neural-node');
|
||||
|
||||
// Random positioning
|
||||
node.style.left = `${Math.random() * 100}%`;
|
||||
node.style.top = `${Math.random() * 100}%`;
|
||||
|
||||
// Random animation delay
|
||||
node.style.animationDelay = `${Math.random() * 5}s`;
|
||||
|
||||
nodesContainer.appendChild(node);
|
||||
}
|
||||
} else {
|
||||
console.warn("Element with class 'neural-nodes' not found.");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Add copy to clipboard functionality for code blocks -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Find all code blocks
|
||||
const codeBlocks = document.querySelectorAll('pre code');
|
||||
|
||||
// Add copy button to each
|
||||
codeBlocks.forEach((codeBlock, index) => {
|
||||
// Create container for copy button (to enable positioning)
|
||||
const container = document.createElement('div');
|
||||
container.className = 'code-block-container';
|
||||
container.style.position = 'relative';
|
||||
|
||||
// Create copy button
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.className = 'copy-code-button';
|
||||
copyButton.setAttribute('aria-label', 'Copy code to clipboard');
|
||||
copyButton.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="copy-icon">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="check-icon" style="display: none;">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Style the button
|
||||
copyButton.style.position = 'absolute';
|
||||
copyButton.style.top = '0.5rem';
|
||||
copyButton.style.right = '0.5rem';
|
||||
copyButton.style.padding = '0.25rem';
|
||||
copyButton.style.background = 'rgba(45, 55, 72, 0.5)';
|
||||
copyButton.style.border = '1px solid rgba(255, 255, 255, 0.2)';
|
||||
copyButton.style.borderRadius = '0.25rem';
|
||||
copyButton.style.cursor = 'pointer';
|
||||
copyButton.style.zIndex = '10';
|
||||
copyButton.style.opacity = '0';
|
||||
copyButton.style.transition = 'opacity 0.2s';
|
||||
|
||||
// Add click handler
|
||||
copyButton.addEventListener('click', () => {
|
||||
// Get code text
|
||||
const code = codeBlock.textContent;
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
// Show success UI
|
||||
copyButton.querySelector('.copy-icon').style.display = 'none';
|
||||
copyButton.querySelector('.check-icon').style.display = 'block';
|
||||
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => {
|
||||
copyButton.querySelector('.copy-icon').style.display = 'block';
|
||||
copyButton.querySelector('.check-icon').style.display = 'none';
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// Clone the code block
|
||||
const preElement = codeBlock.parentElement;
|
||||
const wrapper = preElement.parentElement;
|
||||
|
||||
// Create the container structure
|
||||
container.appendChild(preElement.cloneNode(true));
|
||||
container.appendChild(copyButton);
|
||||
|
||||
// Replace the original pre with our container
|
||||
wrapper.replaceChild(container, preElement);
|
||||
|
||||
// Update the reference to the new code block
|
||||
const newCodeBlock = container.querySelector('code');
|
||||
|
||||
// Add hover behavior
|
||||
container.addEventListener('mouseenter', () => {
|
||||
copyButton.style.opacity = '1';
|
||||
});
|
||||
|
||||
container.addEventListener('mouseleave', () => {
|
||||
copyButton.style.opacity = '0';
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,601 @@
|
|||
---
|
||||
// The key change is moving the MiniKnowledgeGraph component BEFORE the tags section
|
||||
// and styling it properly with clear z-index values to ensure proper display
|
||||
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import MiniKnowledgeGraph from '../components/MiniKnowledgeGraph.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
interface Props {
|
||||
frontmatter: {
|
||||
title: string;
|
||||
description?: string;
|
||||
pubDate: Date;
|
||||
updatedDate?: Date;
|
||||
heroImage?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
readTime?: string;
|
||||
draft?: boolean;
|
||||
author?: string;
|
||||
github?: string;
|
||||
live?: string;
|
||||
technologies?: string[];
|
||||
related_posts?: string[]; // Explicit related posts by slug
|
||||
}
|
||||
}
|
||||
|
||||
const { frontmatter } = Astro.props;
|
||||
|
||||
// Format dates
|
||||
const formattedPubDate = frontmatter.pubDate ? new Date(frontmatter.pubDate).toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}) : 'N/A';
|
||||
|
||||
const formattedUpdatedDate = frontmatter.updatedDate ? new Date(frontmatter.updatedDate).toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}) : null;
|
||||
|
||||
// Default image if heroImage is missing
|
||||
const displayImage = frontmatter.heroImage || '/images/placeholders/default.jpg';
|
||||
|
||||
// Get related posts for MiniKnowledgeGraph
|
||||
// First get all posts
|
||||
const allPosts = await getCollection('posts').catch(error => {
|
||||
console.error('Error fetching posts collection:', error);
|
||||
return [];
|
||||
});
|
||||
|
||||
// Try blog collection if posts collection doesn't exist
|
||||
const blogPosts = allPosts.length === 0 ? await getCollection('blog').catch(() => []) : [];
|
||||
const combinedPosts = [...allPosts, ...blogPosts];
|
||||
|
||||
// Find the current post in collection
|
||||
const currentPost = combinedPosts.find(post =>
|
||||
post.data.title === frontmatter.title ||
|
||||
post.slug === frontmatter.title.toLowerCase().replace(/\s+/g, '-')
|
||||
);
|
||||
|
||||
// Get related posts - first from explicit frontmatter relation, then by tag similarity
|
||||
let relatedPosts = [];
|
||||
|
||||
// If related_posts is specified in frontmatter, use those first
|
||||
if (frontmatter.related_posts && frontmatter.related_posts.length > 0) {
|
||||
const explicitRelatedPosts = combinedPosts.filter(post =>
|
||||
frontmatter.related_posts.includes(post.slug)
|
||||
);
|
||||
relatedPosts = [...explicitRelatedPosts];
|
||||
}
|
||||
|
||||
// If we need more related posts, find them by tags
|
||||
if (relatedPosts.length < 3 && frontmatter.tags && frontmatter.tags.length > 0) {
|
||||
// Calculate tag similarity score for each post
|
||||
const tagSimilarityPosts = combinedPosts
|
||||
.filter(post =>
|
||||
// Filter out current post and already included related posts
|
||||
post.data.title !== frontmatter.title &&
|
||||
!relatedPosts.some(rp => rp.slug === post.slug)
|
||||
)
|
||||
.map(post => {
|
||||
// Count matching tags
|
||||
const postTags = post.data.tags || [];
|
||||
const matchingTags = postTags.filter(tag =>
|
||||
frontmatter.tags.includes(tag)
|
||||
);
|
||||
return {
|
||||
post,
|
||||
score: matchingTags.length
|
||||
};
|
||||
})
|
||||
.filter(item => item.score > 0) // Only consider posts with at least one matching tag
|
||||
.sort((a, b) => b.score - a.score) // Sort by score descending
|
||||
.map(item => item.post); // Extract just the post
|
||||
|
||||
// Add tag-related posts to fill up to 3 related posts
|
||||
relatedPosts = [...relatedPosts, ...tagSimilarityPosts.slice(0, 3 - relatedPosts.length)];
|
||||
}
|
||||
|
||||
// Limit to 3 related posts
|
||||
relatedPosts = relatedPosts.slice(0, 3);
|
||||
|
||||
// Check if we can show the Knowledge Graph
|
||||
const showKnowledgeGraph = currentPost || (frontmatter.tags?.length > 0 || relatedPosts.length > 0);
|
||||
|
||||
// Create fallback data if current post is missing
|
||||
const fallbackCurrentPost = currentPost || {
|
||||
slug: frontmatter.title.toLowerCase().replace(/\s+/g, '-'),
|
||||
data: {
|
||||
title: frontmatter.title,
|
||||
tags: frontmatter.tags || [],
|
||||
category: frontmatter.category || 'Uncategorized'
|
||||
}
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title={frontmatter.title} description={frontmatter.description} image={displayImage}>
|
||||
<Header slot="header" />
|
||||
|
||||
<div class="blog-post-container">
|
||||
<article class="blog-post">
|
||||
<header class="blog-post-header">
|
||||
{/* Display Draft Badge First */}
|
||||
{frontmatter.draft && <span class="draft-badge mb-4">DRAFT</span>}
|
||||
|
||||
{/* Title */}
|
||||
<h1 class="blog-post-title mb-2">{frontmatter.title}</h1>
|
||||
|
||||
{/* Description */}
|
||||
{frontmatter.description && <p class="blog-post-description mb-4">{frontmatter.description}</p>}
|
||||
|
||||
{/* Metadata (Date, Read Time) */}
|
||||
<div class="blog-post-meta mb-4">
|
||||
<span class="blog-post-date">Published {formattedPubDate}</span>
|
||||
{formattedUpdatedDate && (
|
||||
<span class="blog-post-updated">(Updated {formattedUpdatedDate})</span>
|
||||
)}
|
||||
{frontmatter.readTime && <span class="blog-post-read-time">{frontmatter.readTime}</span>}
|
||||
</div>
|
||||
|
||||
{/* Debug information */}
|
||||
<div class="debug-info" style="background: rgba(255,0,0,0.1); padding: 10px; margin-bottom: 20px; border-radius: 5px; display: block;">
|
||||
<p><strong>Debug Info:</strong></p>
|
||||
<p>ShowKnowledgeGraph: {showKnowledgeGraph ? 'true' : 'false'}</p>
|
||||
<p>CurrentPost exists: {currentPost ? 'yes' : 'no'}</p>
|
||||
<p>Has Tags: {frontmatter.tags?.length > 0 ? 'yes' : 'no'}</p>
|
||||
<p>Has Related Posts: {relatedPosts.length > 0 ? 'yes' : 'no'}</p>
|
||||
<p>Current Post Tags: {JSON.stringify(frontmatter.tags || [])}</p>
|
||||
</div>
|
||||
|
||||
{/* IMPORTANT CHANGE: Knowledge Graph - Display BEFORE tags */}
|
||||
{showKnowledgeGraph && (
|
||||
<div class="mini-knowledge-graph-wrapper">
|
||||
<MiniKnowledgeGraph
|
||||
currentPost={fallbackCurrentPost}
|
||||
relatedPosts={relatedPosts}
|
||||
height="250px"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags - Now placed AFTER the knowledge graph */}
|
||||
{frontmatter.tags && frontmatter.tags.length > 0 && (
|
||||
<div class="blog-post-tags">
|
||||
{frontmatter.tags.map((tag) => (
|
||||
<a href={`/tag/${tag}`} class="blog-post-tag">#{tag}</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Display Hero Image */}
|
||||
{displayImage && (
|
||||
<div class="blog-post-hero">
|
||||
<img src={displayImage.startsWith('/') ? displayImage : `/${displayImage}`} alt={frontmatter.title} width="1024" height="512" loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div class="blog-post-content prose prose-invert max-w-none">
|
||||
<slot /> {/* Renders the actual markdown content */}
|
||||
</div>
|
||||
|
||||
</article>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside class="blog-post-sidebar">
|
||||
{/* Author Card Updated */}
|
||||
<div class="sidebar-card author-card">
|
||||
<div class="author-avatar">
|
||||
<img src="/images/avatar.jpg" alt="ArgoBox Tech Blogs" />
|
||||
</div>
|
||||
<div class="author-info">
|
||||
<h3>ArgoBox.com Tech Blogs</h3>
|
||||
<p>For Home Labbers, Technologists & Engineers</p>
|
||||
</div>
|
||||
<p class="author-bio">
|
||||
Exploring enterprise-grade infrastructure, automation, Kubernetes, and zero-trust networking in the home lab and beyond.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Table of Contents Card */}
|
||||
<div class="sidebar-card toc-card">
|
||||
<h3>Table of Contents</h3>
|
||||
<nav class="toc-container" id="toc">
|
||||
<p class="text-sm text-gray-400">Loading TOC...</p>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Related Posts */}
|
||||
{relatedPosts.length > 0 && (
|
||||
<div class="sidebar-card related-posts-card">
|
||||
<h3>Related Articles</h3>
|
||||
<div class="related-posts">
|
||||
{relatedPosts.map(post => (
|
||||
<a href={`/posts/${post.slug}/`} class="related-post-link">
|
||||
<h4>{post.data.title}</h4>
|
||||
{post.data.tags && post.data.tags.length > 0 && (
|
||||
<div class="related-post-tags">
|
||||
{post.data.tags.slice(0, 2).map(tag => (
|
||||
<span class="related-tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Footer slot="footer" />
|
||||
</BaseLayout>
|
||||
|
||||
{/* Script for Table of Contents Generation */}
|
||||
<script>
|
||||
function generateToc() {
|
||||
const tocContainer = document.getElementById('toc');
|
||||
const contentArea = document.querySelector('.blog-post-content');
|
||||
if (!tocContainer || !contentArea) return;
|
||||
const headings = contentArea.querySelectorAll('h2, h3');
|
||||
if (headings.length > 0) {
|
||||
const tocList = document.createElement('ul');
|
||||
tocList.className = 'toc-list';
|
||||
headings.forEach((heading) => {
|
||||
let id = heading.id;
|
||||
if (!id) {
|
||||
id = heading.textContent?.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/--+/g, '-') || `heading-${Math.random().toString(36).substring(7)}`;
|
||||
heading.id = id;
|
||||
}
|
||||
const listItem = document.createElement('li');
|
||||
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
|
||||
const link = document.createElement('a');
|
||||
link.href = `#${id}`;
|
||||
link.textContent = heading.textContent;
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
listItem.appendChild(link);
|
||||
tocList.appendChild(listItem);
|
||||
});
|
||||
tocContainer.innerHTML = '';
|
||||
tocContainer.appendChild(tocList);
|
||||
} else {
|
||||
tocContainer.innerHTML = '<p class="text-sm text-gray-400">No sections found.</p>';
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', generateToc);
|
||||
} else {
|
||||
generateToc();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.draft-badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: rgba(234, 179, 8, 0.2);
|
||||
color: #ca8a04;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 600;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.blog-post-container {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
gap: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
.blog-post-header {
|
||||
margin-bottom: 2.5rem;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
.blog-post-title {
|
||||
font-size: clamp(1.8rem, 4vw, 2.5rem);
|
||||
line-height: 1.25;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.blog-post-description {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 75ch;
|
||||
}
|
||||
.blog-post-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Knowledge Graph Container - Improved styles for proper placement */
|
||||
.mini-knowledge-graph-wrapper {
|
||||
width: 100%;
|
||||
margin: 0 0 1.5rem;
|
||||
position: relative;
|
||||
z-index: 2; /* Ensure proper stacking context */
|
||||
display: block !important; /* Force display */
|
||||
min-height: 250px; /* Ensure minimum height */
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
/* Add additional styles to improve visibility */
|
||||
background: var(--card-bg, #1e293b);
|
||||
border: 1px solid var(--card-border, #334155);
|
||||
height: 250px; /* Explicit height */
|
||||
visibility: visible !important; /* Force visibility */
|
||||
}
|
||||
|
||||
.blog-post-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem; /* Increased margin-top to separate from graph */
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.blog-post-tag {
|
||||
color: var(--accent-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
transition: color 0.3s ease;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.blog-post-tag:hover {
|
||||
color: var(--accent-primary);
|
||||
background-color: rgba(6, 182, 212, 0.15);
|
||||
}
|
||||
.blog-post-hero {
|
||||
width: 100%;
|
||||
margin-bottom: 2.5rem;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--card-border);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
.blog-post-hero img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blog-post-sidebar {
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
align-self: start;
|
||||
height: calc(100vh - 4rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.author-card {
|
||||
text-align: center;
|
||||
}
|
||||
.author-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin: 0 auto 1rem;
|
||||
border: 2px solid var(--accent-primary);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
.author-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.author-info h3 {
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.author-info p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.author-bio {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toc-card h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.toc-item {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.toc-item a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
font-size: 0.9rem;
|
||||
display: block;
|
||||
padding-left: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.toc-item a:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
.toc-h3 a {
|
||||
padding-left: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Related Posts */
|
||||
.related-posts-card h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.related-posts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.related-post-link {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-primary);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.related-post-link:hover {
|
||||
background: rgba(6, 182, 212, 0.05);
|
||||
border-color: var(--accent-primary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.related-post-link h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.related-post-tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.related-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.blog-post-container {
|
||||
grid-template-columns: 1fr; /* Stack on smaller screens */
|
||||
}
|
||||
.blog-post-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Table of Contents Generator -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const headings = document.querySelectorAll('.post-content h2, .post-content h3');
|
||||
if (headings.length === 0) return;
|
||||
|
||||
const tocContainer = document.getElementById('table-of-contents');
|
||||
if (!tocContainer) return;
|
||||
|
||||
const toc = document.createElement('ul');
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
// Add ID to heading if it doesn't have one
|
||||
if (!heading.id) {
|
||||
heading.id = `heading-${index}`;
|
||||
}
|
||||
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
a.href = `#${heading.id}`;
|
||||
a.textContent = heading.textContent;
|
||||
|
||||
// Add appropriate class based on heading level
|
||||
if (heading.tagName === 'H3') {
|
||||
li.style.paddingLeft = '1rem';
|
||||
}
|
||||
|
||||
li.appendChild(a);
|
||||
toc.appendChild(li);
|
||||
});
|
||||
|
||||
tocContainer.appendChild(toc);
|
||||
});
|
||||
</script>
|
||||
|
||||
{/* Script to ensure the knowledge graph initializes properly */}
|
||||
<script>
|
||||
// Knowledge Graph Initialization Helper
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Generate TOC as before
|
||||
generateToc();
|
||||
|
||||
// Ensure the knowledge graph initializes properly
|
||||
initializeKnowledgeGraph();
|
||||
});
|
||||
|
||||
function initializeKnowledgeGraph() {
|
||||
const graphContainer = document.querySelector('.mini-knowledge-graph-wrapper');
|
||||
|
||||
if (graphContainer && !graphContainer.dataset.initialized) {
|
||||
// Mark as initialized to prevent multiple initializations
|
||||
graphContainer.dataset.initialized = 'true';
|
||||
|
||||
// Ensure container is visible
|
||||
graphContainer.style.display = 'block';
|
||||
graphContainer.style.visibility = 'visible';
|
||||
graphContainer.style.height = '250px';
|
||||
|
||||
// Force a reflow/repaint
|
||||
void graphContainer.offsetHeight;
|
||||
|
||||
// Add debugging log
|
||||
console.log('Knowledge graph container found with dimensions:',
|
||||
graphContainer.offsetWidth, 'x', graphContainer.offsetHeight);
|
||||
|
||||
// Check if the container is visible in viewport
|
||||
const rect = graphContainer.getBoundingClientRect();
|
||||
const isVisible = rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth);
|
||||
|
||||
console.log('Knowledge graph is in viewport:', isVisible);
|
||||
|
||||
// If we have a script to initialize Cytoscape from MiniKnowledgeGraph.astro,
|
||||
// it should run automatically. This just ensures the container is ready.
|
||||
}
|
||||
}
|
||||
|
||||
// Also try after window load, when all resources are fully loaded
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(initializeKnowledgeGraph, 500);
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
// src/layouts/MinimalLayout.astro
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Minimal Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,630 @@
|
|||
---
|
||||
// src/pages/ansible/docs.astro - Converted from static ansible-docs.html
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Header from '../../components/Header.astro';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
|
||||
const title = "Ansible Sandbox Documentation | Argobox";
|
||||
const description = "Comprehensive documentation for the Ansible Sandbox playbooks. Learn about infrastructure automation, deployment patterns, and best practices.";
|
||||
|
||||
// Define sidebar structure (can be generated dynamically if needed)
|
||||
const sidebarNav = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
links: [
|
||||
{ href: "#introduction", text: "Introduction" },
|
||||
{ href: "#sandbox-overview", text: "Sandbox Overview" },
|
||||
{ href: "#infrastructure", text: "Infrastructure Design" },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Playbooks",
|
||||
links: [
|
||||
{ href: "#web-server", text: "Web Server Deployment" },
|
||||
{ href: "#docker-compose", text: "Docker Compose Stack" },
|
||||
{ href: "#k3s-cluster", text: "K3s Kubernetes Cluster" },
|
||||
{ href: "#lamp-stack", text: "LAMP Stack" },
|
||||
{ href: "#security-hardening", text: "Security Hardening" },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Advanced Topics",
|
||||
links: [
|
||||
{ href: "#custom-roles", text: "Custom Roles" },
|
||||
{ href: "#variables-inventory", text: "Variables & Inventory" },
|
||||
{ href: "#best-practices", text: "Best Practices" },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Reference",
|
||||
links: [
|
||||
{ href: "#cli-commands", text: "CLI Commands" },
|
||||
{ href: "#troubleshooting", text: "Troubleshooting" },
|
||||
]
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout {title} {description}>
|
||||
{/* Add Font Awesome if not loaded globally */}
|
||||
{/* <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" /> */}
|
||||
<Header slot="header" />
|
||||
|
||||
<div class="docs-container">
|
||||
<aside class="sidebar" id="docs-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1 class="sidebar-title">Ansible Docs</h1>
|
||||
<a href="/ansible/sandbox" class="back-link"> {/* Updated Link */}
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>Back to Sandbox</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{sidebarNav.map(section => (
|
||||
<div class="sidebar-section">
|
||||
<h2 class="sidebar-section-title">{section.title}</h2>
|
||||
<ul class="sidebar-nav">
|
||||
{section.links.map(link => (
|
||||
<li class="sidebar-nav-item">
|
||||
<a href={link.href} class="sidebar-nav-link">{link.text}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<div class="sidebar-section contact-info">
|
||||
<h2 class="sidebar-section-title">Need Help?</h2>
|
||||
<p>If you encounter issues or have questions, feel free to reach out:</p>
|
||||
<a href="mailto:daniel.laforce@argobox.com" class="contact-email">daniel.laforce@argobox.com</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content" id="main-docs-content">
|
||||
<header class="docs-header">
|
||||
<h1 class="docs-title">Ansible Sandbox Documentation</h1>
|
||||
<p class="docs-subtitle">Explore the playbooks, understand the infrastructure, and learn best practices for using the interactive Ansible Sandbox.</p>
|
||||
</header>
|
||||
|
||||
<!-- Introduction Section -->
|
||||
<section id="introduction" class="docs-section">
|
||||
<h2 class="docs-section-title">Introduction</h2>
|
||||
<p class="docs-text">
|
||||
Welcome to the documentation for the ArgoBox Ansible Sandbox. This interactive environment allows you to safely experiment with Ansible playbooks designed to manage various aspects of a modern infrastructure setup, including web servers, containerized applications, and Kubernetes clusters.
|
||||
</p>
|
||||
<p class="docs-text">
|
||||
This documentation provides details on the available playbooks, the underlying infrastructure design of the sandbox environment, and best practices for writing and testing your own automation scripts.
|
||||
</p>
|
||||
<div class="docs-note">
|
||||
<h3 class="docs-note-title"><i class="fas fa-info-circle"></i> Purpose</h3>
|
||||
<p>The primary goal of the sandbox is to provide a hands-on learning experience for Ansible in a pre-configured, safe environment that mirrors real-world scenarios.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sandbox Overview Section -->
|
||||
<section id="sandbox-overview" class="docs-section">
|
||||
<h2 class="docs-section-title">Sandbox Overview</h2>
|
||||
<p class="docs-text">
|
||||
The Ansible Sandbox provides you with temporary access to a set of virtual machines (VMs) where you can execute pre-defined Ansible playbooks or even upload and run your own. The environment is reset periodically to ensure a clean state for each session.
|
||||
</p>
|
||||
<h3 class="docs-subsection-title">Key Features</h3>
|
||||
<ul class="docs-list">
|
||||
<li>Isolated environment with multiple target VMs.</li>
|
||||
<li>Pre-loaded collection of common Ansible roles and playbooks.</li>
|
||||
<li>Ability to select and run specific playbooks via a web interface.</li>
|
||||
<li>Real-time output streaming of playbook execution.</li>
|
||||
<li>Network access between sandbox VMs for testing multi-tier applications.</li>
|
||||
<li>Environment reset functionality.</li>
|
||||
</ul>
|
||||
<a href="/ansible/sandbox" class="docs-button"> {/* Updated Link */}
|
||||
<i class="fab fa-ansible"></i> Launch Ansible Sandbox
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- Infrastructure Design Section -->
|
||||
<section id="infrastructure" class="docs-section">
|
||||
<h2 class="docs-section-title">Infrastructure Design</h2>
|
||||
<p class="docs-text">
|
||||
The sandbox environment consists of several virtual machines managed by the main ArgoBox infrastructure, typically running on Proxmox VE. These VMs are provisioned specifically for sandbox use and are isolated from the core production services.
|
||||
</p>
|
||||
<h3 class="docs-subsection-title">Components</h3>
|
||||
<table class="docs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Description</th>
|
||||
<th>OS</th>
|
||||
<th>Purpose</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Control Node</td>
|
||||
<td>The VM where Ansible commands are executed from.</td>
|
||||
<td>Debian 12</td>
|
||||
<td>Runs Ansible engine, hosts playbooks.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Web Server Node</td>
|
||||
<td>Target VM for web server playbooks (Nginx/Apache).</td>
|
||||
<td>Ubuntu 22.04</td>
|
||||
<td>Simulates a typical web server.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Database Node</td>
|
||||
<td>Target VM for database playbooks (PostgreSQL/MySQL).</td>
|
||||
<td>Debian 12</td>
|
||||
<td>Simulates a database server.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Docker Node</td>
|
||||
<td>Target VM with Docker installed for container playbooks.</td>
|
||||
<td>Ubuntu 22.04</td>
|
||||
<td>Runs Docker containers via Compose.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="docs-note">
|
||||
<h3 class="docs-note-title"><i class="fas fa-network-wired"></i> Networking</h3>
|
||||
<p>All sandbox VMs are placed on a dedicated, isolated VLAN with controlled access to simulate a realistic network environment.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Playbooks Section -->
|
||||
<section id="playbooks" class="docs-section">
|
||||
<h2 class="docs-section-title">Available Playbooks</h2>
|
||||
<p class="docs-text">The following playbooks are available for execution within the sandbox environment. Each playbook demonstrates different Ansible concepts and common infrastructure tasks.</p>
|
||||
|
||||
{/* Web Server Playbook */}
|
||||
<div id="web-server" class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Web Server Deployment (Nginx)</h3>
|
||||
<p class="docs-text">Installs and configures the Nginx web server on the 'Web Server Node'. Includes setting up a basic virtual host and ensuring the service is running.</p>
|
||||
<div class="docs-code">
|
||||
<pre><code>---
|
||||
- name: Deploy Nginx Web Server
|
||||
hosts: web_server_node # Corresponds to inventory group
|
||||
become: yes # Execute tasks with sudo
|
||||
tasks:
|
||||
- name: Update apt cache
|
||||
ansible.builtin.apt:
|
||||
update_cache: yes
|
||||
tags: [install]
|
||||
|
||||
- name: Install Nginx
|
||||
ansible.builtin.apt:
|
||||
name: nginx
|
||||
state: present
|
||||
tags: [install]
|
||||
|
||||
- name: Ensure Nginx service is started and enabled
|
||||
ansible.builtin.service:
|
||||
name: nginx
|
||||
state: started
|
||||
enabled: yes
|
||||
tags: [configure]
|
||||
|
||||
- name: Deploy basic index page (template example)
|
||||
ansible.builtin.template:
|
||||
src: templates/index.html.j2 # Example template path
|
||||
dest: /var/www/html/index.nginx-debian.html # Default Nginx path
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
tags: [configure]</code></pre>
|
||||
</div>
|
||||
|
||||
{/* Docker Compose Playbook */}
|
||||
<div id="docker-compose" class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Docker Compose Stack (Example: Portainer)</h3>
|
||||
<p class="docs-text">Deploys a simple Docker Compose application (e.g., Portainer) on the 'Docker Node'. Requires Docker and Docker Compose to be pre-installed on the target.</p>
|
||||
{/* Temporarily commented out Docker Compose example to debug build error */}
|
||||
{/*
|
||||
<div class="docs-code">
|
||||
<pre><code>---
|
||||
- name: Deploy Docker Compose Application
|
||||
hosts: docker_node
|
||||
become: yes
|
||||
vars:
|
||||
app_name: portainer
|
||||
app_dir: "/opt/{{ app_name }}"
|
||||
compose_file_url: "https://downloads.portainer.io/ce2-19/portainer-agent-stack.yml" # Example URL
|
||||
tasks:
|
||||
- name: Ensure application directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ app_dir }}"
|
||||
state: directory
|
||||
mode: '0755'
|
||||
|
||||
- name: Download Docker Compose file
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ compose_file_url }}"
|
||||
dest: "{{ app_dir }}/docker-compose.yml"
|
||||
mode: '0644'
|
||||
register: compose_download
|
||||
|
||||
- name: Deploy Docker Compose stack
|
||||
community.docker.docker_compose:
|
||||
project_src: "{{ app_dir }}"
|
||||
state: present # Ensures stack is up
|
||||
when: compose_download.changed # Only run if file was downloaded/updated
|
||||
</code></pre>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{/* K3s Playbook */}
|
||||
<div id="k3s-cluster" class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">K3s Kubernetes Cluster (Basic Setup)</h3>
|
||||
<p class="docs-text">Installs a single-node K3s cluster on the 'Control Node'. Note: This is a simplified setup for demonstration.</p>
|
||||
<div class="docs-code">
|
||||
<pre><code>---
|
||||
- name: Install K3s Single Node Cluster
|
||||
hosts: control_node # Install on the control node itself for demo
|
||||
become: yes
|
||||
tasks:
|
||||
- name: Download K3s installation script
|
||||
ansible.builtin.get_url:
|
||||
url: https://get.k3s.io
|
||||
dest: /tmp/k3s-install.sh
|
||||
mode: '0755'
|
||||
|
||||
- name: Execute K3s installation script
|
||||
ansible.builtin.command:
|
||||
cmd: /tmp/k3s-install.sh
|
||||
creates: /usr/local/bin/k3s # Avoid re-running if k3s exists
|
||||
register: k3s_install_result
|
||||
changed_when: k3s_install_result.rc == 0
|
||||
|
||||
- name: Ensure K3s service is started
|
||||
ansible.builtin.service:
|
||||
name: k3s
|
||||
state: started
|
||||
enabled: yes
|
||||
|
||||
- name: Wait for Kubeconfig to be available
|
||||
ansible.builtin.wait_for:
|
||||
path: /etc/rancher/k3s/k3s.yaml
|
||||
timeout: 60
|
||||
|
||||
- name: Read Kubeconfig file
|
||||
ansible.builtin.slurp:
|
||||
src: /etc/rancher/k3s/k3s.yaml
|
||||
register: k3s_kubeconfig
|
||||
|
||||
- name: Display Kubeconfig hint (for manual use)
|
||||
ansible.builtin.debug:
|
||||
msg: "K3s installed. Use 'sudo k3s kubectl get nodes' or copy Kubeconfig."
|
||||
</code></pre>
|
||||
</div>
|
||||
<div class="docs-warning">
|
||||
<h3 class="docs-warning-title"><i class="fas fa-exclamation-triangle"></i> Simplified Setup</h3>
|
||||
<p>This playbook installs a basic single-node K3s cluster. Production setups require more complex configuration, multiple nodes, and security considerations.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LAMP Stack Playbook */}
|
||||
<div id="lamp-stack" class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">LAMP Stack Installation</h3>
|
||||
<p class="docs-text">Installs Apache, MySQL (MariaDB), and PHP on the 'Web Server Node'.</p>
|
||||
{/* Add LAMP playbook code example here */}
|
||||
<p class="docs-text"><i>Code example for LAMP stack coming soon...</i></p>
|
||||
</div>
|
||||
|
||||
{/* Security Hardening Playbook */}
|
||||
<div id="security-hardening" class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Basic Security Hardening</h3>
|
||||
<p class="docs-text">Applies basic security measures like installing fail2ban and configuring UFW firewall rules on a target node.</p>
|
||||
{/* Add Security playbook code example here */}
|
||||
<p class="docs-text"><i>Code example for security hardening coming soon...</i></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Advanced Topics Section */}
|
||||
<section id="advanced-topics" class="docs-section">
|
||||
<h2 class="docs-section-title">Advanced Topics</h2>
|
||||
|
||||
<div id="custom-roles" class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Using Custom Roles</h3>
|
||||
<p class="docs-text">Learn how to structure your automation using Ansible Roles for better reusability and organization. Examples of common role structures will be provided.</p>
|
||||
<p class="docs-text"><i>Details on custom roles coming soon...</i></p>
|
||||
</div>
|
||||
|
||||
<div id="variables-inventory" class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Variables and Inventory Management</h3>
|
||||
<p class="docs-text">Understand how to manage variables for different environments (e.g., development, production) and how to define your infrastructure using Ansible inventory files (static and dynamic).</p>
|
||||
<p class="docs-text"><i>Details on variables and inventory coming soon...</i></p>
|
||||
</div>
|
||||
|
||||
<div id="best-practices" class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Ansible Best Practices</h3>
|
||||
<p class="docs-text">Explore recommended practices for writing effective, maintainable, and idempotent Ansible playbooks, including task naming, using handlers, and managing secrets.</p>
|
||||
<p class="docs-text"><i>Best practices details coming soon...</i></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Reference Section -->
|
||||
<section id="reference" class="docs-section">
|
||||
<h2 class="docs-section-title">Reference</h2>
|
||||
|
||||
<div id="cli-commands" class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Common CLI Commands</h3>
|
||||
<p class="docs-text">A quick reference for frequently used Ansible CLI commands.</p>
|
||||
<table class="docs-table">
|
||||
<thead>
|
||||
<tr><th>Command</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>ansible --version</code></td><td>Check Ansible version.</td></tr>
|
||||
<tr><td><code>ansible-inventory --list -i inventory.yml</code></td><td>List inventory hosts and groups.</td></tr>
|
||||
<tr><td><code>ansible all -m ping -i inventory.yml</code></td><td>Ping all hosts in inventory.</td></tr>
|
||||
<tr><td><code>ansible-playbook playbook.yml -i inventory.yml</code></td><td>Run a playbook.</td></tr>
|
||||
<tr><td><code>ansible-playbook playbook.yml --check</code></td><td>Perform a dry run of a playbook.</td></tr>
|
||||
<tr><td><code>ansible-playbook playbook.yml --limit web_servers</code></td><td>Run playbook only on specific hosts/groups.</td></tr>
|
||||
<tr><td><code>ansible-vault create secrets.yml</code></td><td>Create an encrypted vault file.</td></tr>
|
||||
<tr><td><code>ansible-playbook playbook.yml --ask-vault-pass</code></td><td>Run playbook asking for vault password.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="troubleshooting" class="docs-subsection">
|
||||
<h3 class="docs-subsection-title">Troubleshooting Tips</h3>
|
||||
<p class="docs-text">Common issues and how to resolve them when working with the sandbox or Ansible in general.</p>
|
||||
<ul class="docs-list">
|
||||
<li><strong>Connection Errors:</strong> Ensure SSH keys are correctly configured and target VMs are reachable. Check firewall rules.</li>
|
||||
<li><strong>Permission Denied:</strong> Use `become: yes` for tasks requiring root privileges. Verify sudo configuration on target nodes.</li>
|
||||
<li><strong>Module Not Found:</strong> Ensure required Ansible collections or Python libraries are installed on the control node.</li>
|
||||
<li><strong>Idempotency Issues:</strong> Review tasks to ensure they only make changes when necessary (e.g., use `creates` argument for command/shell modules).</li>
|
||||
<li><strong>Variable Undefined:</strong> Check variable definitions, scope (host_vars, group_vars), and precedence. Use `-v` or `-vvv` for verbose output.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="docs-footer">
|
||||
Last updated: April 27, 2025 | Need more help? Visit the <a href="https://docs.ansible.com/" target="_blank" rel="noopener noreferrer">Official Ansible Documentation</a>.
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<button class="mobile-menu-toggle" id="mobile-menu-btn" aria-label="Toggle Documentation Menu">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
|
||||
<Footer /> {/* Assuming Footer is global */}
|
||||
</BaseLayout>
|
||||
|
||||
<style is:global>
|
||||
/* Import base styles if needed, or rely on BaseLayout */
|
||||
/* @import url('../styles/theme.css'); */
|
||||
|
||||
/* Styles specific to the docs layout */
|
||||
.docs-container {
|
||||
display: flex;
|
||||
/* Adjust top/bottom margin if header/footer height changes */
|
||||
/* margin-top: 4.5rem; */ /* Assuming header height */
|
||||
/* min-height: calc(100vh - 4.5rem - 3rem); */ /* Full height minus header/footer */
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background-color: var(--sidebar-bg, var(--bg-secondary)); /* Use theme var with fallback */
|
||||
border-right: 1px solid var(--border);
|
||||
position: sticky; /* Make sidebar sticky */
|
||||
top: 4.5rem; /* Stick below header */
|
||||
height: calc(100vh - 4.5rem); /* Full height minus header */
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
padding: 2rem 0;
|
||||
flex-shrink: 0; /* Prevent shrinking */
|
||||
}
|
||||
.sidebar::-webkit-scrollbar { width: 6px; }
|
||||
.sidebar::-webkit-scrollbar-track { background: transparent; }
|
||||
.sidebar::-webkit-scrollbar-thumb { background-color: var(--border); border-radius: 3px; }
|
||||
|
||||
.sidebar-header { padding: 0 1.5rem; margin-bottom: 2rem; }
|
||||
.sidebar-title { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary); }
|
||||
.back-link { display: flex; align-items: center; gap: 0.5rem; color: var(--accent); font-weight: 500; font-size: 0.9rem; padding: 0.5rem 0; transition: all var(--transition-normal); text-decoration: none; }
|
||||
.back-link:hover { color: var(--accent-darker); transform: translateX(-3px); }
|
||||
|
||||
.sidebar-section { margin-bottom: 1.5rem; }
|
||||
.sidebar-section-title { font-weight: 600; font-size: 0.85rem; text-transform: uppercase; color: var(--text-secondary); letter-spacing: 0.05em; padding: 0 1.5rem; margin-bottom: 0.75rem; }
|
||||
.sidebar-nav { list-style: none; padding: 0; margin: 0; }
|
||||
.sidebar-nav-item { /* No extra styles needed */ }
|
||||
.sidebar-nav-link { display: block; padding: 0.5rem 1.5rem; color: var(--text-secondary); text-decoration: none; transition: all var(--transition-normal); position: relative; font-size: 0.95rem; border-left: 3px solid transparent; /* Add space for active indicator */ }
|
||||
.sidebar-nav-link:hover { color: var(--text-primary); background-color: rgba(255, 255, 255, 0.05); }
|
||||
.sidebar-nav-link.active { color: var(--accent); background-color: rgba(59, 130, 246, 0.1); border-left-color: var(--accent); font-weight: 500; }
|
||||
/* .sidebar-nav-link.active::before { content: ''; position: absolute; top: 0; left: 0; height: 100%; width: 3px; background: var(--accent-gradient); } */ /* Alternative active style */
|
||||
|
||||
.contact-info { padding: 0 1.5rem; margin-top: 2rem; }
|
||||
.contact-info p { margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-secondary); }
|
||||
.contact-email { color: var(--accent); font-weight: 600; text-decoration: none; }
|
||||
.contact-email:hover { text-decoration: underline; }
|
||||
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 3rem;
|
||||
max-width: 1000px; /* Adjust max width as needed */
|
||||
margin-left: 280px; /* Ensure space for fixed sidebar */
|
||||
}
|
||||
|
||||
.docs-header { margin-bottom: 3rem; border-bottom: 1px solid var(--border); padding-bottom: 1.5rem; }
|
||||
.docs-title { font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem; color: var(--text-primary); }
|
||||
.docs-subtitle { color: var(--text-secondary); font-size: 1.2rem; }
|
||||
|
||||
.docs-section { margin-bottom: 3rem; scroll-margin-top: 6rem; /* Offset for sticky header */ }
|
||||
.docs-section-title { font-size: 1.75rem; font-weight: 600; margin-bottom: 1.5rem; color: var(--text-primary); position: relative; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||
.docs-subsection { margin-bottom: 2rem; }
|
||||
.docs-subsection-title { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; color: var(--accent); }
|
||||
|
||||
.docs-text { color: var(--text-secondary); margin-bottom: 1.5rem; line-height: 1.7; }
|
||||
.docs-text a { color: var(--accent); text-decoration: underline; }
|
||||
.docs-text a:hover { color: var(--accent-darker); }
|
||||
|
||||
.docs-list { list-style-type: disc; margin-left: 1.5rem; margin-bottom: 1.5rem; color: var(--text-secondary); padding-left: 1rem; }
|
||||
.docs-list li { margin-bottom: 0.5rem; }
|
||||
|
||||
.docs-code {
|
||||
font-family: var(--font-mono); /* Use theme mono font */
|
||||
background-color: var(--bg-code); /* Use theme code bg */
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-code); /* Use theme code text color */
|
||||
}
|
||||
.docs-code pre { margin: 0; }
|
||||
/* Add syntax highlighting styles if using Prism/Shiki integrated with Astro */
|
||||
|
||||
.docs-table { width: 100%; border-collapse: collapse; margin-bottom: 1.5rem; font-size: 0.9rem; }
|
||||
.docs-table th { text-align: left; padding: 0.75rem 1rem; background-color: var(--bg-secondary); color: var(--text-primary); font-weight: 600; border-bottom: 2px solid var(--border); }
|
||||
.docs-table td { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); color: var(--text-secondary); }
|
||||
.docs-table tr:last-child td { border-bottom: none; }
|
||||
.docs-table code { font-family: var(--font-mono); background-color: rgba(255, 255, 255, 0.05); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
|
||||
|
||||
.docs-note, .docs-warning {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.docs-note { background-color: rgba(59, 130, 246, 0.1); border-left: 4px solid var(--accent); }
|
||||
.docs-warning { background-color: rgba(245, 158, 11, 0.1); border-left: 4px solid var(--warning); }
|
||||
.docs-note-title, .docs-warning-title { font-weight: 600; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.docs-note-title { color: var(--accent); }
|
||||
.docs-warning-title { color: var(--warning); }
|
||||
.docs-note p, .docs-warning p { margin: 0; color: var(--text-secondary); }
|
||||
|
||||
.docs-button { display: inline-flex; align-items: center; gap: 0.5rem; background-color: var(--accent); color: white; padding: 0.75rem 1.25rem; border-radius: 0.5rem; font-weight: 500; text-decoration: none; transition: all var(--transition-normal); margin-bottom: 1.5rem; border: none; cursor: pointer; }
|
||||
.docs-button:hover { background-color: var(--accent-darker); transform: translateY(-2px); box-shadow: var(--card-shadow); }
|
||||
|
||||
.docs-footer { border-top: 1px solid var(--border); margin-top: 3rem; padding-top: 2rem; color: var(--text-secondary); font-size: 0.9rem; }
|
||||
.docs-footer a { color: var(--accent); }
|
||||
.docs-footer a:hover { color: var(--accent-darker); }
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: none; /* Hidden by default */
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1010; /* Above sidebar */
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.sidebar {
|
||||
position: fixed; /* Change to fixed for mobile overlay */
|
||||
top: 0; /* Align to top */
|
||||
left: 0;
|
||||
height: 100vh; /* Full viewport height */
|
||||
z-index: 1000; /* Ensure it's above content */
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--transition-normal);
|
||||
background-color: var(--sidebar-bg, var(--bg-secondary)); /* Ensure background */
|
||||
padding-top: 5.5rem; /* Space for header */
|
||||
}
|
||||
.sidebar.active { transform: translateX(0); }
|
||||
.main-content { margin-left: 0; padding: 2rem 1.5rem; margin-top: 4.5rem; /* Space for header */ }
|
||||
.mobile-menu-toggle { display: flex; }
|
||||
.docs-container { margin-top: 0; margin-bottom: 0; min-height: 100vh; } /* Adjust container */
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Docs Page Specific JS
|
||||
|
||||
// Mobile Sidebar Toggle
|
||||
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
|
||||
const sidebar = document.getElementById('docs-sidebar');
|
||||
const mainContent = document.getElementById('main-docs-content'); // Target main content
|
||||
|
||||
if (mobileMenuBtn && sidebar) {
|
||||
mobileMenuBtn.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('active');
|
||||
// Optional: Add overlay or dim main content when sidebar is open
|
||||
if (sidebar.classList.contains('active')) {
|
||||
mobileMenuBtn.innerHTML = '<i class="fas fa-times"></i>'; // Change icon to X
|
||||
// Add overlay logic if desired
|
||||
} else {
|
||||
mobileMenuBtn.innerHTML = '<i class="fas fa-bars"></i>'; // Change icon back to bars
|
||||
// Remove overlay logic if desired
|
||||
}
|
||||
});
|
||||
|
||||
// Close sidebar when clicking outside of it (on main content)
|
||||
mainContent?.addEventListener('click', () => {
|
||||
if (sidebar.classList.contains('active')) {
|
||||
sidebar.classList.remove('active');
|
||||
mobileMenuBtn.innerHTML = '<i class="fas fa-bars"></i>';
|
||||
}
|
||||
});
|
||||
|
||||
// Close sidebar when a link is clicked
|
||||
sidebar.querySelectorAll('.sidebar-nav-link').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
if (sidebar.classList.contains('active')) {
|
||||
sidebar.classList.remove('active');
|
||||
mobileMenuBtn.innerHTML = '<i class="fas fa-bars"></i>';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Active Link Highlighting (Scroll Spy)
|
||||
const sections = document.querySelectorAll('.docs-section');
|
||||
const navLinks = document.querySelectorAll('.sidebar-nav-link');
|
||||
|
||||
function activateLink() {
|
||||
let currentSectionId = '';
|
||||
const scrollPosition = window.scrollY;
|
||||
|
||||
sections.forEach(section => {
|
||||
const sectionTop = section.offsetTop - 150; // Adjust offset as needed
|
||||
const sectionHeight = section.offsetHeight;
|
||||
const sectionId = section.getAttribute('id');
|
||||
|
||||
if (scrollPosition >= sectionTop && scrollPosition < sectionTop + sectionHeight) {
|
||||
currentSectionId = sectionId;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle reaching the bottom of the page
|
||||
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 50) {
|
||||
const lastSection = sections[sections.length - 1];
|
||||
if (lastSection) {
|
||||
currentSectionId = lastSection.getAttribute('id');
|
||||
}
|
||||
}
|
||||
|
||||
navLinks.forEach(link => {
|
||||
link.classList.remove('active');
|
||||
if (link.getAttribute('href') === `#${currentSectionId}`) {
|
||||
link.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure first link is active if scrolled to top
|
||||
if (window.scrollY < 100 && navLinks.length > 0) {
|
||||
navLinks.forEach(link => link.classList.remove('active'));
|
||||
navLinks[0].classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', activateLink);
|
||||
// Initial check on load
|
||||
document.addEventListener('DOMContentLoaded', activateLink);
|
||||
|
||||
</script>
|
|
@ -0,0 +1,323 @@
|
|||
---
|
||||
// src/pages/ansible/help.astro - Converted from static ansible-help.html
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Header from '../../components/Header.astro';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
|
||||
const title = "Ansible Sandbox Help | Argobox";
|
||||
const description = "Help guide for using the Ansible Sandbox environment. Learn how to deploy infrastructure as code demonstrations.";
|
||||
---
|
||||
|
||||
<BaseLayout {title} {description}>
|
||||
{/* Add Font Awesome if not loaded globally */}
|
||||
{/* <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" /> */}
|
||||
<Header slot="header" />
|
||||
|
||||
<main class="main-content">
|
||||
<div class="container">
|
||||
<header class="help-header">
|
||||
<a href="/ansible/sandbox" class="back-link"> {/* Updated Link */}
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
Back to Ansible Sandbox
|
||||
</a>
|
||||
<h1 class="help-title">Ansible Sandbox Help Guide</h1>
|
||||
<p class="help-subtitle">
|
||||
Learn how to use and get the most out of the Ansible Sandbox environment.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="help-section">
|
||||
<h2 class="help-section-title">Getting Started</h2>
|
||||
|
||||
<div class="step-grid">
|
||||
<div class="step-number">1</div>
|
||||
<div class="step-title">Select a Playbook</div>
|
||||
<div class="step-content">
|
||||
<p>The sandbox offers various Ansible playbooks showcasing different infrastructure automation scenarios. Choose one from the left panel based on your interests.</p>
|
||||
<p>Each playbook comes with a description and information about its complexity level and typical run time.</p>
|
||||
</div>
|
||||
{/* Optional: Add placeholder image if available <div class="step-image"><img src="/images/help/step1.png" alt="Select Playbook Step"></div> */}
|
||||
</div>
|
||||
|
||||
<div class="step-grid">
|
||||
<div class="step-number">2</div>
|
||||
<div class="step-title">Explore the Playbook Code</div>
|
||||
<div class="step-content">
|
||||
<p>View the Ansible YAML code to understand how the automation works. The code is syntax-highlighted for readability.</p>
|
||||
<p>Hover over different sections to see what each part of the playbook does.</p>
|
||||
</div>
|
||||
{/* Optional: Add placeholder image <div class="step-image"><img src="/images/help/step2.png" alt="Explore Code Step"></div> */}
|
||||
</div>
|
||||
|
||||
<div class="step-grid">
|
||||
<div class="step-number">3</div>
|
||||
<div class="step-title">Configure Parameters</div>
|
||||
<div class="step-content">
|
||||
<p>Switch to the "Configuration" tab to customize various parameters for your deployment. These might include:</p>
|
||||
<ul class="config-list">
|
||||
<li>Domain names</li>
|
||||
<li>Directory paths</li>
|
||||
<li>Feature toggles</li>
|
||||
<li>VM template selection</li>
|
||||
<li>Resource allocation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-grid">
|
||||
<div class="step-number">4</div>
|
||||
<div class="step-title">Deploy the Environment</div>
|
||||
<div class="step-content">
|
||||
<p>Click the "Deploy" button to launch the automation process. The system will:</p>
|
||||
<ol class="deploy-steps-list">
|
||||
<li>Create necessary virtual machines</li>
|
||||
<li>Run the selected Ansible playbook</li>
|
||||
<li>Configure all services</li>
|
||||
<li>Provide access to the deployed environment</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-grid">
|
||||
<div class="step-number">5</div>
|
||||
<div class="step-title">Monitor Progress</div>
|
||||
<div class="step-content">
|
||||
<p>Watch the deployment process in real-time on the "Output" tab, which shows the Ansible execution log.</p>
|
||||
<p>Once deployment completes, you'll see a success message with details on how to access the deployed environment.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-grid">
|
||||
<div class="step-number">6</div>
|
||||
<div class="step-title">Explore the Environment</div>
|
||||
<div class="step-content">
|
||||
<p>After deployment, you can:</p>
|
||||
<ul class="explore-list">
|
||||
<li>View the VM status and details in the "VM Status" tab</li>
|
||||
<li>Access deployed applications via provided URLs</li>
|
||||
<li>See resource utilization metrics</li>
|
||||
<li>Monitor the environment's remaining active time</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip">
|
||||
<div class="tip-title">
|
||||
<i class="fas fa-lightbulb"></i>
|
||||
<span>Pro Tip</span>
|
||||
</div>
|
||||
<p>
|
||||
Sandbox environments automatically shut down after 30 minutes to conserve resources. You'll see a countdown timer showing the remaining time. Make sure to complete your exploration before time runs out!
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-section">
|
||||
<h2 class="help-section-title">Frequently Asked Questions</h2>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">What is an Ansible playbook?</div>
|
||||
<div class="faq-answer">
|
||||
An Ansible playbook is a YAML file that describes a set of tasks to be executed on remote servers. Playbooks define the desired state of systems and can configure applications, deploy software, and orchestrate advanced IT workflows.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">Is the sandbox environment isolated?</div>
|
||||
<div class="faq-answer">
|
||||
Yes, each sandbox environment is completely isolated. Your deployments and configurations won't affect other users or any production systems. This provides a safe space to experiment with infrastructure automation.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">Can I save or download the playbooks?</div>
|
||||
<div class="faq-answer">
|
||||
Yes, you can copy the playbook code to use in your own environments. Each code segment can be selected and copied to your clipboard. For a more organized approach, visit the <a href="/ansible/docs">Documentation page</a> for downloadable versions of all playbooks. {/* Updated Link */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">What happens if I need more time with an environment?</div>
|
||||
<div class="faq-answer">
|
||||
Currently, all sandbox environments are limited to 30 minutes. If you need more time, you can always redeploy the environment after it expires, which will give you a fresh 30-minute window to continue your exploration.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">Can I modify the playbooks?</div>
|
||||
<div class="faq-answer">
|
||||
The playbook code displayed is read-only to ensure consistent and reliable deployments. However, you can customize many aspects of the deployment through the Configuration tab, which allows you to adjust key parameters without modifying the underlying code.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item">
|
||||
<div class="faq-question">What if I encounter an error during deployment?</div>
|
||||
<div class="faq-answer">
|
||||
If an error occurs during deployment, the Output tab will display the specific error message from Ansible. You can use the "Reset" button to clear the environment and try again, potentially with different configuration options.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="help-section">
|
||||
<h2 class="help-section-title">Key Terms</h2>
|
||||
<div class="key-terms-list">
|
||||
<p><span class="key-term">Playbook</span> - A YAML file defining a series of Ansible tasks and configurations.</p>
|
||||
<p><span class="key-term">Task</span> - An individual action Ansible executes on a managed host.</p>
|
||||
<p><span class="key-term">Role</span> - A reusable unit of tasks, variables, files, etc.</p>
|
||||
<p><span class="key-term">Inventory</span> - A list of hosts managed by Ansible.</p>
|
||||
<p><span class="key-term">Handler</span> - A special task triggered by a change notification.</p>
|
||||
<p><span class="key-term">Variable</span> - A value used to make playbooks flexible.</p>
|
||||
<p><span class="key-term">Template</span> - A file using variables to create dynamic configuration files (e.g., Jinja2).</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="help-footer">
|
||||
<p>Need more assistance? <a href="mailto:daniel.laforce@argobox.com">Contact Support</a> or visit the <a href="/ansible/docs">Full Documentation</a>.</p> {/* Updated Link */}
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer slot="footer" />
|
||||
</BaseLayout>
|
||||
|
||||
<style is:global>
|
||||
/* Styles adapted from ansible-help.html and styles.css */
|
||||
/* Rely on BaseLayout for body, container, etc. */
|
||||
|
||||
.main-content {
|
||||
/* Adjust top margin if header height changes */
|
||||
/* margin-top: 4.5rem; */
|
||||
padding-top: 2rem; /* Add padding */
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.help-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
position: relative;
|
||||
padding-top: 1rem; /* Add space from header */
|
||||
}
|
||||
.help-title {
|
||||
font-size: clamp(1.8rem, 5vw, 2.5rem); /* Responsive title */
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.help-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: clamp(1rem, 3vw, 1.2rem);
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.back-link {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: inline-flex; /* Use inline-flex */
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-normal);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.back-link:hover { color: var(--accent-darker); transform: translateX(-3px); }
|
||||
|
||||
.help-section {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
padding: clamp(1.5rem, 4vw, 2rem); /* Responsive padding */
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.help-section-title {
|
||||
font-size: clamp(1.25rem, 4vw, 1.5rem);
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--accent);
|
||||
position: relative;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border); /* Use border instead of ::after */
|
||||
}
|
||||
/* .help-section-title::after { content: ''; position: absolute; bottom: 0; left: 0; width: 50px; height: 3px; background: var(--accent-gradient); border-radius: 3px; } */
|
||||
|
||||
.step-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 1rem 1rem; /* Reduced gap */
|
||||
margin-bottom: 1.5rem;
|
||||
align-items: start; /* Align items to start */
|
||||
}
|
||||
.step-number {
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
grid-row: 1 / span 2; /* Span number across title/content */
|
||||
margin-top: 0.1rem; /* Align better with title */
|
||||
}
|
||||
.step-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
/* align-self: center; */ /* Removed */
|
||||
color: var(--text-primary);
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
margin: 0; /* Remove default margins */
|
||||
}
|
||||
.step-content {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.step-content p { margin-bottom: 1rem; }
|
||||
.step-content p:last-child { margin-bottom: 0; }
|
||||
.step-content ul, .step-content ol {
|
||||
list-style-position: outside;
|
||||
margin-left: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.step-content li { margin-bottom: 0.5rem; }
|
||||
|
||||
.tip {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border-left: 4px solid var(--accent);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.tip-title { font-weight: 600; color: var(--accent); display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.tip p { margin: 0; color: var(--text-secondary); }
|
||||
|
||||
.faq-item { margin-bottom: 1.5rem; }
|
||||
.faq-question { font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary); }
|
||||
.faq-answer { color: var(--text-secondary); font-size: 0.95rem; }
|
||||
|
||||
.key-terms-list p { margin-bottom: 0.75rem; }
|
||||
.key-term { font-weight: 500; color: var(--accent); font-family: var(--font-mono); background-color: rgba(59, 130, 246, 0.1); padding: 0.1em 0.3em; border-radius: 3px; }
|
||||
|
||||
.help-footer { text-align: center; margin-top: 3rem; color: var(--text-secondary); font-size: 0.9rem; }
|
||||
.help-footer a { color: var(--accent); text-decoration: none; }
|
||||
.help-footer a:hover { text-decoration: underline; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.step-grid { grid-template-columns: 1fr; gap: 0.5rem; }
|
||||
.step-number { grid-row: auto; margin-bottom: 0.5rem; justify-self: start; }
|
||||
.step-title { grid-column: 1; grid-row: auto; }
|
||||
.step-content { grid-column: 1; grid-row: auto; }
|
||||
.back-link { position: static; margin-bottom: 1rem; }
|
||||
.help-header { padding-top: 0; }
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,625 @@
|
|||
---
|
||||
// src/pages/ansible/sandbox.astro - Converted from static ansible-sandbox.html
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Header from '../../components/Header.astro';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
|
||||
const title = "Ansible Sandbox | Argobox";
|
||||
const description = "Deploy interactive visualizations with ArgoBox Ansible Sandbox - the easiest way to deploy web animations and showcase your creative work.";
|
||||
|
||||
// Template data (can be externalized later)
|
||||
const templates = [
|
||||
{ id: "fireworks", name: "Fireworks", description: "Interactive fireworks animation with click-to-launch effects", icon: "fas fa-fire", tags: ["Popular"], complexity: "Basic" },
|
||||
{ id: "matrix", name: "Matrix Rain", description: "Digital rain effect inspired by The Matrix movie", icon: "fas fa-code", tags: [], complexity: "Intermediate" },
|
||||
{ id: "starfield", name: "Starfield", description: "3D space journey through a field of stars", icon: "fas fa-star", tags: [], complexity: "Basic" },
|
||||
{ id: "particles", name: "Particles", description: "Interactive particle system that responds to mouse movement", icon: "fas fa-atom", tags: [], complexity: "Intermediate" },
|
||||
{ id: "3d-globe", name: "3D Globe", description: "Interactive 3D Earth globe with custom markers", icon: "fas fa-globe-americas", tags: ["New"], complexity: "Advanced" }
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout {title} {description}>
|
||||
{/* Add Font Awesome if not loaded globally */}
|
||||
{/* <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" /> */}
|
||||
<Header slot="header" />
|
||||
|
||||
<main class="container sandbox-page-container"> {/* Use specific container class */}
|
||||
|
||||
<!-- Sandbox Container -->
|
||||
<div class="sandbox-container card"> {/* Re-use card style for main container */}
|
||||
|
||||
<!-- Simulation Toggle -->
|
||||
<div class="simulation-toggle">
|
||||
<span class="toggle-label">Simulation Mode</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="simulation-toggle" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span class="toggle-status">Active</span>
|
||||
</div>
|
||||
|
||||
<!-- Offline Notice -->
|
||||
<div class="offline-notice">
|
||||
<div class="offline-notice-icon">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<div class="offline-notice-text">
|
||||
<h3>Ansible Sandbox is Currently Offline</h3>
|
||||
<p>The Ansible Sandbox environment is currently in simulation mode. You can explore the interface, but actual deployments are not available at this time. We're working to bring the full functionality online soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sandbox Header -->
|
||||
<div class="sandbox-header">
|
||||
<div class="sandbox-title">
|
||||
<h1>Template Deployment</h1>
|
||||
</div>
|
||||
<div class="sandbox-actions">
|
||||
<a href="/ansible/help" class="sandbox-btn"> {/* Updated Link */}
|
||||
<i class="fas fa-question-circle"></i>
|
||||
<span>Help</span>
|
||||
</a>
|
||||
<a href="/ansible/docs" class="sandbox-btn"> {/* Updated Link */}
|
||||
<i class="fas fa-book"></i>
|
||||
<span>Documentation</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Selection Section -->
|
||||
<div class="templates-section">
|
||||
<h2 class="section-title">Select a Template to Deploy</h2>
|
||||
<p class="section-description">Choose from our collection of interactive visualizations to deploy with Ansible automation.</p>
|
||||
|
||||
<div class="templates-grid">
|
||||
{templates.map(template => (
|
||||
<div class="template-card" data-template={template.id}>
|
||||
<div class="template-preview">
|
||||
<i class={template.icon}></i>
|
||||
</div>
|
||||
<div class="template-content">
|
||||
<h3 class="template-name">{template.name}</h3>
|
||||
<p class="template-description">{template.description}</p>
|
||||
<div class="template-meta">
|
||||
{template.tags.map(tag => <span class="template-tag">{tag}</span>)}
|
||||
<span class="template-complexity">{template.complexity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="deployment-actions">
|
||||
<button id="preview-btn" class="sandbox-btn" disabled>
|
||||
<i class="fas fa-eye"></i>
|
||||
<span>Preview Template</span>
|
||||
</button>
|
||||
<button id="deploy-btn" class="sandbox-btn btn-primary" disabled>
|
||||
<i class="fas fa-rocket"></i>
|
||||
<span>Deploy Now</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deployment Status Section (Initially Hidden) -->
|
||||
<div id="deployment-section" class="deployment-section" style="display: none;">
|
||||
<h2 class="section-title">Deployment Progress</h2>
|
||||
<div class="status-container">
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-value" id="deployment-progress"></div>
|
||||
</div>
|
||||
<div class="progress-label" id="progress-text">Initializing deployment...</div>
|
||||
</div>
|
||||
<div class="deployment-steps">
|
||||
<div class="deployment-step pending" id="step-init">
|
||||
<div class="step-icon">1</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Initialization</div>
|
||||
<div class="step-description">Setting up deployment environment</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="deployment-step pending" id="step-template">
|
||||
<div class="step-icon">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Template Preparation</div>
|
||||
<div class="step-description">Configuring selected template</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="deployment-step pending" id="step-deploy">
|
||||
<div class="step-icon">3</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Deployment</div>
|
||||
<div class="step-description">Executing Ansible deployment</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="deployment-step pending" id="step-publish">
|
||||
<div class="step-icon">4</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Publishing</div>
|
||||
<div class="step-description">Making your site live</div> {/* Simplified */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="deployment-details">
|
||||
<div class="deployment-info" id="deployment-info">
|
||||
<p>Deployment information will appear here once the process begins.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deployment Result Section (Initially Hidden) -->
|
||||
<div id="result-section" class="result-section" style="display: none;">
|
||||
<div class="success-card">
|
||||
<div class="success-icon">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</div>
|
||||
<h2>Deployment Successful!</h2>
|
||||
<p>Your template has been deployed and is now live.</p>
|
||||
<div class="deployment-url-container">
|
||||
<p>You can access your deployment at:</p>
|
||||
<a href="#" class="deployment-url" id="deployment-url" target="_blank" rel="noopener noreferrer">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
<span id="deployment-url-text">https://u12345.argobox.com</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button id="view-deployment-btn" class="sandbox-btn btn-primary">
|
||||
<i class="fas fa-eye"></i>
|
||||
<span>View Deployment</span>
|
||||
</button>
|
||||
<button id="new-deployment-btn" class="sandbox-btn">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span>New Deployment</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sandbox Footer -->
|
||||
<div class="sandbox-footer">
|
||||
<a href="/" class="back-to-site"> {/* Updated Link */}
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
<span>Back to Main Site</span>
|
||||
</a>
|
||||
<div class="footer-info">Template deployments automatically expire after 30 minutes.</div> {/* Adjusted expiry */}
|
||||
</div>
|
||||
|
||||
</div> {/* End sandbox-container */}
|
||||
</main>
|
||||
|
||||
<Footer slot="footer" />
|
||||
</BaseLayout>
|
||||
|
||||
<style is:global>
|
||||
/* Styles adapted from ansible-sandbox.html and styles.css */
|
||||
/* Use theme variables where possible */
|
||||
|
||||
.sandbox-page-container {
|
||||
padding-top: 2rem; /* Add padding above main card */
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.card { /* Style for the main sandbox container */
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 0.75rem; /* Slightly larger radius */
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: clamp(1.5rem, 5vw, 2.5rem); /* Responsive padding */
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Simulation Toggle Styles */
|
||||
.simulation-toggle { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; background-color: var(--bg-secondary); padding: 0.75rem 1rem; border-radius: 0.375rem; border: 1px solid var(--border); width: fit-content; }
|
||||
.toggle-label { font-size: 0.875rem; font-weight: 500; color: var(--text-secondary); }
|
||||
.toggle-switch { position: relative; display: inline-block; width: 40px; height: 20px; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--text-secondary); transition: .4s; border-radius: 20px; }
|
||||
.toggle-slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 2px; bottom: 2px; background-color: white; transition: .4s; border-radius: 50%; }
|
||||
input:checked + .toggle-slider { background-color: var(--accent); }
|
||||
input:checked + .toggle-slider:before { transform: translateX(20px); }
|
||||
.toggle-status { font-size: 0.875rem; font-weight: 500; color: var(--text-primary); }
|
||||
|
||||
/* Offline Notice Styles */
|
||||
.offline-notice { display: flex; align-items: center; background-color: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.3); border-left: 4px solid var(--warning); padding: 1rem 1.5rem; border-radius: 0.5rem; margin-bottom: 2rem; }
|
||||
.offline-notice-icon { font-size: 1.5rem; color: var(--warning); margin-right: 1rem; }
|
||||
.offline-notice-text h3 { font-size: 1.1rem; font-weight: 600; color: var(--text-primary); margin-bottom: 0.25rem; }
|
||||
.offline-notice-text p { font-size: 0.9rem; color: var(--text-secondary); margin: 0; }
|
||||
|
||||
/* Sandbox Header Styles */
|
||||
.sandbox-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); flex-wrap: wrap; gap: 1rem; /* Allow wrapping */ }
|
||||
.sandbox-title h1 { font-size: clamp(1.5rem, 4vw, 1.8rem); margin: 0; color: var(--text-primary); }
|
||||
.sandbox-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.sandbox-btn { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; background-color: var(--bg-secondary); color: var(--text-secondary); border: 1px solid var(--border); border-radius: 0.375rem; text-decoration: none; font-size: 0.875rem; font-weight: 500; transition: all var(--transition-normal); cursor: pointer; }
|
||||
.sandbox-btn:hover { background-color: var(--bg-tertiary); color: var(--text-primary); border-color: var(--text-secondary); }
|
||||
.sandbox-btn.btn-primary { background-color: var(--accent); color: white; border-color: var(--accent); }
|
||||
.sandbox-btn.btn-primary:hover { background-color: var(--accent-darker); border-color: var(--accent-darker); }
|
||||
.sandbox-btn:disabled { opacity: 0.6; cursor: not-allowed; background-color: var(--bg-secondary); color: var(--text-secondary); border-color: var(--border); }
|
||||
.sandbox-btn.btn-primary:disabled { background-color: var(--accent); opacity: 0.5; border-color: var(--accent); }
|
||||
|
||||
/* Template Deployment Styles */
|
||||
.section-title { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary); }
|
||||
.section-description { font-size: 0.95rem; color: var(--text-secondary); margin-bottom: 2rem; }
|
||||
.templates-section { margin-bottom: 3rem; }
|
||||
.templates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; } /* Slightly smaller minmax */
|
||||
.template-card { background-color: var(--card-bg); border: 1px solid var(--border); border-radius: 0.75rem; overflow: hidden; cursor: pointer; transition: all var(--transition-normal); display: flex; flex-direction: column; }
|
||||
.template-card:hover { transform: translateY(-5px); box-shadow: var(--card-shadow); border-color: var(--accent); }
|
||||
.template-card.selected { border: 2px solid var(--accent); box-shadow: 0 0 0 1px var(--accent), var(--card-shadow); background-color: rgba(59, 130, 246, 0.05); }
|
||||
.template-preview { height: 140px; background-color: var(--bg-secondary); display: flex; align-items: center; justify-content: center; font-size: 3rem; color: var(--accent); overflow: hidden; position: relative; border-bottom: 1px solid var(--border); }
|
||||
.template-preview img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.template-content { padding: 1.25rem; flex: 1; display: flex; flex-direction: column; }
|
||||
.template-name { font-size: 1.2rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary); }
|
||||
.template-description { font-size: 0.9rem; color: var(--text-secondary); margin-bottom: 1rem; flex: 1; }
|
||||
.template-meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; margin-top: auto; } /* Push meta to bottom */
|
||||
.template-tag { background-color: rgba(59, 130, 246, 0.1); color: var(--accent); padding: 0.25rem 0.75rem; border-radius: 9999px; font-weight: 500; }
|
||||
.template-complexity { color: var(--text-secondary); font-weight: 500; }
|
||||
.deployment-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1rem; flex-wrap: wrap; }
|
||||
|
||||
/* Deployment Status Styles */
|
||||
.deployment-section { margin-bottom: 3rem; }
|
||||
.status-container { background-color: var(--card-bg); border: 1px solid var(--border); border-radius: 0.75rem; padding: 1.5rem; }
|
||||
.progress-bar-container { margin-bottom: 2rem; }
|
||||
.progress-bar { height: 0.5rem; background-color: var(--bg-tertiary); border-radius: 9999px; overflow: hidden; margin-bottom: 1rem; }
|
||||
.progress-value { height: 100%; background-color: var(--accent); border-radius: 9999px; width: 0%; transition: width 0.5s ease; }
|
||||
.progress-label { font-size: 0.9rem; color: var(--text-secondary); text-align: center; }
|
||||
.deployment-steps { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2rem; }
|
||||
.deployment-step { display: flex; align-items: flex-start; padding: 1rem; background-color: var(--bg-secondary); border-radius: 0.5rem; border-left: 4px solid var(--border); transition: all var(--transition-normal); }
|
||||
.deployment-step.pending { border-left-color: var(--border); }
|
||||
.deployment-step.in-progress { border-left-color: var(--accent); background-color: rgba(59, 130, 246, 0.05); }
|
||||
.deployment-step.completed { border-left-color: var(--success); background-color: rgba(16, 185, 129, 0.05); }
|
||||
.deployment-step.failed { border-left-color: var(--error); background-color: rgba(239, 68, 68, 0.05); }
|
||||
.step-icon { width: 2rem; height: 2rem; border-radius: 50%; background-color: var(--bg-tertiary); display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.9rem; margin-right: 1rem; flex-shrink: 0; transition: all var(--transition-normal); color: var(--text-secondary); }
|
||||
.pending .step-icon { background-color: var(--bg-tertiary); }
|
||||
.in-progress .step-icon { background-color: var(--accent); color: white; }
|
||||
.completed .step-icon { background-color: var(--success); color: white; }
|
||||
.failed .step-icon { background-color: var(--error); color: white; }
|
||||
.step-content { flex: 1; }
|
||||
.step-title { font-weight: 600; font-size: 1rem; margin-bottom: 0.25rem; color: var(--text-primary); }
|
||||
.step-description { font-size: 0.85rem; color: var(--text-secondary); }
|
||||
.deployment-details { background-color: var(--bg-secondary); border-radius: 0.5rem; padding: 1.25rem; border: 1px solid var(--border); }
|
||||
.deployment-info { font-size: 0.9rem; color: var(--text-secondary); }
|
||||
.deployment-info dl { display: grid; grid-template-columns: auto 1fr; gap: 0.5rem 1rem; margin: 0; }
|
||||
.deployment-info dt { font-weight: 600; color: var(--text-primary); grid-column: 1; text-align: right; }
|
||||
.deployment-info dd { margin: 0; color: var(--text-secondary); grid-column: 2; word-break: break-all; }
|
||||
.deployment-info .status-badge { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 600; background-color: var(--bg-tertiary); }
|
||||
.deployment-info .status-initializing, .deployment-info .status-unknown { color: var(--info); }
|
||||
.deployment-info .status-deploying, .deployment-info .status-publishing { color: var(--warning); }
|
||||
.deployment-info .status-completed { color: var(--success); }
|
||||
.deployment-info .status-failed { color: var(--error); }
|
||||
.deployment-info dd a { color: var(--accent); text-decoration: none; }
|
||||
.deployment-info dd a:hover { text-decoration: underline; }
|
||||
|
||||
/* Deployment Result Styles */
|
||||
.result-section { margin-bottom: 3rem; }
|
||||
.success-card { background-color: var(--card-bg); border: 1px solid var(--border); border-radius: 0.75rem; padding: 2.5rem 1.5rem; text-align: center; }
|
||||
.success-icon { font-size: 4rem; color: var(--success); margin-bottom: 1.5rem; }
|
||||
.success-card h2 { font-size: 1.75rem; font-weight: 600; margin-bottom: 0.75rem; color: var(--text-primary); }
|
||||
.success-card p { font-size: 1rem; color: var(--text-secondary); margin-bottom: 2rem; }
|
||||
.deployment-url-container { margin-bottom: 2rem; }
|
||||
.deployment-url-container p { margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-secondary); }
|
||||
.deployment-url { display: inline-flex; align-items: center; gap: 0.75rem; padding: 0.75rem 1.5rem; background-color: var(--bg-secondary); border: 1px solid var(--border); border-radius: 0.5rem; color: var(--accent); font-size: 1.1rem; font-weight: 500; text-decoration: none; transition: all var(--transition-normal); }
|
||||
.deployment-url:hover { background-color: var(--bg-tertiary); transform: translateY(-2px); border-color: var(--accent); }
|
||||
.result-actions { display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; }
|
||||
|
||||
/* Sandbox Footer Styles */
|
||||
.sandbox-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border); font-size: 0.85rem; color: var(--text-secondary); flex-wrap: wrap; gap: 1rem; }
|
||||
.back-to-site { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--text-secondary); text-decoration: none; transition: color var(--transition-normal); }
|
||||
.back-to-site:hover { color: var(--accent); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sandbox-header { flex-direction: column; align-items: flex-start; }
|
||||
.sandbox-actions { width: 100%; }
|
||||
.templates-grid { grid-template-columns: 1fr; }
|
||||
.deployment-actions { flex-direction: column; }
|
||||
.deployment-actions button { width: 100%; }
|
||||
.result-actions { flex-direction: column; }
|
||||
.result-actions button { width: 100%; }
|
||||
.sandbox-footer { flex-direction: column; text-align: center; }
|
||||
.deployment-info dl { grid-template-columns: 1fr; }
|
||||
.deployment-info dt { text-align: left; margin-bottom: 0.1rem; }
|
||||
.deployment-info dd { margin-bottom: 0.5rem; }
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
// Adapted Sandbox JS from ansible-sandbox.html
|
||||
// Needs to run inline because it manipulates the DOM of this specific page
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Configuration
|
||||
// Use Astro environment variables (import.meta.env)
|
||||
// Make sure these are prefixed with PUBLIC_ if they need to be client-accessible
|
||||
const API_BASE_URL = import.meta.env.PUBLIC_ANSIBLE_SANDBOX_API_URL || 'https://ansible-sandbox.fly.dev';
|
||||
let SIMULATION_MODE = true; // Default to true, controlled by toggle
|
||||
const BASE_DOMAIN = import.meta.env.PUBLIC_ANSIBLE_SANDBOX_DOMAIN || 'argobox.com';
|
||||
|
||||
// Elements
|
||||
const templateCards = document.querySelectorAll('.template-card');
|
||||
const deployBtn = document.getElementById('deploy-btn');
|
||||
const previewBtn = document.getElementById('preview-btn');
|
||||
const deploymentSection = document.getElementById('deployment-section');
|
||||
const resultSection = document.getElementById('result-section');
|
||||
const deploymentProgress = document.getElementById('deployment-progress');
|
||||
const progressText = document.getElementById('progress-text');
|
||||
const deploymentInfo = document.getElementById('deployment-info');
|
||||
const deploymentUrl = document.getElementById('deployment-url');
|
||||
const deploymentUrlText = document.getElementById('deployment-url-text');
|
||||
const viewDeploymentBtn = document.getElementById('view-deployment-btn');
|
||||
const newDeploymentBtn = document.getElementById('new-deployment-btn');
|
||||
const simulationToggle = document.getElementById('simulation-toggle');
|
||||
const toggleStatus = document.querySelector('.toggle-status');
|
||||
const templatesSection = document.querySelector('.templates-section');
|
||||
|
||||
// State
|
||||
let selectedTemplate = null;
|
||||
let deploymentId = null;
|
||||
let deploymentStatus = 'pending';
|
||||
let pollingIntervalId = null;
|
||||
|
||||
// Initialize UI
|
||||
function initUI() {
|
||||
templateCards.forEach(card => card.addEventListener('click', () => selectTemplate(card)));
|
||||
if (deployBtn) deployBtn.addEventListener('click', startDeployment);
|
||||
if (previewBtn) previewBtn.addEventListener('click', previewTemplate);
|
||||
if (viewDeploymentBtn) viewDeploymentBtn.addEventListener('click', () => {
|
||||
if (deploymentUrl && deploymentUrl.href && deploymentUrl.href !== '#') {
|
||||
window.open(deploymentUrl.href, '_blank');
|
||||
}
|
||||
});
|
||||
if (newDeploymentBtn) newDeploymentBtn.addEventListener('click', resetDeployment);
|
||||
|
||||
if (simulationToggle) {
|
||||
SIMULATION_MODE = simulationToggle.checked;
|
||||
updateToggleStatus();
|
||||
simulationToggle.addEventListener('change', () => {
|
||||
SIMULATION_MODE = simulationToggle.checked;
|
||||
updateToggleStatus();
|
||||
console.log(`Simulation mode: ${SIMULATION_MODE}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Check URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const templateParam = urlParams.get('template');
|
||||
if (templateParam) {
|
||||
const matchingCard = Array.from(templateCards).find(card => card.dataset.template === templateParam);
|
||||
if (matchingCard) {
|
||||
selectTemplate(matchingCard);
|
||||
if (urlParams.get('deploy') === 'true') {
|
||||
setTimeout(startDeployment, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateToggleStatus() {
|
||||
if (toggleStatus) {
|
||||
toggleStatus.textContent = SIMULATION_MODE ? 'Active' : 'Inactive';
|
||||
}
|
||||
}
|
||||
|
||||
function selectTemplate(card) {
|
||||
templateCards.forEach(c => c.classList.remove('selected'));
|
||||
card.classList.add('selected');
|
||||
selectedTemplate = card.dataset.template;
|
||||
if (deployBtn) deployBtn.disabled = false;
|
||||
if (previewBtn) previewBtn.disabled = false;
|
||||
}
|
||||
|
||||
function previewTemplate() {
|
||||
if (!selectedTemplate) return;
|
||||
alert(`Previewing ${selectedTemplate}... (Replace with actual preview logic)`);
|
||||
}
|
||||
|
||||
function startDeployment() {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
if (templatesSection) templatesSection.style.display = 'none';
|
||||
if (deploymentSection) deploymentSection.style.display = 'block';
|
||||
if (resultSection) resultSection.style.display = 'none';
|
||||
|
||||
resetProgressSteps();
|
||||
updateProgress(5, 'Initializing deployment...');
|
||||
updateStepStatus('step-init', 'in-progress');
|
||||
if (deploymentInfo) deploymentInfo.innerHTML = '<p>Preparing deployment environment...</p>';
|
||||
|
||||
if (deployBtn) deployBtn.disabled = true;
|
||||
if (previewBtn) previewBtn.disabled = true;
|
||||
|
||||
if (SIMULATION_MODE) {
|
||||
simulateDeployment();
|
||||
} else {
|
||||
makeDeploymentRequest();
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeploymentRequest() {
|
||||
const payload = { template_name: selectedTemplate };
|
||||
|
||||
fetch(`${API_BASE_URL}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => { throw new Error(err.detail || `Deployment failed: ${response.statusText}`); })
|
||||
.catch(() => { throw new Error(`Deployment failed: ${response.statusText}`); });
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
deploymentId = data.deployment_id;
|
||||
console.log('Deployment initiated:', deploymentId);
|
||||
const initialStatus = { deployment_id: deploymentId, template: selectedTemplate, status: 'initializing', timestamp: new Date().toISOString(), ...data };
|
||||
updateDeploymentStatus(initialStatus);
|
||||
pollDeploymentStatus();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Deployment error:', error);
|
||||
updateProgress(0, `Error: ${error.message}`);
|
||||
updateStepStatus('step-init', 'failed');
|
||||
if (deploymentInfo) deploymentInfo.innerHTML = `<p style="color: var(--error);"><strong>Error:</strong> ${error.message}</p>`;
|
||||
if (deployBtn) deployBtn.disabled = false;
|
||||
if (previewBtn) previewBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function pollDeploymentStatus() {
|
||||
if (!deploymentId) return;
|
||||
if (pollingIntervalId) clearInterval(pollingIntervalId);
|
||||
|
||||
pollingIntervalId = setInterval(() => {
|
||||
fetch(`${API_BASE_URL}/status/${deploymentId}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) { console.warn(`Deployment ${deploymentId} not found yet.`); return null; }
|
||||
throw new Error(`Status check failed: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data === null) return;
|
||||
updateDeploymentStatus(data);
|
||||
if (data.status === 'completed' || data.status === 'failed') {
|
||||
clearInterval(pollingIntervalId);
|
||||
pollingIntervalId = null;
|
||||
if (deployBtn) deployBtn.disabled = false;
|
||||
if (previewBtn) previewBtn.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Status check error:', error);
|
||||
clearInterval(pollingIntervalId);
|
||||
pollingIntervalId = null;
|
||||
if (deploymentInfo) deploymentInfo.innerHTML += `<p style="color: var(--error);">Status polling failed: ${error.message}</p>`;
|
||||
if (deployBtn) deployBtn.disabled = false;
|
||||
if (previewBtn) previewBtn.disabled = false;
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function updateDeploymentStatus(data) {
|
||||
deploymentStatus = data.status;
|
||||
let progressPercent = 0;
|
||||
let progressMessage = '';
|
||||
resetProgressStepsVisually();
|
||||
|
||||
switch (deploymentStatus) {
|
||||
case 'initializing':
|
||||
progressPercent = 20; progressMessage = 'Initializing deployment...';
|
||||
updateStepStatus('step-init', 'in-progress'); break;
|
||||
case 'preparing': case 'deploying':
|
||||
progressPercent = 40; progressMessage = 'Deploying template...';
|
||||
updateStepStatus('step-init', 'completed'); updateStepStatus('step-template', 'completed'); updateStepStatus('step-deploy', 'in-progress'); break;
|
||||
case 'publishing':
|
||||
progressPercent = 70; progressMessage = 'Publishing...';
|
||||
updateStepStatus('step-init', 'completed'); updateStepStatus('step-template', 'completed'); updateStepStatus('step-deploy', 'completed'); updateStepStatus('step-publish', 'in-progress'); break;
|
||||
case 'completed':
|
||||
progressPercent = 100; progressMessage = 'Deployment complete!';
|
||||
updateStepStatus('step-init', 'completed'); updateStepStatus('step-template', 'completed'); updateStepStatus('step-deploy', 'completed'); updateStepStatus('step-publish', 'completed');
|
||||
showDeploymentResult(data); break;
|
||||
case 'failed':
|
||||
progressPercent = 0; progressMessage = `Deployment failed: ${data.error || 'Unknown error'}`;
|
||||
updateStepStatus('step-init', 'completed'); // Assume init ok
|
||||
if (data.failed_step === 'template' || data.failed_step === 'deploy' || data.failed_step === 'publish') updateStepStatus('step-template', 'completed');
|
||||
if (data.failed_step === 'deploy' || data.failed_step === 'publish') updateStepStatus('step-deploy', 'completed');
|
||||
// Mark specific failed step
|
||||
if (data.failed_step === 'init') updateStepStatus('step-init', 'failed');
|
||||
else if (data.failed_step === 'template') updateStepStatus('step-template', 'failed');
|
||||
else if (data.failed_step === 'deploy') updateStepStatus('step-deploy', 'failed');
|
||||
else if (data.failed_step === 'publish') updateStepStatus('step-publish', 'failed');
|
||||
else { // Fallback
|
||||
const currentStep = document.querySelector('.deployment-step.in-progress');
|
||||
if (currentStep) { currentStep.classList.remove('in-progress'); currentStep.classList.add('failed'); }
|
||||
else { updateStepStatus('step-init', 'failed'); }
|
||||
}
|
||||
break;
|
||||
default: console.warn("Unknown status:", deploymentStatus); progressMessage = `Status: ${deploymentStatus}`; break;
|
||||
}
|
||||
updateProgress(progressPercent, progressMessage);
|
||||
updateDeploymentInfo(data);
|
||||
}
|
||||
|
||||
function simulateDeployment() {
|
||||
deploymentId = `sim-${Math.random().toString(36).substring(2, 9)}`;
|
||||
selectedTemplate = selectedTemplate || 'fireworks';
|
||||
const steps = [ { status: 'initializing', delay: 1500 }, { status: 'deploying', delay: 2500 }, { status: 'publishing', delay: 3000 }, { status: 'completed', delay: 1000 } ];
|
||||
let currentDelay = 0;
|
||||
steps.forEach(step => {
|
||||
currentDelay += step.delay;
|
||||
setTimeout(() => {
|
||||
const statusData = { deployment_id: deploymentId, template: selectedTemplate, status: step.status, timestamp: new Date().toISOString() };
|
||||
updateDeploymentStatus(statusData);
|
||||
if (step.status === 'completed' || step.status === 'failed') {
|
||||
if (pollingIntervalId) clearInterval(pollingIntervalId); pollingIntervalId = null;
|
||||
if (deployBtn) deployBtn.disabled = false; if (previewBtn) previewBtn.disabled = false;
|
||||
}
|
||||
}, currentDelay);
|
||||
});
|
||||
}
|
||||
|
||||
function showDeploymentResult(data) {
|
||||
const url = data.deployment_url || `https://${data.deployment_id}.${BASE_DOMAIN}`;
|
||||
if (deploymentUrl) deploymentUrl.href = url;
|
||||
if (deploymentUrlText) deploymentUrlText.textContent = url.replace(/^https?:\/\//, '');
|
||||
if (deploymentSection) deploymentSection.style.display = 'none';
|
||||
if (resultSection) resultSection.style.display = 'block';
|
||||
}
|
||||
|
||||
function resetDeployment() {
|
||||
if (pollingIntervalId) { clearInterval(pollingIntervalId); pollingIntervalId = null; }
|
||||
if (resultSection) resultSection.style.display = 'none';
|
||||
if (deploymentSection) deploymentSection.style.display = 'none';
|
||||
if (templatesSection) templatesSection.style.display = 'block';
|
||||
templateCards.forEach(card => card.classList.remove('selected'));
|
||||
selectedTemplate = null; deploymentId = null; deploymentStatus = 'pending';
|
||||
if (deployBtn) deployBtn.disabled = true; if (previewBtn) previewBtn.disabled = true;
|
||||
resetProgressSteps();
|
||||
if (deploymentInfo) deploymentInfo.innerHTML = '<p>Deployment information will appear here once the process begins.</p>';
|
||||
}
|
||||
|
||||
function resetProgressStepsVisually() {
|
||||
document.querySelectorAll('.deployment-step').forEach(step => {
|
||||
step.classList.remove('in-progress', 'completed', 'failed'); step.classList.add('pending');
|
||||
const icon = step.querySelector('.step-icon');
|
||||
if(icon) { // Reset icon to number
|
||||
const stepNumber = step.id.split('-')[1] === 'init' ? 1 : step.id.split('-')[1] === 'template' ? 2 : step.id.split('-')[1] === 'deploy' ? 3 : 4;
|
||||
icon.innerHTML = stepNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
function resetProgressSteps() { resetProgressStepsVisually(); updateProgress(0, ''); }
|
||||
function updateProgress(percent, message) { if (deploymentProgress) deploymentProgress.style.width = `${percent}%`; if (progressText) progressText.textContent = message; }
|
||||
function updateStepStatus(stepId, status) {
|
||||
const step = document.getElementById(stepId);
|
||||
if (step) {
|
||||
step.classList.remove('pending', 'in-progress', 'completed', 'failed'); step.classList.add(status);
|
||||
const icon = step.querySelector('.step-icon');
|
||||
if (icon) {
|
||||
if (status === 'completed') icon.innerHTML = '<i class="fas fa-check"></i>';
|
||||
else if (status === 'failed') icon.innerHTML = '<i class="fas fa-times"></i>';
|
||||
else if (status === 'in-progress') icon.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
else { const stepNumber = stepId.split('-')[1] === 'init' ? 1 : stepId.split('-')[1] === 'template' ? 2 : stepId.split('-')[1] === 'deploy' ? 3 : 4; icon.innerHTML = stepNumber; }
|
||||
}
|
||||
} else { console.warn(`Step element with ID ${stepId} not found.`); }
|
||||
}
|
||||
function updateDeploymentInfo(data) {
|
||||
if (!deploymentInfo) return;
|
||||
let html = '<dl>';
|
||||
if (data.deployment_id) html += `<dt>Deployment ID:</dt><dd>${data.deployment_id}</dd>`;
|
||||
if (data.template || selectedTemplate) html += `<dt>Template:</dt><dd>${data.template || selectedTemplate}</dd>`;
|
||||
let statusText = data.status ? data.status.charAt(0).toUpperCase() + data.status.slice(1) : 'Unknown';
|
||||
html += `<dt>Status:</dt><dd><span class="status-badge status-${data.status || 'unknown'}">${statusText}</span></dd>`;
|
||||
if (data.timestamp) { try { html += `<dt>Timestamp:</dt><dd>${new Date(data.timestamp).toLocaleString()}</dd>`; } catch (e) { console.error("Invalid timestamp", data.timestamp); } }
|
||||
if (data.status === 'completed') { const url = data.deployment_url || `https://${data.deployment_id}.${BASE_DOMAIN}`; html += `<dt>URL:</dt><dd><a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a></dd>`; } // Added rel
|
||||
if (data.status === 'failed' && data.error) html += `<dt style="color: var(--error);">Error:</dt><dd style="color: var(--error);">${data.error}</dd>`;
|
||||
html += '</dl>';
|
||||
deploymentInfo.innerHTML = html;
|
||||
}
|
||||
|
||||
// Initialize
|
||||
initUI();
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,83 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
// Define the expected structure of the form data
|
||||
interface FormData {
|
||||
name: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
// Initialize Resend inside the handler to access runtime env vars/secrets
|
||||
// IMPORTANT: Set RESEND_API_KEY in your deployment environment (e.g., Cloudflare Pages)
|
||||
const resend = new Resend(import.meta.env.RESEND_API_KEY);
|
||||
|
||||
// Check if API key is configured
|
||||
if (!import.meta.env.RESEND_API_KEY) {
|
||||
console.error("Resend API key is not configured.");
|
||||
return new Response(JSON.stringify({ message: 'Server configuration error.' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let data: FormData;
|
||||
try {
|
||||
data = await request.json();
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ message: 'Invalid request body.' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if (!data.name || !data.email || !data.subject || !data.message) {
|
||||
return new Response(JSON.stringify({ message: 'Missing required fields.' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Send email using Resend
|
||||
const { data: emailData, error: emailError } = await resend.emails.send({
|
||||
from: 'Contact Form <noreply@argobox.com>', // Replace with your desired "from" address (must be a verified domain in Resend)
|
||||
to: ['daniel.laforce@gmail.com'], // Replace with the email address where you want to receive messages
|
||||
subject: `New Contact Form Submission: ${data.subject}`,
|
||||
html: `
|
||||
<h1>New Contact Form Submission</h1>
|
||||
<p><strong>Name:</strong> ${data.name}</p>
|
||||
<p><strong>Email:</strong> ${data.email}</p>
|
||||
<p><strong>Subject:</strong> ${data.subject}</p>
|
||||
<hr>
|
||||
<p><strong>Message:</strong></p>
|
||||
<p>${data.message.replace(/\n/g, '<br>')}</p>
|
||||
`,
|
||||
replyTo: data.email, // Set the reply-to header to the sender's email
|
||||
});
|
||||
|
||||
if (emailError) {
|
||||
console.error('Resend error:', emailError);
|
||||
return new Response(JSON.stringify({ message: 'Failed to send email.' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Email sent successfully
|
||||
return new Response(JSON.stringify({ message: 'Email sent successfully!' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Unexpected error sending email:', error);
|
||||
return new Response(JSON.stringify({ message: 'An unexpected server error occurred.' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
|
@ -0,0 +1,264 @@
|
|||
---
|
||||
// src/pages/blog/[slug].astro
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
|
||||
// Required getStaticPaths function for dynamic routes
|
||||
export async function getStaticPaths() {
|
||||
try {
|
||||
// Try first from 'blog' collection (auto-generated)
|
||||
let allPosts = [];
|
||||
try {
|
||||
allPosts = await getCollection('blog', ({ data }) => {
|
||||
return import.meta.env.PROD ? !data.draft : true;
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('Blog collection not found, trying posts');
|
||||
}
|
||||
|
||||
// If that fails or is empty, try 'posts' collection
|
||||
if (allPosts.length === 0) {
|
||||
allPosts = await getCollection('posts', ({ data }) => {
|
||||
return import.meta.env.PROD ? !data.draft : true;
|
||||
});
|
||||
}
|
||||
|
||||
return allPosts.map(post => ({
|
||||
params: { slug: post.slug },
|
||||
props: { post },
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching posts:', error);
|
||||
// Return empty array if both collections don't exist or are empty
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get the post from props
|
||||
const { post } = Astro.props;
|
||||
|
||||
// Format date helper
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Generate datetime attribute safely
|
||||
const getISODate = (date) => {
|
||||
if (!date) return '';
|
||||
// Handle various date formats
|
||||
try {
|
||||
// If already a Date object
|
||||
if (date instanceof Date) {
|
||||
return date.toISOString();
|
||||
}
|
||||
// If it's a string or number, convert to Date
|
||||
return new Date(date).toISOString();
|
||||
} catch (error) {
|
||||
// Fallback if date is invalid
|
||||
console.error('Invalid date format:', date);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get the Content component for rendering markdown
|
||||
const { Content } = await post.render();
|
||||
---
|
||||
|
||||
<BaseLayout title={post.data.title} description={post.data.description || ''}>
|
||||
<article class="container blog-post">
|
||||
<header class="post-header">
|
||||
<h1>{post.data.title}</h1>
|
||||
<div class="post-meta">
|
||||
{post.data.pubDate && <time datetime={getISODate(post.data.pubDate)}>{formatDate(post.data.pubDate)}</time>}
|
||||
{post.data.updatedDate && <div class="updated-date">Updated: {formatDate(post.data.updatedDate)}</div>}
|
||||
{post.data.readTime && <div class="read-time">{post.data.readTime} read</div>}
|
||||
{post.data.author && <div class="author">By {post.data.author}</div>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{post.data.heroImage && (
|
||||
<div class="hero-image">
|
||||
<img src={post.data.heroImage} alt={post.data.title} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="post-content">
|
||||
<div class="post-body">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<aside class="post-sidebar">
|
||||
{post.data.tags && post.data.tags.length > 0 && (
|
||||
<div class="tags-section">
|
||||
<h3>Tags</h3>
|
||||
<div class="tags">
|
||||
{post.data.tags.map(tag => (
|
||||
<a href={`/tag/${tag}`} class="tag">{tag}</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="category-section">
|
||||
<h3>Category</h3>
|
||||
<a href={`/categories/${post.data.category || 'Uncategorized'}`} class="category">
|
||||
{post.data.category || 'Uncategorized'}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{post.data.categories && post.data.categories.length > 0 && (
|
||||
<div class="categories-section">
|
||||
<h3>Categories</h3>
|
||||
<div class="categories">
|
||||
{post.data.categories.map(category => (
|
||||
<a href={`/categories/${category}`} class="category">
|
||||
{category}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--container-padding, 1.5rem);
|
||||
}
|
||||
|
||||
.blog-post {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.post-header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.post-header h1 {
|
||||
font-size: var(--font-size-4xl, 2.25rem);
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.hero-image img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.post-body {
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.post-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.tags-section, .category-section, .categories-section {
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.tags-section h3, .category-section h3, .categories-section h3 {
|
||||
font-size: var(--font-size-lg, 1.125rem);
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
border-radius: 20px;
|
||||
color: var(--accent-primary);
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.category, .categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||
border-radius: 20px;
|
||||
color: var(--bg-primary);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.post-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.post-header h1 {
|
||||
font-size: var(--font-size-2xl, 1.5rem);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,985 @@
|
|||
---
|
||||
// src/pages/blog/index.astro - Blog page with enhanced knowledge graph and filtering
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import KnowledgeGraph from '../../components/KnowledgeGraph.astro';
|
||||
import Terminal from '../../components/Terminal.astro';
|
||||
import Header from '../../components/Header.astro';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
|
||||
// Get all blog entries
|
||||
const allPosts = await getCollection('posts');
|
||||
|
||||
// Sort by publication date
|
||||
const sortedPosts = allPosts.sort((a, b) => {
|
||||
const dateA = a.data.pubDate ? new Date(a.data.pubDate) : new Date(0);
|
||||
const dateB = b.data.pubDate ? new Date(b.data.pubDate) : new Date(0);
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
});
|
||||
|
||||
// Get all unique tags
|
||||
const allTags = [...new Set(allPosts.flatMap(post => post.data.tags || []))].sort();
|
||||
|
||||
// Get all unique categories
|
||||
const allCategories = [...new Set(allPosts.map(post => post.data.category).filter(Boolean))].sort();
|
||||
|
||||
// Prepare post data for client-side filtering and knowledge graph
|
||||
const postsData = sortedPosts.map(post => ({
|
||||
slug: post.slug,
|
||||
title: post.data.title,
|
||||
description: post.data.description || '',
|
||||
pubDate: post.data.pubDate ? new Date(post.data.pubDate).toLocaleDateString('en-us', { year: 'numeric', month: 'short', day: 'numeric' }) : 'No date',
|
||||
pubDateISO: post.data.pubDate ? new Date(post.data.pubDate).toISOString() : '',
|
||||
category: post.data.category || 'Uncategorized',
|
||||
tags: post.data.tags || [],
|
||||
heroImage: post.data.heroImage || '/images/placeholders/default.jpg',
|
||||
readTime: post.data.readTime || '5 min read',
|
||||
isDraft: post.data.draft || false
|
||||
}));
|
||||
|
||||
// Prepare enhanced graph data with both posts and tags
|
||||
const graphData = {
|
||||
nodes: [
|
||||
// Add post nodes
|
||||
...sortedPosts
|
||||
.filter(post => !post.data.draft)
|
||||
.map(post => ({
|
||||
id: post.slug,
|
||||
label: post.data.title,
|
||||
type: 'post',
|
||||
category: post.data.category || 'Uncategorized',
|
||||
tags: post.data.tags || [],
|
||||
url: `/posts/${post.slug}/`
|
||||
})),
|
||||
|
||||
// Add tag nodes
|
||||
...allTags.map(tag => ({
|
||||
id: `tag-${tag}`,
|
||||
label: tag,
|
||||
type: 'tag',
|
||||
url: `/tag/${tag}/`
|
||||
}))
|
||||
],
|
||||
edges: []
|
||||
};
|
||||
|
||||
// Create edges between posts and their tags
|
||||
sortedPosts
|
||||
.filter(post => !post.data.draft)
|
||||
.forEach(post => {
|
||||
const postTags = post.data.tags || [];
|
||||
|
||||
// Add edges from post to tags
|
||||
postTags.forEach(tag => {
|
||||
graphData.edges.push({
|
||||
source: post.slug,
|
||||
target: `tag-${tag}`,
|
||||
type: 'post-tag',
|
||||
strength: 1
|
||||
});
|
||||
});
|
||||
|
||||
// Check if post references other posts (optional)
|
||||
// This requires a related_posts field in frontmatter
|
||||
if (post.data.related_posts && Array.isArray(post.data.related_posts)) {
|
||||
post.data.related_posts.forEach(relatedSlug => {
|
||||
// Make sure related post exists
|
||||
if (sortedPosts.some(p => p.slug === relatedSlug)) {
|
||||
graphData.edges.push({
|
||||
source: post.slug,
|
||||
target: relatedSlug,
|
||||
type: 'post-post',
|
||||
strength: 2
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Terminal commands for tech effect
|
||||
const commands = [
|
||||
{
|
||||
prompt: "[user@argobox]$ ",
|
||||
command: "find ./posts -type f -name \"*.md\" | sort -n | wc -l",
|
||||
output: [`${allPosts.length} posts found`]
|
||||
},
|
||||
{
|
||||
prompt: "[user@argobox]$ ",
|
||||
command: "ls -la ./tags",
|
||||
output: allTags.map(tag => `${tag}`)
|
||||
},
|
||||
{
|
||||
prompt: "[user@argobox]$ ",
|
||||
command: "grep -r \"kubernetes\" --include=\"*.md\" ./posts | wc -l",
|
||||
output: [`${allPosts.filter(post =>
|
||||
post.data.tags?.includes('kubernetes') ||
|
||||
post.data.category === 'Kubernetes' ||
|
||||
post.data.title?.toLowerCase().includes('kubernetes') ||
|
||||
post.data.description?.toLowerCase().includes('kubernetes')
|
||||
).length} matches found`]
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title="Blog | LaForce IT - Home Lab & DevOps Insights" description="Explore articles about Kubernetes, Infrastructure, DevOps, and Home Lab setups">
|
||||
<Header slot="header" />
|
||||
<main>
|
||||
<!-- Hero Section with Terminal -->
|
||||
<section class="hero-section">
|
||||
<div class="hero-bg"></div>
|
||||
<div class="container">
|
||||
<div class="hero-content">
|
||||
<div class="hero-text">
|
||||
<div class="hero-subtitle">Technical Articles & Guides</div>
|
||||
<h1 class="hero-title">Exploring <span>advanced infrastructure</span> and automation</h1>
|
||||
<p class="hero-description">
|
||||
Dive into enterprise-grade home lab setups, Kubernetes deployments, and DevOps best practices for the modern tech enthusiast.
|
||||
</p>
|
||||
</div>
|
||||
<div class="terminal-container">
|
||||
<Terminal commands={commands} title="argobox:~/blog" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Blog Content Section -->
|
||||
<section class="blog-content-section">
|
||||
<div class="container">
|
||||
<!-- Search and Filter Section with integrated Knowledge Graph -->
|
||||
<div class="search-filter-container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Knowledge Graph & Content Explorer</h2>
|
||||
<p class="section-description">
|
||||
Explore connections between articles and topics, or search by keyword
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Knowledge Graph Visualization -->
|
||||
<div class="knowledge-graph-wrapper">
|
||||
<KnowledgeGraph graphData={graphData} height="500px" />
|
||||
</div>
|
||||
|
||||
<div class="search-filter-section">
|
||||
<div class="search-bar">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="search-icon"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||
<input type="search" id="search-input" placeholder="Search posts..." class="search-input" />
|
||||
</div>
|
||||
<div class="tag-filters">
|
||||
<span class="filter-label">Filter by Tag:</span>
|
||||
<button class="tag-filter-btn active" data-tag="all">All</button>
|
||||
{allTags.map(tag => (
|
||||
<button class="tag-filter-btn" data-tag={tag}>{tag}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blog Grid (populated by JS) -->
|
||||
<div class="blog-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">All Articles</h2>
|
||||
<p class="section-description">
|
||||
Technical insights, infrastructure guides, and DevOps best practices
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="blog-grid" id="blog-grid">
|
||||
<div class="loading-indicator">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>Loading articles...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer slot="footer" />
|
||||
|
||||
<!-- Client-side script for filtering and graph interactions -->
|
||||
<script define:vars={{ postsData, graphData }}>
|
||||
// DOM Elements
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const tagButtons = document.querySelectorAll('.tag-filter-btn');
|
||||
const blogGrid = document.getElementById('blog-grid');
|
||||
|
||||
// State variables
|
||||
let currentFilterTag = 'all';
|
||||
let currentSearchTerm = '';
|
||||
let cy; // Cytoscape instance will be set by KnowledgeGraph component
|
||||
|
||||
// Wait for cytoscape instance to be available
|
||||
document.addEventListener('graphReady', (e) => {
|
||||
cy = e.detail.cy;
|
||||
console.log('Graph ready and connected to filtering system');
|
||||
});
|
||||
|
||||
// Function to create HTML for a single post card
|
||||
// Update the post card HTML creation function in the blog/index.astro file
|
||||
// Find the function that creates post cards (might be called createPostCardHTML)
|
||||
|
||||
function createPostCardHTML(post) {
|
||||
// Make sure tags is an array before stringifying
|
||||
const tagsString = JSON.stringify(post.tags || []);
|
||||
|
||||
// Create tag pills HTML
|
||||
const tagPills = post.tags.map(tag =>
|
||||
`<span class="post-tag" data-tag="${tag}">${tag}</span>`
|
||||
).join('');
|
||||
|
||||
return `
|
||||
<article class="post-card" data-tags='${tagsString}' data-slug="${post.slug}">
|
||||
<div class="post-card-inner">
|
||||
<a href="/posts/${post.slug}/" class="post-image-link">
|
||||
<div class="post-image-container">
|
||||
<img
|
||||
width="720"
|
||||
height="360"
|
||||
src="${post.heroImage}"
|
||||
alt=""
|
||||
class="post-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="post-category-badge">${post.category}</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="post-content">
|
||||
<div class="post-meta">
|
||||
<time datetime="${post.pubDateISO}">${post.pubDate}</time>
|
||||
<span class="post-read-time">${post.readTime}</span>
|
||||
</div>
|
||||
<h3 class="post-title">
|
||||
<a href="/posts/${post.slug}/">${post.title}</a>
|
||||
${post.isDraft ? '<span class="draft-badge">Draft</span>' : ''}
|
||||
</h3>
|
||||
<p class="post-excerpt">${post.description}</p>
|
||||
<div class="post-footer">
|
||||
<div class="post-tags">
|
||||
${tagPills}
|
||||
</div>
|
||||
<a href="/posts/${post.slug}/" class="read-more">Read More</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
// Function to filter and update the grid
|
||||
function updateGrid() {
|
||||
const searchTermLower = currentSearchTerm.toLowerCase();
|
||||
|
||||
// Ensure postsData is available
|
||||
if (typeof postsData === 'undefined' || !postsData) {
|
||||
console.error("postsData is not available in updateGrid");
|
||||
if(blogGrid) blogGrid.innerHTML = '<p class="no-results">Error loading post data.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredPosts = postsData.filter(post => {
|
||||
const postTags = post.tags || []; // Ensure tags is an array
|
||||
const matchesTag = currentFilterTag === 'all' || postTags.includes(currentFilterTag);
|
||||
const matchesSearch = searchTermLower === '' ||
|
||||
post.title.toLowerCase().includes(searchTermLower) ||
|
||||
post.description.toLowerCase().includes(searchTermLower) ||
|
||||
postTags.some(tag => tag.toLowerCase().includes(searchTermLower));
|
||||
return matchesTag && matchesSearch;
|
||||
});
|
||||
|
||||
// Update the grid HTML
|
||||
if (blogGrid) {
|
||||
if (filteredPosts.length > 0) {
|
||||
blogGrid.innerHTML = filteredPosts.map(createPostCardHTML).join('');
|
||||
|
||||
// Add click handlers to post tag spans
|
||||
document.querySelectorAll('.post-tag').forEach(tagSpan => {
|
||||
tagSpan.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const tag = tagSpan.dataset.tag;
|
||||
|
||||
// Find and click the matching tag filter button
|
||||
const tagBtn = Array.from(tagButtons).find(btn => btn.dataset.tag === tag);
|
||||
if (tagBtn) {
|
||||
tagBtn.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// If graph is available, highlight matching nodes
|
||||
if (cy) {
|
||||
// Get matching slugs for posts
|
||||
const matchingSlugs = filteredPosts.map(post => post.slug);
|
||||
|
||||
if (currentFilterTag !== 'all') {
|
||||
// We're filtering by tag - highlight tag node and connected posts
|
||||
cy.elements().addClass('faded').removeClass('highlighted filtered');
|
||||
|
||||
// Highlight the tag node
|
||||
const tagNode = cy.getElementById(`tag-${currentFilterTag}`);
|
||||
if (tagNode.length > 0) {
|
||||
tagNode.removeClass('faded').addClass('highlighted');
|
||||
|
||||
// Get connected posts and highlight them
|
||||
const connectedPosts = tagNode.neighborhood('node[type="post"]');
|
||||
connectedPosts.removeClass('faded').addClass('filtered');
|
||||
|
||||
// Highlight connecting edges
|
||||
tagNode.connectedEdges().removeClass('faded').addClass('highlighted');
|
||||
}
|
||||
}
|
||||
else if (currentSearchTerm) {
|
||||
// We're searching - highlight matching posts
|
||||
cy.elements().addClass('faded').removeClass('highlighted filtered');
|
||||
|
||||
// Find and highlight matching post nodes
|
||||
matchingSlugs.forEach(slug => {
|
||||
const node = cy.getElementById(slug);
|
||||
if (node.length > 0) {
|
||||
node.removeClass('faded').addClass('highlighted');
|
||||
|
||||
// Also show connected tags
|
||||
const connectedTags = node.neighborhood('node[type="tag"]');
|
||||
connectedTags.removeClass('faded').addClass('filtered');
|
||||
|
||||
// And highlight edges
|
||||
node.connectedEdges().removeClass('faded');
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Reset graph view
|
||||
cy.elements().removeClass('faded highlighted filtered');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
blogGrid.innerHTML = '<p class="no-results">No posts found matching your criteria. Try adjusting your search or filters.</p>';
|
||||
|
||||
// Reset graph view
|
||||
if (cy) {
|
||||
cy.elements().removeClass('faded highlighted filtered');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error("Blog grid element not found!");
|
||||
}
|
||||
}
|
||||
|
||||
// Event listener for search input
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
currentSearchTerm = e.target.value;
|
||||
updateGrid();
|
||||
});
|
||||
} else {
|
||||
console.error("Search input element not found!");
|
||||
}
|
||||
|
||||
// Event listeners for tag buttons
|
||||
tagButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
// Update active button style
|
||||
tagButtons.forEach(btn => btn.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
|
||||
// Update filter and grid
|
||||
currentFilterTag = button.dataset.tag;
|
||||
updateGrid();
|
||||
|
||||
// If tag changes but search is active, keep it integrated
|
||||
if (cy && currentFilterTag !== 'all') {
|
||||
// Find the tag node
|
||||
const tagNode = cy.getElementById(`tag-${currentFilterTag}`);
|
||||
if (tagNode.length > 0) {
|
||||
// Center the view on this tag
|
||||
cy.animate({
|
||||
center: { eles: tagNode },
|
||||
zoom: 1.5
|
||||
}, {
|
||||
duration: 500
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initial grid population on client side
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateGrid(); // Call after DOM is fully loaded
|
||||
|
||||
// Create link between graph and grid
|
||||
document.addEventListener('graphReady', (e) => {
|
||||
// Add a scroll-to-graph button
|
||||
const searchSection = document.querySelector('.search-filter-section');
|
||||
if (searchSection) {
|
||||
const graphButton = document.createElement('button');
|
||||
graphButton.className = 'graph-toggle-btn';
|
||||
graphButton.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
<line x1="11" y1="8" x2="11" y2="14"></line>
|
||||
<line x1="8" y1="11" x2="14" y2="11"></line>
|
||||
</svg>
|
||||
Explore Knowledge Graph
|
||||
`;
|
||||
graphButton.addEventListener('click', () => {
|
||||
document.querySelector('.knowledge-graph-wrapper').scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
});
|
||||
searchSection.appendChild(graphButton);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
/* Hero Section */
|
||||
.hero-section {
|
||||
padding: 6rem 0 4rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--bg-secondary), var(--bg-primary));
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 35%, rgba(6, 182, 212, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 15%, rgba(59, 130, 246, 0.1) 0%, transparent 45%),
|
||||
radial-gradient(circle at 85% 70%, rgba(139, 92, 246, 0.1) 0%, transparent 40%);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.hero-bg::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(rgba(226, 232, 240, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(226, 232, 240, 0.03) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 clamp(1rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hero-subtitle::before {
|
||||
content: '>';
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(2.5rem, 5vw, 4rem);
|
||||
line-height: 1.1;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-title span {
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-tertiary));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
/* Blog Content Section */
|
||||
.blog-content-section {
|
||||
padding: 2rem 0 5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
max-width: 800px;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: clamp(1.75rem, 3vw, 2.5rem);
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.section-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 4px;
|
||||
width: 60px;
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||
bottom: -10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--text-secondary);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Search Filter Container with Knowledge Graph */
|
||||
.search-filter-container {
|
||||
margin-bottom: 4rem;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-primary);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.knowledge-graph-wrapper {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.search-filter-section {
|
||||
padding: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-family: var(--font-sans);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tag-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin-right: 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.tag-filter-btn {
|
||||
background-color: rgba(226, 232, 240, 0.05);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--card-border);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s ease;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.tag-filter-btn:hover {
|
||||
background-color: rgba(226, 232, 240, 0.1);
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(56, 189, 248, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tag-filter-btn.active {
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--accent-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.graph-toggle-btn {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 10px rgba(6, 182, 212, 0.2);
|
||||
}
|
||||
|
||||
.graph-toggle-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 15px rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
|
||||
/* Blog Section and Grid */
|
||||
.blog-section {
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.blog-grid {
|
||||
margin: 2rem 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* Enhanced Post Card Styles */
|
||||
.post-card {
|
||||
height: 100%;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.post-card-inner {
|
||||
height: 100%;
|
||||
background: var(--card-bg);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--card-border);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.post-card:hover .post-card-inner {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(6, 182, 212, 0.15);
|
||||
border-color: rgba(56, 189, 248, 0.4);
|
||||
}
|
||||
|
||||
.post-image-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-image-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.post-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.post-card:hover .post-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.post-category-badge {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
color: var(--bg-primary);
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 10px rgba(6, 182, 212, 0.25);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.post-content {
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.post-title a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.post-title a:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.draft-badge {
|
||||
display: inline-block;
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #F59E0B;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.5rem;
|
||||
vertical-align: middle;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.post-excerpt {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.post-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.post-tag {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10B981;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.post-tag:hover {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.read-more {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.read-more:hover {
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.read-more::after {
|
||||
content: '→';
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.post-card:hover .read-more::after {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(15, 23, 42, 0.2);
|
||||
border-radius: 10px;
|
||||
border: 1px dashed var(--card-border);
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.loading-indicator {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(6, 182, 212, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent-primary);
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 1024px) {
|
||||
.hero-content {
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.graph-toggle-btn {
|
||||
top: auto;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-section {
|
||||
padding: 4rem 0 3rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-filter-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.blog-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.graph-toggle-btn {
|
||||
padding: 0.5rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
}
|
||||
|
||||
.graph-toggle-btn span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add CSS to make the image link more obvious on hover */
|
||||
.post-image-link {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px 8px 0 0; /* Match card radius */
|
||||
}
|
||||
|
||||
.post-image-link:hover .post-image {
|
||||
transform: scale(1.05);
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.post-image-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(6, 182, 212, 0.1); /* Use accent color with alpha */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none; /* Allow clicks through */
|
||||
}
|
||||
|
||||
.post-image-link:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,294 @@
|
|||
---
|
||||
// src/pages/categories/[category].astro
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
try {
|
||||
// Get posts from the posts collection
|
||||
const allPosts = await getCollection('posts', ({ data }) => {
|
||||
// Exclude draft posts in production
|
||||
return import.meta.env.PROD ? !data.draft : true;
|
||||
});
|
||||
|
||||
// Extract all categories from posts
|
||||
const allCategories = new Set<string>();
|
||||
|
||||
allPosts.forEach(post => {
|
||||
// Handle both single category and categories array
|
||||
if (post.data.categories && Array.isArray(post.data.categories)) {
|
||||
post.data.categories.forEach(cat => allCategories.add(cat));
|
||||
} else if (post.data.category) {
|
||||
allCategories.add(post.data.category);
|
||||
} else {
|
||||
allCategories.add('Uncategorized');
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to array and sort alphabetically
|
||||
const uniqueCategories = Array.from(allCategories).sort();
|
||||
|
||||
// If there are no categories, provide a default path
|
||||
if (uniqueCategories.length === 0) {
|
||||
return [{
|
||||
params: { category: 'general' },
|
||||
props: { posts: [] }
|
||||
}];
|
||||
}
|
||||
|
||||
// Create a path for each category
|
||||
return uniqueCategories.map(category => {
|
||||
// Filter posts for this category
|
||||
const filteredPosts = allPosts.filter(post => {
|
||||
if (post.data.categories && Array.isArray(post.data.categories)) {
|
||||
return post.data.categories.includes(category);
|
||||
}
|
||||
return post.data.category === category;
|
||||
});
|
||||
|
||||
return {
|
||||
params: { category },
|
||||
props: { posts: filteredPosts }
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating category pages:', error);
|
||||
// Fallback to ensure build doesn't fail
|
||||
return [{
|
||||
params: { category: 'general' },
|
||||
props: { posts: [] }
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
const { category } = Astro.params;
|
||||
const { posts } = Astro.props;
|
||||
|
||||
// Format date function
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout title={`${category} | LaForce IT Blog`} description={`Articles in the ${category} category`}>
|
||||
<div class="container">
|
||||
<div class="category-header">
|
||||
<h1>Posts in category: <span class="category-name">{category}</span></h1>
|
||||
<p class="category-description">Browse {posts.length} {posts.length === 1 ? 'article' : 'articles'} in this category</p>
|
||||
</div>
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<div class="posts-grid">
|
||||
{posts.map((post) => (
|
||||
<article class="post-card">
|
||||
<img
|
||||
width={720}
|
||||
height={360}
|
||||
src={post.data.heroImage || "/images/placeholders/default.jpg"}
|
||||
alt=""
|
||||
class="post-image"
|
||||
/>
|
||||
<div class="post-content">
|
||||
<time datetime={post.data.pubDate}>{formatDate(post.data.pubDate)}</time>
|
||||
<h2 class="post-title">
|
||||
<a href={`/posts/${post.slug}/`}>{post.data.title}</a>
|
||||
</h2>
|
||||
<p class="post-excerpt">{post.data.description || post.data.excerpt || ''}</p>
|
||||
<div class="post-meta">
|
||||
<span class="reading-time">{post.data.readTime || '5 min'} read</span>
|
||||
{post.data.tags && post.data.tags.length > 0 && (
|
||||
<div class="post-tags">
|
||||
{post.data.tags.slice(0, 3).map((tag) => (
|
||||
<a href={`/tag/${tag}`} class="tag-link">
|
||||
{tag}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="empty-state">
|
||||
<p>No posts in this category yet. Check back soon!</p>
|
||||
<a href="/blog" class="back-to-blog">Browse all posts</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
padding: 2rem 0;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--container-padding, 1.5rem);
|
||||
}
|
||||
|
||||
.category-header {
|
||||
text-align: center;
|
||||
margin: 2rem 0 4rem;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.category-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-lg, 1.125rem);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.post-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-primary);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.post-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.post-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.post-content {
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.post-content time {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: var(--font-size-xl, 1.25rem);
|
||||
margin: 0.5rem 0 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.post-title a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.post-title a:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.post-excerpt {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-md, 1rem);
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.reading-time {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.post-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-link {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
border-radius: 20px;
|
||||
color: var(--accent-primary);
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-link:hover {
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.back-to-blog {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-to-blog:hover {
|
||||
background: var(--accent-secondary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.posts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
margin: 1rem 0 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,225 @@
|
|||
---
|
||||
// src/pages/configurations/[slug].astro
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
|
||||
// Required getStaticPaths function for dynamic routes
|
||||
export async function getStaticPaths() {
|
||||
try {
|
||||
const configEntries = await getCollection('configurations', ({ data }) => {
|
||||
// Filter out drafts in production
|
||||
return import.meta.env.PROD ? !data.draft : true;
|
||||
});
|
||||
|
||||
return configEntries.map(entry => ({
|
||||
params: { slug: entry.slug },
|
||||
props: { entry },
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching configurations:', error);
|
||||
// Return empty array if collection doesn't exist or is empty
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get the configuration from props
|
||||
const { entry } = Astro.props;
|
||||
|
||||
// Format date helper
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Generate datetime attribute safely
|
||||
const getISODate = (date) => {
|
||||
if (!date) return '';
|
||||
// Handle various date formats
|
||||
try {
|
||||
// If already a Date object
|
||||
if (date instanceof Date) {
|
||||
return date.toISOString();
|
||||
}
|
||||
// If it's a string or number, convert to Date
|
||||
return new Date(date).toISOString();
|
||||
} catch (error) {
|
||||
// Fallback if date is invalid
|
||||
console.error('Invalid date format:', date);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get the Content component for rendering markdown
|
||||
const { Content } = await entry.render();
|
||||
---
|
||||
|
||||
<BaseLayout title={entry.data.title} description={entry.data.description || ''}>
|
||||
<article class="container configuration-detail">
|
||||
<header class="configuration-header">
|
||||
<h1>{entry.data.title}</h1>
|
||||
{entry.data.pubDate && <time datetime={getISODate(entry.data.pubDate)}>{formatDate(entry.data.pubDate)}</time>}
|
||||
{entry.data.updatedDate && <div class="updated-date">Updated: {formatDate(entry.data.updatedDate)}</div>}
|
||||
</header>
|
||||
|
||||
<div class="configuration-content">
|
||||
<div class="configuration-body">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<aside class="configuration-sidebar">
|
||||
{entry.data.heroImage && (
|
||||
<div class="configuration-image">
|
||||
<img src={entry.data.heroImage} alt={entry.data.title} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.data.tags && entry.data.tags.length > 0 && (
|
||||
<div class="tags-section">
|
||||
<h3>Tags</h3>
|
||||
<div class="tags">
|
||||
{entry.data.tags.map(tag => (
|
||||
<a href={`/tag/${tag}`} class="tag">{tag}</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="category-section">
|
||||
<h3>Category</h3>
|
||||
<a href={`/categories/${entry.data.category || 'Uncategorized'}`} class="category">
|
||||
{entry.data.category || 'Uncategorized'}
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--container-padding, 1.5rem);
|
||||
}
|
||||
|
||||
.configuration-detail {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.configuration-header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.configuration-header h1 {
|
||||
font-size: var(--font-size-3xl, 1.875rem);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.configuration-header time, .updated-date {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.updated-date {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.configuration-content {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.configuration-body {
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.configuration-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.configuration-image {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.configuration-image img {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.tags-section, .category-section {
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.tags-section h3, .category-section h3 {
|
||||
font-size: var(--font-size-lg, 1.125rem);
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
border-radius: 20px;
|
||||
color: var(--accent-primary);
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.category {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||
border-radius: 20px;
|
||||
color: var(--bg-primary);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.category:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.configuration-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.configuration-header h1 {
|
||||
font-size: var(--font-size-2xl, 1.5rem);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,203 @@
|
|||
---
|
||||
// src/pages/contact.astro
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
const title = "Contact | ArgoBox";
|
||||
const description = "Get in touch with ArgoBox. Send a message using the contact form.";
|
||||
---
|
||||
|
||||
<BaseLayout {title} {description}>
|
||||
<Header slot="header" />
|
||||
|
||||
<main class="contact-page container">
|
||||
<section class="contact-form-section">
|
||||
<h1>Contact Us</h1>
|
||||
<p>Have a question, suggestion, or just want to say hello? Fill out the form below.</p>
|
||||
|
||||
<form id="contact-form" class="contact-form">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="subject">Subject</label>
|
||||
<input type="text" id="subject" name="subject" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" name="message" rows="6" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-button">
|
||||
Send Message
|
||||
</button>
|
||||
<div id="form-status" class="form-status" aria-live="polite"></div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.contact-page {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 4rem;
|
||||
max-width: 700px; /* Limit width for better readability */
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.contact-form-section h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.contact-form-section p {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
background: var(--bg-secondary);
|
||||
padding: 2.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-primary);
|
||||
box-shadow: var(--card-shadow-lg);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="email"],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px var(--glow-primary);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
padding: 0.8rem 1.5rem;
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-secondary);
|
||||
}
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-status {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-status.success {
|
||||
background-color: rgba(16, 185, 129, 0.1); /* Tailwind green-100 */
|
||||
color: #059669; /* Tailwind green-700 */
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.form-status.error {
|
||||
background-color: rgba(239, 68, 68, 0.1); /* Tailwind red-100 */
|
||||
color: #dc2626; /* Tailwind red-600 */
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('contact-form') as HTMLFormElement;
|
||||
const formStatus = document.getElementById('form-status');
|
||||
const submitButton = document.getElementById('submit-button') as HTMLButtonElement;
|
||||
|
||||
form?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!formStatus || !submitButton) return;
|
||||
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Sending...';
|
||||
formStatus.textContent = '';
|
||||
formStatus.className = 'form-status'; // Reset classes
|
||||
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
formStatus.textContent = 'Message sent successfully! Thank you.';
|
||||
formStatus.classList.add('success');
|
||||
form.reset(); // Clear the form
|
||||
} else {
|
||||
formStatus.textContent = `Error: ${result.message || 'Could not send message.'}`;
|
||||
formStatus.classList.add('error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
formStatus.textContent = 'An unexpected error occurred. Please try again later.';
|
||||
formStatus.classList.add('error');
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Send Message';
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -0,0 +1,448 @@
|
|||
---
|
||||
// src/pages/dashboard.astro - Converted from static dashboard.html
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
const title = "ArgoBox Dashboard | Status and Metrics";
|
||||
const description = "ArgoBox operations dashboard provides real-time monitoring and status updates for all lab services and infrastructure components.";
|
||||
|
||||
// Placeholder data - In a real app, this would come from an API or state management
|
||||
const systemHealth = { cpu: 23, memory: 42, disk: 78, temp: 52, uptime: "14d 7h 32m" };
|
||||
const network = { throughput: 12.4, latency: 4, connections: 127, packetLoss: 0.02, dns: 2 };
|
||||
const kubernetes = { nodes: "3/3 Ready", pods: "42/44 Running", deployments: "11/12 Healthy", memoryPressure: "Low", load: 37 };
|
||||
const storage = { nasStatus: "Online", raidHealth: "Optimal", iops: 243, diskIO: 2.4, backups: "Completed 07/24" };
|
||||
const serviceStatus = [
|
||||
{ name: "Kubernetes API", status: "online" },
|
||||
{ name: "Container Registry", status: "online" },
|
||||
{ name: "Monitoring", status: "degraded" },
|
||||
{ name: "Authentication", status: "online" },
|
||||
{ name: "Database", status: "online" },
|
||||
{ name: "Storage", status: "online" },
|
||||
{ name: "CI/CD", status: "offline" },
|
||||
{ name: "Logging", status: "online" },
|
||||
];
|
||||
const weeklyTraffic = [65, 80, 45, 90, 60, 75, 40]; // Example percentages
|
||||
const recentAlerts = [
|
||||
{ name: "CI/CD Pipeline Failure", level: "error", time: "3h ago" },
|
||||
{ name: "High Memory Usage", level: "warning", time: "6h ago" },
|
||||
{ name: "Monitoring Service Degraded", level: "warning", time: "12h ago" },
|
||||
{ name: "Backup Completed", level: "healthy", time: "1d ago" },
|
||||
{ name: "Security Update Available", level: "warning", time: "2d ago" },
|
||||
];
|
||||
|
||||
// Function to determine status class
|
||||
const getStatusClass = (value: number | string, type: 'percent' | 'temp' | 'loss' | 'latency' | 'dns' | 'status') => {
|
||||
if (type === 'status') {
|
||||
return value === 'Online' || value === 'Optimal' || value === 'Low' || value === 'Ready' || value === 'Running' || value === 'Completed' ? 'healthy' :
|
||||
value === 'Degraded' || value === 'Warning' ? 'warning' : 'error';
|
||||
}
|
||||
if (typeof value !== 'number') return '';
|
||||
if (type === 'percent') return value > 90 ? 'error' : value > 75 ? 'warning' : 'healthy';
|
||||
if (type === 'temp') return value > 70 ? 'error' : value > 60 ? 'warning' : 'healthy';
|
||||
if (type === 'loss') return value > 1 ? 'error' : value > 0.1 ? 'warning' : 'healthy';
|
||||
if (type === 'latency' || type === 'dns') return value > 50 ? 'error' : value > 10 ? 'warning' : 'healthy';
|
||||
return 'healthy';
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout {title} {description}>
|
||||
{/* Add Font Awesome if not loaded globally */}
|
||||
{/* <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" /> */}
|
||||
<Header slot="header" />
|
||||
|
||||
<main class="dashboard-container">
|
||||
<div class="container">
|
||||
|
||||
<!-- Offline Notice Banner -->
|
||||
<div class="offline-notice" id="offline-notice">
|
||||
<div class="offline-notice-icon">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<div class="offline-notice-text">
|
||||
<h3>Dashboard is Currently Offline</h3>
|
||||
<p>The live dashboard is currently in simulation mode using placeholder data. Real-time data is not available.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="dashboard-header">
|
||||
<h1 class="dashboard-title">Infrastructure Status Dashboard</h1>
|
||||
<p class="dashboard-subtitle">Monitoring and metrics for ArgoBox infrastructure components</p>
|
||||
</header>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<!-- System Health Card -->
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">System Health</h2>
|
||||
<div class="card-icon"><i class="fas fa-heartbeat"></i></div>
|
||||
</div>
|
||||
<div class="metric-list">
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">CPU Usage</span>
|
||||
<span class={`metric-value ${getStatusClass(systemHealth.cpu, 'percent')}`}>{systemHealth.cpu}%</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Memory Usage</span>
|
||||
<span class={`metric-value ${getStatusClass(systemHealth.memory, 'percent')}`}>{systemHealth.memory}%</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Disk Space</span>
|
||||
<span class={`metric-value ${getStatusClass(systemHealth.disk, 'percent')}`}>{systemHealth.disk}%</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Temperature</span>
|
||||
<span class={`metric-value ${getStatusClass(systemHealth.temp, 'temp')}`}>{systemHealth.temp}°C</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Uptime</span>
|
||||
<span class="metric-value">{systemHealth.uptime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Card -->
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Network</h2>
|
||||
<div class="card-icon"><i class="fas fa-network-wired"></i></div>
|
||||
</div>
|
||||
<div class="metric-list">
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Throughput</span>
|
||||
<span class="metric-value healthy">{network.throughput} MB/s</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Latency</span>
|
||||
<span class={`metric-value ${getStatusClass(network.latency, 'latency')}`}>{network.latency}ms</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Active Connections</span>
|
||||
<span class="metric-value">{network.connections}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Packet Loss</span>
|
||||
<span class={`metric-value ${getStatusClass(network.packetLoss, 'loss')}`}>{network.packetLoss}%</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">DNS Response</span>
|
||||
<span class={`metric-value ${getStatusClass(network.dns, 'dns')}`}>{network.dns}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kubernetes Card -->
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Kubernetes</h2>
|
||||
<div class="card-icon"><i class="fas fa-dharmachakra"></i></div>
|
||||
</div>
|
||||
<div class="metric-list">
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Node Status</span>
|
||||
<span class={`metric-value ${kubernetes.nodes.includes('Ready') && !kubernetes.nodes.includes('/') ? 'healthy' : 'warning'}`}>{kubernetes.nodes}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Pods</span>
|
||||
<span class={`metric-value ${kubernetes.pods.includes('Running') && !kubernetes.pods.includes('/') ? 'healthy' : 'warning'}`}>{kubernetes.pods}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Deployments</span>
|
||||
<span class={`metric-value ${kubernetes.deployments.includes('Healthy') && !kubernetes.deployments.includes('/') ? 'healthy' : 'warning'}`}>{kubernetes.deployments}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Memory Pressure</span>
|
||||
<span class={`metric-value ${getStatusClass(kubernetes.memoryPressure, 'status')}`}>{kubernetes.memoryPressure}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Cluster Load</span>
|
||||
<span class={`metric-value ${getStatusClass(kubernetes.load, 'percent')}`}>{kubernetes.load}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Card -->
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Storage</h2>
|
||||
<div class="card-icon"><i class="fas fa-hdd"></i></div>
|
||||
</div>
|
||||
<div class="metric-list">
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">NAS Status</span>
|
||||
<span class={`metric-value ${getStatusClass(storage.nasStatus, 'status')}`}>{storage.nasStatus}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">RAID Health</span>
|
||||
<span class={`metric-value ${getStatusClass(storage.raidHealth, 'status')}`}>{storage.raidHealth}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">IOPS</span>
|
||||
<span class="metric-value">{storage.iops}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Disk I/O</span>
|
||||
<span class="metric-value healthy">{storage.diskIO} MB/s</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-name">Backups</span>
|
||||
<span class={`metric-value ${getStatusClass(storage.backups, 'status')}`}>{storage.backups}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Status Card -->
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Service Status</h2>
|
||||
<div class="card-icon"><i class="fas fa-server"></i></div>
|
||||
</div>
|
||||
<div class="status-grid">
|
||||
{serviceStatus.map(service => (
|
||||
<div class="status-item">
|
||||
<div class={`status-indicator ${service.status}`}></div>
|
||||
<span class="status-name">{service.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid for Charts/Alerts -->
|
||||
<div class="dashboard-grid">
|
||||
<!-- Weekly Traffic Card -->
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Weekly Traffic</h2>
|
||||
<div class="card-icon"><i class="fas fa-chart-line"></i></div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
{weeklyTraffic.map(value => (
|
||||
<div class="chart-bar" style={`height: ${value}%;`}></div>
|
||||
))}
|
||||
</div>
|
||||
<div class="chart-labels">
|
||||
<div class="chart-label">Mon</div>
|
||||
<div class="chart-label">Tue</div>
|
||||
<div class="chart-label">Wed</div>
|
||||
<div class="chart-label">Thu</div>
|
||||
<div class="chart-label">Fri</div>
|
||||
<div class="chart-label">Sat</div>
|
||||
<div class="chart-label">Sun</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Alerts Card -->
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Recent Alerts</h2>
|
||||
<div class="card-icon"><i class="fas fa-bell"></i></div>
|
||||
</div>
|
||||
<div class="metric-list">
|
||||
{recentAlerts.map(alert => (
|
||||
<div class="metric-item">
|
||||
<span class={`metric-name alert-${alert.level}`}>{alert.name}</span>
|
||||
<span class={`metric-value ${alert.level}`}>{alert.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer slot="footer" />
|
||||
</BaseLayout>
|
||||
|
||||
<style is:global>
|
||||
/* Dashboard-specific styles adapted from dashboard.html */
|
||||
/* Use theme variables */
|
||||
|
||||
.dashboard-container {
|
||||
padding-top: 2rem; /* Add space from header */
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center; /* Center header */
|
||||
}
|
||||
.dashboard-title {
|
||||
font-size: clamp(1.8rem, 5vw, 2.5rem); /* Responsive title */
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.dashboard-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: clamp(1rem, 3vw, 1.1rem);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
.dashboard-card:hover {
|
||||
background-color: var(--card-hover-bg);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem; /* Add padding */
|
||||
border-bottom: 1px solid var(--border); /* Add border */
|
||||
}
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.card-icon {
|
||||
color: var(--accent);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.metric-list { display: grid; gap: 1rem; }
|
||||
.metric-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.9rem; }
|
||||
.metric-name { color: var(--text-secondary); }
|
||||
.metric-value { font-family: var(--font-mono); font-weight: 500; color: var(--text-primary); }
|
||||
.metric-value.healthy { color: var(--success); }
|
||||
.metric-value.warning { color: var(--warning); }
|
||||
.metric-value.error { color: var(--error); }
|
||||
.metric-name.alert-warning { color: var(--warning); font-weight: 500; } /* Style alert names */
|
||||
.metric-name.alert-error { color: var(--error); font-weight: 500; }
|
||||
.metric-name.alert-healthy { color: var(--text-secondary); } /* Normal color for healthy alerts */
|
||||
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); /* Adjust minmax */
|
||||
gap: 1rem;
|
||||
}
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem; /* Increased gap */
|
||||
background-color: var(--bg-secondary); /* Use secondary bg */
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.status-indicator {
|
||||
width: 10px; /* Slightly smaller */
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0; /* Prevent shrinking */
|
||||
}
|
||||
.status-indicator.online { background-color: var(--success); box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); } /* Adjusted shadow */
|
||||
.status-indicator.offline { background-color: var(--error); box-shadow: 0 0 8px rgba(239, 68, 68, 0.4); }
|
||||
.status-indicator.degraded { background-color: var(--warning); box-shadow: 0 0 8px rgba(245, 158, 11, 0.4); }
|
||||
.status-name { font-size: 0.9rem; color: var(--text-primary); }
|
||||
|
||||
.chart-container {
|
||||
height: 180px; /* Slightly smaller */
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem; /* Add padding */
|
||||
background-color: var(--bg-secondary); /* Add background */
|
||||
border-radius: 0.5rem; /* Add radius */
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.chart-bar {
|
||||
flex: 1;
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
border-radius: 4px 4px 0 0;
|
||||
position: relative;
|
||||
min-height: 5px; /* Ensure visibility */
|
||||
transition: height 0.5s ease; /* Animate height */
|
||||
}
|
||||
/* Removed ::before for simplicity, height set directly */
|
||||
|
||||
.chart-labels { display: flex; justify-content: space-between; margin-top: 0.5rem; }
|
||||
.chart-label { font-size: 0.75rem; color: var(--text-secondary); flex: 1; text-align: center; }
|
||||
|
||||
.offline-notice {
|
||||
background-color: rgba(239, 68, 68, 0.1); /* Use theme error */
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-left: 4px solid var(--error); /* Use theme error */
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.offline-notice-icon { color: var(--error); font-size: 2rem; flex-shrink: 0; }
|
||||
.offline-notice-text h3 { font-weight: 600; margin-bottom: 0.5rem; color: var(--error); }
|
||||
.offline-notice-text p { color: var(--text-secondary); margin: 0; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.status-grid { grid-template-columns: 1fr 1fr; }
|
||||
.offline-notice { flex-direction: column; text-align: center; gap: 1rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
// Placeholder for potential future JS to fetch real data
|
||||
// For now, it uses the static data from the frontmatter
|
||||
|
||||
// Example: Function to update status indicators dynamically
|
||||
// function updateServiceStatus(serviceName, status) {
|
||||
// const item = document.querySelector(`.service-${serviceName.toLowerCase().replace(/\s+/g, '-')}`); // Requires adding class to status items
|
||||
// if (item) {
|
||||
// const indicator = item.querySelector('.status-indicator');
|
||||
// indicator.className = `status-indicator ${status}`; // status should be 'online', 'offline', 'degraded'
|
||||
// }
|
||||
// }
|
||||
|
||||
// Example: Function to update metrics
|
||||
// function updateMetric(metricNameSelector, value, statusClass = '') {
|
||||
// const valueEl = document.querySelector(metricNameSelector); // Need unique selectors for each metric value
|
||||
// if (valueEl) {
|
||||
// valueEl.textContent = value;
|
||||
// valueEl.className = `metric-value ${statusClass}`;
|
||||
// }
|
||||
// }
|
||||
|
||||
// Example: Fetch data and update UI
|
||||
// async function fetchDashboardData() {
|
||||
// try {
|
||||
// const response = await fetch('/api/dashboard-status'); // Your API endpoint
|
||||
// if (!response.ok) throw new Error('Failed to fetch status');
|
||||
// const data = await response.json();
|
||||
//
|
||||
// // Update UI elements based on data
|
||||
// // updateMetric('#cpu-usage', data.systemHealth.cpu, getStatusClass(data.systemHealth.cpu, 'percent'));
|
||||
// // ... update other metrics ...
|
||||
// // data.serviceStatus.forEach(service => updateServiceStatus(service.name, service.status));
|
||||
//
|
||||
// } catch (error) {
|
||||
// console.error("Error fetching dashboard data:", error);
|
||||
// // Show error state in UI
|
||||
// }
|
||||
// }
|
||||
|
||||
// document.addEventListener('DOMContentLoaded', () => {
|
||||
// // fetchDashboardData(); // Initial fetch
|
||||
// // setInterval(fetchDashboardData, 60000); // Fetch every minute
|
||||
// });
|
||||
|
||||
</script>
|