Compare commits
29 Commits
6afc50eb81
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
678eb22d2b | ||
|
|
e913eb34f2 | ||
|
|
2f9f75ee3c | ||
|
|
45b78d5faf | ||
|
|
dff07ef340 | ||
|
|
625b231de5 | ||
|
|
013ddb26c7 | ||
|
|
657a224163 | ||
|
|
c957d839dd | ||
|
|
8eef535e02 | ||
|
|
e9d94f706c | ||
|
|
374558d30f | ||
|
|
16989ed518 | ||
|
|
d3df001397 | ||
|
|
5ba5107a3b | ||
|
|
90cb14eb2a | ||
|
|
3c6acc9359 | ||
|
|
6460cab465 | ||
|
|
ec379769e8 | ||
|
|
887d100bdc | ||
| e0f60d9d73 | |||
|
|
81f078f0a9 | ||
|
|
5f5beaae2f | ||
|
|
f73c66c1a1 | ||
|
|
a6f93edf72 | ||
|
|
b76cef22ae | ||
|
|
fcb875aaf9 | ||
|
|
c8aa5e9917 | ||
|
|
288a40952e |
35
.gitea/workflows/build-container.yml
Normal file
35
.gitea/workflows/build-container.yml
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Build Container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: gitea.abzk.fr
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: gitea.abzk.fr/mrzaiko/project-monitor:latest
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ __pycache__
|
||||
*.db
|
||||
.envrc
|
||||
exec_folder/
|
||||
.env
|
||||
|
||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
# Use an official Python image as the base
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Install Node.js, npm, and cron
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
cron \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Setup backend
|
||||
WORKDIR /app/backend
|
||||
ADD backend .
|
||||
RUN python3 -m venv venv
|
||||
RUN . /app/backend/venv/bin/activate && pip install -r requirements.txt
|
||||
|
||||
WORKDIR /app/frontend
|
||||
ADD frontend .
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY docker/* .
|
||||
RUN chmod 0644 services.cron
|
||||
RUN crontab services.cron
|
||||
RUN chmod +x entry.sh
|
||||
|
||||
EXPOSE 8080 8000
|
||||
|
||||
# Command to start cron and the backend
|
||||
CMD ["sh", "-c", "/app/entry.sh"]
|
||||
147
backend/alembic.ini
Normal file
147
backend/alembic.ini
Normal file
@@ -0,0 +1,147 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = sqlite:///./project_monitor.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
77
backend/alembic/env.py
Normal file
77
backend/alembic/env.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
from model import Base
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' supports
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
32
backend/alembic/versions/9a7bb433ef44_initial_migration.py
Normal file
32
backend/alembic/versions/9a7bb433ef44_initial_migration.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: 9a7bb433ef44
|
||||
Revises:
|
||||
Create Date: 2025-10-14 20:19:30.322104
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9a7bb433ef44'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
67
backend/auth.py
Normal file
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
|
||||
from run_scripts import run_scripts
|
||||
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,18 +101,455 @@ def hello():
|
||||
return {"message": "Welcome to the Project Monitor API"}
|
||||
|
||||
|
||||
@app.get("/script", response_model=list[ScriptResponse])
|
||||
def read_scripts():
|
||||
# User Management Endpoints
|
||||
@app.get("/users", response_model=list[UserResponse])
|
||||
def list_users(current_user: User = Depends(get_current_user)):
|
||||
db = SessionLocal()
|
||||
scripts = db.query(Script).all()
|
||||
users = db.query(User).all()
|
||||
db.close()
|
||||
return users
|
||||
|
||||
|
||||
@app.put("/users/{user_id}")
|
||||
def update_user(
|
||||
user_id: int,
|
||||
user: UserCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
db = SessionLocal()
|
||||
existing_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not existing_user:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
existing_user.username = user.username
|
||||
existing_user.password_hash = get_password_hash(user.password)
|
||||
db.commit()
|
||||
db.refresh(existing_user)
|
||||
db.close()
|
||||
return {"message": "User updated successfully"}
|
||||
|
||||
|
||||
@app.delete("/users/{user_id}")
|
||||
def delete_user(user_id: int, current_user: User = Depends(get_current_user)):
|
||||
db = SessionLocal()
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"message": "User deleted successfully"}
|
||||
|
||||
|
||||
@app.post("/register", response_model=Token)
|
||||
def register(user: UserCreate):
|
||||
db = SessionLocal()
|
||||
existing_user = db.query(User).filter(User.username == user.username).first()
|
||||
if existing_user:
|
||||
db.close()
|
||||
raise HTTPException(status_code=400, detail="Username already registered")
|
||||
hashed_password = get_password_hash(user.password)
|
||||
new_user = User(username=user.username, password_hash=hashed_password)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
access_token = create_access_token(data={"sub": new_user.username})
|
||||
db.close()
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@app.post("/login", response_model=Token)
|
||||
def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
db = SessionLocal()
|
||||
user = authenticate_user(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
db.close()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token = create_access_token(data={"sub": user.username})
|
||||
db.close()
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
class SubscriptionCreate(BaseModel):
|
||||
topic: str
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
id: int
|
||||
topic: str
|
||||
created_at: datetime
|
||||
has_unread: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# Subscriptions API Endpoints
|
||||
@app.get("/subscriptions", response_model=list[SubscriptionResponse])
|
||||
def list_subscriptions(current_user: User = Depends(get_current_user)):
|
||||
db = SessionLocal()
|
||||
subscriptions = (
|
||||
db.query(Subscription).filter(Subscription.user_id == current_user.id).all()
|
||||
)
|
||||
|
||||
# TODO: find a better way to do this
|
||||
for subscription in subscriptions:
|
||||
not_viewed_count = (
|
||||
db.query(Notification)
|
||||
.filter(
|
||||
Notification.subscription_id == subscription.id,
|
||||
~Notification.viewed,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
subscription.has_unread = not_viewed_count > 0
|
||||
|
||||
db.close()
|
||||
return subscriptions
|
||||
|
||||
|
||||
@app.get("/subscriptions/{subscription_id}", response_model=SubscriptionResponse)
|
||||
def get_subscription(
|
||||
subscription_id: int, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
db = SessionLocal()
|
||||
subscription = (
|
||||
db.query(Subscription).filter(Subscription.id == subscription_id).first()
|
||||
)
|
||||
if not subscription:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
|
||||
# checking if subscription has unread messages
|
||||
subscription.has_unread = (
|
||||
db.query(Notification)
|
||||
.filter(
|
||||
Notification.subscription_id == subscription_id and not Notification.viewed
|
||||
)
|
||||
.count()
|
||||
> 0
|
||||
)
|
||||
|
||||
db.close()
|
||||
return subscription
|
||||
|
||||
|
||||
@app.post("/subscriptions")
|
||||
def add_subscription(
|
||||
subscription: SubscriptionCreate, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
db = SessionLocal()
|
||||
existing_subscription = (
|
||||
db.query(Subscription).filter(Subscription.topic == subscription.topic).first()
|
||||
)
|
||||
if existing_subscription:
|
||||
db.close()
|
||||
raise HTTPException(status_code=400, detail="Subscription already exists")
|
||||
new_subscription = Subscription(topic=subscription.topic, user_id=current_user.id)
|
||||
db.add(new_subscription)
|
||||
db.commit()
|
||||
db.refresh(new_subscription)
|
||||
db.close()
|
||||
return new_subscription
|
||||
|
||||
|
||||
@app.delete("/subscriptions/{subscription_id}")
|
||||
def remove_subscription(
|
||||
subscription_id: int, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
db = SessionLocal()
|
||||
subscription = (
|
||||
db.query(Subscription).filter(Subscription.id == subscription_id).first()
|
||||
)
|
||||
if not subscription:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
db.delete(subscription)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"message": "Subscription removed"}
|
||||
|
||||
|
||||
@app.get("/subscriptions/{subscription_id}/notifications")
|
||||
def list_subscription_notifications(
|
||||
subscription_id: int,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
db = SessionLocal()
|
||||
notifications = (
|
||||
db.query(Notification)
|
||||
.filter(Notification.subscription_id == subscription_id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
db.close()
|
||||
return [
|
||||
NotificationResponse.model_validate(notification)
|
||||
for notification in notifications
|
||||
]
|
||||
|
||||
|
||||
@app.post("/subscriptions/{subscription_id}/notifications")
|
||||
def set_all_notifications_viewed(
|
||||
subscription_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
db = SessionLocal()
|
||||
notifications = (
|
||||
db.query(Notification)
|
||||
.filter(Notification.subscription_id == subscription_id)
|
||||
.all()
|
||||
)
|
||||
for notification in notifications:
|
||||
notification.viewed = True
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"message": "Notifications marked as viewed"}
|
||||
|
||||
|
||||
@app.delete("/subscriptions/{subscription_id}/notifications")
|
||||
def remove_subscription_notifications(
|
||||
subscription_id: int, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
db = SessionLocal()
|
||||
notifications = (
|
||||
db.query(Notification)
|
||||
.filter(Notification.subscription_id == subscription_id)
|
||||
.all()
|
||||
)
|
||||
for notification in notifications:
|
||||
db.delete(notification)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"message": "Notifications removed"}
|
||||
|
||||
|
||||
@app.get("/notifications")
|
||||
def list_notifications(current_user: User = Depends(get_current_user)):
|
||||
db = SessionLocal()
|
||||
notifications = db.query(Notification).all()
|
||||
db.close()
|
||||
return [
|
||||
NotificationResponse.model_validate(notification)
|
||||
for notification in notifications
|
||||
]
|
||||
|
||||
|
||||
@app.delete("/notifications/{notification_id}")
|
||||
def remove_notification(
|
||||
notification_id: int, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
db = SessionLocal()
|
||||
notification = (
|
||||
db.query(Notification).filter(Notification.id == notification_id).first()
|
||||
)
|
||||
if not notification:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
db.delete(notification)
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"message": "Notification removed"}
|
||||
|
||||
|
||||
class NotificationCreate(BaseModel):
|
||||
subscription_id: int
|
||||
title: str
|
||||
message: str
|
||||
priority: int
|
||||
|
||||
|
||||
class NotificationUpdate(BaseModel):
|
||||
subscription_id: int | None = None
|
||||
title: str | None = None
|
||||
message: str | None = None
|
||||
priority: int | None = None
|
||||
viewed: bool | None = None
|
||||
|
||||
|
||||
class NotificationResponse(NotificationCreate):
|
||||
id: int
|
||||
created_at: datetime
|
||||
viewed: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@app.put("/notifications/{notification_id}", response_model=NotificationResponse)
|
||||
def update_notification(
|
||||
notification_id: int,
|
||||
notification: NotificationUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
db = SessionLocal()
|
||||
existing_notification = (
|
||||
db.query(Notification).filter(Notification.id == notification_id).first()
|
||||
)
|
||||
if not existing_notification:
|
||||
db.close()
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
if notification.subscription_id is not None:
|
||||
existing_notification.subscription_id = notification.subscription_id
|
||||
if notification.title is not None:
|
||||
existing_notification.title = notification.title
|
||||
if notification.message is not None:
|
||||
existing_notification.message = notification.message
|
||||
if notification.priority is not None:
|
||||
existing_notification.priority = notification.priority
|
||||
if notification.viewed is not None:
|
||||
existing_notification.viewed = notification.viewed
|
||||
db.commit()
|
||||
db.refresh(existing_notification)
|
||||
db.close()
|
||||
return existing_notification
|
||||
|
||||
|
||||
@app.post("/notifications", response_model=NotificationResponse)
|
||||
def create_notification(
|
||||
notification: NotificationCreate, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
db = SessionLocal()
|
||||
new_notification = Notification(
|
||||
subscription_id=notification.subscription_id,
|
||||
title=notification.title,
|
||||
message=notification.message,
|
||||
priority=notification.priority,
|
||||
)
|
||||
db.add(new_notification)
|
||||
db.commit()
|
||||
db.refresh(new_notification)
|
||||
db.close()
|
||||
return new_notification
|
||||
|
||||
|
||||
# Define Pydantic models for Settings
|
||||
class SettingsBase(BaseModel):
|
||||
requirements: str
|
||||
environment: str
|
||||
ntfy_url: str
|
||||
|
||||
|
||||
class SettingsUpdate(SettingsBase):
|
||||
pass
|
||||
|
||||
|
||||
class SettingsResponse(SettingsBase):
|
||||
id: int
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# Settings API Endpoints
|
||||
@app.get("/settings", response_model=SettingsResponse)
|
||||
def read_settings(current_user: User = Depends(get_current_user)):
|
||||
db = SessionLocal()
|
||||
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, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
db = SessionLocal()
|
||||
new_setting = Settings(**settings.model_dump(), user_id=current_user.id)
|
||||
db.add(new_setting)
|
||||
db.commit()
|
||||
db.refresh(new_setting)
|
||||
db.close()
|
||||
|
||||
return new_setting
|
||||
|
||||
|
||||
@app.get("/settings/{settings_id}", response_model=SettingsResponse)
|
||||
def read_setting(settings_id: int, current_user: User = Depends(get_current_user)):
|
||||
db = SessionLocal()
|
||||
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")
|
||||
return setting
|
||||
|
||||
|
||||
@app.put("/settings/{settings_id}", response_model=SettingsResponse)
|
||||
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, Settings.user_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if not existing_setting:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
|
||||
if settings.requirements and existing_setting.requirements != settings.requirements:
|
||||
existing_setting.requirements = settings.requirements
|
||||
update_requirements(settings)
|
||||
|
||||
if settings.environment and existing_setting.environment != settings.environment:
|
||||
existing_setting.environment = settings.environment
|
||||
update_environment(settings)
|
||||
|
||||
if settings.ntfy_url is not None:
|
||||
existing_setting.ntfy_url = settings.ntfy_url
|
||||
|
||||
db.commit()
|
||||
db.refresh(existing_setting)
|
||||
db.close()
|
||||
|
||||
return existing_setting
|
||||
|
||||
|
||||
@app.get("/script", response_model=list[ScriptResponse])
|
||||
def read_scripts(current_user: User = Depends(get_current_user)):
|
||||
db = SessionLocal()
|
||||
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)
|
||||
@@ -72,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()
|
||||
@@ -82,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:
|
||||
@@ -98,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:
|
||||
@@ -114,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()
|
||||
@@ -122,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,
|
||||
@@ -138,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:
|
||||
@@ -150,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}
|
||||
|
||||
|
||||
113
backend/get_notifications.py
Normal file
113
backend/get_notifications.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import os
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from model import SessionLocal, Subscription, Settings, Notification
|
||||
import json
|
||||
|
||||
# Constants
|
||||
NTFY_TOKEN = os.getenv("NTFY_TOKEN")
|
||||
|
||||
|
||||
def fetch_ntfy_notifications(base_url, subscriptions):
|
||||
"""Fetch notifications from the ntfy.sh server for the given subscriptions using streaming."""
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {NTFY_TOKEN}" if NTFY_TOKEN else None,
|
||||
}
|
||||
|
||||
notifications = []
|
||||
for subscription in subscriptions:
|
||||
print(f"Fetching notifications for {subscription.topic}")
|
||||
topic = subscription.topic
|
||||
last_message_id = subscription.last_message_id
|
||||
since_param = "all" if last_message_id is None else last_message_id
|
||||
url = f"{base_url}/{topic}/json?poll=1&since={since_param}"
|
||||
response = requests.get(url, headers=headers, stream=True)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
notification = json.loads(line)
|
||||
if notification.get("event") == "message":
|
||||
notifications.append(notification)
|
||||
|
||||
print(f"Fetched {len(notifications)} notifications")
|
||||
return notifications
|
||||
|
||||
|
||||
def save_notifications_to_db(notifications, topic_to_subscription, db):
|
||||
"""Save the fetched notifications to the database and update last_message_id."""
|
||||
last_message_ids = {}
|
||||
for notification in notifications:
|
||||
topic = notification["topic"]
|
||||
last_message_ids[topic] = notification["id"]
|
||||
subscription_id = topic_to_subscription.get(notification["topic"])
|
||||
if subscription_id:
|
||||
new_notification = Notification(
|
||||
title=notification.get("title", "No Title"),
|
||||
message=notification.get("message", ""),
|
||||
priority=notification.get("priority", 3),
|
||||
created_at=datetime.fromtimestamp(notification["time"]),
|
||||
subscription_id=subscription_id,
|
||||
)
|
||||
db.add(new_notification)
|
||||
for topic, message_id in last_message_ids.items():
|
||||
subscription_id = topic_to_subscription.get(topic)
|
||||
if subscription_id:
|
||||
subscription = (
|
||||
db.query(Subscription)
|
||||
.filter(Subscription.id == subscription_id)
|
||||
.first()
|
||||
)
|
||||
if subscription:
|
||||
subscription.last_message_id = message_id
|
||||
db.commit()
|
||||
|
||||
|
||||
def process_user_notifications(user_settings, db):
|
||||
"""Process notifications for a specific user's subscriptions."""
|
||||
ntfy_url = user_settings.ntfy_url
|
||||
|
||||
if not ntfy_url:
|
||||
print(f"Ntfy URL not found for user ID {user_settings.user_id}. Skipping...")
|
||||
return
|
||||
|
||||
# Get all subscriptions for the user
|
||||
subscriptions = (
|
||||
db.query(Subscription)
|
||||
.filter(Subscription.user_id == user_settings.user_id)
|
||||
.all()
|
||||
)
|
||||
topic_to_subscription = {
|
||||
subscription.topic: subscription.id for subscription in subscriptions
|
||||
}
|
||||
|
||||
# Fetch notifications from ntfy.sh
|
||||
notifications = fetch_ntfy_notifications(ntfy_url, subscriptions)
|
||||
|
||||
# Save notifications to the database
|
||||
save_notifications_to_db(notifications, topic_to_subscription, db)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to fetch and save notifications for all users."""
|
||||
db = SessionLocal()
|
||||
|
||||
# Get all user settings
|
||||
user_settings_list = db.query(Settings).all()
|
||||
|
||||
if not user_settings_list:
|
||||
print("No user settings found.")
|
||||
return
|
||||
|
||||
# Process notifications for each user
|
||||
for user_settings in user_settings_list:
|
||||
print(f"Processing notifications for user ID {user_settings.user_id}")
|
||||
process_user_notifications(user_settings, db)
|
||||
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
113
backend/model.py
113
backend/model.py
@@ -1,12 +1,18 @@
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey, Boolean
|
||||
from sqlalchemy.sql.sqltypes import DateTime
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.sql.functions import func
|
||||
from sqlalchemy.sql.sqltypes import DateTime
|
||||
from sqlalchemy.types import Boolean
|
||||
import os
|
||||
import secrets
|
||||
from passlib.context import CryptContext
|
||||
|
||||
# Initialize the database
|
||||
DATABASE_URL = "sqlite:///./project_monitor.db"
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
if not DATABASE_URL:
|
||||
raise ValueError("DATABASE_URL environment variable is not set")
|
||||
|
||||
# SQLAlchemy setup
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
@@ -14,7 +20,15 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# 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):
|
||||
@@ -27,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):
|
||||
@@ -40,8 +57,94 @@ 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):
|
||||
__tablename__ = "user_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
requirements = Column(String, nullable=False)
|
||||
environment = Column(String, nullable=False)
|
||||
ntfy_url = Column(String, nullable=True)
|
||||
user_id = Column(
|
||||
Integer, ForeignKey("users.id", name="fk_user_settings_user_id"), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
topic = Column(String, nullable=False, unique=True)
|
||||
last_message_id = Column(String, nullable=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
user_id = Column(
|
||||
Integer, ForeignKey("users.id", name="fk_subscription_user_id"), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
message = Column(Text, nullable=False)
|
||||
priority = Column(Integer, nullable=False, default=3)
|
||||
viewed = Column(Boolean, default=False)
|
||||
sent = Column(Boolean, default=False)
|
||||
|
||||
subscription_id = Column(
|
||||
Integer,
|
||||
ForeignKey("subscriptions.id", name="fk_notification_subscription_id"),
|
||||
nullable=False,
|
||||
)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
# Create the database tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
# Ensure a default admin user exists
|
||||
def ensure_default_setting():
|
||||
db = SessionLocal()
|
||||
admin_user = db.query(User).filter(User.username == "admin").first()
|
||||
if not admin_user:
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
random_password = secrets.token_urlsafe(12)
|
||||
password_hash = pwd_context.hash(random_password)
|
||||
admin_user = User(username="admin", password_hash=password_hash)
|
||||
db.add(admin_user)
|
||||
db.commit()
|
||||
print(
|
||||
f"Default admin user created. Username: admin, Password: {random_password}"
|
||||
)
|
||||
# Refresh to get admin_user.id
|
||||
db.refresh(admin_user)
|
||||
# Set all rows with null user_id in Script and Subscription to admin user id
|
||||
db.query(Script).filter(Script.user_id is None).update({"user_id": admin_user.id})
|
||||
db.query(Subscription).filter(Subscription.user_id is None).update(
|
||||
{"user_id": admin_user.id}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
default_setting = (
|
||||
db.query(Settings).filter(Settings.user_id == admin_user.id).first()
|
||||
)
|
||||
if not default_setting:
|
||||
new_setting = Settings(
|
||||
requirements="",
|
||||
environment="",
|
||||
user_id=admin_user.id,
|
||||
ntfy_url="https://ntfy.abzk.fr",
|
||||
)
|
||||
db.add(new_setting)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
9
backend/requirements.txt
Normal file
9
backend/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
requests
|
||||
uvicorn
|
||||
fastapi
|
||||
sqlalchemy
|
||||
alembic
|
||||
passlib
|
||||
python-jose
|
||||
argon2_cffi
|
||||
python-multipart
|
||||
@@ -34,14 +34,42 @@ def run_scripts(script_ids: list[int] | None = None):
|
||||
|
||||
def dump_script_to_file(script, filename):
|
||||
with open(filename, "w") as file:
|
||||
file.write("from dotenv import load_dotenv\nload_dotenv()\n")
|
||||
file.write(script.script_content)
|
||||
|
||||
|
||||
def execute_script(filename) -> subprocess.CompletedProcess:
|
||||
result = subprocess.run(["python", filename], capture_output=True, text=True)
|
||||
result = subprocess.run(
|
||||
["exec_folder/venv/bin/python", filename], capture_output=True, text=True
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def update_requirements(settings):
|
||||
if settings is None:
|
||||
raise ValueError("No default settings found")
|
||||
|
||||
# create requirements.txt
|
||||
with open("exec_folder/requirements.txt", "w") as file:
|
||||
file.write("dotenv\n")
|
||||
file.write(settings.requirements)
|
||||
|
||||
# install requirements
|
||||
subprocess.run(
|
||||
["exec_folder/venv/bin/pip", "install", "-r", "exec_folder/requirements.txt"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def update_environment(settings):
|
||||
if settings is None:
|
||||
raise ValueError("No default settings found")
|
||||
|
||||
# create .env file
|
||||
with open("exec_folder/.env", "w") as file:
|
||||
file.write(settings.environment)
|
||||
|
||||
|
||||
def delete_script(filename):
|
||||
try:
|
||||
os.remove(filename)
|
||||
|
||||
25
docker/entry.sh
Normal file
25
docker/entry.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
printenv | while read line; do echo "export $line"; done > /etc/container_environment
|
||||
|
||||
source /app/backend/venv/bin/activate
|
||||
|
||||
# Navigate to the frontend directory, install dependencies, and start the Svelte app
|
||||
cd frontend
|
||||
npm run dev -- --host 0.0.0.0 --port 8080 &
|
||||
|
||||
# Navigate back to the root directory
|
||||
cd ..
|
||||
|
||||
# Setup exec venv
|
||||
cd backend/exec_folder
|
||||
python3 -m venv venv
|
||||
cd ../..
|
||||
|
||||
# Start the backend using uvicorn
|
||||
cd backend
|
||||
alembic upgrade head
|
||||
uvicorn backend:app --host 0.0.0.0 --port 8000 &
|
||||
cd ..
|
||||
|
||||
# Start the cron daemon
|
||||
cron -f
|
||||
2
docker/services.cron
Normal file
2
docker/services.cron
Normal file
@@ -0,0 +1,2 @@
|
||||
*/1 * * * * . /etc/container_environment && cd /app/backend && . /app/backend/venv/bin/activate && python3 /app/backend/get_notifications.py >> /var/log/cron.log 2>&1
|
||||
*/5 * * * * . /etc/container_environment && cd /app/backend && . /app/backend/venv/bin/activate && python3 /app/backend/run_scripts.py >> /var/log/cron.log 2>&1
|
||||
@@ -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,4 +1,102 @@
|
||||
export const API_URL = 'http://127.0.0.1:8000';
|
||||
import { env } from '$env/dynamic/public';
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
password: string | null;
|
||||
}
|
||||
|
||||
export const API_URL = env.PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
// Helper to get token from localStorage
|
||||
export function getToken(): string | null {
|
||||
const tokenData = localStorage.getItem('token');
|
||||
if (tokenData) {
|
||||
const { value, expiresAt } = JSON.parse(tokenData);
|
||||
if (Date.now() > expiresAt) {
|
||||
localStorage.removeItem('token');
|
||||
location.reload();
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to add Authorization header if token exists
|
||||
export function authHeaders(headers: Record<string, string> = {}): Record<string, string> {
|
||||
const token = getToken();
|
||||
return token ? { ...headers, Authorization: `Bearer ${token}` } : headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login and Register API
|
||||
*/
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<AuthResponse> {
|
||||
const form = new FormData();
|
||||
form.append('username', username);
|
||||
form.append('password', password);
|
||||
|
||||
const response = await fetch(`${API_URL}/login`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || 'Login failed');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function register(username: string, password: string): Promise<AuthResponse> {
|
||||
const response = await fetch(`${API_URL}/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || 'Registration failed');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Type definitions for Subscriptions and Notifications
|
||||
*/
|
||||
export interface Subscription {
|
||||
id: number;
|
||||
topic: string;
|
||||
created_at: string;
|
||||
has_unread: boolean;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
subscription_id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
priority: number;
|
||||
created_at: string;
|
||||
viewed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type definitions for Settings
|
||||
*/
|
||||
export interface Settings {
|
||||
id: number;
|
||||
requirements: string;
|
||||
environment: string;
|
||||
user: string;
|
||||
ntfy_url?: string;
|
||||
}
|
||||
|
||||
export async function checkHealth(): Promise<'healthy' | 'unhealthy'> {
|
||||
try {
|
||||
@@ -34,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);
|
||||
}
|
||||
@@ -47,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) {
|
||||
@@ -58,9 +156,227 @@ export async function addScript(
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Fetch all 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);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Fetch a single setting by ID
|
||||
export async function fetchSettingById(id: number): Promise<Settings> {
|
||||
const response = await fetch(`${API_URL}/settings/${id}`, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch setting');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Update an existing setting
|
||||
export async function updateSetting(
|
||||
id: number,
|
||||
updatedSetting: Partial<Settings>
|
||||
): Promise<Settings> {
|
||||
const response = await fetch(`${API_URL}/settings/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ ...updatedSetting, ntfy_url: updatedSetting.ntfy_url })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update setting');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Fetch all subscriptions
|
||||
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
||||
const response = await fetch(`${API_URL}/subscriptions`, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch subscriptions');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Fetch subscriptions by topic
|
||||
export async function getSubscription(topic_id: string): Promise<Subscription> {
|
||||
const response = await fetch(`${API_URL}/subscriptions/${topic_id}`, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch subscriptions');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Add a new notification to a subscription
|
||||
export async function addNotification(
|
||||
subscriptionId: number,
|
||||
title: string,
|
||||
message: string,
|
||||
priority: number
|
||||
): Promise<Notification> {
|
||||
const response = await fetch(`${API_URL}/notifications`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ subscription_id: subscriptionId, title, message, priority })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add notification');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Mark a notification as viewed
|
||||
export async function setViewed(notificationId: number): Promise<Notification> {
|
||||
const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ viewed: true })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to set notification as viewed');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Add a new subscription
|
||||
export async function addSubscription(topic: string): Promise<Subscription> {
|
||||
const response = await fetch(`${API_URL}/subscriptions`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ topic })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add subscription');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Add a new notification to a specific subscription
|
||||
export async function markAllNotificationsAsViewed(subscriptionId: number): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to mark all notifications as viewed for subscription');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all notifications for a specific subscription
|
||||
export async function deleteSubscriptionNotifications(subscriptionId: number): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete notifications for subscription');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a subscription
|
||||
export async function deleteSubscription(subscriptionId: number): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete subscription');
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all users
|
||||
export async function fetchUsers(): Promise<User[]> {
|
||||
const response = await fetch(`${API_URL}/users`, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch users');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Update a user
|
||||
export async function updateUser(
|
||||
userId: number,
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update user');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a user
|
||||
export async function deleteUser(userId: number): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete user');
|
||||
}
|
||||
}
|
||||
|
||||
// Get subscription notifications with pagination
|
||||
export async function fetchSubscriptionNotifications(
|
||||
subscriptionId: string,
|
||||
limit: number = 20,
|
||||
offset: number = 0
|
||||
): Promise<Notification[]> {
|
||||
const response = await fetch(
|
||||
`${API_URL}/subscriptions/${subscriptionId}/notifications?limit=${limit}&offset=${offset}`,
|
||||
{
|
||||
headers: authHeaders()
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch subscription notifications');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Fetch all notifications or filter by topic
|
||||
export async function fetchAllNotifications(): Promise<Notification[]> {
|
||||
const url = `${API_URL}/notifications`;
|
||||
const response = await fetch(url, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch notifications');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Delete a notification
|
||||
export async function deleteNotification(notificationId: number): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete notification');
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch a single script by ID
|
||||
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');
|
||||
}
|
||||
@@ -71,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) {
|
||||
@@ -84,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');
|
||||
}
|
||||
@@ -95,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) {
|
||||
@@ -109,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');
|
||||
@@ -120,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');
|
||||
@@ -131,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,26 +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="/" class="text-lg hover:text-gray-400">Home</a>
|
||||
<a href="/scripts" class="text-lg hover:text-gray-400">Scripts</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">
|
||||
|
||||
@@ -10,3 +10,11 @@
|
||||
Go to Scripts
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex justify-center mt-4">
|
||||
<a
|
||||
href="/notifications"
|
||||
class="px-6 py-3 bg-green-500 text-white rounded-lg shadow-md hover:bg-green-600"
|
||||
>
|
||||
View Notifications
|
||||
</a>
|
||||
</div>
|
||||
|
||||
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>
|
||||
124
frontend/src/routes/notifications/+page.svelte
Normal file
124
frontend/src/routes/notifications/+page.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { addSubscription, deleteSubscription, fetchSubscriptions } from '$lib/api';
|
||||
import type { Subscription } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let subscriptions: Subscription[] = [];
|
||||
let newTopic = '';
|
||||
let loading: boolean = true;
|
||||
let errorMsg: string | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
loading = true;
|
||||
errorMsg = null;
|
||||
try {
|
||||
subscriptions = await fetchSubscriptions();
|
||||
} catch (error) {
|
||||
console.error('Failed to load subscriptions:', error);
|
||||
errorMsg = 'Failed to load subscriptions';
|
||||
subscriptions = [];
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function handleAddSubscription() {
|
||||
if (!newTopic.trim()) {
|
||||
window.showNotification('error', 'Topic name cannot be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addSubscription(newTopic.trim());
|
||||
newTopic = '';
|
||||
window.showNotification('success', 'Subscription added successfully.');
|
||||
subscriptions = await fetchSubscriptions();
|
||||
} catch (error) {
|
||||
window.showNotification('error', 'Failed to add subscription - ' + error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSubscription(id: number) {
|
||||
try {
|
||||
await deleteSubscription(id);
|
||||
subscriptions = await fetchSubscriptions();
|
||||
} catch (error) {
|
||||
window.showNotification('error', 'Failed to delete subscription - ' + error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">Subscriptions</h1>
|
||||
|
||||
{#if loading}
|
||||
<p>Loading...</p>
|
||||
{:else if errorMsg}
|
||||
<p class="text-red-500">{errorMsg}</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each subscriptions as subscription (subscription.id)}
|
||||
<div
|
||||
class="relative block p-4 border rounded bg-white hover:bg-gray-100 shadow-lg shadow-blue-500/50"
|
||||
>
|
||||
<!-- Red cross button for delete -->
|
||||
<button
|
||||
class="absolute top-2 right-2 w-6 h-6 flex items-center justify-center bg-transparent text-red-600 hover:text-red-800"
|
||||
aria-label="Delete subscription"
|
||||
on:click|stopPropagation={() => handleDeleteSubscription(subscription.id)}
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" />
|
||||
<line x1="6" y1="18" x2="18" y2="6" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
href={`/notifications/${subscription.id}`}
|
||||
class="block"
|
||||
style="text-decoration: none;"
|
||||
>
|
||||
{#if subscription.has_unread}
|
||||
<span class="absolute top-2 left-2 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
{/if}
|
||||
<h2 class="text-lg font-semibold text-gray-800">{subscription.topic}</h2>
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold mb-2">Add New Subscription</h2>
|
||||
<form
|
||||
on:submit|preventDefault={handleAddSubscription}
|
||||
class="space-y-4 p-4 border rounded shadow"
|
||||
>
|
||||
<div>
|
||||
<label for="newTopic" class="block text-sm font-medium">Topic</label>
|
||||
<input
|
||||
id="newTopic"
|
||||
type="text"
|
||||
bind:value={newTopic}
|
||||
required
|
||||
class="mt-1 block w-full p-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded">
|
||||
Add Subscription
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
197
frontend/src/routes/notifications/[subscription_id]/+page.svelte
Normal file
197
frontend/src/routes/notifications/[subscription_id]/+page.svelte
Normal file
@@ -0,0 +1,197 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
deleteNotification,
|
||||
setViewed,
|
||||
fetchSubscriptionNotifications,
|
||||
deleteSubscriptionNotifications,
|
||||
markAllNotificationsAsViewed
|
||||
} from '$lib/api';
|
||||
import type { Notification, Subscription } from '$lib/api';
|
||||
let { data } = $props();
|
||||
|
||||
let subscription: Subscription | null = $state(data.subscription);
|
||||
let notifications: Notification[] = $state(data.notifications);
|
||||
let selectedNotification: Notification | null = $state(null);
|
||||
|
||||
// Delete all notifications for this subscription
|
||||
async function handleDeleteAllNotifications() {
|
||||
if (notifications.length === 0) return;
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to delete all notifications for this subscription?'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
deleteSubscriptionNotifications(subscription!.id);
|
||||
notifications = [];
|
||||
window.showNotification('success', 'All notifications deleted successfully.');
|
||||
} catch (error) {
|
||||
window.showNotification('error', 'Failed to delete all notifications - ' + error);
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination state
|
||||
let limit = 20;
|
||||
let offset = $derived(notifications.length);
|
||||
let loadingMore = $state(false);
|
||||
let allLoaded = $derived(notifications.length < limit);
|
||||
|
||||
async function openNotificationPopup(notification: Notification) {
|
||||
if (!notification.viewed) {
|
||||
await setViewed(notification.id);
|
||||
notifications = notifications.map((n) =>
|
||||
n.id === notification.id ? { ...n, viewed: true } : n
|
||||
);
|
||||
notification.viewed = true;
|
||||
}
|
||||
|
||||
selectedNotification = notification;
|
||||
}
|
||||
|
||||
function closeNotificationPopup() {
|
||||
selectedNotification = null;
|
||||
}
|
||||
|
||||
async function handleDeleteNotification(e, id: number) {
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
await deleteNotification(id);
|
||||
notifications = notifications.filter((notification) => notification.id !== id);
|
||||
window.showNotification('success', 'Notification deleted successfully.');
|
||||
} catch (error) {
|
||||
window.showNotification('error', 'Failed to delete notification - ' + error);
|
||||
}
|
||||
}
|
||||
async function markAllViewed() {
|
||||
try {
|
||||
markAllNotificationsAsViewed(subscription!.id);
|
||||
notifications = notifications.map((notification) =>
|
||||
notification.viewed ? notification : { ...notification, viewed: true }
|
||||
);
|
||||
window.showNotification('success', 'All notifications marked as viewed.');
|
||||
} catch (error) {
|
||||
window.showNotification('error', 'Failed to mark all notifications as viewed.');
|
||||
}
|
||||
}
|
||||
|
||||
// Load more notifications
|
||||
async function loadMoreNotifications() {
|
||||
loadingMore = true;
|
||||
try {
|
||||
const more = await fetchSubscriptionNotifications(subscription!.id.toString(), limit, offset);
|
||||
notifications = [...notifications, ...more];
|
||||
offset += more.length;
|
||||
if (more.length < limit) {
|
||||
allLoaded = true;
|
||||
}
|
||||
} catch (err) {
|
||||
window.showNotification('error', 'Failed to load more notifications - ' + err);
|
||||
}
|
||||
loadingMore = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">Notifications for {subscription!.topic}:</h1>
|
||||
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<a href="/notifications" class="text-blue-500 hover:underline">← Return to Subscriptions</a>
|
||||
<div class="flex gap-2">
|
||||
<button class="bg-blue-500 text-white px-4 py-2 rounded" onclick={markAllViewed}>
|
||||
Mark All Viewed
|
||||
</button>
|
||||
<button
|
||||
class="bg-red-500 text-white px-4 py-2 rounded"
|
||||
onclick={handleDeleteAllNotifications}
|
||||
disabled={notifications.length === 0}
|
||||
>
|
||||
Delete All Notifications
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if notifications.length === 0}
|
||||
<p>No notifications found for this topic.</p>
|
||||
{:else}
|
||||
<ul class="space-y-4">
|
||||
{#each notifications as notification (notification.id)}
|
||||
<li
|
||||
class="p-2 rounded flex justify-between items-center border-s-slate-400 border-1"
|
||||
class:bg-green-200={notification.viewed}
|
||||
>
|
||||
<button class="p-2 w-full text-left" onclick={() => openNotificationPopup(notification)}>
|
||||
<div>
|
||||
<p class="font-semibold">{notification.title}</p>
|
||||
<p>
|
||||
{notification.message.split('\n')[0].length > 75
|
||||
? `${notification.message.split('\n')[0].slice(0, 75)}...`
|
||||
: notification.message.split('\n')[0]}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{new Date(notification.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 block"
|
||||
onclick={(e) => handleDeleteNotification(e, notification.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if !allLoaded && notifications.length > 0}
|
||||
<div class="flex justify-center mt-6">
|
||||
<button
|
||||
class="bg-gray-200 px-4 py-2 rounded hover:bg-gray-300"
|
||||
onclick={loadMoreNotifications}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedNotification}
|
||||
<div class="fixed inset-0 bg-opacity-30 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div class="bg-white p-6 rounded shadow-lg w-3/4 max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<h3 class="text-lg font-bold mb-4">Notification Details</h3>
|
||||
<div class="mb-4">
|
||||
<p class="font-semibold">Title:</p>
|
||||
<pre
|
||||
class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{selectedNotification.title}</pre>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p class="font-semibold">Message:</p>
|
||||
<pre
|
||||
class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{selectedNotification.message}</pre>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p class="font-semibold">Priority:</p>
|
||||
<pre
|
||||
class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{selectedNotification.priority}</pre>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p class="font-semibold">Created At:</p>
|
||||
<pre class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{new Date(
|
||||
selectedNotification.created_at
|
||||
).toLocaleString()}</pre>
|
||||
</div>
|
||||
<button
|
||||
class="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
onclick={closeNotificationPopup}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { updateScript, deleteScript, addLog, deleteLog, executeScript } from '$lib/api';
|
||||
import {
|
||||
updateScript,
|
||||
deleteScript,
|
||||
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) {
|
||||
@@ -29,12 +55,14 @@
|
||||
selectedLog = null;
|
||||
}
|
||||
|
||||
// Notifications are now handled globally via the layout
|
||||
|
||||
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(
|
||||
(a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
|
||||
);
|
||||
} catch (err) {
|
||||
window.showNotification('error', 'Failed to execute script. ' + err);
|
||||
}
|
||||
@@ -57,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) {
|
||||
@@ -100,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"
|
||||
@@ -181,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
|
||||
@@ -252,7 +227,7 @@
|
||||
<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">
|
||||
<div class="bg-white p-6 rounded shadow-lg w-3/4 max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<h3 class="text-lg font-bold mb-4">Log Details</h3>
|
||||
<div class="mb-4">
|
||||
<p class="font-semibold">Message:</p>
|
||||
|
||||
190
frontend/src/routes/settings/+page.svelte
Normal file
190
frontend/src/routes/settings/+page.svelte
Normal file
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchUserSettings, updateSetting, fetchUsers, updateUser, deleteUser } from '$lib/api';
|
||||
import type { Settings, User } from '$lib/api';
|
||||
import CodeMirror from 'svelte-codemirror-editor';
|
||||
|
||||
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 = true;
|
||||
error = null;
|
||||
try {
|
||||
const data = await fetchUserSettings();
|
||||
settings = data;
|
||||
} catch (err) {
|
||||
error = 'Failed to load settings - ' + err;
|
||||
} finally {
|
||||
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 = true;
|
||||
error = null;
|
||||
try {
|
||||
await updateSetting(setting.id, setting);
|
||||
loadSettings(); // Refresh settings after update
|
||||
} catch (err) {
|
||||
error = 'Failed to save settings - ' + err;
|
||||
} finally {
|
||||
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}
|
||||
<p>Loading...</p>
|
||||
{:else if error}
|
||||
<p class="text-red-500">{$error}</p>
|
||||
{:else if settings !== null}
|
||||
<div class="space-y-4">
|
||||
<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 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={settings.ntfy_url}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,5 +3,8 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
allowedHosts: ['monitor.abzk.fr']
|
||||
}
|
||||
});
|
||||
|
||||
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