Compare commits

...

17 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
28 changed files with 1526 additions and 363 deletions

1
.gitignore vendored
View File

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

View File

@@ -18,6 +18,8 @@ RUN . /app/backend/venv/bin/activate && pip install -r requirements.txt
WORKDIR /app/frontend WORKDIR /app/frontend
ADD frontend . ADD frontend .
RUN npm install
RUN npm run build
WORKDIR /app WORKDIR /app

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, Subscription, Notification 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,13 +101,78 @@ def hello():
return {"message": "Welcome to the Project Monitor API"} return {"message": "Welcome to the Project Monitor API"}
# Subscriptions API Endpoints # User Management Endpoints
@app.get("/subscriptions") @app.get("/users", response_model=list[UserResponse])
def list_subscriptions(): def list_users(current_user: User = Depends(get_current_user)):
db = SessionLocal() db = SessionLocal()
subscriptions = db.query(Subscription).all() users = db.query(User).all()
db.close() db.close()
return subscriptions 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): class SubscriptionCreate(BaseModel):
@@ -69,12 +183,39 @@ class SubscriptionResponse(BaseModel):
id: int id: int
topic: str topic: str
created_at: datetime created_at: datetime
has_unread: bool
model_config = {"from_attributes": True} 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) @app.get("/subscriptions/{subscription_id}", response_model=SubscriptionResponse)
def get_subscription(subscription_id: int): def get_subscription(
subscription_id: int, current_user: User = Depends(get_current_user)
):
db = SessionLocal() db = SessionLocal()
subscription = ( subscription = (
db.query(Subscription).filter(Subscription.id == subscription_id).first() db.query(Subscription).filter(Subscription.id == subscription_id).first()
@@ -82,12 +223,25 @@ def get_subscription(subscription_id: int):
if not subscription: if not subscription:
db.close() db.close()
raise HTTPException(status_code=404, detail="Subscription not found") 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() db.close()
return subscription return subscription
@app.post("/subscriptions") @app.post("/subscriptions")
def add_subscription(subscription: SubscriptionCreate): def add_subscription(
subscription: SubscriptionCreate, current_user: User = Depends(get_current_user)
):
db = SessionLocal() db = SessionLocal()
existing_subscription = ( existing_subscription = (
db.query(Subscription).filter(Subscription.topic == subscription.topic).first() db.query(Subscription).filter(Subscription.topic == subscription.topic).first()
@@ -95,7 +249,7 @@ def add_subscription(subscription: SubscriptionCreate):
if existing_subscription: if existing_subscription:
db.close() db.close()
raise HTTPException(status_code=400, detail="Subscription already exists") raise HTTPException(status_code=400, detail="Subscription already exists")
new_subscription = Subscription(topic=subscription.topic) new_subscription = Subscription(topic=subscription.topic, user_id=current_user.id)
db.add(new_subscription) db.add(new_subscription)
db.commit() db.commit()
db.refresh(new_subscription) db.refresh(new_subscription)
@@ -104,7 +258,9 @@ def add_subscription(subscription: SubscriptionCreate):
@app.delete("/subscriptions/{subscription_id}") @app.delete("/subscriptions/{subscription_id}")
def remove_subscription(subscription_id: int): def remove_subscription(
subscription_id: int, current_user: User = Depends(get_current_user)
):
db = SessionLocal() db = SessionLocal()
subscription = ( subscription = (
db.query(Subscription).filter(Subscription.id == subscription_id).first() db.query(Subscription).filter(Subscription.id == subscription_id).first()
@@ -119,11 +275,19 @@ def remove_subscription(subscription_id: int):
@app.get("/subscriptions/{subscription_id}/notifications") @app.get("/subscriptions/{subscription_id}/notifications")
def list_subscription_notifications(subscription_id: int): 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() db = SessionLocal()
notifications = ( notifications = (
db.query(Notification) db.query(Notification)
.filter(Notification.subscription_id == subscription_id) .filter(Notification.subscription_id == subscription_id)
.order_by(Notification.created_at.desc())
.limit(limit)
.offset(offset)
.all() .all()
) )
db.close() db.close()
@@ -133,8 +297,43 @@ def list_subscription_notifications(subscription_id: int):
] ]
@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") @app.get("/notifications")
def list_notifications(): def list_notifications(current_user: User = Depends(get_current_user)):
db = SessionLocal() db = SessionLocal()
notifications = db.query(Notification).all() notifications = db.query(Notification).all()
db.close() db.close()
@@ -145,7 +344,9 @@ def list_notifications():
@app.delete("/notifications/{notification_id}") @app.delete("/notifications/{notification_id}")
def remove_notification(notification_id: int): def remove_notification(
notification_id: int, current_user: User = Depends(get_current_user)
):
db = SessionLocal() db = SessionLocal()
notification = ( notification = (
db.query(Notification).filter(Notification.id == notification_id).first() db.query(Notification).filter(Notification.id == notification_id).first()
@@ -183,7 +384,11 @@ class NotificationResponse(NotificationCreate):
@app.put("/notifications/{notification_id}", response_model=NotificationResponse) @app.put("/notifications/{notification_id}", response_model=NotificationResponse)
def update_notification(notification_id: int, notification: NotificationUpdate): def update_notification(
notification_id: int,
notification: NotificationUpdate,
current_user: User = Depends(get_current_user),
):
db = SessionLocal() db = SessionLocal()
existing_notification = ( existing_notification = (
db.query(Notification).filter(Notification.id == notification_id).first() db.query(Notification).filter(Notification.id == notification_id).first()
@@ -208,7 +413,9 @@ def update_notification(notification_id: int, notification: NotificationUpdate):
@app.post("/notifications", response_model=NotificationResponse) @app.post("/notifications", response_model=NotificationResponse)
def create_notification(notification: NotificationCreate): def create_notification(
notification: NotificationCreate, current_user: User = Depends(get_current_user)
):
db = SessionLocal() db = SessionLocal()
new_notification = Notification( new_notification = Notification(
subscription_id=notification.subscription_id, subscription_id=notification.subscription_id,
@@ -227,7 +434,6 @@ def create_notification(notification: NotificationCreate):
class SettingsBase(BaseModel): class SettingsBase(BaseModel):
requirements: str requirements: str
environment: str environment: str
user: str
ntfy_url: str ntfy_url: str
@@ -242,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)
@@ -263,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")
@@ -273,9 +504,17 @@ 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")
@@ -298,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)
@@ -317,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()
@@ -327,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:
@@ -343,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:
@@ -359,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()
@@ -367,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,
@@ -383,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:
@@ -395,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

@@ -5,7 +5,6 @@ from model import SessionLocal, Subscription, Settings, Notification
import json import json
# Constants # Constants
NTFY_TOKEN = os.getenv("NTFY_TOKEN") NTFY_TOKEN = os.getenv("NTFY_TOKEN")
@@ -18,6 +17,7 @@ def fetch_ntfy_notifications(base_url, subscriptions):
notifications = [] notifications = []
for subscription in subscriptions: for subscription in subscriptions:
print(f"Fetching notifications for {subscription.topic}")
topic = subscription.topic topic = subscription.topic
last_message_id = subscription.last_message_id last_message_id = subscription.last_message_id
since_param = "all" if last_message_id is None else last_message_id since_param = "all" if last_message_id is None else last_message_id
@@ -33,14 +33,11 @@ def fetch_ntfy_notifications(base_url, subscriptions):
notifications.append(notification) notifications.append(notification)
print(f"Fetched {len(notifications)} notifications") print(f"Fetched {len(notifications)} notifications")
print(notifications)
return notifications return notifications
def save_notifications_to_db(notifications, topic_to_subscription, db): def save_notifications_to_db(notifications, topic_to_subscription, db):
"""Save the fetched notifications to the database and update last_message_id.""" """Save the fetched notifications to the database and update last_message_id."""
db = SessionLocal()
last_message_ids = {} last_message_ids = {}
for notification in notifications: for notification in notifications:
topic = notification["topic"] topic = notification["topic"]
@@ -66,33 +63,26 @@ def save_notifications_to_db(notifications, topic_to_subscription, db):
if subscription: if subscription:
subscription.last_message_id = message_id subscription.last_message_id = message_id
db.commit() db.commit()
db.close()
def main(): def process_user_notifications(user_settings, db):
"""Main function to fetch and save notifications.""" """Process notifications for a specific user's subscriptions."""
db = SessionLocal() ntfy_url = user_settings.ntfy_url
# Get the ntfy base URL from settings
settings = db.query(Settings).filter(Settings.user == "default").first()
if not settings:
print("Default user settings not found.")
return
ntfy_url = settings.ntfy_url
if not ntfy_url: if not ntfy_url:
print("Ntfy URL not found in settings.") print(f"Ntfy URL not found for user ID {user_settings.user_id}. Skipping...")
return return
# Get all subscribed topics # Get all subscriptions for the user
subscriptions = db.query(Subscription).all() subscriptions = (
db.query(Subscription)
.filter(Subscription.user_id == user_settings.user_id)
.all()
)
topic_to_subscription = { topic_to_subscription = {
subscription.topic: subscription.id for subscription in subscriptions subscription.topic: subscription.id for subscription in subscriptions
} }
db.close()
# Fetch notifications from ntfy.sh # Fetch notifications from ntfy.sh
notifications = fetch_ntfy_notifications(ntfy_url, subscriptions) notifications = fetch_ntfy_notifications(ntfy_url, subscriptions)
@@ -100,5 +90,24 @@ def main():
save_notifications_to_db(notifications, topic_to_subscription, db) 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__": if __name__ == "__main__":
main() main()

View File

@@ -1,12 +1,18 @@
from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey, Boolean 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
import os import os
import secrets
from passlib.context import CryptContext
# Initialize the database # Initialize the database
DATABASE_URL = os.getenv("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,10 @@ 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) ntfy_url = Column(String, nullable=True)
user_id = Column(
Integer, ForeignKey("users.id", name="fk_user_settings_user_id"), nullable=False
)
class Subscription(Base): class Subscription(Base):
@@ -62,6 +83,9 @@ class Subscription(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_subscription_user_id"), nullable=False
)
class Notification(Base): class Notification(Base):
@@ -74,7 +98,11 @@ class Notification(Base):
viewed = Column(Boolean, default=False) viewed = Column(Boolean, default=False)
sent = Column(Boolean, default=False) sent = Column(Boolean, default=False)
subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=False) subscription_id = Column(
Integer,
ForeignKey("subscriptions.id", name="fk_notification_subscription_id"),
nullable=False,
)
created_at = Column( created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
@@ -84,20 +112,39 @@ class Notification(Base):
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Ensure a default setting line exists # Ensure a default admin user exists
def ensure_default_setting(): def ensure_default_setting():
db = SessionLocal() db = SessionLocal()
default_setting = db.query(Settings).filter(Settings.user == "default").first() 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: if not default_setting:
new_setting = Settings( new_setting = Settings(
requirements="", requirements="",
environment="", environment="",
user="default", user_id=admin_user.id,
ntfy_url="https://ntfy.abzk.fr", ntfy_url="https://ntfy.abzk.fr",
) )
db.add(new_setting) db.add(new_setting)
db.commit() db.commit()
db.close() db.close()
ensure_default_setting()

View File

@@ -2,3 +2,8 @@ requests
uvicorn uvicorn
fastapi fastapi
sqlalchemy sqlalchemy
alembic
passlib
python-jose
argon2_cffi
python-multipart

View File

@@ -5,8 +5,6 @@ source /app/backend/venv/bin/activate
# Navigate to the frontend directory, install dependencies, and start the Svelte app # Navigate to the frontend directory, install dependencies, and start the Svelte app
cd frontend cd frontend
npm install
npm run build
npm run dev -- --host 0.0.0.0 --port 8080 & npm run dev -- --host 0.0.0.0 --port 8080 &
# Navigate back to the root directory # Navigate back to the root directory
@@ -19,6 +17,7 @@ cd ../..
# Start the backend using uvicorn # Start the backend using uvicorn
cd backend cd backend
alembic upgrade head
uvicorn backend:app --host 0.0.0.0 --port 8000 & uvicorn backend:app --host 0.0.0.0 --port 8000 &
cd .. cd ..

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,6 +1,71 @@
import { env } from '$env/dynamic/public'; 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:8080'; 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 * Type definitions for Subscriptions and Notifications
@@ -9,6 +74,7 @@ export interface Subscription {
id: number; id: number;
topic: string; topic: string;
created_at: string; created_at: string;
has_unread: boolean;
} }
export interface Notification { export interface Notification {
@@ -66,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);
} }
@@ -79,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) {
@@ -91,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);
} }
@@ -101,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');
} }
@@ -115,9 +185,7 @@ 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, ntfy_url: updatedSetting.ntfy_url })
}); });
if (!response.ok) { if (!response.ok) {
@@ -128,7 +196,9 @@ export async function updateSetting(
// Fetch all subscriptions // Fetch all subscriptions
export async function fetchSubscriptions(): Promise<Subscription[]> { export async function fetchSubscriptions(): Promise<Subscription[]> {
const response = await fetch(`${API_URL}/subscriptions`); const response = await fetch(`${API_URL}/subscriptions`, {
headers: authHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch subscriptions'); throw new Error('Failed to fetch subscriptions');
} }
@@ -137,7 +207,9 @@ export async function fetchSubscriptions(): Promise<Subscription[]> {
// Fetch subscriptions by topic // Fetch subscriptions by topic
export async function getSubscription(topic_id: string): Promise<Subscription> { export async function getSubscription(topic_id: string): Promise<Subscription> {
const response = await fetch(`${API_URL}/subscriptions/${topic_id}`); const response = await fetch(`${API_URL}/subscriptions/${topic_id}`, {
headers: authHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch subscriptions'); throw new Error('Failed to fetch subscriptions');
} }
@@ -153,9 +225,7 @@ export async function addNotification(
): Promise<Notification> { ): Promise<Notification> {
const response = await fetch(`${API_URL}/notifications`, { const response = await fetch(`${API_URL}/notifications`, {
method: 'POST', method: 'POST',
headers: { headers: authHeaders({ 'Content-Type': 'application/json' }),
'Content-Type': 'application/json'
},
body: JSON.stringify({ subscription_id: subscriptionId, title, message, priority }) body: JSON.stringify({ subscription_id: subscriptionId, title, message, priority })
}); });
if (!response.ok) { if (!response.ok) {
@@ -168,9 +238,7 @@ export async function addNotification(
export async function setViewed(notificationId: number): Promise<Notification> { export async function setViewed(notificationId: number): Promise<Notification> {
const response = await fetch(`${API_URL}/notifications/${notificationId}`, { const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: authHeaders({ 'Content-Type': 'application/json' }),
'Content-Type': 'application/json'
},
body: JSON.stringify({ viewed: true }) body: JSON.stringify({ viewed: true })
}); });
if (!response.ok) { if (!response.ok) {
@@ -183,9 +251,7 @@ export async function setViewed(notificationId: number): Promise<Notification> {
export async function addSubscription(topic: string): Promise<Subscription> { export async function addSubscription(topic: string): Promise<Subscription> {
const response = await fetch(`${API_URL}/subscriptions`, { const response = await fetch(`${API_URL}/subscriptions`, {
method: 'POST', method: 'POST',
headers: { headers: authHeaders({ 'Content-Type': 'application/json' }),
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic }) body: JSON.stringify({ topic })
}); });
if (!response.ok) { if (!response.ok) {
@@ -194,21 +260,89 @@ export async function addSubscription(topic: string): Promise<Subscription> {
return response.json(); 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 // Delete a subscription
export async function deleteSubscription(subscriptionId: number): Promise<void> { export async function deleteSubscription(subscriptionId: number): Promise<void> {
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}`, { const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}`, {
method: 'DELETE' method: 'DELETE',
headers: authHeaders()
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to delete subscription'); throw new Error('Failed to delete subscription');
} }
} }
// Get all subscription notifications // 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( export async function fetchSubscriptionNotifications(
subscriptionId: string subscriptionId: string,
limit: number = 20,
offset: number = 0
): Promise<Notification[]> { ): Promise<Notification[]> {
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`); const response = await fetch(
`${API_URL}/subscriptions/${subscriptionId}/notifications?limit=${limit}&offset=${offset}`,
{
headers: authHeaders()
}
);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch subscription notifications'); throw new Error('Failed to fetch subscription notifications');
} }
@@ -218,7 +352,9 @@ export async function fetchSubscriptionNotifications(
// Fetch all notifications or filter by topic // Fetch all notifications or filter by topic
export async function fetchAllNotifications(): Promise<Notification[]> { export async function fetchAllNotifications(): Promise<Notification[]> {
const url = `${API_URL}/notifications`; const url = `${API_URL}/notifications`;
const response = await fetch(url); const response = await fetch(url, {
headers: authHeaders()
});
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch notifications'); throw new Error('Failed to fetch notifications');
} }
@@ -228,7 +364,8 @@ export async function fetchAllNotifications(): Promise<Notification[]> {
// Delete a notification // Delete a notification
export async function deleteNotification(notificationId: number): Promise<void> { export async function deleteNotification(notificationId: number): Promise<void> {
const response = await fetch(`${API_URL}/notifications/${notificationId}`, { const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
method: 'DELETE' method: 'DELETE',
headers: authHeaders()
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to delete notification'); throw new Error('Failed to delete notification');
@@ -237,7 +374,9 @@ export async function deleteNotification(notificationId: number): Promise<void>
// 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');
} }
@@ -248,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) {
@@ -261,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');
} }
@@ -272,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) {
@@ -286,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');
@@ -297,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');
@@ -308,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,30 +64,37 @@
<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> <a href="/notifications" class="text-lg hover:text-gray-400">Notifications</a>
<a href="/settings" class="text-lg hover:text-gray-400"> <button class="text-lg hover:text-gray-400" onclick={logout}>Logout</button>
<Icon icon="material-symbols:settings" width="24" height="24" /> <a href="/settings" class="text-lg hover:text-gray-400">
</a> <Icon icon="material-symbols:settings" width="24" height="24" />
</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">
{@render children()} {#if isAuthenticated || page.url.pathname === '/login' || page.url.pathname === '/register'}
{@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)}
<div <div
class="p-4 rounded shadow-lg text-white" class="p-4 rounded shadow-lg text-white"
class:bg-green-500={notification.type === 'success'} class:bg-green-500={notification.type === 'success'}
class:bg-red-500={notification.type === 'error'} class:bg-red-500={notification.type === 'error'}
> >
{notification.message} {notification.message}
</div> </div>
{/each} {/each}
</div>
</div> </div>
<div class="fixed bottom-4 left-4 group"> <div class="fixed bottom-4 left-4 group">

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

@@ -1,12 +0,0 @@
import { fetchSubscriptions } from '$lib/api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
try {
const subscriptions = await fetchSubscriptions();
return { subscriptions };
} catch (error) {
console.error('Failed to load subscriptions:', error);
return { subscriptions: [] };
}
};

View File

@@ -1,10 +1,25 @@
<script lang="ts"> <script lang="ts">
import { addSubscription, deleteSubscription, fetchSubscriptions } from '$lib/api'; import { addSubscription, deleteSubscription, fetchSubscriptions } from '$lib/api';
import type { Subscription } from '$lib/api'; import type { Subscription } from '$lib/api';
export let data: { subscriptions: Subscription[] }; import { onMount } from 'svelte';
let subscriptions: Subscription[] = data.subscriptions; let subscriptions: Subscription[] = [];
let newTopic = ''; 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() { async function handleAddSubscription() {
if (!newTopic.trim()) { if (!newTopic.trim()) {
@@ -35,16 +50,48 @@
<main class="p-4"> <main class="p-4">
<h1 class="text-2xl font-bold mb-4">Subscriptions</h1> <h1 class="text-2xl font-bold mb-4">Subscriptions</h1>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> {#if loading}
{#each subscriptions as subscription (subscription.id)} <p>Loading...</p>
<a {:else if errorMsg}
href={`/notifications/${subscription.id}`} <p class="text-red-500">{errorMsg}</p>
class="block p-4 border rounded bg-white hover:bg-gray-100 shadow-lg shadow-blue-500/50" {:else}
> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<h2 class="text-lg font-semibold text-gray-800">{subscription.topic}</h2> {#each subscriptions as subscription (subscription.id)}
</a> <div
{/each} class="relative block p-4 border rounded bg-white hover:bg-gray-100 shadow-lg shadow-blue-500/50"
</div> >
<!-- 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"> <div class="mt-8">
<h2 class="text-xl font-semibold mb-2">Add New Subscription</h2> <h2 class="text-xl font-semibold mb-2">Add New Subscription</h2>

View File

@@ -1,18 +0,0 @@
import { fetchSubscriptionNotifications, getSubscription } from '$lib/api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
try {
const subscription_id: string = 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, notifications };
} catch (error) {
console.error('Failed to load:', error);
return { subscription: {}, notifications: [] };
}
};

View File

@@ -1,14 +1,39 @@
<script lang="ts"> <script lang="ts">
import { deleteNotification, addNotification, setViewed } from '$lib/api'; import {
deleteNotification,
setViewed,
fetchSubscriptionNotifications,
deleteSubscriptionNotifications,
markAllNotificationsAsViewed
} from '$lib/api';
import type { Notification, Subscription } from '$lib/api'; import type { Notification, Subscription } from '$lib/api';
let { data } = $props();
export let data: { notifications: Notification[]; subscription: Subscription }; let subscription: Subscription | null = $state(data.subscription);
let notifications: Notification[] = $state(data.notifications);
let selectedNotification: Notification | null = $state(null);
let notifications: Notification[] = data.notifications; // Delete all notifications for this subscription
let newNotificationTitle = ''; async function handleDeleteAllNotifications() {
let newNotificationMessage = ''; if (notifications.length === 0) return;
let newNotificationPriority = 3; const confirmed = window.confirm(
let selectedNotification: Notification | null = null; '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) { async function openNotificationPopup(notification: Notification) {
if (!notification.viewed) { if (!notification.viewed) {
@@ -26,7 +51,9 @@
selectedNotification = null; selectedNotification = null;
} }
async function handleDeleteNotification(id: number) { async function handleDeleteNotification(e, id: number) {
e.stopPropagation();
try { try {
await deleteNotification(id); await deleteNotification(id);
notifications = notifications.filter((notification) => notification.id !== id); notifications = notifications.filter((notification) => notification.id !== id);
@@ -37,11 +64,7 @@
} }
async function markAllViewed() { async function markAllViewed() {
try { try {
await Promise.all( markAllNotificationsAsViewed(subscription!.id);
notifications
.filter((notification) => !notification.viewed)
.map((notification) => setViewed(notification.id))
);
notifications = notifications.map((notification) => notifications = notifications.map((notification) =>
notification.viewed ? notification : { ...notification, viewed: true } notification.viewed ? notification : { ...notification, viewed: true }
); );
@@ -50,16 +73,41 @@
window.showNotification('error', 'Failed to mark all notifications as viewed.'); 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> </script>
<main class="p-4"> <main class="p-4">
<h1 class="text-2xl font-bold mb-4">Notifications for {data.subscription.topic}:</h1> <h1 class="text-2xl font-bold mb-4">Notifications for {subscription!.topic}:</h1>
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<a href="/notifications" class="text-blue-500 hover:underline">← Return to Subscriptions</a> <a href="/notifications" class="text-blue-500 hover:underline">← Return to Subscriptions</a>
<button class="bg-blue-500 text-white px-4 py-2 rounded" on:click={markAllViewed}> <div class="flex gap-2">
Mark All Viewed <button class="bg-blue-500 text-white px-4 py-2 rounded" onclick={markAllViewed}>
</button> 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> </div>
{#if notifications.length === 0} {#if notifications.length === 0}
@@ -71,9 +119,14 @@
class="p-2 rounded flex justify-between items-center border-s-slate-400 border-1" class="p-2 rounded flex justify-between items-center border-s-slate-400 border-1"
class:bg-green-200={notification.viewed} class:bg-green-200={notification.viewed}
> >
<button class="p-2 w-full text-left" on:click={() => openNotificationPopup(notification)}> <button class="p-2 w-full text-left" onclick={() => openNotificationPopup(notification)}>
<div> <div>
<p class="font-semibold">{notification.title}</p> <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"> <p class="text-sm text-gray-500">
{new Date(notification.created_at).toLocaleString()} {new Date(notification.created_at).toLocaleString()}
</p> </p>
@@ -81,7 +134,7 @@
</button> </button>
<button <button
class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 block" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 block"
on:click|stopPropagation={() => handleDeleteNotification(notification.id)} onclick={(e) => handleDeleteNotification(e, notification.id)}
> >
Delete Delete
</button> </button>
@@ -89,6 +142,17 @@
{/each} {/each}
</ul> </ul>
{/if} {/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} {#if selectedNotification}
<div class="fixed inset-0 bg-opacity-30 backdrop-blur-sm flex items-center justify-center z-50"> <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"> <div class="bg-white p-6 rounded shadow-lg w-3/4 max-w-2xl max-h-[80vh] overflow-y-auto">
@@ -116,7 +180,7 @@
</div> </div>
<button <button
class="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600" class="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
on:click={closeNotificationPopup} onclick={closeNotificationPopup}
> >
Close Close
</button> </button>

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,88 +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">
<label class="block mb-2 font-bold"> Requirements
Requirements <div class="w-full border rounded font-normal">
<div class="w-full border rounded"> <CodeMirror bind:value={settings.requirements} />
<CodeMirror bind:value={setting.requirements} /> </div>
</div> </label>
</label>
<label class="block mt-4 mb-2 font-bold"> <label class="block mt-4 mb-2 font-bold">
Environment Environment
<div class="w-full border rounded"> <div class="w-full border rounded font-normal">
<CodeMirror bind:value={setting.environment} /> <CodeMirror bind:value={settings.environment} />
</div> </div>
</label> </label>
<label class="block mt-4 mb-2 font-bold"> <label class="block mt-4 mb-2 font-bold">
Ntfy URL Ntfy URL
<input <input
type="text" type="text"
class="w-full p-2 border rounde font-normal" class="w-full p-2 border rounde font-normal"
bind:value={setting.ntfy_url} bind:value={settings.ntfy_url}
/> />
</label> </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>

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