SSE endpoint for build events
And dyamic build.html page.
This commit is contained in:
parent
03e6fa4795
commit
31beee0a47
11 changed files with 202 additions and 72 deletions
|
|
@ -17,6 +17,7 @@ dependencies = [
|
|||
"jinja2",
|
||||
"kubernetes",
|
||||
"rich",
|
||||
"sse-starlette",
|
||||
"uvicorn",
|
||||
]
|
||||
dynamic = ["version", "description"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SSE Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>SSE Test</h1>
|
||||
<ul id='messages'>
|
||||
</ul>
|
||||
<script>
|
||||
const evtSource = new EventSource("/api/v1/build-events");
|
||||
evtSource.onmessage = function(event) {
|
||||
var messages = document.getElementById('messages')
|
||||
var message = document.createElement('li')
|
||||
oEvent = JSON.parse(event.data);
|
||||
var content = document.createTextNode(
|
||||
`${oEvent.event} - ${oEvent.build.name} ${oEvent.build.status}`
|
||||
)
|
||||
message.appendChild(content)
|
||||
messages.insertBefore(message, messages.firstChild)
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@router.get("/estest")
|
||||
async def get() -> HTMLResponse:
|
||||
return HTMLResponse(eshtml)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
74
src/runboat/webui/build.html
Normal file
74
src/runboat/webui/build.html
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Runboat build</title>
|
||||
</head>
|
||||
<body>
|
||||
<runboat-build id="build"></runboat-build>
|
||||
<script type="module">
|
||||
import {LitElement, html} from 'https://unpkg.com/lit@2.0.2?module';
|
||||
|
||||
class RunboatBuild extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
build: {}
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.build = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<p>Build: ${this.build.name}
|
||||
${this.build.status == "started"?
|
||||
html`<a href="${this.build.deploy_link}">=> live</a>`:""
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
Repo: ${this.build.repo}
|
||||
${this.build.pr?
|
||||
html`PR <a href="${this.build.repo_link}">${this.build.pr}</a> to`:""
|
||||
}
|
||||
<a href="${this.build.repo_link}">${this.build.target_branch}</a>
|
||||
${this.build.git_commit?
|
||||
html`(<a href="${this.build.repo_commit_link}">${this.build.git_commit.substring(0, 8)}</a>)`:""
|
||||
}
|
||||
</p>
|
||||
<p>Status: ${this.build.status}</p>
|
||||
<p>Logs:
|
||||
<a href="/api/v1/builds/${this.build.name}/init-log">init log</a>
|
||||
${this.build.status == "started"?
|
||||
html`| <a href="/api/v1/builds/${this.build.name}/log">log</a>`:""
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
<button @click="${this.stopHandler}" ?disabled="${this.build.status != "started"}">stop</button>
|
||||
<button @click="${this.startHandler}" ?disabled="${this.build.status != "stopped"}">start</button>
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
startHandler(e) {
|
||||
fetch(`/api/v1/builds/${this.build.name}/start`, {method: 'POST'});
|
||||
}
|
||||
|
||||
stopHandler(e) {
|
||||
fetch(`/api/v1/builds/${this.build.name}/stop`, {method: 'POST'});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('runboat-build', RunboatBuild);
|
||||
|
||||
const buildName = new URLSearchParams(window.location.search).get("name");
|
||||
const buildElement = document.getElementById("build");
|
||||
const evtSource = new EventSource(`/api/v1/build-events?build_name=${buildName}`);
|
||||
evtSource.onmessage = function(event) {
|
||||
var oEvent = JSON.parse(event.data);
|
||||
buildElement.build = oEvent.event == "upd" ? oEvent.build : {};
|
||||
}
|
||||
// TODO: evtSource error handling
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Runboat build {{ build.name }} for {{ build.repo }}</title>
|
||||
<script>
|
||||
build_name = "{{ build.name }}";
|
||||
function start() {
|
||||
fetch(`/api/v1/builds/${build_name}/start`, options={method: 'POST'})
|
||||
}
|
||||
function stop() {
|
||||
fetch(`/api/v1/builds/${build_name}/stop`, options={method: 'POST'})
|
||||
}
|
||||
</script>
|
||||
<body>
|
||||
<p>Repo: {{ build.repo }}</p>
|
||||
{% if build.pr %}
|
||||
<p>PR: <a href="{{ build.repo_link }}">{{ build.pr }}</a> to {{ build.target_branch }}</p>
|
||||
{% else %}
|
||||
<p>Branch: <a href="{{ build.repo_link }}">{{ build.target_branch }}</a></p>
|
||||
{% endif %}
|
||||
<p>Commit: <a href="{{ build.repo_commit_link }}">{{ build.git_commit }}</a></p>
|
||||
<p>Status: {{ build.status.value }}</p>
|
||||
<p>
|
||||
Logs:
|
||||
<a href="/api/v1/builds/{{ build.name }}/init-log" target="_blank">init log</a>
|
||||
{% if build.status == 'started' %}
|
||||
|
|
||||
<a href="/api/v1/builds/{{ build.name }}/log" target="_blank">log</a>
|
||||
|
|
||||
<a href="{{ build.deploy_link }}">=> live</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if build.status == 'started' %}
|
||||
<button onclick="stop()">stop</button>
|
||||
{% else %}
|
||||
<button onclick="start()">start</button>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue