Compare commits

..

5 Commits

Author SHA1 Message Date
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
21 changed files with 912 additions and 350 deletions

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

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,38 @@
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 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 +43,17 @@ app.add_middleware(
) )
# User registration/login models
class UserCreate(BaseModel):
username: str
password: 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,6 +87,39 @@ def hello():
return {"message": "Welcome to the Project Monitor API"} return {"message": "Welcome to the Project Monitor API"}
@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):
topic: str topic: str
@@ -67,9 +135,11 @@ class SubscriptionResponse(BaseModel):
# Subscriptions API Endpoints # Subscriptions API Endpoints
@app.get("/subscriptions", response_model=list[SubscriptionResponse]) @app.get("/subscriptions", response_model=list[SubscriptionResponse])
def list_subscriptions(): def list_subscriptions(current_user: User = Depends(get_current_user)):
db = SessionLocal() db = SessionLocal()
subscriptions = db.query(Subscription).all() subscriptions = (
db.query(Subscription).filter(Subscription.user_id == current_user.id).all()
)
# TODO: find a better way to do this # TODO: find a better way to do this
for subscription in subscriptions: for subscription in subscriptions:
@@ -88,7 +158,9 @@ def list_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()
@@ -112,7 +184,9 @@ def get_subscription(subscription_id: int):
@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()
@@ -120,7 +194,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)
@@ -129,7 +203,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()
@@ -144,11 +220,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()
@@ -159,7 +243,7 @@ def list_subscription_notifications(subscription_id: int):
@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()
@@ -170,7 +254,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()
@@ -208,7 +294,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()
@@ -233,7 +323,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,
@@ -252,7 +344,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
@@ -267,18 +358,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)
@@ -288,9 +400,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")
@@ -298,9 +414,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")
@@ -323,17 +447,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)
@@ -342,7 +468,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()
@@ -352,7 +478,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:
@@ -368,7 +494,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:
@@ -384,7 +512,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()
@@ -392,7 +520,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,
@@ -408,7 +538,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:
@@ -420,7 +552,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")
@@ -34,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"]
@@ -67,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)
@@ -101,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,9 +1,12 @@
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") DATABASE_URL = os.getenv("DATABASE_URL")
@@ -17,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):
@@ -30,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):
@@ -43,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):
@@ -52,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):
@@ -65,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):
@@ -77,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()
) )
@@ -87,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

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

View File

@@ -2,6 +2,56 @@ import { env } from '$env/dynamic/public';
export const API_URL = env.PUBLIC_API_URL || 'http://localhost:8000'; export const API_URL = env.PUBLIC_API_URL || 'http://localhost:8000';
// Helper to get token from localStorage
export function getToken(): string | null {
return localStorage.getItem('token');
}
// 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
*/ */
@@ -67,7 +117,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);
} }
@@ -80,9 +132,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) {
@@ -92,8 +142,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);
} }
@@ -102,7 +154,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');
} }
@@ -116,9 +170,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) {
@@ -129,7 +181,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');
} }
@@ -138,7 +192,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');
} }
@@ -154,9 +210,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) {
@@ -169,9 +223,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) {
@@ -184,9 +236,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) {
@@ -198,18 +248,26 @@ export async function addSubscription(topic: string): Promise<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 // 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');
} }
@@ -219,7 +277,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');
} }
@@ -229,7 +289,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');
@@ -238,7 +299,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');
} }
@@ -249,9 +312,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) {
@@ -262,7 +323,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');
} }
@@ -273,9 +336,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) {
@@ -287,7 +348,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');
@@ -298,7 +360,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');
@@ -309,7 +372,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,29 +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="/scripts" class="text-lg hover:text-gray-400">Scripts</a> {#if isAuthenticated}
<a href="/notifications" class="text-lg hover:text-gray-400">Notifications</a> <a href="/scripts" class="text-lg hover:text-gray-400">Scripts</a>
<a href="/settings" class="text-lg hover:text-gray-400"> <a href="/notifications" class="text-lg hover:text-gray-400">Notifications</a>
<Icon icon="material-symbols:settings" width="24" height="24" /> <button class="text-lg hover:text-gray-400" onclick={logout}>Logout</button>
</a> <a href="/settings" class="text-lg hover:text-gray-400">
<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,91 @@
<script lang="ts">
import { writable } from 'svelte/store';
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();
localStorage.setItem('token', data.access_token);
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,19 +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="relative 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">
{#if subscription.has_unread} {#each subscriptions as subscription (subscription.id)}
<span class="absolute top-2 right-2 w-3 h-3 bg-green-500 rounded-full"></span> <div
{/if} class="relative block p-4 border rounded bg-white hover:bg-gray-100 shadow-lg shadow-blue-500/50"
<h2 class="text-lg font-semibold text-gray-800">{subscription.topic}</h2> >
</a> <!-- Red cross button for delete -->
{/each} <button
</div> 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,33 @@
<script lang="ts"> <script lang="ts">
import { deleteNotification, addNotification, setViewed } from '$lib/api'; import { deleteNotification, setViewed, fetchSubscriptionNotifications } 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 {
await Promise.all(notifications.map((notification) => deleteNotification(notification.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 +45,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);
@@ -50,16 +71,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 (error) {
window.showNotification('error', 'Failed to load more notifications');
}
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,7 +117,7 @@
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 class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
@@ -81,7 +127,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 +135,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 +173,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('/');
} catch (err) {
error = 'Network error';
} 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;
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) {
@@ -66,23 +85,6 @@
} }
} }
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);
@@ -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,37 +1,36 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fetchSettings, updateSetting } from '$lib/api'; import { fetchUserSettings, updateSetting } from '$lib/api';
import type { Settings } from '$lib/api'; import type { Settings } 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 = $state(null);
let isLoading = writable(false); let isLoading = $state(false);
let error = writable<string | null>(null); 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 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;
} }
} }
@@ -43,45 +42,43 @@
<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">
<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">
<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>
{/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