diff --git a/controller/thymis_controller/dependencies.py b/controller/thymis_controller/dependencies.py index b6b1caca..a0d8411d 100644 --- a/controller/thymis_controller/dependencies.py +++ b/controller/thymis_controller/dependencies.py @@ -1,5 +1,6 @@ import datetime import logging +import threading import uuid from typing import Annotated, Generator, Optional, Union @@ -16,15 +17,18 @@ global_project = None SESSION_LIFETIME = datetime.timedelta(days=1) +project_lock = threading.Lock() def get_project(): global global_project - if global_project is None: - REPO_PATH = global_settings.REPO_PATH.resolve() - global_project = Project(REPO_PATH, next(get_db_session())) - return global_project + with project_lock: + if global_project is None: + REPO_PATH = global_settings.REPO_PATH.resolve() + + global_project = Project(REPO_PATH, next(get_db_session())) + return global_project def get_state(project: Project = Depends(get_project)): diff --git a/controller/thymis_controller/models/__init__.py b/controller/thymis_controller/models/__init__.py index 9d8e7116..5953f513 100644 --- a/controller/thymis_controller/models/__init__.py +++ b/controller/thymis_controller/models/__init__.py @@ -1,4 +1,5 @@ from .device import * +from .history import * from .module import * from .state import * from .task import * @@ -10,6 +11,7 @@ + task.__all__ # pylint: disable=undefined-variable + web_session.__all__ # pylint: disable=undefined-variable + device.__all__ # pylint: disable=undefined-variable + + history.__all__ # pylint: disable=undefined-variable ) # See https://stackoverflow.com/questions/60440945/correct-way-to-re-export-modules-from-init-py diff --git a/controller/thymis_controller/models/history.py b/controller/thymis_controller/models/history.py new file mode 100644 index 00000000..f92444ed --- /dev/null +++ b/controller/thymis_controller/models/history.py @@ -0,0 +1,21 @@ +import datetime +from typing import List + +from pydantic import BaseModel + + +class Commit(BaseModel): + SHA: str + SHA1: str + message: str + date: datetime.datetime + author: str + state_diff: List[str] + + +class Remote(BaseModel): + name: str + url: str + + +__all__ = ["Commit", "Remote"] diff --git a/controller/thymis_controller/project.py b/controller/thymis_controller/project.py index 5c865968..95eb1d03 100644 --- a/controller/thymis_controller/project.py +++ b/controller/thymis_controller/project.py @@ -8,6 +8,7 @@ import subprocess import sys import tempfile +import threading import traceback from pathlib import Path from typing import List @@ -16,6 +17,7 @@ from sqlalchemy.orm import Session from thymis_controller import crud, migration, models, task from thymis_controller.config import global_settings +from thymis_controller.models import history from thymis_controller.models.state import State from thymis_controller.nix import NIX_CMD, get_input_out_path, render_flake_nix @@ -126,6 +128,7 @@ class Project: repo: git.Repo known_hosts_path: pathlib.Path public_key: str + history_lock = threading.Lock() def __init__(self, path, db_session: Session): self.path = pathlib.Path(path) @@ -240,23 +243,32 @@ def commit(self, summary: str): self.repo.index.commit(summary) def get_history(self): - return [ - { - "message": commit.message, - "author": commit.author.name, - "date": commit.authored_datetime, - "SHA": commit.hexsha, - "SHA1": self.repo.git.rev_parse(commit.hexsha, short=True), - "state_diff": self.repo.git.diff( - commit.hexsha, - commit.parents[0].hexsha if len(commit.parents) > 0 else None, - "-R", - "state.json", - unified=5, - ).split("\n")[4:], - } - for commit in self.repo.iter_commits() - ] + try: + with self.history_lock: + return [ + history.Commit( + message=commit.message, + author=commit.author.name, + date=commit.authored_datetime, + SHA=commit.hexsha, + SHA1=self.repo.git.rev_parse(commit.hexsha, short=True), + state_diff=self.repo.git.diff( + commit.hexsha, + ( + commit.parents[0].hexsha + if len(commit.parents) > 0 + else None + ), + "-R", + "state.json", + unified=5, + ).split("\n")[4:], + ) + for commit in self.repo.iter_commits() + ] + except Exception: + traceback.print_exc() + return [] def update_known_hosts(self, db_session: Session): if not self.known_hosts_path or not self.known_hosts_path.exists(): @@ -293,6 +305,9 @@ def clone_state_device( state.devices.append(new_device) self.write_state_and_reload(state) + def get_remotes(self): + return [history.Remote(name=r.name, url=r.url) for r in self.repo.remotes] + def create_build_task(self): return task.global_task_controller.add_task(task.BuildProjectTask(self.path)) @@ -304,7 +319,11 @@ def create_deploy_device_task(self, device_identifier: str, target_host: str): ) return task.global_task_controller.add_task( task.DeployDeviceTask( - self.path, device, target_host, global_settings.SSH_KEY_PATH + self.path, + device, + global_settings.SSH_KEY_PATH, + self.known_hosts_path, + target_host, ) ) diff --git a/controller/thymis_controller/routers/api.py b/controller/thymis_controller/routers/api.py index 87acb3e9..69530702 100644 --- a/controller/thymis_controller/routers/api.py +++ b/controller/thymis_controller/routers/api.py @@ -145,12 +145,12 @@ def download_image( raise HTTPException(status_code=404, detail="Image not found") -@router.get("/history") +@router.get("/history", tags=["history"]) def get_history(project: project.Project = Depends(get_project)): return project.get_history() -@router.post("/history/revert-commit") +@router.post("/history/revert-commit", tags=["history"]) def revert_commit( commit_sha: str, project: project.Project = Depends(get_project), @@ -159,6 +159,11 @@ def revert_commit( return {"message": "reverted commit"} +@router.get("/history/remotes", tags=["history"]) +def get_remotes(project: project.Project = Depends(get_project)): + return project.get_remotes() + + @router.post("/action/update") async def update( project: project.Project = Depends(get_project), diff --git a/controller/thymis_controller/routers/frontend.py b/controller/thymis_controller/routers/frontend.py index c15d9cbc..ab75b1c3 100644 --- a/controller/thymis_controller/routers/frontend.py +++ b/controller/thymis_controller/routers/frontend.py @@ -84,13 +84,12 @@ async def run(self): # read stdout and stderr in background async def read_stream(stream: asyncio.StreamReader, level=logging.INFO): - while self.process.returncode is None and not self.stopped: + while not stream.at_eof(): line = await stream.readline() if line: logger.log( level, "frontend process: %s", line.decode("utf-8").strip() ) - await asyncio.sleep(0.001) # start threads asyncio.create_task(read_stream(self.process.stdout)) diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts new file mode 100644 index 00000000..40ee0ee8 --- /dev/null +++ b/frontend/src/lib/history.ts @@ -0,0 +1,13 @@ +export type Commit = { + message: string; + author: string; + date: string; + SHA: string; + SHA1: string; + state_diff: string[]; +}; + +export type Remote = { + name: string; + url: string; +}; diff --git a/frontend/src/routes/(authenticated)/history/+page.svelte b/frontend/src/routes/(authenticated)/history/+page.svelte index bc33592c..8781d1f5 100644 --- a/frontend/src/routes/(authenticated)/history/+page.svelte +++ b/frontend/src/routes/(authenticated)/history/+page.svelte @@ -5,10 +5,11 @@ import DeployActions from '$lib/components/DeployActions.svelte'; import Undo from 'lucide-svelte/icons/undo-2'; import RollbackModal from './RollbackModal.svelte'; + import type { Commit } from '$lib/history'; export let data: PageData; - let revertCommit: { SHA1: string; message: string } | undefined; + let revertCommit: Commit | undefined; const lineColor = (line: string) => { if (line.startsWith('+')) { @@ -42,7 +43,7 @@ with hash {history.SHA1}
{#if index === 0}
diff --git a/frontend/src/routes/(authenticated)/history/+page.ts b/frontend/src/routes/(authenticated)/history/+page.ts
index e4447140..818257fe 100644
--- a/frontend/src/routes/(authenticated)/history/+page.ts
+++ b/frontend/src/routes/(authenticated)/history/+page.ts
@@ -1,22 +1,19 @@
+import type { Commit } from '$lib/history';
import type { PageLoad } from './$types';
export const load = (async ({ fetch }) => {
- const response = await fetch(`/api/history`, {
+ const history_response = fetch(`/api/history`, {
method: 'GET',
headers: {
'content-type': 'application/json'
}
+ }).then((res) => {
+ if (!res.ok) {
+ throw new Error(`Failed to fetch history: ${res.status} ${res.statusText}`);
+ }
+ return res.json() as Promise