Files
vps-panel/src/lib/appDb.ts
T
mstfyldz e5dc347a0b feat: database backups & analytics site management
- Added manual database backup functionality with pg_dump
- Added cron-based automated backups to Google Drive and Dropbox
- Added ability to manually add, edit, and delete sites in Analytics
- Added 1-day timeframe filter in Analytics page
- Updated Dockerfile to include postgresql-client
2026-06-02 17:44:46 +03:00

248 lines
8.2 KiB
TypeScript

import { Pool } from 'pg'
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
// Initialize tables
pool.query(`
CREATE TABLE IF NOT EXISTS ping_logs (
id SERIAL PRIMARY KEY,
site_id TEXT NOT NULL,
status TEXT NOT NULL,
ms INTEGER,
code INTEGER,
error TEXT,
ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
CREATE INDEX IF NOT EXISTS idx_ping_site ON ping_logs(site_id, ts DESC);
CREATE TABLE IF NOT EXISTS pageviews (
id SERIAL PRIMARY KEY,
domain TEXT NOT NULL,
path TEXT NOT NULL,
referrer TEXT,
country TEXT,
city TEXT,
ua TEXT,
device TEXT,
browser TEXT,
os TEXT,
visitor_id TEXT NOT NULL,
ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
CREATE INDEX IF NOT EXISTS idx_pv_domain ON pageviews(domain, ts DESC);
CREATE INDEX IF NOT EXISTS idx_pv_ts ON pageviews(ts DESC);
CREATE TABLE IF NOT EXISTS analytics_sites (
domain TEXT PRIMARY KEY,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
CREATE TABLE IF NOT EXISTS config_sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
interval_min INTEGER
);
CREATE TABLE IF NOT EXISTS config_databases (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
database TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
ssl BOOLEAN NOT NULL DEFAULT FALSE,
color TEXT
);
CREATE TABLE IF NOT EXISTS config_services (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
icon TEXT,
description TEXT
);
CREATE TABLE IF NOT EXISTS backup_configs (
id TEXT PRIMARY KEY,
db_id TEXT NOT NULL,
schedule TEXT NOT NULL,
cloud_type TEXT NOT NULL,
credentials TEXT NOT NULL,
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
CREATE TABLE IF NOT EXISTS backup_logs (
id SERIAL PRIMARY KEY,
db_id TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
file_size BIGINT,
ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::BIGINT)
);
CREATE INDEX IF NOT EXISTS idx_backup_db_ts ON backup_logs(db_id, ts DESC);
`).catch(console.error)
export type PingLog = {
id: number
site_id: string
status: 'up' | 'down' | 'degraded'
ms: number | null
code: number | null
error: string | null
ts: number
}
export async function insertPingLog(log: Omit<PingLog, 'id' | 'ts'>) {
await pool.query(
`INSERT INTO ping_logs (site_id, status, ms, code, error) VALUES ($1, $2, $3, $4, $5)`,
[log.site_id, log.status, log.ms, log.code, log.error]
)
}
export async function getRecentLogs(siteId: string, limit = 100): Promise<PingLog[]> {
const res = await pool.query(
`SELECT * FROM ping_logs WHERE site_id = $1 ORDER BY ts DESC LIMIT $2`,
[siteId, limit]
)
return res.rows.map(r => ({ ...r, ms: Number(r.ms), code: Number(r.code), ts: Number(r.ts) }))
}
export async function getUptimePercent(siteId: string, hours = 24): Promise<number> {
const since = Math.floor(Date.now() / 1000) - hours * 3600
const res = await pool.query(
`SELECT status FROM ping_logs WHERE site_id = $1 AND ts > $2`,
[siteId, since]
)
const rows = res.rows
if (rows.length === 0) return 100
const up = rows.filter(r => r.status === 'up').length
return Math.round((up / rows.length) * 1000) / 10
}
export async function getLastLog(siteId: string): Promise<PingLog | null> {
const res = await pool.query(
`SELECT * FROM ping_logs WHERE site_id = $1 ORDER BY ts DESC LIMIT 1`,
[siteId]
)
if (res.rows.length === 0) return null
const r = res.rows[0]
return { ...r, ms: Number(r.ms), code: Number(r.code), ts: Number(r.ts) }
}
export type Pageview = {
id: number
domain: string
path: string
referrer: string | null
country: string | null
city: string | null
ua: string | null
device: string | null
browser: string | null
os: string | null
visitor_id: string
ts: number
}
export async function insertPageview(pv: Omit<Pageview, 'id' | 'ts'>) {
await pool.query(
`INSERT INTO pageviews (domain, path, referrer, country, city, ua, device, browser, os, visitor_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[pv.domain, pv.path, pv.referrer, pv.country, pv.city, pv.ua, pv.device, pv.browser, pv.os, pv.visitor_id]
)
}
export async function getAnalyticsStats(domain: string, days = 30) {
const since = Math.floor(Date.now() / 1000) - days * 86400
const totalRes = await pool.query(`SELECT COUNT(*) as n FROM pageviews WHERE domain = $1 AND ts > $2`, [domain, since])
const total = Number(totalRes.rows[0].n)
const uniqueRes = await pool.query(`SELECT COUNT(DISTINCT visitor_id) as n FROM pageviews WHERE domain = $1 AND ts > $2`, [domain, since])
const unique = Number(uniqueRes.rows[0].n)
const topPagesRes = await pool.query(`
SELECT path, COUNT(*) as views
FROM pageviews WHERE domain = $1 AND ts > $2
GROUP BY path ORDER BY views DESC LIMIT 10
`, [domain, since])
const topPages = topPagesRes.rows.map(r => ({ path: r.path, views: Number(r.views) }))
const topReferrersRes = await pool.query(`
SELECT referrer, COUNT(*) as visits
FROM pageviews WHERE domain = $1 AND ts > $2 AND referrer IS NOT NULL AND referrer != ''
GROUP BY referrer ORDER BY visits DESC LIMIT 10
`, [domain, since])
const topReferrers = topReferrersRes.rows.map(r => ({ referrer: r.referrer, visits: Number(r.visits) }))
const byDeviceRes = await pool.query(`
SELECT device, COUNT(*) as n
FROM pageviews WHERE domain = $1 AND ts > $2 AND device IS NOT NULL
GROUP BY device ORDER BY n DESC
`, [domain, since])
const byDevice = byDeviceRes.rows.map(r => ({ device: r.device, n: Number(r.n) }))
const byBrowserRes = await pool.query(`
SELECT browser, COUNT(*) as n
FROM pageviews WHERE domain = $1 AND ts > $2 AND browser IS NOT NULL
GROUP BY browser ORDER BY n DESC LIMIT 6
`, [domain, since])
const byBrowser = byBrowserRes.rows.map(r => ({ browser: r.browser, n: Number(r.n) }))
const byOsRes = await pool.query(`
SELECT os, COUNT(*) as n
FROM pageviews WHERE domain = $1 AND ts > $2 AND os IS NOT NULL
GROUP BY os ORDER BY n DESC LIMIT 6
`, [domain, since])
const byOs = byOsRes.rows.map(r => ({ os: r.os, n: Number(r.n) }))
const byCountryRes = await pool.query(`
SELECT country, COUNT(*) as n
FROM pageviews WHERE domain = $1 AND ts > $2 AND country IS NOT NULL
GROUP BY country ORDER BY n DESC LIMIT 10
`, [domain, since])
const byCountry = byCountryRes.rows.map(r => ({ country: r.country, n: Number(r.n) }))
const dailyRes = await pool.query(`
SELECT to_char(to_timestamp(ts), 'YYYY-MM-DD') as day, COUNT(*) as views, COUNT(DISTINCT visitor_id) as visitors
FROM pageviews WHERE domain = $1 AND ts > $2
GROUP BY day ORDER BY day ASC
`, [domain, since])
const daily = dailyRes.rows.map(r => ({ day: r.day, views: Number(r.views), visitors: Number(r.visitors) }))
return { total, unique, topPages, topReferrers, byDevice, byBrowser, byOs, byCountry, daily }
}
export async function getAllDomains(): Promise<string[]> {
const res = await pool.query(`
SELECT domain FROM analytics_sites
UNION
SELECT DISTINCT domain FROM pageviews
ORDER BY domain
`)
return res.rows.map(r => r.domain)
}
export async function addAnalyticsSite(domain: string) {
await pool.query(
`INSERT INTO analytics_sites (domain) VALUES ($1) ON CONFLICT DO NOTHING`,
[domain]
)
}
export async function renameAnalyticsSite(oldDomain: string, newDomain: string) {
// Update both tables
await pool.query(`UPDATE analytics_sites SET domain = $1 WHERE domain = $2`, [newDomain, oldDomain])
await pool.query(`UPDATE pageviews SET domain = $1 WHERE domain = $2`, [newDomain, oldDomain])
}
export async function deleteAnalyticsSite(domain: string) {
await pool.query(`DELETE FROM analytics_sites WHERE domain = $1`, [domain])
await pool.query(`DELETE FROM pageviews WHERE domain = $1`, [domain])
}
export default pool