feat(nav): show last-deployed timestamp

Docker final stage bakes BUILD_TIMESTAMP into /app/BUILD_TIMESTAMP via
build-arg (only consumed after mix compile, so earlier layer caching is
preserved). Application.build_timestamp/0 reads the file, falls back to
DEPLOY_TIMESTAMP env, then to compile time. Navbar renders a compact
"Deployed Xm ago" with the full UTC time in the tooltip.
This commit is contained in:
Graham McIntire 2026-04-17 13:08:43 -05:00
parent 48cbf40b9d
commit b6caffea3e
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 156 additions and 2 deletions

View file

@ -41,6 +41,9 @@ jobs:
run: |
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
TAG="${{ steps.tag.outputs.tag }}"
docker build -t "${IMAGE}:${TAG}" -t "${IMAGE}:latest" .
BUILD_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
docker build \
--build-arg BUILD_TIMESTAMP="${BUILD_TIMESTAMP}" \
-t "${IMAGE}:${TAG}" -t "${IMAGE}:latest" .
docker push "${IMAGE}:${TAG}"
docker push "${IMAGE}:latest"

View file

@ -137,6 +137,11 @@ RUN mix release
# ---- Final runtime stage ----
FROM ${RUNNER_IMAGE} AS final
# Build timestamp baked in by CI (--build-arg BUILD_TIMESTAMP=...).
# Only consumed in this final stage, so it never busts the earlier
# compile cache. Read at runtime by Microwaveprop.Application.build_timestamp/0.
ARG BUILD_TIMESTAMP=unknown
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libstdc++6 openssl libncurses6 locales ca-certificates snmp \
@ -162,6 +167,9 @@ RUN ldconfig
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/microwaveprop ./
RUN echo "${BUILD_TIMESTAMP}" > /app/BUILD_TIMESTAMP \
&& chown nobody /app/BUILD_TIMESTAMP
USER nobody
CMD ["/app/bin/server"]

View file

@ -5,6 +5,8 @@ defmodule Microwaveprop.Application do
use Application
@build_timestamp DateTime.utc_now()
@impl true
def start(_type, _args) do
topologies = Application.get_env(:libcluster, :topologies, [])
@ -53,4 +55,51 @@ defmodule Microwaveprop.Application do
MicrowavepropWeb.Endpoint.config_change(changed, removed)
:ok
end
@doc """
Returns the deployment (image build) timestamp.
Sources, in priority order:
1. The `/app/BUILD_TIMESTAMP` file baked into the Docker image at build
time (the CI pipeline passes `--build-arg BUILD_TIMESTAMP=...` in the
final stage, so this value is fresh on every image and is not affected
by earlier-stage layer caching). The path is overridable via
`config :microwaveprop, :build_timestamp_file` for tests.
2. The `DEPLOY_TIMESTAMP` environment variable, for environments that set
it imperatively (e.g. `kubectl set env`).
3. The compile-time fallback captured when this module was compiled.
All accepted values are ISO 8601, e.g. `2026-04-17T18:30:00Z`.
"""
@spec build_timestamp() :: DateTime.t()
def build_timestamp do
with :error <- from_file(),
:error <- from_env() do
@build_timestamp
end
end
defp from_file do
path = Application.get_env(:microwaveprop, :build_timestamp_file, "/app/BUILD_TIMESTAMP")
case File.read(path) do
{:ok, contents} -> parse_iso8601(contents)
{:error, _} -> :error
end
end
defp from_env do
case System.get_env("DEPLOY_TIMESTAMP") do
nil -> :error
string -> parse_iso8601(string)
end
end
defp parse_iso8601(string) do
case string |> String.trim() |> DateTime.from_iso8601() do
{:ok, dt, _offset} -> dt
{:error, _} -> :error
end
end
end

View file

@ -80,7 +80,8 @@ defmodule MicrowavepropWeb.Layouts do
<% end %>
</nav>
</div>
<div class="flex-none">
<div class="flex-none flex items-center gap-3">
<.deploy_stamp />
<.theme_toggle />
</div>
</header>
@ -158,6 +159,38 @@ defmodule MicrowavepropWeb.Layouts do
"""
end
@doc """
Shows when the running release was deployed, as a compact relative timestamp
with the full UTC time in the tooltip.
"""
def deploy_stamp(assigns) do
ts = Microwaveprop.Application.build_timestamp()
iso = Calendar.strftime(ts, "%Y-%m-%d %H:%M UTC")
assigns = assign(assigns, build_time: ts, iso: iso, ago: format_time_ago(ts))
~H"""
<span
class="hidden md:inline text-xs opacity-60"
title={"Last deployed #{@iso}"}
>
Deployed {@ago}
</span>
"""
end
defp format_time_ago(%DateTime{} = dt) do
diff = max(DateTime.diff(DateTime.utc_now(), dt, :second), 0)
cond do
diff < 60 -> "just now"
diff < 3600 -> "#{div(diff, 60)}m ago"
diff < 86_400 -> "#{div(diff, 3600)}h ago"
diff < 2_592_000 -> "#{div(diff, 86_400)}d ago"
diff < 31_536_000 -> "#{div(diff, 2_592_000)}mo ago"
true -> "#{div(diff, 31_536_000)}y ago"
end
end
@doc """
Provides dark vs light theme toggle based on themes defined in app.css.

View file

@ -0,0 +1,61 @@
defmodule Microwaveprop.ApplicationTest do
use ExUnit.Case, async: false
alias Microwaveprop.Application, as: App
describe "build_timestamp/0" do
setup do
original_env = System.get_env("DEPLOY_TIMESTAMP")
original_file = Application.get_env(:microwaveprop, :build_timestamp_file)
tmp = Path.join(System.tmp_dir!(), "build_timestamp_#{System.unique_integer([:positive])}")
on_exit(fn ->
if original_env do
System.put_env("DEPLOY_TIMESTAMP", original_env)
else
System.delete_env("DEPLOY_TIMESTAMP")
end
if original_file do
Application.put_env(:microwaveprop, :build_timestamp_file, original_file)
else
Application.delete_env(:microwaveprop, :build_timestamp_file)
end
File.rm(tmp)
end)
System.delete_env("DEPLOY_TIMESTAMP")
Application.put_env(:microwaveprop, :build_timestamp_file, tmp)
{:ok, tmp: tmp}
end
test "returns a DateTime when no sources are present" do
assert %DateTime{} = App.build_timestamp()
end
test "reads ISO8601 timestamp from the build-timestamp file", %{tmp: tmp} do
File.write!(tmp, "2026-04-15T12:30:00Z\n")
assert App.build_timestamp() == ~U[2026-04-15 12:30:00Z]
end
test "file takes precedence over DEPLOY_TIMESTAMP env", %{tmp: tmp} do
File.write!(tmp, "2026-04-15T12:30:00Z")
System.put_env("DEPLOY_TIMESTAMP", "2026-01-01T00:00:00Z")
assert App.build_timestamp() == ~U[2026-04-15 12:30:00Z]
end
test "falls back to DEPLOY_TIMESTAMP env when file is absent" do
System.put_env("DEPLOY_TIMESTAMP", "2026-02-02T02:02:02Z")
assert App.build_timestamp() == ~U[2026-02-02 02:02:02Z]
end
test "falls back to compile time when both are malformed", %{tmp: tmp} do
File.write!(tmp, "not-a-timestamp")
System.put_env("DEPLOY_TIMESTAMP", "also-garbage")
assert %DateTime{} = App.build_timestamp()
end
end
end