Compare commits

2 Commits

Author SHA1 Message Date
João Pedro Battistella Nadas
f9acdef3fe chore: introduce semver + CHANGELOG.md, cut v0.1.0
- CHANGELOG.md following Keep a Changelog; 0.1.0 entry covers all
  features shipped through the loaded-model indicator
- build.sh now also tags the image with the version read from
  pyproject.toml, e.g. rg.nl-ams.scw.cloud/valinor/finrod:0.1.0
- README documents the release workflow (edit CHANGELOG, bump
  pyproject version, merge, tag vX.Y.Z, build, bump valinor)
2026-06-09 15:55:09 +02:00
João Pedro Battistella Nadas
732982e66c feat: show which model is currently loaded in Ollama
- /api/loaded backend endpoint proxies Ollama /api/ps and returns
  {models: [{name, expires_at}]}
- Status line under the controls shows "Hot: gemma2:9b — unloads in 4m"
  with a glowing orange dot, or "No model loaded" in muted style
- Loaded models in the dropdown get a "● " prefix
- Refreshes on page load, after every translation, and 500ms after the
  user pauses typing in the input box
2026-06-09 15:51:57 +02:00
5 changed files with 179 additions and 15 deletions

34
CHANGELOG.md Normal file
View File

@@ -0,0 +1,34 @@
# Changelog
All notable changes to finrod are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.1.0] - 2026-06-09
First release. Stateless translator UI that proxies streaming requests to
Ollama on radagast.
### Added
- FastAPI backend with three endpoints:
- `GET /api/models` — filtered (`gemma,aya` by default) list from Ollama's `/api/tags`
- `POST /api/translate` — streams Ollama's NDJSON `/api/generate` response back to the browser
- `GET /api/loaded` — proxies Ollama's `/api/ps` for the currently-loaded model
- `GET /healthz` — for Kubernetes probes
- Single-page UI:
- Source / target / model dropdowns (30+ languages, "Auto-detect" source)
- Token-by-token streaming into the output pane with a blinking caret
- Live status line: spinner while waiting for first token, pulsing dot during streaming, elapsed time and token count
- Glowing border on the output pane during streaming
- Loaded-model indicator (`● ` prefix in dropdown, "Hot: … — unloads in Xm" status line); refreshes on page load, on translation completion, and 500 ms after typing pauses
- ⌘/Ctrl-Enter to translate from the input
- Dark mode, inline SVG favicon (Star of Fëanor)
- Dockerfile: two-stage `uv` build, slim Python 3.13 runtime
- `build.sh`: multi-arch (amd64 + arm64) manual push to Scaleway, tags `:latest`, `:<git-short-sha>`, and `:<pyproject-version>`
- Cluster deploy via the `valinor` repo at `apps/finrod/` (bjw-s app-template, internal-only ingress at `finrod.jpnadas.xyz`)
### Fixed
- Lock against public PyPI explicitly — the workstation's global `uv` config had pointed `uv.lock` sources at the dexterenergy private artifact registry, which 401'd inside Docker

View File

@@ -47,14 +47,29 @@ Then on every change:
./build.sh ./build.sh
``` ```
That builds `linux/amd64,linux/arm64` and pushes `rg.nl-ams.scw.cloud/valinor/finrod:{latest, <git-short-sha>}`. Take note of the short SHA it prints — that's what you pin in valinor. That builds `linux/amd64,linux/arm64` and pushes three tags:
- `:latest` — moving pointer
- `:<git-short-sha>` — immutable, traceable
- `:<pyproject-version>` (e.g. `:0.1.0`) — semver
## Deploy ## Deploy
Cluster manifests live in `valinor/apps/finrod/` (bjw-s app-template, internal-only ingress at `finrod.jpnadas.xyz`). To roll out a new build: Cluster manifests live in `valinor/apps/finrod/` (bjw-s app-template, internal-only ingress at `finrod.jpnadas.xyz`). To roll out a new build, bump `tag:` in `valinor/apps/finrod/values.yaml` to the new semver on a branch → PR → merge → ArgoCD syncs.
1. `./build.sh` here — get the SHA ## Release workflow
2. Bump `tag:` in `valinor/apps/finrod/values.yaml` to that SHA on a branch
3. Merge → ArgoCD syncs the new image
Using `:latest` works for the first deploy (the pull policy is `Always`) but pinning to a SHA on each rollout gives you trivial rollback via git revert. This project uses [Semantic Versioning](https://semver.org/) and keeps a human-edited [CHANGELOG.md](./CHANGELOG.md).
To cut a release:
1. On a branch, edit `CHANGELOG.md`: move accumulated `[Unreleased]` notes into a new `## [X.Y.Z] - YYYY-MM-DD` section.
2. Bump `version` in `pyproject.toml` to `X.Y.Z`.
3. PR → merge to `main`.
4. Tag the merge commit:
```sh
git checkout main && git pull
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z
```
5. `./build.sh` — the image now also gets `:X.Y.Z` (read from `pyproject.toml`).
6. In `valinor`, bump `apps/finrod/values.yaml` `tag:` from the previous version to `X.Y.Z` on a branch → PR → ArgoCD picks it up.

17
app.py
View File

@@ -49,6 +49,23 @@ async def list_models() -> list[str]:
return sorted(names) return sorted(names)
@app.get("/api/loaded")
async def loaded_models() -> dict[str, list[dict[str, str | None]]]:
"""Models currently resident in Ollama's memory (from /api/ps)."""
async with httpx.AsyncClient(timeout=5) as client:
try:
r = await client.get(f"{OLLAMA_URL}/api/ps")
r.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(502, f"Ollama unreachable: {e}") from e
return {
"models": [
{"name": m["name"], "expires_at": m.get("expires_at")}
for m in r.json().get("models", [])
]
}
@app.post("/api/translate") @app.post("/api/translate")
async def translate(req: TranslateRequest) -> StreamingResponse: async def translate(req: TranslateRequest) -> StreamingResponse:
text = req.text.strip() text = req.text.strip()

View File

@@ -9,20 +9,24 @@
# docker login rg.nl-ams.scw.cloud -u nologin -p <SCW_SECRET_KEY> # docker login rg.nl-ams.scw.cloud -u nologin -p <SCW_SECRET_KEY>
# #
# Usage: # Usage:
# ./build.sh # tags :latest and :<short-sha> # ./build.sh
# TAG=v0.2.0 ./build.sh # additional explicit tag #
# Always tags:
# :latest — moving pointer
# :<git-short-sha> — immutable, traceable to a commit
# :<pyproject-version> — semver from pyproject.toml (e.g. :0.1.0)
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")"
REGISTRY="${REGISTRY:-rg.nl-ams.scw.cloud/valinor}" REGISTRY="${REGISTRY:-rg.nl-ams.scw.cloud/valinor}"
IMAGE="${REGISTRY}/finrod" IMAGE="${REGISTRY}/finrod"
SHA="$(git rev-parse --short HEAD)" SHA="$(git rev-parse --short HEAD)"
TAGS=(-t "${IMAGE}:latest" -t "${IMAGE}:${SHA}") VERSION="$(awk -F\" '/^version =/ {print $2; exit}' pyproject.toml)"
if [[ -n "${TAG:-}" ]]; then : "${VERSION:?Could not read version from pyproject.toml}"
TAGS+=(-t "${IMAGE}:${TAG}")
fi
cd "$(dirname "$0")" TAGS=(-t "${IMAGE}:latest" -t "${IMAGE}:${SHA}" -t "${IMAGE}:${VERSION}")
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
@@ -31,4 +35,7 @@ docker buildx build \
. .
echo echo
echo "Pushed: ${IMAGE}:{latest,${SHA}${TAG:+,${TAG}}}" echo "Pushed:"
echo " ${IMAGE}:latest"
echo " ${IMAGE}:${SHA}"
echo " ${IMAGE}:${VERSION}"

View File

@@ -132,6 +132,29 @@
0%, 100% { opacity: 0.3; transform: scale(0.85); } 0%, 100% { opacity: 0.3; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); } 50% { opacity: 1; transform: scale(1.15); }
} }
.hot-status {
font-size: 0.8rem;
color: var(--muted);
margin: 0.25rem 0 0.75rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 1.2em;
}
.hot-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #e07a3e;
box-shadow: 0 0 6px #e07a3e;
}
.hot-status.idle { opacity: 0.55; }
.hot-status.idle .hot-dot {
background: var(--border);
box-shadow: none;
}
</style> </style>
</head> </head>
<body> <body>
@@ -154,6 +177,11 @@
</div> </div>
</div> </div>
<div id="hot-status" class="hot-status idle">
<span class="hot-dot"></span>
<span id="hot-status-text">No model loaded</span>
</div>
<div class="panes"> <div class="panes">
<div> <div>
<label for="input">Input</label> <label for="input">Input</label>
@@ -186,6 +214,8 @@
const output = document.getElementById("output"); const output = document.getElementById("output");
const goBtn = document.getElementById("go"); const goBtn = document.getElementById("go");
const meta = document.getElementById("meta"); const meta = document.getElementById("meta");
const hotStatus = document.getElementById("hot-status");
const hotStatusText = document.getElementById("hot-status-text");
function fillLangs() { function fillLangs() {
const auto = document.createElement("option"); const auto = document.createElement("option");
@@ -313,6 +343,7 @@
output.textContent = acc; output.textContent = acc;
clearInterval(ticker); clearInterval(ticker);
setMeta({ phase: "done", model, seconds: dt, tokens }); setMeta({ phase: "done", model, seconds: dt, tokens });
refreshLoaded();
} catch (e) { } catch (e) {
clearInterval(ticker); clearInterval(ticker);
output.innerHTML = `<span class="error">${e.message || e}</span>`; output.innerHTML = `<span class="error">${e.message || e}</span>`;
@@ -324,13 +355,73 @@
} }
} }
function formatRemaining(expiresAt) {
if (!expiresAt) return null;
const ms = new Date(expiresAt).getTime() - Date.now();
if (!isFinite(ms) || ms <= 0) return null;
const totalSec = Math.floor(ms / 1000);
const mins = Math.floor(totalSec / 60);
const secs = totalSec % 60;
if (mins >= 60) {
const hrs = Math.floor(mins / 60);
const m = mins % 60;
return `${hrs}h${m ? m + "m" : ""}`;
}
if (mins > 0) return `${mins}m${secs ? secs + "s" : ""}`;
return `${secs}s`;
}
async function refreshLoaded() {
let models = [];
try {
const r = await fetch("/api/loaded");
if (r.ok) {
const j = await r.json();
models = j.models || [];
}
} catch {
/* leave previous state if Ollama is briefly unreachable */
return;
}
const loadedMap = new Map(models.map(m => [m.name, m.expires_at]));
// Update dropdown options. Use textContent (option doesn't render HTML).
for (const opt of modelSel.options) {
if (loadedMap.has(opt.value)) {
opt.textContent = `${opt.value}`;
} else {
opt.textContent = opt.value;
}
}
// Update status line.
if (models.length === 0) {
hotStatus.classList.add("idle");
hotStatusText.textContent = "No model loaded";
return;
}
hotStatus.classList.remove("idle");
const parts = models.map(m => {
const remaining = formatRemaining(m.expires_at);
return remaining ? `${m.name} — unloads in ${remaining}` : m.name;
});
hotStatusText.textContent = `Hot: ${parts.join(", ")}`;
}
let typingTimer;
input.addEventListener("input", () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(refreshLoaded, 500);
});
goBtn.addEventListener("click", translate); goBtn.addEventListener("click", translate);
input.addEventListener("keydown", (e) => { input.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") translate(); if ((e.metaKey || e.ctrlKey) && e.key === "Enter") translate();
}); });
fillLangs(); fillLangs();
loadModels(); loadModels().then(refreshLoaded);
</script> </script>
</body> </body>
</html> </html>