feat(sk1): Initial commit for Azure Cloud Deployment assignment

This commit is contained in:
Gopikanta Shill 2026-05-19 03:52:09 +02:00
parent 64abf7ead5
commit 1bcef88ed2
23 changed files with 2123 additions and 0 deletions

14
sk1/.gitignore vendored Normal file
View 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
View 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
![Architecture](docs/architecture.png)
| 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

BIN
sk1/docs/documentation.pdf Normal file

Binary file not shown.

361
sk1/docs/documentation.tex Normal file
View 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
View 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
View 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
View 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
View 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);

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

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