diff --git a/pyproject.toml b/pyproject.toml
index ca069ad..1940528 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,7 @@ dependencies = [
"jinja2",
"kubernetes",
"rich",
+ "sse-starlette",
"uvicorn",
]
dynamic = ["version", "description"]
diff --git a/requirements.txt b/requirements.txt
index e65b216..19a1355 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -32,6 +32,7 @@ rich==10.13.0
rsa==4.7.2
six==1.16.0
sniffio==1.2.0
+sse-starlette==0.9.0
starlette==0.16.0
typing-extensions==3.10.0.2
urllib3==1.26.7
diff --git a/src/runboat/api.py b/src/runboat/api.py
index ca81e4c..fb994e6 100644
--- a/src/runboat/api.py
+++ b/src/runboat/api.py
@@ -1,10 +1,12 @@
+import asyncio
import datetime
-from typing import Optional
+from typing import AsyncGenerator, Optional
from ansi2html import Ansi2HTMLConverter
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
+from sse_starlette.sse import EventSourceResponse
from starlette.status import HTTP_404_NOT_FOUND
from . import github, models
@@ -59,6 +61,11 @@ class Build(BaseModel):
read_with_orm_mode = True
+class BuildEvent(BaseModel):
+ event: models.BuildEvent
+ build: Build
+
+
@router.get("/status", response_model=Status)
async def controller_status() -> Controller:
return controller
@@ -75,7 +82,7 @@ async def repos() -> list[models.Repo]:
response_model_exclude_none=True,
)
async def builds(repo: Optional[str] = None) -> list[models.Build]:
- return controller.db.search(repo)
+ return list(controller.db.search(repo))
@router.post(
@@ -163,3 +170,82 @@ async def delete(name: str) -> None:
"""Delete the deployment and drop the database."""
build = await _build_by_name(name)
await build.undeploy()
+
+
+class BuildEventSource:
+ def __init__(
+ self, request: Request, repo: str | None = None, build_name: str | None = None
+ ):
+ self.queue: asyncio.Queue[str] = asyncio.Queue()
+ self.request = request
+ self.repo = repo
+ self.build_name = build_name
+ controller.db.register_listener(self)
+
+ @classmethod
+ def _serialize(cls, event: models.BuildEvent, build: models.Build) -> str:
+ return BuildEvent(event=event, build=Build.from_orm(build)).json()
+
+ def on_build_event(self, event: models.BuildEvent, build: models.Build) -> None:
+ if self.repo and build.repo != self.repo:
+ return
+ if self.build_name and build.name != self.build_name:
+ return
+ self.queue.put_nowait(self._serialize(event, build))
+
+ async def events(self) -> AsyncGenerator[str, None]:
+ for build in controller.db.search(self.repo, self.build_name):
+ yield self._serialize(models.BuildEvent.modified, build)
+ while True:
+ try:
+ event = await asyncio.wait_for(self.queue.get(), timeout=10)
+ except asyncio.TimeoutError:
+ pass
+ else:
+ yield event
+ # Check if the client is still there and wait for events again.
+ if await self.request.is_disconnected():
+ break
+
+
+@router.get("/build-events")
+async def eventsource_endpoint(
+ request: Request,
+ repo: Optional[str] = None,
+ build_name: Optional[str] = None,
+) -> EventSourceResponse:
+ event_source = BuildEventSource(request, repo, build_name)
+ return EventSourceResponse(event_source.events())
+
+
+eshtml = """
+
+
+
+ SSE Test
+
+
+ SSE Test
+
+
+
+
+"""
+
+
+@router.get("/estest")
+async def get() -> HTMLResponse:
+ return HTMLResponse(eshtml)
diff --git a/src/runboat/app.py b/src/runboat/app.py
index aaeeb46..103924d 100644
--- a/src/runboat/app.py
+++ b/src/runboat/app.py
@@ -1,4 +1,7 @@
+from pathlib import Path
+
from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
from . import __version__, api, controller, k8s, webhooks, webui
@@ -8,6 +11,9 @@ app = FastAPI(
app.include_router(api.router, prefix="/api/v1", tags=["api"])
app.include_router(webhooks.router, tags=["webhooks"])
app.include_router(webui.router, tags=["webui"])
+app.mount(
+ "/webui", StaticFiles(directory=Path(__file__).parent / "webui"), name="webui"
+)
@app.on_event("startup")
diff --git a/src/runboat/controller.py b/src/runboat/controller.py
index d0d5797..01cf222 100644
--- a/src/runboat/controller.py
+++ b/src/runboat/controller.py
@@ -3,8 +3,8 @@ import logging
from typing import Any, Awaitable, Callable
from . import k8s
-from .db import BuildEvent, BuildsDb
-from .models import Build, BuildInitStatus, BuildStatus
+from .db import BuildsDb
+from .models import Build, BuildEvent, BuildInitStatus, BuildStatus
from .settings import settings
_logger = logging.getLogger(__name__)
@@ -43,7 +43,7 @@ class Controller:
self.db = BuildsDb()
self.db.register_listener(self)
- def build_updated(self, build: Build, event: BuildEvent) -> None:
+ def on_build_event(self, event: BuildEvent, build: Build) -> None:
self._wakeup()
@property
diff --git a/src/runboat/db.py b/src/runboat/db.py
index 69569ea..249117f 100644
--- a/src/runboat/db.py
+++ b/src/runboat/db.py
@@ -1,21 +1,15 @@
import logging
import sqlite3
-from enum import Enum
-from typing import Protocol, cast
+from typing import Iterator, Protocol, cast
from weakref import WeakSet
-from .models import Build, BuildInitStatus, BuildStatus
+from .models import Build, BuildEvent, BuildInitStatus, BuildStatus
_logger = logging.getLogger(__name__)
-class BuildEvent(Enum):
- modified = 0
- removed = 1
-
-
class BuildListener(Protocol):
- def build_updated(self, build: Build, event: BuildEvent) -> None:
+ def on_build_event(self, event: BuildEvent, build: Build) -> None:
...
@@ -94,7 +88,7 @@ class BuildsDb:
self._con.execute("DELETE FROM builds WHERE name=?", (name,))
_logger.info("Noticed removal of %s", name)
for listener in self._listeners:
- listener.build_updated(build, BuildEvent.removed)
+ listener.on_build_event(BuildEvent.removed, build)
def add(self, build: Build) -> None:
prev_build = self.get(build.name)
@@ -147,7 +141,7 @@ class BuildsDb:
build.last_scaled,
)
for listener in self._listeners:
- listener.build_updated(build, BuildEvent.modified)
+ listener.on_build_event(BuildEvent.modified, build)
def count_by_status(self, status: BuildStatus) -> int:
count = self._con.execute(
@@ -190,15 +184,21 @@ class BuildsDb:
).fetchall()
return [self._build_from_row(row) for row in rows]
- def search(self, repo: str | None = None) -> list[Build]:
+ def search(
+ self, repo: str | None = None, name: str | None = None
+ ) -> Iterator[Build]:
query = "SELECT * FROM builds "
where = []
params = []
if repo:
where.append("repo=?")
params.append(repo.lower())
+ if name:
+ where.append("name=?")
+ params.append(name)
if where:
query += "WHERE " + " AND ".join(where)
query += "ORDER BY repo, target_branch, pr, created DESC"
rows = self._con.execute(query, params).fetchall()
- return [self._build_from_row(row) for row in rows]
+ for row in rows:
+ yield self._build_from_row(row)
diff --git a/src/runboat/models.py b/src/runboat/models.py
index 7a2c483..5b0acb0 100644
--- a/src/runboat/models.py
+++ b/src/runboat/models.py
@@ -16,6 +16,11 @@ from .utils import slugify
_logger = logging.getLogger(__name__)
+class BuildEvent(str, Enum):
+ modified = "upd"
+ removed = "del"
+
+
class BuildStatus(str, Enum):
stopped = "stopped" # initialization succeeded and 0 replicas
stopping = "stopping" # 0 desired replicas but some are still running
diff --git a/src/runboat/webui.py b/src/runboat/webui.py
index 9f4a99f..ea1dd3e 100644
--- a/src/runboat/webui.py
+++ b/src/runboat/webui.py
@@ -1,24 +1,19 @@
-from pathlib import Path
from typing import Optional
-from fastapi import APIRouter, HTTPException, Request, Response, status
+from fastapi import APIRouter, HTTPException, Response, status
from fastapi.responses import HTMLResponse, RedirectResponse
-from fastapi.templating import Jinja2Templates
from .controller import controller
from .models import BuildStatus
router = APIRouter()
-templates = Jinja2Templates(directory=str(Path(__file__).parent / "webui"))
@router.get("/builds/{name}", response_class=HTMLResponse)
-async def build(request: Request, name: str, live: Optional[str] = None) -> Response:
+async def build(name: str, live: Optional[str] = None) -> Response:
build = controller.db.get(name)
if not build:
raise HTTPException(status.HTTP_404_NOT_FOUND)
if live is not None and build.status == BuildStatus.started:
return RedirectResponse(url=build.deploy_link)
- return templates.TemplateResponse(
- "build.html.jinja", {"request": request, "build": build}
- )
+ return RedirectResponse(url=f"/webui/build.html?name={name}")
diff --git a/src/runboat/webui/build.html b/src/runboat/webui/build.html
new file mode 100644
index 0000000..9fa05f1
--- /dev/null
+++ b/src/runboat/webui/build.html
@@ -0,0 +1,74 @@
+
+
+ Runboat build
+
+
+
+
+
+
diff --git a/src/runboat/webui/build.html.jinja b/src/runboat/webui/build.html.jinja
deleted file mode 100644
index 39c53ba..0000000
--- a/src/runboat/webui/build.html.jinja
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
- Runboat build {{ build.name }} for {{ build.repo }}
-
-
- Repo: {{ build.repo }}
- {% if build.pr %}
- PR: {{ build.pr }} to {{ build.target_branch }}
- {% else %}
- Branch: {{ build.target_branch }}
- {% endif %}
- Commit: {{ build.git_commit }}
- Status: {{ build.status.value }}
-
- Logs:
- init log
- {% if build.status == 'started' %}
- |
- log
- |
- => live
- {% endif %}
-
- {% if build.status == 'started' %}
-
- {% else %}
-
- {% endif %}
-
-
diff --git a/tests/test_db.py b/tests/test_db.py
index c6b2f83..56251dc 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -35,12 +35,12 @@ def test_add() -> None:
listener = MagicMock()
db.register_listener(listener)
db.add(_make_build()) # new
- listener.build_updated.assert_called()
+ listener.on_build_event.assert_called()
listener.reset_mock()
db.add(_make_build()) # no change
- listener.build_updated.assert_not_called()
+ listener.on_build_event.assert_not_called()
db.add(_make_build(status=BuildStatus.failed))
- listener.build_updated.assert_called()
+ listener.on_build_event.assert_called()
def test_remove() -> None:
@@ -48,11 +48,11 @@ def test_remove() -> None:
listener = MagicMock()
db.register_listener(listener)
db.remove("not-a-build")
- listener.build_updated.assert_not_called()
+ listener.on_build_event.assert_not_called()
build = _make_build()
db.add(build)
db.remove(build.name)
- listener.build_updated.assert_called()
+ listener.on_build_event.assert_called()
def test_get_for_commit() -> None:
@@ -77,8 +77,8 @@ def test_search() -> None:
db = BuildsDb()
db.add(build1 := _make_build(name="b1", repo="oca/repo1"))
db.add(_make_build(name="b2", repo="oca/repo2"))
- assert len(db.search()) == 2
- assert db.search("oca/repo1") == [build1]
+ assert len(list(db.search())) == 2
+ assert list(db.search("oca/repo1")) == [build1]
def test_count_by_status() -> None: