Пакування: 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/node_modules/
|
||||||
/web/dist/
|
/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` — джерело істини про те, як зняти конфіг із кожної
|
`catalog.json` — джерело істини про те, як зняти конфіг із кожної
|
||||||
підтримуваної платформи. 148 платформ, 67 вендорів.
|
підтримуваної платформи. 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/auth"
|
||||||
"github.com/netpulse/netpulse/server/internal/crypto"
|
"github.com/netpulse/netpulse/server/internal/crypto"
|
||||||
"github.com/netpulse/netpulse/server/internal/httpapi"
|
"github.com/netpulse/netpulse/server/internal/httpapi"
|
||||||
|
"github.com/netpulse/netpulse/server/webui"
|
||||||
"github.com/netpulse/netpulse/server/internal/store"
|
"github.com/netpulse/netpulse/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -88,6 +89,13 @@ func run() error {
|
||||||
|
|
||||||
api := httpapi.New(st, signer, log)
|
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 api.Hub().Run(ctx)
|
||||||
go pruneLoop(ctx, st, log, *pruneAge)
|
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.
|
// заголовку через підпротокол — див. ws.go.
|
||||||
mux.Handle("GET /api/v1/ws", s.authenticated(s.handleWS))
|
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))
|
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