diff --git a/apps/api/src/routes/backup.ts b/apps/api/src/routes/backup.ts index 65bc7c4..178964f 100644 --- a/apps/api/src/routes/backup.ts +++ b/apps/api/src/routes/backup.ts @@ -1,7 +1,13 @@ import type { FastifyPluginAsync } from 'fastify' import { desc } from 'drizzle-orm' -import { existsSync, readFileSync } from 'node:fs' -import { getDb, getDbPath, reloadDatabaseFromBuffer, schema } from '@cfdm/db' +import { existsSync } from 'node:fs' +import { + getDb, + getDbPath, + readDatabaseFileBuffer, + reloadDatabaseFromBuffer, + schema, +} from '@cfdm/db' import { getSnapshot } from '@cfdm/db/repositories/snapshot' import { importJsonSnapshot, type BackupPayload } from '../services/backup-import.js' @@ -9,10 +15,26 @@ import { restartScheduler } from '../services/scheduler.js' const BACKUP_VERSION = 1 +/** Лимит тела для импорта бэкапа (Fastify default = 1 MiB → 413). */ +function backupBodyLimitBytes(): number { + const raw = process.env.BACKUP_BODY_LIMIT_BYTES + if (raw) { + const n = Number(raw) + if (Number.isFinite(n) && n > 0) return Math.floor(n) + } + return 100 * 1024 * 1024 // 100 MiB +} + export const backupRoutes: FastifyPluginAsync = async (app) => { - app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => { - done(null, body) - }) + const bodyLimit = backupBodyLimitBytes() + + app.addContentTypeParser( + 'application/octet-stream', + { parseAs: 'buffer', bodyLimit }, + (_req, body, done) => { + done(null, body) + }, + ) app.get('/api/backup/json', async (_req, reply) => { const syncLog = getDb() @@ -37,41 +59,49 @@ export const backupRoutes: FastifyPluginAsync = async (app) => { if (!existsSync(dbPath)) { return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Файл базы не найден' } }) } - const buf = readFileSync(dbPath) + const buf = readDatabaseFileBuffer() reply.header('Content-Type', 'application/octet-stream') reply.header('Content-Disposition', 'attachment; filename="vps-tracker.db"') return reply.send(buf) }) - app.post('/api/backup/json', async (req, reply) => { - const payload = req.body - if (!payload || typeof payload !== 'object') { - return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } }) - } - try { - importJsonSnapshot(payload as BackupPayload) - restartScheduler() - return { ok: true } - } catch (err) { - req.log.error(err) - const message = err instanceof Error ? err.message : 'Импорт не удался' - return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } }) - } - }) + app.post( + '/api/backup/json', + { bodyLimit }, + async (req, reply) => { + const payload = req.body + if (!payload || typeof payload !== 'object') { + return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } }) + } + try { + importJsonSnapshot(payload as BackupPayload) + restartScheduler() + return { ok: true } + } catch (err) { + req.log.error(err) + const message = err instanceof Error ? err.message : 'Импорт не удался' + return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } }) + } + }, + ) - app.post('/api/backup/database', async (req, reply) => { - const buf = req.body as Buffer - if (!Buffer.isBuffer(buf) || !buf.length) { - return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Пустой файл' } }) - } - try { - reloadDatabaseFromBuffer(buf) - restartScheduler() - return { ok: true } - } catch (err) { - req.log.error(err) - const message = err instanceof Error ? err.message : 'Восстановление не удалось' - return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } }) - } - }) + app.post( + '/api/backup/database', + { bodyLimit }, + async (req, reply) => { + const buf = req.body as Buffer + if (!Buffer.isBuffer(buf) || !buf.length) { + return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Пустой файл' } }) + } + try { + reloadDatabaseFromBuffer(buf) + restartScheduler() + return { ok: true } + } catch (err) { + req.log.error(err) + const message = err instanceof Error ? err.message : 'Восстановление не удалось' + return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } }) + } + }, + ) } diff --git a/apps/web/src/components/data-grid-card.tsx b/apps/web/src/components/data-grid-card.tsx index 474c254..4f42a32 100644 --- a/apps/web/src/components/data-grid-card.tsx +++ b/apps/web/src/components/data-grid-card.tsx @@ -370,7 +370,7 @@ export function DataGridCard({ {hasHeader ? ( ) : null} - + diff --git a/apps/web/src/components/domain/charts.tsx b/apps/web/src/components/domain/charts.tsx index 2648b60..212641c 100644 --- a/apps/web/src/components/domain/charts.tsx +++ b/apps/web/src/components/domain/charts.tsx @@ -51,7 +51,13 @@ import { aggregateBurnByProject } from '@/lib/project-analytics' import { EmptyState } from '@/components/empty-state' function ChartEmpty({ message }: { message: string }) { - return + return ( + + ) } const EXPENSE_CONFIG: ChartConfig = { @@ -103,7 +109,7 @@ export function MonthlyExpenseChart({ {title} {description ?? `Топ-10 по monthly rate, в ${baseCurrency}`} - + {data.length === 0 ? ( ) : ( @@ -167,7 +173,7 @@ export function PaymentsPieChart({ Платежи по типам Структура в {baseCurrency} - + {data.length === 0 ? ( ) : ( @@ -264,7 +270,7 @@ function DashboardMonthlyBarChart({ - + {!hasData ? ( ) : ( @@ -384,7 +390,7 @@ export function DashboardExpensesChart({ - + {!hasData ? ( ) : ( @@ -440,7 +446,7 @@ export function MonthlyTrendChart({ Динамика платежей Последние 12 месяцев, {baseCurrency} - + {data.length === 0 ? ( ) : ( @@ -460,7 +466,11 @@ export function MonthlyTrendChart({ } export function ChartsGrid({ children }: { children: ReactNode }) { - return
{children}
+ return ( +
+ {children} +
+ ) } export function ProjectExpenseChart({ @@ -506,7 +516,7 @@ export function ProjectExpenseChart({ Расходы по проектам (мес) Активные VPS, в {baseCurrency} - + {data.length === 0 ? ( ) : ( diff --git a/apps/web/src/components/empty-state.tsx b/apps/web/src/components/empty-state.tsx index 37712fb..17237db 100644 --- a/apps/web/src/components/empty-state.tsx +++ b/apps/web/src/components/empty-state.tsx @@ -1,3 +1,8 @@ +/** + * ReUI Empty + IconStack — adapted from empty-state-12. + * Preview: https://reui.io/preview/base/empty-state-12 + * Docs: https://reui.io/blocks + */ import { InboxIcon, type LucideIcon } from 'lucide-react' import { IconStack } from '@/components/reui/icon-stack' import { @@ -20,6 +25,11 @@ interface EmptyStateProps { className?: string /** Use IconStack media (empty-state-12). Default true. */ stackedIcon?: boolean + /** + * Center in available width/height (empty-state-12). + * Set false for tight panels/sheets where the parent already centers. + */ + centered?: boolean } function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon { @@ -33,7 +43,6 @@ function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon { return false } -/** Empty state — ReUI empty-state-12. Preview: https://reui.io/preview/base/empty-state-12 */ export function EmptyState({ icon, title, @@ -41,18 +50,24 @@ export function EmptyState({ action, className, stackedIcon = true, + centered = true, }: EmptyStateProps) { const Icon = isLucideIcon(icon) ? icon : InboxIcon const customIcon = icon && !isLucideIcon(icon) ? icon : null - return ( - + const body = ( + {customIcon ? (
{customIcon}
) : stackedIcon ? ( -
- {action ? {action} : null} + {action ? ( + + {action} + + ) : null}
) + + if (!centered) return body + + // empty-state-12: center in available height (parent must be flex column / stretch) + return ( +
+ {body} +
+ ) } diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx index a76ed43..3bea72e 100644 --- a/apps/web/src/components/reui-kit/resource-page.tsx +++ b/apps/web/src/components/reui-kit/resource-page.tsx @@ -242,11 +242,15 @@ export function ResourcePage({ if (data.length === 0 && emptyState) { return ( - + + + + + ) } diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 1172727..c021d87 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -268,6 +268,8 @@ export const api = { const headers = new Headers({ 'Content-Type': 'application/octet-stream' }) const token = getToken() if (token) headers.set('Authorization', `Bearer ${token}`) + const spaceId = getStoredSpaceId() + if (spaceId) headers.set('X-Space-Id', spaceId) const res = await fetch(`${API_BASE}/api/backup/database`, { method: 'POST', headers, @@ -277,7 +279,25 @@ export const api = { if (res.status === 401) { await handoffOnUnauthorized() } - throw new ApiError(res.statusText || 'Ошибка восстановления', res.status) + let message = res.statusText || 'Ошибка восстановления' + if (res.status === 413) { + message = + 'Файл слишком большой для импорта (лимит тела запроса). Увеличьте BACKUP_BODY_LIMIT_BYTES на API или используйте копирование data/*.db на сервере.' + } else { + try { + const data = (await res.json()) as { + error?: string | { message?: string } + message?: string + } + if (typeof data?.error === 'string') message = data.error + else if (data?.error && typeof data.error === 'object' && data.error.message) { + message = data.error.message + } else if (typeof data?.message === 'string') message = data.message + } catch { + /* ignore */ + } + } + throw new ApiError(message, res.status) } return res.json() }, diff --git a/apps/web/src/routes/_auth/resources.tsx b/apps/web/src/routes/_auth/resources.tsx index 1aa0851..2e2a87f 100644 --- a/apps/web/src/routes/_auth/resources.tsx +++ b/apps/web/src/routes/_auth/resources.tsx @@ -97,14 +97,14 @@ function ResourcesPage() { ]} /> - + Ресурсы по хостерам Только активные VPS - + {chartData.length === 0 ? ( - + ) : ( diff --git a/deploy/docker-compose.traefik.yml b/deploy/docker-compose.traefik.yml index 807d3f8..9f618dd 100644 --- a/deploy/docker-compose.traefik.yml +++ b/deploy/docker-compose.traefik.yml @@ -66,6 +66,8 @@ services: AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-} AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top} AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top} + # Import SQLite/JSON via UI (default in app: 100 MiB). Raise if DB is larger. + BACKUP_BODY_LIMIT_BYTES: ${BACKUP_BODY_LIMIT_BYTES:-104857600} volumes: - ./data:/app/data networks: diff --git a/deploy/env.traefik.example b/deploy/env.traefik.example index 62dafe6..c7fd5a5 100644 --- a/deploy/env.traefik.example +++ b/deploy/env.traefik.example @@ -22,3 +22,6 @@ AUTH_REQUIRED=false AUTH_JWT_SECRET= AUTH_ISSUER=https://auth.shnt.top AUTH_PORTAL_URL=https://auth.shnt.top + +# --- Backup import (POST /api/backup/*). Fastify default is 1 MiB → 413 Content Too Large. +# BACKUP_BODY_LIMIT_BYTES=104857600 diff --git a/docs/deploy-traefik.md b/docs/deploy-traefik.md index 5d77627..b74c31e 100644 --- a/docs/deploy-traefik.md +++ b/docs/deploy-traefik.md @@ -177,15 +177,33 @@ docker compose down ## Бэкап ```bash -# SQLite +# SQLite (на хосте; контейнер лучше остановить на время копии) +docker compose stop app cp /opt/vps-tracker/data/vps-tracker.db \ /opt/vps-tracker/data/vps-tracker.db.bak-$(date +%F) +# при WAL также можно скопировать *-wal / *-shm или делать бэкап через UI (там checkpoint) +docker compose start app # ACME (Let's Encrypt) docker run --rm -v vps_tracker_traefik_letsencrypt:/data -v "$PWD:/backup" alpine \ tar czf /backup/traefik-acme-$(date +%F).tgz -C /data . ``` +### Импорт SQLite через UI + +`POST /api/backup/database` принимает файл до **100 MiB** (`BACKUP_BODY_LIMIT_BYTES`, см. `env.traefik.example`). +Раньше лимит Fastify был **1 MiB** → браузерный **413 Content Too Large**. + +Офлайн на новый сервер (без UI): + +```bash +docker compose stop app +cp ./vps-tracker.db /opt/vps-tracker/data/vps-tracker.db +rm -f /opt/vps-tracker/data/vps-tracker.db-wal \ + /opt/vps-tracker/data/vps-tracker.db-shm +docker compose start app +``` + --- ## Troubleshooting @@ -199,6 +217,7 @@ docker run --rm -v vps_tracker_traefik_letsencrypt:/data -v "$PWD:/backup" alpin | `/health` OK, `/api/data` 500 | Старые образы без bootstrap схемы — `docker compose pull && up -d`; смотрите `docker compose logs app` (`no such table`) | | `/health` OK, UI пустой | образ / кэш CDN; смотрите `docker compose logs app` | | SSO 401 / issuer mismatch | `AUTH_JWT_SECRET` = `JWT_SECRET` портала; `AUTH_ISSUER` | +| `POST /api/backup/database` **413** | Обновить образ (лимит 100 MiB) или `BACKUP_BODY_LIMIT_BYTES`; либо копировать `data/*.db` на сервере как выше | ```bash docker compose logs traefik 2>&1 | grep -iE 'acme|certificate|cloudflare|error' diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index a040d02..996c00e 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,10 +1,12 @@ import Database from 'better-sqlite3' import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import * as schema from './schema/index.js' import { ensureRuntimeSchema } from './runtime-migrate.js' +const SQLITE_HEADER = Buffer.from('SQLite format 3\0') + export type Db = BetterSQLite3Database let _db: Db | null = null @@ -46,10 +48,40 @@ export function closeDb(): void { } } +function removeSidecarFiles(dbPath: string): void { + for (const suffix of ['-wal', '-shm'] as const) { + const side = `${dbPath}${suffix}` + if (existsSync(side)) unlinkSync(side) + } +} + +/** + * Согласованный снимок файла БД: checkpoint WAL → чтение основного файла. + * Без checkpoint экспорт может быть неполным (данные только в *-wal). + */ +export function readDatabaseFileBuffer(): Buffer { + const sqlite = getSqlite() + sqlite.pragma('wal_checkpoint(TRUNCATE)') + return readFileSync(getDbPath()) +} + /** Заменить файл SQLite и переоткрыть соединение. */ export function reloadDatabaseFromBuffer(buffer: Buffer): void { + if (!Buffer.isBuffer(buffer) || buffer.length < SQLITE_HEADER.length) { + throw new Error('Файл не похож на SQLite базу') + } + if (!buffer.subarray(0, SQLITE_HEADER.length).equals(SQLITE_HEADER)) { + throw new Error('Файл не похож на SQLite базу (неверный заголовок)') + } + + const dbPath = getDbPath() + const dir = dirname(dbPath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + closeDb() - writeFileSync(getDbPath(), buffer) + // Старые WAL/SHM от предыдущей БД ломают открытие нового файла + removeSidecarFiles(dbPath) + writeFileSync(dbPath, buffer) openDatabase() } diff --git a/packages/db/src/reload-database.test.ts b/packages/db/src/reload-database.test.ts new file mode 100644 index 0000000..40ab383 --- /dev/null +++ b/packages/db/src/reload-database.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it } from 'vitest' +import Database from 'better-sqlite3' +import { mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + closeDb, + getDb, + getSqlite, + readDatabaseFileBuffer, + reloadDatabaseFromBuffer, +} from './index.js' + +describe('reloadDatabaseFromBuffer / readDatabaseFileBuffer', () => { + let dir: string + + afterEach(() => { + closeDb() + delete process.env.DB_PATH + if (dir && existsSync(dir)) rmSync(dir, { recursive: true, force: true }) + }) + + it('rejects non-sqlite buffer', () => { + dir = mkdtempSync(join(tmpdir(), 'vt-db-')) + process.env.DB_PATH = join(dir, 'app.db') + getDb() + expect(() => reloadDatabaseFromBuffer(Buffer.from('not-a-db'))).toThrow(/SQLite/) + }) + + it('replaces db and drops leftover wal/shm', () => { + dir = mkdtempSync(join(tmpdir(), 'vt-db-')) + const dbPath = join(dir, 'app.db') + process.env.DB_PATH = dbPath + getDb() + getSqlite().exec('CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY); INSERT INTO t VALUES (1);') + closeDb() + + // Simulate stale sidecars after closed connection (Windows locks *-shm while open) + writeFileSync(`${dbPath}-wal`, Buffer.alloc(32)) + writeFileSync(`${dbPath}-shm`, Buffer.alloc(32)) + + const donorPath = join(dir, 'donor.db') + const donor = new Database(donorPath) + donor.exec(`CREATE TABLE ok (x TEXT); INSERT INTO ok VALUES ('yes');`) + donor.close() + const buf = readFileSync(donorPath) + + reloadDatabaseFromBuffer(buf) + + const row = getSqlite().prepare('SELECT x FROM ok').get() as { x: string } + expect(row.x).toBe('yes') + // Старый мусорный WAL не должен подмешаться: таблица t из прошлой БД отсутствует + const tables = getSqlite() + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='t'`) + .all() + expect(tables).toHaveLength(0) + closeDb() + }) + + it('readDatabaseFileBuffer checkpoints wal', () => { + dir = mkdtempSync(join(tmpdir(), 'vt-db-')) + process.env.DB_PATH = join(dir, 'app.db') + getDb() + getSqlite().exec('CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY); INSERT INTO t VALUES (42);') + const buf = readDatabaseFileBuffer() + expect(buf.subarray(0, 15).toString('utf8')).toBe('SQLite format 3') + expect(buf.length).toBeGreaterThan(100) + }) +})