Пакування: runner міграцій і вшитий у бінарник фронтенд
netpulse-migrate замість PowerShell-скрипта: у контейнері немає ані psql, ані PowerShell, а тягнути клієнт Postgres в образ заради одного запуску — це половина дистрибутива на порожньому місці. Міграції вшиті через embed і переїхали в server/migrations: embed не бачить нічого за межами кореня свого модуля, а міграції поруч із бінарником, який їх накочує, не можуть розійтися версіями. Накочування під advisory-блокуванням: два інстанси при rolling update інакше застосували б ту саму міграцію двічі. Кожен файл в одній транзакції разом із записом у schema_migrations; виняток — continuous aggregates, які TimescaleDB забороняє в транзакції. Змінена вже застосована міграція зупиняє запуск: у різних інсталяціях інакше опиниться різна схема під одним номером. Перевірено на чистій базі: 23 міграції, 101 таблиця, повторний запуск каже «схема актуальна». Веб віддає сам API через embed: на self-hosted це прибирає з інструкції встановлення цілий компонент. Три політики кешування — назавжди для assets із хешем у імені, ніколи для index.html, коротко для решти. Знайдено живим прогоном: невідомий шлях під /api/ віддавав 200 з index.html, і клієнт падав на розборі HTML як JSON замість чесного 404. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ddaae60fa0
commit
205dd5e079
35 changed files with 451 additions and 64 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -31,3 +31,8 @@ netpulse-server
|
|||
# Фронтенд
|
||||
/web/node_modules/
|
||||
/web/dist/
|
||||
|
||||
# Зібраний фронтенд, вшитий у бінарник API. Кладеться збіркою.
|
||||
server/webui/dist/*
|
||||
!server/webui/dist/index.html
|
||||
!server/webui/dist/.gitignore
|
||||
|
|
|
|||
22
db/README-migrations.md
Normal file
22
db/README-migrations.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Міграції переїхали
|
||||
|
||||
Файли схеми тепер лежать у `server/migrations/`.
|
||||
|
||||
Причина технічна: `netpulse-migrate` вшиває їх у бінарник через
|
||||
`//go:embed`, а `embed` не бачить нічого за межами кореня свого модуля.
|
||||
Модуль сервера починається в `server/`, тож `db/migrations` для нього
|
||||
недосяжні.
|
||||
|
||||
Це не лише про компіляцію. Міграції поруч із бінарником, який їх
|
||||
накочує, не можуть розійтися версіями: у контейнері немає способу
|
||||
підсунути схему з іншого релізу.
|
||||
|
||||
Накочування:
|
||||
|
||||
```
|
||||
netpulse-migrate -dsn postgres://user:pass@host:5432/netpulse
|
||||
netpulse-migrate -dsn … -dry-run # лише показати, що буде застосовано
|
||||
```
|
||||
|
||||
Генератор профілів NCM (`db/profiles/build.py`) пише свій результат
|
||||
туди ж — у `server/migrations/0014_ncm_profiles.sql`.
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
<#
|
||||
Накочує міграції по порядку номерів у транзакції (кожен файл окремо).
|
||||
Використання:
|
||||
.\migrate.ps1 # локальний стенд з docker-compose
|
||||
.\migrate.ps1 -DbUrl "postgres://user:pw@host:5432/db"
|
||||
.\migrate.ps1 -DryRun # лише показати порядок
|
||||
#>
|
||||
param(
|
||||
[string]$DbUrl = "postgres://netpulse:netpulse@localhost:5432/netpulse",
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$dir = Join-Path $PSScriptRoot "migrations"
|
||||
$files = Get-ChildItem -Path $dir -Filter "*.sql" | Sort-Object Name
|
||||
|
||||
if ($DryRun) {
|
||||
$files | ForEach-Object { $_.Name }
|
||||
return
|
||||
}
|
||||
|
||||
if (-not (Get-Command psql -ErrorAction SilentlyContinue)) {
|
||||
throw "psql не знайдено в PATH. Встанови PostgreSQL client tools або запусти через контейнер: docker compose exec -T db psql ..."
|
||||
}
|
||||
|
||||
# Таблиця обліку застосованих міграцій
|
||||
$bootstrap = @'
|
||||
CREATE TABLE IF NOT EXISTS public.schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
checksum text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
'@
|
||||
$bootstrap | psql $DbUrl -v ON_ERROR_STOP=1 -q
|
||||
|
||||
foreach ($f in $files) {
|
||||
$version = $f.BaseName
|
||||
$applied = (psql $DbUrl -tA -c "SELECT 1 FROM public.schema_migrations WHERE version = '$version'").Trim()
|
||||
if ($applied -eq "1") {
|
||||
Write-Host "skip $version" -ForegroundColor DarkGray
|
||||
continue
|
||||
}
|
||||
|
||||
$sum = (Get-FileHash $f.FullName -Algorithm SHA256).Hash
|
||||
Write-Host "apply $version" -ForegroundColor Cyan
|
||||
|
||||
# Зазвичай — одна транзакція на файл, щоб не було часткових міграцій.
|
||||
# Виняток: TimescaleDB забороняє CREATE MATERIALIZED VIEW WITH
|
||||
# (timescaledb.continuous) всередині транзакційного блоку.
|
||||
$needsAutocommit = (Select-String -Path $f.FullName -Pattern "timescaledb\.continuous" -Quiet)
|
||||
if ($needsAutocommit) {
|
||||
Write-Host " (autocommit: continuous aggregates)" -ForegroundColor DarkYellow
|
||||
psql $DbUrl -v ON_ERROR_STOP=1 -q -f $f.FullName
|
||||
} else {
|
||||
psql $DbUrl -v ON_ERROR_STOP=1 --single-transaction -q -f $f.FullName
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw "Міграція $version впала" }
|
||||
|
||||
psql $DbUrl -v ON_ERROR_STOP=1 -q -c `
|
||||
"INSERT INTO public.schema_migrations (version, checksum) VALUES ('$version','$sum')"
|
||||
}
|
||||
|
||||
Write-Host "OK: усі міграції застосовано" -ForegroundColor Green
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
`catalog.json` — джерело істини про те, як зняти конфіг із кожної
|
||||
підтримуваної платформи. 148 платформ, 67 вендорів.
|
||||
|
||||
Міграція `db/migrations/0014_ncm_profiles.sql` **породжується** з
|
||||
Міграція `server/migrations/0014_ncm_profiles.sql` **породжується** з
|
||||
каталогу, а не правиться руками: два описи одного й того самого
|
||||
розійшлися б із першою ж правкою, і невідомо було б, який справжній.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/netpulse/netpulse/server/internal/auth"
|
||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||
"github.com/netpulse/netpulse/server/internal/httpapi"
|
||||
"github.com/netpulse/netpulse/server/webui"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -88,6 +89,13 @@ func run() error {
|
|||
|
||||
api := httpapi.New(st, signer, log)
|
||||
|
||||
// Зібраний інтерфейс, якщо він є в цій збірці. Порожній dist —
|
||||
// робочий стан: розробка йде проти vite, а API просто віддає API.
|
||||
if fsys, ok := webui.FS(); ok {
|
||||
httpapi.SetStatic(fsys)
|
||||
log.Info("веб-інтерфейс вшито в бінарник")
|
||||
}
|
||||
|
||||
go api.Hub().Run(ctx)
|
||||
go pruneLoop(ctx, st, log, *pruneAge)
|
||||
|
||||
|
|
|
|||
238
server/cmd/netpulse-migrate/main.go
Normal file
238
server/cmd/netpulse-migrate/main.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
// Команда netpulse-migrate — накочування схеми.
|
||||
//
|
||||
// Бінарник, а не скрипт: у контейнері немає ані PowerShell, ані psql, а
|
||||
// вимагати клієнт Postgres поруч із застосунком означає тягнути в образ
|
||||
// половину дистрибутива заради одного запуску.
|
||||
//
|
||||
// Міграції вшиті в бінарник через embed: файл, який лежить поруч,
|
||||
// рано чи пізно виявиться версією з іншого релізу.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
schema "github.com/netpulse/netpulse/server/migrations"
|
||||
)
|
||||
|
||||
// Шлях відносний до кореня модуля не працює: embed бачить лише те, що
|
||||
// лежить у каталозі пакета або нижче. Тому міграції тягнуться через
|
||||
// окремий пакет, який стоїть поруч із ними.
|
||||
var migrations = schema.Files
|
||||
|
||||
// migrateLockKey — advisory-блокування на час накочування.
|
||||
//
|
||||
// Два інстанси, що стартують одночасно (rolling update, docker compose
|
||||
// зі скейлом), інакше накотили б ту саму міграцію двічі: перевірка
|
||||
// «чи застосовано» і сам запис — різні моменти часу.
|
||||
const migrateLockKey = 0x6e70_6d67 // "npmg"
|
||||
|
||||
// continuousRe ловить те, що не можна виконати в транзакції.
|
||||
//
|
||||
// TimescaleDB відмовляє: CREATE MATERIALIZED VIEW WITH
|
||||
// (timescaledb.continuous) поза транзакційним блоком. Такі файли
|
||||
// накочуються без обгортки — ціна в тому, що збій посеред файлу лишає
|
||||
// половину змін, тому їх свідомо тримають короткими.
|
||||
var continuousRe = regexp.MustCompile(`timescaledb\.continuous`)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "netpulse-migrate:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
dsn := flag.String("dsn", os.Getenv("NETPULSE_DSN"), "postgres://user:pass@host:5432/db")
|
||||
dryRun := flag.Bool("dry-run", false, "лише показати, що буде застосовано")
|
||||
timeout := flag.Duration("timeout", 10*time.Minute, "стеля на всі міграції")
|
||||
flag.Parse()
|
||||
|
||||
if *dsn == "" {
|
||||
return errors.New("не вказано -dsn (або NETPULSE_DSN)")
|
||||
}
|
||||
|
||||
files, err := listMigrations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
ctx, cancel := context.WithTimeout(ctx, *timeout)
|
||||
defer cancel()
|
||||
|
||||
pool, err := pgxpool.New(ctx, *dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("підключення: %w", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Одне з'єднання на весь запуск: advisory-блокування живе рівно
|
||||
// стільки, скільки з'єднання, яке його взяло.
|
||||
conn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("підключення: %w", err)
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
if err := bootstrap(ctx, conn.Conn()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(ctx, `SELECT pg_advisory_lock($1)`, int64(migrateLockKey)); err != nil {
|
||||
return fmt.Errorf("блокування: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(context.WithoutCancel(ctx),
|
||||
`SELECT pg_advisory_unlock($1)`, int64(migrateLockKey))
|
||||
}()
|
||||
|
||||
applied, err := appliedVersions(ctx, conn.Conn())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pending := 0
|
||||
for _, f := range files {
|
||||
version := strings.TrimSuffix(f, ".sql")
|
||||
body, err := migrations.ReadFile(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
checksum := hex.EncodeToString(sum[:])
|
||||
|
||||
if old, ok := applied[version]; ok {
|
||||
// Змінена вже застосована міграція — це майже завжди
|
||||
// помилка: у різних інсталяціях опиниться різна схема під
|
||||
// одним номером. Кажемо про це й зупиняємось.
|
||||
if old != checksum {
|
||||
return fmt.Errorf(
|
||||
"міграцію %s змінено після застосування (було %s…, стало %s…).\n"+
|
||||
"Не правте застосовані міграції — додайте нову",
|
||||
version, old[:8], checksum[:8])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pending++
|
||||
if *dryRun {
|
||||
fmt.Printf("буде застосовано %s\n", version)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("застосовую %s\n", version)
|
||||
if err := apply(ctx, conn.Conn(), version, checksum, string(body)); err != nil {
|
||||
return fmt.Errorf("міграція %s: %w", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case pending == 0:
|
||||
fmt.Println("схема актуальна")
|
||||
case *dryRun:
|
||||
fmt.Printf("непримінених міграцій: %d\n", pending)
|
||||
default:
|
||||
fmt.Printf("застосовано міграцій: %d\n", pending)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func listMigrations() ([]string, error) {
|
||||
entries, err := fs.ReadDir(migrations, ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
// Порядок — за іменем: номер на початку файлу і є версією.
|
||||
sort.Strings(out)
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("у бінарнику немає жодної міграції")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func bootstrap(ctx context.Context, conn *pgx.Conn) error {
|
||||
_, err := conn.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS public.schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
checksum text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func appliedVersions(ctx context.Context, conn *pgx.Conn) (map[string]string, error) {
|
||||
rows, err := conn.Query(ctx, `SELECT version, checksum FROM public.schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var v, c string
|
||||
if err := rows.Scan(&v, &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[v] = c
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// apply накочує один файл.
|
||||
//
|
||||
// Зазвичай в одній транзакції разом із записом у schema_migrations: без
|
||||
// цього збій посеред файлу лишає схему в стані, який ніхто не описував,
|
||||
// а наступний запуск вважає міграцію незастосованою й повторює її.
|
||||
func apply(ctx context.Context, conn *pgx.Conn, version, checksum, body string) error {
|
||||
if continuousRe.MatchString(body) {
|
||||
// TimescaleDB забороняє continuous aggregates у транзакції.
|
||||
// Позначку ставимо після успіху: інакше збій лишив би міграцію
|
||||
// «застосованою» без застосування.
|
||||
if _, err := conn.Exec(ctx, body); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := conn.Exec(ctx,
|
||||
`INSERT INTO public.schema_migrations (version, checksum) VALUES ($1, $2)`,
|
||||
version, checksum)
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO public.schema_migrations (version, checksum) VALUES ($1, $2)`,
|
||||
version, checksum); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
|
@ -176,6 +176,12 @@ func (s *Server) Handler() http.Handler {
|
|||
// заголовку через підпротокол — див. ws.go.
|
||||
mux.Handle("GET /api/v1/ws", s.authenticated(s.handleWS))
|
||||
|
||||
// Статика останньою: "/" у ServeMux ловить усе, що не збіглося з
|
||||
// конкретнішими маршрутами, тож API лишається головним, а фронтенд
|
||||
// отримує решту. Без цього /api/v1/невідомий-шлях віддавав би
|
||||
// index.html замість 404, і клієнт падав би на розборі HTML як JSON.
|
||||
mux.HandleFunc("/", s.serveStatic)
|
||||
|
||||
return s.withRecovery(s.withLogging(mux))
|
||||
}
|
||||
|
||||
|
|
|
|||
114
server/internal/httpapi/static.go
Normal file
114
server/internal/httpapi/static.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StaticFS — зібраний веб-інтерфейс.
|
||||
//
|
||||
// Порожній за замовчуванням: API збирається й запускається без нього
|
||||
// (розробка йде проти vite з його гарячим перезавантаженням). Реліз
|
||||
// підкладає сюди справжню файлову систему через SetStatic.
|
||||
var staticFS fs.FS
|
||||
|
||||
// SetStatic підключає зібраний фронтенд.
|
||||
//
|
||||
// Віддавати статику самим API, а не Nginx поруч: на self-hosted це
|
||||
// прибирає з інструкції встановлення цілий компонент, який доведеться
|
||||
// налаштовувати, оновлювати й лагодити тому, хто прийшов моніторити
|
||||
// мережу, а не адмініструвати веб-сервер.
|
||||
func SetStatic(f fs.FS) { staticFS = f }
|
||||
|
||||
// buildTime — момент запуску процесу.
|
||||
//
|
||||
// Слугує міткою для Last-Modified: точний час збірки файлу в embed.FS
|
||||
// недоступний (усе нульове), а без будь-якої мітки браузер перепитує
|
||||
// кожен ресурс щоразу.
|
||||
var buildTime = time.Now()
|
||||
|
||||
// serveStatic віддає SPA.
|
||||
//
|
||||
// Три різні політики кешування, бо це три різні речі:
|
||||
//
|
||||
// - /assets/* — імена з хешем вмісту, тож кешуються назавжди;
|
||||
// - index.html — ніколи, інакше після оновлення браузер тягне старий
|
||||
// html зі старими іменами ассетів, яких на сервері вже немає;
|
||||
// - решта (favicon, manifest) — недовго.
|
||||
func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
|
||||
if staticFS == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Невідомий шлях під /api/ — це помилка виклику, а не маршрут SPA.
|
||||
//
|
||||
// Знайдено живим прогоном: /api/v1/невідомий-шлях віддавав 200 з
|
||||
// index.html, і клієнт падав на розборі HTML як JSON — замість
|
||||
// зрозумілого 404. Роутер у браузері про /api/ нічого не знає, тож
|
||||
// віддавати йому ці шляхи немає жодних підстав.
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
writeError(w, http.StatusNotFound, "not_found", "невідомий шлях API")
|
||||
return
|
||||
}
|
||||
|
||||
upath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||
if upath == "" {
|
||||
upath = "index.html"
|
||||
}
|
||||
|
||||
f, err := staticFS.Open(upath)
|
||||
if err != nil {
|
||||
// Будь-який невідомий шлях — це маршрут SPA (/map, /devices…),
|
||||
// а не відсутній файл: роутер живе в браузері, і сервер про
|
||||
// його маршрути не знає й не має знати.
|
||||
s.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
st, err := f.Stat()
|
||||
if err != nil || st.IsDir() {
|
||||
s.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upath, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
}
|
||||
|
||||
rs, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
s.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeContent(w, r, upath, buildTime, rs)
|
||||
}
|
||||
|
||||
func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
f, err := staticFS.Open("index.html")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
rs, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// index.html не кешується ніколи: він містить імена ассетів із
|
||||
// хешами, і закешована копія після оновлення шле браузер по файли,
|
||||
// яких уже немає.
|
||||
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
http.ServeContent(w, r, "index.html", buildTime, rs)
|
||||
}
|
||||
11
server/migrations/embed.go
Normal file
11
server/migrations/embed.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Package schema тримає SQL-міграції, вшиті в бінарник.
|
||||
//
|
||||
// Окремий пакет поруч із самими файлами, бо //go:embed бачить лише
|
||||
// каталог свого пакета й нижче: із server/cmd/netpulse-migrate до
|
||||
// server/migrations дотягнутись не можна.
|
||||
package schema
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.sql
|
||||
var Files embed.FS
|
||||
6
server/webui/dist/.gitignore
vendored
Normal file
6
server/webui/dist/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Зібраний бандл сюди кладе збірка, а не людина: коміт артефакту
|
||||
# означав би тримати в git файли, які розходяться з кодом при кожній
|
||||
# правці й роздувають історію.
|
||||
*
|
||||
!.gitignore
|
||||
!index.html
|
||||
6
server/webui/dist/index.html
vendored
Normal file
6
server/webui/dist/index.html
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>NetPulse API</title>
|
||||
<p>Це збірка без веб-інтерфейсу: працює лише API.</p>
|
||||
<p>Щоб вшити інтерфейс, зберіть фронтенд і покладіть результат сюди:
|
||||
<code>cd web && npm run build && cp -r dist/* ../server/webui/dist/</code></p>
|
||||
34
server/webui/embed.go
Normal file
34
server/webui/embed.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Package webui тримає зібраний фронтенд, вшитий у бінарник API.
|
||||
//
|
||||
// Каталог dist/ порожній у репозиторії й наповнюється при збірці:
|
||||
// коміт зібраного бандла означав би тримати в git артефакт, який
|
||||
// розходиться з кодом при кожній правці й роздуває історію.
|
||||
//
|
||||
// Порожній dist — робочий стан: API стартує без інтерфейсу, а розробка
|
||||
// йде проти vite з гарячим перезавантаженням.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
// FS повертає корінь зібраного інтерфейсу.
|
||||
//
|
||||
// Другий результат — чи є що віддавати. У збірці без фронтенду в dist
|
||||
// лежить лише заглушка з поясненням; ознака справжнього бандла —
|
||||
// каталог assets/, який робить vite. Перевіряти наявність index.html
|
||||
// марно: він там завжди, інакше embed не скомпілюється.
|
||||
func FS() (fs.FS, bool) {
|
||||
sub, err := fs.Sub(dist, "dist")
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := fs.Stat(sub, "assets"); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return sub, true
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue