Compare commits

..

3 Commits

Author SHA1 Message Date
Sami Abuzakuk
a6f93edf72 Start working on containerization 2025-10-12 15:33:28 +02:00
Sami Abuzakuk
b76cef22ae Add frontend support for notifications 2025-10-12 14:54:53 +02:00
Sami Abuzakuk
fcb875aaf9 Add backend support for notifications 2025-10-12 14:54:37 +02:00
15 changed files with 792 additions and 16 deletions

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# Use an official Python image as the base
FROM python:3.12-slim
# Install Node.js, npm, and cron
RUN apt-get update && apt-get install -y \
curl \
cron \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Setup backend
WORKDIR /app/backend
COPY backend/* .
RUN pip install -r requirements.txt
# Setup frontend
WORKDIR /app/frontend
COPY frontend/* .
# Setup docker
WORKDIR /app/docker
COPY docker/* /app/docker/
RUN chmod +x entry.sh
RUN chmod 0644 cronfile
WORKDIR /app
EXPOSE 5173
# Command to start cron and the backend
CMD ["sh", "-c", "/app/docker/entry.sh"]

View File

@@ -3,7 +3,7 @@ from fastapi import FastAPI
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
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 from model import Log, SessionLocal, Script, Settings, Subscription, Notification
from run_scripts import run_scripts, update_requirements, update_environment from run_scripts import run_scripts, update_requirements, update_environment
import uvicorn import uvicorn
@@ -52,11 +52,183 @@ def hello():
return {"message": "Welcome to the Project Monitor API"} return {"message": "Welcome to the Project Monitor API"}
# Subscriptions API Endpoints
@app.get("/subscriptions")
def list_subscriptions():
db = SessionLocal()
subscriptions = db.query(Subscription).all()
db.close()
return subscriptions
class SubscriptionCreate(BaseModel):
topic: str
class SubscriptionResponse(BaseModel):
id: int
topic: str
created_at: datetime
model_config = {"from_attributes": True}
@app.get("/subscriptions/{subscription_id}", response_model=SubscriptionResponse)
def get_subscription(subscription_id: int):
db = SessionLocal()
subscription = (
db.query(Subscription).filter(Subscription.id == subscription_id).first()
)
if not subscription:
db.close()
raise HTTPException(status_code=404, detail="Subscription not found")
db.close()
return subscription
@app.post("/subscriptions")
def add_subscription(subscription: SubscriptionCreate):
db = SessionLocal()
existing_subscription = (
db.query(Subscription).filter(Subscription.topic == subscription.topic).first()
)
if existing_subscription:
db.close()
raise HTTPException(status_code=400, detail="Subscription already exists")
new_subscription = Subscription(topic=subscription.topic)
db.add(new_subscription)
db.commit()
db.refresh(new_subscription)
db.close()
return new_subscription
@app.delete("/subscriptions/{subscription_id}")
def remove_subscription(subscription_id: int):
db = SessionLocal()
subscription = (
db.query(Subscription).filter(Subscription.id == subscription_id).first()
)
if not subscription:
db.close()
raise HTTPException(status_code=404, detail="Subscription not found")
db.delete(subscription)
db.commit()
db.close()
return {"message": "Subscription removed"}
@app.get("/subscriptions/{subscription_id}/notifications")
def list_subscription_notifications(subscription_id: int):
db = SessionLocal()
notifications = (
db.query(Notification)
.filter(Notification.subscription_id == subscription_id)
.all()
)
db.close()
return [
NotificationResponse.model_validate(notification)
for notification in notifications
]
@app.get("/notifications")
def list_notifications():
db = SessionLocal()
notifications = db.query(Notification).all()
db.close()
return [
NotificationResponse.model_validate(notification)
for notification in notifications
]
@app.delete("/notifications/{notification_id}")
def remove_notification(notification_id: int):
db = SessionLocal()
notification = (
db.query(Notification).filter(Notification.id == notification_id).first()
)
if not notification:
db.close()
raise HTTPException(status_code=404, detail="Notification not found")
db.delete(notification)
db.commit()
db.close()
return {"message": "Notification removed"}
class NotificationCreate(BaseModel):
subscription_id: int
title: str
message: str
priority: int
class NotificationUpdate(BaseModel):
subscription_id: int | None = None
title: str | None = None
message: str | None = None
priority: int | None = None
viewed: bool | None = None
class NotificationResponse(NotificationCreate):
id: int
created_at: datetime
viewed: bool
model_config = {"from_attributes": True}
@app.put("/notifications/{notification_id}", response_model=NotificationResponse)
def update_notification(notification_id: int, notification: NotificationUpdate):
db = SessionLocal()
existing_notification = (
db.query(Notification).filter(Notification.id == notification_id).first()
)
if not existing_notification:
db.close()
raise HTTPException(status_code=404, detail="Notification not found")
if notification.subscription_id is not None:
existing_notification.subscription_id = notification.subscription_id
if notification.title is not None:
existing_notification.title = notification.title
if notification.message is not None:
existing_notification.message = notification.message
if notification.priority is not None:
existing_notification.priority = notification.priority
if notification.viewed is not None:
existing_notification.viewed = notification.viewed
db.commit()
db.refresh(existing_notification)
db.close()
return existing_notification
@app.post("/notifications", response_model=NotificationResponse)
def create_notification(notification: NotificationCreate):
db = SessionLocal()
new_notification = Notification(
subscription_id=notification.subscription_id,
title=notification.title,
message=notification.message,
priority=notification.priority,
)
db.add(new_notification)
db.commit()
db.refresh(new_notification)
db.close()
return new_notification
# Define Pydantic models for Settings # Define Pydantic models for Settings
class SettingsBase(BaseModel): class SettingsBase(BaseModel):
requirements: str requirements: str
environment: str environment: str
user: str user: str
ntfy_url: str
class SettingsUpdate(SettingsBase): class SettingsUpdate(SettingsBase):
@@ -107,14 +279,17 @@ def update_setting(settings_id: int, settings: SettingsUpdate):
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")
if existing_setting.requirements != settings.requirements: if settings.requirements and existing_setting.requirements != settings.requirements:
existing_setting.requirements = settings.requirements existing_setting.requirements = settings.requirements
update_requirements(settings) update_requirements(settings)
if existing_setting.environment != settings.environment: if settings.environment and existing_setting.environment != settings.environment:
existing_setting.environment = settings.environment existing_setting.environment = settings.environment
update_environment(settings) update_environment(settings)
if settings.ntfy_url is not None:
existing_setting.ntfy_url = settings.ntfy_url
db.commit() db.commit()
db.refresh(existing_setting) db.refresh(existing_setting)
db.close() db.close()

View File

@@ -0,0 +1,107 @@
import os
import requests
from datetime import datetime
from model import SessionLocal, Subscription, Settings, Notification
import json
# Constants
NTFY_TOKEN = os.getenv("NTFY_TOKEN") or "tk_cdmwd6ix255g3qgo4dx3r0gakw4y3"
def fetch_ntfy_notifications(base_url, subscriptions):
"""Fetch notifications from the ntfy.sh server for the given subscriptions using streaming."""
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {NTFY_TOKEN}" if NTFY_TOKEN else None,
}
notifications = []
for subscription in subscriptions:
topic = subscription.topic
last_message_id = subscription.last_message_id
since_param = "all" if last_message_id is None else last_message_id
url = f"{base_url}/{topic}/json?poll=1&since={since_param}"
response = requests.get(url, headers=headers, stream=True)
response.raise_for_status()
for line in response.iter_lines():
if line:
notification = json.loads(line)
if notification.get("event") == "message":
notifications.append(notification)
print(f"Fetched {len(notifications)} notifications")
print(notifications)
return notifications
def save_notifications_to_db(notifications, topic_to_subscription, db):
"""Save the fetched notifications to the database and update last_message_id."""
db = SessionLocal()
last_message_ids = {}
for notification in notifications:
topic = notification["topic"]
last_message_ids[topic] = notification["id"]
subscription_id = topic_to_subscription.get(notification["topic"])
if subscription_id:
new_notification = Notification(
title=notification.get("title", "No Title"),
message=notification.get("message", ""),
priority=notification.get("priority", 3),
created_at=datetime.fromtimestamp(notification["time"]),
subscription_id=subscription_id,
)
db.add(new_notification)
for topic, message_id in last_message_ids.items():
subscription_id = topic_to_subscription.get(topic)
if subscription_id:
subscription = (
db.query(Subscription)
.filter(Subscription.id == subscription_id)
.first()
)
if subscription:
subscription.last_message_id = message_id
db.commit()
db.close()
def main():
"""Main function to fetch and save notifications."""
db = SessionLocal()
# Get the ntfy base URL from settings
settings = db.query(Settings).filter(Settings.user == "default").first()
if not settings:
print("Default user settings not found.")
return
ntfy_url = settings.ntfy_url
if not ntfy_url:
print("Ntfy URL not found in settings.")
return
# Get all subscribed topics
subscriptions = db.query(Subscription).all()
topic_to_subscription = {
subscription.topic: subscription.id for subscription in subscriptions
}
topic_to_subscription = {
subscription.topic: subscription.id for subscription in subscriptions
}
db.close()
# Fetch notifications from ntfy.sh
notifications = fetch_ntfy_notifications(ntfy_url, subscriptions)
# Save notifications to the database
save_notifications_to_db(notifications, topic_to_subscription, db)
if __name__ == "__main__":
main()

View File

@@ -1,9 +1,8 @@
from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey, Boolean
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
from sqlalchemy.types import Boolean
# Initialize the database # Initialize the database
DATABASE_URL = "sqlite:///./project_monitor.db" DATABASE_URL = "sqlite:///./project_monitor.db"
@@ -50,7 +49,54 @@ class Settings(Base):
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) user = Column(String, nullable=False)
ntfy_url = Column(String, nullable=True)
class Subscription(Base):
__tablename__ = "subscriptions"
id = Column(Integer, primary_key=True, index=True)
topic = Column(String, nullable=False, unique=True)
last_message_id = Column(String, nullable=True)
created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class Notification(Base):
__tablename__ = "notifications"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
message = Column(Text, nullable=False)
priority = Column(Integer, nullable=False, default=3)
viewed = Column(Boolean, default=False)
sent = Column(Boolean, default=False)
subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=False)
created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Create the database tables # Create the database tables
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Ensure a default setting line exists
def ensure_default_setting():
db = SessionLocal()
default_setting = db.query(Settings).filter(Settings.user == "default").first()
if not default_setting:
new_setting = Settings(
requirements="",
environment="",
user="default",
ntfy_url="https://ntfy.abzk.fr",
)
db.add(new_setting)
db.commit()
db.close()
ensure_default_setting()

2
backend/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
requests
uvicorn

2
docker/crontab Normal file
View File

@@ -0,0 +1,2 @@
*/1 * * * * python3 /app/backend/get_notifications.py
*/5 * * * * python3 /app/backend/run_scripts.py

21
docker/entry.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
# Navigate to the frontend directory and start the Svelte app
cd frontend
npm run build
npm run dev &
# Navigate back to the root directory
cd ..
# Setup exec venv
cd backend/exec_folder
python3 -m venv venv
cd ../..
# Start the backend using uvicorn
uvicorn backend.backend:app --host 0.0.0.0 --port 8000 &
# Start the cron daemon
crontab docker/cronfile
cron -f

View File

@@ -1,5 +1,24 @@
export const API_URL = 'http://127.0.0.1:8000'; export const API_URL = 'http://127.0.0.1:8000';
/**
* Type definitions for Subscriptions and Notifications
*/
export interface Subscription {
id: number;
topic: string;
created_at: string;
}
export interface Notification {
id: number;
subscription_id: number;
title: string;
message: string;
priority: number;
created_at: string;
viewed: boolean;
}
/** /**
* Type definitions for Settings * Type definitions for Settings
*/ */
@@ -8,6 +27,7 @@ export interface Settings {
requirements: string; requirements: string;
environment: string; environment: string;
user: string; user: string;
ntfy_url?: string;
} }
export async function checkHealth(): Promise<'healthy' | 'unhealthy'> { export async function checkHealth(): Promise<'healthy' | 'unhealthy'> {
@@ -96,7 +116,7 @@ export async function updateSetting(
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify(updatedSetting) body: JSON.stringify({ ...updatedSetting, ntfy_url: updatedSetting.ntfy_url })
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to update setting'); throw new Error('Failed to update setting');
@@ -104,6 +124,115 @@ export async function updateSetting(
return response.json(); return response.json();
} }
// Fetch all subscriptions
export async function fetchSubscriptions(): Promise<Subscription[]> {
const response = await fetch(`${API_URL}/subscriptions`);
if (!response.ok) {
throw new Error('Failed to fetch subscriptions');
}
return response.json();
}
// Fetch subscriptions by topic
export async function getSubscription(topic_id: string): Promise<Subscription> {
const response = await fetch(`${API_URL}/subscriptions/${topic_id}`);
if (!response.ok) {
throw new Error('Failed to fetch subscriptions');
}
return response.json();
}
// Add a new notification to a subscription
export async function addNotification(
subscriptionId: number,
title: string,
message: string,
priority: number
): Promise<Notification> {
const response = await fetch(`${API_URL}/notifications`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ subscription_id: subscriptionId, title, message, priority })
});
if (!response.ok) {
throw new Error('Failed to add notification');
}
return response.json();
}
// Mark a notification as viewed
export async function setViewed(notificationId: number): Promise<Notification> {
const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ viewed: true })
});
if (!response.ok) {
throw new Error('Failed to set notification as viewed');
}
return response.json();
}
// Add a new subscription
export async function addSubscription(topic: string): Promise<Subscription> {
const response = await fetch(`${API_URL}/subscriptions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ topic })
});
if (!response.ok) {
throw new Error('Failed to add subscription');
}
return response.json();
}
// Delete a subscription
export async function deleteSubscription(subscriptionId: number): Promise<void> {
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete subscription');
}
}
// Get all subscription notifications
export async function fetchSubscriptionNotifications(
subscriptionId: string
): Promise<Notification[]> {
const response = await fetch(`${API_URL}/subscriptions/${subscriptionId}/notifications`);
if (!response.ok) {
throw new Error('Failed to fetch subscription notifications');
}
return response.json();
}
// Fetch all notifications or filter by topic
export async function fetchAllNotifications(): Promise<Notification[]> {
const url = `${API_URL}/notifications`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch notifications');
}
return response.json();
}
// Delete a notification
export async function deleteNotification(notificationId: number): Promise<void> {
const response = await fetch(`${API_URL}/notifications/${notificationId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete notification');
}
}
// 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}`);

View File

@@ -43,6 +43,7 @@
<div class="flex space-x-6"> <div class="flex space-x-6">
<a href="/" class="text-lg hover:text-gray-400">Home</a> <a href="/" class="text-lg hover:text-gray-400">Home</a>
<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="/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>

View File

@@ -10,3 +10,11 @@
Go to Scripts Go to Scripts
</a> </a>
</div> </div>
<div class="flex justify-center mt-4">
<a
href="/notifications"
class="px-6 py-3 bg-green-500 text-white rounded-lg shadow-md hover:bg-green-600"
>
View Notifications
</a>
</div>

View File

@@ -0,0 +1,12 @@
import { fetchSubscriptions } from '$lib/api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
try {
const subscriptions = await fetchSubscriptions();
return { subscriptions };
} catch (error) {
console.error('Failed to load subscriptions:', error);
return { subscriptions: [] };
}
};

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import { addSubscription, deleteSubscription, fetchSubscriptions } from '$lib/api';
import type { Subscription } from '$lib/api';
export let data: { subscriptions: Subscription[] };
let subscriptions: Subscription[] = data.subscriptions;
let newTopic = '';
async function handleAddSubscription() {
if (!newTopic.trim()) {
window.showNotification('error', 'Topic name cannot be empty.');
return;
}
try {
await addSubscription(newTopic.trim());
newTopic = '';
window.showNotification('success', 'Subscription added successfully.');
subscriptions = await fetchSubscriptions();
} catch (error) {
window.showNotification('error', 'Failed to add subscription - ' + error);
}
}
async function handleDeleteSubscription(id: number) {
try {
await deleteSubscription(id);
subscriptions = await fetchSubscriptions();
} catch (error) {
window.showNotification('error', 'Failed to delete subscription - ' + error);
}
}
</script>
<main class="p-4">
<h1 class="text-2xl font-bold mb-4">Subscriptions</h1>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{#each subscriptions as subscription (subscription.id)}
<a
href={`/notifications/${subscription.id}`}
class="block p-4 border rounded bg-white hover:bg-gray-100 shadow-lg shadow-blue-500/50"
>
<h2 class="text-lg font-semibold text-gray-800">{subscription.topic}</h2>
</a>
{/each}
</div>
<div class="mt-8">
<h2 class="text-xl font-semibold mb-2">Add New Subscription</h2>
<form
on:submit|preventDefault={handleAddSubscription}
class="space-y-4 p-4 border rounded shadow"
>
<div>
<label for="newTopic" class="block text-sm font-medium">Topic</label>
<input
id="newTopic"
type="text"
bind:value={newTopic}
required
class="mt-1 block w-full p-2 border rounded"
/>
</div>
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded">
Add Subscription
</button>
</form>
</div>
</main>
<style>
main {
max-width: 800px;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,18 @@
import { fetchSubscriptionNotifications, getSubscription } from '$lib/api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
try {
const subscription_id: string = params.subscription_id;
const subscription = await getSubscription(subscription_id);
const notifications = (await fetchSubscriptionNotifications(subscription_id)).sort(
(a, b) => new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
);
return { subscription, notifications };
} catch (error) {
console.error('Failed to load:', error);
return { subscription: {}, notifications: [] };
}
};

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import { deleteNotification, addNotification, setViewed } from '$lib/api';
import type { Notification, Subscription } from '$lib/api';
export let data: { notifications: Notification[]; subscription: Subscription };
let notifications: Notification[] = data.notifications;
let newNotificationTitle = '';
let newNotificationMessage = '';
let newNotificationPriority = 3;
let selectedNotification: Notification | null = null;
async function openNotificationPopup(notification: Notification) {
if (!notification.viewed) {
await setViewed(notification.id);
notifications = notifications.map((n) =>
n.id === notification.id ? { ...n, viewed: true } : n
);
notification.viewed = true;
}
selectedNotification = notification;
}
function closeNotificationPopup() {
selectedNotification = null;
}
async function handleDeleteNotification(id: number) {
try {
await deleteNotification(id);
notifications = notifications.filter((notification) => notification.id !== id);
window.showNotification('success', 'Notification deleted successfully.');
} catch (error) {
window.showNotification('error', 'Failed to delete notification - ' + error);
}
}
async function markAllViewed() {
try {
await Promise.all(
notifications
.filter((notification) => !notification.viewed)
.map((notification) => setViewed(notification.id))
);
notifications = notifications.map((notification) =>
notification.viewed ? notification : { ...notification, viewed: true }
);
window.showNotification('success', 'All notifications marked as viewed.');
} catch (error) {
window.showNotification('error', 'Failed to mark all notifications as viewed.');
}
}
</script>
<main class="p-4">
<h1 class="text-2xl font-bold mb-4">Notifications for {data.subscription.topic}:</h1>
<div class="flex justify-between items-center mb-4">
<a href="/notifications" class="text-blue-500 hover:underline">← Return to Subscriptions</a>
<button class="bg-blue-500 text-white px-4 py-2 rounded" on:click={markAllViewed}>
Mark All Viewed
</button>
</div>
{#if notifications.length === 0}
<p>No notifications found for this topic.</p>
{:else}
<ul class="space-y-4">
{#each notifications as notification (notification.id)}
<li
class="p-2 rounded flex justify-between items-center border-s-slate-400 border-1"
class:bg-green-200={notification.viewed}
>
<button class="p-2 w-full text-left" on:click={() => openNotificationPopup(notification)}>
<div>
<p class="font-semibold">{notification.title}</p>
<p class="text-sm text-gray-500">
{new Date(notification.created_at).toLocaleString()}
</p>
</div>
</button>
<button
class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 block"
on:click|stopPropagation={() => handleDeleteNotification(notification.id)}
>
Delete
</button>
</li>
{/each}
</ul>
{/if}
{#if selectedNotification}
<div class="fixed inset-0 bg-opacity-30 backdrop-blur-sm flex items-center justify-center z-50">
<div class="bg-white p-6 rounded shadow-lg w-3/4 max-w-2xl max-h-[80vh] overflow-y-auto">
<h3 class="text-lg font-bold mb-4">Notification Details</h3>
<div class="mb-4">
<p class="font-semibold">Title:</p>
<pre
class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{selectedNotification.title}</pre>
</div>
<div class="mb-4">
<p class="font-semibold">Message:</p>
<pre
class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{selectedNotification.message}</pre>
</div>
<div class="mb-4">
<p class="font-semibold">Priority:</p>
<pre
class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{selectedNotification.priority}</pre>
</div>
<div class="mb-4">
<p class="font-semibold">Created At:</p>
<pre class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{new Date(
selectedNotification.created_at
).toLocaleString()}</pre>
</div>
<button
class="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
on:click={closeNotificationPopup}
>
Close
</button>
</div>
</div>
{/if}
</main>
<style>
main {
max-width: 800px;
margin: 0 auto;
}
</style>

View File

@@ -51,18 +51,28 @@
<div class="space-y-4"> <div class="space-y-4">
{#each $settings as setting (setting.id)} {#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">Requirements</label> <label class="block mb-2 font-bold">
Requirements
<div class="w-full border rounded"> <div class="w-full border rounded">
<CodeMirror bind:value={setting.requirements} /> <CodeMirror bind:value={setting.requirements} />
</div> </div>
</label>
<label class="block mt-4 mb-2 font-bold">Environment</label> <label class="block mt-4 mb-2 font-bold">
Environment
<div class="w-full border rounded"> <div class="w-full border rounded">
<CodeMirror bind:value={setting.environment} /> <CodeMirror bind:value={setting.environment} />
</div> </div>
</label>
<label class="block mt-4 mb-2 font-bold">User</label> <label class="block mt-4 mb-2 font-bold">
<input type="text" class="w-full p-2 border rounded" bind:value={setting.user} readonly /> Ntfy URL
<input
type="text"
class="w-full p-2 border rounde font-normal"
bind:value={setting.ntfy_url}
/>
</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"