Compare commits

..

4 Commits

Author SHA1 Message Date
Sami Abuzakuk
6afc50eb81 Add frontend support for script execution 2025-10-11 12:38:19 +02:00
Sami Abuzakuk
78b19a03a8 Add backend support for script execution 2025-10-11 12:38:03 +02:00
Sami Abuzakuk
037d525905 Create .gitkeep 2025-10-11 10:40:08 +02:00
Sami Abuzakuk
7cfb20e1c3 Add iconify and replace offline icons 2025-10-11 10:33:29 +02:00
11 changed files with 317 additions and 42 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ __pycache__
.venv .venv
*.db *.db
.envrc .envrc
exec_folder/

View File

@@ -4,6 +4,7 @@ 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 from model import Log, SessionLocal, Script
from run_scripts import run_scripts
import uvicorn import uvicorn
app = FastAPI() app = FastAPI()
@@ -28,15 +29,22 @@ class ScriptCreate(ScriptBase):
pass pass
class ScriptUpdate(ScriptBase):
enabled: bool
class ScriptResponse(ScriptBase): class ScriptResponse(ScriptBase):
id: int id: int
created_at: datetime created_at: datetime
enabled: bool
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class ScriptLogCreate(BaseModel): class ScriptLogCreate(BaseModel):
message: str message: str
error_code: int
error_message: str
@app.get("/") @app.get("/")
@@ -80,19 +88,25 @@ def delete_script(script_id: int):
if not script: if not script:
raise HTTPException(status_code=404, detail="Script not found") raise HTTPException(status_code=404, detail="Script not found")
db.delete(script) db.delete(script)
logs = db.query(Log).filter(Log.script_id == script_id).all()
for log in logs:
db.delete(log)
db.commit() db.commit()
db.close() db.close()
return {"message": "Script deleted"} return {"message": "Script deleted"}
@app.put("/script/{script_id}", response_model=ScriptResponse) @app.put("/script/{script_id}", response_model=ScriptResponse)
def update_script(script_id: int, script: ScriptCreate): def update_script(script_id: int, script: ScriptUpdate):
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:
raise HTTPException(status_code=404, detail="Script not found") raise HTTPException(status_code=404, detail="Script not found")
existing_script.name = script.name existing_script.name = script.name
existing_script.script_content = script.script_content existing_script.script_content = script.script_content
existing_script.enabled = script.enabled
db.commit() db.commit()
db.refresh(existing_script) db.refresh(existing_script)
db.close() db.close()
@@ -110,7 +124,12 @@ 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):
db = SessionLocal() db = SessionLocal()
new_log = Log(script_id=script_id, message=log.message) new_log = Log(
script_id=script_id,
message=log.message,
error_code=log.error_code,
error_message=log.error_message,
)
db.add(new_log) db.add(new_log)
db.commit() db.commit()
db.refresh(new_log) db.refresh(new_log)
@@ -130,6 +149,12 @@ def delete_script_log(script_id: int, log_id: int):
return {"message": "Log deleted"} return {"message": "Log deleted"}
@app.post("/script/{script_id}/execute")
def execute_script(script_id: int):
run_scripts([script_id])
return {"run_script": True}
@app.get("/health") @app.get("/health")
def health_check(): def health_check():
return {"status": "healthy"} return {"status": "healthy"}

View File

View File

@@ -3,6 +3,7 @@ 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"
@@ -22,6 +23,7 @@ class Script(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
script_content = Column(Text, nullable=True) script_content = Column(Text, nullable=True)
enabled = Column(Boolean, default=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()
) )
@@ -32,6 +34,8 @@ class Log(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
message = Column(String, nullable=False) message = Column(String, nullable=False)
error_code = Column(Integer, nullable=False, default=0)
error_message = Column(String, nullable=True)
created_at = Column( created_at = Column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )

57
backend/run_scripts.py Normal file
View File

@@ -0,0 +1,57 @@
import model
import subprocess
import os
def run_scripts(script_ids: list[int] | None = None):
db = model.SessionLocal()
if script_ids:
scripts = db.query(model.Script).filter(model.Script.id.in_(script_ids)).all()
else:
scripts = db.query(model.Script).filter(model.Script.enabled).all()
for script in scripts:
print(f"Running script: {script.name}")
dump_script_to_file(script, f"exec_folder/{script.name}.py")
result = execute_script(f"exec_folder/{script.name}.py")
db.add(
model.Log(
script_id=script.id,
error_code=result.returncode,
message=result.stdout,
error_message=result.stderr,
)
)
db.commit()
delete_script(f"exec_folder/{script.name}.py")
db.close()
def dump_script_to_file(script, filename):
with open(filename, "w") as file:
file.write(script.script_content)
def execute_script(filename) -> subprocess.CompletedProcess:
result = subprocess.run(["python", filename], capture_output=True, text=True)
return result
def delete_script(filename):
try:
os.remove(filename)
except FileNotFoundError:
pass
def main():
run_scripts()
if __name__ == "__main__":
main()

View File

@@ -9,6 +9,7 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@iconify/svelte": "^5.0.2",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"monaco-editor": "^0.54.0", "monaco-editor": "^0.54.0",
"svelte-codemirror-editor": "^2.1.0" "svelte-codemirror-editor": "^2.1.0"
@@ -803,6 +804,27 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@iconify/svelte": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@iconify/svelte/-/svelte-5.0.2.tgz",
"integrity": "sha512-1iWUT+1veS/QOAzKDG0NPgBtJYGoJqEPwF97voTm8jw6PQ6yU0hL73lEwFoTGMrZmatLvh9cjRBmeSHHaltmrg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"svelte": ">4.0.0"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@isaacs/fs-minipass": { "node_modules/@isaacs/fs-minipass": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",

View File

@@ -38,6 +38,7 @@
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@iconify/svelte": "^5.0.2",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"monaco-editor": "^0.54.0", "monaco-editor": "^0.54.0",
"svelte-codemirror-editor": "^2.1.0" "svelte-codemirror-editor": "^2.1.0"

View File

@@ -20,6 +20,8 @@ export interface Log {
id: number; id: number;
script_id: number; script_id: number;
message: string; message: string;
error_message: string;
error_code: number;
created_at?: string; created_at?: string;
} }
export interface Script { export interface Script {
@@ -27,6 +29,7 @@ export interface Script {
name: string; name: string;
script_content?: string; script_content?: string;
created_at?: string; created_at?: string;
enabled: boolean;
} }
// Fetch all scripts // Fetch all scripts
@@ -39,7 +42,9 @@ export async function fetchScripts(): Promise<Script[]> {
} }
// Add a new script // Add a new script
export async function addScript(script: Omit<Script, 'id' | 'created_at'>): Promise<Script> { export async function addScript(
script: Omit<Script, 'id' | 'created_at' | 'enabled'>
): Promise<Script> {
const response = await fetch(`${API_URL}/script`, { const response = await fetch(`${API_URL}/script`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -87,13 +92,13 @@ export async function fetchLogs(scriptId: number): Promise<Log[]> {
} }
// Add a new log to a specific script // Add a new log to a specific script
export async function addLog(scriptId: number, message: string): 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: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ message }) body: JSON.stringify(log)
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to add log'); throw new Error('Failed to add log');
@@ -101,6 +106,17 @@ export async function addLog(scriptId: number, message: string): Promise<Log> {
return response.json(); return response.json();
} }
// Execute a script by ID
export async function executeScript(scriptId: number): Promise<{ message: string }> {
const response = await fetch(`${API_URL}/script/${scriptId}/execute`, {
method: 'POST'
});
if (!response.ok) {
throw new Error('Failed to execute script');
}
return response.json();
}
// 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}`, {

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import Icon from '@iconify/svelte';
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';
@@ -64,14 +65,19 @@
<div class="fixed bottom-4 left-4 group"> <div class="fixed bottom-4 left-4 group">
{#if $healthStatus === 'healthy'} {#if $healthStatus === 'healthy'}
<span class="text-green-500"></span> <Icon
icon="material-symbols:check-circle-rounded"
width="24"
height="24"
class="text-green-400"
/>
<div <div
class="absolute bottom-full left-1/2 hidden group-hover:flex group-hover:w-max bg-green-500 text-white px-2 py-1 rounded shadow-lg" class="absolute bottom-full left-1/2 hidden group-hover:flex group-hover:w-max bg-green-500 text-white px-2 py-1 rounded shadow-lg"
> >
Connected to backend Connected to backend
</div> </div>
{:else} {:else}
<span class="text-red-500"></span> <Icon icon="ix:disconnected-circle-filled" width="24" height="24" class="text-red-400" />
<div <div
class="absolute bottom-full left-1/2 hidden group-hover:flex group-hover:w-max bg-red-500 text-white px-2 py-1 rounded shadow-lg" class="absolute bottom-full left-1/2 hidden group-hover:flex group-hover:w-max bg-red-500 text-white px-2 py-1 rounded shadow-lg"
> >

View File

@@ -27,7 +27,9 @@
{#each scripts as script (script.id)} {#each scripts as script (script.id)}
<a <a
href={`/scripts/${script.id}`} href={`/scripts/${script.id}`}
class="block p-4 border rounded shadow bg-white hover:bg-gray-100" class="block p-4 border rounded bg-white hover:bg-gray-100 {script.enabled
? 'shadow-lg shadow-green-500/50'
: 'shadow-lg shadow-red-500/50'}"
> >
<h2 class="text-lg font-semibold text-gray-800">{script.name}</h2> <h2 class="text-lg font-semibold text-gray-800">{script.name}</h2>
</a> </a>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { updateScript, deleteScript, addLog, deleteLog } from '$lib/api'; import { updateScript, deleteScript, addLog, deleteLog, executeScript } 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';
@@ -9,17 +9,44 @@
let logs: Log[] = data.logs; let logs: Log[] = data.logs;
let updatedTitle: string = script.name || ''; let updatedTitle: string = script.name || '';
let updatedContent: string = script.script_content || ''; let updatedContent: string = script.script_content || '';
let updatedEnabled: boolean = script.enabled || false;
let isEditMode: boolean = false; let isEditMode: boolean = false;
let newLogMessage: string = ''; let newLog: Omit<Log, 'id' | 'script_id'> = {
message: '',
error_code: 0,
error_message: ''
};
let selectedLog: Log | null = null;
function openLogPopup(log: Log) {
selectedLog = log;
}
function closeLogPopup() {
selectedLog = null;
}
// Notifications are now handled globally via the layout // Notifications are now handled globally via the layout
async function handleExecuteScript() {
try {
await executeScript(script.id);
window.showNotification('success', 'Script executed successfully!');
} catch (err) {
window.showNotification('error', 'Failed to execute script. ' + err);
}
}
async function handleUpdateScript() { async function handleUpdateScript() {
if (script) { if (script) {
try { try {
const updatedScript = await updateScript(script.id, { const updatedScript = await updateScript(script.id, {
name: updatedTitle, name: updatedTitle,
script_content: updatedContent script_content: updatedContent,
enabled: updatedEnabled
}); });
script = updatedScript; script = updatedScript;
window.showNotification('success', 'Script updated successfully!'); window.showNotification('success', 'Script updated successfully!');
@@ -31,11 +58,15 @@
} }
async function handleAddLog() { async function handleAddLog() {
if (newLogMessage.trim()) { if (newLog.message.trim()) {
try { try {
const newLog = await addLog(script.id, newLogMessage); const addedLog = await addLog(script.id, newLog);
logs = [newLog, ...logs]; logs = [addedLog, ...logs];
newLogMessage = ''; newLog = {
message: '',
error_code: 0,
error_message: ''
};
window.showNotification('success', 'Log added successfully!'); window.showNotification('success', 'Log added successfully!');
} catch (err) { } catch (err) {
window.showNotification('error', 'Failed to add log. ' + err); window.showNotification('error', 'Failed to add log. ' + err);
@@ -77,7 +108,19 @@
required required
class="text-2xl font-bold mb-4 w-full p-2 border rounded" class="text-2xl font-bold mb-4 w-full p-2 border rounded"
/> />
<CodeMirror bind:value={updatedContent} lang={python()} /> <CodeMirror bind:value={updatedContent} lang={python()} />
<div class="mt-4 flex items-center space-x-2">
<input
id="enabled"
type="checkbox"
bind:checked={updatedEnabled}
class="h-5 w-5 text-blue-500 border-gray-300 rounded focus:ring focus:ring-blue-200"
/>
<label for="enabled" class="text-sm font-medium text-gray-700">Enabled</label>
</div>
<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={handleUpdateScript} on:click={handleUpdateScript}
@@ -98,51 +141,149 @@
<h1 class="text-2xl font-bold mb-4">{script.name}</h1> <h1 class="text-2xl font-bold mb-4">{script.name}</h1>
<pre <pre
class="w-full p-2 border rounded font-mono text-sm bg-gray-100">{script.script_content}</pre> class="w-full p-2 border rounded font-mono text-sm bg-gray-100">{script.script_content}</pre>
<button <div class="mt-4 flex items-center space-x-2">
class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600" <span class="text-sm font-medium text-gray-700">Enabled:</span>
on:click={() => (isEditMode = true)} <span
> class="px-2 py-1 text-xs font-semibold rounded"
Edit Script class:bg-green-100={script.enabled}
</button> class:bg-red-100={!script.enabled}
<button class:text-green-700={script.enabled}
class="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600" class:text-red-700={!script.enabled}
on:click={handleDeleteScript} >
> {script.enabled ? 'Yes' : 'No'}
Delete Script </span>
</button> </div>
<div class="mt-4 flex justify-between">
<div class="flex space-x-2">
<button
class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
on:click={handleExecuteScript}
aria-label="Execute the script"
>
Execute Script
</button>
<button
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
on:click={() => (isEditMode = true)}
>
Edit Script
</button>
</div>
<button
class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
on:click={handleDeleteScript}
>
Delete Script
</button>
</div>
{/if} {/if}
{/if} {/if}
<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"> <!--- --
<input <form on:submit|preventDefault={handleAddLog} class="mb-4 space-y-4">
type="text" <div>
bind:value={newLogMessage} <label for="logMessage" class="block text-sm font-medium">Log Message</label>
placeholder="Enter new log message" <input
class="w-full p-2 border rounded mb-2" id="logMessage"
required 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"> <button type="submit" class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600">
Add Log Add Log
</button> </button>
</form> </form>
-->
<ul class="space-y-4"> <ul class="space-y-4">
{#each logs as log (log.id)} {#each logs as log (log.id)}
<li class="p-4 border rounded bg-gray-50 flex justify-between items-center"> <li
<div> class="p-2 rounded flex justify-between items-center border-s-slate-400 border-1"
<p class="text-sm text-gray-700">{log.message}</p> class:bg-red-100={log.error_code !== 0}
<p class="text-xs text-gray-500">{log.created_at}</p> class:bg-gray-50={log.error_code === 0}
</div> >
<button class="p-2 w-full" on:click={() => openLogPopup(log)}>
<div class="z">
<p class="text-sm text-left">
{log.error_code !== 0
? log.error_message.split('\n')[0]
: log.message.split('\n')[0]}
</p>
<p class="text-xs text-gray-500 text-left">{log.created_at}</p>
</div>
</button>
<button <button
class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 block"
on:click={() => handleDeleteLog(log.id)} on:click|stopPropagation={() => handleDeleteLog(log.id)}
> >
Delete Delete
</button> </button>
</li> </li>
{/each} {/each}
</ul> </ul>
{#if selectedLog}
<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">
<h3 class="text-lg font-bold mb-4">Log Details</h3>
<div class="mb-4">
<p class="font-semibold">Message:</p>
<pre
class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{selectedLog.message}</pre>
</div>
<div class="mb-4">
<p class="font-semibold">Error Code:</p>
<pre
class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{selectedLog.error_code}</pre>
</div>
<div class="mb-4">
<p class="font-semibold">Error Message:</p>
<pre
class="whitespace-pre-wrap bg-gray-100 p-2 rounded border">{selectedLog.error_message ||
'N/A'}</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">{selectedLog.created_at}</pre>
</div>
<button
class="mt-4 px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
on:click={closeLogPopup}
>
Close
</button>
</div>
</div>
{/if}
</section> </section>
</main> </main>