refactor(structure): move to 7project dir

This commit is contained in:
2025-10-05 01:30:55 +02:00
parent 291305c2e5
commit d58d553945
111 changed files with 6638 additions and 36 deletions

8
7project/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/tofu/controlplane.yaml
/tofu/kubeconfig
/tofu/talosconfig
/tofu/terraform.tfstate
/tofu/terraform.tfstate.backup
/tofu/worker.yaml
/tofu/.terraform.lock.hcl
/tofu/.terraform/

43
7project/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Lab 6: Design Document for Course Project
| Lab 6: | Design Document for Course Project |
| ----------- | ---------------------------------- |
| Subject: | DAT515 Cloud Computing |
| Deadline: | **September 19, 2025 23:59** |
| Grading: | No Grade |
| Submission: | Group |
## Table of Contents
- [Table of Contents](#table-of-contents)
- [1. Design Document (design.md)](#1-design-document-designmd)
The design document is the first deliverable for your project.
We separated this out as a separate deliverable, with its own deadline, to ensure that you have a clear plan before you start coding.
This part only needs a cursory review by the teaching staff to ensure it is sufficiently comprehensive, while still realistic.
The teaching staff will assign you to a project mentor who will provide guidance and support throughout the development process.
## 1. Design Document (design.md)
You are required to prepare a design document for your application.
The design doc should be brief, well-organized and easy to understand.
The design doc should be prepared in markdown format and named `design.md` and submitted in the project group's repository.
Remember that you can use [mermaid diagrams](https://github.com/mermaid-js/mermaid#readme) in markdown files.
The design doc **should include** the following sections:
- **Overview**: A brief description of the application and its purpose.
- **Architecture**: The high-level architecture of the application, including components, interactions, and data flow.
- **Technologies**: The cloud computing technologies or services used in the application.
- **Deployment**: The deployment strategy for the application, including any infrastructure requirements.
The design document should be updated throughout the development process and reflect the final implementation of your project.
Optional sections may include:
- Security: The security measures implemented in the application to protect data and resources.
- Scalability: The scalability considerations for the application, including load balancing and auto-scaling.
- Monitoring: The monitoring and logging strategy for the application to track performance and detect issues.
- Disaster Recovery: The disaster recovery plan for the application to ensure business continuity in case of failures.
- Cost Analysis: The cost analysis of running the application on the cloud, including pricing models and cost-saving strategies.
- References: Any external sources or references used in the design document.

View File

@@ -0,0 +1,7 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD alembic upgrade head && uvicorn app.app:app --host 0.0.0.0 --port 8000

View File

@@ -0,0 +1,148 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
# sqlalchemy.url = driver://user:pass@localhost/dbname
# Pro async MariaDB bude url brána z proměnné prostředí DATABASE_URL
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1,56 @@
import os
import sys
from logging.config import fileConfig
from sqlalchemy import pool, create_engine
from alembic import context
# Add path for correct loading of modules
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from app.core.db import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
mariadb_host = os.getenv("MARIADB_HOST", "localhost")
mariadb_port = os.getenv("MARIADB_PORT", "3306")
mariadb_db = os.getenv("MARIADB_DB", "group_project")
mariadb_user = os.getenv("MARIADB_USER", "root")
mariadb_password = os.getenv("MARIADB_PASSWORD", "strongpassword")
DATABASE_URL = f"mysql+pymysql://{mariadb_user}:{mariadb_password}@{mariadb_host}:{mariadb_port}/{mariadb_db}"
SYNC_DATABASE_URL = DATABASE_URL.replace("+asyncmy", "+pymysql")
ssl_enabled = os.getenv("MARIADB_HOST", "localhost") != "localhost"
connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {}
def run_migrations_offline() -> None:
context.configure(
url=SYNC_DATABASE_URL,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = create_engine(SYNC_DATABASE_URL, poolclass=pool.NullPool, connect_args=connect_args)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,52 @@
"""Init migration
Revision ID: 81f275275556
Revises:
Create Date: 2025-09-24 17:39:25.346690
"""
from typing import Sequence, Union
import fastapi_users_db_sqlalchemy
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '81f275275556'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user',
sa.Column('first_name', sa.String(length=100), nullable=True),
sa.Column('last_name', sa.String(length=100), nullable=True),
sa.Column('id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.Column('email', sa.String(length=320), nullable=False),
sa.Column('hashed_password', sa.String(length=1024), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_superuser', sa.Boolean(), nullable=False),
sa.Column('is_verified', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user')
op.drop_table('transaction')
# ### end Alembic commands ###

View File

View File

View File

View File

@@ -0,0 +1,56 @@
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.models.user import User
from app.schemas.user import UserCreate, UserRead, UserUpdate
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users
app = FastAPI()
# CORS for frontend dev server
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://127.0.0.1:5173",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
)
app.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)
# Liveness/root endpoint
@app.get("/", include_in_schema=False)
async def root():
return {"status": "ok"}
@app.get("/authenticated-route")
async def authenticated_route(user: User = Depends(current_active_verified_user)):
return {"message": f"Hello {user.email}!"}

View File

@@ -0,0 +1,50 @@
import os
from celery import Celery
if os.getenv("RABBITMQ_URL"):
RABBITMQ_URL = os.getenv("RABBITMQ_URL") # type: ignore
else:
from urllib.parse import quote
username = os.getenv("RABBITMQ_USERNAME", "user")
password = os.getenv("RABBITMQ_PASSWORD", "bitnami123")
host = os.getenv("RABBITMQ_HOST", "localhost")
port = os.getenv("RABBITMQ_PORT", "5672")
vhost = os.getenv("RABBITMQ_VHOST", "/")
use_ssl = os.getenv("RABBITMQ_USE_SSL", "0").lower() in {"1", "true", "yes"}
scheme = "amqps" if use_ssl else "amqp"
# Kombu uses '//' to denote the default '/' vhost. For custom vhosts, URL-encode them.
if vhost in ("/", ""):
vhost_path = "/" # will become '//' after concatenation below
else:
vhost_path = f"/{quote(vhost, safe='')}"
# Ensure we end up with e.g. amqp://user:pass@host:5672// (for '/')
RABBITMQ_URL = f"{scheme}://{username}:{password}@{host}:{port}{vhost_path}"
if vhost in ("/", "") and not RABBITMQ_URL.endswith("//"):
RABBITMQ_URL += "/"
DEFAULT_QUEUE = os.getenv("MAIL_QUEUE", "mail_queue")
CELERY_BACKEND = os.getenv("CELERY_BACKEND", "rpc://")
celery_app = Celery(
"app",
broker=RABBITMQ_URL,
# backend=CELERY_BACKEND,
)
celery_app.autodiscover_tasks(["app.workers"], related_name="celery_tasks") # discover app.workers.celery_tasks
celery_app.set_default()
celery_app.conf.update(
task_default_queue=DEFAULT_QUEUE,
task_acks_late=True,
worker_prefetch_multiplier=int(os.getenv("CELERY_PREFETCH", "1")),
task_serializer="json",
result_serializer="json",
accept_content=["json"],
)
__all__ = ["celery_app"]

View File

View File

@@ -0,0 +1,4 @@
from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base
Base: DeclarativeMeta = declarative_base()

View File

@@ -0,0 +1,30 @@
import os
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.core.base import Base
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
mariadb_host = os.getenv("MARIADB_HOST", "localhost")
mariadb_port = os.getenv("MARIADB_PORT", "3306")
mariadb_db = os.getenv("MARIADB_DB", "group_project")
mariadb_user = os.getenv("MARIADB_USER", "root")
mariadb_password = os.getenv("MARIADB_PASSWORD", "strongpassword")
if mariadb_host and mariadb_db and mariadb_user and mariadb_password:
DATABASE_URL = f"mysql+asyncmy://{mariadb_user}:{mariadb_password}@{mariadb_host}:{mariadb_port}/{mariadb_db}"
else:
raise Exception("Only MariaDB is supported. Please set the DATABASE_URL environment variable.")
# Load all models to register them
from app.models.user import User
from app.models.transaction import Transaction
ssl_enabled = os.getenv("MARIADB_HOST", "localhost") != "localhost"
connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {}
engine = create_async_engine(
DATABASE_URL,
pool_pre_ping=True,
echo=os.getenv("SQL_ECHO", "0") == "1",
connect_args=connect_args,
)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)

View File

@@ -0,0 +1,6 @@
import app.celery_app # noqa: F401
from app.workers.celery_tasks import send_email
def enqueue_email(to: str, subject: str, body: str) -> None:
send_email.delay(to, subject, body)

View File

View File

@@ -0,0 +1,9 @@
from sqlalchemy import Column, Integer, String, Float
from app.core.base import Base
class Transaction(Base):
__tablename__ = "transaction"
id = Column(Integer, primary_key=True, autoincrement=True)
amount = Column(Float, nullable=False)
description = Column(String(length=255), nullable=True)

View File

@@ -0,0 +1,7 @@
from sqlalchemy import Column, String
from fastapi_users.db import SQLAlchemyBaseUserTableUUID
from app.core.base import Base
class User(SQLAlchemyBaseUserTableUUID, Base):
first_name = Column(String(length=100), nullable=True)
last_name = Column(String(length=100), nullable=True)

View File

View File

@@ -0,0 +1,16 @@
import uuid
from typing import Optional
from fastapi_users import schemas
class UserRead(schemas.BaseUser[uuid.UUID]):
first_name: Optional[str] = None
surname: Optional[str] = None
class UserCreate(schemas.BaseUserCreate):
first_name: Optional[str] = None
surname: Optional[str] = None
class UserUpdate(schemas.BaseUserUpdate):
first_name: Optional[str] = None
surname: Optional[str] = None

View File

@@ -0,0 +1,14 @@
from typing import AsyncGenerator
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi_users.db import SQLAlchemyUserDatabase
from ..core.db import async_session_maker
from ..models.user import User
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session
async def get_user_db(session: AsyncSession = Depends(get_async_session)):
yield SQLAlchemyUserDatabase(session, User)

View File

@@ -0,0 +1,73 @@
import os
import uuid
from typing import Optional
from fastapi import Depends, Request
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
from fastapi_users.authentication import (
AuthenticationBackend,
BearerTransport,
)
from fastapi_users.authentication.strategy.jwt import JWTStrategy
from fastapi_users.db import SQLAlchemyUserDatabase
from app.models.user import User
from app.services.db import get_user_db
from app.core.queue import enqueue_email
SECRET = os.getenv("SECRET", "CHANGE_ME_SECRET")
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = SECRET
verification_token_secret = SECRET
async def on_after_register(self, user: User, request: Optional[Request] = None):
await self.request_verify(user, request)
async def on_after_forgot_password(
self, user: User, token: str, request: Optional[Request] = None
):
print(f"User {user.id} has forgot their password. Reset token: {token}")
async def on_after_request_verify(
self, user: User, token: str, request: Optional[Request] = None
):
verify_frontend_link = f"{FRONTEND_URL}/verify?token={token}"
verify_backend_link = f"{BACKEND_URL}/auth/verify?token={token}"
subject = "Ověření účtu"
body = (
"Ahoj,\n\n"
"děkujeme za registraci. Prosíme, ověř svůj účet kliknutím na tento odkaz:\n"
f"{verify_frontend_link}\n\n"
"Pokud by odkaz nefungoval, můžeš použít i přímý odkaz na backend:\n"
f"{verify_backend_link}\n\n"
"Pokud jsi registraci neprováděl(a), tento email ignoruj.\n"
)
try:
enqueue_email(to=user.email, subject=subject, body=body)
except Exception as e:
print("[Email Fallback] To:", user.email)
print("[Email Fallback] Subject:", subject)
print("[Email Fallback] Body:\n", body)
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
yield UserManager(user_db)
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=SECRET, lifetime_seconds=3600)
auth_backend = AuthenticationBackend(
name="jwt",
transport=bearer_transport,
get_strategy=get_jwt_strategy,
)
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
current_active_user = fastapi_users.current_user(active=True)
current_active_verified_user = fastapi_users.current_user(active=True, verified=True)

View File

View File

@@ -0,0 +1,19 @@
import logging
from celery import shared_task
logger = logging.getLogger("celery_tasks")
if not logger.handlers:
_h = logging.StreamHandler()
logger.addHandler(_h)
logger.setLevel(logging.INFO)
@shared_task(name="workers.send_email")
def send_email(to: str, subject: str, body: str) -> None:
if not (to and subject and body):
logger.error("Email task missing fields. to=%r subject=%r body_len=%r", to, subject, len(body) if body else 0)
return
# Placeholder for real email sending logic
logger.info("[Celery] Email sent | to=%s | subject=%s | body_len=%d", to, subject, len(body))

4
7project/backend/main.py Normal file
View File

@@ -0,0 +1,4 @@
import uvicorn
if __name__ == "__main__":
uvicorn.run("app.app:app", host="0.0.0.0", log_level="info")

View File

@@ -0,0 +1,63 @@
aio-pika==9.5.6
aiormq==6.8.1
aiosqlite==0.21.0
alembic==1.16.5
amqp==5.3.1
annotated-types==0.7.0
anyio==4.11.0
argon2-cffi==23.1.0
argon2-cffi-bindings==25.1.0
asyncmy==0.2.9
bcrypt==4.3.0
billiard==4.2.2
celery==5.5.3
cffi==2.0.0
click==8.1.8
click-didyoumean==0.3.1
click-plugins==1.1.1.2
click-repl==0.3.0
cryptography==46.0.1
dnspython==2.7.0
email_validator==2.2.0
exceptiongroup==1.3.0
fastapi==0.117.1
fastapi-users==14.0.1
fastapi-users-db-sqlalchemy==7.0.0
greenlet==3.2.4
h11==0.16.0
httptools==0.6.4
idna==3.10
kombu==5.5.4
makefun==1.16.0
Mako==1.3.10
MarkupSafe==3.0.2
multidict==6.6.4
packaging==25.0
pamqp==3.3.0
prompt_toolkit==3.0.52
propcache==0.3.2
pwdlib==0.2.1
pycparser==2.23
pydantic==2.11.9
pydantic_core==2.33.2
PyJWT==2.10.1
PyMySQL==1.1.2
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
python-multipart==0.0.20
PyYAML==6.0.2
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.43
starlette==0.48.0
tomli==2.2.1
typing-inspection==0.4.1
typing_extensions==4.15.0
tzdata==2025.2
uvicorn==0.37.0
uvloop==0.21.0
vine==5.1.0
watchfiles==1.1.0
wcwidth==0.2.14
websockets==15.0.1
yarl==1.20.1

34
7project/compose.yml Normal file
View File

@@ -0,0 +1,34 @@
services:
database:
image: mariadb:11.8.2
ports:
- "3306:3306"
volumes:
- mariadb_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: strongpassword
MYSQL_DATABASE: group_project
redis:
image: quay.io/opstree/redis:v8.2.1
ports:
- "6379:6379"
volumes:
- redis_data:/data
rabbitmq:
image: bitnami/rabbitmq:3.13.3-debian-12-r0
network_mode: host
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_USERNAME: user
RABBITMQ_PASSWORD: bitnami123
RABBITMQ_MANAGEMENT_SSL_FAIL_IF_NO_PEER_CERT: no
RABBITMQ_MANAGEMENT_SSL_VERIFY: verify_none
volumes:
- rabbitmq_data:/bitnami
volumes:
mariadb_data:
redis_data:
rabbitmq_data:

View File

@@ -0,0 +1,11 @@
#!/bin/bash
if [ -z "$1" ]; then
echo "Usage: $0 <migration_message>"
exit 1
fi
cd backend || { echo "Directory 'backend' does not exist"; exit 1; }
alembic revision --autogenerate -m "$1"
git add alembic/versions/*
cd - || exit

View File

@@ -0,0 +1,20 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: Grant
metadata:
name: grant
spec:
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
privileges:
- "ALL PRIVILEGES"
database: "app-demo-database"
table: "*"
username: "app-demo-user"
grantOption: true
host: "%"
# Delete the resource in the database whenever the CR gets deleted.
# Alternatively, you can specify Skip in order to omit deletion.
cleanupPolicy: Skip
requeueInterval: 10h
retryInterval: 30s

View File

@@ -0,0 +1,7 @@
apiVersion: v1
kind: Secret
metadata:
name: app-demo-database-secret
type: kubernetes.io/basic-auth
stringData:
password: "strongpassword"

View File

@@ -0,0 +1,20 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: User
metadata:
name: app-demo-user
spec:
# If you want the user to be created with a different name than the resource name
# name: user-custom
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
passwordSecretKeyRef:
name: app-demo-database-secret
key: password
maxUserConnections: 20
host: "%"
# Delete the resource in the database whenever the CR gets deleted.
# Alternatively, you can specify Skip in order to omit deletion.
cleanupPolicy: Skip
requeueInterval: 10h
retryInterval: 30s

View File

@@ -0,0 +1,15 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: Database
metadata:
name: app-demo-database
spec:
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
characterSet: utf8
collate: utf8_general_ci
# Delete the resource in the database whenever the CR gets deleted.
# Alternatively, you can specify Skip in order to omit deletion.
cleanupPolicy: Skip
requeueInterval: 10h
retryInterval: 30s

View File

@@ -0,0 +1,48 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-demo
spec:
replicas: 3
revisionHistoryLimit: 3
selector:
matchLabels:
app: app-demo
template:
metadata:
labels:
app: app-demo
spec:
containers:
- image: lukastrkan/cc-app-demo@sha256:75634b4d97282b6b8424fe17767c81adf44af5f7359c1d25883073b5629b3e05
name: app-demo
ports:
- containerPort: 8000
env:
- name: MARIADB_HOST
value: mariadb-repl.mariadb-operator.svc.cluster.local
- name: MARIADB_PORT
value: '3306'
- name: MARIADB_DB
value: app-demo-database
- name: MARIADB_USER
value: app-demo-user
- name: MARIADB_PASSWORD
valueFrom:
secretKeyRef:
name: app-demo-database-secret
key: password
livenessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3

View File

@@ -0,0 +1,10 @@
apiVersion: v1
kind: Service
metadata:
name: app-demo
spec:
ports:
- port: 80
targetPort: 8000
selector:
app: app-demo

View File

@@ -0,0 +1,36 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-demo-worker
spec:
replicas: 3
revisionHistoryLimit: 3
selector:
matchLabels:
app: app-demo-worker
template:
metadata:
labels:
app: app-demo-worker
spec:
containers:
- image: lukastrkan/cc-app-demo@sha256:75634b4d97282b6b8424fe17767c81adf44af5f7359c1d25883073b5629b3e05
name: app-demo-worker
command:
- celery
- -A
- app.celery_app
- worker
- -Q
- $(MAIL_QUEUE)
- --loglevel
- INFO
env:
- name: RABBITMQ_USERNAME
value: demo-app
- name: RABBITMQ_PASSWORD
value: StrongPassword123!
- name: RABBITMQ_HOST
value: rabbitmq.rabbitmq.svc.cluster.local
- name: RABBITMQ_PORT
value: '5672'

View File

@@ -0,0 +1,14 @@
apiVersion: networking.cfargotunnel.com/v1alpha1
kind: TunnelBinding
metadata:
name: guestbook-tunnel-binding
namespace: group-project
subjects:
- name: app-server
spec:
target: http://app-demo.group-project.svc.cluster.local
fqdn: demo.ltrk.cz
noTlsVerify: true
tunnelRef:
kind: ClusterTunnel
name: cluster-tunnel

3
7project/frontend/.env Normal file
View File

@@ -0,0 +1,3 @@
# API Configuration
VITE_API_BASE_URL=http://localhost:8000
VITE_ENVIRONMENT=development

View File

@@ -0,0 +1 @@
finance-tracker-bolt

View File

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

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Personal Finance Tracker React App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4923
7project/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@supabase/supabase-js": "^2.57.4",
"axios": "^1.12.2",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.63.0",
"react-router-dom": "^7.9.1",
"recharts": "^3.2.1",
"yup": "^1.7.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,110 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { Layout } from './components/Layout';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Login } from './components/Login';
import { Register } from './components/Register';
import { Dashboard } from './components/Dashboard';
import { Transactions } from './components/Transactions';
const AppRoutes: React.FC = () => {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<Routes>
{!user ? (
<>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</>
) : (
<>
<Route path="/" element={
<ProtectedRoute>
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
} />
<Route path="/transactions" element={
<ProtectedRoute>
<Layout>
<Transactions />
</Layout>
</ProtectedRoute>
} />
<Route path="/analytics" element={
<ProtectedRoute>
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute>
<Layout>
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900">Profile</h1>
<p className="mt-4 text-gray-600">Profile management coming soon...</p>
</div>
</Layout>
</ProtectedRoute>
} />
<Route path="/settings" element={
<ProtectedRoute>
<Layout>
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<div className="mt-4 bg-white rounded-lg shadow-sm p-6 border border-gray-200">
<h2 className="text-lg font-semibold mb-4">API Configuration</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
API Base URL
</label>
<p className="mt-1 text-sm text-gray-600">
{import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Environment
</label>
<p className="mt-1 text-sm text-gray-600">
{import.meta.env.VITE_ENVIRONMENT || 'development'}
</p>
</div>
</div>
</div>
</div>
</Layout>
</ProtectedRoute>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</>
)}
</Routes>
);
};
function App() {
return (
<Router>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</Router>
);
}
export default App;

View File

@@ -0,0 +1,194 @@
import React from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
import { TrendingUp, TrendingDown, DollarSign, CreditCard } from 'lucide-react';
import { useTransactions } from '../hooks/useTransactions';
const COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#F97316'];
export const Dashboard: React.FC = () => {
const { summary, transactions, loading } = useTransactions();
if (loading) {
return (
<div className="p-6">
<div className="animate-pulse space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-24 bg-gray-200 rounded-lg"></div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="h-80 bg-gray-200 rounded-lg"></div>
<div className="h-80 bg-gray-200 rounded-lg"></div>
</div>
</div>
</div>
);
}
const recentTransactions = transactions.slice(0, 5);
const monthlyData = transactions.reduce((acc, transaction) => {
const month = new Date(transaction.date).toLocaleDateString('en-US', { month: 'short' });
const existing = acc.find(item => item.month === month);
if (existing) {
if (transaction.type === 'income') {
existing.income += transaction.amount;
} else {
existing.expenses += transaction.amount;
}
} else {
acc.push({
month,
income: transaction.type === 'income' ? transaction.amount : 0,
expenses: transaction.type === 'expense' ? transaction.amount : 0,
});
}
return acc;
}, [] as Array<{ month: string; income: number; expenses: number; }>);
return (
<div className="p-6 space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Total Balance</p>
<p className={`text-2xl font-bold ${summary.balance >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${summary.balance.toLocaleString('en-US', { minimumFractionDigits: 2 })}
</p>
</div>
<div className={`p-3 rounded-full ${summary.balance >= 0 ? 'bg-green-100' : 'bg-red-100'}`}>
<DollarSign className={`h-6 w-6 ${summary.balance >= 0 ? 'text-green-600' : 'text-red-600'}`} />
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Total Income</p>
<p className="text-2xl font-bold text-green-600">
${summary.totalIncome.toLocaleString('en-US', { minimumFractionDigits: 2 })}
</p>
</div>
<div className="p-3 rounded-full bg-green-100">
<TrendingUp className="h-6 w-6 text-green-600" />
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">Total Expenses</p>
<p className="text-2xl font-bold text-red-600">
${summary.totalExpenses.toLocaleString('en-US', { minimumFractionDigits: 2 })}
</p>
</div>
<div className="p-3 rounded-full bg-red-100">
<TrendingDown className="h-6 w-6 text-red-600" />
</div>
</div>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Category Breakdown */}
<div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Spending by Category</h3>
{summary.categoryBreakdown.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={summary.categoryBreakdown}
cx="50%"
cy="50%"
labelLine={false}
label={({ category, percentage }) => `${category} (${percentage.toFixed(1)}%)`}
outerRadius={80}
fill="#8884d8"
dataKey="amount"
>
{summary.categoryBreakdown.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, 'Amount']} />
</PieChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-64 text-gray-500">
No transactions yet
</div>
)}
</div>
{/* Monthly Trends */}
<div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Monthly Trends</h3>
{monthlyData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={monthlyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, '']} />
<Legend />
<Bar dataKey="income" fill="#10B981" name="Income" />
<Bar dataKey="expenses" fill="#EF4444" name="Expenses" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-64 text-gray-500">
No data available
</div>
)}
</div>
</div>
{/* Recent Transactions */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Recent Transactions</h3>
</div>
<div className="divide-y divide-gray-200">
{recentTransactions.length > 0 ? (
recentTransactions.map((transaction) => (
<div key={transaction.id} className="p-6 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div className="flex items-center space-x-3">
<div className={`p-2 rounded-full ${
transaction.type === 'income' ? 'bg-green-100' :
transaction.type === 'expense' ? 'bg-red-100' : 'bg-blue-100'
}`}>
<CreditCard className={`h-4 w-4 ${
transaction.type === 'income' ? 'text-green-600' :
transaction.type === 'expense' ? 'text-red-600' : 'text-blue-600'
}`} />
</div>
<div>
<p className="text-sm font-medium text-gray-900">{transaction.description}</p>
<p className="text-sm text-gray-500">
{transaction.category?.name} {new Date(transaction.date).toLocaleDateString()}
</p>
</div>
</div>
<span className={`text-sm font-medium ${
transaction.type === 'income' ? 'text-green-600' : 'text-red-600'
}`}>
{transaction.type === 'income' ? '+' : '-'}${transaction.amount.toFixed(2)}
</span>
</div>
))
) : (
<div className="p-6 text-center text-gray-500">
No transactions yet. Add your first transaction to get started.
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,158 @@
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
Home,
CreditCard,
PieChart,
User,
Settings,
LogOut,
Menu,
X,
DollarSign
} from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
interface LayoutProps {
children: React.ReactNode;
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { user, logout } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const navigation = [
{ name: 'Dashboard', href: '/', icon: Home },
{ name: 'Transactions', href: '/transactions', icon: CreditCard },
{ name: 'Analytics', href: '/analytics', icon: PieChart },
{ name: 'Profile', href: '/profile', icon: User },
{ name: 'Settings', href: '/settings', icon: Settings },
];
const handleLogout = async () => {
await logout();
navigate('/login');
};
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile sidebar */}
<div className={`lg:hidden ${sidebarOpen ? 'block' : 'hidden'}`}>
<div className="fixed inset-0 z-50 flex">
<div className="relative flex w-full max-w-xs flex-1 flex-col bg-white shadow-xl">
<div className="absolute top-0 right-0 -mr-12 pt-2">
<button
type="button"
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
onClick={() => setSidebarOpen(false)}
>
<X className="h-6 w-6 text-white" />
</button>
</div>
<div className="flex flex-1 flex-col overflow-y-auto pb-4 pt-5">
<div className="flex items-center flex-shrink-0 px-4">
<DollarSign className="h-8 w-8 text-blue-600" />
<span className="ml-2 text-xl font-bold text-gray-900">FinanceTracker</span>
</div>
<nav className="mt-8 flex-1 space-y-1 px-2">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`group flex items-center px-2 py-2 text-base font-medium rounded-md transition-colors ${
isActive
? 'bg-blue-100 text-blue-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
onClick={() => setSidebarOpen(false)}
>
<item.icon className={`mr-4 h-6 w-6 ${isActive ? 'text-blue-500' : 'text-gray-400'}`} />
{item.name}
</Link>
);
})}
</nav>
</div>
</div>
<div className="w-14 flex-shrink-0"></div>
</div>
</div>
{/* Desktop sidebar */}
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
<div className="flex flex-1 flex-col min-h-0 bg-white shadow-lg">
<div className="flex items-center h-16 flex-shrink-0 px-4 bg-white border-b border-gray-200">
<DollarSign className="h-8 w-8 text-blue-600" />
<span className="ml-2 text-xl font-bold text-gray-900">FinanceTracker</span>
</div>
<div className="flex-1 flex flex-col overflow-y-auto">
<nav className="flex-1 px-2 py-4 space-y-1">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md transition-colors ${
isActive
? 'bg-blue-100 text-blue-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-blue-500' : 'text-gray-400'}`} />
{item.name}
</Link>
);
})}
</nav>
</div>
</div>
</div>
{/* Main content */}
<div className="lg:pl-64 flex flex-col flex-1">
{/* Header */}
<div className="sticky top-0 z-40 flex h-16 flex-shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
<button
type="button"
className="p-2.5 text-gray-700 lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<Menu className="h-6 w-6" />
</button>
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
<div className="flex flex-1 items-center">
<h1 className="text-lg font-semibold text-gray-900">
{navigation.find(item => item.href === location.pathname)?.name || 'Finance Tracker'}
</h1>
</div>
<div className="flex items-center gap-x-4 lg:gap-x-6">
<div className="flex items-center gap-x-2">
<div className="text-sm text-gray-500">
Welcome, {user?.first_name || user?.email?.split('@')[0]}
</div>
<button
onClick={handleLogout}
className="flex items-center gap-x-2 rounded-md bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 transition-colors"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
</div>
</div>
</div>
{/* Page content */}
<main className="flex-1">
{children}
</main>
</div>
</div>
);
};

View File

@@ -0,0 +1,126 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { DollarSign, Eye, EyeOff } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { LoginCredentials } from '../types';
const schema = yup.object({
email: yup.string().email('Invalid email').required('Email is required'),
password: yup.string().required('Password is required'),
});
export const Login: React.FC = () => {
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginCredentials>({
resolver: yupResolver(schema),
});
const onSubmit = async (data: LoginCredentials) => {
try {
setError('');
await login(data);
navigate('/');
} catch (err) {
setError('Invalid email or password');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="flex justify-center">
<DollarSign className="h-12 w-12 text-blue-600" />
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
to="/register"
className="font-medium text-blue-600 hover:text-blue-500 transition-colors"
>
create a new account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
<div className="rounded-md shadow-sm space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<input
{...register('email')}
type="email"
autoComplete="email"
className="mt-1 appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Enter your email"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative">
<input
{...register('password')}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
className="appearance-none rounded-md relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Enter your password"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
</div>
<div>
<button
type="submit"
disabled={isSubmitting}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};

View File

@@ -0,0 +1,160 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { DollarSign, Eye, EyeOff } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { RegisterData } from '../types';
const schema = yup.object({
email: yup.string().email('Invalid email').required('Email is required'),
password: yup.string().min(6, 'Password must be at least 6 characters').required('Password is required'),
first_name: yup.string(),
last_name: yup.string(),
});
export const Register: React.FC = () => {
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const { register: registerUser } = useAuth();
const navigate = useNavigate();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterData>({
resolver: yupResolver(schema),
});
const onSubmit = async (data: RegisterData) => {
try {
setError('');
await registerUser(data);
navigate('/');
} catch (err) {
setError('Registration failed. Please try again.');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="flex justify-center">
<DollarSign className="h-12 w-12 text-blue-600" />
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
to="/login"
className="font-medium text-blue-600 hover:text-blue-500 transition-colors"
>
sign in to your existing account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit(onSubmit)}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
First Name
</label>
<input
{...register('first_name')}
type="text"
className="mt-1 appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="First name"
/>
{errors.first_name && (
<p className="mt-1 text-sm text-red-600">{errors.first_name.message}</p>
)}
</div>
<div>
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700">
Last Name
</label>
<input
{...register('last_name')}
type="text"
className="mt-1 appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Last name"
/>
{errors.last_name && (
<p className="mt-1 text-sm text-red-600">{errors.last_name.message}</p>
)}
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<input
{...register('email')}
type="email"
autoComplete="email"
className="mt-1 appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Enter your email"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative">
<input
{...register('password')}
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
className="appearance-none rounded-md relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Enter your password"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400" />
) : (
<Eye className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
</div>
<div>
<button
type="submit"
disabled={isSubmitting}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? 'Creating account...' : 'Create account'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,178 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { Transaction, Category } from '../types';
import { X } from 'lucide-react';
const schema = yup.object({
amount: yup.number().required('Amount is required').min(0.01, 'Amount must be greater than 0'),
description: yup.string().required('Description is required'),
category_id: yup.string().required('Category is required'),
date: yup.string().required('Date is required'),
type: yup.string().oneOf(['income', 'expense', 'transfer']).required('Type is required'),
});
type FormData = yup.InferType<typeof schema>;
interface TransactionFormProps {
transaction?: Transaction;
categories: Category[];
onSubmit: (data: FormData) => Promise<void>;
onClose: () => void;
}
export const TransactionForm: React.FC<TransactionFormProps> = ({
transaction,
categories,
onSubmit,
onClose,
}) => {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: yupResolver(schema),
defaultValues: {
amount: transaction?.amount || 0,
description: transaction?.description || '',
category_id: transaction?.category_id || '',
date: transaction?.date || new Date().toISOString().split('T')[0],
type: transaction?.type || 'expense',
},
});
const selectedType = watch('type');
const filteredCategories = categories.filter(cat => cat.type === selectedType);
const handleFormSubmit = async (data: FormData) => {
try {
await onSubmit(data);
onClose();
} catch (error) {
console.error('Form submission error:', error);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-lg font-semibold text-gray-900">
{transaction ? 'Edit Transaction' : 'Add Transaction'}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 transition-colors"
>
<X className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Type
</label>
<select
{...register('type')}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="expense">Expense</option>
<option value="income">Income</option>
<option value="transfer">Transfer</option>
</select>
{errors.type && (
<p className="mt-1 text-sm text-red-600">{errors.type.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Amount
</label>
<input
type="number"
step="0.01"
min="0"
{...register('amount')}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="0.00"
/>
{errors.amount && (
<p className="mt-1 text-sm text-red-600">{errors.amount.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<input
type="text"
{...register('description')}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="Enter description"
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">{errors.description.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
{...register('category_id')}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="">Select a category</option>
{filteredCategories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
{errors.category_id && (
<p className="mt-1 text-sm text-red-600">{errors.category_id.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Date
</label>
<input
type="date"
{...register('date')}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
{errors.date && (
<p className="mt-1 text-sm text-red-600">{errors.date.message}</p>
)}
</div>
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? 'Saving...' : transaction ? 'Update' : 'Create'}
</button>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md font-medium hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,207 @@
import React, { useState } from 'react';
import { Plus, Edit, Trash2, Search, Filter } from 'lucide-react';
import { useTransactions } from '../hooks/useTransactions';
import { TransactionForm } from './TransactionForm';
import { Transaction } from '../types';
export const Transactions: React.FC = () => {
const { transactions, categories, createTransaction, updateTransaction, deleteTransaction, loading } = useTransactions();
const [showForm, setShowForm] = useState(false);
const [editingTransaction, setEditingTransaction] = useState<Transaction | undefined>();
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState<'all' | 'income' | 'expense' | 'transfer'>('all');
const filteredTransactions = transactions.filter(transaction => {
const matchesSearch = transaction.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
transaction.category?.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = filterType === 'all' || transaction.type === filterType;
return matchesSearch && matchesType;
});
const handleSubmit = async (data: any) => {
if (editingTransaction) {
await updateTransaction(editingTransaction.id, data);
} else {
await createTransaction(data);
}
setShowForm(false);
setEditingTransaction(undefined);
};
const handleEdit = (transaction: Transaction) => {
setEditingTransaction(transaction);
setShowForm(true);
};
const handleDelete = async (id: string) => {
if (window.confirm('Are you sure you want to delete this transaction?')) {
await deleteTransaction(id);
}
};
const handleCloseForm = () => {
setShowForm(false);
setEditingTransaction(undefined);
};
if (loading) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-16 bg-gray-200 rounded"></div>
))}
</div>
</div>
</div>
);
}
return (
<div className="p-6">
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-900">Transactions</h1>
<button
onClick={() => setShowForm(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
<Plus className="h-4 w-4 mr-2" />
Add Transaction
</button>
</div>
{/* Search and Filter */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<input
type="text"
placeholder="Search transactions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="relative">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as any)}
className="pl-10 pr-8 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">All Types</option>
<option value="income">Income</option>
<option value="expense">Expense</option>
<option value="transfer">Transfer</option>
</select>
</div>
</div>
</div>
{/* Transactions List */}
<div className="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
{filteredTransactions.length > 0 ? (
<div className="divide-y divide-gray-200">
{filteredTransactions.map((transaction) => (
<div key={transaction.id} className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-3">
<div className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium ${
transaction.type === 'income' ? 'bg-green-100 text-green-800' :
transaction.type === 'expense' ? 'bg-red-100 text-red-800' :
'bg-blue-100 text-blue-800'
}`}>
{transaction.category?.name.charAt(0).toUpperCase() || 'T'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{transaction.description}
</p>
<div className="flex items-center space-x-2 mt-1">
<span className="text-xs text-gray-500">
{transaction.category?.name}
</span>
<span className="text-gray-300"></span>
<span className="text-xs text-gray-500">
{new Date(transaction.date).toLocaleDateString()}
</span>
<span className="text-gray-300"></span>
<span className={`text-xs font-medium px-2 py-1 rounded-full ${
transaction.type === 'income' ? 'bg-green-100 text-green-800' :
transaction.type === 'expense' ? 'bg-red-100 text-red-800' :
'bg-blue-100 text-blue-800'
}`}>
{transaction.type}
</span>
</div>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<span className={`text-lg font-semibold ${
transaction.type === 'income' ? 'text-green-600' : 'text-red-600'
}`}>
{transaction.type === 'income' ? '+' : '-'}${transaction.amount.toFixed(2)}
</span>
<div className="flex items-center space-x-2">
<button
onClick={() => handleEdit(transaction)}
className="p-2 text-gray-400 hover:text-blue-600 transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(transaction.id)}
className="p-2 text-gray-400 hover:text-red-600 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="p-12 text-center">
<div className="text-gray-400 mb-4">
<Plus className="h-12 w-12 mx-auto" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">No transactions found</h3>
<p className="text-gray-500 mb-6">
{searchTerm || filterType !== 'all'
? 'Try adjusting your search or filter criteria.'
: 'Get started by adding your first transaction.'
}
</p>
{!searchTerm && filterType === 'all' && (
<button
onClick={() => setShowForm(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
<Plus className="h-4 w-4 mr-2" />
Add Your First Transaction
</button>
)}
</div>
)}
</div>
{/* Transaction Form Modal */}
{showForm && (
<TransactionForm
transaction={editingTransaction}
categories={categories}
onSubmit={handleSubmit}
onClose={handleCloseForm}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,34 @@
export const API_CONFIG = {
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
environment: import.meta.env.VITE_ENVIRONMENT || 'development',
} as const;
export const API_ENDPOINTS = {
// Auth endpoints (fastapi-users)
auth: {
login: '/auth/jwt/login',
logout: '/auth/jwt/logout',
register: '/auth/register',
me: '/users/me',
refresh: '/auth/jwt/refresh',
},
// User endpoints
users: {
profile: '/users/me',
update: '/users/me',
},
// Transaction endpoints
transactions: {
list: '/transactions',
create: '/transactions',
update: (id: string) => `/transactions/${id}`,
delete: (id: string) => `/transactions/${id}`,
},
// Category endpoints
categories: {
list: '/categories',
create: '/categories',
update: (id: string) => `/categories/${id}`,
delete: (id: string) => `/categories/${id}`,
},
} as const;

View File

@@ -0,0 +1,113 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { User, AuthTokens, LoginCredentials, RegisterData } from '../types';
import { apiClient } from '../utils/api';
import { API_ENDPOINTS } from '../config/api';
interface AuthContextType {
user: User | null;
loading: boolean;
login: (credentials: LoginCredentials) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => Promise<void>;
updateProfile: (data: Partial<User>) => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
checkAuth();
}, []);
const checkAuth = async () => {
try {
const token = localStorage.getItem('access_token');
if (token) {
const userData = await apiClient.get<User>(API_ENDPOINTS.auth.me);
setUser(userData);
}
} catch (error) {
localStorage.removeItem('access_token');
} finally {
setLoading(false);
}
};
const login = async (credentials: LoginCredentials) => {
try {
const tokens = await apiClient.postForm<AuthTokens>(
API_ENDPOINTS.auth.login,
{
grant_type: "password",
username: credentials.email,
password: credentials.password
}
);
localStorage.setItem('access_token', tokens.access_token);
const userData = await apiClient.get<User>(API_ENDPOINTS.auth.me);
setUser(userData);
} catch (error) {
throw new Error('Invalid credentials');
}
};
const register = async (data: RegisterData) => {
try {
await apiClient.post<User>(API_ENDPOINTS.auth.register, data);
// Auto-login after registration
await login({ email: data.email, password: data.password });
} catch (error) {
throw new Error('Registration failed');
}
};
const logout = async () => {
try {
await apiClient.post(API_ENDPOINTS.auth.logout);
} catch (error) {
// Continue with logout even if API call fails
} finally {
localStorage.removeItem('access_token');
setUser(null);
}
};
const updateProfile = async (data: Partial<User>) => {
try {
const updatedUser = await apiClient.put<User>(API_ENDPOINTS.users.update, data);
setUser(updatedUser);
} catch (error) {
throw new Error('Failed to update profile');
}
};
return (
<AuthContext.Provider
value={{
user,
loading,
login,
register,
logout,
updateProfile,
}}
>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,131 @@
import { useState, useEffect } from 'react';
import { Transaction, Category, TransactionSummary } from '../types';
import { apiClient } from '../utils/api';
import { API_ENDPOINTS } from '../config/api';
export const useTransactions = () => {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState<TransactionSummary>({
totalIncome: 0,
totalExpenses: 0,
balance: 0,
categoryBreakdown: [],
});
const fetchTransactions = async () => {
setLoading(true);
try {
const data = await apiClient.get<Transaction[]>(API_ENDPOINTS.transactions.list);
setTransactions(data);
calculateSummary(data);
} catch (error) {
console.error('Failed to fetch transactions:', error);
} finally {
setLoading(false);
}
};
const fetchCategories = async () => {
try {
const data = await apiClient.get<Category[]>(API_ENDPOINTS.categories.list);
setCategories(data);
} catch (error) {
console.error('Failed to fetch categories:', error);
}
};
const createTransaction = async (transaction: Omit<Transaction, 'id' | 'user_id' | 'created_at' | 'updated_at'>) => {
try {
const newTransaction = await apiClient.post<Transaction>(
API_ENDPOINTS.transactions.create,
transaction
);
setTransactions(prev => [newTransaction, ...prev]);
calculateSummary([newTransaction, ...transactions]);
return newTransaction;
} catch (error) {
throw new Error('Failed to create transaction');
}
};
const updateTransaction = async (id: string, updates: Partial<Transaction>) => {
try {
const updatedTransaction = await apiClient.put<Transaction>(
API_ENDPOINTS.transactions.update(id),
updates
);
setTransactions(prev =>
prev.map(t => (t.id === id ? updatedTransaction : t))
);
const newTransactions = transactions.map(t => (t.id === id ? updatedTransaction : t));
calculateSummary(newTransactions);
return updatedTransaction;
} catch (error) {
throw new Error('Failed to update transaction');
}
};
const deleteTransaction = async (id: string) => {
try {
await apiClient.delete(API_ENDPOINTS.transactions.delete(id));
setTransactions(prev => prev.filter(t => t.id !== id));
const newTransactions = transactions.filter(t => t.id !== id);
calculateSummary(newTransactions);
} catch (error) {
throw new Error('Failed to delete transaction');
}
};
const calculateSummary = (transactionList: Transaction[]) => {
const totalIncome = transactionList
.filter(t => t.type === 'income')
.reduce((sum, t) => sum + t.amount, 0);
const totalExpenses = transactionList
.filter(t => t.type === 'expense')
.reduce((sum, t) => sum + t.amount, 0);
const balance = totalIncome - totalExpenses;
const categoryTotals = transactionList.reduce((acc, transaction) => {
const categoryName = transaction.category?.name || 'Uncategorized';
acc[categoryName] = (acc[categoryName] || 0) + Math.abs(transaction.amount);
return acc;
}, {} as Record<string, number>);
const totalAmount = Object.values(categoryTotals).reduce((sum, amount) => sum + amount, 0);
const categoryBreakdown = Object.entries(categoryTotals)
.map(([category, amount]) => ({
category,
amount,
percentage: totalAmount > 0 ? (amount / totalAmount) * 100 : 0,
}))
.sort((a, b) => b.amount - a.amount);
setSummary({
totalIncome,
totalExpenses,
balance,
categoryBreakdown,
});
};
useEffect(() => {
fetchTransactions();
fetchCategories();
}, []);
return {
transactions,
categories,
loading,
summary,
createTransaction,
updateTransaction,
deleteTransaction,
refreshTransactions: fetchTransactions,
refreshCategories: fetchCategories,
};
};

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,60 @@
export interface User {
id: string;
email: string;
first_name?: string;
last_name?: string;
is_active: boolean;
is_verified: boolean;
created_at: string;
}
export interface Category {
id: string;
name: string;
type: 'income' | 'expense' | 'transfer';
color: string;
icon: string;
user_id: string;
created_at: string;
}
export interface Transaction {
id: string;
amount: number;
description: string;
category_id: string;
category?: Category;
date: string;
type: 'income' | 'expense' | 'transfer';
user_id: string;
created_at: string;
updated_at: string;
}
export interface AuthTokens {
access_token: string;
token_type: string;
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterData {
email: string;
password: string;
first_name?: string;
last_name?: string;
}
export interface TransactionSummary {
totalIncome: number;
totalExpenses: number;
balance: number;
categoryBreakdown: Array<{
category: string;
amount: number;
percentage: number;
}>;
}

View File

@@ -0,0 +1,81 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import { API_CONFIG } from '../config/api';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: API_CONFIG.baseURL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
}
private setupInterceptors() {
// Request interceptor to add auth token
this.client.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for token refresh
this.client.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
localStorage.removeItem('access_token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
async get<T>(url: string): Promise<T> {
const response: AxiosResponse<T> = await this.client.get(url);
return response.data;
}
async post<T>(url: string, data?: any): Promise<T> {
const response: AxiosResponse<T> = await this.client.post(url, data);
return response.data;
}
async put<T>(url: string, data?: any): Promise<T> {
const response: AxiosResponse<T> = await this.client.put(url, data);
return response.data;
}
async delete<T>(url: string): Promise<T> {
const response: AxiosResponse<T> = await this.client.delete(url);
return response.data;
}
async postForm<T>(url: string, data?: any): Promise<T> {
const params = new URLSearchParams();
for (const key in data) {
if (data[key] !== undefined && data[key] !== null) {
params.append(key, data[key]);
}
}
const response: AxiosResponse<T> = await this.client.post(url, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
return response.data;
}
}
export const apiClient = new ApiClient();

1
7project/frontend/src/vite-env.d.ts vendored Normal file
View File

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

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
});

116
7project/tofu/main.tf Normal file
View File

@@ -0,0 +1,116 @@
terraform {
required_providers {
kubectl = {
source = "gavinbunney/kubectl"
version = "1.19.0"
}
helm = {
source = "hashicorp/helm"
version = "3.0.2"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "2.38.0"
}
kustomization = {
source = "kbst/kustomization"
version = "0.9.6"
}
time = {
source = "hashicorp/time"
version = "0.13.1"
}
}
}
provider "kubernetes" {
config_path = "./kubeconfig"
}
provider "kubectl" {
config_path = "./kubeconfig"
}
provider "kustomization" {
kubeconfig_path = "./kubeconfig"
}
provider "helm" {
kubernetes = {
config_path = "./kubeconfig"
}
}
module "storage" {
source = "${path.module}/modules/storage"
}
module "loadbalancer" {
source = "${path.module}/modules/metallb"
depends_on = [module.storage]
metallb_ip_range = var.metallb_ip_range
}
module "cert-manager" {
source = "${path.module}/modules/cert-manager"
depends_on = [module.loadbalancer]
}
module "cloudflare" {
source = "${path.module}/modules/cloudflare"
depends_on = [module.cert-manager]
cloudflare_api_token = var.cloudflare_api_token
cloudflare_tunnel_name = var.cloudflare_tunnel_name
cloudflare_email = var.cloudflare_email
cloudflare_domain = var.cloudflare_domain
cloudflare_account_id = var.cloudflare_account_id
}
module "monitoring" {
source = "${path.module}/modules/prometheus"
depends_on = [module.cloudflare]
cloudflare_domain = var.cloudflare_domain
}
module "database" {
source = "${path.module}/modules/maxscale"
depends_on = [module.monitoring]
mariadb_password = var.mariadb_password
mariadb_root_password = var.mariadb_root_password
mariadb_user_name = var.mariadb_user_name
mariadb_user_host = var.mariadb_user_host
mariadb_user_password = var.mariadb_user_password
maxscale_ip = var.metallb_maxscale_ip
service_ip = var.metallb_service_ip
primary_ip = var.metallb_primary_ip
secondary_ip = var.metallb_secondary_ip
phpmyadmin_enabled = var.phpmyadmin_enabled
cloudflare_domain = var.cloudflare_domain
}
#module "argocd" {
# source = "${path.module}/modules/argocd"
# depends_on = [module.storage, module.loadbalancer, module.cloudflare]
# argocd_admin_password = var.argocd_admin_password
# cloudflare_domain = var.cloudflare_domain
#}
#module "redis" {
# source = "${path.module}/modules/redis"
# depends_on = [module.storage]
# cloudflare_base_domain = var.cloudflare_domain
#}
module "rabbitmq" {
source = "${path.module}/modules/rabbitmq"
depends_on = [module.database]
base_domain = var.cloudflare_domain
rabbitmq-password = var.rabbitmq-password
}

View File

@@ -0,0 +1,14 @@
apiVersion: networking.cfargotunnel.com/v1alpha1
kind: TunnelBinding
metadata:
name: argocd-tunnel-binding
namespace: argocd
subjects:
- name: argocd-server
spec:
target: https://argocd-server.argocd.svc.cluster.local
fqdn: argocd.${base_domain}
noTlsVerify: true
tunnelRef:
kind: ClusterTunnel
name: cluster-tunnel

View File

@@ -0,0 +1,39 @@
terraform {
required_providers {
kubectl = {
source = "gavinbunney/kubectl"
version = "1.19.0"
}
helm = {
source = "hashicorp/helm"
version = "3.0.2"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "2.38.0"
}
}
}
resource "kubernetes_namespace" "argocd" {
metadata {
name = "argocd"
}
}
resource "helm_release" "argocd" {
name = "argocd"
namespace = "argocd"
repository = "https://argoproj.github.io/argo-helm"
chart = "argo-cd"
depends_on = [kubernetes_namespace.argocd]
}
resource "kubectl_manifest" "argocd-tunnel-bind" {
depends_on = [helm_release.argocd]
yaml_body = templatefile("${path.module}/argocd-ui.yaml", {
base_domain = var.cloudflare_domain
})
}

View File

@@ -0,0 +1,12 @@
variable "argocd_admin_password" {
type = string
nullable = false
sensitive = true
description = "ArgoCD admin password"
}
variable "cloudflare_domain" {
type = string
default = "Base cloudflare domain, e.g. example.com"
nullable = false
}

View File

@@ -0,0 +1,30 @@
terraform {
required_providers {
kubectl = {
source = "gavinbunney/kubectl"
version = "1.19.0"
}
helm = {
source = "hashicorp/helm"
version = "3.0.2"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "2.38.0"
}
}
}
resource "helm_release" "cert_manager" {
name = "cert-manager"
repository = "https://charts.jetstack.io"
chart = "cert-manager"
version = "v1.14.4"
namespace = "cert-manager"
create_namespace = true
set = [{
name = "installCRDs"
value = "true"
}]
}

View File

@@ -0,0 +1,12 @@
apiVersion: networking.cfargotunnel.com/v1alpha2
kind: ClusterTunnel
metadata:
name: cluster-tunnel
spec:
newTunnel:
name: ${cloudflare_tunnel_name}
cloudflare:
email: ${cloudflare_email}
domain: ${cloudflare_domain}
secret: cloudflare-secrets
accountId: ${cloudflare_account_id}

View File

@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: cloudflare-operator-system
resources:
- https://github.com/adyanth/cloudflare-operator.git/config/default?ref=v0.13.1

View File

@@ -0,0 +1,50 @@
terraform {
required_providers {
kubectl = {
source = "gavinbunney/kubectl"
version = "1.19.0"
}
helm = {
source = "hashicorp/helm"
version = "3.0.2"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "2.38.0"
}
kustomization = {
source = "kbst/kustomization"
version = "0.9.6"
}
}
}
data "kustomization" "cloudflare-kustomization" {
path = "${path.module}/kustomization"
}
resource "kustomization_resource" "cloudflare" {
provider = kustomization
for_each = data.kustomization.cloudflare-kustomization.ids
manifest = data.kustomization.cloudflare-kustomization.manifests[each.key]
}
resource "kubectl_manifest" "cloudflare-api-token" {
yaml_body = templatefile("${path.module}/secret.yaml", {
cloudflare_api_token = var.cloudflare_api_token
})
}
resource "kubectl_manifest" "cloudflare-tunnel" {
yaml_body = templatefile("${path.module}/cluster-tunnel.yaml", {
cloudflare_tunnel_name = var.cloudflare_tunnel_name
cloudflare_email = var.cloudflare_email
cloudflare_domain = var.cloudflare_domain
cloudflare_account_id = var.cloudflare_account_id
})
depends_on = [kustomization_resource.cloudflare]
}

View File

@@ -0,0 +1,8 @@
apiVersion: v1
kind: Secret
metadata:
name: cloudflare-secrets
namespace: cloudflare-operator-system
type: Opaque
stringData:
CLOUDFLARE_API_TOKEN: "${cloudflare_api_token}"

View File

@@ -0,0 +1,30 @@
variable "cloudflare_api_token" {
type = string
description = "Cloudflare API token"
sensitive = true
nullable = false
}
variable "cloudflare_tunnel_name" {
type = string
description = "Cloudflare Tunnel Name"
default = "tofu-tunnel"
nullable = false
}
variable "cloudflare_email" {
type = string
description = "Cloudflare Email"
nullable = false
}
variable "cloudflare_domain" {
type = string
description = "Cloudflare Domain"
nullable = false
}
variable "cloudflare_account_id" {
type = string
description = "Cloudflare Account ID"
nullable = false
}

View File

@@ -0,0 +1,4 @@
apiVersion: v2
name: maxscale-helm
version: 1.0.7
description: Helm chart for MaxScale related Kubernetes manifests

View File

@@ -0,0 +1,179 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: MariaDB
metadata:
name: mariadb-repl
namespace: mariadb-operator
spec:
rootPasswordSecretKeyRef:
name: mariadb-secret
key: root-password
username: mariadb
passwordSecretKeyRef:
name: mariadb-secret
key: password
database: mariadb
storage:
size: 5Gi
storageClassName: longhorn
resizeInUseVolumes: true
waitForVolumeResize: true
volumeClaimTemplate:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: longhorn
replicas: 3
replicasAllowEvenNumber: true
podSpec:
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- "ALL"
seccompProfile:
type: RuntimeDefault
maxScale:
enabled: true
kubernetesService:
type: LoadBalancer
metadata:
annotations:
metallb.universe.tf/loadBalancerIPs: {{ .Values.metallb.maxscale_ip | default "" | quote }}
connection:
secretName: mxs-repl-conn
port: 3306
metrics:
enabled: true
serviceMonitor:
enabled: true
interval: 30s
scrapeTimeout: 10s
prometheusRelease: kube-prometheus-stack
jobLabel: mariadb-monitoring
tls:
enabled: true
replication:
enabled: true
primary:
podIndex: 0
automaticFailover: true
replica:
waitPoint: AfterSync
gtid: CurrentPos
replPasswordSecretKeyRef:
name: mariadb-secret
key: password
connectionTimeout: 10s
connectionRetries: 10
syncTimeout: 10s
syncBinlog: 1
probesEnabled: true
service:
type: LoadBalancer
metadata:
annotations:
metallb.universe.tf/loadBalancerIPs: {{ .Values.metallb.service_ip | default "" | quote }}
connection:
secretName: mariadb-repl-conn
secretTemplate:
key: dsn
primaryService:
type: LoadBalancer
metadata:
annotations:
metallb.universe.tf/loadBalancerIPs: {{ .Values.metallb.primary_ip | default "" | quote }}
primaryConnection:
secretName: mariadb-repl-conn-primary
secretTemplate:
key: dsn
secondaryService:
type: LoadBalancer
metadata:
annotations:
metallb.universe.tf/loadBalancerIPs: {{ .Values.metallb.secondary_ip | default "" | quote }}
secondaryConnection:
secretName: mariadb-repl-conn-secondary
secretTemplate:
key: dsn
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- mariadb-repl
topologyKey: kubernetes.io/hostname
tolerations:
- key: "k8s.mariadb.com/ha"
operator: "Exists"
effect: "NoSchedule"
podDisruptionBudget:
maxUnavailable: 33%
updateStrategy:
type: ReplicasFirstPrimaryLast
myCnf: |
[mariadb]
bind-address=*
default_storage_engine=InnoDB
binlog_format=row
innodb_autoinc_lock_mode=2
innodb_buffer_pool_size=1024M
max_allowed_packet=256M
#timeZone: Europe/Prague
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 1Gi
livenessProbe:
initialDelaySeconds: 20
periodSeconds: 5
timeoutSeconds: 5
readinessProbe:
initialDelaySeconds: 20
periodSeconds: 5
timeoutSeconds: 5
metrics:
enabled: true
serviceMonitor:
enabled: true
interval: 30s
scrapeTimeout: 10s
prometheusRelease: kube-prometheus-stack
jobLabel: mariadb-monitoring
tls:
enabled: true
required: true
suspend: false

View File

@@ -0,0 +1,18 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: Grant
metadata:
name: grant
spec:
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
waitForIt: false
privileges:
- "ALL PRIVILEGES"
database: "*"
table: "*"
username: {{ .Values.user.name | default "user" }}
grantOption: true
host: {{ .Values.user.host | default "%" | quote }}
requeueInterval: 30s
retryInterval: 5s

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: mariadb-repl-0
namespace: mariadb-operator
spec:
selector:
app.kubernetes.io/instance: mariadb-repl
app.kubernetes.io/name: mariadb
statefulset.kubernetes.io/pod-name: mariadb-repl-0
ports:
- name: mariadb
port: 3306
targetPort: 3306
protocol: TCP
type: ClusterIP

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: mariadb-repl-1
namespace: mariadb-operator
spec:
selector:
app.kubernetes.io/instance: mariadb-repl
app.kubernetes.io/name: mariadb
statefulset.kubernetes.io/pod-name: mariadb-repl-1
ports:
- name: mariadb
port: 3306
targetPort: 3306
protocol: TCP
type: ClusterIP

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: mariadb-repl-2
namespace: mariadb-operator
spec:
selector:
app.kubernetes.io/instance: mariadb-repl
app.kubernetes.io/name: mariadb
statefulset.kubernetes.io/pod-name: mariadb-repl-2
ports:
- name: mariadb
port: 3306
targetPort: 3306
protocol: TCP
type: ClusterIP

View File

@@ -0,0 +1,14 @@
apiVersion: networking.cfargotunnel.com/v1alpha1
kind: TunnelBinding
metadata:
name: maxscale-tunnel-binding
namespace: mariadb-operator
subjects:
- name: mariadb-repl-maxscale
spec:
target: https://mariadb-repl-maxscale-internal.mariadb-operator.svc.cluster.local:8989
fqdn: maxscale.{{ .Values.base_domain }}
noTlsVerify: true
tunnelRef:
kind: ClusterTunnel
name: cluster-tunnel

View File

@@ -0,0 +1,32 @@
{{- if (.Values.phpmyadmin.enabled | default true) }}
apiVersion: v1
kind: ConfigMap
metadata:
name: phpmyadmin-config
namespace: mariadb-operator
data:
hosts-init-script.sh: |-
#!/bin/bash
echo "
/* Maximum number of databases displayed on one page */
\$cfg['MaxDbList'] = 300;
\$cfg['MaxNavigationItems'] = 300;
/* Additional servers */
\$servers = [
{{- range $i, $e := until (int (3)) }}
'mariadb-repl-{{ $i }}',
{{- end }}
];
foreach (\$servers as \$server) {
\$i++;
/* Authentication type */
\$cfg['Servers'][\$i]['auth_type'] = 'cookie';
/* Server parameters */
\$cfg['Servers'][\$i]['host'] = \$server;
\$cfg['Servers'][\$i]['port'] = '3306';
\$cfg['Servers'][\$i]['compress'] = false;
\$cfg['Servers'][\$i]['AllowNoPassword'] = false;
}
" >> /opt/bitnami/phpmyadmin/config.inc.php
{{- end }}

View File

@@ -0,0 +1,76 @@
{{- if (.Values.phpmyadmin.enabled | default true) }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: phpmyadmin
namespace: mariadb-operator
labels:
app: phpmyadmin
spec:
progressDeadlineSeconds: 600
replicas: 1
revisionHistoryLimit: 10
selector:
matchLabels:
app: phpmyadmin
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
type: RollingUpdate
template:
metadata:
labels:
app: phpmyadmin
spec:
containers:
- env:
- name: DATABASE_ENABLE_SSL
value: "yes"
- name: DATABASE_HOST
value: "mariadb-repl"
- name: DATABASE_PORT_NUMBER
value: "3306"
- name: PHPMYADMIN_ALLOW_NO_PASSWORD
value: "false"
image: "bitnamilegacy/phpmyadmin:5.2.2"
imagePullPolicy: IfNotPresent
livenessProbe:
failureThreshold: 3
httpGet:
path: /
port: http
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
name: phpmyadmin
ports:
- containerPort: 8080
name: http
protocol: TCP
- containerPort: 8443
name: https
protocol: TCP
readinessProbe:
failureThreshold: 3
httpGet:
path: /
port: http
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
volumeMounts:
- mountPath: /docker-entrypoint-init.d/hosts-init-script.sh
name: config
subPath: hosts-init-script.sh
ip: 127.0.0.1
restartPolicy: Always
volumes:
- configMap:
defaultMode: 511
name: phpmyadmin-config
optional: false
name: config
{{- end }}

View File

@@ -0,0 +1,18 @@
{{- if (.Values.phpmyadmin.enabled | default true) }}
apiVersion: v1
kind: Service
metadata:
name: "phpmyadmin"
namespace: {{ .Values.namespace | default "mariadb-operator" | quote }}
labels:
app: "phpmyadmin"
spec:
clusterIP: None
ports:
- name: http
port: {{ .Values.phpmyadmin.servicePort | default 8080 }}
protocol: TCP
targetPort: {{ .Values.phpmyadmin.servicePort | default 8080 }}
selector:
app: "phpmyadmin"
{{- end }}

View File

@@ -0,0 +1,14 @@
apiVersion: networking.cfargotunnel.com/v1alpha1
kind: TunnelBinding
metadata:
name: phpmyadmin-tunnel-binding
namespace: mariadb-operator
subjects:
- name: mariadb-repl-maxscale
spec:
target: http://phpmyadmin.mariadb-operator.svc.cluster.local:8080
fqdn: mysql.{{ .Values.base_domain }}
noTlsVerify: true
tunnelRef:
kind: ClusterTunnel
name: cluster-tunnel

View File

@@ -0,0 +1,16 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: User
metadata:
name: mariadb-user
namespace: mariadb-operator
spec:
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
waitForIt: false
host: {{ .Values.user.host | default "%" | quote }}
name: {{ .Values.user.name | default "user" }}
passwordPlugin: {}
passwordSecretKeyRef:
key: user-password
name: mariadb-secret

View File

@@ -0,0 +1,17 @@
# Default values for maxscale-helm.
# This file can be used to override manifest parameters.
user:
name: user
host: "%"
metallb:
maxscale_ip: ""
service_ip: ""
primary_ip: ""
secondary_ip: ""
phpmyadmin:
enabled: true
base_domain: example.com

View File

@@ -0,0 +1,75 @@
terraform {
required_providers {
kubectl = {
source = "gavinbunney/kubectl"
version = "1.19.0"
}
helm = {
source = "hashicorp/helm"
version = "3.0.2"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "2.38.0"
}
}
}
resource "kubernetes_namespace" "mariadb-operator" {
metadata {
name = "mariadb-operator"
}
}
locals {
mariadb_secret_yaml = templatefile("${path.module}/mariadb-secret.yaml", {
password = var.mariadb_password
user_password = var.mariadb_user_password
root_password = var.mariadb_root_password
})
}
resource "kubectl_manifest" "secrets" {
yaml_body = local.mariadb_secret_yaml
depends_on = [ kubernetes_namespace.mariadb-operator ]
}
resource "helm_release" "mariadb-operator-crds" {
name = "mariadb-operator-crds"
repository = "https://helm.mariadb.com/mariadb-operator"
chart = "mariadb-operator-crds"
namespace = "mariadb-operator"
version = "25.8.4"
depends_on = [ kubectl_manifest.secrets ]
timeout = 3600
}
resource "helm_release" "mariadb-operator" {
name = "mariadb-operator"
repository = "https://helm.mariadb.com/mariadb-operator"
chart = "mariadb-operator"
depends_on = [ helm_release.mariadb-operator-crds, kubectl_manifest.secrets ]
namespace = "mariadb-operator"
timeout = 3600
}
resource "helm_release" "maxscale_helm" {
name = "maxscale-helm"
chart = "${path.module}/charts/maxscale-helm"
version = "1.0.7"
depends_on = [ helm_release.mariadb-operator-crds, kubectl_manifest.secrets ]
timeout = 3600
set = [
{ name = "user.name", value = var.mariadb_user_name },
{ name = "user.host", value = var.mariadb_user_host },
{ name = "metallb.maxscale_ip", value = var.maxscale_ip },
{ name = "metallb.service_ip", value = var.service_ip },
{ name = "metallb.primary_ip", value = var.primary_ip },
{ name = "metallb.secondary_ip", value = var.secondary_ip },
{ name = "phpmyadmin.enabled", value = tostring(var.phpmyadmin_enabled) },
{ name = "base_domain", value = var.cloudflare_domain }
]
}

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: Secret
metadata:
name: mariadb-secret
namespace: mariadb-operator
labels:
k8s.mariadb.com/watch: ""
stringData:
password: ${password}
user-password: ${user_password}
root-password: ${root_password}

View File

@@ -0,0 +1,58 @@
variable "mariadb_password" {
description = "Password for standard MariaDB users (stored in Secret). Keep secure."
type = string
sensitive = true
}
variable "mariadb_root_password" {
description = "Root password for MariaDB (stored in Secret). Keep secure."
type = string
sensitive = true
}
variable "mariadb_user_name" {
description = "Application username to create and grant privileges to"
type = string
}
variable "mariadb_user_host" {
description = "Host (wildcard or specific) for the application user"
type = string
}
variable "maxscale_ip" {
description = "MetalLB IP for MaxScale service"
type = string
}
variable "service_ip" {
description = "MetalLB IP for general MariaDB service"
type = string
}
variable "primary_ip" {
description = "MetalLB IP for MariaDB primary service"
type = string
}
variable "secondary_ip" {
description = "MetalLB IP for MariaDB secondary service"
type = string
}
variable "phpmyadmin_enabled" {
description = "Whether to deploy phpMyAdmin auxiliary components"
type = bool
}
variable "mariadb_user_password" {
description = "Password for the application MariaDB user"
type = string
sensitive = true
}
variable "cloudflare_domain" {
type = string
default = "Base cloudflare domain, e.g. example.com"
nullable = false
}

View File

@@ -0,0 +1,70 @@
terraform {
required_providers {
kubectl = {
source = "gavinbunney/kubectl"
version = "1.19.0"
}
helm = {
source = "hashicorp/helm"
version = "3.0.2"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "2.38.0"
}
}
}
resource "kubernetes_namespace" "metallb-system" {
metadata {
name = "metallb-system"
labels = {
"pod-security.kubernetes.io/enforce" = "privileged"
}
}
}
resource "helm_release" "metallb" {
depends_on = [ kubernetes_namespace.metallb-system ]
name = "metallb"
repository = "https://metallb.github.io/metallb"
chart = "metallb"
namespace = "metallb-system"
version = "0.14.9"
timeout = 3600
}
resource "kubectl_manifest" "metallb_pool" {
depends_on = [ helm_release.metallb ]
yaml_body = yamlencode({
apiVersion = "metallb.io/v1beta1"
kind = "IPAddressPool"
metadata = {
name = "metallb-pool"
namespace = "metallb-system"
}
spec = {
addresses = [var.metallb_ip_range]
}
})
}
resource "kubectl_manifest" "metallb_l2_advertisement" {
depends_on = [ kubectl_manifest.metallb_pool ]
yaml_body = yamlencode({
apiVersion = "metallb.io/v1beta1"
kind = "L2Advertisement"
metadata = {
name = "l2-advertisement"
namespace = "metallb-system"
}
spec = {
ipAddressPools = ["metallb-pool"]
}
})
}

View File

@@ -0,0 +1,4 @@
variable "metallb_ip_range" {
description = "IP address range for MetalLB address pool"
type = string
}

View File

@@ -0,0 +1,14 @@
apiVersion: networking.cfargotunnel.com/v1alpha1
kind: TunnelBinding
metadata:
name: grafana-tunnel-binding
namespace: monitoring
subjects:
- name: grafana
spec:
target: http://kube-prometheus-stack-grafana.monitoring.svc.cluster.local
fqdn: grafana.${base_domain}
noTlsVerify: true
tunnelRef:
kind: ClusterTunnel
name: cluster-tunnel

View File

@@ -0,0 +1,66 @@
terraform {
required_providers {
kubectl = {
source = "gavinbunney/kubectl"
version = "1.19.0"
}
helm = {
source = "hashicorp/helm"
version = "3.0.2"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "2.38.0"
}
kustomization = {
source = "kbst/kustomization"
version = "0.9.6"
}
time = {
source = "hashicorp/time"
version = "0.13.1"
}
}
}
# Create namespace for monitoring
resource "kubernetes_namespace" "monitoring" {
metadata {
name = "monitoring"
labels = {
"pod-security.kubernetes.io/enforce" = "privileged"
}
}
}
# Deploy kube-prometheus-stack
resource "helm_release" "kube_prometheus_stack" {
name = "kube-prometheus-stack"
repository = "https://prometheus-community.github.io/helm-charts"
chart = "kube-prometheus-stack"
namespace = kubernetes_namespace.monitoring.metadata[0].name
version = "67.2.1" # Check for latest version
# Wait for CRDs to be created
wait = true
timeout = 600
force_update = false
recreate_pods = false
# Reference the values file
values = [
file("${path.module}/values.yaml")
]
depends_on = [
kubernetes_namespace.monitoring
]
}
resource "kubectl_manifest" "argocd-tunnel-bind" {
depends_on = [helm_release.kube_prometheus_stack]
yaml_body = templatefile("${path.module}/grafana-ui.yaml", {
base_domain = var.cloudflare_domain
})
}

View File

@@ -0,0 +1,189 @@
# Prometheus configuration
prometheus:
prometheusSpec:
retention: 30d
retentionSize: "45GB"
# Storage configuration
storageSpec:
volumeClaimTemplate:
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 50Gi
# storageClassName: "your-storage-class" # Uncomment and specify if needed
# Resource limits
resources:
requests:
cpu: 500m
memory: 2Gi
limits:
cpu: 2000m
memory: 4Gi
# Scrape interval
scrapeInterval: 30s
evaluationInterval: 30s
# Service configuration
service:
type: ClusterIP
port: 9090
# Ingress (disabled by default)
ingress:
enabled: false
# ingressClassName: nginx
# hosts:
# - prometheus.example.com
# tls:
# - secretName: prometheus-tls
# hosts:
# - prometheus.example.com
# Grafana configuration
grafana:
enabled: true
# Admin credentials
adminPassword: "admin" # CHANGE THIS IN PRODUCTION!
# Persistence
persistence:
enabled: true
size: 10Gi
# storageClassName: "your-storage-class" # Uncomment and specify if needed
# Resource limits
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
# Service configuration
service:
type: ClusterIP
port: 80
# Ingress (disabled by default)
ingress:
enabled: false
# ingressClassName: nginx
# hosts:
# - grafana.example.com
# tls:
# - secretName: grafana-tls
# hosts:
# - grafana.example.com
# Default dashboards
defaultDashboardsEnabled: true
defaultDashboardsTimezone: Europe/Prague
# Alertmanager configuration
alertmanager:
enabled: true
alertmanagerSpec:
# Storage configuration
storage:
volumeClaimTemplate:
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
# storageClassName: "your-storage-class" # Uncomment and specify if needed
# Resource limits
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
# Service configuration
service:
type: ClusterIP
port: 9093
# Ingress (disabled by default)
ingress:
enabled: false
# ingressClassName: nginx
# hosts:
# - alertmanager.example.com
# tls:
# - secretName: alertmanager-tls
# hosts:
# - alertmanager.example.com
# Alertmanager configuration
config:
global:
resolve_timeout: 5m
route:
group_by: [ 'alertname', 'cluster', 'service' ]
group_wait: 10s
group_interval: 10s
repeat_interval: 12h
receiver: 'null'
routes:
- match:
alertname: Watchdog
receiver: 'null'
receivers:
- name: 'null'
# Add your receivers here (email, slack, pagerduty, etc.)
# - name: 'slack'
# slack_configs:
# - api_url: 'YOUR_SLACK_WEBHOOK_URL'
# channel: '#alerts'
# title: '{{ range .Alerts }}{{ .Annotations.summary }}\n{{ end }}'
# text: '{{ range .Alerts }}{{ .Annotations.description }}\n{{ end }}'
# Node Exporter
nodeExporter:
enabled: true
# Kube State Metrics
kubeStateMetrics:
enabled: true
# Prometheus Operator
prometheusOperator:
enabled: true
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
# Service Monitors
# Automatically discover and monitor services with appropriate labels
prometheus-node-exporter:
prometheus:
monitor:
enabled: true
# Additional ServiceMonitors can be defined here
# additionalServiceMonitors: []
# Global settings
global:
rbac:
create: true

View File

@@ -0,0 +1,5 @@
variable "cloudflare_domain" {
type = string
default = "Base cloudflare domain, e.g. example.com"
nullable = false
}

View File

@@ -0,0 +1,81 @@
terraform {
required_providers {
kubectl = {
source = "gavinbunney/kubectl"
version = "1.19.0"
}
helm = {
source = "hashicorp/helm"
version = "3.0.2"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "2.38.0"
}
kustomization = {
source = "kbst/kustomization"
version = "0.9.6"
}
time = {
source = "hashicorp/time"
version = "0.13.1"
}
}
}
# Define the Helm release for RabbitMQ.
# This resource will install the RabbitMQ chart from the Bitnami repository.
resource "helm_release" "rabbitmq" {
# The name of the release in Kubernetes.
name = "rabbitmq"
# The repository where the chart is located.
repository = "https://charts.bitnami.com/bitnami"
# The name of the chart to deploy.
chart = "rabbitmq"
# The version of the chart to deploy. It's best practice to pin the version.
version = "14.4.1"
# The Kubernetes namespace to deploy into.
# If the namespace doesn't exist, you can create it with a kubernetes_namespace resource.
namespace = "rabbitmq"
create_namespace = true
# Override default chart values.
# This is where you customize your RabbitMQ deployment.
set = [
{
name = "auth.username"
value = "admin"
},
{
name = "auth.password"
value = var.rabbitmq-password
},
{
name = "persistence.enabled"
value = "true"
},
{
name = "replicaCount"
value = "1"
},
{
name = "podAntiAffinityPreset"
value = "soft"
},
{
name = "image.repository"
value = "bitnamilegacy/rabbitmq"
},
]
}
resource "kubectl_manifest" "rabbitmq_ui" {
yaml_body = templatefile("${path.module}/rabbit-ui.yaml", {
base_domain = var.base_domain
})
depends_on = [helm_release.rabbitmq]
}

View File

@@ -0,0 +1,14 @@
apiVersion: networking.cfargotunnel.com/v1alpha1
kind: TunnelBinding
metadata:
name: rabbit-tunnel-binding
namespace: rabbitmq
subjects:
- name: rabbit-gui
spec:
target: http://rabbitmq.rabbitmq.svc.cluster.local:15672
fqdn: rabbitmq.${base_domain}
noTlsVerify: true
tunnelRef:
kind: ClusterTunnel
name: cluster-tunnel

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