feat(sk1): Initial commit for Azure Cloud Deployment assignment
This commit is contained in:
parent
64abf7ead5
commit
1bcef88ed2
14
sk1/.gitignore
vendored
Normal file
14
sk1/.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
.env
|
||||
*.pem
|
||||
*.key
|
||||
node_modules/
|
||||
backups/
|
||||
|
||||
# LaTeX build artifacts
|
||||
*.aux
|
||||
*.log
|
||||
*.out
|
||||
*.toc
|
||||
*.fdb_latexmk
|
||||
*.fls
|
||||
*.synctex.gz
|
||||
116
sk1/README.md
Normal file
116
sk1/README.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Task Manager — Cloud Deployment (SK1)
|
||||
|
||||
A full-stack task management web application deployed to **Microsoft Azure** using Docker Compose.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||

|
||||
|
||||
| Container | Image | Port | Purpose |
|
||||
|-----------|-------|------|---------|
|
||||
| **Caddy** | caddy:2-alpine | 443, 80 | Reverse proxy + automatic HTTPS (Let's Encrypt) |
|
||||
| **Frontend** | nginx:alpine | 80 | Static files + API reverse proxy |
|
||||
| **API** | node:20-alpine | 3000 | Express REST API (CRUD) |
|
||||
| **PostgreSQL** | postgres:16-alpine | 5432 | Persistent relational database |
|
||||
| **Redis** | redis:7-alpine | 6379 | In-memory cache |
|
||||
|
||||
## 🚀 Quick Deploy
|
||||
|
||||
```bash
|
||||
# 1. Login to Azure (requires Azure for Students subscription)
|
||||
az login
|
||||
|
||||
# 2. Clone and configure
|
||||
git clone git@git.kemt.fei.tuke.sk:gs699he/zkt26.git
|
||||
cd zkt26/sk1
|
||||
cp env.example .env
|
||||
nano .env # Set POSTGRES_PASSWORD, optionally change DNS_LABEL
|
||||
|
||||
# 3. Deploy (creates VM, installs Docker, starts containers)
|
||||
./prepare-app.sh
|
||||
|
||||
# 4. Visit: https://taskmanager-gs699he.westeurope.cloudapp.azure.com
|
||||
```
|
||||
|
||||
## 🗑️ Remove (after exam)
|
||||
|
||||
```bash
|
||||
./remove-app.sh # Deletes entire resource group (VM, IP, disk)
|
||||
```
|
||||
|
||||
## 💾 Backup & Logs
|
||||
|
||||
```bash
|
||||
./backup-db.sh # Download database dump
|
||||
./view-logs.sh # All container logs
|
||||
./view-logs.sh --api -f # Follow API logs in real-time
|
||||
```
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
sk1/
|
||||
├── api/ # Node.js REST API
|
||||
│ ├── Dockerfile
|
||||
│ ├── server.js # Express + CRUD routes
|
||||
│ ├── db.js # PostgreSQL pool
|
||||
│ └── package.json
|
||||
├── frontend/ # Web UI
|
||||
│ ├── Dockerfile
|
||||
│ ├── nginx.conf
|
||||
│ └── public/ (index.html, style.css, app.js)
|
||||
├── db/init.sql # Database schema
|
||||
├── caddy/Caddyfile # HTTPS proxy config
|
||||
├── docs/
|
||||
│ ├── architecture.png # Architecture diagram
|
||||
│ ├── documentation.tex # LaTeX source
|
||||
│ └── documentation.pdf # 9-page PDF report
|
||||
├── docker-compose.yaml # 5-service orchestration
|
||||
├── cloud-init.yaml # VM bootstrap (Docker install)
|
||||
├── prepare-app.sh # Azure deployment script
|
||||
├── remove-app.sh # Azure teardown script
|
||||
├── backup-db.sh # Database backup
|
||||
├── view-logs.sh # Access log viewer
|
||||
├── env.example # Environment template
|
||||
├── .gitignore
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `POSTGRES_PASSWORD` | Database password | *(required)* |
|
||||
| `AZURE_RESOURCE_GROUP` | Azure resource group | taskmanager-rg |
|
||||
| `AZURE_LOCATION` | Azure region | westeurope |
|
||||
| `AZURE_VM_SIZE` | VM size | Standard_B1s |
|
||||
| `AZURE_DNS_LABEL` | DNS label for URL | taskmanager-gs699he |
|
||||
|
||||
## 💰 Cost (Azure for Students)
|
||||
|
||||
| Resource | Monthly Cost |
|
||||
|----------|-------------|
|
||||
| VM (Standard_B1s) | ~$7.59 |
|
||||
| Public IP | ~$3.00 |
|
||||
| Disk (30 GB) | ~$1.20 |
|
||||
| TLS (Let's Encrypt) | Free |
|
||||
| **Total** | **~$11.79/mo** |
|
||||
|
||||
> 💡 Azure for Students gives $100 free credit — enough for ~8 months.
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
- HTTPS enforced (Caddy + Let's Encrypt)
|
||||
- Databases on isolated Docker network (`backend-net`)
|
||||
- Secrets via `.env` (gitignored)
|
||||
- Security headers in Nginx
|
||||
- Auto-restart on failure
|
||||
|
||||
## 🤖 AI Declaration
|
||||
|
||||
- **Google Antigravity (Gemini)**: Scripts, Docker config, LaTeX docs, diagram
|
||||
- All code reviewed and adapted by student
|
||||
|
||||
---
|
||||
|
||||
*Technical University of Košice — KEMT FEI — Cloud Technologies — 2026*
|
||||
20
sk1/api/Dockerfile
Normal file
20
sk1/api/Dockerfile
Normal file
@ -0,0 +1,20 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
|
||||
# Copy application source
|
||||
COPY . .
|
||||
|
||||
# Expose API port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
18
sk1/api/db.js
Normal file
18
sk1/api/db.js
Normal file
@ -0,0 +1,18 @@
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.POSTGRES_HOST || 'postgres',
|
||||
port: parseInt(process.env.POSTGRES_PORT || '5432'),
|
||||
database: process.env.POSTGRES_DB || 'taskmanager',
|
||||
user: process.env.POSTGRES_USER || 'taskuser',
|
||||
password: process.env.POSTGRES_PASSWORD || 'taskpass',
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected PostgreSQL pool error:', err);
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
15
sk1/api/package.json
Normal file
15
sk1/api/package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "taskmanager-api",
|
||||
"version": "1.0.0",
|
||||
"description": "REST API for Task Manager - Cloud deployment",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"pg": "^8.11.3",
|
||||
"redis": "^4.6.10"
|
||||
}
|
||||
}
|
||||
173
sk1/api/server.js
Normal file
173
sk1/api/server.js
Normal file
@ -0,0 +1,173 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const pool = require('./db');
|
||||
const { createClient } = require('redis');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms - ${req.ip}`
|
||||
);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Redis client
|
||||
let redisClient;
|
||||
const CACHE_TTL = 30; // seconds
|
||||
|
||||
async function initRedis() {
|
||||
try {
|
||||
redisClient = createClient({
|
||||
url: `redis://${process.env.REDIS_HOST || 'redis'}:${process.env.REDIS_PORT || 6379}`
|
||||
});
|
||||
redisClient.on('error', (err) => console.error('Redis error:', err));
|
||||
await redisClient.connect();
|
||||
console.log('Connected to Redis');
|
||||
} catch (err) {
|
||||
console.error('Failed to connect to Redis:', err.message);
|
||||
redisClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: invalidate cache
|
||||
async function invalidateCache() {
|
||||
if (redisClient && redisClient.isOpen) {
|
||||
try {
|
||||
await redisClient.del('tasks:all');
|
||||
} catch (err) {
|
||||
console.error('Cache invalidation error:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', async (req, res) => {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
const redisOk = redisClient && redisClient.isOpen;
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
database: 'connected',
|
||||
cache: redisOk ? 'connected' : 'disconnected',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(503).json({ status: 'unhealthy', error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tasks — List all tasks (with Redis caching)
|
||||
app.get('/api/tasks', async (req, res) => {
|
||||
try {
|
||||
if (redisClient && redisClient.isOpen) {
|
||||
const cached = await redisClient.get('tasks:all');
|
||||
if (cached) {
|
||||
return res.json({ tasks: JSON.parse(cached), source: 'cache' });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM tasks ORDER BY created_at DESC'
|
||||
);
|
||||
|
||||
if (redisClient && redisClient.isOpen) {
|
||||
await redisClient.setEx('tasks:all', CACHE_TTL, JSON.stringify(result.rows));
|
||||
}
|
||||
|
||||
res.json({ tasks: result.rows, source: 'database' });
|
||||
} catch (err) {
|
||||
console.error('Error fetching tasks:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch tasks' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks — Create a new task
|
||||
app.post('/api/tasks', async (req, res) => {
|
||||
try {
|
||||
const { title, description } = req.body;
|
||||
if (!title || title.trim() === '') {
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'INSERT INTO tasks (title, description) VALUES ($1, $2) RETURNING *',
|
||||
[title.trim(), (description || '').trim()]
|
||||
);
|
||||
|
||||
await invalidateCache();
|
||||
res.status(201).json({ task: result.rows[0] });
|
||||
} catch (err) {
|
||||
console.error('Error creating task:', err);
|
||||
res.status(500).json({ error: 'Failed to create task' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/tasks/:id — Update a task
|
||||
app.put('/api/tasks/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { title, description, completed } = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE tasks
|
||||
SET title = COALESCE($1, title),
|
||||
description = COALESCE($2, description),
|
||||
completed = COALESCE($3, completed),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4 RETURNING *`,
|
||||
[title, description, completed, id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
|
||||
await invalidateCache();
|
||||
res.json({ task: result.rows[0] });
|
||||
} catch (err) {
|
||||
console.error('Error updating task:', err);
|
||||
res.status(500).json({ error: 'Failed to update task' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id — Delete a task
|
||||
app.delete('/api/tasks/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await pool.query(
|
||||
'DELETE FROM tasks WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
|
||||
await invalidateCache();
|
||||
res.json({ message: 'Task deleted', task: result.rows[0] });
|
||||
} catch (err) {
|
||||
console.error('Error deleting task:', err);
|
||||
res.status(500).json({ error: 'Failed to delete task' });
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
async function start() {
|
||||
await initRedis();
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Task Manager API running on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
40
sk1/backup-db.sh
Executable file
40
sk1/backup-db.sh
Executable file
@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# backup-db.sh — Backup PostgreSQL from the Azure VM
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env"
|
||||
|
||||
LOC="${AZURE_LOCATION:-westeurope}"
|
||||
DNS="${AZURE_DNS_LABEL:-taskmanager-gs699he}"
|
||||
FQDN="${DNS}.${LOC}.cloudapp.azure.com"
|
||||
SSH_KEY="$HOME/.ssh/sk1_taskmanager"
|
||||
ADMIN="azureuser"
|
||||
|
||||
BACKUP_DIR="$SCRIPT_DIR/backups"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="taskmanager_backup_${TIMESTAMP}.sql"
|
||||
|
||||
echo "============================================="
|
||||
echo " Database Backup — Task Manager"
|
||||
echo " Host: $FQDN"
|
||||
echo "============================================="
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
echo " Creating database dump..."
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" "$ADMIN@$FQDN" \
|
||||
"sudo docker exec taskapp-postgres pg_dump -U ${POSTGRES_USER:-taskuser} ${POSTGRES_DB:-taskmanager}" \
|
||||
> "$BACKUP_DIR/$BACKUP_FILE"
|
||||
|
||||
if [ -s "$BACKUP_DIR/$BACKUP_FILE" ]; then
|
||||
SIZE=$(du -h "$BACKUP_DIR/$BACKUP_FILE" | cut -f1)
|
||||
echo " ✓ Saved: $BACKUP_DIR/$BACKUP_FILE ($SIZE)"
|
||||
else
|
||||
echo " ✗ Backup failed"
|
||||
rm -f "$BACKUP_DIR/$BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
echo "============================================="
|
||||
21
sk1/caddy/Caddyfile
Normal file
21
sk1/caddy/Caddyfile
Normal file
@ -0,0 +1,21 @@
|
||||
{$DOMAIN_NAME} {
|
||||
# Reverse proxy to Nginx frontend
|
||||
reverse_proxy frontend:80
|
||||
|
||||
# Access logging
|
||||
log {
|
||||
output file /data/access.log {
|
||||
roll_size 10mb
|
||||
roll_keep 5
|
||||
}
|
||||
format json
|
||||
}
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
}
|
||||
14
sk1/cloud-init.yaml
Normal file
14
sk1/cloud-init.yaml
Normal file
@ -0,0 +1,14 @@
|
||||
#cloud-config
|
||||
package_update: true
|
||||
packages:
|
||||
- ca-certificates
|
||||
- curl
|
||||
- gnupg
|
||||
- lsb-release
|
||||
|
||||
runcmd:
|
||||
- curl -fsSL https://get.docker.com | sh
|
||||
- systemctl enable docker
|
||||
- systemctl start docker
|
||||
- usermod -aG docker azureuser
|
||||
- mkdir -p /opt/taskmanager
|
||||
17
sk1/db/init.sql
Normal file
17
sk1/db/init.sql
Normal file
@ -0,0 +1,17 @@
|
||||
-- Task Manager Database Initialization
|
||||
-- Creates the tasks table for the application
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
completed BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insert sample tasks so the app isn't empty on first load
|
||||
INSERT INTO tasks (title, description, completed) VALUES
|
||||
('Welcome to Task Manager', 'This is a sample task deployed to the cloud. You can edit or delete it.', FALSE),
|
||||
('Try creating a new task', 'Click the Add Task button to create your own tasks.', FALSE),
|
||||
('Mark tasks as complete', 'Click the checkbox to mark a task as done.', TRUE);
|
||||
125
sk1/docker-compose.yaml
Normal file
125
sk1/docker-compose.yaml
Normal file
@ -0,0 +1,125 @@
|
||||
services:
|
||||
# ==========================================================
|
||||
# Caddy — Reverse Proxy with Automatic HTTPS
|
||||
# Provisions Let's Encrypt TLS certificates automatically.
|
||||
# ==========================================================
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: taskapp-caddy
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
- access_logs:/data
|
||||
environment:
|
||||
- DOMAIN_NAME=${DOMAIN_NAME:-localhost}
|
||||
depends_on:
|
||||
- frontend
|
||||
networks:
|
||||
- frontend-net
|
||||
|
||||
# ==========================================================
|
||||
# Frontend — Nginx serving static files + reverse proxy to API
|
||||
# ==========================================================
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: taskapp-frontend
|
||||
restart: always
|
||||
expose:
|
||||
- "80"
|
||||
depends_on:
|
||||
- api
|
||||
networks:
|
||||
- frontend-net
|
||||
|
||||
# ==========================================================
|
||||
# API — Node.js Express REST Backend
|
||||
# ==========================================================
|
||||
api:
|
||||
build: ./api
|
||||
container_name: taskapp-api
|
||||
restart: always
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- PORT=3000
|
||||
- POSTGRES_HOST=${POSTGRES_HOST:-postgres}
|
||||
- POSTGRES_PORT=${POSTGRES_PORT:-5432}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-taskmanager}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-taskuser}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-taskpass}
|
||||
- REDIS_HOST=${REDIS_HOST:-redis}
|
||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- frontend-net
|
||||
- backend-net
|
||||
|
||||
# ==========================================================
|
||||
# PostgreSQL — Primary persistent database
|
||||
# ==========================================================
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: taskapp-postgres
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_DB=${POSTGRES_DB:-taskmanager}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-taskuser}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-taskpass}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-taskuser} -d ${POSTGRES_DB:-taskmanager}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
networks:
|
||||
- backend-net
|
||||
|
||||
# ==========================================================
|
||||
# Redis — In-memory cache for fast API responses
|
||||
# ==========================================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: taskapp-redis
|
||||
restart: always
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- backend-net
|
||||
|
||||
# Named volumes for data persistence
|
||||
volumes:
|
||||
pgdata:
|
||||
name: taskapp-pgdata
|
||||
redisdata:
|
||||
name: taskapp-redisdata
|
||||
caddy_data:
|
||||
name: taskapp-caddy-data
|
||||
caddy_config:
|
||||
name: taskapp-caddy-config
|
||||
access_logs:
|
||||
name: taskapp-access-logs
|
||||
|
||||
# Virtual networks for service isolation
|
||||
networks:
|
||||
frontend-net:
|
||||
name: taskapp-frontend-net
|
||||
backend-net:
|
||||
name: taskapp-backend-net
|
||||
BIN
sk1/docs/architecture.png
Normal file
BIN
sk1/docs/architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 445 KiB |
BIN
sk1/docs/documentation.pdf
Normal file
BIN
sk1/docs/documentation.pdf
Normal file
Binary file not shown.
361
sk1/docs/documentation.tex
Normal file
361
sk1/docs/documentation.tex
Normal file
@ -0,0 +1,361 @@
|
||||
\documentclass[12pt,a4paper]{article}
|
||||
|
||||
% ============================================================
|
||||
% Packages
|
||||
% ============================================================
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage{lmodern}
|
||||
\usepackage[margin=2.5cm]{geometry}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{hyperref}
|
||||
\usepackage{xcolor}
|
||||
\usepackage{listings}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage{titlesec}
|
||||
\usepackage{float}
|
||||
\usepackage{longtable}
|
||||
\usepackage{caption}
|
||||
\usepackage{textcomp}
|
||||
|
||||
\setlength{\headheight}{14pt}
|
||||
\addtolength{\topmargin}{-2pt}
|
||||
|
||||
% ============================================================
|
||||
% Colors and Styling
|
||||
% ============================================================
|
||||
\definecolor{azureblue}{HTML}{0078D4}
|
||||
\definecolor{codegray}{HTML}{F5F5F7}
|
||||
\definecolor{textdark}{HTML}{1D1D1F}
|
||||
\definecolor{linkblue}{HTML}{0071E3}
|
||||
|
||||
\hypersetup{
|
||||
colorlinks=true,
|
||||
linkcolor=azureblue,
|
||||
urlcolor=linkblue,
|
||||
citecolor=azureblue,
|
||||
pdftitle={Task Manager -- Cloud Deployment Documentation},
|
||||
pdfauthor={Gopi Suvanam}
|
||||
}
|
||||
|
||||
\lstset{
|
||||
backgroundcolor=\color{codegray},
|
||||
basicstyle=\ttfamily\small,
|
||||
breaklines=true,
|
||||
frame=single,
|
||||
rulecolor=\color{gray!30},
|
||||
captionpos=b,
|
||||
tabsize=2,
|
||||
showstringspaces=false,
|
||||
keywordstyle=\color{azureblue}\bfseries,
|
||||
commentstyle=\color{gray},
|
||||
stringstyle=\color{green!50!black}
|
||||
}
|
||||
|
||||
\pagestyle{fancy}
|
||||
\fancyhf{}
|
||||
\fancyhead[L]{\small\textcolor{gray}{Task Manager -- Cloud Deployment}}
|
||||
\fancyhead[R]{\small\textcolor{gray}{SK1 Final Exam}}
|
||||
\fancyfoot[C]{\thepage}
|
||||
\renewcommand{\headrulewidth}{0.4pt}
|
||||
|
||||
\titleformat{\section}{\Large\bfseries\color{azureblue}}{}{0em}{}[\vspace{-0.5em}\textcolor{azureblue}{\rule{\textwidth}{0.5pt}}]
|
||||
\titleformat{\subsection}{\large\bfseries\color{textdark}}{}{0em}{}
|
||||
|
||||
% ============================================================
|
||||
\begin{document}
|
||||
% ============================================================
|
||||
|
||||
% ---- Title Page -------------------------------------------
|
||||
\begin{titlepage}
|
||||
\centering
|
||||
\vspace*{3cm}
|
||||
{\Huge\bfseries\color{azureblue} Task Manager}\\[0.5cm]
|
||||
{\LARGE Cloud Deployment Documentation}\\[2cm]
|
||||
|
||||
\begin{tabular}{rl}
|
||||
\textbf{Course:} & Cloud Technologies / Web Application Deployment \\
|
||||
\textbf{Student:} & Gopi Suvanam \\
|
||||
\textbf{Login:} & gs699he \\
|
||||
\textbf{Date:} & \today \\
|
||||
\textbf{Cloud:} & Microsoft Azure (Azure for Students) \\
|
||||
\textbf{Repository:} & \url{https://git.kemt.fei.tuke.sk/gs699he/zkt25} \\
|
||||
\end{tabular}
|
||||
|
||||
\vspace{3cm}
|
||||
\includegraphics[width=0.65\textwidth]{architecture.png}
|
||||
\vfill
|
||||
{\small Technical University of Ko\v{s}ice -- KEMT FEI}
|
||||
\end{titlepage}
|
||||
|
||||
\tableofcontents
|
||||
\newpage
|
||||
|
||||
% =============================================================
|
||||
\section{Application Overview}
|
||||
% =============================================================
|
||||
|
||||
The \textbf{Task Manager} is a full-stack web application for creating, reading, updating, and deleting (CRUD) tasks. It is deployed to \textbf{Microsoft Azure} using Docker Compose on an Azure Virtual Machine.
|
||||
|
||||
\subsection{Key Features}
|
||||
\begin{itemize}[nosep]
|
||||
\item RESTful API with full CRUD operations
|
||||
\item Apple-inspired premium UI with glassmorphism
|
||||
\item Redis caching (30s TTL) for API optimization
|
||||
\item PostgreSQL database with persistent volume and backup scripts
|
||||
\item Automatic HTTPS via Caddy + Let's Encrypt
|
||||
\item Fully automated deployment via shell scripts
|
||||
\item Auto-restart on container failure
|
||||
\end{itemize}
|
||||
|
||||
% =============================================================
|
||||
\section{Architecture}
|
||||
% =============================================================
|
||||
|
||||
The application uses a \textbf{5-container microservices architecture} on an Azure VM, orchestrated by Docker Compose.
|
||||
|
||||
\begin{figure}[H]
|
||||
\centering
|
||||
\includegraphics[width=0.72\textwidth]{architecture.png}
|
||||
\caption{Architecture -- 5 Docker containers on Azure VM}
|
||||
\label{fig:architecture}
|
||||
\end{figure}
|
||||
|
||||
\subsection{Container Descriptions}
|
||||
|
||||
\begin{longtable}{@{}p{2.8cm}p{3cm}p{7.7cm}@{}}
|
||||
\toprule
|
||||
\textbf{Container} & \textbf{Image} & \textbf{Purpose} \\
|
||||
\midrule
|
||||
\endhead
|
||||
Caddy & caddy:2-alpine & Reverse proxy with automatic HTTPS/TLS via Let's Encrypt. Ports 80, 443. \\[6pt]
|
||||
Frontend & nginx:alpine & Serves static HTML/CSS/JS, proxies \texttt{/api/} to backend. \\[6pt]
|
||||
API & node:20-alpine & Express.js REST API with CRUD endpoints. \\[6pt]
|
||||
PostgreSQL & postgres:16-alpine & Persistent database with Docker named volume. \\[6pt]
|
||||
Redis & redis:7-alpine & In-memory cache with AOF persistence. \\
|
||||
\bottomrule
|
||||
\end{longtable}
|
||||
|
||||
\subsection{Network Isolation}
|
||||
|
||||
\begin{itemize}[nosep]
|
||||
\item \texttt{frontend-net} -- Caddy, Frontend, API
|
||||
\item \texttt{backend-net} -- API, PostgreSQL, Redis
|
||||
\end{itemize}
|
||||
|
||||
Database and cache are \textbf{not accessible} from the internet. Only the API bridges both networks.
|
||||
|
||||
% =============================================================
|
||||
\section{Azure Services Used}
|
||||
% =============================================================
|
||||
|
||||
\begin{longtable}{@{}p{4.5cm}p{9cm}@{}}
|
||||
\toprule
|
||||
\textbf{Service} & \textbf{Usage} \\
|
||||
\midrule
|
||||
\endhead
|
||||
Azure VM (Standard\_B1s) & Ubuntu 24.04, 1 vCPU, 1 GB RAM. Runs Docker. \\[6pt]
|
||||
Public IP + DNS & \texttt{*.westeurope.cloudapp.azure.com} for HTTPS. \\[6pt]
|
||||
Azure for Students & \$100 free credit, no credit card required. \\[6pt]
|
||||
Let's Encrypt & Free TLS certificates via Caddy (auto-renewal). \\[6pt]
|
||||
cloud-init & Installs Docker on first VM boot automatically. \\
|
||||
\bottomrule
|
||||
\end{longtable}
|
||||
|
||||
% =============================================================
|
||||
\section{File Descriptions}
|
||||
% =============================================================
|
||||
|
||||
\begin{longtable}{@{}p{4.5cm}p{9cm}@{}}
|
||||
\toprule
|
||||
\textbf{File} & \textbf{Description} \\
|
||||
\midrule
|
||||
\endhead
|
||||
\texttt{docker-compose.yaml} & 5 services, 2 networks, persistent volumes. \\[4pt]
|
||||
\texttt{prepare-app.sh} & Creates Azure VM, installs Docker, deploys app. \\[4pt]
|
||||
\texttt{remove-app.sh} & Deletes Azure resource group (all resources). \\[4pt]
|
||||
\texttt{backup-db.sh} & SSH + pg\_dump for database backups. \\[4pt]
|
||||
\texttt{view-logs.sh} & Retrieves container logs via SSH. \\[4pt]
|
||||
\texttt{cloud-init.yaml} & Auto-installs Docker on VM first boot. \\[4pt]
|
||||
\texttt{env.example} & Template for secrets (copied to .env). \\[4pt]
|
||||
\texttt{caddy/Caddyfile} & HTTPS reverse proxy configuration. \\[4pt]
|
||||
\texttt{frontend/} & Nginx Dockerfile, config, static files. \\[4pt]
|
||||
\texttt{api/} & Node.js Dockerfile, Express server, DB pool. \\[4pt]
|
||||
\texttt{db/init.sql} & Database schema and sample data. \\[4pt]
|
||||
\texttt{docs/} & This documentation + architecture diagram. \\
|
||||
\bottomrule
|
||||
\end{longtable}
|
||||
|
||||
% =============================================================
|
||||
\section{Configuration}
|
||||
% =============================================================
|
||||
|
||||
All settings via environment variables in \texttt{.env} (gitignored).
|
||||
|
||||
\begin{longtable}{@{}p{4.5cm}p{4.5cm}p{4.5cm}@{}}
|
||||
\toprule
|
||||
\textbf{Variable} & \textbf{Description} & \textbf{Default} \\
|
||||
\midrule
|
||||
\endhead
|
||||
\texttt{POSTGRES\_PASSWORD} & Database password & \textit{(required)} \\
|
||||
\texttt{AZURE\_RESOURCE\_GROUP} & Resource group name & taskmanager-rg \\
|
||||
\texttt{AZURE\_LOCATION} & Azure region & westeurope \\
|
||||
\texttt{AZURE\_VM\_SIZE} & VM size & Standard\_B1s \\
|
||||
\texttt{AZURE\_DNS\_LABEL} & DNS subdomain & taskmanager-gs699he \\
|
||||
\bottomrule
|
||||
\end{longtable}
|
||||
|
||||
\subsection{Security}
|
||||
\begin{itemize}[nosep]
|
||||
\item \texttt{.env} in \texttt{.gitignore} -- secrets never in Git
|
||||
\item Databases on isolated Docker network
|
||||
\item HTTPS enforced by Caddy (HTTP $\rightarrow$ HTTPS redirect)
|
||||
\item Security headers in Nginx
|
||||
\item SSH key authentication (no password login)
|
||||
\end{itemize}
|
||||
|
||||
% =============================================================
|
||||
\section{Deployment Instructions}
|
||||
% =============================================================
|
||||
|
||||
\subsection{Prerequisites}
|
||||
\begin{enumerate}[nosep]
|
||||
\item Azure CLI: \texttt{curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash}
|
||||
\item Azure for Students subscription activated
|
||||
\item \texttt{az login} completed
|
||||
\end{enumerate}
|
||||
|
||||
\subsection{Deploy}
|
||||
\begin{lstlisting}[language=bash]
|
||||
# Clone and configure
|
||||
git clone git@git.kemt.fei.tuke.sk:gs699he/zkt25.git
|
||||
cd zkt25/sk1
|
||||
cp env.example .env
|
||||
nano .env # Set POSTGRES_PASSWORD
|
||||
|
||||
# Deploy to Azure
|
||||
./prepare-app.sh
|
||||
# Creates: Resource Group -> VM -> Docker -> Containers
|
||||
|
||||
# Access:
|
||||
# https://taskmanager-gs699he.westeurope.cloudapp.azure.com
|
||||
\end{lstlisting}
|
||||
|
||||
\subsection{Remove (after exam)}
|
||||
\begin{lstlisting}[language=bash]
|
||||
./remove-app.sh
|
||||
# Deletes entire resource group (VM, IP, disk, NSG)
|
||||
\end{lstlisting}
|
||||
|
||||
% =============================================================
|
||||
\section{Data Persistence and Backups}
|
||||
% =============================================================
|
||||
|
||||
\subsection{Volumes}
|
||||
\begin{itemize}[nosep]
|
||||
\item \texttt{taskapp-pgdata} -- PostgreSQL data
|
||||
\item \texttt{taskapp-redisdata} -- Redis AOF
|
||||
\item \texttt{taskapp-caddy-data} -- TLS certificates
|
||||
\end{itemize}
|
||||
|
||||
\subsection{Backup}
|
||||
\begin{lstlisting}[language=bash]
|
||||
./backup-db.sh
|
||||
# Output: backups/taskmanager_backup_20260519_020000.sql
|
||||
\end{lstlisting}
|
||||
|
||||
\subsection{Auto-Restart}
|
||||
All containers: \texttt{restart: always}. Docker starts on boot via systemd.
|
||||
|
||||
% =============================================================
|
||||
\section{Access Logs}
|
||||
% =============================================================
|
||||
|
||||
\begin{lstlisting}[language=bash]
|
||||
./view-logs.sh # All logs
|
||||
./view-logs.sh --caddy # HTTPS proxy logs
|
||||
./view-logs.sh --api -f # Follow API logs real-time
|
||||
\end{lstlisting}
|
||||
|
||||
Log sources:
|
||||
\begin{itemize}[nosep]
|
||||
\item \textbf{Caddy}: JSON -- client IP, status, TLS version
|
||||
\item \textbf{API}: \texttt{METHOD /path STATUS duration}
|
||||
\item \textbf{Nginx}: Combined log format
|
||||
\end{itemize}
|
||||
|
||||
% =============================================================
|
||||
\section{Cost Analysis}
|
||||
% =============================================================
|
||||
|
||||
\subsection{Azure for Students -- Monthly Costs}
|
||||
|
||||
\begin{longtable}{@{}p{5cm}p{5cm}r@{}}
|
||||
\toprule
|
||||
\textbf{Resource} & \textbf{Configuration} & \textbf{Monthly} \\
|
||||
\midrule
|
||||
\endhead
|
||||
Azure VM & Standard\_B1s (1 vCPU, 1 GB) & \$7.59 \\
|
||||
Public IP & Static IPv4 & \$3.00 \\
|
||||
OS Disk & 30 GB Premium SSD & \$1.20 \\
|
||||
TLS Certificate & Let's Encrypt (free) & \$0.00 \\
|
||||
\midrule
|
||||
\textbf{Total} & & \textbf{\$11.79} \\
|
||||
\textbf{Annual} & & \textbf{\$141.48} \\
|
||||
\bottomrule
|
||||
\end{longtable}
|
||||
|
||||
Azure for Students provides \$100 free credit -- enough for approximately 8 months of operation.
|
||||
|
||||
\subsection{Comparison}
|
||||
\begin{longtable}{@{}p{4cm}p{5cm}r@{}}
|
||||
\toprule
|
||||
\textbf{Provider} & \textbf{Plan} & \textbf{Monthly} \\
|
||||
\midrule
|
||||
\endhead
|
||||
Azure (Students) & B1s + \$100 credit & \$11.79 \\
|
||||
AWS & t3.micro (free 12mo) & \$8.50 \\
|
||||
DigitalOcean & Basic Droplet 2 GB & \$12.00 \\
|
||||
Hostinger & KVM 1 (4 GB) & \$5.99 \\
|
||||
\bottomrule
|
||||
\end{longtable}
|
||||
|
||||
% =============================================================
|
||||
\section{AI Usage Declaration}
|
||||
% =============================================================
|
||||
|
||||
\begin{itemize}[nosep]
|
||||
\item \textbf{Google Antigravity (Gemini)}: Generated scripts, Docker Compose, Nginx/Caddy config, and this documentation.
|
||||
\item \textbf{Architecture diagram}: AI-generated from technical specification.
|
||||
\end{itemize}
|
||||
|
||||
All content reviewed, tested, and adapted. Student understands all components.
|
||||
|
||||
% =============================================================
|
||||
\section{Exam Defense Preparation}
|
||||
% =============================================================
|
||||
|
||||
\textbf{Q: How does HTTPS work?}\\
|
||||
A: Caddy uses the ACME protocol to automatically get a free Let's Encrypt TLS certificate for the Azure DNS hostname. It renews every 90 days.
|
||||
|
||||
\textbf{Q: What if a container crashes?}\\
|
||||
A: \texttt{restart: always} in docker-compose.yaml. Docker auto-restarts failed containers. Docker starts on boot via systemd.
|
||||
|
||||
\textbf{Q: How are secrets managed?}\\
|
||||
A: \texttt{.env} file (gitignored). Docker Compose injects vars into containers. \texttt{env.example} has template without values.
|
||||
|
||||
\textbf{Q: How do you back up the database?}\\
|
||||
A: \texttt{backup-db.sh} runs \texttt{pg\_dump} via SSH inside the PostgreSQL container. SQL dump saved locally.
|
||||
|
||||
\textbf{Q: Can deployment be reproduced?}\\
|
||||
A: Yes. \texttt{prepare-app.sh} creates all Azure resources from scratch via CLI. \texttt{remove-app.sh} deletes everything. No web UI needed.
|
||||
|
||||
\textbf{Q: Why 5 containers?}\\
|
||||
A: Separation of concerns. Caddy handles HTTPS, Nginx serves static files, Node.js handles API logic, PostgreSQL stores data, Redis caches queries. Each can be scaled independently.
|
||||
|
||||
\textbf{Q: How does caching work?}\\
|
||||
A: Redis caches \texttt{GET /api/tasks} for 30 seconds. Write operations invalidate the cache.
|
||||
|
||||
\end{document}
|
||||
23
sk1/env.example
Normal file
23
sk1/env.example
Normal file
@ -0,0 +1,23 @@
|
||||
# =============================================
|
||||
# Environment Variables for Task Manager
|
||||
# Copy this file to .env and fill in your values
|
||||
# DO NOT COMMIT .env TO GIT
|
||||
# =============================================
|
||||
|
||||
# ---- PostgreSQL ----
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=taskmanager
|
||||
POSTGRES_USER=taskuser
|
||||
POSTGRES_PASSWORD=CHANGE_ME_TO_A_STRONG_PASSWORD
|
||||
|
||||
# ---- Redis ----
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# ---- Azure Configuration (used by prepare-app.sh) ----
|
||||
AZURE_RESOURCE_GROUP=taskmanager-rg
|
||||
AZURE_LOCATION=westeurope
|
||||
AZURE_VM_NAME=taskmanager-vm
|
||||
AZURE_VM_SIZE=Standard_B1s
|
||||
AZURE_DNS_LABEL=taskmanager-gs699he
|
||||
19
sk1/frontend/Dockerfile
Normal file
19
sk1/frontend/Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy static frontend files
|
||||
COPY public/ /usr/share/nginx/html/
|
||||
|
||||
# Expose HTTP port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
36
sk1/frontend/nginx.conf
Normal file
36
sk1/frontend/nginx.conf
Normal file
@ -0,0 +1,36 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Access log format with timestamps and client IPs
|
||||
access_log /var/log/nginx/access.log combined;
|
||||
|
||||
# Serve static frontend files
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Reverse proxy API requests to the Node.js backend
|
||||
location /api/ {
|
||||
proxy_pass http://api:3000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml;
|
||||
gzip_min_length 256;
|
||||
}
|
||||
234
sk1/frontend/public/app.js
Normal file
234
sk1/frontend/public/app.js
Normal file
@ -0,0 +1,234 @@
|
||||
// ===== Task Manager Frontend Application =====
|
||||
|
||||
const API_BASE = '/api';
|
||||
let tasks = [];
|
||||
let currentFilter = 'all';
|
||||
|
||||
// ===== DOM Elements =====
|
||||
const taskList = document.getElementById('tasks-container');
|
||||
const addTaskForm = document.getElementById('task-form');
|
||||
const taskTitleInput = document.getElementById('title');
|
||||
const taskDescInput = document.getElementById('description');
|
||||
const filterTabs = document.querySelectorAll('.filter-tab');
|
||||
const toast = document.getElementById('toast');
|
||||
const totalCountEl = document.getElementById('total-tasks-count');
|
||||
|
||||
// ===== API Functions =====
|
||||
async function apiRequest(url, options = {}) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
if (err.name === 'TypeError') {
|
||||
throw new Error('Cannot connect to server. Is the API running?');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTasks() {
|
||||
const data = await apiRequest('/tasks');
|
||||
tasks = data.tasks || [];
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async function createTask(title, description) {
|
||||
const data = await apiRequest('/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, description }),
|
||||
});
|
||||
return data.task;
|
||||
}
|
||||
|
||||
async function updateTask(id, updates) {
|
||||
const data = await apiRequest(`/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
return data.task;
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
await apiRequest(`/tasks/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ===== UI Rendering =====
|
||||
function getFilteredTasks() {
|
||||
switch (currentFilter) {
|
||||
case 'active': return tasks.filter(t => !t.completed);
|
||||
case 'completed': return tasks.filter(t => t.completed);
|
||||
default: return tasks;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function renderTasks() {
|
||||
const filtered = getFilteredTasks();
|
||||
const total = tasks.length;
|
||||
|
||||
if (totalCountEl) totalCountEl.textContent = total;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
const msg = tasks.length > 0
|
||||
? `No ${currentFilter} tasks.`
|
||||
: 'No tasks yet. Add your first task above!';
|
||||
|
||||
taskList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
<p>${msg}</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
taskList.innerHTML = filtered.map(task => `
|
||||
<div class="task-item ${task.completed ? 'completed' : ''}" data-id="${task.id}">
|
||||
<label class="task-checkbox">
|
||||
<input type="checkbox"
|
||||
${task.completed ? 'checked' : ''}
|
||||
onchange="handleToggle(${task.id}, this.checked)"
|
||||
aria-label="Mark task as ${task.completed ? 'incomplete' : 'complete'}">
|
||||
<span class="checkmark">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</span>
|
||||
</label>
|
||||
<div class="task-content">
|
||||
<div class="task-title">${escapeHtml(task.title)}</div>
|
||||
${task.description ? `<div class="task-description">${escapeHtml(task.description)}</div>` : ''}
|
||||
<div class="task-meta">${formatDate(task.created_at)}</div>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<button class="btn-icon" onclick="handleDelete(${task.id})" title="Delete task" aria-label="Delete task">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ===== Toast Notifications =====
|
||||
let toastTimeout;
|
||||
function showToast(message, type = 'info') {
|
||||
clearTimeout(toastTimeout);
|
||||
toast.textContent = message;
|
||||
toast.className = `toast ${type} show`;
|
||||
toastTimeout = setTimeout(() => { toast.classList.remove('show'); }, 3000);
|
||||
}
|
||||
|
||||
// ===== Event Handlers =====
|
||||
async function handleToggle(id, completed) {
|
||||
try {
|
||||
await updateTask(id, { completed });
|
||||
await fetchTasks();
|
||||
renderTasks();
|
||||
showToast(completed ? '✅ Task completed!' : '🔄 Task reopened', 'success');
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
await fetchTasks();
|
||||
renderTasks();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
await deleteTask(id);
|
||||
const el = document.querySelector(`.task-item[data-id="${id}"]`);
|
||||
if (el) {
|
||||
el.style.transform = 'translateX(100px)';
|
||||
el.style.opacity = '0';
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
}
|
||||
await fetchTasks();
|
||||
renderTasks();
|
||||
showToast('🗑️ Task deleted', 'success');
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Form Submit =====
|
||||
addTaskForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const title = taskTitleInput.value.trim();
|
||||
const description = taskDescInput.value.trim();
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
await createTask(title, description);
|
||||
taskTitleInput.value = '';
|
||||
taskDescInput.value = '';
|
||||
taskTitleInput.focus();
|
||||
await fetchTasks();
|
||||
renderTasks();
|
||||
showToast('✨ Task created!', 'success');
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Filter Tabs =====
|
||||
filterTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
filterTabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
currentFilter = tab.dataset.filter;
|
||||
renderTasks();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Initialize =====
|
||||
async function init() {
|
||||
taskList.innerHTML = `
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
`;
|
||||
try {
|
||||
await fetchTasks();
|
||||
renderTasks();
|
||||
} catch (err) {
|
||||
showToast('Failed to load tasks: ' + err.message, 'error');
|
||||
taskList.innerHTML = `
|
||||
<div class="empty-state" style="color: var(--danger)">
|
||||
<p>Unable to connect to the server.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(async () => {
|
||||
try { await fetchTasks(); renderTasks(); } catch (_) {}
|
||||
}, 30000);
|
||||
87
sk1/frontend/public/index.html
Normal file
87
sk1/frontend/public/index.html
Normal file
@ -0,0 +1,87 @@
|
||||
<!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>Tasks</title>
|
||||
<link rel="stylesheet" href="style.css?v=2">
|
||||
<!-- Include Inter font to match SF Pro aesthetic for non-Apple users -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Glassmorphism Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Tasks</h1>
|
||||
</div>
|
||||
<div class="header-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="total-tasks-count">0</span>
|
||||
<span class="stat-label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app-container">
|
||||
|
||||
<h2 class="page-title">What's on your mind?</h2>
|
||||
|
||||
<!-- Add Task Form -->
|
||||
<section class="add-task-section">
|
||||
<form id="task-form" class="add-task-form">
|
||||
<div class="form-row">
|
||||
<div class="input-group input-group-title">
|
||||
<input type="text" id="title" placeholder="Task name..." required autocomplete="off">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input type="text" id="description" placeholder="Description (optional)" autocomplete="off">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-add">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Add Task
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Filter Segmented Control -->
|
||||
<section class="filter-section">
|
||||
<div class="filter-tabs">
|
||||
<button class="filter-tab active" data-filter="all">All</button>
|
||||
<button class="filter-tab" data-filter="active">Active</button>
|
||||
<button class="filter-tab" data-filter="completed">Completed</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Task List Container -->
|
||||
<main id="tasks-container" class="task-list">
|
||||
<!-- Loading state is managed by JS -->
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="app-footer">
|
||||
<p>Task Manager • Designed with precision</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification Container -->
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="app.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
528
sk1/frontend/public/style.css
Normal file
528
sk1/frontend/public/style.css
Normal file
@ -0,0 +1,528 @@
|
||||
/* ===== Apple Premium Design System ===== */
|
||||
:root {
|
||||
/* Colors - Light Mode (Apple Inspired) */
|
||||
--bg-primary: #f5f5f7;
|
||||
--bg-surface: rgba(255, 255, 255, 0.72);
|
||||
--bg-card: #ffffff;
|
||||
--text-primary: #1d1d1f;
|
||||
--text-secondary: #86868b;
|
||||
--text-tertiary: #d2d2d7;
|
||||
--accent: #0071e3;
|
||||
--accent-hover: #0077ed;
|
||||
--accent-glow: rgba(0, 113, 227, 0.3);
|
||||
--success: #34c759;
|
||||
--danger: #ff3b30;
|
||||
--border-color: rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.08);
|
||||
--shadow-float: 0 20px 40px rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* Geometry */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--radius-pill: 9999px;
|
||||
|
||||
/* Animation */
|
||||
--spring: cubic-bezier(0.25, 1, 0.5, 1);
|
||||
--transition: 0.3s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
}
|
||||
|
||||
@supports (font-variation-settings: normal) {
|
||||
:root {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Icons", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.47059;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.022em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ===== App Container ===== */
|
||||
.app-container {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ===== Header (Glassmorphism) ===== */
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 40px;
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ===== Typography Header ===== */
|
||||
.page-title {
|
||||
font-size: 48px;
|
||||
line-height: 1.08349;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.002em;
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* ===== Add Task Form ===== */
|
||||
.add-task-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.add-task-form {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 8px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: box-shadow var(--transition), transform var(--transition);
|
||||
}
|
||||
|
||||
.add-task-form:focus-within {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-group-title {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 16px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-group input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 4px 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-add:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* ===== Filter Tabs (Segmented Control) ===== */
|
||||
.filter-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: inline-flex;
|
||||
background: rgba(118, 118, 128, 0.12);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 6px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* ===== Task List ===== */
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition);
|
||||
animation: slideUp 0.4s var(--spring) both;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
.task-item.completed {
|
||||
opacity: 0.5;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.task-item.completed:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.task-item.completed .task-title {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Checkbox (Apple Style) */
|
||||
.task-checkbox {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-checkbox input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.task-checkbox .checkmark {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
border: 1.5px solid var(--text-tertiary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.task-checkbox input:hover ~ .checkmark {
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-checkbox input:checked ~ .checkmark {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.task-checkbox .checkmark svg {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.task-checkbox input:checked ~ .checkmark svg {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Task Content */
|
||||
.task-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
word-break: break-word;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Task Actions */
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.task-item:hover .task-actions {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0,0,0,0.04);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
color: var(--danger);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* ===== Empty & Loading States ===== */
|
||||
.empty-state, .loading-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--text-secondary);
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.4;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--text-secondary);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 20px;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== Toast Notification ===== */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100px) scale(0.9);
|
||||
padding: 12px 24px;
|
||||
background: rgba(29, 29, 31, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: var(--radius-pill);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow-float);
|
||||
opacity: 0;
|
||||
transition: all 0.4s var(--spring);
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ===== Footer ===== */
|
||||
.app-footer {
|
||||
margin-top: auto;
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-footer p {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 640px) {
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
174
sk1/prepare-app.sh
Executable file
174
sk1/prepare-app.sh
Executable file
@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# prepare-app.sh — Deploy Task Manager to Azure VM
|
||||
#
|
||||
# This script:
|
||||
# 1. Creates an Azure Resource Group
|
||||
# 2. Creates a VM with Docker pre-installed (cloud-init)
|
||||
# 3. Opens ports 80, 443 for HTTPS
|
||||
# 4. Uploads all application files via SCP
|
||||
# 5. Starts all containers with docker compose
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Azure CLI installed and logged in (az login)
|
||||
# - Azure for Students subscription active
|
||||
# - .env file configured (copy from env.example)
|
||||
#
|
||||
# Usage:
|
||||
# ./prepare-app.sh
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# ---- Load configuration -----------------------------------------------
|
||||
if [ ! -f "$SCRIPT_DIR/.env" ]; then
|
||||
echo "ERROR: .env file not found!"
|
||||
echo " cp env.example .env && nano .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$SCRIPT_DIR/.env"
|
||||
|
||||
RG="${AZURE_RESOURCE_GROUP:-taskmanager-rg}"
|
||||
LOC="${AZURE_LOCATION:-westeurope}"
|
||||
VM="${AZURE_VM_NAME:-taskmanager-vm}"
|
||||
SIZE="${AZURE_VM_SIZE:-Standard_B1s}"
|
||||
DNS="${AZURE_DNS_LABEL:-taskmanager-gs699he}"
|
||||
ADMIN="azureuser"
|
||||
|
||||
FQDN="${DNS}.${LOC}.cloudapp.azure.com"
|
||||
SSH_KEY="$HOME/.ssh/sk1_taskmanager"
|
||||
|
||||
echo "============================================="
|
||||
echo " Task Manager — Azure Cloud Deployment"
|
||||
echo "============================================="
|
||||
echo ""
|
||||
echo " Resource Group: $RG"
|
||||
echo " Location: $LOC"
|
||||
echo " VM Name: $VM"
|
||||
echo " VM Size: $SIZE"
|
||||
echo " DNS: $FQDN"
|
||||
echo ""
|
||||
|
||||
# ---- Step 1: Check Azure CLI ------------------------------------------
|
||||
echo "[1/7] Checking Azure CLI..."
|
||||
if ! az account show &>/dev/null; then
|
||||
echo "ERROR: Not logged in. Run: az login"
|
||||
exit 1
|
||||
fi
|
||||
SUB_NAME=$(az account show --query "name" -o tsv)
|
||||
echo " ✓ Subscription: $SUB_NAME"
|
||||
|
||||
# ---- Step 2: Generate SSH key if needed --------------------------------
|
||||
echo "[2/7] Checking SSH key..."
|
||||
if [ ! -f "$SSH_KEY" ]; then
|
||||
ssh-keygen -t ed25519 -f "$SSH_KEY" -N "" -q
|
||||
echo " ✓ Created new SSH key: $SSH_KEY"
|
||||
else
|
||||
echo " ✓ Using existing key: $SSH_KEY"
|
||||
fi
|
||||
|
||||
# ---- Step 3: Create Resource Group -------------------------------------
|
||||
echo "[3/7] Creating Resource Group '$RG'..."
|
||||
az group create --name "$RG" --location "$LOC" -o none
|
||||
echo " ✓ Resource Group ready"
|
||||
|
||||
# ---- Step 4: Create VM ------------------------------------------------
|
||||
echo "[4/7] Creating VM '$VM' (this takes ~2 minutes)..."
|
||||
az vm create \
|
||||
--resource-group "$RG" \
|
||||
--name "$VM" \
|
||||
--image Ubuntu2404 \
|
||||
--size "$SIZE" \
|
||||
--admin-username "$ADMIN" \
|
||||
--ssh-key-values "$SSH_KEY.pub" \
|
||||
--public-ip-address-dns-name "$DNS" \
|
||||
--custom-data "$SCRIPT_DIR/cloud-init.yaml" \
|
||||
--output none
|
||||
|
||||
echo " ✓ VM created: $FQDN"
|
||||
|
||||
# ---- Step 5: Open firewall ports --------------------------------------
|
||||
echo "[5/7] Opening ports 80 and 443..."
|
||||
az vm open-port --resource-group "$RG" --name "$VM" --port 80 --priority 1010 -o none
|
||||
az vm open-port --resource-group "$RG" --name "$VM" --port 443 --priority 1020 -o none
|
||||
echo " ✓ Ports 80, 443 open"
|
||||
|
||||
# ---- Step 6: Wait for Docker and upload files --------------------------
|
||||
echo "[6/7] Waiting for Docker installation (cloud-init ~60s)..."
|
||||
SSH_CMD="ssh -o StrictHostKeyChecking=no -i $SSH_KEY $ADMIN@$FQDN"
|
||||
|
||||
# Wait for cloud-init to finish
|
||||
for i in $(seq 1 30); do
|
||||
if $SSH_CMD "command -v docker" &>/dev/null; then
|
||||
echo " ✓ Docker installed"
|
||||
break
|
||||
fi
|
||||
echo " ... waiting ($i/30)"
|
||||
sleep 10
|
||||
done
|
||||
|
||||
# Verify Docker
|
||||
if ! $SSH_CMD "docker --version" &>/dev/null; then
|
||||
echo "ERROR: Docker not available after 5 minutes."
|
||||
echo "SSH in and check: $SSH_CMD"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upload application files
|
||||
echo " Uploading application files..."
|
||||
cd "$SCRIPT_DIR"
|
||||
tar czf /tmp/taskmanager-app.tar.gz \
|
||||
--exclude='.git' \
|
||||
--exclude='docs' \
|
||||
--exclude='backups' \
|
||||
--exclude='*.pdf' \
|
||||
--exclude='*.tex' \
|
||||
--exclude='*.aux' \
|
||||
--exclude='*.log' \
|
||||
--exclude='*.png' \
|
||||
--exclude='node_modules' \
|
||||
api/ frontend/ db/ caddy/ docker-compose.yaml .env
|
||||
|
||||
scp -o StrictHostKeyChecking=no -i "$SSH_KEY" \
|
||||
/tmp/taskmanager-app.tar.gz "$ADMIN@$FQDN:/tmp/"
|
||||
rm -f /tmp/taskmanager-app.tar.gz
|
||||
|
||||
$SSH_CMD bash -s <<'EXTRACT'
|
||||
sudo mkdir -p /opt/taskmanager
|
||||
sudo tar xzf /tmp/taskmanager-app.tar.gz -C /opt/taskmanager
|
||||
sudo chown -R $USER:$USER /opt/taskmanager
|
||||
rm -f /tmp/taskmanager-app.tar.gz
|
||||
EXTRACT
|
||||
echo " ✓ Files uploaded"
|
||||
|
||||
# ---- Step 7: Start containers -----------------------------------------
|
||||
echo "[7/7] Starting application containers..."
|
||||
$SSH_CMD bash -s "$FQDN" <<'START'
|
||||
cd /opt/taskmanager
|
||||
echo "DOMAIN_NAME=$1" | sudo tee -a .env > /dev/null
|
||||
sudo docker compose down 2>/dev/null || true
|
||||
sudo docker compose up -d --build
|
||||
echo ""
|
||||
echo "Waiting for containers to start (30s)..."
|
||||
sleep 30
|
||||
echo ""
|
||||
sudo docker compose ps
|
||||
START
|
||||
echo " ✓ Application started"
|
||||
|
||||
# ---- Summary ----------------------------------------------------------
|
||||
echo ""
|
||||
echo "============================================="
|
||||
echo " ✓ Deployment Complete!"
|
||||
echo ""
|
||||
echo " 🌐 URL: https://$FQDN"
|
||||
echo " 📍 SSH: ssh -i $SSH_KEY $ADMIN@$FQDN"
|
||||
echo ""
|
||||
echo " HTTPS certificate may take 1-2 min to provision."
|
||||
echo ""
|
||||
echo " Logs: ./view-logs.sh"
|
||||
echo " Backup: ./backup-db.sh"
|
||||
echo " Remove: ./remove-app.sh"
|
||||
echo "============================================="
|
||||
30
sk1/remove-app.sh
Executable file
30
sk1/remove-app.sh
Executable file
@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# remove-app.sh — Delete all Azure resources for Task Manager
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env"
|
||||
|
||||
RG="${AZURE_RESOURCE_GROUP:-taskmanager-rg}"
|
||||
|
||||
echo "============================================="
|
||||
echo " ⚠️ Remove Task Manager from Azure"
|
||||
echo "============================================="
|
||||
echo " Resource Group: $RG"
|
||||
echo " This will delete the VM and ALL resources."
|
||||
echo ""
|
||||
read -p " Are you sure? (y/N): " confirm
|
||||
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
||||
echo " Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Deleting resource group '$RG'..."
|
||||
az group delete --name "$RG" --yes --no-wait
|
||||
echo ""
|
||||
echo " ✓ Deletion started (runs in background, ~2 min)"
|
||||
echo " ✓ All resources (VM, IP, disk, NSG) will be removed"
|
||||
echo "============================================="
|
||||
58
sk1/view-logs.sh
Executable file
58
sk1/view-logs.sh
Executable file
@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# view-logs.sh — View access logs from the Azure VM
|
||||
#
|
||||
# Usage:
|
||||
# ./view-logs.sh # All logs
|
||||
# ./view-logs.sh --caddy # HTTPS proxy logs
|
||||
# ./view-logs.sh --api -f # Follow API logs real-time
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env"
|
||||
|
||||
LOC="${AZURE_LOCATION:-westeurope}"
|
||||
DNS="${AZURE_DNS_LABEL:-taskmanager-gs699he}"
|
||||
FQDN="${DNS}.${LOC}.cloudapp.azure.com"
|
||||
SSH_KEY="$HOME/.ssh/sk1_taskmanager"
|
||||
ADMIN="azureuser"
|
||||
SSH_CMD="ssh -o StrictHostKeyChecking=no -i $SSH_KEY $ADMIN@$FQDN"
|
||||
|
||||
LOG_TYPE="all"
|
||||
FOLLOW=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--caddy) LOG_TYPE="caddy"; shift ;;
|
||||
--api) LOG_TYPE="api"; shift ;;
|
||||
--nginx) LOG_TYPE="nginx"; shift ;;
|
||||
--all) LOG_TYPE="all"; shift ;;
|
||||
-f) FOLLOW="-f"; shift ;;
|
||||
*) echo "Unknown: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "============================================="
|
||||
echo " Access Logs — $FQDN"
|
||||
echo "============================================="
|
||||
|
||||
case "$LOG_TYPE" in
|
||||
caddy)
|
||||
echo "--- Caddy (HTTPS) ---"
|
||||
$SSH_CMD "sudo docker logs taskapp-caddy --tail 50 $FOLLOW" ;;
|
||||
api)
|
||||
echo "--- API (Node.js) ---"
|
||||
$SSH_CMD "sudo docker logs taskapp-api --tail 50 $FOLLOW" ;;
|
||||
nginx)
|
||||
echo "--- Nginx (Frontend) ---"
|
||||
$SSH_CMD "sudo docker logs taskapp-frontend --tail 50 $FOLLOW" ;;
|
||||
all)
|
||||
echo "--- Caddy ---"
|
||||
$SSH_CMD "sudo docker logs taskapp-caddy --tail 20" 2>&1 || true
|
||||
echo ""
|
||||
echo "--- API ---"
|
||||
$SSH_CMD "sudo docker logs taskapp-api --tail 20" 2>&1 || true
|
||||
echo ""
|
||||
echo "--- Nginx ---"
|
||||
$SSH_CMD "sudo docker logs taskapp-frontend --tail 20" 2>&1 || true ;;
|
||||
esac
|
||||
Loading…
Reference in New Issue
Block a user