feat: update application versioning and enhance release management
Docker images / prepare-release (push) Successful in 38s
Docker images / backend-image (push) Has been skipped
Docker images / frontend-image (push) Has been skipped
Docker images / updater-image (push) Has been skipped
Docker images / publish-release (push) Has been skipped
Docker images / notify-webhook (push) Has been skipped

- Bumped application version to 1.0.0 across all relevant package files, ensuring consistency in versioning.
- Introduced new environment variables for application version and release URL in Dockerfiles, improving deployment transparency.
- Enhanced the health check endpoint to return the current application version, providing better visibility for monitoring.
- Updated CI/CD workflows to include steps for preparing and publishing releases, streamlining the release process.
- Added a new "Releases" section in the application sidebar for easier access to version information.
This commit is contained in:
Denozordec
2026-05-12 17:19:20 +07:00
parent f282585b52
commit 9ccc8459d7
21 changed files with 630 additions and 22 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"version": "1.0.0",
"tag": "v1.0.0",
"publishedAt": null,
"releaseUrl": "",
"commits": []
}
View File
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process"
import { mkdirSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = join(__dirname, "..", "..")
const baseVersion = process.env.BASE_VERSION ?? "1.0.0"
const outputDir = process.env.RELEASE_OUTPUT_DIR ?? join(repoRoot, ".ci", "release")
const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shts.su").replace(/\/$/, "")
const CONVENTIONAL_RE =
/^(feat|fix|chore|docs|refactor|style|test|build|ci)(\([^)]+\))?!?:\s*(.+)$/i
const MINOR_TYPES = new Set(["feat"])
const PATCH_TYPES = new Set([
"fix",
"chore",
"docs",
"refactor",
"style",
"test",
"build",
"ci",
])
function git(...args) {
return execFileSync("git", args, {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim()
}
function parseSemver(version) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version)
if (!match) {
throw new Error(`Invalid semver: ${version}`)
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
}
}
function formatSemver(parts) {
return `${parts.major}.${parts.minor}.${parts.patch}`
}
function bumpVersion(version, bump) {
const parts = parseSemver(version)
if (bump === "minor") {
parts.minor += 1
parts.patch = 0
return formatSemver(parts)
}
parts.patch += 1
return formatSemver(parts)
}
function parseCommit(subject, body = "") {
const match = CONVENTIONAL_RE.exec(subject.trim())
const breaking =
/BREAKING CHANGE/i.test(body) ||
(match?.[1] && subject.includes(`${match[1]}!:`))
if (!match) {
return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
}
const type = match[1].toLowerCase()
const scope = match[2]?.slice(1, -1) ?? null
const description = match[3].trim()
let bump = "patch"
if (breaking || MINOR_TYPES.has(type)) {
bump = "minor"
} else if (PATCH_TYPES.has(type)) {
bump = "patch"
}
return { type, scope, description, bump }
}
function getLatestTag() {
try {
const tags = git("tag", "--list", "v*.*.*", "--sort=-v:refname")
const first = tags.split("\n").map((line) => line.trim()).find(Boolean)
return first ?? null
} catch {
return null
}
}
function getCommitsSince(tag) {
const range = tag ? `${tag}..HEAD` : "HEAD"
const raw = git(
"log",
range,
"--pretty=format:%H%x1f%h%x1f%an%x1f%s%x1f%b%x1e",
)
if (!raw) return []
return raw
.split("\x1e")
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const [sha, shortSha, author, subject, body] = entry.split("\x1f")
const parsed = parseCommit(subject, body)
return {
sha,
shortSha,
author,
subject,
type: parsed.type,
scope: parsed.scope,
bump: parsed.bump,
}
})
}
function groupCommits(commits) {
const groups = new Map()
for (const commit of commits) {
const key = commit.type
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(commit)
}
return groups
}
function renderReleaseNotes({ tag, version, commits }) {
const lines = [`# ${tag}`, "", `Версия **${version}**.`, ""]
const groups = groupCommits(commits)
const order = ["feat", "fix", "chore", "docs", "refactor", "style", "test", "build", "ci", "other"]
for (const type of order) {
const items = groups.get(type)
if (!items?.length) continue
lines.push(`## ${type}`, "")
for (const item of items) {
lines.push(`- ${item.subject} (\`${item.shortSha}\`, ${item.author})`)
}
lines.push("")
}
return `${lines.join("\n").trim()}\n`
}
function buildReleaseUrl(tag) {
if (!repository) return ""
const [owner, repo] = repository.split("/")
if (!owner || !repo) return ""
return `${serverUrl}/${owner}/${repo}/releases/tag/${encodeURIComponent(tag)}`
}
function writeGithubOutput(values) {
const outputFile = process.env.GITHUB_OUTPUT
if (!outputFile) return
const lines = Object.entries(values)
.map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "%0A")}`)
.join("\n")
writeFileSync(outputFile, `${lines}\n`, { encoding: "utf8", flag: "a" })
}
function main() {
const latestTag = getLatestTag()
const baseFromTag = latestTag?.replace(/^v/, "") ?? baseVersion
const commits = latestTag ? getCommitsSince(latestTag) : []
if (!latestTag || commits.length === 0) {
mkdirSync(outputDir, { recursive: true })
writeFileSync(join(outputDir, "release_notes.md"), "", "utf8")
writeFileSync(
join(outputDir, "release-manifest.json"),
`${JSON.stringify(
{
version: baseFromTag,
tag: latestTag ?? `v${baseFromTag}`,
publishedAt: null,
releaseUrl: latestTag ? buildReleaseUrl(latestTag) : "",
commits: [],
},
null,
2,
)}\n`,
"utf8",
)
writeGithubOutput({
should_release: "false",
version: baseFromTag,
tag: latestTag ?? `v${baseFromTag}`,
bump: "none",
release_url: latestTag ? buildReleaseUrl(latestTag) : "",
})
return
}
const bump = commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
const version = bumpVersion(baseFromTag, bump)
const tag = `v${version}`
const publishedAt = new Date().toISOString()
const releaseUrl = buildReleaseUrl(tag)
const releaseNotes = renderReleaseNotes({ tag, version, commits })
const manifest = {
version,
tag,
publishedAt,
releaseUrl,
commits: commits.map((commit) => ({
sha: commit.sha,
shortSha: commit.shortSha,
subject: commit.subject,
author: commit.author,
type: commit.type,
})),
}
mkdirSync(outputDir, { recursive: true })
writeFileSync(join(outputDir, "release_notes.md"), releaseNotes, "utf8")
writeFileSync(join(outputDir, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8")
writeGithubOutput({
should_release: "true",
version,
tag,
bump,
release_url: releaseUrl,
})
process.stdout.write(`${tag}\n`)
}
main()
+1
View File
@@ -27,3 +27,4 @@ alwaysApply: false
## Связь с проектом
- Полные стандарты проекта (формат ответа, чеклисты) — в правиле **`next-shadcn-production.mdc`** (обычно уже подключено). Эти правила **дополняют** его ролью в пайплайне, не отменяют.
- Сообщения коммитов и ожидаемый semver bump — в **`release-versioning.mdc`**.
+1
View File
@@ -18,6 +18,7 @@ alwaysApply: false
- Выравнивать UI/UX с **`/servers`** как с главным эталоном (layout, отступы, сетка, типографика, композиция).
- Предпочитать **Server Components**; `'use client'` — только при необходимости интерактива (состояние, браузерные API, обработчики).
- UI: по возможности **shadcn/ui**; не вводить новый визуальный/UX-паттерн, если на `/servers` уже есть эквивалент.
- При завершении задачи предлагать subject коммита по **`release-versioning.mdc`** (`fix:` для patch, `feat:` для minor).
## Формат вывода (строго)
+32
View File
@@ -0,0 +1,32 @@
---
description: "Conventional Commits и semver для patch/minor релизов RouterLists"
alwaysApply: true
---
# Версионирование релизов
- Версию **не** править вручную в UI, `package.json` и git-тегах; bump делает CI через [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs).
- Базовая линия: `v1.0.0`. Формат отображения: `v1.2.3`.
## Patch (`1.0.x`)
`fix:`, `chore:`, `docs:`, `refactor:`, `style:`, `test:`, `build:`, `ci:`
- Один логический смысл на коммит; subject в повелительном наклонении, до ~72 символов.
- Scope по желанию: `fix(servers):`, `chore(ci):`.
## Minor (`1.x.0`)
`feat:`, `feat!`, footer `BREAKING CHANGE` — только для заметного core-функционала.
## Примеры
- `fix(servers): исправить опрос API после таймаута` — patch
- `feat(filters): добавить синхронизацию BGP in` — minor
- `chore: мелкие правки` — плохо; `chore(ci): добавить job prepare-release` — хорошо
## Антипаттерны
WIP, «update», смешение несвязанных изменений, маскировка фич под `fix`/`chore`, ручной bump в sidebar.
Подробности — раздел CI/CD в `README.md`.
+143 -8
View File
@@ -8,9 +8,45 @@ on:
env:
REGISTRY: git.shts.su
GITEA_SERVER_URL: https://git.shts.su
jobs:
prepare-release:
runs-on: ubuntu-latest
outputs:
should_release: ${{ steps.release.outputs.should_release }}
version: ${{ steps.release.outputs.version }}
tag: ${{ steps.release.outputs.tag }}
bump: ${{ steps.release.outputs.bump }}
release_url: ${{ steps.release.outputs.release_url }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute release metadata
id: release
shell: bash
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_SERVER_URL: ${{ env.GITEA_SERVER_URL }}
BASE_VERSION: "1.0.0"
RELEASE_OUTPUT_DIR: .ci/release
run: node .ci/scripts/compute-release.mjs
- name: Upload release artifacts
if: steps.release.outputs.should_release == 'true'
uses: actions/upload-artifact@v4
with:
name: release-artifacts
path: |
.ci/release/release_notes.md
.ci/release/release-manifest.json
backend-image:
needs: prepare-release
if: needs.prepare-release.outputs.should_release == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -58,24 +94,35 @@ jobs:
file: backend/Dockerfile
platforms: linux/amd64
push: true
build-args: |
APP_VERSION=${{ needs.prepare-release.outputs.version }}
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
cache-from: type=gha,scope=backend
cache-to: type=gha,mode=max,scope=backend
labels: |
org.opencontainers.image.title=mmapp-backend
org.opencontainers.image.description=MikroTik Manager backend (Fastify)
org.opencontainers.image.version=latest
org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
frontend-image:
needs: prepare-release
if: needs.prepare-release.outputs.should_release == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
name: release-artifacts
path: .ci/release
- name: Configure frontend image name
shell: bash
run: |
@@ -91,7 +138,7 @@ jobs:
set -euo pipefail
STAGING=".ci/docker/frontend"
rm -rf "$STAGING"
mkdir -p "$STAGING/packages/contracts"
mkdir -p "$STAGING/packages/contracts" "$STAGING/public"
cp package-lock.json next.config.ts tsconfig.json postcss.config.mjs eslint.config.mjs components.json "$STAGING/"
node <<'NODE'
const fs = require("node:fs")
@@ -106,7 +153,7 @@ jobs:
cp -R "$dir" "$STAGING/"
fi
done
mkdir -p "$STAGING/public"
cp .ci/release/release-manifest.json "$STAGING/public/release-manifest.json"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -130,19 +177,24 @@ jobs:
NEXT_PUBLIC_BACKEND_URL=same-origin
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
cache-from: type=gha,scope=frontend
cache-to: type=gha,mode=max,scope=frontend
labels: |
org.opencontainers.image.title=mmapp-frontend
org.opencontainers.image.description=MikroTik Manager frontend (Next.js)
org.opencontainers.image.version=latest
org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
updater-image:
needs: prepare-release
if: needs.prepare-release.outputs.should_release == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -177,20 +229,103 @@ jobs:
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ gitea.sha }}
${{ env.IMAGE_NAME }}:${{ needs.prepare-release.outputs.version }}
cache-from: type=gha,scope=updater
cache-to: type=gha,mode=max,scope=updater
labels: |
org.opencontainers.image.title=mmapp-updater
org.opencontainers.image.description=MikroTik Manager autonomous Docker updater
org.opencontainers.image.version=latest
org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }}
org.opencontainers.image.revision=${{ gitea.sha }}
org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }}
notify-webhook:
if: ${{ secrets.DEPLOY_WEBHOOK_URL != '' }}
publish-release:
needs:
- prepare-release
- backend-image
- frontend-image
- updater-image
if: needs.prepare-release.outputs.should_release == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
name: release-artifacts
path: .ci/release
- name: Configure git author
shell: bash
run: |
set -euo pipefail
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@users.noreply.gitea"
- name: Create and push release tag
shell: bash
env:
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
set -euo pipefail
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists, skipping tag push"
exit 0
fi
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
- name: Publish Gitea release
shell: bash
env:
TAG: ${{ needs.prepare-release.outputs.tag }}
VERSION: ${{ needs.prepare-release.outputs.version }}
REPOSITORY: ${{ gitea.repository }}
TOKEN: ${{ secrets.ACTIONS_PAT }}
run: |
set -euo pipefail
owner="${REPOSITORY%%/*}"
repo="${REPOSITORY#*/}"
payload=$(node <<'NODE'
const fs = require("node:fs")
const body = fs.readFileSync(".ci/release/release_notes.md", "utf8")
const payload = {
tag_name: process.env.TAG,
target_commitish: "main",
name: process.env.VERSION,
body,
draft: false,
prerelease: false,
}
process.stdout.write(JSON.stringify(payload))
NODE
)
status=$(curl --silent --show-error --output /tmp/gitea-release.json --write-out "%{http_code}" \
--request POST \
--header "Authorization: token ${TOKEN}" \
--header "Content-Type: application/json" \
--data "$payload" \
"${{ env.GITEA_SERVER_URL }}/api/v1/repos/${owner}/${repo}/releases")
if [ "$status" = "409" ] || [ "$status" = "422" ]; then
echo "Release for ${TAG} already exists (${status}), skipping"
exit 0
fi
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
cat /tmp/gitea-release.json
exit 1
fi
notify-webhook:
if: ${{ secrets.DEPLOY_WEBHOOK_URL != '' && needs.prepare-release.outputs.should_release == 'true' }}
needs:
- prepare-release
- backend-image
- frontend-image
- publish-release
runs-on: ubuntu-latest
steps:
- name: Notify deploy webhook
@@ -201,5 +336,5 @@ jobs:
set -euo pipefail
curl --fail --silent --show-error --request POST \
--header "Content-Type: application/json" \
--data "{\"repository\":\"${{ gitea.repository }}\",\"sha\":\"${{ gitea.sha }}\",\"ref\":\"${{ gitea.ref }}\"}" \
--data "{\"repository\":\"${{ gitea.repository }}\",\"sha\":\"${{ gitea.sha }}\",\"ref\":\"${{ gitea.ref }}\",\"version\":\"${{ needs.prepare-release.outputs.version }}\",\"tag\":\"${{ needs.prepare-release.outputs.tag }}\",\"releaseUrl\":\"${{ needs.prepare-release.outputs.release_url }}\"}" \
"$WEBHOOK_URL"
+4
View File
@@ -12,10 +12,14 @@ ARG BACKEND_INTERNAL_URL=http://backend:8000
ARG NEXT_PUBLIC_BACKEND_URL=same-origin
ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
ARG NEXT_PUBLIC_APP_VERSION=dev
ARG NEXT_PUBLIC_RELEASE_URL=
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
COPY packages/contracts packages/contracts
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
COPY app app
+12 -1
View File
@@ -203,7 +203,18 @@ npm --prefix backend run db:studio
Кэш Buildx: `type=gha`, отдельные scope `backend`, `frontend`, `updater`.
Опциональный job **`notify-webhook`**: POST JSON `{"repository","sha","ref"}` на URL из секрета **`DEPLOY_WEBHOOK_URL`** после успешной сборки backend и frontend (updater в `needs` не входит).
Опциональный job **`notify-webhook`**: POST JSON `{"repository","sha","ref","version","tag","releaseUrl"}` на URL из секрета **`DEPLOY_WEBHOOK_URL`** после успешной сборки backend и frontend и публикации релиза (updater в `needs` не входит).
### Версионирование и релизы
- Базовая версия: **`v1.0.0`**. Линия semver: `1.x.y` (major `2.x` вне scope).
- Job **`prepare-release`** запускает [`.ci/scripts/compute-release.mjs`](.ci/scripts/compute-release.mjs): коммиты с последнего тега `v*.*.*`, bump по **Conventional Commits**.
- **`feat` / `feat!` / `BREAKING CHANGE`** → minor (`1.x.0`); **`fix`**, `chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci` → patch (`1.0.x`).
- Если после последнего тега нет новых коммитов, релиз и сборка образов **пропускаются**.
- Job **`publish-release`**: annotated tag `v1.2.3`, Gitea Release на `https://git.shts.su` (markdown notes), образы с тегами `:latest`, `:<sha>`, `:<semver>`.
- UI: версия в sidebar и страница **`/releases`**; manifest `public/release-manifest.json` (в CI подставляется из артефакта).
- Bootstrap: один раз выставить `1.0.0` в workspace `package.json` и создать тег **`v1.0.0`** на `main` перед первым автоматическим bump.
- Сообщения коммитов: см. [`.cursor/rules/release-versioning.mdc`](.cursor/rules/release-versioning.mdc).
## Прод-развёртывание Docker
+93
View File
@@ -0,0 +1,93 @@
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { buttonVariants } from "@/components/ui/button"
import { formatAppVersionLabel, getAppVersion, getReleaseUrl } from "@/lib/app-version"
import { readReleaseManifest } from "@/lib/release-manifest"
import { cn } from "@/lib/utils"
import { ExternalLinkIcon } from "lucide-react"
function formatPublishedAt(value: string | null): string {
if (!value) return "Локальная сборка"
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "long",
timeStyle: "short",
}).format(new Date(value))
}
export default function ReleasesPage() {
const manifest = readReleaseManifest()
const version = getAppVersion()
const releaseUrl = getReleaseUrl() || manifest.releaseUrl || null
const commits = manifest.commits
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Система" }, { label: "Релизы" }]}
actions={
releaseUrl ? (
<Link
href={releaseUrl}
target="_blank"
rel="noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 inline-flex items-center gap-2")}
>
<ExternalLinkIcon className="size-4" />
Открыть в Gitea
</Link>
) : null
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-[960px] mx-auto space-y-6">
<Card>
<CardHeader>
<CardTitle>Текущая версия</CardTitle>
<CardDescription>Сборка RouterLists, опубликованная через CI/CD.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<span className="text-2xl font-semibold tracking-tight">{formatAppVersionLabel(version)}</span>
<span className="text-sm text-muted-foreground">{formatPublishedAt(manifest.publishedAt)}</span>
</div>
<p className="text-sm text-muted-foreground">
Версия формируется автоматически от базы <span className="font-mono">v1.0.0</span> по Conventional Commits.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Сводка коммитов</CardTitle>
<CardDescription>
{commits.length > 0
? `Изменения, вошедшие в ${formatAppVersionLabel(manifest.version)}.`
: "Для локальной разработки список коммитов пуст. В прод-сборке он заполняется CI."}
</CardDescription>
</CardHeader>
<CardContent>
{commits.length === 0 ? (
<p className="text-sm text-muted-foreground">Коммитов для отображения пока нет.</p>
) : (
<ul className="space-y-3">
{commits.map((commit) => (
<li key={commit.sha} className="rounded-md border px-3 py-2">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono uppercase">{commit.type}</span>
<span className="font-mono">{commit.shortSha}</span>
<span>{commit.author}</span>
</div>
<p className="mt-1 text-sm">{commit.subject}</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
</div>
)
}
+4
View File
@@ -12,6 +12,8 @@ RUN npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --i
&& npm rebuild better-sqlite3
FROM deps AS build
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
COPY packages/contracts packages/contracts
COPY backend backend
RUN npm run build -w @mmapp/contracts \
@@ -20,9 +22,11 @@ RUN npm run build -w @mmapp/contracts \
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ARG APP_VERSION=dev
ENV NODE_ENV=production
ENV PORT=8000
ENV DATABASE_PATH=/app/data/mikrotik.db
ENV APP_VERSION=$APP_VERSION
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/contracts ./packages/contracts
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.1.0",
"version": "1.0.0",
"description": "MikroTik Manager backend — Fastify + Drizzle + SQLite",
"type": "module",
"scripts": {
@@ -13,7 +13,7 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@mmapp/contracts": "0.1.0",
"@mmapp/contracts": "1.0.0",
"@fastify/cors": "^11.2.0",
"@fastify/type-provider-zod": "^1.0.0",
"better-sqlite3": "^12.9.0",
+5 -1
View File
@@ -47,7 +47,11 @@ await app.register(cors, {
// ── routes ─────────────────────────────────────────────────────────────────────
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }))
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
+4 -1
View File
@@ -47,6 +47,7 @@ import {
mockSidebarBadgesByUrl,
type SidebarCountsDto,
} from "@/lib/sidebar-badges"
import { formatAppVersionLabel, getAppVersion } from "@/lib/app-version"
// ─── Command palette trigger button ──────────────────────────────────────────
@@ -117,6 +118,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
items: [
{ title: "Оповещения", url: "/alerts", icon: <BellIcon /> },
{ title: "Сбор данных", url: "/data-collection", icon: <DatabaseIcon /> },
{ title: "Релизы", url: "/releases", icon: <BadgeCheckIcon /> },
{ title: "Настройки", url: "/settings", icon: <SettingsIcon /> },
],
},
@@ -215,6 +217,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}, [mounted, mode, backendStatus, liveCounts, mockBadges, evo.enabled, evo.snapshot])
const isDark = (resolvedTheme ?? "dark") === "dark"
const appVersionLabel = formatAppVersionLabel(getAppVersion())
return (
<Sidebar collapsible="icon" {...props}>
@@ -225,7 +228,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</div>
<div className="flex flex-col leading-tight group-data-[collapsible=icon]:hidden">
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">RouterLists</span>
<span className="text-[10px] font-mono text-sidebar-foreground/40">v3.4.1</span>
<span className="text-[10px] font-mono text-sidebar-foreground/40">{appVersionLabel}</span>
</div>
</div>
<CommandPaletteButton />
+1
View File
@@ -54,6 +54,7 @@ const ALL_ITEMS: CommandItem[] = [
// Система
{ id: "alerts", title: "Оповещения", group: "Система", url: "/alerts", icon: <BellIcon />, keywords: ["alert","notification","уведомление"] },
{ id: "data-collection", title: "Сбор данных", group: "Система", url: "/data-collection", icon: <DatabaseIcon />, keywords: ["scheduler","планировщик","коллектор","uptime","трафик","журнал","прогон"] },
{ id: "releases", title: "Релизы", group: "Система", url: "/releases", icon: <BadgeCheckIcon />, keywords: ["release","version","версия","changelog","релиз"] },
{ id: "settings", title: "Настройки", group: "Система", url: "/settings", icon: <SettingsIcon />, keywords: ["settings","config","конфигурация"] },
]
+16
View File
@@ -0,0 +1,16 @@
import packageJson from "@/package.json"
const packageVersion = packageJson.version
export function getAppVersion(): string {
return process.env.NEXT_PUBLIC_APP_VERSION?.trim() || `${packageVersion}-dev`
}
export function formatAppVersionLabel(version = getAppVersion()): string {
return `v${version}`
}
export function getReleaseUrl(): string | null {
const value = process.env.NEXT_PUBLIC_RELEASE_URL?.trim()
return value || null
}
+36
View File
@@ -0,0 +1,36 @@
import { readFileSync } from "node:fs"
import { join } from "node:path"
export interface ReleaseCommit {
sha: string
shortSha: string
subject: string
author: string
type: string
}
export interface ReleaseManifest {
version: string
tag: string
publishedAt: string | null
releaseUrl: string
commits: ReleaseCommit[]
}
const fallbackManifest: ReleaseManifest = {
version: "1.0.0",
tag: "v1.0.0",
publishedAt: null,
releaseUrl: "",
commits: [],
}
export function readReleaseManifest(): ReleaseManifest {
try {
const filePath = join(process.cwd(), "public", "release-manifest.json")
const raw = readFileSync(filePath, "utf8")
return JSON.parse(raw) as ReleaseManifest
} catch {
return fallbackManifest
}
}
+21 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mmapp",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mmapp",
"version": "0.1.0",
"version": "1.0.0",
"hasInstallScript": true,
"workspaces": [
"packages/*",
@@ -14,7 +14,7 @@
],
"dependencies": {
"@base-ui/react": "^1.4.1",
"@mmapp/contracts": "0.1.0",
"@mmapp/contracts": "1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.11.0",
@@ -39,11 +39,11 @@
},
"backend": {
"name": "mikrotik-manager-backend",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "0.1.0",
"@mmapp/contracts": "1.0.0",
"better-sqlite3": "^12.9.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
@@ -12670,10 +12670,25 @@
},
"packages/contracts": {
"name": "@mmapp/contracts",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"zod": "^4.4.1"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mmapp",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/*",
@@ -14,7 +14,7 @@
"lint": "eslint"
},
"dependencies": {
"@mmapp/contracts": "0.1.0",
"@mmapp/contracts": "1.0.0",
"@base-ui/react": "^1.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mmapp/contracts",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"type": "module",
"files": [
+7
View File
@@ -0,0 +1,7 @@
{
"version": "1.0.0",
"tag": "v1.0.0",
"publishedAt": null,
"releaseUrl": "",
"commits": []
}