- 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
248 lines
8.2 KiB
TypeScript
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
|