Compare commits
17 Commits
6460cab465
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
678eb22d2b | ||
|
|
e913eb34f2 | ||
|
|
2f9f75ee3c | ||
|
|
45b78d5faf | ||
|
|
dff07ef340 | ||
|
|
625b231de5 | ||
|
|
013ddb26c7 | ||
|
|
657a224163 | ||
|
|
c957d839dd | ||
|
|
8eef535e02 | ||
|
|
e9d94f706c | ||
|
|
374558d30f | ||
|
|
16989ed518 | ||
|
|
d3df001397 | ||
|
|
5ba5107a3b | ||
|
|
90cb14eb2a | ||
|
|
3c6acc9359 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ __pycache__
|
|||||||
*.db
|
*.db
|
||||||
.envrc
|
.envrc
|
||||||
exec_folder/
|
exec_folder/
|
||||||
|
.env
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ RUN . /app/backend/venv/bin/activate && pip install -r requirements.txt
|
|||||||
|
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
ADD frontend .
|
ADD frontend .
|
||||||
|
RUN npm install
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
147
backend/alembic.ini
Normal file
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 datetime import datetime
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Depends, HTTPException, status, Query
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from model import Log, SessionLocal, Script, Settings, Subscription, Notification
|
from model import (
|
||||||
|
Base,
|
||||||
|
Log,
|
||||||
|
SessionLocal,
|
||||||
|
Script,
|
||||||
|
Settings,
|
||||||
|
Subscription,
|
||||||
|
Notification,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from run_scripts import run_scripts, update_requirements, update_environment
|
from run_scripts import run_scripts, update_requirements, update_environment
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
import os
|
||||||
|
from model import ensure_default_setting
|
||||||
|
|
||||||
|
from auth import (
|
||||||
|
get_password_hash,
|
||||||
|
create_access_token,
|
||||||
|
authenticate_user,
|
||||||
|
get_current_user,
|
||||||
|
)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
# JWT settings
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "")
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60
|
||||||
|
|
||||||
|
if SECRET_KEY == "":
|
||||||
|
raise ValueError("SECRET_KEY environment variable is not set")
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
|
||||||
|
|
||||||
|
ensure_default_setting()
|
||||||
|
|
||||||
|
|
||||||
# Update cors
|
# Update cors
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -19,6 +52,22 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# User registration/login models
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
# Define Pydantic models
|
# Define Pydantic models
|
||||||
class ScriptBase(BaseModel):
|
class ScriptBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@@ -52,13 +101,78 @@ def hello():
|
|||||||
return {"message": "Welcome to the Project Monitor API"}
|
return {"message": "Welcome to the Project Monitor API"}
|
||||||
|
|
||||||
|
|
||||||
# Subscriptions API Endpoints
|
# User Management Endpoints
|
||||||
@app.get("/subscriptions")
|
@app.get("/users", response_model=list[UserResponse])
|
||||||
def list_subscriptions():
|
def list_users(current_user: User = Depends(get_current_user)):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
subscriptions = db.query(Subscription).all()
|
users = db.query(User).all()
|
||||||
db.close()
|
db.close()
|
||||||
return subscriptions
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/users/{user_id}")
|
||||||
|
def update_user(
|
||||||
|
user_id: int,
|
||||||
|
user: UserCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
db = SessionLocal()
|
||||||
|
existing_user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not existing_user:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
existing_user.username = user.username
|
||||||
|
existing_user.password_hash = get_password_hash(user.password)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing_user)
|
||||||
|
db.close()
|
||||||
|
return {"message": "User updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/users/{user_id}")
|
||||||
|
def delete_user(user_id: int, current_user: User = Depends(get_current_user)):
|
||||||
|
db = SessionLocal()
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"message": "User deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/register", response_model=Token)
|
||||||
|
def register(user: UserCreate):
|
||||||
|
db = SessionLocal()
|
||||||
|
existing_user = db.query(User).filter(User.username == user.username).first()
|
||||||
|
if existing_user:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(status_code=400, detail="Username already registered")
|
||||||
|
hashed_password = get_password_hash(user.password)
|
||||||
|
new_user = User(username=user.username, password_hash=hashed_password)
|
||||||
|
db.add(new_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_user)
|
||||||
|
access_token = create_access_token(data={"sub": new_user.username})
|
||||||
|
db.close()
|
||||||
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login", response_model=Token)
|
||||||
|
def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||||
|
db = SessionLocal()
|
||||||
|
user = authenticate_user(db, form_data.username, form_data.password)
|
||||||
|
if not user:
|
||||||
|
db.close()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
access_token = create_access_token(data={"sub": user.username})
|
||||||
|
db.close()
|
||||||
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionCreate(BaseModel):
|
class SubscriptionCreate(BaseModel):
|
||||||
@@ -69,12 +183,39 @@ class SubscriptionResponse(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
topic: str
|
topic: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
has_unread: bool
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# Subscriptions API Endpoints
|
||||||
|
@app.get("/subscriptions", response_model=list[SubscriptionResponse])
|
||||||
|
def list_subscriptions(current_user: User = Depends(get_current_user)):
|
||||||
|
db = SessionLocal()
|
||||||
|
subscriptions = (
|
||||||
|
db.query(Subscription).filter(Subscription.user_id == current_user.id).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: find a better way to do this
|
||||||
|
for subscription in subscriptions:
|
||||||
|
not_viewed_count = (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(
|
||||||
|
Notification.subscription_id == subscription.id,
|
||||||
|
~Notification.viewed,
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
subscription.has_unread = not_viewed_count > 0
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
return subscriptions
|
||||||
|
|
||||||
|
|
||||||
@app.get("/subscriptions/{subscription_id}", response_model=SubscriptionResponse)
|
@app.get("/subscriptions/{subscription_id}", response_model=SubscriptionResponse)
|
||||||
def get_subscription(subscription_id: int):
|
def get_subscription(
|
||||||
|
subscription_id: int, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
subscription = (
|
subscription = (
|
||||||
db.query(Subscription).filter(Subscription.id == subscription_id).first()
|
db.query(Subscription).filter(Subscription.id == subscription_id).first()
|
||||||
@@ -82,12 +223,25 @@ def get_subscription(subscription_id: int):
|
|||||||
if not subscription:
|
if not subscription:
|
||||||
db.close()
|
db.close()
|
||||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||||
|
|
||||||
|
# checking if subscription has unread messages
|
||||||
|
subscription.has_unread = (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(
|
||||||
|
Notification.subscription_id == subscription_id and not Notification.viewed
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
> 0
|
||||||
|
)
|
||||||
|
|
||||||
db.close()
|
db.close()
|
||||||
return subscription
|
return subscription
|
||||||
|
|
||||||
|
|
||||||
@app.post("/subscriptions")
|
@app.post("/subscriptions")
|
||||||
def add_subscription(subscription: SubscriptionCreate):
|
def add_subscription(
|
||||||
|
subscription: SubscriptionCreate, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
existing_subscription = (
|
existing_subscription = (
|
||||||
db.query(Subscription).filter(Subscription.topic == subscription.topic).first()
|
db.query(Subscription).filter(Subscription.topic == subscription.topic).first()
|
||||||
@@ -95,7 +249,7 @@ def add_subscription(subscription: SubscriptionCreate):
|
|||||||
if existing_subscription:
|
if existing_subscription:
|
||||||
db.close()
|
db.close()
|
||||||
raise HTTPException(status_code=400, detail="Subscription already exists")
|
raise HTTPException(status_code=400, detail="Subscription already exists")
|
||||||
new_subscription = Subscription(topic=subscription.topic)
|
new_subscription = Subscription(topic=subscription.topic, user_id=current_user.id)
|
||||||
db.add(new_subscription)
|
db.add(new_subscription)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_subscription)
|
db.refresh(new_subscription)
|
||||||
@@ -104,7 +258,9 @@ def add_subscription(subscription: SubscriptionCreate):
|
|||||||
|
|
||||||
|
|
||||||
@app.delete("/subscriptions/{subscription_id}")
|
@app.delete("/subscriptions/{subscription_id}")
|
||||||
def remove_subscription(subscription_id: int):
|
def remove_subscription(
|
||||||
|
subscription_id: int, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
subscription = (
|
subscription = (
|
||||||
db.query(Subscription).filter(Subscription.id == subscription_id).first()
|
db.query(Subscription).filter(Subscription.id == subscription_id).first()
|
||||||
@@ -119,11 +275,19 @@ def remove_subscription(subscription_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/subscriptions/{subscription_id}/notifications")
|
@app.get("/subscriptions/{subscription_id}/notifications")
|
||||||
def list_subscription_notifications(subscription_id: int):
|
def list_subscription_notifications(
|
||||||
|
subscription_id: int,
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
notifications = (
|
notifications = (
|
||||||
db.query(Notification)
|
db.query(Notification)
|
||||||
.filter(Notification.subscription_id == subscription_id)
|
.filter(Notification.subscription_id == subscription_id)
|
||||||
|
.order_by(Notification.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
db.close()
|
db.close()
|
||||||
@@ -133,8 +297,43 @@ def list_subscription_notifications(subscription_id: int):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/subscriptions/{subscription_id}/notifications")
|
||||||
|
def set_all_notifications_viewed(
|
||||||
|
subscription_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
db = SessionLocal()
|
||||||
|
notifications = (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(Notification.subscription_id == subscription_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for notification in notifications:
|
||||||
|
notification.viewed = True
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"message": "Notifications marked as viewed"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/subscriptions/{subscription_id}/notifications")
|
||||||
|
def remove_subscription_notifications(
|
||||||
|
subscription_id: int, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
db = SessionLocal()
|
||||||
|
notifications = (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(Notification.subscription_id == subscription_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for notification in notifications:
|
||||||
|
db.delete(notification)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
return {"message": "Notifications removed"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/notifications")
|
@app.get("/notifications")
|
||||||
def list_notifications():
|
def list_notifications(current_user: User = Depends(get_current_user)):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
notifications = db.query(Notification).all()
|
notifications = db.query(Notification).all()
|
||||||
db.close()
|
db.close()
|
||||||
@@ -145,7 +344,9 @@ def list_notifications():
|
|||||||
|
|
||||||
|
|
||||||
@app.delete("/notifications/{notification_id}")
|
@app.delete("/notifications/{notification_id}")
|
||||||
def remove_notification(notification_id: int):
|
def remove_notification(
|
||||||
|
notification_id: int, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
notification = (
|
notification = (
|
||||||
db.query(Notification).filter(Notification.id == notification_id).first()
|
db.query(Notification).filter(Notification.id == notification_id).first()
|
||||||
@@ -183,7 +384,11 @@ class NotificationResponse(NotificationCreate):
|
|||||||
|
|
||||||
|
|
||||||
@app.put("/notifications/{notification_id}", response_model=NotificationResponse)
|
@app.put("/notifications/{notification_id}", response_model=NotificationResponse)
|
||||||
def update_notification(notification_id: int, notification: NotificationUpdate):
|
def update_notification(
|
||||||
|
notification_id: int,
|
||||||
|
notification: NotificationUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
existing_notification = (
|
existing_notification = (
|
||||||
db.query(Notification).filter(Notification.id == notification_id).first()
|
db.query(Notification).filter(Notification.id == notification_id).first()
|
||||||
@@ -208,7 +413,9 @@ def update_notification(notification_id: int, notification: NotificationUpdate):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/notifications", response_model=NotificationResponse)
|
@app.post("/notifications", response_model=NotificationResponse)
|
||||||
def create_notification(notification: NotificationCreate):
|
def create_notification(
|
||||||
|
notification: NotificationCreate, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
new_notification = Notification(
|
new_notification = Notification(
|
||||||
subscription_id=notification.subscription_id,
|
subscription_id=notification.subscription_id,
|
||||||
@@ -227,7 +434,6 @@ def create_notification(notification: NotificationCreate):
|
|||||||
class SettingsBase(BaseModel):
|
class SettingsBase(BaseModel):
|
||||||
requirements: str
|
requirements: str
|
||||||
environment: str
|
environment: str
|
||||||
user: str
|
|
||||||
ntfy_url: str
|
ntfy_url: str
|
||||||
|
|
||||||
|
|
||||||
@@ -242,18 +448,39 @@ class SettingsResponse(SettingsBase):
|
|||||||
|
|
||||||
|
|
||||||
# Settings API Endpoints
|
# Settings API Endpoints
|
||||||
@app.get("/settings", response_model=list[SettingsResponse])
|
@app.get("/settings", response_model=SettingsResponse)
|
||||||
def read_settings():
|
def read_settings(current_user: User = Depends(get_current_user)):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
settings = db.query(Settings).all()
|
settings = db.query(Settings).filter(Settings.user_id == current_user.id).all()
|
||||||
|
if not settings:
|
||||||
|
# Add a default settings row for this user if not found
|
||||||
|
new_setting = Settings(
|
||||||
|
requirements="",
|
||||||
|
environment="",
|
||||||
|
user_id=current_user.id,
|
||||||
|
ntfy_url="https://ntfy.abzk.fr",
|
||||||
|
)
|
||||||
|
db.add(new_setting)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_setting)
|
||||||
|
db.close()
|
||||||
|
return new_setting
|
||||||
|
|
||||||
|
if len(settings) > 1:
|
||||||
|
raise HTTPException(status_code=400, detail="Multiple settings found")
|
||||||
|
|
||||||
|
settings = settings[0]
|
||||||
|
|
||||||
db.close()
|
db.close()
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
@app.post("/settings", response_model=SettingsResponse)
|
@app.post("/settings", response_model=SettingsResponse)
|
||||||
def create_setting(settings: SettingsBase):
|
def create_setting(
|
||||||
|
settings: SettingsBase, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
new_setting = Settings(**settings.model_dump())
|
new_setting = Settings(**settings.model_dump(), user_id=current_user.id)
|
||||||
db.add(new_setting)
|
db.add(new_setting)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_setting)
|
db.refresh(new_setting)
|
||||||
@@ -263,9 +490,13 @@ def create_setting(settings: SettingsBase):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/settings/{settings_id}", response_model=SettingsResponse)
|
@app.get("/settings/{settings_id}", response_model=SettingsResponse)
|
||||||
def read_setting(settings_id: int):
|
def read_setting(settings_id: int, current_user: User = Depends(get_current_user)):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
setting = db.query(Settings).filter(Settings.id == settings_id).first()
|
setting = (
|
||||||
|
db.query(Settings)
|
||||||
|
.filter(Settings.id == settings_id, Settings.user_id == current_user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
db.close()
|
db.close()
|
||||||
if not setting:
|
if not setting:
|
||||||
raise HTTPException(status_code=404, detail="Setting not found")
|
raise HTTPException(status_code=404, detail="Setting not found")
|
||||||
@@ -273,9 +504,17 @@ def read_setting(settings_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@app.put("/settings/{settings_id}", response_model=SettingsResponse)
|
@app.put("/settings/{settings_id}", response_model=SettingsResponse)
|
||||||
def update_setting(settings_id: int, settings: SettingsUpdate):
|
def update_setting(
|
||||||
|
settings_id: int,
|
||||||
|
settings: SettingsUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
existing_setting = db.query(Settings).filter(Settings.id == settings_id).first()
|
existing_setting = (
|
||||||
|
db.query(Settings)
|
||||||
|
.filter(Settings.id == settings_id, Settings.user_id == current_user.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if not existing_setting:
|
if not existing_setting:
|
||||||
raise HTTPException(status_code=404, detail="Setting not found")
|
raise HTTPException(status_code=404, detail="Setting not found")
|
||||||
|
|
||||||
@@ -298,17 +537,19 @@ def update_setting(settings_id: int, settings: SettingsUpdate):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/script", response_model=list[ScriptResponse])
|
@app.get("/script", response_model=list[ScriptResponse])
|
||||||
def read_scripts():
|
def read_scripts(current_user: User = Depends(get_current_user)):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
scripts = db.query(Script).all()
|
scripts = db.query(Script).filter(Script.user_id == current_user.id).all()
|
||||||
db.close()
|
db.close()
|
||||||
return scripts
|
return scripts
|
||||||
|
|
||||||
|
|
||||||
@app.post("/script", response_model=ScriptResponse)
|
@app.post("/script", response_model=ScriptResponse)
|
||||||
def create_script(script: ScriptCreate):
|
def create_script(script: ScriptCreate, current_user: User = Depends(get_current_user)):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
new_script = Script(name=script.name, script_content=script.script_content)
|
new_script = Script(
|
||||||
|
name=script.name, script_content=script.script_content, user_id=current_user.id
|
||||||
|
)
|
||||||
db.add(new_script)
|
db.add(new_script)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_script)
|
db.refresh(new_script)
|
||||||
@@ -317,7 +558,7 @@ def create_script(script: ScriptCreate):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/script/{script_id}", response_model=ScriptResponse)
|
@app.get("/script/{script_id}", response_model=ScriptResponse)
|
||||||
def read_script(script_id: int):
|
def read_script(script_id: int, current_user: User = Depends(get_current_user)):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
script = db.query(Script).filter(Script.id == script_id).first()
|
script = db.query(Script).filter(Script.id == script_id).first()
|
||||||
db.close()
|
db.close()
|
||||||
@@ -327,7 +568,7 @@ def read_script(script_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@app.delete("/script/{script_id}")
|
@app.delete("/script/{script_id}")
|
||||||
def delete_script(script_id: int):
|
def delete_script(script_id: int, current_user: User = Depends(get_current_user)):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
script = db.query(Script).filter(Script.id == script_id).first()
|
script = db.query(Script).filter(Script.id == script_id).first()
|
||||||
if not script:
|
if not script:
|
||||||
@@ -343,7 +584,9 @@ def delete_script(script_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@app.put("/script/{script_id}", response_model=ScriptResponse)
|
@app.put("/script/{script_id}", response_model=ScriptResponse)
|
||||||
def update_script(script_id: int, script: ScriptUpdate):
|
def update_script(
|
||||||
|
script_id: int, script: ScriptUpdate, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
existing_script = db.query(Script).filter(Script.id == script_id).first()
|
existing_script = db.query(Script).filter(Script.id == script_id).first()
|
||||||
if not existing_script:
|
if not existing_script:
|
||||||
@@ -359,7 +602,7 @@ def update_script(script_id: int, script: ScriptUpdate):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/script/{script_id}/log")
|
@app.get("/script/{script_id}/log")
|
||||||
def get_script_logs(script_id: int):
|
def get_script_logs(script_id: int, current_user: User = Depends(get_current_user)):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
logs = db.query(Log).filter(Log.script_id == script_id).all()
|
logs = db.query(Log).filter(Log.script_id == script_id).all()
|
||||||
db.close()
|
db.close()
|
||||||
@@ -367,7 +610,9 @@ def get_script_logs(script_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/script/{script_id}/log")
|
@app.post("/script/{script_id}/log")
|
||||||
def create_script_log(script_id: int, log: ScriptLogCreate):
|
def create_script_log(
|
||||||
|
script_id: int, log: ScriptLogCreate, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
new_log = Log(
|
new_log = Log(
|
||||||
script_id=script_id,
|
script_id=script_id,
|
||||||
@@ -383,7 +628,9 @@ def create_script_log(script_id: int, log: ScriptLogCreate):
|
|||||||
|
|
||||||
|
|
||||||
@app.delete("/script/{script_id}/log/{log_id}")
|
@app.delete("/script/{script_id}/log/{log_id}")
|
||||||
def delete_script_log(script_id: int, log_id: int):
|
def delete_script_log(
|
||||||
|
script_id: int, log_id: int, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
log = db.query(Log).filter(Log.id == log_id and Log.script_id == script_id).first()
|
log = db.query(Log).filter(Log.id == log_id and Log.script_id == script_id).first()
|
||||||
if not log:
|
if not log:
|
||||||
@@ -395,7 +642,7 @@ def delete_script_log(script_id: int, log_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/script/{script_id}/execute")
|
@app.post("/script/{script_id}/execute")
|
||||||
def execute_script(script_id: int):
|
def execute_script(script_id: int, current_user: User = Depends(get_current_user)):
|
||||||
run_scripts([script_id])
|
run_scripts([script_id])
|
||||||
return {"run_script": True}
|
return {"run_script": True}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from model import SessionLocal, Subscription, Settings, Notification
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
|
|
||||||
NTFY_TOKEN = os.getenv("NTFY_TOKEN")
|
NTFY_TOKEN = os.getenv("NTFY_TOKEN")
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +17,7 @@ def fetch_ntfy_notifications(base_url, subscriptions):
|
|||||||
|
|
||||||
notifications = []
|
notifications = []
|
||||||
for subscription in subscriptions:
|
for subscription in subscriptions:
|
||||||
|
print(f"Fetching notifications for {subscription.topic}")
|
||||||
topic = subscription.topic
|
topic = subscription.topic
|
||||||
last_message_id = subscription.last_message_id
|
last_message_id = subscription.last_message_id
|
||||||
since_param = "all" if last_message_id is None else last_message_id
|
since_param = "all" if last_message_id is None else last_message_id
|
||||||
@@ -33,14 +33,11 @@ def fetch_ntfy_notifications(base_url, subscriptions):
|
|||||||
notifications.append(notification)
|
notifications.append(notification)
|
||||||
|
|
||||||
print(f"Fetched {len(notifications)} notifications")
|
print(f"Fetched {len(notifications)} notifications")
|
||||||
print(notifications)
|
|
||||||
|
|
||||||
return notifications
|
return notifications
|
||||||
|
|
||||||
|
|
||||||
def save_notifications_to_db(notifications, topic_to_subscription, db):
|
def save_notifications_to_db(notifications, topic_to_subscription, db):
|
||||||
"""Save the fetched notifications to the database and update last_message_id."""
|
"""Save the fetched notifications to the database and update last_message_id."""
|
||||||
db = SessionLocal()
|
|
||||||
last_message_ids = {}
|
last_message_ids = {}
|
||||||
for notification in notifications:
|
for notification in notifications:
|
||||||
topic = notification["topic"]
|
topic = notification["topic"]
|
||||||
@@ -66,33 +63,26 @@ def save_notifications_to_db(notifications, topic_to_subscription, db):
|
|||||||
if subscription:
|
if subscription:
|
||||||
subscription.last_message_id = message_id
|
subscription.last_message_id = message_id
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def process_user_notifications(user_settings, db):
|
||||||
"""Main function to fetch and save notifications."""
|
"""Process notifications for a specific user's subscriptions."""
|
||||||
db = SessionLocal()
|
ntfy_url = user_settings.ntfy_url
|
||||||
|
|
||||||
# Get the ntfy base URL from settings
|
|
||||||
settings = db.query(Settings).filter(Settings.user == "default").first()
|
|
||||||
if not settings:
|
|
||||||
print("Default user settings not found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
ntfy_url = settings.ntfy_url
|
|
||||||
|
|
||||||
if not ntfy_url:
|
if not ntfy_url:
|
||||||
print("Ntfy URL not found in settings.")
|
print(f"Ntfy URL not found for user ID {user_settings.user_id}. Skipping...")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get all subscribed topics
|
# Get all subscriptions for the user
|
||||||
subscriptions = db.query(Subscription).all()
|
subscriptions = (
|
||||||
|
db.query(Subscription)
|
||||||
|
.filter(Subscription.user_id == user_settings.user_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
topic_to_subscription = {
|
topic_to_subscription = {
|
||||||
subscription.topic: subscription.id for subscription in subscriptions
|
subscription.topic: subscription.id for subscription in subscriptions
|
||||||
}
|
}
|
||||||
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# Fetch notifications from ntfy.sh
|
# Fetch notifications from ntfy.sh
|
||||||
notifications = fetch_ntfy_notifications(ntfy_url, subscriptions)
|
notifications = fetch_ntfy_notifications(ntfy_url, subscriptions)
|
||||||
|
|
||||||
@@ -100,5 +90,24 @@ def main():
|
|||||||
save_notifications_to_db(notifications, topic_to_subscription, db)
|
save_notifications_to_db(notifications, topic_to_subscription, db)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function to fetch and save notifications for all users."""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
# Get all user settings
|
||||||
|
user_settings_list = db.query(Settings).all()
|
||||||
|
|
||||||
|
if not user_settings_list:
|
||||||
|
print("No user settings found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process notifications for each user
|
||||||
|
for user_settings in user_settings_list:
|
||||||
|
print(f"Processing notifications for user ID {user_settings.user_id}")
|
||||||
|
process_user_notifications(user_settings, db)
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey, Boolean
|
from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.sql.sqltypes import DateTime
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy.sql.functions import func
|
from sqlalchemy.sql.functions import func
|
||||||
from sqlalchemy.sql.sqltypes import DateTime
|
from sqlalchemy.sql.sqltypes import DateTime
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
# Initialize the database
|
# Initialize the database
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./project_monitor.db")
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
|
if not DATABASE_URL:
|
||||||
|
raise ValueError("DATABASE_URL environment variable is not set")
|
||||||
|
|
||||||
# SQLAlchemy setup
|
# SQLAlchemy setup
|
||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
@@ -14,7 +20,15 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
# Define the table model
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(64), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = Column(String(128), nullable=False)
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Script(Base):
|
class Script(Base):
|
||||||
@@ -27,6 +41,9 @@ class Script(Base):
|
|||||||
created_at = Column(
|
created_at = Column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
user_id = Column(
|
||||||
|
Integer, ForeignKey("users.id", name="fk_script_user_id"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Log(Base):
|
class Log(Base):
|
||||||
@@ -40,7 +57,9 @@ class Log(Base):
|
|||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
script_id = Column(Integer, ForeignKey("scripts.id"), nullable=False)
|
script_id = Column(
|
||||||
|
Integer, ForeignKey("scripts.id", name="fk_log_script_id"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Settings(Base):
|
class Settings(Base):
|
||||||
@@ -49,8 +68,10 @@ class Settings(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
requirements = Column(String, nullable=False)
|
requirements = Column(String, nullable=False)
|
||||||
environment = Column(String, nullable=False)
|
environment = Column(String, nullable=False)
|
||||||
user = Column(String, nullable=False)
|
|
||||||
ntfy_url = Column(String, nullable=True)
|
ntfy_url = Column(String, nullable=True)
|
||||||
|
user_id = Column(
|
||||||
|
Integer, ForeignKey("users.id", name="fk_user_settings_user_id"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Subscription(Base):
|
class Subscription(Base):
|
||||||
@@ -62,6 +83,9 @@ class Subscription(Base):
|
|||||||
created_at = Column(
|
created_at = Column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
user_id = Column(
|
||||||
|
Integer, ForeignKey("users.id", name="fk_subscription_user_id"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Notification(Base):
|
class Notification(Base):
|
||||||
@@ -74,7 +98,11 @@ class Notification(Base):
|
|||||||
viewed = Column(Boolean, default=False)
|
viewed = Column(Boolean, default=False)
|
||||||
sent = Column(Boolean, default=False)
|
sent = Column(Boolean, default=False)
|
||||||
|
|
||||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=False)
|
subscription_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("subscriptions.id", name="fk_notification_subscription_id"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
created_at = Column(
|
created_at = Column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
@@ -84,20 +112,39 @@ class Notification(Base):
|
|||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
# Ensure a default setting line exists
|
# Ensure a default admin user exists
|
||||||
def ensure_default_setting():
|
def ensure_default_setting():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
default_setting = db.query(Settings).filter(Settings.user == "default").first()
|
admin_user = db.query(User).filter(User.username == "admin").first()
|
||||||
|
if not admin_user:
|
||||||
|
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||||
|
random_password = secrets.token_urlsafe(12)
|
||||||
|
password_hash = pwd_context.hash(random_password)
|
||||||
|
admin_user = User(username="admin", password_hash=password_hash)
|
||||||
|
db.add(admin_user)
|
||||||
|
db.commit()
|
||||||
|
print(
|
||||||
|
f"Default admin user created. Username: admin, Password: {random_password}"
|
||||||
|
)
|
||||||
|
# Refresh to get admin_user.id
|
||||||
|
db.refresh(admin_user)
|
||||||
|
# Set all rows with null user_id in Script and Subscription to admin user id
|
||||||
|
db.query(Script).filter(Script.user_id is None).update({"user_id": admin_user.id})
|
||||||
|
db.query(Subscription).filter(Subscription.user_id is None).update(
|
||||||
|
{"user_id": admin_user.id}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
default_setting = (
|
||||||
|
db.query(Settings).filter(Settings.user_id == admin_user.id).first()
|
||||||
|
)
|
||||||
if not default_setting:
|
if not default_setting:
|
||||||
new_setting = Settings(
|
new_setting = Settings(
|
||||||
requirements="",
|
requirements="",
|
||||||
environment="",
|
environment="",
|
||||||
user="default",
|
user_id=admin_user.id,
|
||||||
ntfy_url="https://ntfy.abzk.fr",
|
ntfy_url="https://ntfy.abzk.fr",
|
||||||
)
|
)
|
||||||
db.add(new_setting)
|
db.add(new_setting)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
ensure_default_setting()
|
|
||||||
|
|||||||
@@ -2,3 +2,8 @@ requests
|
|||||||
uvicorn
|
uvicorn
|
||||||
fastapi
|
fastapi
|
||||||
sqlalchemy
|
sqlalchemy
|
||||||
|
alembic
|
||||||
|
passlib
|
||||||
|
python-jose
|
||||||
|
argon2_cffi
|
||||||
|
python-multipart
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ source /app/backend/venv/bin/activate
|
|||||||
|
|
||||||
# Navigate to the frontend directory, install dependencies, and start the Svelte app
|
# Navigate to the frontend directory, install dependencies, and start the Svelte app
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
npm run dev -- --host 0.0.0.0 --port 8080 &
|
npm run dev -- --host 0.0.0.0 --port 8080 &
|
||||||
|
|
||||||
# Navigate back to the root directory
|
# Navigate back to the root directory
|
||||||
@@ -19,6 +17,7 @@ cd ../..
|
|||||||
|
|
||||||
# Start the backend using uvicorn
|
# Start the backend using uvicorn
|
||||||
cd backend
|
cd backend
|
||||||
|
alembic upgrade head
|
||||||
uvicorn backend:app --host 0.0.0.0 --port 8000 &
|
uvicorn backend:app --host 0.0.0.0 --port 8000 &
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export default defineConfig(
|
|||||||
rules: {
|
rules: {
|
||||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||||
'no-undef': 'off'
|
'no-undef': 'off',
|
||||||
|
'svelte/no-navigation-without-resolve': 'off'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,71 @@
|
|||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
password: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const API_URL = env.PUBLIC_API_URL || 'http://localhost:8080';
|
export const API_URL = env.PUBLIC_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
// Helper to get token from localStorage
|
||||||
|
export function getToken(): string | null {
|
||||||
|
const tokenData = localStorage.getItem('token');
|
||||||
|
if (tokenData) {
|
||||||
|
const { value, expiresAt } = JSON.parse(tokenData);
|
||||||
|
if (Date.now() > expiresAt) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
location.reload();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to add Authorization header if token exists
|
||||||
|
export function authHeaders(headers: Record<string, string> = {}): Record<string, string> {
|
||||||
|
const token = getToken();
|
||||||
|
return token ? { ...headers, Authorization: `Bearer ${token}` } : headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login and Register API
|
||||||
|
*/
|
||||||
|
export interface AuthResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username: string, password: string): Promise<AuthResponse> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('username', username);
|
||||||
|
form.append('password', password);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.detail || 'Login failed');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(username: string, password: string): Promise<AuthResponse> {
|
||||||
|
const response = await fetch(`${API_URL}/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.detail || 'Registration failed');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type definitions for Subscriptions and Notifications
|
* Type definitions for Subscriptions and Notifications
|
||||||
@@ -9,6 +74,7 @@ export interface Subscription {
|
|||||||
id: number;
|
id: number;
|
||||||
topic: string;
|
topic: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
has_unread: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
@@ -66,7 +132,9 @@ export interface Script {
|
|||||||
|
|
||||||
// Fetch all scripts
|
// Fetch all scripts
|
||||||
export async function fetchScripts(): Promise<Script[]> {
|
export async function fetchScripts(): Promise<Script[]> {
|
||||||
const response = await fetch(`${API_URL}/script`);
|
const response = await fetch(`${API_URL}/script`, {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch scripts ' + response.statusText);
|
throw new Error('Failed to fetch scripts ' + response.statusText);
|
||||||
}
|
}
|
||||||
@@ -79,9 +147,7 @@ export async function addScript(
|
|||||||
): Promise<Script> {
|
): Promise<Script> {
|
||||||
const response = await fetch(`${API_URL}/script`, {
|
const response = await fetch(`${API_URL}/script`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(script)
|
body: JSON.stringify(script)
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -91,8 +157,10 @@ export async function addScript(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all settings
|
// Fetch all settings
|
||||||
export async function fetchSettings(): Promise<Settings[]> {
|
export async function fetchUserSettings(): Promise<Settings> {
|
||||||
const response = await fetch(`${API_URL}/settings`);
|
const response = await fetch(`${API_URL}/settings`, {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch settings ' + response.statusText);
|
throw new Error('Failed to fetch settings ' + response.statusText);
|
||||||
}
|
}
|
||||||
@@ -101,7 +169,9 @@ export async function fetchSettings(): Promise<Settings[]> {
|
|||||||
|
|
||||||
// Fetch a single setting by ID
|
// Fetch a single setting by ID
|
||||||
export async function fetchSettingById(id: number): Promise<Settings> {
|
export async function fetchSettingById(id: number): Promise<Settings> {
|
||||||
const response = await fetch(`${API_URL}/settings/${id}`);
|
const response = await fetch(`${API_URL}/settings/${id}`, {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch setting');
|
throw new Error('Failed to fetch setting');
|
||||||
}
|
}
|
||||||
@@ -115,9 +185,7 @@ export async function updateSetting(
|
|||||||
): Promise<Settings> {
|
): Promise<Settings> {
|
||||||
const response = await fetch(`${API_URL}/settings/${id}`, {
|
const response = await fetch(`${API_URL}/settings/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ ...updatedSetting, ntfy_url: updatedSetting.ntfy_url })
|
body: JSON.stringify({ ...updatedSetting, ntfy_url: updatedSetting.ntfy_url })
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -128,7 +196,9 @@ export async function updateSetting(
|
|||||||
|
|
||||||
// Fetch all subscriptions
|
// Fetch all subscriptions
|
||||||
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
||||||
const response = await fetch(`${API_URL}/subscriptions`);
|
const response = await fetch(`${API_URL}/subscriptions`, {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch subscriptions');
|
throw new Error('Failed to fetch subscriptions');
|
||||||
}
|
}
|
||||||
@@ -137,7 +207,9 @@ export async function fetchSubscriptions(): Promise<Subscription[]> {
|
|||||||
|
|
||||||
// Fetch subscriptions by topic
|
// Fetch subscriptions by topic
|
||||||
export async function getSubscription(topic_id: string): Promise<Subscription> {
|
export async function getSubscription(topic_id: string): Promise<Subscription> {
|
||||||
const response = await fetch(`${API_URL}/subscriptions/${topic_id}`);
|
const response = await fetch(`${API_URL}/subscriptions/${topic_id}`, {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch subscriptions');
|
throw new Error('Failed to fetch subscriptions');
|
||||||
}
|
}
|
||||||
@@ -153,9 +225,7 @@ export async function addNotification(
|
|||||||
): Promise<Notification> {
|
): Promise<Notification> {
|
||||||
const response = await fetch(`${API_URL}/notifications`, {
|
const response = await fetch(`${API_URL}/notifications`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ subscription_id: subscriptionId, title, message, priority })
|
body: JSON.stringify({ subscription_id: subscriptionId, title, message, priority })
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -168,9 +238,7 @@ export async function addNotification(
|
|||||||
export async function setViewed(notificationId: number): Promise<Notification> {
|
export async function setViewed(notificationId: number): Promise<Notification> {
|
||||||
const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
|
const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ viewed: true })
|
body: JSON.stringify({ viewed: true })
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -183,9 +251,7 @@ export async function setViewed(notificationId: number): Promise<Notification> {
|
|||||||
export async function addSubscription(topic: string): Promise<Subscription> {
|
export async function addSubscription(topic: string): Promise<Subscription> {
|
||||||
const response = await fetch(`${API_URL}/subscriptions`, {
|
const response = await fetch(`${API_URL}/subscriptions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ topic })
|
body: JSON.stringify({ topic })
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -194,21 +260,89 @@ export async function addSubscription(topic: string): Promise<Subscription> {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add a new notification to a specific subscription
|
||||||
|
export async function markAllNotificationsAsViewed(subscriptionId: number): Promise<void> {
|
||||||
|
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to mark all notifications as viewed for subscription');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all notifications for a specific subscription
|
||||||
|
export async function deleteSubscriptionNotifications(subscriptionId: number): Promise<void> {
|
||||||
|
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete notifications for subscription');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete a subscription
|
// Delete a subscription
|
||||||
export async function deleteSubscription(subscriptionId: number): Promise<void> {
|
export async function deleteSubscription(subscriptionId: number): Promise<void> {
|
||||||
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}`, {
|
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
headers: authHeaders()
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to delete subscription');
|
throw new Error('Failed to delete subscription');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all subscription notifications
|
// Fetch all users
|
||||||
|
export async function fetchUsers(): Promise<User[]> {
|
||||||
|
const response = await fetch(`${API_URL}/users`, {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch users');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a user
|
||||||
|
export async function updateUser(
|
||||||
|
userId: number,
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(`${API_URL}/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a user
|
||||||
|
export async function deleteUser(userId: number): Promise<void> {
|
||||||
|
const response = await fetch(`${API_URL}/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subscription notifications with pagination
|
||||||
export async function fetchSubscriptionNotifications(
|
export async function fetchSubscriptionNotifications(
|
||||||
subscriptionId: string
|
subscriptionId: string,
|
||||||
|
limit: number = 20,
|
||||||
|
offset: number = 0
|
||||||
): Promise<Notification[]> {
|
): Promise<Notification[]> {
|
||||||
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`);
|
const response = await fetch(
|
||||||
|
`${API_URL}/subscriptions/${subscriptionId}/notifications?limit=${limit}&offset=${offset}`,
|
||||||
|
{
|
||||||
|
headers: authHeaders()
|
||||||
|
}
|
||||||
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch subscription notifications');
|
throw new Error('Failed to fetch subscription notifications');
|
||||||
}
|
}
|
||||||
@@ -218,7 +352,9 @@ export async function fetchSubscriptionNotifications(
|
|||||||
// Fetch all notifications or filter by topic
|
// Fetch all notifications or filter by topic
|
||||||
export async function fetchAllNotifications(): Promise<Notification[]> {
|
export async function fetchAllNotifications(): Promise<Notification[]> {
|
||||||
const url = `${API_URL}/notifications`;
|
const url = `${API_URL}/notifications`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url, {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch notifications');
|
throw new Error('Failed to fetch notifications');
|
||||||
}
|
}
|
||||||
@@ -228,7 +364,8 @@ export async function fetchAllNotifications(): Promise<Notification[]> {
|
|||||||
// Delete a notification
|
// Delete a notification
|
||||||
export async function deleteNotification(notificationId: number): Promise<void> {
|
export async function deleteNotification(notificationId: number): Promise<void> {
|
||||||
const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
|
const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
headers: authHeaders()
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to delete notification');
|
throw new Error('Failed to delete notification');
|
||||||
@@ -237,7 +374,9 @@ export async function deleteNotification(notificationId: number): Promise<void>
|
|||||||
|
|
||||||
// Fetch a single script by ID
|
// Fetch a single script by ID
|
||||||
export async function fetchScriptById(id: number): Promise<Script> {
|
export async function fetchScriptById(id: number): Promise<Script> {
|
||||||
const response = await fetch(`${API_URL}/script/${id}`);
|
const response = await fetch(`${API_URL}/script/${id}`, {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch script');
|
throw new Error('Failed to fetch script');
|
||||||
}
|
}
|
||||||
@@ -248,9 +387,7 @@ export async function fetchScriptById(id: number): Promise<Script> {
|
|||||||
export async function updateScript(id: number, updatedScript: Partial<Script>): Promise<Script> {
|
export async function updateScript(id: number, updatedScript: Partial<Script>): Promise<Script> {
|
||||||
const response = await fetch(`${API_URL}/script/${id}`, {
|
const response = await fetch(`${API_URL}/script/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(updatedScript)
|
body: JSON.stringify(updatedScript)
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -261,7 +398,9 @@ export async function updateScript(id: number, updatedScript: Partial<Script>):
|
|||||||
|
|
||||||
// Fetch logs for a specific script
|
// Fetch logs for a specific script
|
||||||
export async function fetchLogs(scriptId: number): Promise<Log[]> {
|
export async function fetchLogs(scriptId: number): Promise<Log[]> {
|
||||||
const response = await fetch(`${API_URL}/script/${scriptId}/log`);
|
const response = await fetch(`${API_URL}/script/${scriptId}/log`, {
|
||||||
|
headers: authHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch logs');
|
throw new Error('Failed to fetch logs');
|
||||||
}
|
}
|
||||||
@@ -272,9 +411,7 @@ export async function fetchLogs(scriptId: number): Promise<Log[]> {
|
|||||||
export async function addLog(scriptId: number, log: Log): Promise<Log> {
|
export async function addLog(scriptId: number, log: Log): Promise<Log> {
|
||||||
const response = await fetch(`${API_URL}/script/${scriptId}/log`, {
|
const response = await fetch(`${API_URL}/script/${scriptId}/log`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(log)
|
body: JSON.stringify(log)
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -286,7 +423,8 @@ export async function addLog(scriptId: number, log: Log): Promise<Log> {
|
|||||||
// Execute a script by ID
|
// Execute a script by ID
|
||||||
export async function executeScript(scriptId: number): Promise<{ message: string }> {
|
export async function executeScript(scriptId: number): Promise<{ message: string }> {
|
||||||
const response = await fetch(`${API_URL}/script/${scriptId}/execute`, {
|
const response = await fetch(`${API_URL}/script/${scriptId}/execute`, {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
headers: authHeaders()
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to execute script');
|
throw new Error('Failed to execute script');
|
||||||
@@ -297,7 +435,8 @@ export async function executeScript(scriptId: number): Promise<{ message: string
|
|||||||
// Delete a log from a specific script
|
// Delete a log from a specific script
|
||||||
export async function deleteLog(scriptId: number, logId: number): Promise<void> {
|
export async function deleteLog(scriptId: number, logId: number): Promise<void> {
|
||||||
const response = await fetch(`${API_URL}/script/${scriptId}/log/${logId}`, {
|
const response = await fetch(`${API_URL}/script/${scriptId}/log/${logId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
headers: authHeaders()
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to delete log');
|
throw new Error('Failed to delete log');
|
||||||
@@ -308,7 +447,8 @@ export async function deleteLog(scriptId: number, logId: number): Promise<void>
|
|||||||
// Delete a script
|
// Delete a script
|
||||||
export async function deleteScript(id: number): Promise<void> {
|
export async function deleteScript(id: number): Promise<void> {
|
||||||
const response = await fetch(`${API_URL}/script/${id}`, {
|
const response = await fetch(`${API_URL}/script/${id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
headers: authHeaders()
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to delete script');
|
throw new Error('Failed to delete script');
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import { checkHealth } from '$lib/api';
|
import { checkHealth } from '$lib/api';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
|
let notifications: Notification[] = $state([]);
|
||||||
|
let notificationId = 0;
|
||||||
|
let healthStatus = writable<'healthy' | 'unhealthy'>('unhealthy');
|
||||||
|
let isAuthenticated = $state(false);
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
@@ -12,10 +20,12 @@
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let notifications: Notification[] = $state([]);
|
function checkAuth() {
|
||||||
let notificationId = 0;
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
let healthStatus = writable<'healthy' | 'unhealthy'>('unhealthy');
|
isAuthenticated = !!token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateHealthStatus() {
|
async function updateHealthStatus() {
|
||||||
const status = await checkHealth();
|
const status = await checkHealth();
|
||||||
@@ -30,10 +40,23 @@
|
|||||||
}, 4000);
|
}, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
isAuthenticated = false;
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
window.showNotification = showNotification;
|
window.showNotification = showNotification;
|
||||||
updateHealthStatus();
|
updateHealthStatus();
|
||||||
setInterval(updateHealthStatus, 10000); // Check health every 10 seconds
|
setInterval(updateHealthStatus, 10000); // Check health every 10 seconds
|
||||||
|
checkAuth();
|
||||||
|
// Redirect unauthenticated users to /login unless they're on /register or /login
|
||||||
|
if (!isAuthenticated && page.url.pathname !== '/register' && page.url.pathname !== '/login') {
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -41,18 +64,26 @@
|
|||||||
<div class="container mx-auto flex justify-between items-center p-4">
|
<div class="container mx-auto flex justify-between items-center p-4">
|
||||||
<a href="/" class="text-2xl font-bold hover:text-gray-400">Project Monitor</a>
|
<a href="/" class="text-2xl font-bold hover:text-gray-400">Project Monitor</a>
|
||||||
<div class="flex space-x-6">
|
<div class="flex space-x-6">
|
||||||
<a href="/" class="text-lg hover:text-gray-400">Home</a>
|
{#if isAuthenticated}
|
||||||
<a href="/scripts" class="text-lg hover:text-gray-400">Scripts</a>
|
<a href="/scripts" class="text-lg hover:text-gray-400">Scripts</a>
|
||||||
<a href="/notifications" class="text-lg hover:text-gray-400">Notifications</a>
|
<a href="/notifications" class="text-lg hover:text-gray-400">Notifications</a>
|
||||||
|
<button class="text-lg hover:text-gray-400" onclick={logout}>Logout</button>
|
||||||
<a href="/settings" class="text-lg hover:text-gray-400">
|
<a href="/settings" class="text-lg hover:text-gray-400">
|
||||||
<Icon icon="material-symbols:settings" width="24" height="24" />
|
<Icon icon="material-symbols:settings" width="24" height="24" />
|
||||||
</a>
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a href="/login" class="text-lg hover:text-gray-400">Login</a>
|
||||||
|
<a href="/register" class="text-lg hover:text-gray-400">Register</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
{#if isAuthenticated || page.url.pathname === '/login' || page.url.pathname === '/register'}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="fixed bottom-4 right-4 space-y-2">
|
<div class="fixed bottom-4 right-4 space-y-2">
|
||||||
{#each notifications as notification (notification.id)}
|
{#each notifications as notification (notification.id)}
|
||||||
@@ -65,7 +96,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fixed bottom-4 left-4 group">
|
<div class="fixed bottom-4 left-4 group">
|
||||||
{#if $healthStatus === 'healthy'}
|
{#if $healthStatus === 'healthy'}
|
||||||
|
|||||||
96
frontend/src/routes/login/+page.svelte
Normal file
96
frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { API_URL } from '$lib/api';
|
||||||
|
|
||||||
|
// Login form state
|
||||||
|
let loginUsername = $state('');
|
||||||
|
let loginPassword = $state('');
|
||||||
|
let loginError: string | null = $state(null);
|
||||||
|
let loginLoading = $state(false);
|
||||||
|
|
||||||
|
async function handleLogin(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
loginError = null;
|
||||||
|
loginLoading = true;
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('username', loginUsername);
|
||||||
|
form.append('password', loginPassword);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
loginError = data.detail || 'Login failed';
|
||||||
|
loginLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const expirationTime = Date.now() + 60 * 60 * 1000; // 60 minutes in milliseconds
|
||||||
|
localStorage.setItem(
|
||||||
|
'token',
|
||||||
|
JSON.stringify({ value: data.access_token, expiresAt: expirationTime })
|
||||||
|
);
|
||||||
|
localStorage.setItem('username', loginUsername);
|
||||||
|
|
||||||
|
goto('/').then(() => location.reload());
|
||||||
|
} catch (err) {
|
||||||
|
loginError = 'Network error - ' + err;
|
||||||
|
} finally {
|
||||||
|
loginLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center justify-center min-h-screen bg-gray-50">
|
||||||
|
<form
|
||||||
|
class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 w-full max-w-sm"
|
||||||
|
onsubmit={handleLogin}
|
||||||
|
>
|
||||||
|
<h2 class="text-2xl font-bold mb-6 text-center">Login</h2>
|
||||||
|
{#if loginError}
|
||||||
|
<div class="mb-4 text-red-600 text-sm">{loginError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-gray-700 text-sm font-bold mb-2" for="username"> Username </label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||||
|
bind:value={loginUsername}
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-gray-700 text-sm font-bold mb-2" for="password"> Password </label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
|
||||||
|
bind:value={loginPassword}
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
|
||||||
|
disabled={loginLoading}
|
||||||
|
>
|
||||||
|
{loginLoading ? 'Logging in...' : 'Login'}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/register"
|
||||||
|
class="inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800 ml-4"
|
||||||
|
>
|
||||||
|
Create an account
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { fetchSubscriptions } from '$lib/api';
|
|
||||||
import type { PageServerLoad } from './$types';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
|
||||||
try {
|
|
||||||
const subscriptions = await fetchSubscriptions();
|
|
||||||
return { subscriptions };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load subscriptions:', error);
|
|
||||||
return { subscriptions: [] };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { addSubscription, deleteSubscription, fetchSubscriptions } from '$lib/api';
|
import { addSubscription, deleteSubscription, fetchSubscriptions } from '$lib/api';
|
||||||
import type { Subscription } from '$lib/api';
|
import type { Subscription } from '$lib/api';
|
||||||
export let data: { subscriptions: Subscription[] };
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let subscriptions: Subscription[] = data.subscriptions;
|
let subscriptions: Subscription[] = [];
|
||||||
let newTopic = '';
|
let newTopic = '';
|
||||||
|
let loading: boolean = true;
|
||||||
|
let errorMsg: string | null = null;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
loading = true;
|
||||||
|
errorMsg = null;
|
||||||
|
try {
|
||||||
|
subscriptions = await fetchSubscriptions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load subscriptions:', error);
|
||||||
|
errorMsg = 'Failed to load subscriptions';
|
||||||
|
subscriptions = [];
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
async function handleAddSubscription() {
|
async function handleAddSubscription() {
|
||||||
if (!newTopic.trim()) {
|
if (!newTopic.trim()) {
|
||||||
@@ -35,16 +50,48 @@
|
|||||||
<main class="p-4">
|
<main class="p-4">
|
||||||
<h1 class="text-2xl font-bold mb-4">Subscriptions</h1>
|
<h1 class="text-2xl font-bold mb-4">Subscriptions</h1>
|
||||||
|
|
||||||
|
{#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">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{#each subscriptions as subscription (subscription.id)}
|
{#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
|
<a
|
||||||
href={`/notifications/${subscription.id}`}
|
href={`/notifications/${subscription.id}`}
|
||||||
class="block p-4 border rounded bg-white hover:bg-gray-100 shadow-lg shadow-blue-500/50"
|
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>
|
<h2 class="text-lg font-semibold text-gray-800">{subscription.topic}</h2>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<h2 class="text-xl font-semibold mb-2">Add New Subscription</h2>
|
<h2 class="text-xl font-semibold mb-2">Add New Subscription</h2>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { fetchSubscriptionNotifications, getSubscription } from '$lib/api';
|
|
||||||
import type { PageServerLoad } from './$types';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
|
||||||
try {
|
|
||||||
const subscription_id: string = params.subscription_id;
|
|
||||||
|
|
||||||
const subscription = await getSubscription(subscription_id);
|
|
||||||
const notifications = (await fetchSubscriptionNotifications(subscription_id)).sort(
|
|
||||||
(a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
return { subscription, notifications };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load:', error);
|
|
||||||
return { subscription: {}, notifications: [] };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,14 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { deleteNotification, addNotification, setViewed } from '$lib/api';
|
import {
|
||||||
|
deleteNotification,
|
||||||
|
setViewed,
|
||||||
|
fetchSubscriptionNotifications,
|
||||||
|
deleteSubscriptionNotifications,
|
||||||
|
markAllNotificationsAsViewed
|
||||||
|
} from '$lib/api';
|
||||||
import type { Notification, Subscription } from '$lib/api';
|
import type { Notification, Subscription } from '$lib/api';
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
export let data: { notifications: Notification[]; subscription: Subscription };
|
let subscription: Subscription | null = $state(data.subscription);
|
||||||
|
let notifications: Notification[] = $state(data.notifications);
|
||||||
|
let selectedNotification: Notification | null = $state(null);
|
||||||
|
|
||||||
let notifications: Notification[] = data.notifications;
|
// Delete all notifications for this subscription
|
||||||
let newNotificationTitle = '';
|
async function handleDeleteAllNotifications() {
|
||||||
let newNotificationMessage = '';
|
if (notifications.length === 0) return;
|
||||||
let newNotificationPriority = 3;
|
const confirmed = window.confirm(
|
||||||
let selectedNotification: Notification | null = null;
|
'Are you sure you want to delete all notifications for this subscription?'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
deleteSubscriptionNotifications(subscription!.id);
|
||||||
|
notifications = [];
|
||||||
|
window.showNotification('success', 'All notifications deleted successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
window.showNotification('error', 'Failed to delete all notifications - ' + error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
let limit = 20;
|
||||||
|
let offset = $derived(notifications.length);
|
||||||
|
let loadingMore = $state(false);
|
||||||
|
let allLoaded = $derived(notifications.length < limit);
|
||||||
|
|
||||||
async function openNotificationPopup(notification: Notification) {
|
async function openNotificationPopup(notification: Notification) {
|
||||||
if (!notification.viewed) {
|
if (!notification.viewed) {
|
||||||
@@ -26,7 +51,9 @@
|
|||||||
selectedNotification = null;
|
selectedNotification = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteNotification(id: number) {
|
async function handleDeleteNotification(e, id: number) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteNotification(id);
|
await deleteNotification(id);
|
||||||
notifications = notifications.filter((notification) => notification.id !== id);
|
notifications = notifications.filter((notification) => notification.id !== id);
|
||||||
@@ -37,11 +64,7 @@
|
|||||||
}
|
}
|
||||||
async function markAllViewed() {
|
async function markAllViewed() {
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
markAllNotificationsAsViewed(subscription!.id);
|
||||||
notifications
|
|
||||||
.filter((notification) => !notification.viewed)
|
|
||||||
.map((notification) => setViewed(notification.id))
|
|
||||||
);
|
|
||||||
notifications = notifications.map((notification) =>
|
notifications = notifications.map((notification) =>
|
||||||
notification.viewed ? notification : { ...notification, viewed: true }
|
notification.viewed ? notification : { ...notification, viewed: true }
|
||||||
);
|
);
|
||||||
@@ -50,16 +73,41 @@
|
|||||||
window.showNotification('error', 'Failed to mark all notifications as viewed.');
|
window.showNotification('error', 'Failed to mark all notifications as viewed.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load more notifications
|
||||||
|
async function loadMoreNotifications() {
|
||||||
|
loadingMore = true;
|
||||||
|
try {
|
||||||
|
const more = await fetchSubscriptionNotifications(subscription!.id.toString(), limit, offset);
|
||||||
|
notifications = [...notifications, ...more];
|
||||||
|
offset += more.length;
|
||||||
|
if (more.length < limit) {
|
||||||
|
allLoaded = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
window.showNotification('error', 'Failed to load more notifications - ' + err);
|
||||||
|
}
|
||||||
|
loadingMore = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="p-4">
|
<main class="p-4">
|
||||||
<h1 class="text-2xl font-bold mb-4">Notifications for {data.subscription.topic}:</h1>
|
<h1 class="text-2xl font-bold mb-4">Notifications for {subscription!.topic}:</h1>
|
||||||
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<a href="/notifications" class="text-blue-500 hover:underline">← Return to Subscriptions</a>
|
<a href="/notifications" class="text-blue-500 hover:underline">← Return to Subscriptions</a>
|
||||||
<button class="bg-blue-500 text-white px-4 py-2 rounded" on:click={markAllViewed}>
|
<div class="flex gap-2">
|
||||||
|
<button class="bg-blue-500 text-white px-4 py-2 rounded" onclick={markAllViewed}>
|
||||||
Mark All Viewed
|
Mark All Viewed
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="bg-red-500 text-white px-4 py-2 rounded"
|
||||||
|
onclick={handleDeleteAllNotifications}
|
||||||
|
disabled={notifications.length === 0}
|
||||||
|
>
|
||||||
|
Delete All Notifications
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if notifications.length === 0}
|
{#if notifications.length === 0}
|
||||||
@@ -71,9 +119,14 @@
|
|||||||
class="p-2 rounded flex justify-between items-center border-s-slate-400 border-1"
|
class="p-2 rounded flex justify-between items-center border-s-slate-400 border-1"
|
||||||
class:bg-green-200={notification.viewed}
|
class:bg-green-200={notification.viewed}
|
||||||
>
|
>
|
||||||
<button class="p-2 w-full text-left" on:click={() => openNotificationPopup(notification)}>
|
<button class="p-2 w-full text-left" onclick={() => openNotificationPopup(notification)}>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">{notification.title}</p>
|
<p class="font-semibold">{notification.title}</p>
|
||||||
|
<p>
|
||||||
|
{notification.message.split('\n')[0].length > 75
|
||||||
|
? `${notification.message.split('\n')[0].slice(0, 75)}...`
|
||||||
|
: notification.message.split('\n')[0]}
|
||||||
|
</p>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
{new Date(notification.created_at).toLocaleString()}
|
{new Date(notification.created_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
@@ -81,7 +134,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 block"
|
class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 block"
|
||||||
on:click|stopPropagation={() => handleDeleteNotification(notification.id)}
|
onclick={(e) => handleDeleteNotification(e, notification.id)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -89,6 +142,17 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if !allLoaded && notifications.length > 0}
|
||||||
|
<div class="flex justify-center mt-6">
|
||||||
|
<button
|
||||||
|
class="bg-gray-200 px-4 py-2 rounded hover:bg-gray-300"
|
||||||
|
onclick={loadMoreNotifications}
|
||||||
|
disabled={loadingMore}
|
||||||
|
>
|
||||||
|
{loadingMore ? 'Loading...' : 'Load More'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if selectedNotification}
|
{#if selectedNotification}
|
||||||
<div class="fixed inset-0 bg-opacity-30 backdrop-blur-sm flex items-center justify-center z-50">
|
<div class="fixed inset-0 bg-opacity-30 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
<div class="bg-white p-6 rounded shadow-lg w-3/4 max-w-2xl max-h-[80vh] overflow-y-auto">
|
<div class="bg-white p-6 rounded shadow-lg w-3/4 max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
@@ -116,7 +180,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
class="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
on:click={closeNotificationPopup}
|
onclick={closeNotificationPopup}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
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">
|
<script lang="ts">
|
||||||
import { addScript } from '$lib/api';
|
import { addScript, fetchScripts } from '$lib/api';
|
||||||
import type { Script } from '$lib/api';
|
import type { Script } from '$lib/api';
|
||||||
import CodeMirror from 'svelte-codemirror-editor';
|
import CodeMirror from 'svelte-codemirror-editor';
|
||||||
import { python } from '@codemirror/lang-python';
|
import { python } from '@codemirror/lang-python';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
export let data: { scripts: Script[] };
|
let scripts: Script[] = [];
|
||||||
let scripts: Script[] = data.scripts;
|
let newScript: Omit<Script, 'id' | 'created_at'> = {
|
||||||
let newScript: Omit<Script, 'id' | 'created_at'> = { name: '', script_content: '' };
|
name: '',
|
||||||
|
script_content: '',
|
||||||
|
enabled: false
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
scripts = await fetchScripts();
|
||||||
|
});
|
||||||
|
|
||||||
// Add a new script
|
// Add a new script
|
||||||
async function handleAddScript() {
|
async function handleAddScript() {
|
||||||
try {
|
try {
|
||||||
const addedScript = await addScript(newScript);
|
const addedScript = await addScript(newScript);
|
||||||
scripts = [...scripts, addedScript];
|
scripts = [...scripts, addedScript];
|
||||||
newScript = { name: '', script_content: '' };
|
newScript = { name: '', script_content: '', enabled: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.showNotification('Failed to add script. ' + err);
|
window.showNotification('Failed to add script. ' + err);
|
||||||
}
|
}
|
||||||
@@ -38,11 +46,7 @@
|
|||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<h2 class="text-xl font-semibold mb-2">Add New Script</h2>
|
<h2 class="text-xl font-semibold mb-2">Add New Script</h2>
|
||||||
<form
|
<form onsubmit={handleAddScript} class="space-y-4 p-4 border rounded shadow">
|
||||||
on:submit|preventDefault={handleAddScript}
|
|
||||||
class="space-y-4 p-4 border rounded shadow"
|
|
||||||
on:submit={() => (newScript.script_content = editor.getValue())}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<label for="name" class="block text-sm font-medium">Name</label>
|
<label for="name" class="block text-sm font-medium">Name</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import { fetchScriptById, fetchLogs } from '$lib/api';
|
|
||||||
import type { Log } from '$lib/api';
|
|
||||||
|
|
||||||
export async function load({ params }) {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const script = await fetchScriptById(parseInt(id));
|
|
||||||
const logs: Log[] = (await fetchLogs(script.id)).sort(
|
|
||||||
(a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!script) {
|
|
||||||
throw error(404, 'Script not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { script, logs };
|
|
||||||
} catch (err) {
|
|
||||||
throw error(500, 'Failed to fetch script data - ' + err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,30 +2,49 @@
|
|||||||
import {
|
import {
|
||||||
updateScript,
|
updateScript,
|
||||||
deleteScript,
|
deleteScript,
|
||||||
addLog,
|
|
||||||
deleteLog,
|
deleteLog,
|
||||||
executeScript,
|
executeScript,
|
||||||
|
fetchScriptById,
|
||||||
fetchLogs
|
fetchLogs
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import type { Script, Log } from '$lib/api';
|
import type { Script, Log } from '$lib/api';
|
||||||
import CodeMirror from 'svelte-codemirror-editor';
|
import CodeMirror from 'svelte-codemirror-editor';
|
||||||
import { python } from '@codemirror/lang-python';
|
import { python } from '@codemirror/lang-python';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
export let data: { script: Script; logs: Log[] };
|
export let params: { id: string };
|
||||||
let script: Script = data.script;
|
let script: Script | null = null;
|
||||||
let logs: Log[] = data.logs;
|
let logs: Log[] = [];
|
||||||
let updatedTitle: string = script.name || '';
|
let updatedTitle: string = '';
|
||||||
let updatedContent: string = script.script_content || '';
|
let updatedContent: string = '';
|
||||||
let updatedEnabled: boolean = script.enabled || false;
|
let updatedEnabled: boolean = false;
|
||||||
|
let loading: boolean = true;
|
||||||
|
let errorMsg: string | null = null;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const fetchedScript = await fetchScriptById(parseInt(params.id));
|
||||||
|
if (!fetchedScript) {
|
||||||
|
errorMsg = 'Script not found';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
script = fetchedScript;
|
||||||
|
updatedTitle = script.name || '';
|
||||||
|
updatedContent = script.script_content || '';
|
||||||
|
updatedEnabled = script.enabled || false;
|
||||||
|
const fetchedLogs: Log[] = (await fetchLogs(script.id)).sort(
|
||||||
|
(a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
|
||||||
|
);
|
||||||
|
logs = fetchedLogs;
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg = 'Failed to fetch script data - ' + err;
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
let isEditMode: boolean = false;
|
let isEditMode: boolean = false;
|
||||||
|
|
||||||
let newLog: Omit<Log, 'id' | 'script_id'> = {
|
|
||||||
message: '',
|
|
||||||
error_code: 0,
|
|
||||||
error_message: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
let selectedLog: Log | null = null;
|
let selectedLog: Log | null = null;
|
||||||
|
|
||||||
function openLogPopup(log: Log) {
|
function openLogPopup(log: Log) {
|
||||||
@@ -38,10 +57,10 @@
|
|||||||
|
|
||||||
async function handleExecuteScript() {
|
async function handleExecuteScript() {
|
||||||
try {
|
try {
|
||||||
await executeScript(script.id);
|
await executeScript(script!.id);
|
||||||
window.showNotification('success', 'Script executed successfully!');
|
window.showNotification('success', 'Script executed successfully!');
|
||||||
// Reload the list of logs after execution
|
// Reload the list of logs after execution
|
||||||
logs = (await fetchLogs(script.id)).sort(
|
logs = (await fetchLogs(script!.id)).sort(
|
||||||
(a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
|
(a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -66,26 +85,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddLog() {
|
|
||||||
if (newLog.message.trim()) {
|
|
||||||
try {
|
|
||||||
const addedLog = await addLog(script.id, newLog);
|
|
||||||
logs = [addedLog, ...logs];
|
|
||||||
newLog = {
|
|
||||||
message: '',
|
|
||||||
error_code: 0,
|
|
||||||
error_message: ''
|
|
||||||
};
|
|
||||||
window.showNotification('success', 'Log added successfully!');
|
|
||||||
} catch (err) {
|
|
||||||
window.showNotification('error', 'Failed to add log. ' + err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteLog(logId: number) {
|
async function handleDeleteLog(logId: number) {
|
||||||
try {
|
try {
|
||||||
await deleteLog(script.id, logId);
|
await deleteLog(script!.id, logId);
|
||||||
logs = logs.filter((log) => log.id !== logId);
|
logs = logs.filter((log) => log.id !== logId);
|
||||||
window.showNotification('success', 'Log deleted successfully!');
|
window.showNotification('success', 'Log deleted successfully!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -109,7 +111,11 @@
|
|||||||
<main class="p-4">
|
<main class="p-4">
|
||||||
<!-- Removed local notification container as notifications are now global -->
|
<!-- Removed local notification container as notifications are now global -->
|
||||||
|
|
||||||
{#if script}
|
{#if loading}
|
||||||
|
<p>Loading...</p>
|
||||||
|
{:else if errorMsg}
|
||||||
|
<p class="text-red-500">{errorMsg}</p>
|
||||||
|
{:else if script}
|
||||||
{#if isEditMode}
|
{#if isEditMode}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -190,46 +196,6 @@
|
|||||||
|
|
||||||
<section class="mt-8">
|
<section class="mt-8">
|
||||||
<h2 class="text-xl font-bold mb-4">Logs</h2>
|
<h2 class="text-xl font-bold mb-4">Logs</h2>
|
||||||
<!--- --
|
|
||||||
<form on:submit|preventDefault={handleAddLog} class="mb-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="logMessage" class="block text-sm font-medium">Log Message</label>
|
|
||||||
<input
|
|
||||||
id="logMessage"
|
|
||||||
type="text"
|
|
||||||
bind:value={newLog.message}
|
|
||||||
placeholder="Enter new log message"
|
|
||||||
class="w-full p-2 border rounded"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="errorCode" class="block text-sm font-medium">Error Code</label>
|
|
||||||
<input
|
|
||||||
id="errorCode"
|
|
||||||
type="number"
|
|
||||||
bind:value={newLog.error_code}
|
|
||||||
placeholder="Enter error code (0 for no error)"
|
|
||||||
class="w-full p-2 border rounded"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="errorMessage" class="block text-sm font-medium">Error Message</label>
|
|
||||||
<textarea
|
|
||||||
id="errorMessage"
|
|
||||||
type="text"
|
|
||||||
bind:value={newLog.error_message}
|
|
||||||
placeholder="Enter error message (optional)"
|
|
||||||
class="w-full p-2 border rounded"
|
|
||||||
>
|
|
||||||
</textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600">
|
|
||||||
Add Log
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
-->
|
|
||||||
<ul class="space-y-4">
|
<ul class="space-y-4">
|
||||||
{#each logs as log (log.id)}
|
{#each logs as log (log.id)}
|
||||||
<li
|
<li
|
||||||
|
|||||||
@@ -1,67 +1,106 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fetchSettings, updateSetting } from '$lib/api';
|
import { fetchUserSettings, updateSetting, fetchUsers, updateUser, deleteUser } from '$lib/api';
|
||||||
import type { Settings } from '$lib/api';
|
import type { Settings, User } from '$lib/api';
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
import CodeMirror from 'svelte-codemirror-editor';
|
import CodeMirror from 'svelte-codemirror-editor';
|
||||||
|
|
||||||
let settings = writable<Settings[]>([]);
|
let settings: Settings | null = $state(null);
|
||||||
let isLoading = writable(false);
|
let users: User[] = $state([]);
|
||||||
let error = writable<string | null>(null);
|
let currentUser: string | null = $state(localStorage.getItem('username'));
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
isLoading.set(true);
|
isLoading = true;
|
||||||
error.set(null);
|
error = null;
|
||||||
try {
|
try {
|
||||||
const data = await fetchSettings();
|
const data = await fetchUserSettings();
|
||||||
settings.set(data);
|
settings = data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.set('Failed to load settings');
|
error = 'Failed to load settings - ' + err;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.set(false);
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
users = await fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Failed to load users - ' + err;
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSetting(setting: Settings) {
|
async function saveSetting(setting: Settings) {
|
||||||
isLoading.set(true);
|
isLoading = true;
|
||||||
error.set(null);
|
error = null;
|
||||||
try {
|
try {
|
||||||
await updateSetting(setting.id, setting);
|
await updateSetting(setting.id, setting);
|
||||||
loadSettings(); // Refresh settings after update
|
loadSettings(); // Refresh settings after update
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.set('Failed to save setting');
|
error = 'Failed to save settings - ' + err;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.set(false);
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateUser(user: User, username: string, password: string) {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
await updateUser(user.id, username, password);
|
||||||
|
loadUsers(); // Refresh users after update
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Failed to update user - ' + err;
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUser(userId: number) {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
await deleteUser(userId);
|
||||||
|
loadUsers(); // Refresh users after deletion
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Failed to delete user - ' + err;
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
loadUsers();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto p-4">
|
<div class="container mx-auto p-4">
|
||||||
<h1 class="text-2xl font-bold mb-4">Settings</h1>
|
<h1 class="text-2xl font-bold mb-4">Settings</h1>
|
||||||
|
|
||||||
{#if $isLoading}
|
{#if isLoading}
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
{:else if $error}
|
{:else if error}
|
||||||
<p class="text-red-500">{$error}</p>
|
<p class="text-red-500">{$error}</p>
|
||||||
{:else}
|
{:else if settings !== null}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#each $settings as setting (setting.id)}
|
|
||||||
<div class="p-4 border rounded shadow">
|
<div class="p-4 border rounded shadow">
|
||||||
<label class="block mb-2 font-bold">
|
<label class="block mb-2 font-bold">
|
||||||
Requirements
|
Requirements
|
||||||
<div class="w-full border rounded">
|
<div class="w-full border rounded font-normal">
|
||||||
<CodeMirror bind:value={setting.requirements} />
|
<CodeMirror bind:value={settings.requirements} />
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="block mt-4 mb-2 font-bold">
|
<label class="block mt-4 mb-2 font-bold">
|
||||||
Environment
|
Environment
|
||||||
<div class="w-full border rounded">
|
<div class="w-full border rounded font-normal">
|
||||||
<CodeMirror bind:value={setting.environment} />
|
<CodeMirror bind:value={settings.environment} />
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -70,19 +109,77 @@
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full p-2 border rounde font-normal"
|
class="w-full p-2 border rounde font-normal"
|
||||||
bind:value={setting.ntfy_url}
|
bind:value={settings.ntfy_url}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||||
on:click={() => saveSetting(setting)}
|
onclick={() => saveSetting(settings!)}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 class="text-xl font-bold mt-8">User Management</h2>
|
||||||
|
{#if isLoading}
|
||||||
|
<p>Loading users...</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-red-500">{$error}</p>
|
||||||
|
{:else}
|
||||||
|
<table
|
||||||
|
class="table-auto w-full mt-4 border-collapse border border-gray-300 shadow-lg rounded-lg"
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-100">
|
||||||
|
<th class="px-4 py-2 border border-gray-300 text-left font-semibold">Users</th>
|
||||||
|
<th class="px-4 py-2 border border-gray-300 text-center font-semibold">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each users as user (user.id)}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="border px-4 py-2 border-gray-300">
|
||||||
|
{#if user.username === currentUser}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="New Username"
|
||||||
|
class="px-2 py-1 border rounded"
|
||||||
|
bind:value={user.username}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="New Password"
|
||||||
|
class="px-2 py-1 border rounded"
|
||||||
|
bind:value={user.password}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{user.username}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="border px-4 py-2 border-gray-300 text-center">
|
||||||
|
{#if user.username === currentUser}
|
||||||
|
<button
|
||||||
|
class="ml-2 px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600"
|
||||||
|
onclick={() => handleUpdateUser(user, user.username, user.password)}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="ml-2 px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
|
onclick={() => handleDeleteUser(user.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
12
start.sh
Executable file
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