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",
|
"jinja2",
|
||||||
"kubernetes",
|
"kubernetes",
|
||||||
"rich",
|
"rich",
|
||||||
|
"sse-starlette",
|
||||||
"uvicorn",
|
"uvicorn",
|
||||||
]
|
]
|
||||||
dynamic = ["version", "description"]
|
dynamic = ["version", "description"]
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ rich==10.13.0
|
||||||
rsa==4.7.2
|
rsa==4.7.2
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
sniffio==1.2.0
|
sniffio==1.2.0
|
||||||
|
sse-starlette==0.9.0
|
||||||
starlette==0.16.0
|
starlette==0.16.0
|
||||||
typing-extensions==3.10.0.2
|
typing-extensions==3.10.0.2
|
||||||
urllib3==1.26.7
|
urllib3==1.26.7
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
from typing import Optional
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
from ansi2html import Ansi2HTMLConverter
|
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 fastapi.responses import HTMLResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sse_starlette.sse import EventSourceResponse
|
||||||
from starlette.status import HTTP_404_NOT_FOUND
|
from starlette.status import HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
from . import github, models
|
from . import github, models
|
||||||
|
|
@ -59,6 +61,11 @@ class Build(BaseModel):
|
||||||
read_with_orm_mode = True
|
read_with_orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
|
class BuildEvent(BaseModel):
|
||||||
|
event: models.BuildEvent
|
||||||
|
build: Build
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=Status)
|
@router.get("/status", response_model=Status)
|
||||||
async def controller_status() -> Controller:
|
async def controller_status() -> Controller:
|
||||||
return controller
|
return controller
|
||||||
|
|
@ -75,7 +82,7 @@ async def repos() -> list[models.Repo]:
|
||||||
response_model_exclude_none=True,
|
response_model_exclude_none=True,
|
||||||
)
|
)
|
||||||
async def builds(repo: Optional[str] = None) -> list[models.Build]:
|
async def builds(repo: Optional[str] = None) -> list[models.Build]:
|
||||||
return controller.db.search(repo)
|
return list(controller.db.search(repo))
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|
@ -163,3 +170,82 @@ async def delete(name: str) -> None:
|
||||||
"""Delete the deployment and drop the database."""
|
"""Delete the deployment and drop the database."""
|
||||||
build = await _build_by_name(name)
|
build = await _build_by_name(name)
|
||||||
await build.undeploy()
|
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 import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import __version__, api, controller, k8s, webhooks, webui
|
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(api.router, prefix="/api/v1", tags=["api"])
|
||||||
app.include_router(webhooks.router, tags=["webhooks"])
|
app.include_router(webhooks.router, tags=["webhooks"])
|
||||||
app.include_router(webui.router, tags=["webui"])
|
app.include_router(webui.router, tags=["webui"])
|
||||||
|
app.mount(
|
||||||
|
"/webui", StaticFiles(directory=Path(__file__).parent / "webui"), name="webui"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import logging
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
from . import k8s
|
from . import k8s
|
||||||
from .db import BuildEvent, BuildsDb
|
from .db import BuildsDb
|
||||||
from .models import Build, BuildInitStatus, BuildStatus
|
from .models import Build, BuildEvent, BuildInitStatus, BuildStatus
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
@ -43,7 +43,7 @@ class Controller:
|
||||||
self.db = BuildsDb()
|
self.db = BuildsDb()
|
||||||
self.db.register_listener(self)
|
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()
|
self._wakeup()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,15 @@
|
||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from enum import Enum
|
from typing import Iterator, Protocol, cast
|
||||||
from typing import Protocol, cast
|
|
||||||
from weakref import WeakSet
|
from weakref import WeakSet
|
||||||
|
|
||||||
from .models import Build, BuildInitStatus, BuildStatus
|
from .models import Build, BuildEvent, BuildInitStatus, BuildStatus
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BuildEvent(Enum):
|
|
||||||
modified = 0
|
|
||||||
removed = 1
|
|
||||||
|
|
||||||
|
|
||||||
class BuildListener(Protocol):
|
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,))
|
self._con.execute("DELETE FROM builds WHERE name=?", (name,))
|
||||||
_logger.info("Noticed removal of %s", name)
|
_logger.info("Noticed removal of %s", name)
|
||||||
for listener in self._listeners:
|
for listener in self._listeners:
|
||||||
listener.build_updated(build, BuildEvent.removed)
|
listener.on_build_event(BuildEvent.removed, build)
|
||||||
|
|
||||||
def add(self, build: Build) -> None:
|
def add(self, build: Build) -> None:
|
||||||
prev_build = self.get(build.name)
|
prev_build = self.get(build.name)
|
||||||
|
|
@ -147,7 +141,7 @@ class BuildsDb:
|
||||||
build.last_scaled,
|
build.last_scaled,
|
||||||
)
|
)
|
||||||
for listener in self._listeners:
|
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:
|
def count_by_status(self, status: BuildStatus) -> int:
|
||||||
count = self._con.execute(
|
count = self._con.execute(
|
||||||
|
|
@ -190,15 +184,21 @@ class BuildsDb:
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [self._build_from_row(row) for row in rows]
|
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 "
|
query = "SELECT * FROM builds "
|
||||||
where = []
|
where = []
|
||||||
params = []
|
params = []
|
||||||
if repo:
|
if repo:
|
||||||
where.append("repo=?")
|
where.append("repo=?")
|
||||||
params.append(repo.lower())
|
params.append(repo.lower())
|
||||||
|
if name:
|
||||||
|
where.append("name=?")
|
||||||
|
params.append(name)
|
||||||
if where:
|
if where:
|
||||||
query += "WHERE " + " AND ".join(where)
|
query += "WHERE " + " AND ".join(where)
|
||||||
query += "ORDER BY repo, target_branch, pr, created DESC"
|
query += "ORDER BY repo, target_branch, pr, created DESC"
|
||||||
rows = self._con.execute(query, params).fetchall()
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildEvent(str, Enum):
|
||||||
|
modified = "upd"
|
||||||
|
removed = "del"
|
||||||
|
|
||||||
|
|
||||||
class BuildStatus(str, Enum):
|
class BuildStatus(str, Enum):
|
||||||
stopped = "stopped" # initialization succeeded and 0 replicas
|
stopped = "stopped" # initialization succeeded and 0 replicas
|
||||||
stopping = "stopping" # 0 desired replicas but some are still running
|
stopping = "stopping" # 0 desired replicas but some are still running
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,19 @@
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
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.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
from .controller import controller
|
from .controller import controller
|
||||||
from .models import BuildStatus
|
from .models import BuildStatus
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).parent / "webui"))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/builds/{name}", response_class=HTMLResponse)
|
@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)
|
build = controller.db.get(name)
|
||||||
if not build:
|
if not build:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||||
if live is not None and build.status == BuildStatus.started:
|
if live is not None and build.status == BuildStatus.started:
|
||||||
return RedirectResponse(url=build.deploy_link)
|
return RedirectResponse(url=build.deploy_link)
|
||||||
return templates.TemplateResponse(
|
return RedirectResponse(url=f"/webui/build.html?name={name}")
|
||||||
"build.html.jinja", {"request": request, "build": build}
|
|
||||||
)
|
|
||||||
|
|
|
||||||
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()
|
listener = MagicMock()
|
||||||
db.register_listener(listener)
|
db.register_listener(listener)
|
||||||
db.add(_make_build()) # new
|
db.add(_make_build()) # new
|
||||||
listener.build_updated.assert_called()
|
listener.on_build_event.assert_called()
|
||||||
listener.reset_mock()
|
listener.reset_mock()
|
||||||
db.add(_make_build()) # no change
|
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))
|
db.add(_make_build(status=BuildStatus.failed))
|
||||||
listener.build_updated.assert_called()
|
listener.on_build_event.assert_called()
|
||||||
|
|
||||||
|
|
||||||
def test_remove() -> None:
|
def test_remove() -> None:
|
||||||
|
|
@ -48,11 +48,11 @@ def test_remove() -> None:
|
||||||
listener = MagicMock()
|
listener = MagicMock()
|
||||||
db.register_listener(listener)
|
db.register_listener(listener)
|
||||||
db.remove("not-a-build")
|
db.remove("not-a-build")
|
||||||
listener.build_updated.assert_not_called()
|
listener.on_build_event.assert_not_called()
|
||||||
build = _make_build()
|
build = _make_build()
|
||||||
db.add(build)
|
db.add(build)
|
||||||
db.remove(build.name)
|
db.remove(build.name)
|
||||||
listener.build_updated.assert_called()
|
listener.on_build_event.assert_called()
|
||||||
|
|
||||||
|
|
||||||
def test_get_for_commit() -> None:
|
def test_get_for_commit() -> None:
|
||||||
|
|
@ -77,8 +77,8 @@ def test_search() -> None:
|
||||||
db = BuildsDb()
|
db = BuildsDb()
|
||||||
db.add(build1 := _make_build(name="b1", repo="oca/repo1"))
|
db.add(build1 := _make_build(name="b1", repo="oca/repo1"))
|
||||||
db.add(_make_build(name="b2", repo="oca/repo2"))
|
db.add(_make_build(name="b2", repo="oca/repo2"))
|
||||||
assert len(db.search()) == 2
|
assert len(list(db.search())) == 2
|
||||||
assert db.search("oca/repo1") == [build1]
|
assert list(db.search("oca/repo1")) == [build1]
|
||||||
|
|
||||||
|
|
||||||
def test_count_by_status() -> None:
|
def test_count_by_status() -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue