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