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:
parent
48cbf40b9d
commit
b6caffea3e
5 changed files with 156 additions and 2 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
61
test/microwaveprop/application_test.exs
Normal file
61
test/microwaveprop/application_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue