Compare commits

..

14 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
23 changed files with 1204 additions and 360 deletions

View File

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

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 fastapi import FastAPI
from fastapi.exceptions import HTTPException
from fastapi import FastAPI, Depends, HTTPException, status, Query
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.middleware.cors import CORSMiddleware
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
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()
# 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
app.add_middleware(
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
class ScriptBase(BaseModel):
name: str
@@ -52,6 +101,80 @@ def hello():
return {"message": "Welcome to the Project Monitor API"}
# User Management Endpoints
@app.get("/users", response_model=list[UserResponse])
def list_users(current_user: User = Depends(get_current_user)):
db = SessionLocal()
users = db.query(User).all()
db.close()
return users
@app.put("/users/{user_id}")
def update_user(
user_id: int,
user: UserCreate,
current_user: User = Depends(get_current_user),
):
db = SessionLocal()
existing_user = db.query(User).filter(User.id == user_id).first()
if not existing_user:
db.close()
raise HTTPException(status_code=404, detail="User not found")
existing_user.username = user.username
existing_user.password_hash = get_password_hash(user.password)
db.commit()
db.refresh(existing_user)
db.close()
return {"message": "User updated successfully"}
@app.delete("/users/{user_id}")
def delete_user(user_id: int, current_user: User = Depends(get_current_user)):
db = SessionLocal()
user = db.query(User).filter(User.id == user_id).first()
if not user:
db.close()
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
db.commit()
db.close()
return {"message": "User deleted successfully"}
@app.post("/register", response_model=Token)
def register(user: UserCreate):
db = SessionLocal()
existing_user = db.query(User).filter(User.username == user.username).first()
if existing_user:
db.close()
raise HTTPException(status_code=400, detail="Username already registered")
hashed_password = get_password_hash(user.password)
new_user = User(username=user.username, password_hash=hashed_password)
db.add(new_user)
db.commit()
db.refresh(new_user)
access_token = create_access_token(data={"sub": new_user.username})
db.close()
return {"access_token": access_token, "token_type": "bearer"}
@app.post("/login", response_model=Token)
def login(form_data: OAuth2PasswordRequestForm = Depends()):
db = SessionLocal()
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
db.close()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(data={"sub": user.username})
db.close()
return {"access_token": access_token, "token_type": "bearer"}
class SubscriptionCreate(BaseModel):
topic: str
@@ -67,9 +190,11 @@ class SubscriptionResponse(BaseModel):
# Subscriptions API Endpoints
@app.get("/subscriptions", response_model=list[SubscriptionResponse])
def list_subscriptions():
def list_subscriptions(current_user: User = Depends(get_current_user)):
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
for subscription in subscriptions:
@@ -88,7 +213,9 @@ def list_subscriptions():
@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()
subscription = (
db.query(Subscription).filter(Subscription.id == subscription_id).first()
@@ -112,7 +239,9 @@ def get_subscription(subscription_id: int):
@app.post("/subscriptions")
def add_subscription(subscription: SubscriptionCreate):
def add_subscription(
subscription: SubscriptionCreate, current_user: User = Depends(get_current_user)
):
db = SessionLocal()
existing_subscription = (
db.query(Subscription).filter(Subscription.topic == subscription.topic).first()
@@ -120,7 +249,7 @@ def add_subscription(subscription: SubscriptionCreate):
if existing_subscription:
db.close()
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.commit()
db.refresh(new_subscription)
@@ -129,7 +258,9 @@ def add_subscription(subscription: SubscriptionCreate):
@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()
subscription = (
db.query(Subscription).filter(Subscription.id == subscription_id).first()
@@ -144,11 +275,19 @@ def remove_subscription(subscription_id: int):
@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()
notifications = (
db.query(Notification)
.filter(Notification.subscription_id == subscription_id)
.order_by(Notification.created_at.desc())
.limit(limit)
.offset(offset)
.all()
)
db.close()
@@ -158,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")
def list_notifications():
def list_notifications(current_user: User = Depends(get_current_user)):
db = SessionLocal()
notifications = db.query(Notification).all()
db.close()
@@ -170,7 +344,9 @@ def list_notifications():
@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()
notification = (
db.query(Notification).filter(Notification.id == notification_id).first()
@@ -208,7 +384,11 @@ class NotificationResponse(NotificationCreate):
@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()
existing_notification = (
db.query(Notification).filter(Notification.id == notification_id).first()
@@ -233,7 +413,9 @@ def update_notification(notification_id: int, notification: NotificationUpdate):
@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()
new_notification = Notification(
subscription_id=notification.subscription_id,
@@ -252,7 +434,6 @@ def create_notification(notification: NotificationCreate):
class SettingsBase(BaseModel):
requirements: str
environment: str
user: str
ntfy_url: str
@@ -267,18 +448,39 @@ class SettingsResponse(SettingsBase):
# Settings API Endpoints
@app.get("/settings", response_model=list[SettingsResponse])
def read_settings():
@app.get("/settings", response_model=SettingsResponse)
def read_settings(current_user: User = Depends(get_current_user)):
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()
return settings
@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()
new_setting = Settings(**settings.model_dump())
new_setting = Settings(**settings.model_dump(), user_id=current_user.id)
db.add(new_setting)
db.commit()
db.refresh(new_setting)
@@ -288,9 +490,13 @@ def create_setting(settings: SettingsBase):
@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()
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()
if not setting:
raise HTTPException(status_code=404, detail="Setting not found")
@@ -298,9 +504,17 @@ def read_setting(settings_id: int):
@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()
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:
raise HTTPException(status_code=404, detail="Setting not found")
@@ -323,17 +537,19 @@ def update_setting(settings_id: int, settings: SettingsUpdate):
@app.get("/script", response_model=list[ScriptResponse])
def read_scripts():
def read_scripts(current_user: User = Depends(get_current_user)):
db = SessionLocal()
scripts = db.query(Script).all()
scripts = db.query(Script).filter(Script.user_id == current_user.id).all()
db.close()
return scripts
@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()
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.commit()
db.refresh(new_script)
@@ -342,7 +558,7 @@ def create_script(script: ScriptCreate):
@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()
script = db.query(Script).filter(Script.id == script_id).first()
db.close()
@@ -352,7 +568,7 @@ def read_script(script_id: int):
@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()
script = db.query(Script).filter(Script.id == script_id).first()
if not script:
@@ -368,7 +584,9 @@ def delete_script(script_id: int):
@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()
existing_script = db.query(Script).filter(Script.id == script_id).first()
if not existing_script:
@@ -384,7 +602,7 @@ def update_script(script_id: int, script: ScriptUpdate):
@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()
logs = db.query(Log).filter(Log.script_id == script_id).all()
db.close()
@@ -392,7 +610,9 @@ def get_script_logs(script_id: int):
@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()
new_log = Log(
script_id=script_id,
@@ -408,7 +628,9 @@ def create_script_log(script_id: int, log: ScriptLogCreate):
@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()
log = db.query(Log).filter(Log.id == log_id and Log.script_id == script_id).first()
if not log:
@@ -420,7 +642,7 @@ def delete_script_log(script_id: int, log_id: int):
@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])
return {"run_script": True}

View File

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

View File

@@ -1,9 +1,12 @@
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.orm import sessionmaker
from sqlalchemy.sql.functions import func
from sqlalchemy.sql.sqltypes import DateTime
import os
import secrets
from passlib.context import CryptContext
# Initialize the database
DATABASE_URL = os.getenv("DATABASE_URL")
@@ -17,7 +20,15 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
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):
@@ -30,6 +41,9 @@ class Script(Base):
created_at = Column(
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):
@@ -43,7 +57,9 @@ class Log(Base):
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):
@@ -52,8 +68,10 @@ class Settings(Base):
id = Column(Integer, primary_key=True, index=True)
requirements = Column(String, nullable=False)
environment = Column(String, nullable=False)
user = Column(String, nullable=False)
ntfy_url = Column(String, nullable=True)
user_id = Column(
Integer, ForeignKey("users.id", name="fk_user_settings_user_id"), nullable=False
)
class Subscription(Base):
@@ -65,6 +83,9 @@ class Subscription(Base):
created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
user_id = Column(
Integer, ForeignKey("users.id", name="fk_subscription_user_id"), nullable=False
)
class Notification(Base):
@@ -77,7 +98,11 @@ class Notification(Base):
viewed = 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(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@@ -87,20 +112,39 @@ class Notification(Base):
Base.metadata.create_all(bind=engine)
# Ensure a default setting line exists
# Ensure a default admin user exists
def ensure_default_setting():
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:
new_setting = Settings(
requirements="",
environment="",
user="default",
user_id=admin_user.id,
ntfy_url="https://ntfy.abzk.fr",
)
db.add(new_setting)
db.commit()
db.close()
ensure_default_setting()

View File

@@ -3,3 +3,7 @@ uvicorn
fastapi
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
cd frontend
npm install
npm run build
npm run dev -- --host 0.0.0.0 --port 8080 &
# Navigate back to the root directory

View File

@@ -24,7 +24,8 @@ export default defineConfig(
rules: {
// 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
'no-undef': 'off'
'no-undef': 'off',
'svelte/no-navigation-without-resolve': 'off'
}
},
{

View File

@@ -1,7 +1,72 @@
import { env } from '$env/dynamic/public';
export interface User {
id: number;
username: string;
password: string | null;
}
export const API_URL = env.PUBLIC_API_URL || 'http://localhost:8000';
// Helper to get token from localStorage
export function getToken(): string | null {
const tokenData = localStorage.getItem('token');
if (tokenData) {
const { value, expiresAt } = JSON.parse(tokenData);
if (Date.now() > expiresAt) {
localStorage.removeItem('token');
location.reload();
return null;
}
return value;
}
return null;
}
// Helper to add Authorization header if token exists
export function authHeaders(headers: Record<string, string> = {}): Record<string, string> {
const token = getToken();
return token ? { ...headers, Authorization: `Bearer ${token}` } : headers;
}
/**
* Login and Register API
*/
export interface AuthResponse {
access_token: string;
token_type: string;
}
export async function login(username: string, password: string): Promise<AuthResponse> {
const form = new FormData();
form.append('username', username);
form.append('password', password);
const response = await fetch(`${API_URL}/login`, {
method: 'POST',
body: form
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || 'Login failed');
}
return response.json();
}
export async function register(username: string, password: string): Promise<AuthResponse> {
const response = await fetch(`${API_URL}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || 'Registration failed');
}
return response.json();
}
/**
* Type definitions for Subscriptions and Notifications
*/
@@ -67,7 +132,9 @@ export interface Script {
// Fetch all scripts
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) {
throw new Error('Failed to fetch scripts ' + response.statusText);
}
@@ -80,9 +147,7 @@ export async function addScript(
): Promise<Script> {
const response = await fetch(`${API_URL}/script`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(script)
});
if (!response.ok) {
@@ -92,8 +157,10 @@ export async function addScript(
}
// Fetch all settings
export async function fetchSettings(): Promise<Settings[]> {
const response = await fetch(`${API_URL}/settings`);
export async function fetchUserSettings(): Promise<Settings> {
const response = await fetch(`${API_URL}/settings`, {
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch settings ' + response.statusText);
}
@@ -102,7 +169,9 @@ export async function fetchSettings(): Promise<Settings[]> {
// Fetch a single setting by ID
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) {
throw new Error('Failed to fetch setting');
}
@@ -116,9 +185,7 @@ export async function updateSetting(
): Promise<Settings> {
const response = await fetch(`${API_URL}/settings/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ ...updatedSetting, ntfy_url: updatedSetting.ntfy_url })
});
if (!response.ok) {
@@ -129,7 +196,9 @@ export async function updateSetting(
// Fetch all subscriptions
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) {
throw new Error('Failed to fetch subscriptions');
}
@@ -138,7 +207,9 @@ export async function fetchSubscriptions(): Promise<Subscription[]> {
// Fetch subscriptions by topic
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) {
throw new Error('Failed to fetch subscriptions');
}
@@ -154,9 +225,7 @@ export async function addNotification(
): Promise<Notification> {
const response = await fetch(`${API_URL}/notifications`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ subscription_id: subscriptionId, title, message, priority })
});
if (!response.ok) {
@@ -169,9 +238,7 @@ export async function addNotification(
export async function setViewed(notificationId: number): Promise<Notification> {
const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ viewed: true })
});
if (!response.ok) {
@@ -184,9 +251,7 @@ export async function setViewed(notificationId: number): Promise<Notification> {
export async function addSubscription(topic: string): Promise<Subscription> {
const response = await fetch(`${API_URL}/subscriptions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ topic })
});
if (!response.ok) {
@@ -195,21 +260,89 @@ export async function addSubscription(topic: string): Promise<Subscription> {
return response.json();
}
// Add a new notification to a specific subscription
export async function markAllNotificationsAsViewed(subscriptionId: number): Promise<void> {
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`, {
method: 'POST',
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to mark all notifications as viewed for subscription');
}
}
// Delete all notifications for a specific subscription
export async function deleteSubscriptionNotifications(subscriptionId: number): Promise<void> {
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`, {
method: 'DELETE',
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to delete notifications for subscription');
}
}
// Delete a subscription
export async function deleteSubscription(subscriptionId: number): Promise<void> {
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}`, {
method: 'DELETE'
method: 'DELETE',
headers: authHeaders()
});
if (!response.ok) {
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(
subscriptionId: string
subscriptionId: string,
limit: number = 20,
offset: number = 0
): 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) {
throw new Error('Failed to fetch subscription notifications');
}
@@ -219,7 +352,9 @@ export async function fetchSubscriptionNotifications(
// Fetch all notifications or filter by topic
export async function fetchAllNotifications(): Promise<Notification[]> {
const url = `${API_URL}/notifications`;
const response = await fetch(url);
const response = await fetch(url, {
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch notifications');
}
@@ -229,7 +364,8 @@ export async function fetchAllNotifications(): Promise<Notification[]> {
// Delete a notification
export async function deleteNotification(notificationId: number): Promise<void> {
const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
method: 'DELETE'
method: 'DELETE',
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to delete notification');
@@ -238,7 +374,9 @@ export async function deleteNotification(notificationId: number): Promise<void>
// Fetch a single script by ID
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) {
throw new Error('Failed to fetch script');
}
@@ -249,9 +387,7 @@ export async function fetchScriptById(id: number): Promise<Script> {
export async function updateScript(id: number, updatedScript: Partial<Script>): Promise<Script> {
const response = await fetch(`${API_URL}/script/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(updatedScript)
});
if (!response.ok) {
@@ -262,7 +398,9 @@ export async function updateScript(id: number, updatedScript: Partial<Script>):
// Fetch logs for a specific script
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) {
throw new Error('Failed to fetch logs');
}
@@ -273,9 +411,7 @@ export async function fetchLogs(scriptId: number): Promise<Log[]> {
export async function addLog(scriptId: number, log: Log): Promise<Log> {
const response = await fetch(`${API_URL}/script/${scriptId}/log`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(log)
});
if (!response.ok) {
@@ -287,7 +423,8 @@ export async function addLog(scriptId: number, log: Log): Promise<Log> {
// Execute a script by ID
export async function executeScript(scriptId: number): Promise<{ message: string }> {
const response = await fetch(`${API_URL}/script/${scriptId}/execute`, {
method: 'POST'
method: 'POST',
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to execute script');
@@ -298,7 +435,8 @@ export async function executeScript(scriptId: number): Promise<{ message: string
// Delete a log from a specific script
export async function deleteLog(scriptId: number, logId: number): Promise<void> {
const response = await fetch(`${API_URL}/script/${scriptId}/log/${logId}`, {
method: 'DELETE'
method: 'DELETE',
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to delete log');
@@ -309,7 +447,8 @@ export async function deleteLog(scriptId: number, logId: number): Promise<void>
// Delete a script
export async function deleteScript(id: number): Promise<void> {
const response = await fetch(`${API_URL}/script/${id}`, {
method: 'DELETE'
method: 'DELETE',
headers: authHeaders()
});
if (!response.ok) {
throw new Error('Failed to delete script');

View File

@@ -4,6 +4,14 @@
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
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();
interface Notification {
@@ -12,10 +20,12 @@
message: string;
}
let notifications: Notification[] = $state([]);
let notificationId = 0;
let healthStatus = writable<'healthy' | 'unhealthy'>('unhealthy');
function checkAuth() {
if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem('token');
isAuthenticated = !!token;
}
}
async function updateHealthStatus() {
const status = await checkHealth();
@@ -30,10 +40,23 @@
}, 4000);
}
function logout() {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('token');
isAuthenticated = false;
goto('/login');
}
}
onMount(() => {
window.showNotification = showNotification;
updateHealthStatus();
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>
@@ -41,29 +64,37 @@
<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>
<div class="flex space-x-6">
<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="/settings" class="text-lg hover:text-gray-400">
<Icon icon="material-symbols:settings" width="24" height="24" />
</a>
{#if isAuthenticated}
<a href="/scripts" class="text-lg hover:text-gray-400">Scripts</a>
<a href="/notifications" class="text-lg hover:text-gray-400">Notifications</a>
<button class="text-lg hover:text-gray-400" onclick={logout}>Logout</button>
<a href="/settings" class="text-lg hover:text-gray-400">
<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>
</nav>
<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">
{#each notifications as notification (notification.id)}
<div
class="p-4 rounded shadow-lg text-white"
class:bg-green-500={notification.type === 'success'}
class:bg-red-500={notification.type === 'error'}
>
{notification.message}
</div>
{/each}
</div>
<div class="fixed bottom-4 right-4 space-y-2">
{#each notifications as notification (notification.id)}
<div
class="p-4 rounded shadow-lg text-white"
class:bg-green-500={notification.type === 'success'}
class:bg-red-500={notification.type === 'error'}
>
{notification.message}
</div>
{/each}
</div>
<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">
import { addSubscription, deleteSubscription, fetchSubscriptions } 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 loading: boolean = true;
let errorMsg: string | null = null;
onMount(async () => {
loading = true;
errorMsg = null;
try {
subscriptions = await fetchSubscriptions();
} catch (error) {
console.error('Failed to load subscriptions:', error);
errorMsg = 'Failed to load subscriptions';
subscriptions = [];
}
loading = false;
});
async function handleAddSubscription() {
if (!newTopic.trim()) {
@@ -35,19 +50,48 @@
<main class="p-4">
<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">
{#each subscriptions as subscription (subscription.id)}
<a
href={`/notifications/${subscription.id}`}
class="relative block p-4 border rounded bg-white hover:bg-gray-100 shadow-lg shadow-blue-500/50"
>
{#if subscription.has_unread}
<span class="absolute top-2 right-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>
{/each}
</div>
{#if loading}
<p>Loading...</p>
{:else if errorMsg}
<p class="text-red-500">{errorMsg}</p>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{#each subscriptions as subscription (subscription.id)}
<div
class="relative block p-4 border rounded bg-white hover:bg-gray-100 shadow-lg shadow-blue-500/50"
>
<!-- Red cross button for delete -->
<button
class="absolute top-2 right-2 w-6 h-6 flex items-center justify-center bg-transparent text-red-600 hover:text-red-800"
aria-label="Delete subscription"
on:click|stopPropagation={() => handleDeleteSubscription(subscription.id)}
tabindex="0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" />
<line x1="6" y1="18" x2="18" y2="6" stroke="currentColor" stroke-width="2" />
</svg>
</button>
<a
href={`/notifications/${subscription.id}`}
class="block"
style="text-decoration: none;"
>
{#if subscription.has_unread}
<span class="absolute top-2 left-2 w-3 h-3 bg-green-500 rounded-full"></span>
{/if}
<h2 class="text-lg font-semibold text-gray-800">{subscription.topic}</h2>
</a>
</div>
{/each}
</div>
{/if}
<div class="mt-8">
<h2 class="text-xl font-semibold mb-2">Add New Subscription</h2>

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">
import { deleteNotification, addNotification, setViewed } from '$lib/api';
import {
deleteNotification,
setViewed,
fetchSubscriptionNotifications,
deleteSubscriptionNotifications,
markAllNotificationsAsViewed
} 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;
let newNotificationTitle = '';
let newNotificationMessage = '';
let newNotificationPriority = 3;
let selectedNotification: Notification | null = null;
// Delete all notifications for this subscription
async function handleDeleteAllNotifications() {
if (notifications.length === 0) return;
const confirmed = window.confirm(
'Are you sure you want to delete all notifications for this subscription?'
);
if (!confirmed) return;
try {
deleteSubscriptionNotifications(subscription!.id);
notifications = [];
window.showNotification('success', 'All notifications deleted successfully.');
} catch (error) {
window.showNotification('error', 'Failed to delete all notifications - ' + error);
}
}
// Pagination state
let limit = 20;
let offset = $derived(notifications.length);
let loadingMore = $state(false);
let allLoaded = $derived(notifications.length < limit);
async function openNotificationPopup(notification: Notification) {
if (!notification.viewed) {
@@ -26,7 +51,9 @@
selectedNotification = null;
}
async function handleDeleteNotification(id: number) {
async function handleDeleteNotification(e, id: number) {
e.stopPropagation();
try {
await deleteNotification(id);
notifications = notifications.filter((notification) => notification.id !== id);
@@ -37,11 +64,7 @@
}
async function markAllViewed() {
try {
await Promise.all(
notifications
.filter((notification) => !notification.viewed)
.map((notification) => setViewed(notification.id))
);
markAllNotificationsAsViewed(subscription!.id);
notifications = notifications.map((notification) =>
notification.viewed ? notification : { ...notification, viewed: true }
);
@@ -50,16 +73,41 @@
window.showNotification('error', 'Failed to mark all notifications as viewed.');
}
}
// Load more notifications
async function loadMoreNotifications() {
loadingMore = true;
try {
const more = await fetchSubscriptionNotifications(subscription!.id.toString(), limit, offset);
notifications = [...notifications, ...more];
offset += more.length;
if (more.length < limit) {
allLoaded = true;
}
} catch (err) {
window.showNotification('error', 'Failed to load more notifications - ' + err);
}
loadingMore = false;
}
</script>
<main class="p-4">
<h1 class="text-2xl font-bold mb-4">Notifications for {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">
<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}>
Mark All Viewed
</button>
<div class="flex gap-2">
<button class="bg-blue-500 text-white px-4 py-2 rounded" onclick={markAllViewed}>
Mark All Viewed
</button>
<button
class="bg-red-500 text-white px-4 py-2 rounded"
onclick={handleDeleteAllNotifications}
disabled={notifications.length === 0}
>
Delete All Notifications
</button>
</div>
</div>
{#if notifications.length === 0}
@@ -71,9 +119,14 @@
class="p-2 rounded flex justify-between items-center border-s-slate-400 border-1"
class:bg-green-200={notification.viewed}
>
<button class="p-2 w-full text-left" on:click={() => openNotificationPopup(notification)}>
<button class="p-2 w-full text-left" onclick={() => openNotificationPopup(notification)}>
<div>
<p class="font-semibold">{notification.title}</p>
<p>
{notification.message.split('\n')[0].length > 75
? `${notification.message.split('\n')[0].slice(0, 75)}...`
: notification.message.split('\n')[0]}
</p>
<p class="text-sm text-gray-500">
{new Date(notification.created_at).toLocaleString()}
</p>
@@ -81,7 +134,7 @@
</button>
<button
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
</button>
@@ -89,6 +142,17 @@
{/each}
</ul>
{/if}
{#if !allLoaded && notifications.length > 0}
<div class="flex justify-center mt-6">
<button
class="bg-gray-200 px-4 py-2 rounded hover:bg-gray-300"
onclick={loadMoreNotifications}
disabled={loadingMore}
>
{loadingMore ? 'Loading...' : 'Load More'}
</button>
</div>
{/if}
{#if selectedNotification}
<div class="fixed inset-0 bg-opacity-30 backdrop-blur-sm flex items-center justify-center z-50">
<div class="bg-white p-6 rounded shadow-lg w-3/4 max-w-2xl max-h-[80vh] overflow-y-auto">
@@ -116,7 +180,7 @@
</div>
<button
class="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
on:click={closeNotificationPopup}
onclick={closeNotificationPopup}
>
Close
</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">
import { addScript } from '$lib/api';
import { addScript, fetchScripts } from '$lib/api';
import type { Script } from '$lib/api';
import CodeMirror from 'svelte-codemirror-editor';
import { python } from '@codemirror/lang-python';
import { onMount } from 'svelte';
export let data: { scripts: Script[] };
let scripts: Script[] = data.scripts;
let newScript: Omit<Script, 'id' | 'created_at'> = { name: '', script_content: '' };
let scripts: Script[] = [];
let newScript: Omit<Script, 'id' | 'created_at'> = {
name: '',
script_content: '',
enabled: false
};
onMount(async () => {
scripts = await fetchScripts();
});
// Add a new script
async function handleAddScript() {
try {
const addedScript = await addScript(newScript);
scripts = [...scripts, addedScript];
newScript = { name: '', script_content: '' };
newScript = { name: '', script_content: '', enabled: false };
} catch (err) {
window.showNotification('Failed to add script. ' + err);
}
@@ -38,11 +46,7 @@
<div class="mt-8">
<h2 class="text-xl font-semibold mb-2">Add New Script</h2>
<form
on:submit|preventDefault={handleAddScript}
class="space-y-4 p-4 border rounded shadow"
on:submit={() => (newScript.script_content = editor.getValue())}
>
<form onsubmit={handleAddScript} class="space-y-4 p-4 border rounded shadow">
<div>
<label for="name" class="block text-sm font-medium">Name</label>
<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 {
updateScript,
deleteScript,
addLog,
deleteLog,
executeScript,
fetchScriptById,
fetchLogs
} from '$lib/api';
import type { Script, Log } from '$lib/api';
import CodeMirror from 'svelte-codemirror-editor';
import { python } from '@codemirror/lang-python';
import { onMount } from 'svelte';
export let data: { script: Script; logs: Log[] };
let script: Script = data.script;
let logs: Log[] = data.logs;
let updatedTitle: string = script.name || '';
let updatedContent: string = script.script_content || '';
let updatedEnabled: boolean = script.enabled || false;
export let params: { id: string };
let script: Script | null = null;
let logs: Log[] = [];
let updatedTitle: string = '';
let updatedContent: string = '';
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 newLog: Omit<Log, 'id' | 'script_id'> = {
message: '',
error_code: 0,
error_message: ''
};
let selectedLog: Log | null = null;
function openLogPopup(log: Log) {
@@ -38,10 +57,10 @@
async function handleExecuteScript() {
try {
await executeScript(script.id);
await executeScript(script!.id);
window.showNotification('success', 'Script executed successfully!');
// 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()
);
} 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) {
try {
await deleteLog(script.id, logId);
await deleteLog(script!.id, logId);
logs = logs.filter((log) => log.id !== logId);
window.showNotification('success', 'Log deleted successfully!');
} catch (err) {
@@ -109,7 +111,11 @@
<main class="p-4">
<!-- 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}
<input
type="text"
@@ -190,46 +196,6 @@
<section class="mt-8">
<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">
{#each logs as log (log.id)}
<li

View File

@@ -1,88 +1,185 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchSettings, updateSetting } from '$lib/api';
import type { Settings } from '$lib/api';
import { writable } from 'svelte/store';
import { fetchUserSettings, updateSetting, fetchUsers, updateUser, deleteUser } from '$lib/api';
import type { Settings, User } from '$lib/api';
import CodeMirror from 'svelte-codemirror-editor';
let settings = writable<Settings[]>([]);
let isLoading = writable(false);
let error = writable<string | null>(null);
let settings: Settings | null = $state(null);
let users: User[] = $state([]);
let currentUser: string | null = $state(localStorage.getItem('username'));
let isLoading = $state(false);
let error: string | null = $state(null);
async function loadSettings() {
isLoading.set(true);
error.set(null);
isLoading = true;
error = null;
try {
const data = await fetchSettings();
settings.set(data);
const data = await fetchUserSettings();
settings = data;
} catch (err) {
error.set('Failed to load settings');
error = 'Failed to load settings - ' + err;
} 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) {
isLoading.set(true);
error.set(null);
isLoading = true;
error = null;
try {
await updateSetting(setting.id, setting);
loadSettings(); // Refresh settings after update
} catch (err) {
error.set('Failed to save setting');
error = 'Failed to save settings - ' + err;
} 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(() => {
loadSettings();
loadUsers();
});
</script>
<div class="container mx-auto p-4">
<h1 class="text-2xl font-bold mb-4">Settings</h1>
{#if $isLoading}
{#if isLoading}
<p>Loading...</p>
{:else if $error}
{:else if error}
<p class="text-red-500">{$error}</p>
{:else}
{:else if settings !== null}
<div class="space-y-4">
{#each $settings as setting (setting.id)}
<div class="p-4 border rounded shadow">
<label class="block mb-2 font-bold">
Requirements
<div class="w-full border rounded">
<CodeMirror bind:value={setting.requirements} />
</div>
</label>
<div class="p-4 border rounded shadow">
<label class="block mb-2 font-bold">
Requirements
<div class="w-full border rounded font-normal">
<CodeMirror bind:value={settings.requirements} />
</div>
</label>
<label class="block mt-4 mb-2 font-bold">
Environment
<div class="w-full border rounded">
<CodeMirror bind:value={setting.environment} />
</div>
</label>
<label class="block mt-4 mb-2 font-bold">
Environment
<div class="w-full border rounded font-normal">
<CodeMirror bind:value={settings.environment} />
</div>
</label>
<label class="block mt-4 mb-2 font-bold">
Ntfy URL
<input
type="text"
class="w-full p-2 border rounde font-normal"
bind:value={setting.ntfy_url}
/>
</label>
<label class="block mt-4 mb-2 font-bold">
Ntfy URL
<input
type="text"
class="w-full p-2 border rounde font-normal"
bind:value={settings.ntfy_url}
/>
</label>
<button
class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
on:click={() => saveSetting(setting)}
>
Save
</button>
</div>
{/each}
<button
class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
onclick={() => saveSetting(settings!)}
>
Save
</button>
</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}
</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