Compare commits

...

27 Commits

Author SHA1 Message Date
Sami Abuzakuk
678eb22d2b Fix small bug
All checks were successful
Build Container / build (push) Successful in 3m49s
2025-11-01 22:23:08 +01:00
Sami Abuzakuk
e913eb34f2 Add user frontend support 2025-11-01 22:21:06 +01:00
Sami Abuzakuk
2f9f75ee3c Add user api 2025-11-01 22:20:59 +01:00
Sami Abuzakuk
45b78d5faf Update token handling (added expiration time) 2025-11-01 21:58:20 +01:00
Sami Abuzakuk
dff07ef340 Frontend support for mark all viewed and delete all
All checks were successful
Build Container / build (push) Successful in 3m48s
2025-11-01 17:05:24 +01:00
Sami Abuzakuk
625b231de5 Add backend for set all viewed and delete all 2025-11-01 17:05:10 +01:00
Sami Abuzakuk
013ddb26c7 Fix no reload on register 2025-11-01 16:41:52 +01:00
Sami Abuzakuk
657a224163 Fix requirements.txt
All checks were successful
Build Container / build (push) Successful in 3m46s
2025-11-01 16:25:36 +01:00
Sami Abuzakuk
c957d839dd Small fixes 2025-11-01 16:15:35 +01:00
Sami Abuzakuk
8eef535e02 Add small dev helper
All checks were successful
Build Container / build (push) Successful in 3m51s
2025-11-01 16:06:01 +01:00
Sami Abuzakuk
e9d94f706c Add frontend support for user 2025-11-01 16:05:52 +01:00
Sami Abuzakuk
374558d30f Add backend support for users 2025-11-01 16:05:34 +01:00
Sami Abuzakuk
16989ed518 Add notification pagination to backend and frontend 2025-10-22 22:37:12 +02:00
Sami Abuzakuk
d3df001397 Move npm build and dev to Dockerfile 2025-10-22 22:23:06 +02:00
Sami Abuzakuk
5ba5107a3b Add auto alembic upgrade in container
All checks were successful
Build Container / build (push) Successful in 3m51s
2025-10-14 20:26:51 +02:00
Sami Abuzakuk
90cb14eb2a Add alembic support 2025-10-14 20:23:36 +02:00
Sami Abuzakuk
3c6acc9359 Add unread indication to subscription cards 2025-10-14 20:23:21 +02:00
Sami Abuzakuk
6460cab465 Fix env variables in cron
All checks were successful
Build Container / build (push) Successful in 5m15s
2025-10-12 19:06:03 +02:00
Sami Abuzakuk
ec379769e8 Merge branch 'main' of https://gitea.abzk.fr/MrZaiko/Project-Monitor
All checks were successful
Build Container / build (push) Successful in 4m41s
2025-10-12 17:41:52 +02:00
Sami Abuzakuk
887d100bdc Update vite.config.ts 2025-10-12 17:39:00 +02:00
e0f60d9d73 Update .gitea/workflows/build-container.yml
All checks were successful
Build Container / build (push) Successful in 6m36s
2025-10-12 17:23:28 +02:00
Sami Abuzakuk
81f078f0a9 Add gitea support
Some checks failed
Build Container / Build Docker Container (push) Has been cancelled
2025-10-12 17:20:14 +02:00
Sami Abuzakuk
5f5beaae2f Add env variables support 2025-10-12 17:19:58 +02:00
Sami Abuzakuk
f73c66c1a1 Update dockerfile 2025-10-12 17:09:35 +02:00
Sami Abuzakuk
a6f93edf72 Start working on containerization 2025-10-12 15:33:28 +02:00
Sami Abuzakuk
b76cef22ae Add frontend support for notifications 2025-10-12 14:54:53 +02:00
Sami Abuzakuk
fcb875aaf9 Add backend support for notifications 2025-10-12 14:54:37 +02:00
30 changed files with 2214 additions and 233 deletions

View File

@@ -0,0 +1,35 @@
name: Build Container
on:
push:
branches:
- main
jobs:
build:
runs-on: linux
container:
image: catthehacker/ubuntu:act-latest
options: --privileged
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to Gitea Container Registry
uses: docker/login-action@v2
with:
registry: gitea.abzk.fr
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
push: true
tags: gitea.abzk.fr/mrzaiko/project-monitor:latest

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ __pycache__
*.db *.db
.envrc .envrc
exec_folder/ exec_folder/
.env

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Use an official Python image as the base
FROM python:3.12-slim
# Install Node.js, npm, and cron
RUN apt-get update && apt-get install -y \
curl \
cron \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Setup backend
WORKDIR /app/backend
ADD backend .
RUN python3 -m venv venv
RUN . /app/backend/venv/bin/activate && pip install -r requirements.txt
WORKDIR /app/frontend
ADD frontend .
RUN npm install
RUN npm run build
WORKDIR /app
COPY docker/* .
RUN chmod 0644 services.cron
RUN crontab services.cron
RUN chmod +x entry.sh
EXPOSE 8080 8000
# Command to start cron and the backend
CMD ["sh", "-c", "/app/entry.sh"]

147
backend/alembic.ini Normal file
View File

@@ -0,0 +1,147 @@
# 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 tzdata library which can be 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 = sqlite:///./project_monitor.db
[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

77
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,77 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from model import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' supports
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
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,32 @@
"""Initial migration
Revision ID: 9a7bb433ef44
Revises:
Create Date: 2025-10-14 20:19:30.322104
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9a7bb433ef44'
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! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

67
backend/auth.py Normal file
View File

@@ -0,0 +1,67 @@
from datetime import datetime, timedelta
from passlib.context import CryptContext
from jose import JWTError, jwt
import os
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from model import User, SessionLocal
# JWT settings
SECRET_KEY = os.getenv("SECRET_KEY", "")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
if SECRET_KEY == "":
raise ValueError("SECRET_KEY environment variable is not set")
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
expire = datetime.utcnow() + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_user(db, username: str):
return db.query(User).filter(User.username == username).first()
def authenticate_user(db, username: str, password: str):
user = get_user(db, username)
if not user or not verify_password(password, user.password_hash):
return None
return user
def get_current_user(token: str = Depends(oauth2_scheme)):
db = SessionLocal()
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str | None = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = get_user(db, username)
db.close()
if user is None:
raise credentials_exception
return user

View File

@@ -1,14 +1,47 @@
from datetime import datetime from datetime import datetime
from fastapi import FastAPI from fastapi import FastAPI, Depends, HTTPException, status, Query
from fastapi.exceptions import HTTPException from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from model import Log, SessionLocal, Script, Settings from model import (
Base,
Log,
SessionLocal,
Script,
Settings,
Subscription,
Notification,
User,
)
from run_scripts import run_scripts, update_requirements, update_environment from run_scripts import run_scripts, update_requirements, update_environment
import uvicorn import uvicorn
from passlib.context import CryptContext
import os
from model import ensure_default_setting
from auth import (
get_password_hash,
create_access_token,
authenticate_user,
get_current_user,
)
app = FastAPI() app = FastAPI()
# JWT settings
SECRET_KEY = os.getenv("SECRET_KEY", "")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
if SECRET_KEY == "":
raise ValueError("SECRET_KEY environment variable is not set")
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
ensure_default_setting()
# Update cors # Update cors
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -19,6 +52,22 @@ app.add_middleware(
) )
# User registration/login models
class UserCreate(BaseModel):
username: str
password: str
class UserResponse(BaseModel):
id: int
username: str
class Token(BaseModel):
access_token: str
token_type: str
# Define Pydantic models # Define Pydantic models
class ScriptBase(BaseModel): class ScriptBase(BaseModel):
name: str name: str
@@ -52,11 +101,340 @@ def hello():
return {"message": "Welcome to the Project Monitor API"} return {"message": "Welcome to the Project Monitor API"}
# User Management Endpoints
@app.get("/users", response_model=list[UserResponse])
def list_users(current_user: User = Depends(get_current_user)):
db = SessionLocal()
users = db.query(User).all()
db.close()
return users
@app.put("/users/{user_id}")
def update_user(
user_id: int,
user: UserCreate,
current_user: User = Depends(get_current_user),
):
db = SessionLocal()
existing_user = db.query(User).filter(User.id == user_id).first()
if not existing_user:
db.close()
raise HTTPException(status_code=404, detail="User not found")
existing_user.username = user.username
existing_user.password_hash = get_password_hash(user.password)
db.commit()
db.refresh(existing_user)
db.close()
return {"message": "User updated successfully"}
@app.delete("/users/{user_id}")
def delete_user(user_id: int, current_user: User = Depends(get_current_user)):
db = SessionLocal()
user = db.query(User).filter(User.id == user_id).first()
if not user:
db.close()
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
db.commit()
db.close()
return {"message": "User deleted successfully"}
@app.post("/register", response_model=Token)
def register(user: UserCreate):
db = SessionLocal()
existing_user = db.query(User).filter(User.username == user.username).first()
if existing_user:
db.close()
raise HTTPException(status_code=400, detail="Username already registered")
hashed_password = get_password_hash(user.password)
new_user = User(username=user.username, password_hash=hashed_password)
db.add(new_user)
db.commit()
db.refresh(new_user)
access_token = create_access_token(data={"sub": new_user.username})
db.close()
return {"access_token": access_token, "token_type": "bearer"}
@app.post("/login", response_model=Token)
def login(form_data: OAuth2PasswordRequestForm = Depends()):
db = SessionLocal()
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
db.close()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(data={"sub": user.username})
db.close()
return {"access_token": access_token, "token_type": "bearer"}
class SubscriptionCreate(BaseModel):
topic: str
class SubscriptionResponse(BaseModel):
id: int
topic: str
created_at: datetime
has_unread: bool
model_config = {"from_attributes": True}
# Subscriptions API Endpoints
@app.get("/subscriptions", response_model=list[SubscriptionResponse])
def list_subscriptions(current_user: User = Depends(get_current_user)):
db = SessionLocal()
subscriptions = (
db.query(Subscription).filter(Subscription.user_id == current_user.id).all()
)
# TODO: find a better way to do this
for subscription in subscriptions:
not_viewed_count = (
db.query(Notification)
.filter(
Notification.subscription_id == subscription.id,
~Notification.viewed,
)
.count()
)
subscription.has_unread = not_viewed_count > 0
db.close()
return subscriptions
@app.get("/subscriptions/{subscription_id}", response_model=SubscriptionResponse)
def get_subscription(
subscription_id: int, current_user: User = Depends(get_current_user)
):
db = SessionLocal()
subscription = (
db.query(Subscription).filter(Subscription.id == subscription_id).first()
)
if not subscription:
db.close()
raise HTTPException(status_code=404, detail="Subscription not found")
# checking if subscription has unread messages
subscription.has_unread = (
db.query(Notification)
.filter(
Notification.subscription_id == subscription_id and not Notification.viewed
)
.count()
> 0
)
db.close()
return subscription
@app.post("/subscriptions")
def add_subscription(
subscription: SubscriptionCreate, current_user: User = Depends(get_current_user)
):
db = SessionLocal()
existing_subscription = (
db.query(Subscription).filter(Subscription.topic == subscription.topic).first()
)
if existing_subscription:
db.close()
raise HTTPException(status_code=400, detail="Subscription already exists")
new_subscription = Subscription(topic=subscription.topic, user_id=current_user.id)
db.add(new_subscription)
db.commit()
db.refresh(new_subscription)
db.close()
return new_subscription
@app.delete("/subscriptions/{subscription_id}")
def remove_subscription(
subscription_id: int, current_user: User = Depends(get_current_user)
):
db = SessionLocal()
subscription = (
db.query(Subscription).filter(Subscription.id == subscription_id).first()
)
if not subscription:
db.close()
raise HTTPException(status_code=404, detail="Subscription not found")
db.delete(subscription)
db.commit()
db.close()
return {"message": "Subscription removed"}
@app.get("/subscriptions/{subscription_id}/notifications")
def list_subscription_notifications(
subscription_id: int,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
current_user: User = Depends(get_current_user),
):
db = SessionLocal()
notifications = (
db.query(Notification)
.filter(Notification.subscription_id == subscription_id)
.order_by(Notification.created_at.desc())
.limit(limit)
.offset(offset)
.all()
)
db.close()
return [
NotificationResponse.model_validate(notification)
for notification in notifications
]
@app.post("/subscriptions/{subscription_id}/notifications")
def set_all_notifications_viewed(
subscription_id: int,
current_user: User = Depends(get_current_user),
):
db = SessionLocal()
notifications = (
db.query(Notification)
.filter(Notification.subscription_id == subscription_id)
.all()
)
for notification in notifications:
notification.viewed = True
db.commit()
db.close()
return {"message": "Notifications marked as viewed"}
@app.delete("/subscriptions/{subscription_id}/notifications")
def remove_subscription_notifications(
subscription_id: int, current_user: User = Depends(get_current_user)
):
db = SessionLocal()
notifications = (
db.query(Notification)
.filter(Notification.subscription_id == subscription_id)
.all()
)
for notification in notifications:
db.delete(notification)
db.commit()
db.close()
return {"message": "Notifications removed"}
@app.get("/notifications")
def list_notifications(current_user: User = Depends(get_current_user)):
db = SessionLocal()
notifications = db.query(Notification).all()
db.close()
return [
NotificationResponse.model_validate(notification)
for notification in notifications
]
@app.delete("/notifications/{notification_id}")
def remove_notification(
notification_id: int, current_user: User = Depends(get_current_user)
):
db = SessionLocal()
notification = (
db.query(Notification).filter(Notification.id == notification_id).first()
)
if not notification:
db.close()
raise HTTPException(status_code=404, detail="Notification not found")
db.delete(notification)
db.commit()
db.close()
return {"message": "Notification removed"}
class NotificationCreate(BaseModel):
subscription_id: int
title: str
message: str
priority: int
class NotificationUpdate(BaseModel):
subscription_id: int | None = None
title: str | None = None
message: str | None = None
priority: int | None = None
viewed: bool | None = None
class NotificationResponse(NotificationCreate):
id: int
created_at: datetime
viewed: bool
model_config = {"from_attributes": True}
@app.put("/notifications/{notification_id}", response_model=NotificationResponse)
def update_notification(
notification_id: int,
notification: NotificationUpdate,
current_user: User = Depends(get_current_user),
):
db = SessionLocal()
existing_notification = (
db.query(Notification).filter(Notification.id == notification_id).first()
)
if not existing_notification:
db.close()
raise HTTPException(status_code=404, detail="Notification not found")
if notification.subscription_id is not None:
existing_notification.subscription_id = notification.subscription_id
if notification.title is not None:
existing_notification.title = notification.title
if notification.message is not None:
existing_notification.message = notification.message
if notification.priority is not None:
existing_notification.priority = notification.priority
if notification.viewed is not None:
existing_notification.viewed = notification.viewed
db.commit()
db.refresh(existing_notification)
db.close()
return existing_notification
@app.post("/notifications", response_model=NotificationResponse)
def create_notification(
notification: NotificationCreate, current_user: User = Depends(get_current_user)
):
db = SessionLocal()
new_notification = Notification(
subscription_id=notification.subscription_id,
title=notification.title,
message=notification.message,
priority=notification.priority,
)
db.add(new_notification)
db.commit()
db.refresh(new_notification)
db.close()
return new_notification
# Define Pydantic models for Settings # Define Pydantic models for Settings
class SettingsBase(BaseModel): class SettingsBase(BaseModel):
requirements: str requirements: str
environment: str environment: str
user: str ntfy_url: str
class SettingsUpdate(SettingsBase): class SettingsUpdate(SettingsBase):
@@ -70,18 +448,39 @@ class SettingsResponse(SettingsBase):
# Settings API Endpoints # Settings API Endpoints
@app.get("/settings", response_model=list[SettingsResponse]) @app.get("/settings", response_model=SettingsResponse)
def read_settings(): def read_settings(current_user: User = Depends(get_current_user)):
db = SessionLocal() db = SessionLocal()
settings = db.query(Settings).all() settings = db.query(Settings).filter(Settings.user_id == current_user.id).all()
if not settings:
# Add a default settings row for this user if not found
new_setting = Settings(
requirements="",
environment="",
user_id=current_user.id,
ntfy_url="https://ntfy.abzk.fr",
)
db.add(new_setting)
db.commit()
db.refresh(new_setting)
db.close()
return new_setting
if len(settings) > 1:
raise HTTPException(status_code=400, detail="Multiple settings found")
settings = settings[0]
db.close() db.close()
return settings return settings
@app.post("/settings", response_model=SettingsResponse) @app.post("/settings", response_model=SettingsResponse)
def create_setting(settings: SettingsBase): def create_setting(
settings: SettingsBase, current_user: User = Depends(get_current_user)
):
db = SessionLocal() db = SessionLocal()
new_setting = Settings(**settings.model_dump()) new_setting = Settings(**settings.model_dump(), user_id=current_user.id)
db.add(new_setting) db.add(new_setting)
db.commit() db.commit()
db.refresh(new_setting) db.refresh(new_setting)
@@ -91,9 +490,13 @@ def create_setting(settings: SettingsBase):
@app.get("/settings/{settings_id}", response_model=SettingsResponse) @app.get("/settings/{settings_id}", response_model=SettingsResponse)
def read_setting(settings_id: int): def read_setting(settings_id: int, current_user: User = Depends(get_current_user)):
db = SessionLocal() db = SessionLocal()
setting = db.query(Settings).filter(Settings.id == settings_id).first() setting = (
db.query(Settings)
.filter(Settings.id == settings_id, Settings.user_id == current_user.id)
.first()
)
db.close() db.close()
if not setting: if not setting:
raise HTTPException(status_code=404, detail="Setting not found") raise HTTPException(status_code=404, detail="Setting not found")
@@ -101,20 +504,31 @@ def read_setting(settings_id: int):
@app.put("/settings/{settings_id}", response_model=SettingsResponse) @app.put("/settings/{settings_id}", response_model=SettingsResponse)
def update_setting(settings_id: int, settings: SettingsUpdate): def update_setting(
settings_id: int,
settings: SettingsUpdate,
current_user: User = Depends(get_current_user),
):
db = SessionLocal() db = SessionLocal()
existing_setting = db.query(Settings).filter(Settings.id == settings_id).first() existing_setting = (
db.query(Settings)
.filter(Settings.id == settings_id, Settings.user_id == current_user.id)
.first()
)
if not existing_setting: if not existing_setting:
raise HTTPException(status_code=404, detail="Setting not found") raise HTTPException(status_code=404, detail="Setting not found")
if existing_setting.requirements != settings.requirements: if settings.requirements and existing_setting.requirements != settings.requirements:
existing_setting.requirements = settings.requirements existing_setting.requirements = settings.requirements
update_requirements(settings) update_requirements(settings)
if existing_setting.environment != settings.environment: if settings.environment and existing_setting.environment != settings.environment:
existing_setting.environment = settings.environment existing_setting.environment = settings.environment
update_environment(settings) update_environment(settings)
if settings.ntfy_url is not None:
existing_setting.ntfy_url = settings.ntfy_url
db.commit() db.commit()
db.refresh(existing_setting) db.refresh(existing_setting)
db.close() db.close()
@@ -123,17 +537,19 @@ def update_setting(settings_id: int, settings: SettingsUpdate):
@app.get("/script", response_model=list[ScriptResponse]) @app.get("/script", response_model=list[ScriptResponse])
def read_scripts(): def read_scripts(current_user: User = Depends(get_current_user)):
db = SessionLocal() db = SessionLocal()
scripts = db.query(Script).all() scripts = db.query(Script).filter(Script.user_id == current_user.id).all()
db.close() db.close()
return scripts return scripts
@app.post("/script", response_model=ScriptResponse) @app.post("/script", response_model=ScriptResponse)
def create_script(script: ScriptCreate): def create_script(script: ScriptCreate, current_user: User = Depends(get_current_user)):
db = SessionLocal() db = SessionLocal()
new_script = Script(name=script.name, script_content=script.script_content) new_script = Script(
name=script.name, script_content=script.script_content, user_id=current_user.id
)
db.add(new_script) db.add(new_script)
db.commit() db.commit()
db.refresh(new_script) db.refresh(new_script)
@@ -142,7 +558,7 @@ def create_script(script: ScriptCreate):
@app.get("/script/{script_id}", response_model=ScriptResponse) @app.get("/script/{script_id}", response_model=ScriptResponse)
def read_script(script_id: int): def read_script(script_id: int, current_user: User = Depends(get_current_user)):
db = SessionLocal() db = SessionLocal()
script = db.query(Script).filter(Script.id == script_id).first() script = db.query(Script).filter(Script.id == script_id).first()
db.close() db.close()
@@ -152,7 +568,7 @@ def read_script(script_id: int):
@app.delete("/script/{script_id}") @app.delete("/script/{script_id}")
def delete_script(script_id: int): def delete_script(script_id: int, current_user: User = Depends(get_current_user)):
db = SessionLocal() db = SessionLocal()
script = db.query(Script).filter(Script.id == script_id).first() script = db.query(Script).filter(Script.id == script_id).first()
if not script: if not script:
@@ -168,7 +584,9 @@ def delete_script(script_id: int):
@app.put("/script/{script_id}", response_model=ScriptResponse) @app.put("/script/{script_id}", response_model=ScriptResponse)
def update_script(script_id: int, script: ScriptUpdate): def update_script(
script_id: int, script: ScriptUpdate, current_user: User = Depends(get_current_user)
):
db = SessionLocal() db = SessionLocal()
existing_script = db.query(Script).filter(Script.id == script_id).first() existing_script = db.query(Script).filter(Script.id == script_id).first()
if not existing_script: if not existing_script:
@@ -184,7 +602,7 @@ def update_script(script_id: int, script: ScriptUpdate):
@app.get("/script/{script_id}/log") @app.get("/script/{script_id}/log")
def get_script_logs(script_id: int): def get_script_logs(script_id: int, current_user: User = Depends(get_current_user)):
db = SessionLocal() db = SessionLocal()
logs = db.query(Log).filter(Log.script_id == script_id).all() logs = db.query(Log).filter(Log.script_id == script_id).all()
db.close() db.close()
@@ -192,7 +610,9 @@ def get_script_logs(script_id: int):
@app.post("/script/{script_id}/log") @app.post("/script/{script_id}/log")
def create_script_log(script_id: int, log: ScriptLogCreate): def create_script_log(
script_id: int, log: ScriptLogCreate, current_user: User = Depends(get_current_user)
):
db = SessionLocal() db = SessionLocal()
new_log = Log( new_log = Log(
script_id=script_id, script_id=script_id,
@@ -208,7 +628,9 @@ def create_script_log(script_id: int, log: ScriptLogCreate):
@app.delete("/script/{script_id}/log/{log_id}") @app.delete("/script/{script_id}/log/{log_id}")
def delete_script_log(script_id: int, log_id: int): def delete_script_log(
script_id: int, log_id: int, current_user: User = Depends(get_current_user)
):
db = SessionLocal() db = SessionLocal()
log = db.query(Log).filter(Log.id == log_id and Log.script_id == script_id).first() log = db.query(Log).filter(Log.id == log_id and Log.script_id == script_id).first()
if not log: if not log:
@@ -220,7 +642,7 @@ def delete_script_log(script_id: int, log_id: int):
@app.post("/script/{script_id}/execute") @app.post("/script/{script_id}/execute")
def execute_script(script_id: int): def execute_script(script_id: int, current_user: User = Depends(get_current_user)):
run_scripts([script_id]) run_scripts([script_id])
return {"run_script": True} return {"run_script": True}

View File

@@ -0,0 +1,113 @@
import os
import requests
from datetime import datetime
from model import SessionLocal, Subscription, Settings, Notification
import json
# Constants
NTFY_TOKEN = os.getenv("NTFY_TOKEN")
def fetch_ntfy_notifications(base_url, subscriptions):
"""Fetch notifications from the ntfy.sh server for the given subscriptions using streaming."""
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {NTFY_TOKEN}" if NTFY_TOKEN else None,
}
notifications = []
for subscription in subscriptions:
print(f"Fetching notifications for {subscription.topic}")
topic = subscription.topic
last_message_id = subscription.last_message_id
since_param = "all" if last_message_id is None else last_message_id
url = f"{base_url}/{topic}/json?poll=1&since={since_param}"
response = requests.get(url, headers=headers, stream=True)
response.raise_for_status()
for line in response.iter_lines():
if line:
notification = json.loads(line)
if notification.get("event") == "message":
notifications.append(notification)
print(f"Fetched {len(notifications)} notifications")
return notifications
def save_notifications_to_db(notifications, topic_to_subscription, db):
"""Save the fetched notifications to the database and update last_message_id."""
last_message_ids = {}
for notification in notifications:
topic = notification["topic"]
last_message_ids[topic] = notification["id"]
subscription_id = topic_to_subscription.get(notification["topic"])
if subscription_id:
new_notification = Notification(
title=notification.get("title", "No Title"),
message=notification.get("message", ""),
priority=notification.get("priority", 3),
created_at=datetime.fromtimestamp(notification["time"]),
subscription_id=subscription_id,
)
db.add(new_notification)
for topic, message_id in last_message_ids.items():
subscription_id = topic_to_subscription.get(topic)
if subscription_id:
subscription = (
db.query(Subscription)
.filter(Subscription.id == subscription_id)
.first()
)
if subscription:
subscription.last_message_id = message_id
db.commit()
def process_user_notifications(user_settings, db):
"""Process notifications for a specific user's subscriptions."""
ntfy_url = user_settings.ntfy_url
if not ntfy_url:
print(f"Ntfy URL not found for user ID {user_settings.user_id}. Skipping...")
return
# Get all subscriptions for the user
subscriptions = (
db.query(Subscription)
.filter(Subscription.user_id == user_settings.user_id)
.all()
)
topic_to_subscription = {
subscription.topic: subscription.id for subscription in subscriptions
}
# Fetch notifications from ntfy.sh
notifications = fetch_ntfy_notifications(ntfy_url, subscriptions)
# Save notifications to the database
save_notifications_to_db(notifications, topic_to_subscription, db)
def main():
"""Main function to fetch and save notifications for all users."""
db = SessionLocal()
# Get all user settings
user_settings_list = db.query(Settings).all()
if not user_settings_list:
print("No user settings found.")
return
# Process notifications for each user
for user_settings in user_settings_list:
print(f"Processing notifications for user ID {user_settings.user_id}")
process_user_notifications(user_settings, db)
db.close()
if __name__ == "__main__":
main()

View File

@@ -1,12 +1,18 @@
from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey, Boolean
from sqlalchemy.sql.sqltypes import DateTime
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.sql.functions import func from sqlalchemy.sql.functions import func
from sqlalchemy.sql.sqltypes import DateTime from sqlalchemy.sql.sqltypes import DateTime
from sqlalchemy.types import Boolean import os
import secrets
from passlib.context import CryptContext
# Initialize the database # Initialize the database
DATABASE_URL = "sqlite:///./project_monitor.db" DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
raise ValueError("DATABASE_URL environment variable is not set")
# SQLAlchemy setup # SQLAlchemy setup
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
@@ -14,7 +20,15 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() Base = declarative_base()
# Define the table model class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(64), unique=True, nullable=False, index=True)
password_hash = Column(String(128), nullable=False)
created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class Script(Base): class Script(Base):
@@ -27,6 +41,9 @@ class Script(Base):
created_at = Column( created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
user_id = Column(
Integer, ForeignKey("users.id", name="fk_script_user_id"), nullable=False
)
class Log(Base): class Log(Base):
@@ -40,7 +57,9 @@ class Log(Base):
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
script_id = Column(Integer, ForeignKey("scripts.id"), nullable=False) script_id = Column(
Integer, ForeignKey("scripts.id", name="fk_log_script_id"), nullable=False
)
class Settings(Base): class Settings(Base):
@@ -49,8 +68,83 @@ class Settings(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
requirements = Column(String, nullable=False) requirements = Column(String, nullable=False)
environment = Column(String, nullable=False) environment = Column(String, nullable=False)
user = Column(String, nullable=False) ntfy_url = Column(String, nullable=True)
user_id = Column(
Integer, ForeignKey("users.id", name="fk_user_settings_user_id"), nullable=False
)
class Subscription(Base):
__tablename__ = "subscriptions"
id = Column(Integer, primary_key=True, index=True)
topic = Column(String, nullable=False, unique=True)
last_message_id = Column(String, nullable=True)
created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
user_id = Column(
Integer, ForeignKey("users.id", name="fk_subscription_user_id"), nullable=False
)
class Notification(Base):
__tablename__ = "notifications"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
message = Column(Text, nullable=False)
priority = Column(Integer, nullable=False, default=3)
viewed = Column(Boolean, default=False)
sent = Column(Boolean, default=False)
subscription_id = Column(
Integer,
ForeignKey("subscriptions.id", name="fk_notification_subscription_id"),
nullable=False,
)
created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Create the database tables # Create the database tables
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Ensure a default admin user exists
def ensure_default_setting():
db = SessionLocal()
admin_user = db.query(User).filter(User.username == "admin").first()
if not admin_user:
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
random_password = secrets.token_urlsafe(12)
password_hash = pwd_context.hash(random_password)
admin_user = User(username="admin", password_hash=password_hash)
db.add(admin_user)
db.commit()
print(
f"Default admin user created. Username: admin, Password: {random_password}"
)
# Refresh to get admin_user.id
db.refresh(admin_user)
# Set all rows with null user_id in Script and Subscription to admin user id
db.query(Script).filter(Script.user_id is None).update({"user_id": admin_user.id})
db.query(Subscription).filter(Subscription.user_id is None).update(
{"user_id": admin_user.id}
)
db.commit()
default_setting = (
db.query(Settings).filter(Settings.user_id == admin_user.id).first()
)
if not default_setting:
new_setting = Settings(
requirements="",
environment="",
user_id=admin_user.id,
ntfy_url="https://ntfy.abzk.fr",
)
db.add(new_setting)
db.commit()
db.close()

9
backend/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
requests
uvicorn
fastapi
sqlalchemy
alembic
passlib
python-jose
argon2_cffi
python-multipart

25
docker/entry.sh Normal file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
printenv | while read line; do echo "export $line"; done > /etc/container_environment
source /app/backend/venv/bin/activate
# Navigate to the frontend directory, install dependencies, and start the Svelte app
cd frontend
npm run dev -- --host 0.0.0.0 --port 8080 &
# Navigate back to the root directory
cd ..
# Setup exec venv
cd backend/exec_folder
python3 -m venv venv
cd ../..
# Start the backend using uvicorn
cd backend
alembic upgrade head
uvicorn backend:app --host 0.0.0.0 --port 8000 &
cd ..
# Start the cron daemon
cron -f

2
docker/services.cron Normal file
View File

@@ -0,0 +1,2 @@
*/1 * * * * . /etc/container_environment && cd /app/backend && . /app/backend/venv/bin/activate && python3 /app/backend/get_notifications.py >> /var/log/cron.log 2>&1
*/5 * * * * . /etc/container_environment && cd /app/backend && . /app/backend/venv/bin/activate && python3 /app/backend/run_scripts.py >> /var/log/cron.log 2>&1

View File

@@ -24,7 +24,8 @@ export default defineConfig(
rules: { rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off' 'no-undef': 'off',
'svelte/no-navigation-without-resolve': 'off'
} }
}, },
{ {

View File

@@ -1,4 +1,91 @@
export const API_URL = 'http://127.0.0.1:8000'; import { env } from '$env/dynamic/public';
export interface User {
id: number;
username: string;
password: string | null;
}
export const API_URL = env.PUBLIC_API_URL || 'http://localhost:8000';
// Helper to get token from localStorage
export function getToken(): string | null {
const tokenData = localStorage.getItem('token');
if (tokenData) {
const { value, expiresAt } = JSON.parse(tokenData);
if (Date.now() > expiresAt) {
localStorage.removeItem('token');
location.reload();
return null;
}
return value;
}
return null;
}
// Helper to add Authorization header if token exists
export function authHeaders(headers: Record<string, string> = {}): Record<string, string> {
const token = getToken();
return token ? { ...headers, Authorization: `Bearer ${token}` } : headers;
}
/**
* Login and Register API
*/
export interface AuthResponse {
access_token: string;
token_type: string;
}
export async function login(username: string, password: string): Promise<AuthResponse> {
const form = new FormData();
form.append('username', username);
form.append('password', password);
const response = await fetch(`${API_URL}/login`, {
method: 'POST',
body: form
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || 'Login failed');
}
return response.json();
}
export async function register(username: string, password: string): Promise<AuthResponse> {
const response = await fetch(`${API_URL}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || 'Registration failed');
}
return response.json();
}
/**
* Type definitions for Subscriptions and Notifications
*/
export interface Subscription {
id: number;
topic: string;
created_at: string;
has_unread: boolean;
}
export interface Notification {
id: number;
subscription_id: number;
title: string;
message: string;
priority: number;
created_at: string;
viewed: boolean;
}
/** /**
* Type definitions for Settings * Type definitions for Settings
@@ -8,6 +95,7 @@ export interface Settings {
requirements: string; requirements: string;
environment: string; environment: string;
user: string; user: string;
ntfy_url?: string;
} }
export async function checkHealth(): Promise<'healthy' | 'unhealthy'> { export async function checkHealth(): Promise<'healthy' | 'unhealthy'> {
@@ -44,7 +132,9 @@ export interface Script {
// Fetch all scripts // Fetch all scripts
export async function fetchScripts(): Promise<Script[]> { export async function fetchScripts(): Promise<Script[]> {
const response = await fetch(`${API_URL}/script`); const response = await fetch(`${API_URL}/script`, {
headers: authHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch scripts ' + response.statusText); throw new Error('Failed to fetch scripts ' + response.statusText);
} }
@@ -57,9 +147,7 @@ export async function addScript(
): Promise<Script> { ): Promise<Script> {
const response = await fetch(`${API_URL}/script`, { const response = await fetch(`${API_URL}/script`, {
method: 'POST', method: 'POST',
headers: { headers: authHeaders({ 'Content-Type': 'application/json' }),
'Content-Type': 'application/json'
},
body: JSON.stringify(script) body: JSON.stringify(script)
}); });
if (!response.ok) { if (!response.ok) {
@@ -69,8 +157,10 @@ export async function addScript(
} }
// Fetch all settings // Fetch all settings
export async function fetchSettings(): Promise<Settings[]> { export async function fetchUserSettings(): Promise<Settings> {
const response = await fetch(`${API_URL}/settings`); const response = await fetch(`${API_URL}/settings`, {
headers: authHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch settings ' + response.statusText); throw new Error('Failed to fetch settings ' + response.statusText);
} }
@@ -79,7 +169,9 @@ export async function fetchSettings(): Promise<Settings[]> {
// Fetch a single setting by ID // Fetch a single setting by ID
export async function fetchSettingById(id: number): Promise<Settings> { export async function fetchSettingById(id: number): Promise<Settings> {
const response = await fetch(`${API_URL}/settings/${id}`); const response = await fetch(`${API_URL}/settings/${id}`, {
headers: authHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch setting'); throw new Error('Failed to fetch setting');
} }
@@ -93,10 +185,8 @@ export async function updateSetting(
): Promise<Settings> { ): Promise<Settings> {
const response = await fetch(`${API_URL}/settings/${id}`, { const response = await fetch(`${API_URL}/settings/${id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: authHeaders({ 'Content-Type': 'application/json' }),
'Content-Type': 'application/json' body: JSON.stringify({ ...updatedSetting, ntfy_url: updatedSetting.ntfy_url })
},
body: JSON.stringify(updatedSetting)
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to update setting'); throw new Error('Failed to update setting');
@@ -104,9 +194,189 @@ export async function updateSetting(
return response.json(); return response.json();
} }
// Fetch all subscriptions
export async function fetchSubscriptions(): Promise<Subscription[]> {
const response = await fetch(`${API_URL}/subscriptions`, {
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch subscriptions');
}
return response.json();
}
// Fetch subscriptions by topic
export async function getSubscription(topic_id: string): Promise<Subscription> {
const response = await fetch(`${API_URL}/subscriptions/${topic_id}`, {
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch subscriptions');
}
return response.json();
}
// Add a new notification to a subscription
export async function addNotification(
subscriptionId: number,
title: string,
message: string,
priority: number
): Promise<Notification> {
const response = await fetch(`${API_URL}/notifications`, {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ subscription_id: subscriptionId, title, message, priority })
});
if (!response.ok) {
throw new Error('Failed to add notification');
}
return response.json();
}
// Mark a notification as viewed
export async function setViewed(notificationId: number): Promise<Notification> {
const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ viewed: true })
});
if (!response.ok) {
throw new Error('Failed to set notification as viewed');
}
return response.json();
}
// Add a new subscription
export async function addSubscription(topic: string): Promise<Subscription> {
const response = await fetch(`${API_URL}/subscriptions`, {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ topic })
});
if (!response.ok) {
throw new Error('Failed to add subscription');
}
return response.json();
}
// Add a new notification to a specific subscription
export async function markAllNotificationsAsViewed(subscriptionId: number): Promise<void> {
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`, {
method: 'POST',
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to mark all notifications as viewed for subscription');
}
}
// Delete all notifications for a specific subscription
export async function deleteSubscriptionNotifications(subscriptionId: number): Promise<void> {
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`, {
method: 'DELETE',
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to delete notifications for subscription');
}
}
// Delete a subscription
export async function deleteSubscription(subscriptionId: number): Promise<void> {
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}`, {
method: 'DELETE',
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to delete subscription');
}
}
// Fetch all users
export async function fetchUsers(): Promise<User[]> {
const response = await fetch(`${API_URL}/users`, {
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch users');
}
return response.json();
}
// Update a user
export async function updateUser(
userId: number,
username: string,
password: string
): Promise<void> {
const response = await fetch(`${API_URL}/users/${userId}`, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ username, password })
});
if (!response.ok) {
throw new Error('Failed to update user');
}
}
// Delete a user
export async function deleteUser(userId: number): Promise<void> {
const response = await fetch(`${API_URL}/users/${userId}`, {
method: 'DELETE',
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to delete user');
}
}
// Get subscription notifications with pagination
export async function fetchSubscriptionNotifications(
subscriptionId: string,
limit: number = 20,
offset: number = 0
): Promise<Notification[]> {
const response = await fetch(
`${API_URL}/subscriptions/${subscriptionId}/notifications?limit=${limit}&offset=${offset}`,
{
headers: authHeaders()
}
);
if (!response.ok) {
throw new Error('Failed to fetch subscription notifications');
}
return response.json();
}
// Fetch all notifications or filter by topic
export async function fetchAllNotifications(): Promise<Notification[]> {
const url = `${API_URL}/notifications`;
const response = await fetch(url, {
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch notifications');
}
return response.json();
}
// Delete a notification
export async function deleteNotification(notificationId: number): Promise<void> {
const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
method: 'DELETE',
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to delete notification');
}
}
// Fetch a single script by ID // Fetch a single script by ID
export async function fetchScriptById(id: number): Promise<Script> { export async function fetchScriptById(id: number): Promise<Script> {
const response = await fetch(`${API_URL}/script/${id}`); const response = await fetch(`${API_URL}/script/${id}`, {
headers: authHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch script'); throw new Error('Failed to fetch script');
} }
@@ -117,9 +387,7 @@ export async function fetchScriptById(id: number): Promise<Script> {
export async function updateScript(id: number, updatedScript: Partial<Script>): Promise<Script> { export async function updateScript(id: number, updatedScript: Partial<Script>): Promise<Script> {
const response = await fetch(`${API_URL}/script/${id}`, { const response = await fetch(`${API_URL}/script/${id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: authHeaders({ 'Content-Type': 'application/json' }),
'Content-Type': 'application/json'
},
body: JSON.stringify(updatedScript) body: JSON.stringify(updatedScript)
}); });
if (!response.ok) { if (!response.ok) {
@@ -130,7 +398,9 @@ export async function updateScript(id: number, updatedScript: Partial<Script>):
// Fetch logs for a specific script // Fetch logs for a specific script
export async function fetchLogs(scriptId: number): Promise<Log[]> { export async function fetchLogs(scriptId: number): Promise<Log[]> {
const response = await fetch(`${API_URL}/script/${scriptId}/log`); const response = await fetch(`${API_URL}/script/${scriptId}/log`, {
headers: authHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch logs'); throw new Error('Failed to fetch logs');
} }
@@ -141,9 +411,7 @@ export async function fetchLogs(scriptId: number): Promise<Log[]> {
export async function addLog(scriptId: number, log: Log): Promise<Log> { export async function addLog(scriptId: number, log: Log): Promise<Log> {
const response = await fetch(`${API_URL}/script/${scriptId}/log`, { const response = await fetch(`${API_URL}/script/${scriptId}/log`, {
method: 'POST', method: 'POST',
headers: { headers: authHeaders({ 'Content-Type': 'application/json' }),
'Content-Type': 'application/json'
},
body: JSON.stringify(log) body: JSON.stringify(log)
}); });
if (!response.ok) { if (!response.ok) {
@@ -155,7 +423,8 @@ export async function addLog(scriptId: number, log: Log): Promise<Log> {
// Execute a script by ID // Execute a script by ID
export async function executeScript(scriptId: number): Promise<{ message: string }> { export async function executeScript(scriptId: number): Promise<{ message: string }> {
const response = await fetch(`${API_URL}/script/${scriptId}/execute`, { const response = await fetch(`${API_URL}/script/${scriptId}/execute`, {
method: 'POST' method: 'POST',
headers: authHeaders()
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to execute script'); throw new Error('Failed to execute script');
@@ -166,7 +435,8 @@ export async function executeScript(scriptId: number): Promise<{ message: string
// Delete a log from a specific script // Delete a log from a specific script
export async function deleteLog(scriptId: number, logId: number): Promise<void> { export async function deleteLog(scriptId: number, logId: number): Promise<void> {
const response = await fetch(`${API_URL}/script/${scriptId}/log/${logId}`, { const response = await fetch(`${API_URL}/script/${scriptId}/log/${logId}`, {
method: 'DELETE' method: 'DELETE',
headers: authHeaders()
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to delete log'); throw new Error('Failed to delete log');
@@ -177,7 +447,8 @@ export async function deleteLog(scriptId: number, logId: number): Promise<void>
// Delete a script // Delete a script
export async function deleteScript(id: number): Promise<void> { export async function deleteScript(id: number): Promise<void> {
const response = await fetch(`${API_URL}/script/${id}`, { const response = await fetch(`${API_URL}/script/${id}`, {
method: 'DELETE' method: 'DELETE',
headers: authHeaders()
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to delete script'); throw new Error('Failed to delete script');

View File

@@ -4,6 +4,14 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { checkHealth } from '$lib/api'; import { checkHealth } from '$lib/api';
import { goto } from '$app/navigation';
import { page } from '$app/state';
let notifications: Notification[] = $state([]);
let notificationId = 0;
let healthStatus = writable<'healthy' | 'unhealthy'>('unhealthy');
let isAuthenticated = $state(false);
let { children } = $props(); let { children } = $props();
interface Notification { interface Notification {
@@ -12,10 +20,12 @@
message: string; message: string;
} }
let notifications: Notification[] = $state([]); function checkAuth() {
let notificationId = 0; if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem('token');
let healthStatus = writable<'healthy' | 'unhealthy'>('unhealthy'); isAuthenticated = !!token;
}
}
async function updateHealthStatus() { async function updateHealthStatus() {
const status = await checkHealth(); const status = await checkHealth();
@@ -30,10 +40,23 @@
}, 4000); }, 4000);
} }
function logout() {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('token');
isAuthenticated = false;
goto('/login');
}
}
onMount(() => { onMount(() => {
window.showNotification = showNotification; window.showNotification = showNotification;
updateHealthStatus(); updateHealthStatus();
setInterval(updateHealthStatus, 10000); // Check health every 10 seconds setInterval(updateHealthStatus, 10000); // Check health every 10 seconds
checkAuth();
// Redirect unauthenticated users to /login unless they're on /register or /login
if (!isAuthenticated && page.url.pathname !== '/register' && page.url.pathname !== '/login') {
goto('/login');
}
}); });
</script> </script>
@@ -41,17 +64,26 @@
<div class="container mx-auto flex justify-between items-center p-4"> <div class="container mx-auto flex justify-between items-center p-4">
<a href="/" class="text-2xl font-bold hover:text-gray-400">Project Monitor</a> <a href="/" class="text-2xl font-bold hover:text-gray-400">Project Monitor</a>
<div class="flex space-x-6"> <div class="flex space-x-6">
<a href="/" class="text-lg hover:text-gray-400">Home</a> {#if isAuthenticated}
<a href="/scripts" class="text-lg hover:text-gray-400">Scripts</a> <a href="/scripts" class="text-lg hover:text-gray-400">Scripts</a>
<a href="/notifications" class="text-lg hover:text-gray-400">Notifications</a>
<button class="text-lg hover:text-gray-400" onclick={logout}>Logout</button>
<a href="/settings" class="text-lg hover:text-gray-400"> <a href="/settings" class="text-lg hover:text-gray-400">
<Icon icon="material-symbols:settings" width="24" height="24" /> <Icon icon="material-symbols:settings" width="24" height="24" />
</a> </a>
{:else}
<a href="/login" class="text-lg hover:text-gray-400">Login</a>
<a href="/register" class="text-lg hover:text-gray-400">Register</a>
{/if}
</div> </div>
</div> </div>
</nav> </nav>
<div class="relative"> <div class="relative">
{#if isAuthenticated || page.url.pathname === '/login' || page.url.pathname === '/register'}
{@render children()} {@render children()}
{/if}
</div>
<div class="fixed bottom-4 right-4 space-y-2"> <div class="fixed bottom-4 right-4 space-y-2">
{#each notifications as notification (notification.id)} {#each notifications as notification (notification.id)}
@@ -64,7 +96,6 @@
</div> </div>
{/each} {/each}
</div> </div>
</div>
<div class="fixed bottom-4 left-4 group"> <div class="fixed bottom-4 left-4 group">
{#if $healthStatus === 'healthy'} {#if $healthStatus === 'healthy'}

View File

@@ -10,3 +10,11 @@
Go to Scripts Go to Scripts
</a> </a>
</div> </div>
<div class="flex justify-center mt-4">
<a
href="/notifications"
class="px-6 py-3 bg-green-500 text-white rounded-lg shadow-md hover:bg-green-600"
>
View Notifications
</a>
</div>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { API_URL } from '$lib/api';
// Login form state
let loginUsername = $state('');
let loginPassword = $state('');
let loginError: string | null = $state(null);
let loginLoading = $state(false);
async function handleLogin(e: Event) {
e.preventDefault();
loginError = null;
loginLoading = true;
try {
const form = new FormData();
form.append('username', loginUsername);
form.append('password', loginPassword);
const response = await fetch(`${API_URL}/login`, {
method: 'POST',
body: form
});
if (!response.ok) {
const data = await response.json();
loginError = data.detail || 'Login failed';
loginLoading = false;
return;
}
const data = await response.json();
const expirationTime = Date.now() + 60 * 60 * 1000; // 60 minutes in milliseconds
localStorage.setItem(
'token',
JSON.stringify({ value: data.access_token, expiresAt: expirationTime })
);
localStorage.setItem('username', loginUsername);
goto('/').then(() => location.reload());
} catch (err) {
loginError = 'Network error - ' + err;
} finally {
loginLoading = false;
}
}
</script>
<div class="flex flex-col items-center justify-center min-h-screen bg-gray-50">
<form
class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 w-full max-w-sm"
onsubmit={handleLogin}
>
<h2 class="text-2xl font-bold mb-6 text-center">Login</h2>
{#if loginError}
<div class="mb-4 text-red-600 text-sm">{loginError}</div>
{/if}
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="username"> Username </label>
<input
id="username"
type="text"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
bind:value={loginUsername}
required
autocomplete="username"
/>
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="password"> Password </label>
<input
id="password"
type="password"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
bind:value={loginPassword}
required
autocomplete="current-password"
/>
</div>
<div class="flex items-center justify-between">
<button
type="submit"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
disabled={loginLoading}
>
{loginLoading ? 'Logging in...' : 'Login'}
</button>
<a
href="/register"
class="inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800 ml-4"
>
Create an account
</a>
</div>
</form>
</div>

View File

@@ -0,0 +1,124 @@
<script lang="ts">
import { addSubscription, deleteSubscription, fetchSubscriptions } from '$lib/api';
import type { Subscription } from '$lib/api';
import { onMount } from 'svelte';
let subscriptions: Subscription[] = [];
let newTopic = '';
let loading: boolean = true;
let errorMsg: string | null = null;
onMount(async () => {
loading = true;
errorMsg = null;
try {
subscriptions = await fetchSubscriptions();
} catch (error) {
console.error('Failed to load subscriptions:', error);
errorMsg = 'Failed to load subscriptions';
subscriptions = [];
}
loading = false;
});
async function handleAddSubscription() {
if (!newTopic.trim()) {
window.showNotification('error', 'Topic name cannot be empty.');
return;
}
try {
await addSubscription(newTopic.trim());
newTopic = '';
window.showNotification('success', 'Subscription added successfully.');
subscriptions = await fetchSubscriptions();
} catch (error) {
window.showNotification('error', 'Failed to add subscription - ' + error);
}
}
async function handleDeleteSubscription(id: number) {
try {
await deleteSubscription(id);
subscriptions = await fetchSubscriptions();
} catch (error) {
window.showNotification('error', 'Failed to delete subscription - ' + error);
}
}
</script>
<main class="p-4">
<h1 class="text-2xl font-bold mb-4">Subscriptions</h1>
{#if loading}
<p>Loading...</p>
{:else if errorMsg}
<p class="text-red-500">{errorMsg}</p>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{#each subscriptions as subscription (subscription.id)}
<div
class="relative block p-4 border rounded bg-white hover:bg-gray-100 shadow-lg shadow-blue-500/50"
>
<!-- Red cross button for delete -->
<button
class="absolute top-2 right-2 w-6 h-6 flex items-center justify-center bg-transparent text-red-600 hover:text-red-800"
aria-label="Delete subscription"
on:click|stopPropagation={() => handleDeleteSubscription(subscription.id)}
tabindex="0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" />
<line x1="6" y1="18" x2="18" y2="6" stroke="currentColor" stroke-width="2" />
</svg>
</button>
<a
href={`/notifications/${subscription.id}`}
class="block"
style="text-decoration: none;"
>
{#if subscription.has_unread}
<span class="absolute top-2 left-2 w-3 h-3 bg-green-500 rounded-full"></span>
{/if}
<h2 class="text-lg font-semibold text-gray-800">{subscription.topic}</h2>
</a>
</div>
{/each}
</div>
{/if}
<div class="mt-8">
<h2 class="text-xl font-semibold mb-2">Add New Subscription</h2>
<form
on:submit|preventDefault={handleAddSubscription}
class="space-y-4 p-4 border rounded shadow"
>
<div>
<label for="newTopic" class="block text-sm font-medium">Topic</label>
<input
id="newTopic"
type="text"
bind:value={newTopic}
required
class="mt-1 block w-full p-2 border rounded"
/>
</div>
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded">
Add Subscription
</button>
</form>
</div>
</main>
<style>
main {
max-width: 800px;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,197 @@
<script lang="ts">
import {
deleteNotification,
setViewed,
fetchSubscriptionNotifications,
deleteSubscriptionNotifications,
markAllNotificationsAsViewed
} from '$lib/api';
import type { Notification, Subscription } from '$lib/api';
let { data } = $props();
let subscription: Subscription | null = $state(data.subscription);
let notifications: Notification[] = $state(data.notifications);
let selectedNotification: Notification | null = $state(null);
// Delete all notifications for this subscription
async function handleDeleteAllNotifications() {
if (notifications.length === 0) return;
const confirmed = window.confirm(
'Are you sure you want to delete all notifications for this subscription?'
);
if (!confirmed) return;
try {
deleteSubscriptionNotifications(subscription!.id);
notifications = [];
window.showNotification('success', 'All notifications deleted successfully.');
} catch (error) {
window.showNotification('error', 'Failed to delete all notifications - ' + error);
}
}
// Pagination state
let limit = 20;
let offset = $derived(notifications.length);
let loadingMore = $state(false);
let allLoaded = $derived(notifications.length < limit);
async function openNotificationPopup(notification: Notification) {
if (!notification.viewed) {
await setViewed(notification.id);
notifications = notifications.map((n) =>
n.id === notification.id ? { ...n, viewed: true } : n
);
notification.viewed = true;
}
selectedNotification = notification;
}
function closeNotificationPopup() {
selectedNotification = null;
}
async function handleDeleteNotification(e, id: number) {
e.stopPropagation();
try {
await deleteNotification(id);
notifications = notifications.filter((notification) => notification.id !== id);
window.showNotification('success', 'Notification deleted successfully.');
} catch (error) {
window.showNotification('error', 'Failed to delete notification - ' + error);
}
}
async function markAllViewed() {
try {
markAllNotificationsAsViewed(subscription!.id);
notifications = notifications.map((notification) =>
notification.viewed ? notification : { ...notification, viewed: true }
);
window.showNotification('success', 'All notifications marked as viewed.');
} catch (error) {
window.showNotification('error', 'Failed to mark all notifications as viewed.');
}
}
// Load more notifications
async function loadMoreNotifications() {
loadingMore = true;
try {
const more = await fetchSubscriptionNotifications(subscription!.id.toString(), limit, offset);
notifications = [...notifications, ...more];
offset += more.length;
if (more.length < limit) {
allLoaded = true;
}
} catch (err) {
window.showNotification('error', 'Failed to load more notifications - ' + err);
}
loadingMore = false;
}
</script>
<main class="p-4">
<h1 class="text-2xl font-bold mb-4">Notifications for {subscription!.topic}:</h1>
<div class="flex justify-between items-center mb-4">
<a href="/notifications" class="text-blue-500 hover:underline">← Return to Subscriptions</a>
<div class="flex gap-2">
<button class="bg-blue-500 text-white px-4 py-2 rounded" onclick={markAllViewed}>
Mark All Viewed
</button>
<button
class="bg-red-500 text-white px-4 py-2 rounded"
onclick={handleDeleteAllNotifications}
disabled={notifications.length === 0}
>
Delete All Notifications
</button>
</div>
</div>
{#if notifications.length === 0}
<p>No notifications found for this topic.</p>
{:else}
<ul class="space-y-4">
{#each notifications as notification (notification.id)}
<li
class="p-2 rounded flex justify-between items-center border-s-slate-400 border-1"
class:bg-green-200={notification.viewed}
>
<button class="p-2 w-full text-left" onclick={() => openNotificationPopup(notification)}>
<div>
<p class="font-semibold">{notification.title}</p>
<p>
{notification.message.split('\n')[0].length > 75
? `${notification.message.split('\n')[0].slice(0, 75)}...`
: notification.message.split('\n')[0]}
</p>
<p class="text-sm text-gray-500">
{new Date(notification.created_at).toLocaleString()}
</p>
</div>
</button>
<button
class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 block"
onclick={(e) => handleDeleteNotification(e, notification.id)}
>
Delete
</button>
</li>
{/each}
</ul>
{/if}
{#if !allLoaded && notifications.length > 0}
<div class="flex justify-center mt-6">
<button
class="bg-gray-200 px-4 py-2 rounded hover:bg-gray-300"
onclick={loadMoreNotifications}
disabled={loadingMore}
>
{loadingMore ? 'Loading...' : 'Load More'}
</button>
</div>
{/if}
{#if selectedNotification}
<div class="fixed inset-0 bg-opacity-30 backdrop-blur-sm flex items-center justify-center z-50">
<div class="bg-white p-6 rounded shadow-lg w-3/4 max-w-2xl max-h-[80vh] overflow-y-auto">
<h3 class="text-lg font-bold mb-4">Notification Details</h3>
<div class="mb-4">
<p class="font-semibold">Title:</p>
<pre
class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{selectedNotification.title}</pre>
</div>
<div class="mb-4">
<p class="font-semibold">Message:</p>
<pre
class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{selectedNotification.message}</pre>
</div>
<div class="mb-4">
<p class="font-semibold">Priority:</p>
<pre
class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{selectedNotification.priority}</pre>
</div>
<div class="mb-4">
<p class="font-semibold">Created At:</p>
<pre class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{new Date(
selectedNotification.created_at
).toLocaleString()}</pre>
</div>
<button
class="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
onclick={closeNotificationPopup}
>
Close
</button>
</div>
</div>
{/if}
</main>
<style>
main {
max-width: 800px;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,23 @@
import type { PageLoad } from './$types';
import { getSubscription, fetchSubscriptionNotifications } from '$lib/api';
export const load: PageLoad = async ({ params }) => {
if (import.meta.env.SSR) {
return {
subscription: null,
notifications: []
};
} else {
const subscription_id = params.subscription_id;
const subscription = await getSubscription(subscription_id);
const notifications = (await fetchSubscriptionNotifications(subscription_id)).sort(
(a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
);
return {
subscription: subscription,
notifications: notifications
};
}
};

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { API_URL } from '$lib/api';
let username = '';
let password = '';
let error: string | null = null;
let loading = false;
async function handleRegister(e: Event) {
e.preventDefault();
error = null;
loading = true;
try {
const response = await fetch(`${API_URL}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const data = await response.json();
error = data.detail || 'Registration failed';
loading = false;
return;
}
const data = await response.json();
localStorage.setItem('token', data.access_token);
goto('/').then(() => location.reload());
} catch (err) {
error = 'Network error - ' + err;
} finally {
loading = false;
}
}
</script>
<div class="flex flex-col items-center justify-center min-h-screen bg-gray-50">
<form
class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 w-full max-w-sm"
on:submit|preventDefault={handleRegister}
>
<h2 class="text-2xl font-bold mb-6 text-center">Register</h2>
{#if error}
<div class="mb-4 text-red-600 text-sm">{error}</div>
{/if}
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="username"> Username </label>
<input
id="username"
type="text"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
bind:value={username}
required
autocomplete="username"
/>
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="password"> Password </label>
<input
id="password"
type="password"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
bind:value={password}
required
autocomplete="new-password"
/>
</div>
<div class="flex items-center justify-between">
<button
type="submit"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
disabled={loading}
>
{loading ? 'Registering...' : 'Register'}
</button>
<a
href="/login"
class="inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800 ml-4"
>
Already have an account?
</a>
</div>
</form>
</div>

View File

@@ -1,12 +0,0 @@
import { error } from '@sveltejs/kit';
import { fetchScripts } from '$lib/api';
/** @type {import('./$types').PageServerLoad} */
export async function load() {
try {
const scripts = await fetchScripts();
return { scripts };
} catch (err) {
throw error(500, 'Failed to fetch scripts - ' + err);
}
}

View File

@@ -1,19 +1,27 @@
<script lang="ts"> <script lang="ts">
import { addScript } from '$lib/api'; import { addScript, fetchScripts } from '$lib/api';
import type { Script } from '$lib/api'; import type { Script } from '$lib/api';
import CodeMirror from 'svelte-codemirror-editor'; import CodeMirror from 'svelte-codemirror-editor';
import { python } from '@codemirror/lang-python'; import { python } from '@codemirror/lang-python';
import { onMount } from 'svelte';
export let data: { scripts: Script[] }; let scripts: Script[] = [];
let scripts: Script[] = data.scripts; let newScript: Omit<Script, 'id' | 'created_at'> = {
let newScript: Omit<Script, 'id' | 'created_at'> = { name: '', script_content: '' }; name: '',
script_content: '',
enabled: false
};
onMount(async () => {
scripts = await fetchScripts();
});
// Add a new script // Add a new script
async function handleAddScript() { async function handleAddScript() {
try { try {
const addedScript = await addScript(newScript); const addedScript = await addScript(newScript);
scripts = [...scripts, addedScript]; scripts = [...scripts, addedScript];
newScript = { name: '', script_content: '' }; newScript = { name: '', script_content: '', enabled: false };
} catch (err) { } catch (err) {
window.showNotification('Failed to add script. ' + err); window.showNotification('Failed to add script. ' + err);
} }
@@ -38,11 +46,7 @@
<div class="mt-8"> <div class="mt-8">
<h2 class="text-xl font-semibold mb-2">Add New Script</h2> <h2 class="text-xl font-semibold mb-2">Add New Script</h2>
<form <form onsubmit={handleAddScript} class="space-y-4 p-4 border rounded shadow">
on:submit|preventDefault={handleAddScript}
class="space-y-4 p-4 border rounded shadow"
on:submit={() => (newScript.script_content = editor.getValue())}
>
<div> <div>
<label for="name" class="block text-sm font-medium">Name</label> <label for="name" class="block text-sm font-medium">Name</label>
<input <input

View File

@@ -1,22 +0,0 @@
import { error } from '@sveltejs/kit';
import { fetchScriptById, fetchLogs } from '$lib/api';
import type { Log } from '$lib/api';
export async function load({ params }) {
const { id } = params;
try {
const script = await fetchScriptById(parseInt(id));
const logs: Log[] = (await fetchLogs(script.id)).sort(
(a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
);
if (!script) {
throw error(404, 'Script not found');
}
return { script, logs };
} catch (err) {
throw error(500, 'Failed to fetch script data - ' + err);
}
}

View File

@@ -2,30 +2,49 @@
import { import {
updateScript, updateScript,
deleteScript, deleteScript,
addLog,
deleteLog, deleteLog,
executeScript, executeScript,
fetchScriptById,
fetchLogs fetchLogs
} from '$lib/api'; } from '$lib/api';
import type { Script, Log } from '$lib/api'; import type { Script, Log } from '$lib/api';
import CodeMirror from 'svelte-codemirror-editor'; import CodeMirror from 'svelte-codemirror-editor';
import { python } from '@codemirror/lang-python'; import { python } from '@codemirror/lang-python';
import { onMount } from 'svelte';
export let data: { script: Script; logs: Log[] }; export let params: { id: string };
let script: Script = data.script; let script: Script | null = null;
let logs: Log[] = data.logs; let logs: Log[] = [];
let updatedTitle: string = script.name || ''; let updatedTitle: string = '';
let updatedContent: string = script.script_content || ''; let updatedContent: string = '';
let updatedEnabled: boolean = script.enabled || false; let updatedEnabled: boolean = false;
let loading: boolean = true;
let errorMsg: string | null = null;
onMount(async () => {
try {
const fetchedScript = await fetchScriptById(parseInt(params.id));
if (!fetchedScript) {
errorMsg = 'Script not found';
loading = false;
return;
}
script = fetchedScript;
updatedTitle = script.name || '';
updatedContent = script.script_content || '';
updatedEnabled = script.enabled || false;
const fetchedLogs: Log[] = (await fetchLogs(script.id)).sort(
(a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
);
logs = fetchedLogs;
} catch (err) {
errorMsg = 'Failed to fetch script data - ' + err;
}
loading = false;
});
let isEditMode: boolean = false; let isEditMode: boolean = false;
let newLog: Omit<Log, 'id' | 'script_id'> = {
message: '',
error_code: 0,
error_message: ''
};
let selectedLog: Log | null = null; let selectedLog: Log | null = null;
function openLogPopup(log: Log) { function openLogPopup(log: Log) {
@@ -38,10 +57,10 @@
async function handleExecuteScript() { async function handleExecuteScript() {
try { try {
await executeScript(script.id); await executeScript(script!.id);
window.showNotification('success', 'Script executed successfully!'); window.showNotification('success', 'Script executed successfully!');
// Reload the list of logs after execution // Reload the list of logs after execution
logs = (await fetchLogs(script.id)).sort( logs = (await fetchLogs(script!.id)).sort(
(a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime() (a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
); );
} catch (err) { } catch (err) {
@@ -66,26 +85,9 @@
} }
} }
async function handleAddLog() {
if (newLog.message.trim()) {
try {
const addedLog = await addLog(script.id, newLog);
logs = [addedLog, ...logs];
newLog = {
message: '',
error_code: 0,
error_message: ''
};
window.showNotification('success', 'Log added successfully!');
} catch (err) {
window.showNotification('error', 'Failed to add log. ' + err);
}
}
}
async function handleDeleteLog(logId: number) { async function handleDeleteLog(logId: number) {
try { try {
await deleteLog(script.id, logId); await deleteLog(script!.id, logId);
logs = logs.filter((log) => log.id !== logId); logs = logs.filter((log) => log.id !== logId);
window.showNotification('success', 'Log deleted successfully!'); window.showNotification('success', 'Log deleted successfully!');
} catch (err) { } catch (err) {
@@ -109,7 +111,11 @@
<main class="p-4"> <main class="p-4">
<!-- Removed local notification container as notifications are now global --> <!-- Removed local notification container as notifications are now global -->
{#if script} {#if loading}
<p>Loading...</p>
{:else if errorMsg}
<p class="text-red-500">{errorMsg}</p>
{:else if script}
{#if isEditMode} {#if isEditMode}
<input <input
type="text" type="text"
@@ -190,46 +196,6 @@
<section class="mt-8"> <section class="mt-8">
<h2 class="text-xl font-bold mb-4">Logs</h2> <h2 class="text-xl font-bold mb-4">Logs</h2>
<!--- --
<form on:submit|preventDefault={handleAddLog} class="mb-4 space-y-4">
<div>
<label for="logMessage" class="block text-sm font-medium">Log Message</label>
<input
id="logMessage"
type="text"
bind:value={newLog.message}
placeholder="Enter new log message"
class="w-full p-2 border rounded"
required
/>
</div>
<div>
<label for="errorCode" class="block text-sm font-medium">Error Code</label>
<input
id="errorCode"
type="number"
bind:value={newLog.error_code}
placeholder="Enter error code (0 for no error)"
class="w-full p-2 border rounded"
required
/>
</div>
<div>
<label for="errorMessage" class="block text-sm font-medium">Error Message</label>
<textarea
id="errorMessage"
type="text"
bind:value={newLog.error_message}
placeholder="Enter error message (optional)"
class="w-full p-2 border rounded"
>
</textarea>
</div>
<button type="submit" class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600">
Add Log
</button>
</form>
-->
<ul class="space-y-4"> <ul class="space-y-4">
{#each logs as log (log.id)} {#each logs as log (log.id)}
<li <li

View File

@@ -1,78 +1,185 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fetchSettings, updateSetting } from '$lib/api'; import { fetchUserSettings, updateSetting, fetchUsers, updateUser, deleteUser } from '$lib/api';
import type { Settings } from '$lib/api'; import type { Settings, User } from '$lib/api';
import { writable } from 'svelte/store';
import CodeMirror from 'svelte-codemirror-editor'; import CodeMirror from 'svelte-codemirror-editor';
let settings = writable<Settings[]>([]); let settings: Settings | null = $state(null);
let isLoading = writable(false); let users: User[] = $state([]);
let error = writable<string | null>(null); let currentUser: string | null = $state(localStorage.getItem('username'));
let isLoading = $state(false);
let error: string | null = $state(null);
async function loadSettings() { async function loadSettings() {
isLoading.set(true); isLoading = true;
error.set(null); error = null;
try { try {
const data = await fetchSettings(); const data = await fetchUserSettings();
settings.set(data); settings = data;
} catch (err) { } catch (err) {
error.set('Failed to load settings'); error = 'Failed to load settings - ' + err;
} finally { } finally {
isLoading.set(false); isLoading = false;
}
}
async function loadUsers() {
isLoading = true;
error = null;
try {
users = await fetchUsers();
} catch (err) {
error = 'Failed to load users - ' + err;
} finally {
isLoading = false;
} }
} }
async function saveSetting(setting: Settings) { async function saveSetting(setting: Settings) {
isLoading.set(true); isLoading = true;
error.set(null); error = null;
try { try {
await updateSetting(setting.id, setting); await updateSetting(setting.id, setting);
loadSettings(); // Refresh settings after update loadSettings(); // Refresh settings after update
} catch (err) { } catch (err) {
error.set('Failed to save setting'); error = 'Failed to save settings - ' + err;
} finally { } finally {
isLoading.set(false); isLoading = false;
}
}
async function handleUpdateUser(user: User, username: string, password: string) {
isLoading = true;
error = null;
try {
await updateUser(user.id, username, password);
loadUsers(); // Refresh users after update
} catch (err) {
error = 'Failed to update user - ' + err;
} finally {
isLoading = false;
}
}
async function handleDeleteUser(userId: number) {
isLoading = true;
error = null;
try {
await deleteUser(userId);
loadUsers(); // Refresh users after deletion
} catch (err) {
error = 'Failed to delete user - ' + err;
} finally {
isLoading = false;
} }
} }
onMount(() => { onMount(() => {
loadSettings(); loadSettings();
loadUsers();
}); });
</script> </script>
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">
<h1 class="text-2xl font-bold mb-4">Settings</h1> <h1 class="text-2xl font-bold mb-4">Settings</h1>
{#if $isLoading} {#if isLoading}
<p>Loading...</p> <p>Loading...</p>
{:else if $error} {:else if error}
<p class="text-red-500">{$error}</p> <p class="text-red-500">{$error}</p>
{:else} {:else if settings !== null}
<div class="space-y-4"> <div class="space-y-4">
{#each $settings as setting (setting.id)}
<div class="p-4 border rounded shadow"> <div class="p-4 border rounded shadow">
<label class="block mb-2 font-bold">Requirements</label> <label class="block mb-2 font-bold">
<div class="w-full border rounded"> Requirements
<CodeMirror bind:value={setting.requirements} /> <div class="w-full border rounded font-normal">
<CodeMirror bind:value={settings.requirements} />
</div> </div>
</label>
<label class="block mt-4 mb-2 font-bold">Environment</label> <label class="block mt-4 mb-2 font-bold">
<div class="w-full border rounded"> Environment
<CodeMirror bind:value={setting.environment} /> <div class="w-full border rounded font-normal">
<CodeMirror bind:value={settings.environment} />
</div> </div>
</label>
<label class="block mt-4 mb-2 font-bold">User</label> <label class="block mt-4 mb-2 font-bold">
<input type="text" class="w-full p-2 border rounded" bind:value={setting.user} readonly /> Ntfy URL
<input
type="text"
class="w-full p-2 border rounde font-normal"
bind:value={settings.ntfy_url}
/>
</label>
<button <button
class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600" class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
on:click={() => saveSetting(setting)} onclick={() => saveSetting(settings!)}
> >
Save Save
</button> </button>
</div> </div>
{/each}
</div> </div>
<h2 class="text-xl font-bold mt-8">User Management</h2>
{#if isLoading}
<p>Loading users...</p>
{:else if error}
<p class="text-red-500">{$error}</p>
{:else}
<table
class="table-auto w-full mt-4 border-collapse border border-gray-300 shadow-lg rounded-lg"
>
<thead>
<tr class="bg-gray-100">
<th class="px-4 py-2 border border-gray-300 text-left font-semibold">Users</th>
<th class="px-4 py-2 border border-gray-300 text-center font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{#each users as user (user.id)}
<tr class="hover:bg-gray-50">
<td class="border px-4 py-2 border-gray-300">
{#if user.username === currentUser}
<input
type="text"
placeholder="New Username"
class="px-2 py-1 border rounded"
bind:value={user.username}
/>
<input
type="password"
placeholder="New Password"
class="px-2 py-1 border rounded"
bind:value={user.password}
/>
{:else}
{user.username}
{/if}
</td>
<td class="border px-4 py-2 border-gray-300 text-center">
{#if user.username === currentUser}
<button
class="ml-2 px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600"
onclick={() => handleUpdateUser(user, user.username, user.password)}
>
Update
</button>
{:else}
<button
class="ml-2 px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600"
onclick={() => handleDeleteUser(user.id)}
>
Delete
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{/if} {/if}
</div> </div>

View File

@@ -3,5 +3,8 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()] plugins: [tailwindcss(), sveltekit()],
server: {
allowedHosts: ['monitor.abzk.fr']
}
}); });

12
start.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
SESSION="project_monitor"
tmux new-session -d -s $SESSION
tmux send-keys -t $SESSION "cd backend && uvicorn backend:app --reload" C-m
tmux split-window -h -t $SESSION
tmux send-keys -t $SESSION:0.1 "cd frontend && npm run dev" C-m
tmux attach -t $SESSION