225 lines
6.3 KiB
TypeScript
225 lines
6.3 KiB
TypeScript
import { prisma } from '@/lib/prisma'
|
|
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
|
|
|
|
type StatusCount = Record<string, number>
|
|
|
|
type WindowSummary = {
|
|
total: number
|
|
finishedTotal: number
|
|
completed: number
|
|
failed: number
|
|
successRate: number | null
|
|
byStatus: StatusCount
|
|
byType: Record<string, number>
|
|
}
|
|
|
|
type Options = {
|
|
minutes: number
|
|
baselineMinutes: number
|
|
baselineOffsetMinutes: number
|
|
projectId: string | null
|
|
tolerancePct: number
|
|
minFinishedSamples: number
|
|
strict: boolean
|
|
json: boolean
|
|
}
|
|
|
|
const DEFAULT_MINUTES = 60 * 24 * 7
|
|
const DEFAULT_TOLERANCE_PCT = 2
|
|
const DEFAULT_MIN_FINISHED_SAMPLES = 20
|
|
|
|
function parseNumberArg(name: string, fallback: number): number {
|
|
const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`))
|
|
if (!raw) return fallback
|
|
const value = Number.parseFloat(raw.split('=')[1] || '')
|
|
return Number.isFinite(value) && value > 0 ? value : fallback
|
|
}
|
|
|
|
function parseBooleanArg(name: string, fallback = false): boolean {
|
|
const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`))
|
|
if (!raw) return fallback
|
|
const value = (raw.split('=')[1] || '').trim().toLowerCase()
|
|
return value === '1' || value === 'true' || value === 'yes' || value === 'on'
|
|
}
|
|
|
|
function parseStringArg(name: string): string | null {
|
|
const raw = process.argv.find((arg) => arg.startsWith(`--${name}=`))
|
|
if (!raw) return null
|
|
const value = (raw.split('=')[1] || '').trim()
|
|
return value || null
|
|
}
|
|
|
|
function parseOptions(): Options {
|
|
const minutes = parseNumberArg('minutes', DEFAULT_MINUTES)
|
|
const baselineMinutes = parseNumberArg('baselineMinutes', minutes)
|
|
const baselineOffsetMinutes = parseNumberArg('baselineOffsetMinutes', minutes)
|
|
return {
|
|
minutes,
|
|
baselineMinutes,
|
|
baselineOffsetMinutes,
|
|
projectId: parseStringArg('projectId'),
|
|
tolerancePct: parseNumberArg('tolerancePct', DEFAULT_TOLERANCE_PCT),
|
|
minFinishedSamples: parseNumberArg('minFinishedSamples', DEFAULT_MIN_FINISHED_SAMPLES),
|
|
strict: parseBooleanArg('strict', false),
|
|
json: parseBooleanArg('json', false),
|
|
}
|
|
}
|
|
|
|
function asPct(value: number | null): string {
|
|
return value === null ? 'N/A' : `${value.toFixed(2)}%`
|
|
}
|
|
|
|
function getSuccessRate(completed: number, failed: number): number | null {
|
|
const total = completed + failed
|
|
if (total <= 0) return null
|
|
return (completed / total) * 100
|
|
}
|
|
|
|
function summarizeRows(
|
|
rows: Array<{ status: string; type: string }>,
|
|
): WindowSummary {
|
|
const byStatus: StatusCount = {}
|
|
const byType: Record<string, number> = {}
|
|
for (const row of rows) {
|
|
byStatus[row.status] = (byStatus[row.status] || 0) + 1
|
|
byType[row.type] = (byType[row.type] || 0) + 1
|
|
}
|
|
|
|
const completed = byStatus[TASK_STATUS.COMPLETED] || 0
|
|
const failed = byStatus[TASK_STATUS.FAILED] || 0
|
|
const finishedTotal = completed + failed
|
|
|
|
return {
|
|
total: rows.length,
|
|
finishedTotal,
|
|
completed,
|
|
failed,
|
|
successRate: getSuccessRate(completed, failed),
|
|
byStatus,
|
|
byType,
|
|
}
|
|
}
|
|
|
|
async function fetchWindowSummary(params: {
|
|
from: Date
|
|
to: Date
|
|
projectId: string | null
|
|
}) {
|
|
const monitoredTypes = [
|
|
TASK_TYPE.MODIFY_ASSET_IMAGE,
|
|
TASK_TYPE.ASSET_HUB_MODIFY,
|
|
TASK_TYPE.VIDEO_PANEL,
|
|
]
|
|
|
|
const rows = await prisma.task.findMany({
|
|
where: {
|
|
type: { in: monitoredTypes },
|
|
createdAt: {
|
|
gte: params.from,
|
|
lt: params.to,
|
|
},
|
|
...(params.projectId ? { projectId: params.projectId } : {}),
|
|
},
|
|
select: {
|
|
status: true,
|
|
type: true,
|
|
},
|
|
})
|
|
|
|
return summarizeRows(rows)
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseOptions()
|
|
const now = Date.now()
|
|
|
|
const currentEnd = new Date(now)
|
|
const currentStart = new Date(now - options.minutes * 60_000)
|
|
|
|
const baselineEnd = new Date(now - options.baselineOffsetMinutes * 60_000)
|
|
const baselineStart = new Date(baselineEnd.getTime() - options.baselineMinutes * 60_000)
|
|
|
|
const [current, baseline] = await Promise.all([
|
|
fetchWindowSummary({
|
|
from: currentStart,
|
|
to: currentEnd,
|
|
projectId: options.projectId,
|
|
}),
|
|
fetchWindowSummary({
|
|
from: baselineStart,
|
|
to: baselineEnd,
|
|
projectId: options.projectId,
|
|
}),
|
|
])
|
|
|
|
const hasEnoughCurrent = current.finishedTotal >= options.minFinishedSamples
|
|
const hasEnoughBaseline = baseline.finishedTotal >= options.minFinishedSamples
|
|
const hasEnoughSamples = hasEnoughCurrent && hasEnoughBaseline
|
|
|
|
const rateDeltaPct =
|
|
current.successRate !== null && baseline.successRate !== null
|
|
? current.successRate - baseline.successRate
|
|
: null
|
|
|
|
const meetsTolerance =
|
|
rateDeltaPct !== null
|
|
? rateDeltaPct >= -Math.abs(options.tolerancePct)
|
|
: false
|
|
|
|
const status = hasEnoughSamples
|
|
? meetsTolerance
|
|
? 'pass'
|
|
: 'fail'
|
|
: 'blocked'
|
|
|
|
process.stdout.write(
|
|
`[check:outbound-image-success-rate] current=${asPct(current.successRate)} baseline=${asPct(baseline.successRate)} delta=${asPct(rateDeltaPct)} tolerance=-${Math.abs(options.tolerancePct).toFixed(2)}% status=${status}\n`,
|
|
)
|
|
process.stdout.write(
|
|
`[check:outbound-image-success-rate] current_finished=${current.finishedTotal} baseline_finished=${baseline.finishedTotal} min_required=${options.minFinishedSamples}\n`,
|
|
)
|
|
process.stdout.write(
|
|
`[check:outbound-image-success-rate] current_by_type=${JSON.stringify(current.byType)} baseline_by_type=${JSON.stringify(baseline.byType)}\n`,
|
|
)
|
|
|
|
if (options.json) {
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
status,
|
|
tolerancePct: options.tolerancePct,
|
|
minFinishedSamples: options.minFinishedSamples,
|
|
windows: {
|
|
current: {
|
|
from: currentStart.toISOString(),
|
|
to: currentEnd.toISOString(),
|
|
...current,
|
|
},
|
|
baseline: {
|
|
from: baselineStart.toISOString(),
|
|
to: baselineEnd.toISOString(),
|
|
...baseline,
|
|
},
|
|
},
|
|
rateDeltaPct,
|
|
hasEnoughSamples,
|
|
})}\n`,
|
|
)
|
|
}
|
|
|
|
if (!options.strict) return
|
|
|
|
if (status === 'pass') return
|
|
if (status === 'blocked') process.exit(2)
|
|
process.exit(1)
|
|
}
|
|
|
|
main()
|
|
.catch((error) => {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
process.stderr.write(`[check:outbound-image-success-rate] failed: ${message}\n`)
|
|
process.exit(1)
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect()
|
|
})
|