feat(admin): VERSION+sha в UI, bake CI и auto stage по sha-*
CI / test (push) Successful in 6m45s
CI / push-image (push) Failing after 4m38s
CI / ci-done (push) Has been cancelled

This commit is contained in:
2026-07-14 21:25:35 +03:00
parent b7d572ea8e
commit 2a32465050
9 changed files with 128 additions and 27 deletions
+3 -2
View File
@@ -1,3 +1,4 @@
VITE_API_BASE_URL=https://admin-api.dev.eventhub.local # Same-origin через Vite proxy (см. vite.config.ts) — иначе CORS с localhost → admin-api.
VITE_WS_URL=wss://admin-ws.dev.eventhub.local VITE_API_BASE_URL=
VITE_WS_URL=
VITE_APP_TITLE=EventHub Admin VITE_APP_TITLE=EventHub Admin
+13
View File
@@ -30,6 +30,19 @@ jobs:
with: with:
node-version: '22' node-version: '22'
- name: Bake build identity
id: build_meta
run: |
set -euo pipefail
APP_VERSION="$(tr -d '[:space:]' < VERSION 2>/dev/null || echo 0.0)"
GIT_SHA="${GITHUB_SHA:0:12}"
BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "VITE_APP_VERSION=${APP_VERSION}" >> "$GITHUB_ENV"
echo "VITE_GIT_SHA=${GIT_SHA}" >> "$GITHUB_ENV"
echo "VITE_BUILT_AT=${BUILT_AT}" >> "$GITHUB_ENV"
echo "version=${APP_VERSION}" >> "$GITHUB_OUTPUT"
echo "git_sha=${GIT_SHA}" >> "$GITHUB_OUTPUT"
- run: npm ci - run: npm ci
- run: npm run lint - run: npm run lint
- run: npm run build - run: npm run build
+38 -22
View File
@@ -1,40 +1,46 @@
name: Deploy stage (admin) name: Deploy stage (admin)
# Auto после успешного CI на master: тот же sha-*, плюс floating :stage.
# Product version (VERSION) не бампается — только sha в рантайме/registry.
on: on:
push: workflow_run:
tags: workflows: [CI]
- 'v*' types: [completed]
branches: [master, main]
workflow_dispatch: workflow_dispatch:
inputs:
release_tag: concurrency:
description: 'Release tag (например v0.1.1)' group: eventhub-frontadmin-deploy-stage
required: true cancel-in-progress: false
default: 'v0.1.1'
sha_tag:
description: 'Образ sha-* (например sha-3f5dde498652)'
required: true
default: 'sha-3f5dde498652'
env: env:
REGISTRY: git.sabilin.com/eventhub REGISTRY: git.sabilin.com/eventhub
jobs: jobs:
deploy-stage-admin: deploy-stage-admin:
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
- name: Compute image tags - name: Compute image tag
id: meta id: meta
run: | run: |
set -euo pipefail set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_run" ]; then
echo "sha_tag=${{ github.event.inputs.sha_tag }}" >> "$GITHUB_OUTPUT" SHA="${{ github.event.workflow_run.head_sha }}"
echo "release_tag=${{ github.event.inputs.release_tag }}" >> "$GITHUB_OUTPUT"
else else
echo "sha_tag=sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" SHA="${GITHUB_SHA}"
echo "release_tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi fi
echo "tag=sha-${SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Wait for Docker daemon
run: bash scripts/ensure-docker-daemon.sh 240
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -46,14 +52,24 @@ jobs:
username: ${{ secrets.REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Promote admin-ui image (no rebuild) - name: Verify admin-ui image from CI
run: |
set -euo pipefail
TAG="${{ steps.meta.outputs.tag }}"
if ! docker buildx imagetools inspect "${REGISTRY}/eventhub-admin-ui:${TAG}" >/dev/null 2>&1; then
echo "Образ eventhub-admin-ui:${TAG} не найден — CI push не завершился."
exit 1
fi
echo "OK: eventhub-admin-ui:${TAG}"
- name: Promote sha → :stage (no rebuild)
env: env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: | run: |
bash scripts/clone-devops-run.sh scripts/promote-images.sh \ bash scripts/clone-devops-run.sh scripts/promote-images.sh \
"${{ steps.meta.outputs.sha_tag }}" \ "${{ steps.meta.outputs.tag }}" \
"${{ steps.meta.outputs.release_tag }}" \ "${{ steps.meta.outputs.tag }}" \
stage \ stage \
eventhub-admin-ui eventhub-admin-ui
@@ -66,7 +82,7 @@ jobs:
script: | script: |
export REGISTRY_USER='${{ secrets.REGISTRY_USER }}' export REGISTRY_USER='${{ secrets.REGISTRY_USER }}'
export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}' export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}'
bash /opt/eventhub-stage/devops/scripts/deploy-service.sh stage admin ${{ steps.meta.outputs.release_tag }} bash /opt/eventhub-stage/devops/scripts/deploy-service.sh stage admin ${{ steps.meta.outputs.tag }}
- name: Prune old registry images - name: Prune old registry images
continue-on-error: true continue-on-error: true
+1
View File
@@ -0,0 +1 @@
0.1
+16
View File
@@ -0,0 +1,16 @@
import apiClient from './client';
export type AdminHealthInfo = {
status: string;
service?: string;
version?: string;
git_sha?: string;
built_at?: string;
};
export const healthApi = {
getAdminHealth: async (): Promise<AdminHealthInfo> => {
const { data } = await apiClient.get<AdminHealthInfo>('/v1/admin/health');
return data;
},
};
+39
View File
@@ -52,6 +52,12 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
import { CommandPalette } from '@/components/CommandPalette'; import { CommandPalette } from '@/components/CommandPalette';
import { useCommandPaletteHotkey } from '@/hooks/useCommandPaletteHotkey'; import { useCommandPaletteHotkey } from '@/hooks/useCommandPaletteHotkey';
import { HeaderNodeMetrics } from '@/components/HeaderNodeMetrics'; import { HeaderNodeMetrics } from '@/components/HeaderNodeMetrics';
import { healthApi } from '@/api/healthApi';
function formatBuildLabel(version: string, sha: string): string {
const shortSha = sha && sha !== 'unknown' && sha !== 'dev' ? sha.slice(0, 12) : sha;
return `${version} (${shortSha})`;
}
const navIconByKey: Record<string, React.ComponentType<{ className?: string }>> = { const navIconByKey: Record<string, React.ComponentType<{ className?: string }>> = {
'/dashboard': LayoutDashboard, '/dashboard': LayoutDashboard,
@@ -102,10 +108,34 @@ const ControlCenterLayout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
const [density, setDensity] = useState<TableDensity>(() => getTableDensity()); const [density, setDensity] = useState<TableDensity>(() => getTableDensity());
const [apiVersionLabel, setApiVersionLabel] = useState<string | null>(null);
const openPalette = useCallback(() => setPaletteOpen(true), []); const openPalette = useCallback(() => setPaletteOpen(true), []);
useCommandPaletteHotkey(openPalette); useCommandPaletteHotkey(openPalette);
const adminVersionLabel = formatBuildLabel(
import.meta.env.VITE_APP_VERSION || '0.0',
import.meta.env.VITE_GIT_SHA || 'dev'
);
useEffect(() => {
let cancelled = false;
healthApi
.getAdminHealth()
.then((info) => {
if (cancelled) return;
setApiVersionLabel(
formatBuildLabel(info.version || '0.0', info.git_sha || 'unknown')
);
})
.catch(() => {
if (!cancelled) setApiVersionLabel(null);
});
return () => {
cancelled = true;
};
}, []);
const selectedKey = getMenuSelectedKey(location.pathname); const selectedKey = getMenuSelectedKey(location.pathname);
const groups = useMemo(() => filterNavGroupsByRole(NAV_GROUPS, user?.role), [user?.role]); const groups = useMemo(() => filterNavGroupsByRole(NAV_GROUPS, user?.role), [user?.role]);
@@ -317,6 +347,15 @@ const ControlCenterLayout: React.FC = () => {
{t('common.langEn')} {t('common.langEn')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel className="space-y-0.5 text-xs font-normal text-muted-foreground">
<div>{t('layout.adminVersion', { version: adminVersionLabel })}</div>
<div>
{apiVersionLabel
? t('layout.apiVersion', { version: apiVersionLabel })
: t('layout.apiVersionUnavailable')}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive"> <DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
{t('layout.logout')} {t('layout.logout')}
+4 -1
View File
@@ -192,7 +192,10 @@
"nodeCpu": "CPU: {{value}}%", "nodeCpu": "CPU: {{value}}%",
"nodeMemory": "Memory: {{used}} / {{total}} MB ({{percent}}%)", "nodeMemory": "Memory: {{used}} / {{total}} MB ({{percent}}%)",
"nodeWas": "was {{value}}%", "nodeWas": "was {{value}}%",
"nodeAria": "{{node}}: CPU {{cpu}}%, memory {{memory}}%" "nodeAria": "{{node}}: CPU {{cpu}}%, memory {{memory}}%",
"adminVersion": "Admin {{version}}",
"apiVersion": "API {{version}}",
"apiVersionUnavailable": "API — unavailable"
}, },
"explore": { "explore": {
"viewAria": "List view", "viewAria": "List view",
+4 -1
View File
@@ -192,7 +192,10 @@
"nodeCpu": "CPU: {{value}}%", "nodeCpu": "CPU: {{value}}%",
"nodeMemory": "Память: {{used}} / {{total}} МБ ({{percent}}%)", "nodeMemory": "Память: {{used}} / {{total}} МБ ({{percent}}%)",
"nodeWas": "было {{value}}%", "nodeWas": "было {{value}}%",
"nodeAria": "{{node}}: CPU {{cpu}}%, память {{memory}}%" "nodeAria": "{{node}}: CPU {{cpu}}%, память {{memory}}%",
"adminVersion": "Admin {{version}}",
"apiVersion": "API {{version}}",
"apiVersionUnavailable": "API — нет данных"
}, },
"explore": { "explore": {
"viewAria": "Вид списка", "viewAria": "Вид списка",
+9
View File
@@ -6,8 +6,17 @@ import tailwindcss from '@tailwindcss/vite';
const apiBaseUrl = process.env.VITE_API_BASE_URL ?? 'https://admin-api.dev.eventhub.local'; const apiBaseUrl = process.env.VITE_API_BASE_URL ?? 'https://admin-api.dev.eventhub.local';
const wsUrl = process.env.VITE_WS_URL ?? 'wss://admin-ws.dev.eventhub.local'; const wsUrl = process.env.VITE_WS_URL ?? 'wss://admin-ws.dev.eventhub.local';
const appVersion = process.env.VITE_APP_VERSION ?? '0.0';
const gitSha = process.env.VITE_GIT_SHA ?? 'dev';
const builtAt = process.env.VITE_BUILT_AT ?? '';
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
'import.meta.env.VITE_GIT_SHA': JSON.stringify(gitSha),
'import.meta.env.VITE_BUILT_AT': JSON.stringify(builtAt),
},
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),