Центрирование заглушек в графиках/Frame; лимит тела backup 100 MiB; WAL checkpoint при экспорте и очистка -wal/-shm при импорте. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,7 +1,13 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify'
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
import { desc } from 'drizzle-orm'
|
import { desc } from 'drizzle-orm'
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { getDb, getDbPath, reloadDatabaseFromBuffer, schema } from '@cfdm/db'
|
import {
|
||||||
|
getDb,
|
||||||
|
getDbPath,
|
||||||
|
readDatabaseFileBuffer,
|
||||||
|
reloadDatabaseFromBuffer,
|
||||||
|
schema,
|
||||||
|
} from '@cfdm/db'
|
||||||
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
|
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
|
||||||
|
|
||||||
import { importJsonSnapshot, type BackupPayload } from '../services/backup-import.js'
|
import { importJsonSnapshot, type BackupPayload } from '../services/backup-import.js'
|
||||||
@@ -9,10 +15,26 @@ import { restartScheduler } from '../services/scheduler.js'
|
|||||||
|
|
||||||
const BACKUP_VERSION = 1
|
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) => {
|
export const backupRoutes: FastifyPluginAsync = async (app) => {
|
||||||
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => {
|
const bodyLimit = backupBodyLimitBytes()
|
||||||
done(null, body)
|
|
||||||
})
|
app.addContentTypeParser(
|
||||||
|
'application/octet-stream',
|
||||||
|
{ parseAs: 'buffer', bodyLimit },
|
||||||
|
(_req, body, done) => {
|
||||||
|
done(null, body)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
app.get('/api/backup/json', async (_req, reply) => {
|
app.get('/api/backup/json', async (_req, reply) => {
|
||||||
const syncLog = getDb()
|
const syncLog = getDb()
|
||||||
@@ -37,41 +59,49 @@ export const backupRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
if (!existsSync(dbPath)) {
|
if (!existsSync(dbPath)) {
|
||||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Файл базы не найден' } })
|
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-Type', 'application/octet-stream')
|
||||||
reply.header('Content-Disposition', 'attachment; filename="vps-tracker.db"')
|
reply.header('Content-Disposition', 'attachment; filename="vps-tracker.db"')
|
||||||
return reply.send(buf)
|
return reply.send(buf)
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/backup/json', async (req, reply) => {
|
app.post(
|
||||||
const payload = req.body
|
'/api/backup/json',
|
||||||
if (!payload || typeof payload !== 'object') {
|
{ bodyLimit },
|
||||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } })
|
async (req, reply) => {
|
||||||
}
|
const payload = req.body
|
||||||
try {
|
if (!payload || typeof payload !== 'object') {
|
||||||
importJsonSnapshot(payload as BackupPayload)
|
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } })
|
||||||
restartScheduler()
|
}
|
||||||
return { ok: true }
|
try {
|
||||||
} catch (err) {
|
importJsonSnapshot(payload as BackupPayload)
|
||||||
req.log.error(err)
|
restartScheduler()
|
||||||
const message = err instanceof Error ? err.message : 'Импорт не удался'
|
return { ok: true }
|
||||||
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } })
|
} 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) => {
|
app.post(
|
||||||
const buf = req.body as Buffer
|
'/api/backup/database',
|
||||||
if (!Buffer.isBuffer(buf) || !buf.length) {
|
{ bodyLimit },
|
||||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Пустой файл' } })
|
async (req, reply) => {
|
||||||
}
|
const buf = req.body as Buffer
|
||||||
try {
|
if (!Buffer.isBuffer(buf) || !buf.length) {
|
||||||
reloadDatabaseFromBuffer(buf)
|
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Пустой файл' } })
|
||||||
restartScheduler()
|
}
|
||||||
return { ok: true }
|
try {
|
||||||
} catch (err) {
|
reloadDatabaseFromBuffer(buf)
|
||||||
req.log.error(err)
|
restartScheduler()
|
||||||
const message = err instanceof Error ? err.message : 'Восстановление не удалось'
|
return { ok: true }
|
||||||
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } })
|
} catch (err) {
|
||||||
}
|
req.log.error(err)
|
||||||
})
|
const message = err instanceof Error ? err.message : 'Восстановление не удалось'
|
||||||
|
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ export function DataGridCard<TData extends object>({
|
|||||||
{hasHeader ? (
|
{hasHeader ? (
|
||||||
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
|
||||||
) : null}
|
) : null}
|
||||||
<FramePanel>
|
<FramePanel className="flex min-h-72 w-full flex-col items-center justify-center">
|
||||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|||||||
@@ -51,7 +51,13 @@ import { aggregateBurnByProject } from '@/lib/project-analytics'
|
|||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
|
||||||
function ChartEmpty({ message }: { message: string }) {
|
function ChartEmpty({ message }: { message: string }) {
|
||||||
return <EmptyState title={message} className="h-72 border-none" />
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={message}
|
||||||
|
className="min-h-72 w-full flex-1 py-0"
|
||||||
|
stackedIcon
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXPENSE_CONFIG: ChartConfig = {
|
const EXPENSE_CONFIG: ChartConfig = {
|
||||||
@@ -103,7 +109,7 @@ export function MonthlyExpenseChart({
|
|||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
<CardDescription>{description ?? `Топ-10 по monthly rate, в ${baseCurrency}`}</CardDescription>
|
<CardDescription>{description ?? `Топ-10 по monthly rate, в ${baseCurrency}`}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<ChartEmpty message="Нет данных для графика" />
|
<ChartEmpty message="Нет данных для графика" />
|
||||||
) : (
|
) : (
|
||||||
@@ -167,7 +173,7 @@ export function PaymentsPieChart({
|
|||||||
<CardTitle>Платежи по типам</CardTitle>
|
<CardTitle>Платежи по типам</CardTitle>
|
||||||
<CardDescription>Структура в {baseCurrency}</CardDescription>
|
<CardDescription>Структура в {baseCurrency}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<ChartEmpty message="Нет данных о платежах" />
|
<ChartEmpty message="Нет данных о платежах" />
|
||||||
) : (
|
) : (
|
||||||
@@ -264,7 +270,7 @@ function DashboardMonthlyBarChart({
|
|||||||
</div>
|
</div>
|
||||||
</CardAction>
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||||
{!hasData ? (
|
{!hasData ? (
|
||||||
<ChartEmpty message="Нет данных за выбранный период" />
|
<ChartEmpty message="Нет данных за выбранный период" />
|
||||||
) : (
|
) : (
|
||||||
@@ -384,7 +390,7 @@ export function DashboardExpensesChart({
|
|||||||
</div>
|
</div>
|
||||||
</CardAction>
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||||
{!hasData ? (
|
{!hasData ? (
|
||||||
<ChartEmpty message="Нет данных за выбранный период" />
|
<ChartEmpty message="Нет данных за выбранный период" />
|
||||||
) : (
|
) : (
|
||||||
@@ -440,7 +446,7 @@ export function MonthlyTrendChart({
|
|||||||
<CardTitle>Динамика платежей</CardTitle>
|
<CardTitle>Динамика платежей</CardTitle>
|
||||||
<CardDescription>Последние 12 месяцев, {baseCurrency}</CardDescription>
|
<CardDescription>Последние 12 месяцев, {baseCurrency}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<ChartEmpty message="Нет данных за выбранный период" />
|
<ChartEmpty message="Нет данных за выбранный период" />
|
||||||
) : (
|
) : (
|
||||||
@@ -460,7 +466,11 @@ export function MonthlyTrendChart({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChartsGrid({ children }: { children: ReactNode }) {
|
export function ChartsGrid({ children }: { children: ReactNode }) {
|
||||||
return <div className="grid w-full gap-4 lg:grid-cols-2">{children}</div>
|
return (
|
||||||
|
<div className="grid w-full gap-4 lg:grid-cols-2 lg:items-stretch [&>*]:min-h-0 [&>*]:h-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectExpenseChart({
|
export function ProjectExpenseChart({
|
||||||
@@ -506,7 +516,7 @@ export function ProjectExpenseChart({
|
|||||||
<CardTitle>Расходы по проектам (мес)</CardTitle>
|
<CardTitle>Расходы по проектам (мес)</CardTitle>
|
||||||
<CardDescription>Активные VPS, в {baseCurrency}</CardDescription>
|
<CardDescription>Активные VPS, в {baseCurrency}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex min-h-72 flex-1 flex-col items-center justify-center">
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<ChartEmpty message="Нет данных для графика" />
|
<ChartEmpty message="Нет данных для графика" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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 { InboxIcon, type LucideIcon } from 'lucide-react'
|
||||||
import { IconStack } from '@/components/reui/icon-stack'
|
import { IconStack } from '@/components/reui/icon-stack'
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +25,11 @@ interface EmptyStateProps {
|
|||||||
className?: string
|
className?: string
|
||||||
/** Use IconStack media (empty-state-12). Default true. */
|
/** Use IconStack media (empty-state-12). Default true. */
|
||||||
stackedIcon?: boolean
|
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 {
|
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
|
||||||
@@ -33,7 +43,6 @@ function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Empty state — ReUI empty-state-12. Preview: https://reui.io/preview/base/empty-state-12 */
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
@@ -41,18 +50,24 @@ export function EmptyState({
|
|||||||
action,
|
action,
|
||||||
className,
|
className,
|
||||||
stackedIcon = true,
|
stackedIcon = true,
|
||||||
|
centered = true,
|
||||||
}: EmptyStateProps) {
|
}: EmptyStateProps) {
|
||||||
const Icon = isLucideIcon(icon) ? icon : InboxIcon
|
const Icon = isLucideIcon(icon) ? icon : InboxIcon
|
||||||
const customIcon = icon && !isLucideIcon(icon) ? icon : null
|
const customIcon = icon && !isLucideIcon(icon) ? icon : null
|
||||||
|
|
||||||
return (
|
const body = (
|
||||||
<Empty className={cn('max-w-md flex-none border-0 bg-transparent p-0', className)}>
|
<Empty
|
||||||
|
className={cn(
|
||||||
|
'max-w-md flex-none border-0 bg-transparent p-0',
|
||||||
|
!centered && className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<EmptyHeader className="gap-5 text-center">
|
<EmptyHeader className="gap-5 text-center">
|
||||||
<EmptyMedia className="mb-0">
|
<EmptyMedia className="mb-0">
|
||||||
{customIcon ? (
|
{customIcon ? (
|
||||||
<div className="text-muted-foreground">{customIcon}</div>
|
<div className="text-muted-foreground">{customIcon}</div>
|
||||||
) : stackedIcon ? (
|
) : stackedIcon ? (
|
||||||
<IconStack aria-hidden="true" className="h-14 w-12">
|
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
|
||||||
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
|
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
|
||||||
</IconStack>
|
</IconStack>
|
||||||
) : (
|
) : (
|
||||||
@@ -72,7 +87,25 @@ export function EmptyState({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
{action ? <EmptyContent>{action}</EmptyContent> : null}
|
{action ? (
|
||||||
|
<EmptyContent className="mt-1 items-center justify-center">
|
||||||
|
{action}
|
||||||
|
</EmptyContent>
|
||||||
|
) : null}
|
||||||
</Empty>
|
</Empty>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!centered) return body
|
||||||
|
|
||||||
|
// empty-state-12: center in available height (parent must be flex column / stretch)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex w-full flex-1 items-center justify-center self-stretch py-14 sm:py-16',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,11 +242,15 @@ export function ResourcePage<T extends object>({
|
|||||||
|
|
||||||
if (data.length === 0 && emptyState) {
|
if (data.length === 0 && emptyState) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<Frame dense spacing="sm" className="w-full">
|
||||||
title={emptyState.title}
|
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-center justify-center p-0">
|
||||||
description={emptyState.description}
|
<EmptyState
|
||||||
action={emptyState.action}
|
title={emptyState.title}
|
||||||
/>
|
description={emptyState.description}
|
||||||
|
action={emptyState.action}
|
||||||
|
/>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -268,6 +268,8 @@ export const api = {
|
|||||||
const headers = new Headers({ 'Content-Type': 'application/octet-stream' })
|
const headers = new Headers({ 'Content-Type': 'application/octet-stream' })
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
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`, {
|
const res = await fetch(`${API_BASE}/api/backup/database`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -277,7 +279,25 @@ export const api = {
|
|||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
await handoffOnUnauthorized()
|
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()
|
return res.json()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -97,14 +97,14 @@ function ResourcesPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card className="flex flex-col">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Ресурсы по хостерам</CardTitle>
|
<CardTitle>Ресурсы по хостерам</CardTitle>
|
||||||
<CardDescription>Только активные VPS</CardDescription>
|
<CardDescription>Только активные VPS</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex min-h-80 flex-1 flex-col items-center justify-center">
|
||||||
{chartData.length === 0 ? (
|
{chartData.length === 0 ? (
|
||||||
<EmptyState title="Нет данных для графика" />
|
<EmptyState title="Нет данных для графика" className="min-h-80 w-full flex-1 py-0" />
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer config={RESOURCE_CONFIG} className="h-80 w-full" aria-label="Ресурсы по хостерам">
|
<ChartContainer config={RESOURCE_CONFIG} className="h-80 w-full" aria-label="Ресурсы по хостерам">
|
||||||
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ services:
|
|||||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
|
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
|
||||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-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:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -22,3 +22,6 @@ AUTH_REQUIRED=false
|
|||||||
AUTH_JWT_SECRET=
|
AUTH_JWT_SECRET=
|
||||||
AUTH_ISSUER=https://auth.shnt.top
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
AUTH_PORTAL_URL=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
|
||||||
|
|||||||
+20
-1
@@ -177,15 +177,33 @@ docker compose down
|
|||||||
## Бэкап
|
## Бэкап
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# SQLite
|
# SQLite (на хосте; контейнер лучше остановить на время копии)
|
||||||
|
docker compose stop app
|
||||||
cp /opt/vps-tracker/data/vps-tracker.db \
|
cp /opt/vps-tracker/data/vps-tracker.db \
|
||||||
/opt/vps-tracker/data/vps-tracker.db.bak-$(date +%F)
|
/opt/vps-tracker/data/vps-tracker.db.bak-$(date +%F)
|
||||||
|
# при WAL также можно скопировать *-wal / *-shm или делать бэкап через UI (там checkpoint)
|
||||||
|
docker compose start app
|
||||||
|
|
||||||
# ACME (Let's Encrypt)
|
# ACME (Let's Encrypt)
|
||||||
docker run --rm -v vps_tracker_traefik_letsencrypt:/data -v "$PWD:/backup" alpine \
|
docker run --rm -v vps_tracker_traefik_letsencrypt:/data -v "$PWD:/backup" alpine \
|
||||||
tar czf /backup/traefik-acme-$(date +%F).tgz -C /data .
|
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
|
## 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, `/api/data` 500 | Старые образы без bootstrap схемы — `docker compose pull && up -d`; смотрите `docker compose logs app` (`no such table`) |
|
||||||
| `/health` OK, UI пустой | образ / кэш CDN; смотрите `docker compose logs app` |
|
| `/health` OK, UI пустой | образ / кэш CDN; смотрите `docker compose logs app` |
|
||||||
| SSO 401 / issuer mismatch | `AUTH_JWT_SECRET` = `JWT_SECRET` портала; `AUTH_ISSUER` |
|
| 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
|
```bash
|
||||||
docker compose logs traefik 2>&1 | grep -iE 'acme|certificate|cloudflare|error'
|
docker compose logs traefik 2>&1 | grep -iE 'acme|certificate|cloudflare|error'
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/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 { dirname, resolve } from 'node:path'
|
||||||
import * as schema from './schema/index.js'
|
import * as schema from './schema/index.js'
|
||||||
import { ensureRuntimeSchema } from './runtime-migrate.js'
|
import { ensureRuntimeSchema } from './runtime-migrate.js'
|
||||||
|
|
||||||
|
const SQLITE_HEADER = Buffer.from('SQLite format 3\0')
|
||||||
|
|
||||||
export type Db = BetterSQLite3Database<typeof schema>
|
export type Db = BetterSQLite3Database<typeof schema>
|
||||||
|
|
||||||
let _db: Db | null = null
|
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 и переоткрыть соединение. */
|
/** Заменить файл SQLite и переоткрыть соединение. */
|
||||||
export function reloadDatabaseFromBuffer(buffer: Buffer): void {
|
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()
|
closeDb()
|
||||||
writeFileSync(getDbPath(), buffer)
|
// Старые WAL/SHM от предыдущей БД ломают открытие нового файла
|
||||||
|
removeSidecarFiles(dbPath)
|
||||||
|
writeFileSync(dbPath, buffer)
|
||||||
openDatabase()
|
openDatabase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user