로컬(개발)용과 운영용 설정분리
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# 환경 설정
|
||||
# development: 로컬 개발 (스케줄러 수동)
|
||||
# production: 운영 서버 (스케줄러 자동)
|
||||
NODE_ENV=development
|
||||
|
||||
# PostgreSQL 연결 정보
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=osolit_monitor
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=your_password
|
||||
|
||||
# 스케줄러 자동시작 여부 (true/false)
|
||||
# 생략 시: production=true, development=false
|
||||
AUTO_START_SCHEDULER=false
|
||||
@@ -1,6 +1,6 @@
|
||||
import { startServerScheduler } from '../../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
startServerScheduler()
|
||||
export default defineEventHandler(async () => {
|
||||
await startServerScheduler()
|
||||
return { success: true, message: 'Server scheduler started' }
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getServerSchedulerStatus } from '../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
return getServerSchedulerStatus()
|
||||
export default defineEventHandler(async () => {
|
||||
return await getServerSchedulerStatus()
|
||||
})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
import { query } from '../../../utils/db'
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
const db = getDb()
|
||||
|
||||
const targets = db.prepare(`
|
||||
export default defineEventHandler(async () => {
|
||||
const targets = await query(`
|
||||
SELECT * FROM server_targets
|
||||
ORDER BY target_id ASC
|
||||
`).all()
|
||||
`)
|
||||
|
||||
return targets
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDb } from '../../../utils/db'
|
||||
import { queryOne } from '../../../utils/db'
|
||||
import { refreshServerTimer } from '../../../utils/server-scheduler'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -12,18 +12,17 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const result = db.prepare(`
|
||||
const result = await queryOne<{ target_id: number }>(`
|
||||
INSERT INTO server_targets (server_name, server_ip, glances_url, is_active, collect_interval)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(server_name, server_ip, glances_url, is_active ? 1 : 0, collect_interval)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING target_id
|
||||
`, [server_name, server_ip, glances_url, is_active ? 1 : 0, collect_interval])
|
||||
|
||||
const targetId = result.lastInsertRowid as number
|
||||
const targetId = result?.target_id
|
||||
|
||||
// 스케줄러에 반영
|
||||
if (is_active) {
|
||||
refreshServerTimer(targetId)
|
||||
if (is_active && targetId) {
|
||||
await refreshServerTimer(targetId)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { initPrivnetTables } from '../utils/db'
|
||||
import { initPrivnetTables, shouldAutoStartScheduler } from '../utils/db'
|
||||
import { privnetScheduler } from '../utils/privnet-scheduler'
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
export default defineNitroPlugin(async (nitroApp) => {
|
||||
console.log('[Plugin] privnet-init starting...')
|
||||
|
||||
// DB 테이블 초기화
|
||||
initPrivnetTables()
|
||||
await initPrivnetTables()
|
||||
|
||||
// 스케줄러 자동 시작
|
||||
privnetScheduler.start()
|
||||
// 스케줄러 자동 시작 (환경에 따라)
|
||||
if (shouldAutoStartScheduler()) {
|
||||
privnetScheduler.start()
|
||||
console.log('[Plugin] privnet scheduler auto-started (production mode)')
|
||||
} else {
|
||||
console.log('[Plugin] privnet scheduler NOT started (development mode - use API to start)')
|
||||
}
|
||||
|
||||
// 서버 종료 시 클린업
|
||||
nitroApp.hooks.hook('close', () => {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { initPubnetTables } from '../utils/db'
|
||||
import { initPubnetTables, shouldAutoStartScheduler } from '../utils/db'
|
||||
import { pubnetScheduler } from '../utils/pubnet-scheduler'
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
export default defineNitroPlugin(async (nitroApp) => {
|
||||
console.log('[Plugin] pubnet-init starting...')
|
||||
|
||||
// DB 테이블 초기화
|
||||
initPubnetTables()
|
||||
await initPubnetTables()
|
||||
|
||||
// 스케줄러 자동 시작
|
||||
pubnetScheduler.start()
|
||||
// 스케줄러 자동 시작 (환경에 따라)
|
||||
if (shouldAutoStartScheduler()) {
|
||||
pubnetScheduler.start()
|
||||
console.log('[Plugin] pubnet scheduler auto-started (production mode)')
|
||||
} else {
|
||||
console.log('[Plugin] pubnet scheduler NOT started (development mode - use API to start)')
|
||||
}
|
||||
|
||||
// 서버 종료 시 클린업
|
||||
nitroApp.hooks.hook('close', () => {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { shouldAutoStartScheduler } from '../utils/db'
|
||||
import { startServerScheduler } from '../utils/server-scheduler'
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
// 서버 시작 시 스케줄러 자동 시작
|
||||
startServerScheduler()
|
||||
|
||||
console.log('[Server] Plugin initialized - scheduler auto-started')
|
||||
// 스케줄러 자동 시작 (환경에 따라)
|
||||
if (shouldAutoStartScheduler()) {
|
||||
startServerScheduler()
|
||||
console.log('[Server] Plugin initialized - scheduler auto-started (production mode)')
|
||||
} else {
|
||||
console.log('[Server] Plugin initialized - scheduler NOT started (development mode - use API to start)')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,44 +1,109 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { resolve } from 'path'
|
||||
import pg from 'pg'
|
||||
|
||||
// 싱글톤 DB 인스턴스
|
||||
let db: Database.Database | null = null
|
||||
const { Pool } = pg
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
const dbPath = resolve(process.cwd(), 'database/osolit-monitor.db')
|
||||
db = new Database(dbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
// 환경 설정
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
|
||||
// PostgreSQL 연결 풀 (싱글톤)
|
||||
let pool: pg.Pool | null = null
|
||||
|
||||
/**
|
||||
* PostgreSQL 연결 풀 가져오기
|
||||
*/
|
||||
export function getPool(): pg.Pool {
|
||||
if (!pool) {
|
||||
pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'osolit_monitor',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
})
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('[DB] Unexpected pool error:', err)
|
||||
})
|
||||
|
||||
console.log(`[DB] PostgreSQL pool created (${isDev ? 'development' : 'production'})`)
|
||||
}
|
||||
return db
|
||||
return pool
|
||||
}
|
||||
|
||||
export function initPubnetTables(): void {
|
||||
const db = getDb()
|
||||
/**
|
||||
* 단일 쿼리 실행 (자동 커넥션 반환)
|
||||
*/
|
||||
export async function query<T = any>(sql: string, params?: any[]): Promise<T[]> {
|
||||
const pool = getPool()
|
||||
const result = await pool.query(sql, params)
|
||||
return result.rows as T[]
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
/**
|
||||
* 단일 행 조회
|
||||
*/
|
||||
export async function queryOne<T = any>(sql: string, params?: any[]): Promise<T | null> {
|
||||
const rows = await query<T>(sql, params)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT/UPDATE/DELETE 실행
|
||||
*/
|
||||
export async function execute(sql: string, params?: any[]): Promise<number> {
|
||||
const pool = getPool()
|
||||
const result = await pool.query(sql, params)
|
||||
return result.rowCount || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 자동시작 여부
|
||||
*/
|
||||
export function shouldAutoStartScheduler(): boolean {
|
||||
// 환경변수로 명시적 설정된 경우
|
||||
const envValue = process.env.AUTO_START_SCHEDULER
|
||||
if (envValue !== undefined) {
|
||||
return envValue === 'true'
|
||||
}
|
||||
// 기본값: production=true, development=false
|
||||
return !isDev
|
||||
}
|
||||
|
||||
/**
|
||||
* pubnet 테이블 초기화 (PostgreSQL용)
|
||||
*/
|
||||
export async function initPubnetTables(): Promise<void> {
|
||||
const pool = getPool()
|
||||
|
||||
// pubnet_targets
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS pubnet_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
|
||||
updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
// pubnet_logs
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS pubnet_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id SERIAL PRIMARY KEY,
|
||||
target_id INTEGER NOT NULL,
|
||||
is_success INTEGER NOT NULL,
|
||||
checked_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
checked_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
|
||||
FOREIGN KEY (target_id) REFERENCES pubnet_targets(id)
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
// pubnet_status (싱글톤 패턴)
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS pubnet_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
current_index INTEGER DEFAULT 0,
|
||||
@@ -47,44 +112,51 @@ export function initPubnetTables(): void {
|
||||
last_target_id INTEGER,
|
||||
last_checked_at TEXT,
|
||||
scheduler_running INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
|
||||
)
|
||||
`)
|
||||
|
||||
const statusExists = db.prepare('SELECT COUNT(*) as cnt FROM pubnet_status').get() as { cnt: number }
|
||||
if (statusExists.cnt === 0) {
|
||||
db.prepare('INSERT INTO pubnet_status (id) VALUES (1)').run()
|
||||
// 상태 행 존재 확인 및 생성
|
||||
const statusExists = await queryOne<{ cnt: number }>('SELECT COUNT(*) as cnt FROM pubnet_status')
|
||||
if (!statusExists || statusExists.cnt === 0) {
|
||||
await pool.query('INSERT INTO pubnet_status (id) VALUES (1)')
|
||||
}
|
||||
|
||||
console.log('[DB] pubnet tables initialized')
|
||||
}
|
||||
|
||||
export function initPrivnetTables(): void {
|
||||
const db = getDb()
|
||||
/**
|
||||
* privnet 테이블 초기화 (PostgreSQL용)
|
||||
*/
|
||||
export async function initPrivnetTables(): Promise<void> {
|
||||
const pool = getPool()
|
||||
|
||||
db.exec(`
|
||||
// privnet_targets
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS privnet_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
|
||||
updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
// privnet_logs
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS privnet_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id SERIAL PRIMARY KEY,
|
||||
target_id INTEGER NOT NULL,
|
||||
is_success INTEGER NOT NULL,
|
||||
checked_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
checked_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
|
||||
FOREIGN KEY (target_id) REFERENCES privnet_targets(id)
|
||||
)
|
||||
`)
|
||||
|
||||
db.exec(`
|
||||
// privnet_status (싱글톤 패턴)
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS privnet_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
current_index INTEGER DEFAULT 0,
|
||||
@@ -93,21 +165,26 @@ export function initPrivnetTables(): void {
|
||||
last_target_id INTEGER,
|
||||
last_checked_at TEXT,
|
||||
scheduler_running INTEGER DEFAULT 0,
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
updated_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
|
||||
)
|
||||
`)
|
||||
|
||||
const statusExists = db.prepare('SELECT COUNT(*) as cnt FROM privnet_status').get() as { cnt: number }
|
||||
if (statusExists.cnt === 0) {
|
||||
db.prepare('INSERT INTO privnet_status (id) VALUES (1)').run()
|
||||
// 상태 행 존재 확인 및 생성
|
||||
const statusExists = await queryOne<{ cnt: number }>('SELECT COUNT(*) as cnt FROM privnet_status')
|
||||
if (!statusExists || statusExists.cnt === 0) {
|
||||
await pool.query('INSERT INTO privnet_status (id) VALUES (1)')
|
||||
}
|
||||
|
||||
console.log('[DB] privnet tables initialized')
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (db) {
|
||||
db.close()
|
||||
db = null
|
||||
/**
|
||||
* 연결 풀 종료
|
||||
*/
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end()
|
||||
pool = null
|
||||
console.log('[DB] PostgreSQL pool closed')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDb } from './db'
|
||||
import { query, queryOne, execute } from './db'
|
||||
|
||||
// 상수 정의
|
||||
const INTERVAL_SUCCESS = 5 * 60 * 1000 // 5분
|
||||
@@ -84,10 +84,10 @@ class PrivnetScheduler {
|
||||
const nextInterval = result.isSuccess ? INTERVAL_SUCCESS : INTERVAL_FAILURE
|
||||
|
||||
// 로그 저장
|
||||
this.saveLog(result)
|
||||
await this.saveLog(result)
|
||||
|
||||
// 상태 업데이트 (인덱스 증가)
|
||||
this.updateStatus(result, nextInterval)
|
||||
await this.updateStatus(result, nextInterval)
|
||||
|
||||
console.log(
|
||||
`[PrivnetScheduler] ${result.targetName} (${result.url}) - ` +
|
||||
@@ -109,21 +109,23 @@ class PrivnetScheduler {
|
||||
* 현재 타겟 URL 체크
|
||||
*/
|
||||
private async checkCurrentTarget(): Promise<CheckResult> {
|
||||
const db = getDb()
|
||||
|
||||
// 활성화된 타겟 목록 조회
|
||||
const targets = db.prepare(`
|
||||
const targets = await query<PrivnetTarget>(`
|
||||
SELECT * FROM privnet_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY id ASC
|
||||
`).all() as PrivnetTarget[]
|
||||
`)
|
||||
|
||||
if (targets.length === 0) {
|
||||
throw new Error('No active targets found')
|
||||
}
|
||||
|
||||
// 현재 인덱스 조회
|
||||
const status = db.prepare('SELECT * FROM privnet_status WHERE id = 1').get() as PrivnetStatus
|
||||
const status = await queryOne<PrivnetStatus>('SELECT * FROM privnet_status WHERE id = 1')
|
||||
if (!status) {
|
||||
throw new Error('Status not found')
|
||||
}
|
||||
|
||||
const currentIndex = status.current_index % targets.length
|
||||
const target = targets[currentIndex]
|
||||
|
||||
@@ -160,60 +162,50 @@ class PrivnetScheduler {
|
||||
/**
|
||||
* 체크 결과 로그 저장
|
||||
*/
|
||||
private saveLog(result: CheckResult): void {
|
||||
const db = getDb()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO privnet_logs (target_id, is_success)
|
||||
VALUES (@targetId, @isSuccess)
|
||||
`).run({
|
||||
targetId: result.targetId,
|
||||
isSuccess: result.isSuccess ? 1 : 0
|
||||
})
|
||||
private async saveLog(result: CheckResult): Promise<void> {
|
||||
await execute(
|
||||
`INSERT INTO privnet_logs (target_id, is_success) VALUES ($1, $2)`,
|
||||
[result.targetId, result.isSuccess ? 1 : 0]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 업데이트 (인덱스 순환)
|
||||
*/
|
||||
private updateStatus(result: CheckResult, nextInterval: number): void {
|
||||
const db = getDb()
|
||||
|
||||
private async updateStatus(result: CheckResult, nextInterval: number): Promise<void> {
|
||||
// 활성 타겟 수 조회
|
||||
const countResult = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM privnet_targets WHERE is_active = 1
|
||||
`).get() as { cnt: number }
|
||||
const countResult = await queryOne<{ cnt: string }>(
|
||||
`SELECT COUNT(*) as cnt FROM privnet_targets WHERE is_active = 1`
|
||||
)
|
||||
const totalCount = parseInt(countResult?.cnt || '0')
|
||||
|
||||
const status = db.prepare('SELECT current_index FROM privnet_status WHERE id = 1').get() as { current_index: number }
|
||||
const nextIndex = (status.current_index + 1) % countResult.cnt
|
||||
const status = await queryOne<{ current_index: number }>(
|
||||
'SELECT current_index FROM privnet_status WHERE id = 1'
|
||||
)
|
||||
const nextIndex = ((status?.current_index || 0) + 1) % totalCount
|
||||
|
||||
db.prepare(`
|
||||
await execute(`
|
||||
UPDATE privnet_status SET
|
||||
current_index = @nextIndex,
|
||||
check_interval = @checkInterval,
|
||||
is_healthy = @isHealthy,
|
||||
last_target_id = @lastTargetId,
|
||||
last_checked_at = datetime('now', 'localtime'),
|
||||
updated_at = datetime('now', 'localtime')
|
||||
current_index = $1,
|
||||
check_interval = $2,
|
||||
is_healthy = $3,
|
||||
last_target_id = $4,
|
||||
last_checked_at = to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
|
||||
updated_at = to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
|
||||
WHERE id = 1
|
||||
`).run({
|
||||
nextIndex,
|
||||
checkInterval: nextInterval,
|
||||
isHealthy: result.isSuccess ? 1 : 0,
|
||||
lastTargetId: result.targetId
|
||||
})
|
||||
`, [nextIndex, nextInterval, result.isSuccess ? 1 : 0, result.targetId])
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 실행 상태 업데이트
|
||||
*/
|
||||
private updateSchedulerRunning(running: number): void {
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
private async updateSchedulerRunning(running: number): Promise<void> {
|
||||
await execute(`
|
||||
UPDATE privnet_status SET
|
||||
scheduler_running = @running,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
scheduler_running = $1,
|
||||
updated_at = to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
|
||||
WHERE id = 1
|
||||
`).run({ running })
|
||||
`, [running])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDb } from './db'
|
||||
import { query, queryOne, execute } from './db'
|
||||
|
||||
// 상수 정의
|
||||
const INTERVAL_SUCCESS = 5 * 60 * 1000 // 5분
|
||||
@@ -84,10 +84,10 @@ class PubnetScheduler {
|
||||
const nextInterval = result.isSuccess ? INTERVAL_SUCCESS : INTERVAL_FAILURE
|
||||
|
||||
// 로그 저장 (1개만)
|
||||
this.saveLog(result)
|
||||
await this.saveLog(result)
|
||||
|
||||
// 상태 업데이트 (인덱스 +1)
|
||||
this.updateStatus(result, nextInterval)
|
||||
await this.updateStatus(result, nextInterval)
|
||||
|
||||
console.log(
|
||||
`[PubnetScheduler] ${result.targetName} (${result.url}) - ` +
|
||||
@@ -109,21 +109,22 @@ class PubnetScheduler {
|
||||
* 현재 타겟 1개 체크
|
||||
*/
|
||||
private async checkCurrentTargets(): Promise<CheckResult> {
|
||||
const db = getDb()
|
||||
|
||||
// 활성화된 타겟 목록 조회
|
||||
const targets = db.prepare(`
|
||||
const targets = await query<PubnetTarget>(`
|
||||
SELECT * FROM pubnet_targets
|
||||
WHERE is_active = 1
|
||||
ORDER BY id ASC
|
||||
`).all() as PubnetTarget[]
|
||||
`)
|
||||
|
||||
if (targets.length === 0) {
|
||||
throw new Error('No active targets found')
|
||||
}
|
||||
|
||||
// 현재 인덱스 조회
|
||||
const status = db.prepare('SELECT * FROM pubnet_status WHERE id = 1').get() as PubnetStatus
|
||||
const status = await queryOne<PubnetStatus>('SELECT * FROM pubnet_status WHERE id = 1')
|
||||
if (!status) {
|
||||
throw new Error('Status not found')
|
||||
}
|
||||
|
||||
// 1개 타겟 선택
|
||||
const idx = status.current_index % targets.length
|
||||
@@ -160,62 +161,52 @@ class PubnetScheduler {
|
||||
/**
|
||||
* 체크 결과 로그 저장
|
||||
*/
|
||||
private saveLog(result: CheckResult): void {
|
||||
const db = getDb()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO pubnet_logs (target_id, is_success)
|
||||
VALUES (@targetId, @isSuccess)
|
||||
`).run({
|
||||
targetId: result.targetId,
|
||||
isSuccess: result.isSuccess ? 1 : 0
|
||||
})
|
||||
private async saveLog(result: CheckResult): Promise<void> {
|
||||
await execute(
|
||||
`INSERT INTO pubnet_logs (target_id, is_success) VALUES ($1, $2)`,
|
||||
[result.targetId, result.isSuccess ? 1 : 0]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 업데이트 (인덱스 +1 순환)
|
||||
*/
|
||||
private updateStatus(result: CheckResult, nextInterval: number): void {
|
||||
const db = getDb()
|
||||
|
||||
private async updateStatus(result: CheckResult, nextInterval: number): Promise<void> {
|
||||
// 활성 타겟 수 조회
|
||||
const countResult = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM pubnet_targets WHERE is_active = 1
|
||||
`).get() as { cnt: number }
|
||||
const countResult = await queryOne<{ cnt: string }>(
|
||||
`SELECT COUNT(*) as cnt FROM pubnet_targets WHERE is_active = 1`
|
||||
)
|
||||
const totalCount = parseInt(countResult?.cnt || '0')
|
||||
|
||||
const status = db.prepare('SELECT current_index FROM pubnet_status WHERE id = 1').get() as { current_index: number }
|
||||
const status = await queryOne<{ current_index: number }>(
|
||||
'SELECT current_index FROM pubnet_status WHERE id = 1'
|
||||
)
|
||||
|
||||
// 인덱스 +1 (순환)
|
||||
const nextIndex = (status.current_index + 1) % countResult.cnt
|
||||
const nextIndex = ((status?.current_index || 0) + 1) % totalCount
|
||||
|
||||
db.prepare(`
|
||||
await execute(`
|
||||
UPDATE pubnet_status SET
|
||||
current_index = @nextIndex,
|
||||
check_interval = @checkInterval,
|
||||
is_healthy = @isHealthy,
|
||||
last_target_id = @lastTargetId,
|
||||
last_checked_at = datetime('now', 'localtime'),
|
||||
updated_at = datetime('now', 'localtime')
|
||||
current_index = $1,
|
||||
check_interval = $2,
|
||||
is_healthy = $3,
|
||||
last_target_id = $4,
|
||||
last_checked_at = to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS'),
|
||||
updated_at = to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
|
||||
WHERE id = 1
|
||||
`).run({
|
||||
nextIndex,
|
||||
checkInterval: nextInterval,
|
||||
isHealthy: result.isSuccess ? 1 : 0,
|
||||
lastTargetId: result.targetId
|
||||
})
|
||||
`, [nextIndex, nextInterval, result.isSuccess ? 1 : 0, result.targetId])
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 실행 상태 업데이트
|
||||
*/
|
||||
private updateSchedulerRunning(running: number): void {
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
private async updateSchedulerRunning(running: number): Promise<void> {
|
||||
await execute(`
|
||||
UPDATE pubnet_status SET
|
||||
scheduler_running = @running,
|
||||
updated_at = datetime('now', 'localtime')
|
||||
scheduler_running = $1,
|
||||
updated_at = to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
|
||||
WHERE id = 1
|
||||
`).run({ running })
|
||||
`, [running])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDb } from './db'
|
||||
import { query, queryOne, execute, getPool } from './db'
|
||||
|
||||
interface ServerTarget {
|
||||
target_id: number
|
||||
@@ -60,20 +60,19 @@ async function detectApiVersion(baseUrl: string, serverName: string): Promise<st
|
||||
|
||||
// 이상감지 실행
|
||||
async function detectAnomalies(targetId: number, serverName: string) {
|
||||
const db = getDb()
|
||||
const now = timestamp()
|
||||
|
||||
try {
|
||||
// === 단기 변화율 감지 ===
|
||||
const SHORT_TERM_THRESHOLD = 30
|
||||
|
||||
const snapshots = db.prepare(`
|
||||
const snapshots = await query<any>(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
WHERE target_id = $1 AND is_online = 1
|
||||
ORDER BY collected_at DESC
|
||||
LIMIT 20
|
||||
`).all(targetId) as any[]
|
||||
`, [targetId])
|
||||
|
||||
if (snapshots.length >= 4) {
|
||||
const half = Math.floor(snapshots.length / 2)
|
||||
@@ -88,39 +87,46 @@ async function detectAnomalies(targetId: number, serverName: string) {
|
||||
const cpuChange = prevCpuAvg > 1 ? ((currCpuAvg - prevCpuAvg) / prevCpuAvg) * 100 : currCpuAvg - prevCpuAvg
|
||||
const memChange = prevMemAvg > 1 ? ((currMemAvg - prevMemAvg) / prevMemAvg) * 100 : currMemAvg - prevMemAvg
|
||||
|
||||
// 중복 체크용
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = ? AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
// CPU 단기 변화율 체크
|
||||
if (Math.abs(cpuChange) >= SHORT_TERM_THRESHOLD) {
|
||||
const level = Math.abs(cpuChange) >= 100 ? 'danger' : 'warning'
|
||||
const direction = cpuChange >= 0 ? '증가' : '감소'
|
||||
const message = `CPU ${direction} 감지 (${prevCpuAvg.toFixed(1)}% → ${currCpuAvg.toFixed(1)}%)`
|
||||
const recentExists = await queryOne(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = $1 AND detect_type = 'short-term' AND metric = 'CPU'
|
||||
AND detected_at > NOW() - INTERVAL '1 minute'
|
||||
LIMIT 1
|
||||
`, [targetId])
|
||||
|
||||
if (!recentLogExists.get(targetId, 'short-term', 'CPU')) {
|
||||
insertLog.run(targetId, serverName, 'short-term', 'CPU', level, currCpuAvg, cpuChange, message)
|
||||
if (!recentExists) {
|
||||
const level = Math.abs(cpuChange) >= 100 ? 'danger' : 'warning'
|
||||
const direction = cpuChange >= 0 ? '증가' : '감소'
|
||||
const message = `CPU ${direction} 감지 (${prevCpuAvg.toFixed(1)}% → ${currCpuAvg.toFixed(1)}%)`
|
||||
|
||||
await execute(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES ($1, $2, 'short-term', 'CPU', $3, $4, $5, $6)
|
||||
`, [targetId, serverName, level, currCpuAvg, cpuChange, message])
|
||||
console.log(`[${now}] 🚨 [${serverName}] 단기변화율 이상감지: CPU ${cpuChange.toFixed(1)}% (${level})`)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory 단기 변화율 체크
|
||||
if (Math.abs(memChange) >= SHORT_TERM_THRESHOLD) {
|
||||
const level = Math.abs(memChange) >= 100 ? 'danger' : 'warning'
|
||||
const direction = memChange >= 0 ? '증가' : '감소'
|
||||
const message = `Memory ${direction} 감지 (${prevMemAvg.toFixed(1)}% → ${currMemAvg.toFixed(1)}%)`
|
||||
const recentExists = await queryOne(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = $1 AND detect_type = 'short-term' AND metric = 'Memory'
|
||||
AND detected_at > NOW() - INTERVAL '1 minute'
|
||||
LIMIT 1
|
||||
`, [targetId])
|
||||
|
||||
if (!recentLogExists.get(targetId, 'short-term', 'Memory')) {
|
||||
insertLog.run(targetId, serverName, 'short-term', 'Memory', level, currMemAvg, memChange, message)
|
||||
if (!recentExists) {
|
||||
const level = Math.abs(memChange) >= 100 ? 'danger' : 'warning'
|
||||
const direction = memChange >= 0 ? '증가' : '감소'
|
||||
const message = `Memory ${direction} 감지 (${prevMemAvg.toFixed(1)}% → ${currMemAvg.toFixed(1)}%)`
|
||||
|
||||
await execute(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES ($1, $2, 'short-term', 'Memory', $3, $4, $5, $6)
|
||||
`, [targetId, serverName, level, currMemAvg, memChange, message])
|
||||
console.log(`[${now}] 🚨 [${serverName}] 단기변화율 이상감지: Memory ${memChange.toFixed(1)}% (${level})`)
|
||||
}
|
||||
}
|
||||
@@ -130,13 +136,13 @@ async function detectAnomalies(targetId: number, serverName: string) {
|
||||
const WARNING_Z = 2.0
|
||||
const DANGER_Z = 3.0
|
||||
|
||||
const hourSnapshots = db.prepare(`
|
||||
const hourSnapshots = await query<any>(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
AND collected_at >= datetime('now', '-1 hour', 'localtime')
|
||||
WHERE target_id = $1 AND is_online = 1
|
||||
AND collected_at >= NOW() - INTERVAL '1 hour'
|
||||
ORDER BY collected_at DESC
|
||||
`).all(targetId) as any[]
|
||||
`, [targetId])
|
||||
|
||||
if (hourSnapshots.length >= 10) {
|
||||
const current = hourSnapshots[0]
|
||||
@@ -157,38 +163,46 @@ async function detectAnomalies(targetId: number, serverName: string) {
|
||||
const cpuZscore = cpuStd > 0.1 ? (currCpu - cpuAvg) / cpuStd : 0
|
||||
const memZscore = memStd > 0.1 ? (currMem - memAvg) / memStd : 0
|
||||
|
||||
const recentLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'zscore' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'zscore', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
// CPU Z-Score 체크
|
||||
if (Math.abs(cpuZscore) >= WARNING_Z) {
|
||||
const level = Math.abs(cpuZscore) >= DANGER_Z ? 'danger' : 'warning'
|
||||
const direction = cpuZscore >= 0 ? '높음' : '낮음'
|
||||
const message = `CPU 평균 대비 ${Math.abs(cpuZscore).toFixed(1)}σ ${direction} (평균: ${cpuAvg.toFixed(1)}%, 현재: ${currCpu.toFixed(1)}%)`
|
||||
const recentExists = await queryOne(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = $1 AND detect_type = 'zscore' AND metric = 'CPU'
|
||||
AND detected_at > NOW() - INTERVAL '1 minute'
|
||||
LIMIT 1
|
||||
`, [targetId])
|
||||
|
||||
if (!recentLogExists.get(targetId, 'CPU')) {
|
||||
insertLog.run(targetId, serverName, 'CPU', level, currCpu, cpuZscore, message)
|
||||
if (!recentExists) {
|
||||
const level = Math.abs(cpuZscore) >= DANGER_Z ? 'danger' : 'warning'
|
||||
const direction = cpuZscore >= 0 ? '높음' : '낮음'
|
||||
const message = `CPU 평균 대비 ${Math.abs(cpuZscore).toFixed(1)}σ ${direction} (평균: ${cpuAvg.toFixed(1)}%, 현재: ${currCpu.toFixed(1)}%)`
|
||||
|
||||
await execute(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES ($1, $2, 'zscore', 'CPU', $3, $4, $5, $6)
|
||||
`, [targetId, serverName, level, currCpu, cpuZscore, message])
|
||||
console.log(`[${now}] 🚨 [${serverName}] Z-Score 이상감지: CPU Z=${cpuZscore.toFixed(2)} (${level})`)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory Z-Score 체크
|
||||
if (Math.abs(memZscore) >= WARNING_Z) {
|
||||
const level = Math.abs(memZscore) >= DANGER_Z ? 'danger' : 'warning'
|
||||
const direction = memZscore >= 0 ? '높음' : '낮음'
|
||||
const message = `Memory 평균 대비 ${Math.abs(memZscore).toFixed(1)}σ ${direction} (평균: ${memAvg.toFixed(1)}%, 현재: ${currMem.toFixed(1)}%)`
|
||||
const recentExists = await queryOne(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = $1 AND detect_type = 'zscore' AND metric = 'Memory'
|
||||
AND detected_at > NOW() - INTERVAL '1 minute'
|
||||
LIMIT 1
|
||||
`, [targetId])
|
||||
|
||||
if (!recentLogExists.get(targetId, 'Memory')) {
|
||||
insertLog.run(targetId, serverName, 'Memory', level, currMem, memZscore, message)
|
||||
if (!recentExists) {
|
||||
const level = Math.abs(memZscore) >= DANGER_Z ? 'danger' : 'warning'
|
||||
const direction = memZscore >= 0 ? '높음' : '낮음'
|
||||
const message = `Memory 평균 대비 ${Math.abs(memZscore).toFixed(1)}σ ${direction} (평균: ${memAvg.toFixed(1)}%, 현재: ${currMem.toFixed(1)}%)`
|
||||
|
||||
await execute(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES ($1, $2, 'zscore', 'Memory', $3, $4, $5, $6)
|
||||
`, [targetId, serverName, level, currMem, memZscore, message])
|
||||
console.log(`[${now}] 🚨 [${serverName}] Z-Score 이상감지: Memory Z=${memZscore.toFixed(2)} (${level})`)
|
||||
}
|
||||
}
|
||||
@@ -201,25 +215,25 @@ async function detectAnomalies(targetId: number, serverName: string) {
|
||||
const isWeekend = currentDayOfWeek === 0 || currentDayOfWeek === 6
|
||||
const dayType = isWeekend ? 'weekend' : 'weekday'
|
||||
|
||||
const baselineData = db.prepare(`
|
||||
const baselineData = await query<any>(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
AND collected_at >= datetime('now', '-14 days', 'localtime')
|
||||
AND strftime('%H', collected_at) = ?
|
||||
WHERE target_id = $1 AND is_online = 1
|
||||
AND collected_at >= NOW() - INTERVAL '14 days'
|
||||
AND EXTRACT(HOUR FROM collected_at) = $2
|
||||
AND (
|
||||
(? = 'weekend' AND strftime('%w', collected_at) IN ('0', '6'))
|
||||
($3 = 'weekend' AND EXTRACT(DOW FROM collected_at) IN (0, 6))
|
||||
OR
|
||||
(? = 'weekday' AND strftime('%w', collected_at) NOT IN ('0', '6'))
|
||||
($3 = 'weekday' AND EXTRACT(DOW FROM collected_at) NOT IN (0, 6))
|
||||
)
|
||||
`).all(targetId, currentHour.toString().padStart(2, '0'), dayType, dayType) as any[]
|
||||
`, [targetId, currentHour, dayType])
|
||||
|
||||
const currentSnapshot = db.prepare(`
|
||||
const currentSnapshot = await queryOne<any>(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
WHERE target_id = $1 AND is_online = 1
|
||||
ORDER BY collected_at DESC LIMIT 1
|
||||
`).get(targetId) as any
|
||||
`, [targetId])
|
||||
|
||||
if (baselineData.length >= 5 && currentSnapshot) {
|
||||
const currCpu = currentSnapshot.cpu_percent ?? 0
|
||||
@@ -239,38 +253,46 @@ async function detectAnomalies(targetId: number, serverName: string) {
|
||||
const cpuDeviation = cpuStd > 0.1 ? (currCpu - cpuAvg) / cpuStd : 0
|
||||
const memDeviation = memStd > 0.1 ? (currMem - memAvg) / memStd : 0
|
||||
|
||||
const baselineLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'baseline' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const baselineInsertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'baseline', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
if (Math.abs(cpuDeviation) >= DEVIATION_THRESHOLD) {
|
||||
const level = Math.abs(cpuDeviation) >= 3.0 ? 'danger' : 'warning'
|
||||
const direction = cpuDeviation >= 0 ? '높음' : '낮음'
|
||||
const dayLabel = isWeekend ? '주말' : '평일'
|
||||
const message = `CPU ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(cpuDeviation).toFixed(1)}σ ${direction}`
|
||||
const recentExists = await queryOne(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = $1 AND detect_type = 'baseline' AND metric = 'CPU'
|
||||
AND detected_at > NOW() - INTERVAL '1 minute'
|
||||
LIMIT 1
|
||||
`, [targetId])
|
||||
|
||||
if (!baselineLogExists.get(targetId, 'CPU')) {
|
||||
baselineInsertLog.run(targetId, serverName, 'CPU', level, currCpu, cpuDeviation, message)
|
||||
if (!recentExists) {
|
||||
const level = Math.abs(cpuDeviation) >= 3.0 ? 'danger' : 'warning'
|
||||
const direction = cpuDeviation >= 0 ? '높음' : '낮음'
|
||||
const dayLabel = isWeekend ? '주말' : '평일'
|
||||
const message = `CPU ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(cpuDeviation).toFixed(1)}σ ${direction}`
|
||||
|
||||
await execute(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES ($1, $2, 'baseline', 'CPU', $3, $4, $5, $6)
|
||||
`, [targetId, serverName, level, currCpu, cpuDeviation, message])
|
||||
console.log(`[${now}] 🚨 [${serverName}] 베이스라인 이상감지: CPU σ=${cpuDeviation.toFixed(2)} (${level})`)
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.abs(memDeviation) >= DEVIATION_THRESHOLD) {
|
||||
const level = Math.abs(memDeviation) >= 3.0 ? 'danger' : 'warning'
|
||||
const direction = memDeviation >= 0 ? '높음' : '낮음'
|
||||
const dayLabel = isWeekend ? '주말' : '평일'
|
||||
const message = `Memory ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(memDeviation).toFixed(1)}σ ${direction}`
|
||||
const recentExists = await queryOne(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = $1 AND detect_type = 'baseline' AND metric = 'Memory'
|
||||
AND detected_at > NOW() - INTERVAL '1 minute'
|
||||
LIMIT 1
|
||||
`, [targetId])
|
||||
|
||||
if (!baselineLogExists.get(targetId, 'Memory')) {
|
||||
baselineInsertLog.run(targetId, serverName, 'Memory', level, currMem, memDeviation, message)
|
||||
if (!recentExists) {
|
||||
const level = Math.abs(memDeviation) >= 3.0 ? 'danger' : 'warning'
|
||||
const direction = memDeviation >= 0 ? '높음' : '낮음'
|
||||
const dayLabel = isWeekend ? '주말' : '평일'
|
||||
const message = `Memory ${dayLabel} ${currentHour}시 베이스라인 대비 ${Math.abs(memDeviation).toFixed(1)}σ ${direction}`
|
||||
|
||||
await execute(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES ($1, $2, 'baseline', 'Memory', $3, $4, $5, $6)
|
||||
`, [targetId, serverName, level, currMem, memDeviation, message])
|
||||
console.log(`[${now}] 🚨 [${serverName}] 베이스라인 이상감지: Memory σ=${memDeviation.toFixed(2)} (${level})`)
|
||||
}
|
||||
}
|
||||
@@ -280,13 +302,13 @@ async function detectAnomalies(targetId: number, serverName: string) {
|
||||
const SLOPE_THRESHOLD = 0.5
|
||||
const WINDOW_MINUTES = 30
|
||||
|
||||
const trendSnapshots = db.prepare(`
|
||||
const trendSnapshots = await query<any>(`
|
||||
SELECT cpu_percent, memory_percent
|
||||
FROM server_snapshots
|
||||
WHERE target_id = ? AND is_online = 1
|
||||
AND collected_at >= datetime('now', '-${WINDOW_MINUTES} minutes', 'localtime')
|
||||
WHERE target_id = $1 AND is_online = 1
|
||||
AND collected_at >= NOW() - INTERVAL '${WINDOW_MINUTES} minutes'
|
||||
ORDER BY collected_at ASC
|
||||
`).all(targetId) as any[]
|
||||
`, [targetId])
|
||||
|
||||
if (trendSnapshots.length >= 10) {
|
||||
const n = trendSnapshots.length
|
||||
@@ -315,34 +337,42 @@ async function detectAnomalies(targetId: number, serverName: string) {
|
||||
const cpuResult = calcSlope(trendSnapshots.map(s => s.cpu_percent ?? 0))
|
||||
const memResult = calcSlope(trendSnapshots.map(s => s.memory_percent ?? 0))
|
||||
|
||||
const trendLogExists = db.prepare(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = ? AND detect_type = 'trend' AND metric = ?
|
||||
AND detected_at > datetime('now', '-1 minute', 'localtime')
|
||||
LIMIT 1
|
||||
`)
|
||||
|
||||
const trendInsertLog = db.prepare(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES (?, ?, 'trend', ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
if (cpuResult.slope >= SLOPE_THRESHOLD && cpuResult.r2 >= 0.3) {
|
||||
const level = cpuResult.slope >= 1.0 ? 'danger' : 'warning'
|
||||
const message = `CPU 지속 상승 중 (분당 +${cpuResult.slope.toFixed(2)}%, R²=${cpuResult.r2.toFixed(2)})`
|
||||
const recentExists = await queryOne(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = $1 AND detect_type = 'trend' AND metric = 'CPU'
|
||||
AND detected_at > NOW() - INTERVAL '1 minute'
|
||||
LIMIT 1
|
||||
`, [targetId])
|
||||
|
||||
if (!trendLogExists.get(targetId, 'CPU')) {
|
||||
trendInsertLog.run(targetId, serverName, 'CPU', level, currCpu, cpuResult.slope, message)
|
||||
if (!recentExists) {
|
||||
const level = cpuResult.slope >= 1.0 ? 'danger' : 'warning'
|
||||
const message = `CPU 지속 상승 중 (분당 +${cpuResult.slope.toFixed(2)}%, R²=${cpuResult.r2.toFixed(2)})`
|
||||
|
||||
await execute(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES ($1, $2, 'trend', 'CPU', $3, $4, $5, $6)
|
||||
`, [targetId, serverName, level, currCpu, cpuResult.slope, message])
|
||||
console.log(`[${now}] 🚨 [${serverName}] 추세 이상감지: CPU +${cpuResult.slope.toFixed(2)}/분 (${level})`)
|
||||
}
|
||||
}
|
||||
|
||||
if (memResult.slope >= SLOPE_THRESHOLD && memResult.r2 >= 0.3) {
|
||||
const level = memResult.slope >= 1.0 ? 'danger' : 'warning'
|
||||
const message = `Memory 지속 상승 중 (분당 +${memResult.slope.toFixed(2)}%, R²=${memResult.r2.toFixed(2)})`
|
||||
const recentExists = await queryOne(`
|
||||
SELECT 1 FROM anomaly_logs
|
||||
WHERE target_id = $1 AND detect_type = 'trend' AND metric = 'Memory'
|
||||
AND detected_at > NOW() - INTERVAL '1 minute'
|
||||
LIMIT 1
|
||||
`, [targetId])
|
||||
|
||||
if (!trendLogExists.get(targetId, 'Memory')) {
|
||||
trendInsertLog.run(targetId, serverName, 'Memory', level, currMem, memResult.slope, message)
|
||||
if (!recentExists) {
|
||||
const level = memResult.slope >= 1.0 ? 'danger' : 'warning'
|
||||
const message = `Memory 지속 상승 중 (분당 +${memResult.slope.toFixed(2)}%, R²=${memResult.r2.toFixed(2)})`
|
||||
|
||||
await execute(`
|
||||
INSERT INTO anomaly_logs (target_id, server_name, detect_type, metric, level, current_value, threshold_value, message)
|
||||
VALUES ($1, $2, 'trend', 'Memory', $3, $4, $5, $6)
|
||||
`, [targetId, serverName, level, currMem, memResult.slope, message])
|
||||
console.log(`[${now}] 🚨 [${serverName}] 추세 이상감지: Memory +${memResult.slope.toFixed(2)}/분 (${level})`)
|
||||
}
|
||||
}
|
||||
@@ -353,9 +383,9 @@ async function detectAnomalies(targetId: number, serverName: string) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 서버 데이터 수집
|
||||
async function collectServerData(target: ServerTarget) {
|
||||
const db = getDb()
|
||||
const now = timestamp()
|
||||
|
||||
console.log(`[${now}] 📡 [${target.server_name}] 수집 시작... (${target.glances_url})`)
|
||||
@@ -373,10 +403,10 @@ async function collectServerData(target: ServerTarget) {
|
||||
|
||||
if (!apiVersion) {
|
||||
console.log(`[${now}] ❌ [${target.server_name}] 연결 실패 - Offline 기록`)
|
||||
db.prepare(`
|
||||
await execute(`
|
||||
INSERT INTO server_snapshots (target_id, is_online, collected_at)
|
||||
VALUES (?, 0, ?)
|
||||
`).run(target.target_id, now)
|
||||
VALUES ($1, 0, $2)
|
||||
`, [target.target_id, now])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -403,10 +433,10 @@ async function collectServerData(target: ServerTarget) {
|
||||
// 캐시 클리어 후 재시도 위해
|
||||
apiVersionCache.delete(target.target_id)
|
||||
console.log(`[${now}] ❌ [${target.server_name}] 연결 실패 - Offline 기록`)
|
||||
db.prepare(`
|
||||
await execute(`
|
||||
INSERT INTO server_snapshots (target_id, is_online, collected_at)
|
||||
VALUES (?, 0, ?)
|
||||
`).run(target.target_id, now)
|
||||
VALUES ($1, 0, $2)
|
||||
`, [target.target_id, now])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -423,16 +453,17 @@ async function collectServerData(target: ServerTarget) {
|
||||
cpuTemp = tempSensor?.value ?? null
|
||||
}
|
||||
|
||||
// server_snapshots INSERT (api_version 포함)
|
||||
// server_snapshots INSERT
|
||||
console.log(`[${now}] 💾 [${target.server_name}] snapshot 저장 (API v${apiVersion}, CPU: ${cpu?.total?.toFixed(1) || 0}%, MEM: ${mem?.percent?.toFixed(1) || 0}%, TEMP: ${cpuTemp ?? 'N/A'}°C, LOAD: ${quicklook?.load?.toFixed(1) ?? 'N/A'}%)`)
|
||||
db.prepare(`
|
||||
|
||||
await execute(`
|
||||
INSERT INTO server_snapshots (
|
||||
target_id, os_name, os_version, host_name, uptime_seconds, uptime_str, ip_address,
|
||||
cpu_name, cpu_count, cpu_percent, memory_total, memory_used, memory_percent,
|
||||
swap_total, swap_used, swap_percent, is_online, api_version, cpu_temp,
|
||||
load_1, load_5, load_15, load_percent, collected_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
||||
`, [
|
||||
target.target_id,
|
||||
system?.os_name || system?.linux_distro || null,
|
||||
system?.os_version || null,
|
||||
@@ -457,19 +488,18 @@ async function collectServerData(target: ServerTarget) {
|
||||
load?.min15 ?? null,
|
||||
quicklook?.load ?? null,
|
||||
now
|
||||
)
|
||||
])
|
||||
|
||||
// server_disks INSERT (배열)
|
||||
if (Array.isArray(fs) && fs.length > 0) {
|
||||
console.log(`[${now}] 💾 [${target.server_name}] disk 저장 (${fs.length}개 파티션)`)
|
||||
const diskStmt = db.prepare(`
|
||||
INSERT INTO server_disks (
|
||||
target_id, device_name, mount_point, fs_type,
|
||||
disk_total, disk_used, disk_percent, collected_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const disk of fs) {
|
||||
diskStmt.run(
|
||||
await execute(`
|
||||
INSERT INTO server_disks (
|
||||
target_id, device_name, mount_point, fs_type,
|
||||
disk_total, disk_used, disk_percent, collected_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, [
|
||||
target.target_id,
|
||||
disk.device_name || null,
|
||||
disk.mnt_point || null,
|
||||
@@ -478,22 +508,21 @@ async function collectServerData(target: ServerTarget) {
|
||||
disk.used || null,
|
||||
disk.percent || null,
|
||||
now
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// server_containers INSERT (배열)
|
||||
if (Array.isArray(docker) && docker.length > 0) {
|
||||
console.log(`[${now}] 🐳 [${target.server_name}] container 저장 (${docker.length}개 컨테이너)`)
|
||||
const containerStmt = db.prepare(`
|
||||
INSERT INTO server_containers (
|
||||
target_id, docker_id, container_name, container_image,
|
||||
container_status, cpu_percent, memory_usage, memory_limit,
|
||||
memory_percent, uptime, network_rx, network_tx, collected_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const container of docker) {
|
||||
containerStmt.run(
|
||||
await execute(`
|
||||
INSERT INTO server_containers (
|
||||
target_id, docker_id, container_name, container_image,
|
||||
container_status, cpu_percent, memory_usage, memory_limit,
|
||||
memory_percent, uptime, network_rx, network_tx, collected_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
`, [
|
||||
target.target_id,
|
||||
container.id || null,
|
||||
container.name || null,
|
||||
@@ -509,22 +538,21 @@ async function collectServerData(target: ServerTarget) {
|
||||
container.network?.rx ?? container.network_rx ?? null,
|
||||
container.network?.tx ?? container.network_tx ?? null,
|
||||
now
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// server_networks INSERT (배열)
|
||||
if (Array.isArray(network) && network.length > 0) {
|
||||
console.log(`[${now}] 🌐 [${target.server_name}] network 저장 (${network.length}개 인터페이스)`)
|
||||
const netStmt = db.prepare(`
|
||||
INSERT INTO server_networks (
|
||||
target_id, interface_name, bytes_recv, bytes_sent,
|
||||
packets_recv, packets_sent, speed_recv, speed_sent,
|
||||
is_up, collected_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const iface of network) {
|
||||
netStmt.run(
|
||||
await execute(`
|
||||
INSERT INTO server_networks (
|
||||
target_id, interface_name, bytes_recv, bytes_sent,
|
||||
packets_recv, packets_sent, speed_recv, speed_sent,
|
||||
is_up, collected_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`, [
|
||||
target.target_id,
|
||||
iface.interface_name || null,
|
||||
iface.bytes_recv || iface.cumulative_rx || null,
|
||||
@@ -535,7 +563,7 @@ async function collectServerData(target: ServerTarget) {
|
||||
iface.bytes_sent_rate_per_sec || iface.tx || iface.bytes_sent_rate || null,
|
||||
iface.is_up ? 1 : 0,
|
||||
now
|
||||
)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,13 +579,14 @@ async function collectServerData(target: ServerTarget) {
|
||||
apiVersionCache.delete(target.target_id)
|
||||
|
||||
// 오프라인 기록
|
||||
db.prepare(`
|
||||
await execute(`
|
||||
INSERT INTO server_snapshots (target_id, is_online, collected_at)
|
||||
VALUES (?, 0, ?)
|
||||
`).run(target.target_id, now)
|
||||
VALUES ($1, 0, $2)
|
||||
`, [target.target_id, now])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 서버별 타이머 시작
|
||||
function startServerTimer(target: ServerTarget) {
|
||||
const now = timestamp()
|
||||
@@ -591,7 +620,7 @@ function stopServerTimer(targetId: number) {
|
||||
}
|
||||
|
||||
// 스케줄러 시작 (모든 활성 서버)
|
||||
export function startServerScheduler() {
|
||||
export async function startServerScheduler() {
|
||||
const now = timestamp()
|
||||
|
||||
if (isRunning) {
|
||||
@@ -601,10 +630,9 @@ export function startServerScheduler() {
|
||||
|
||||
console.log(`[${now}] 🚀 [Server Scheduler] ========== 스케줄러 시작 ==========`)
|
||||
|
||||
const db = getDb()
|
||||
const targets = db.prepare(`
|
||||
const targets = await query<ServerTarget>(`
|
||||
SELECT * FROM server_targets WHERE is_active = 1
|
||||
`).all() as ServerTarget[]
|
||||
`)
|
||||
|
||||
console.log(`[${now}] 📋 [Server Scheduler] 활성 서버: ${targets.length}개`)
|
||||
|
||||
@@ -632,12 +660,11 @@ export function stopServerScheduler() {
|
||||
}
|
||||
|
||||
// 스케줄러 상태
|
||||
export function getServerSchedulerStatus() {
|
||||
const db = getDb()
|
||||
export async function getServerSchedulerStatus() {
|
||||
const activeServers = serverTimers.size
|
||||
const targets = db.prepare(`
|
||||
const targets = await query<ServerTarget>(`
|
||||
SELECT * FROM server_targets WHERE is_active = 1
|
||||
`).all() as ServerTarget[]
|
||||
`)
|
||||
|
||||
return {
|
||||
is_running: isRunning,
|
||||
@@ -655,12 +682,11 @@ export function getServerSchedulerStatus() {
|
||||
}
|
||||
|
||||
// 특정 서버 타이머 갱신 (설정 변경 시)
|
||||
export function refreshServerTimer(targetId: number) {
|
||||
export async function refreshServerTimer(targetId: number) {
|
||||
const now = timestamp()
|
||||
const db = getDb()
|
||||
const target = db.prepare(`
|
||||
SELECT * FROM server_targets WHERE target_id = ? AND is_active = 1
|
||||
`).get(targetId) as ServerTarget | undefined
|
||||
const target = await queryOne<ServerTarget>(`
|
||||
SELECT * FROM server_targets WHERE target_id = $1 AND is_active = 1
|
||||
`, [targetId])
|
||||
|
||||
if (target && isRunning) {
|
||||
console.log(`[${now}] 🔄 [${target.server_name}] 타이머 갱신`)
|
||||
|
||||
@@ -53,7 +53,7 @@ export default defineNuxtConfig({
|
||||
},
|
||||
// rollup에서 external로 처리
|
||||
rollupConfig: {
|
||||
external: ['better-sqlite3']
|
||||
external: ['pg']
|
||||
},
|
||||
// 플러그인 등록
|
||||
plugins: [
|
||||
@@ -65,7 +65,22 @@ export default defineNuxtConfig({
|
||||
// Vite 설정 (네이티브 모듈)
|
||||
vite: {
|
||||
optimizeDeps: {
|
||||
exclude: ['better-sqlite3']
|
||||
exclude: ['pg']
|
||||
}
|
||||
},
|
||||
|
||||
// 환경변수 설정
|
||||
runtimeConfig: {
|
||||
// 서버 전용 (NUXT_로 시작하는 환경변수 자동 로드)
|
||||
dbHost: process.env.DB_HOST || 'localhost',
|
||||
dbPort: process.env.DB_PORT || '5432',
|
||||
dbName: process.env.DB_NAME || 'osolit_monitor',
|
||||
dbUser: process.env.DB_USER || 'postgres',
|
||||
dbPassword: process.env.DB_PASSWORD || '',
|
||||
autoStartScheduler: process.env.AUTO_START_SCHEDULER || 'false',
|
||||
// 클라이언트 공개
|
||||
public: {
|
||||
nodeEnv: process.env.NODE_ENV || 'development'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
230
package-lock.json
generated
230
package-lock.json
generated
@@ -9,16 +9,16 @@
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"nuxt": "^3.13.0",
|
||||
"pg": "^8.11.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/devtools": "latest",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
},
|
||||
@@ -3188,16 +3188,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -3221,6 +3211,18 @@
|
||||
"integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
|
||||
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||
@@ -3889,18 +3891,6 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "11.10.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
@@ -3924,6 +3914,7 @@
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
@@ -3949,6 +3940,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
@@ -3959,6 +3951,7 @@
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
@@ -4746,6 +4739,7 @@
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
@@ -4761,6 +4755,7 @@
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
@@ -5006,6 +5001,7 @@
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
@@ -5194,6 +5190,7 @@
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -5339,7 +5336,8 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
@@ -5449,7 +5447,8 @@
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
@@ -6350,6 +6349,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -6377,6 +6377,7 @@
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
@@ -6412,7 +6413,8 @@
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.0",
|
||||
@@ -6498,7 +6500,8 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nitropack": {
|
||||
"version": "2.12.9",
|
||||
@@ -7099,6 +7102,7 @@
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
|
||||
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
@@ -7424,6 +7428,7 @@
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
@@ -7700,6 +7705,96 @@
|
||||
"integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
"pg-protocol": "^1.10.3",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.2.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -8206,11 +8301,51 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
@@ -8237,6 +8372,7 @@
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -8292,6 +8428,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
@@ -8362,6 +8499,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
@@ -8376,7 +8514,8 @@
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "2.1.2",
|
||||
@@ -8820,7 +8959,8 @@
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
@@ -8841,6 +8981,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
@@ -8946,6 +9087,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/srvx": {
|
||||
"version": "0.9.8",
|
||||
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.9.8.tgz",
|
||||
@@ -9112,6 +9262,7 @@
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -9284,6 +9435,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
@@ -9295,13 +9447,15 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tar-fs/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
@@ -9316,6 +9470,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
@@ -9459,6 +9614,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
@@ -10476,7 +10632,8 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
@@ -10514,6 +10671,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"nuxt": "^3.13.0",
|
||||
"pg": "^8.11.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/devtools": "latest",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user