Compare commits
14 Commits
5ba5107a3b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
678eb22d2b | ||
|
|
e913eb34f2 | ||
|
|
2f9f75ee3c | ||
|
|
45b78d5faf | ||
|
|
dff07ef340 | ||
|
|
625b231de5 | ||
|
|
013ddb26c7 | ||
|
|
657a224163 | ||
|
|
c957d839dd | ||
|
|
8eef535e02 | ||
|
|
e9d94f706c | ||
|
|
374558d30f | ||
|
|
16989ed518 | ||
|
|
d3df001397 |
@@ -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
67
backend/auth.py
Normal 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
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3,3 +3,7 @@ uvicorn
|
||||
fastapi
|
||||
sqlalchemy
|
||||
alembic
|
||||
passlib
|
||||
python-jose
|
||||
argon2_cffi
|
||||
python-multipart
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,19 +64,28 @@
|
||||
<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">
|
||||
{#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">
|
||||
{#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)}
|
||||
<div
|
||||
class="p-4 rounded shadow-lg text-white"
|
||||
@@ -63,7 +95,6 @@
|
||||
{notification.message}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed bottom-4 left-4 group">
|
||||
|
||||
96
frontend/src/routes/login/+page.svelte
Normal file
96
frontend/src/routes/login/+page.svelte
Normal 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>
|
||||
@@ -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: [] };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
{#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)}
|
||||
<a
|
||||
href={`/notifications/${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 right-2 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
<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>
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
};
|
||||
@@ -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}>
|
||||
<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>
|
||||
|
||||
23
frontend/src/routes/notifications/[subscription_id]/+page.ts
Normal file
23
frontend/src/routes/notifications/[subscription_id]/+page.ts
Normal 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
|
||||
};
|
||||
}
|
||||
};
|
||||
86
frontend/src/routes/register/+page.svelte
Normal file
86
frontend/src/routes/register/+page.svelte
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,67 +1,106 @@
|
||||
<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 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 class="w-full border rounded font-normal">
|
||||
<CodeMirror bind:value={settings.environment} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -70,19 +109,77 @@
|
||||
<input
|
||||
type="text"
|
||||
class="w-full p-2 border rounde font-normal"
|
||||
bind:value={setting.ntfy_url}
|
||||
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)}
|
||||
onclick={() => saveSetting(settings!)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</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
12
start.sh
Executable 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
|
||||
Reference in New Issue
Block a user