Skip to content

Commit fd57a09

Browse files
mchestrclaude
andcommitted
address PR review feedback: improve Prometheus status reliability
- Fix error handling to return isConfigured: true when Prometheus is configured but query fails (shows unknown status instead of hiding) - Add 5-minute caching with unstable_cache to reduce Prometheus API load - Replace console.error with centralized logger for consistency - Add unit tests for getPrometheusStatus action (10 test cases) - Fix Prisma 7 transaction type issue with driver adapters 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c0b8f21 commit fd57a09

File tree

3 files changed

+442
-7
lines changed

3 files changed

+442
-7
lines changed
Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
1+
/**
2+
* Tests for actions/prometheus-status.ts - Prometheus status fetching
3+
*/
4+
5+
import { getPrometheusStatus } from '@/actions/prometheus-status'
6+
import { queryPrometheusRange } from '@/lib/connections/prometheus'
7+
import { prisma } from '@/lib/prisma'
8+
9+
// Mock dependencies
10+
jest.mock('@/lib/prisma', () => ({
11+
prisma: {
12+
prometheus: {
13+
findFirst: jest.fn(),
14+
},
15+
},
16+
}))
17+
18+
jest.mock('@/lib/connections/prometheus', () => ({
19+
queryPrometheusRange: jest.fn(),
20+
}))
21+
22+
jest.mock('next/cache', () => ({
23+
unstable_cache: jest.fn((fn) => fn),
24+
}))
25+
26+
jest.mock('@/lib/utils/logger', () => ({
27+
createLogger: jest.fn(() => ({
28+
info: jest.fn(),
29+
warn: jest.fn(),
30+
error: jest.fn(),
31+
debug: jest.fn(),
32+
})),
33+
}))
34+
35+
const mockPrisma = prisma as jest.Mocked<typeof prisma>
36+
const mockQueryPrometheusRange = queryPrometheusRange as jest.MockedFunction<typeof queryPrometheusRange>
37+
38+
describe('Prometheus Status Actions', () => {
39+
beforeEach(() => {
40+
jest.clearAllMocks()
41+
// Reset date mocks
42+
jest.useFakeTimers()
43+
jest.setSystemTime(new Date('2024-01-15T12:00:00Z'))
44+
})
45+
46+
afterEach(() => {
47+
jest.useRealTimers()
48+
})
49+
50+
describe('getPrometheusStatus', () => {
51+
it('should return not configured when no Prometheus config exists', async () => {
52+
mockPrisma.prometheus.findFirst.mockResolvedValue(null)
53+
54+
const result = await getPrometheusStatus()
55+
56+
expect(result.isConfigured).toBe(false)
57+
expect(result.serviceName).toBe('')
58+
expect(result.segments).toEqual([])
59+
expect(result.overallStatus).toBe('unknown')
60+
})
61+
62+
it('should return unknown segments when Prometheus query fails', async () => {
63+
const mockPrometheus = {
64+
id: '1',
65+
name: 'Plex Server',
66+
url: 'http://prometheus:9090',
67+
query: 'up{job="plex"}',
68+
isActive: true,
69+
createdAt: new Date(),
70+
updatedAt: new Date(),
71+
}
72+
73+
mockPrisma.prometheus.findFirst.mockResolvedValue(mockPrometheus)
74+
mockQueryPrometheusRange.mockResolvedValue({
75+
success: false,
76+
error: 'Connection timeout',
77+
})
78+
79+
const result = await getPrometheusStatus()
80+
81+
expect(result.isConfigured).toBe(true)
82+
expect(result.serviceName).toBe('Plex Server')
83+
expect(result.overallStatus).toBe('unknown')
84+
// Should have 169 segments (7 days * 24 hours + 1)
85+
expect(result.segments.length).toBeGreaterThan(0)
86+
expect(result.segments.every(s => s.status === 'unknown')).toBe(true)
87+
})
88+
89+
it('should calculate operational status when uptime is >= 95%', async () => {
90+
const mockPrometheus = {
91+
id: '1',
92+
name: 'Plex Server',
93+
url: 'http://prometheus:9090',
94+
query: 'up{job="plex"}',
95+
isActive: true,
96+
createdAt: new Date(),
97+
updatedAt: new Date(),
98+
}
99+
100+
mockPrisma.prometheus.findFirst.mockResolvedValue(mockPrometheus)
101+
102+
// Create mock data with all hours having data (100% uptime)
103+
const now = new Date('2024-01-15T12:00:00Z')
104+
const endTime = Math.floor(now.getTime() / 1000)
105+
const startTime = endTime - 7 * 24 * 60 * 60
106+
const values: [number, string][] = []
107+
108+
let currentTime = Math.floor(startTime / 3600) * 3600
109+
const endHour = Math.floor(endTime / 3600) * 3600
110+
while (currentTime <= endHour) {
111+
values.push([currentTime, '1'])
112+
currentTime += 3600
113+
}
114+
115+
mockQueryPrometheusRange.mockResolvedValue({
116+
success: true,
117+
data: {
118+
resultType: 'matrix',
119+
result: [{
120+
metric: { job: 'plex' },
121+
values,
122+
}],
123+
},
124+
})
125+
126+
const result = await getPrometheusStatus()
127+
128+
expect(result.isConfigured).toBe(true)
129+
expect(result.serviceName).toBe('Plex Server')
130+
expect(result.overallStatus).toBe('operational')
131+
expect(result.segments.filter(s => s.status === 'up').length).toBeGreaterThan(0)
132+
})
133+
134+
it('should calculate issues status when uptime is between 50% and 95%', async () => {
135+
const mockPrometheus = {
136+
id: '1',
137+
name: 'Plex Server',
138+
url: 'http://prometheus:9090',
139+
query: 'up{job="plex"}',
140+
isActive: true,
141+
createdAt: new Date(),
142+
updatedAt: new Date(),
143+
}
144+
145+
mockPrisma.prometheus.findFirst.mockResolvedValue(mockPrometheus)
146+
147+
// Create mock data with ~70% uptime in last 24 hours
148+
// New logic: only considers known data points, so we need to include explicit down values
149+
const now = new Date('2024-01-15T12:00:00Z')
150+
const endTime = Math.floor(now.getTime() / 1000)
151+
const values: [number, string][] = []
152+
153+
// Add data for all 24 hours: 17 up (value=1), 7 down (value=0)
154+
const last24HoursStart = endTime - 24 * 3600
155+
let currentTime = Math.floor(last24HoursStart / 3600) * 3600
156+
let count = 0
157+
while (currentTime <= Math.floor(endTime / 3600) * 3600) {
158+
// First 17 hours: up, last 7 hours: down
159+
const value = count < 17 ? '1' : '0'
160+
values.push([currentTime, value])
161+
currentTime += 3600
162+
count++
163+
}
164+
165+
mockQueryPrometheusRange.mockResolvedValue({
166+
success: true,
167+
data: {
168+
resultType: 'matrix',
169+
result: [{
170+
metric: { job: 'plex' },
171+
values,
172+
}],
173+
},
174+
})
175+
176+
const result = await getPrometheusStatus()
177+
178+
expect(result.isConfigured).toBe(true)
179+
expect(result.overallStatus).toBe('issues')
180+
})
181+
182+
it('should calculate down status when uptime is below 50%', async () => {
183+
const mockPrometheus = {
184+
id: '1',
185+
name: 'Plex Server',
186+
url: 'http://prometheus:9090',
187+
query: 'up{job="plex"}',
188+
isActive: true,
189+
createdAt: new Date(),
190+
updatedAt: new Date(),
191+
}
192+
193+
mockPrisma.prometheus.findFirst.mockResolvedValue(mockPrometheus)
194+
195+
// Create mock data with ~30% uptime in last 24 hours
196+
// New logic: only considers known data points, so we need to include explicit down values
197+
const now = new Date('2024-01-15T12:00:00Z')
198+
const endTime = Math.floor(now.getTime() / 1000)
199+
const values: [number, string][] = []
200+
201+
// Add data for all 24 hours: 7 up (value=1), 17 down (value=0)
202+
const last24HoursStart = endTime - 24 * 3600
203+
let currentTime = Math.floor(last24HoursStart / 3600) * 3600
204+
let count = 0
205+
while (currentTime <= Math.floor(endTime / 3600) * 3600) {
206+
// First 7 hours: up, rest: down
207+
const value = count < 7 ? '1' : '0'
208+
values.push([currentTime, value])
209+
currentTime += 3600
210+
count++
211+
}
212+
213+
mockQueryPrometheusRange.mockResolvedValue({
214+
success: true,
215+
data: {
216+
resultType: 'matrix',
217+
result: [{
218+
metric: { job: 'plex' },
219+
values,
220+
}],
221+
},
222+
})
223+
224+
const result = await getPrometheusStatus()
225+
226+
expect(result.isConfigured).toBe(true)
227+
expect(result.overallStatus).toBe('down')
228+
})
229+
230+
it('should handle errors gracefully and return isConfigured true with unknown status', async () => {
231+
const mockPrometheus = {
232+
id: '1',
233+
name: 'Plex Server',
234+
url: 'http://prometheus:9090',
235+
query: 'up{job="plex"}',
236+
isActive: true,
237+
createdAt: new Date(),
238+
updatedAt: new Date(),
239+
}
240+
241+
mockPrisma.prometheus.findFirst.mockResolvedValueOnce(mockPrometheus)
242+
mockQueryPrometheusRange.mockRejectedValue(new Error('Network error'))
243+
244+
// Mock the second findFirst call in error handler
245+
mockPrisma.prometheus.findFirst.mockResolvedValueOnce(mockPrometheus)
246+
247+
const result = await getPrometheusStatus()
248+
249+
expect(result.isConfigured).toBe(true)
250+
expect(result.serviceName).toBe('Plex Server')
251+
expect(result.overallStatus).toBe('unknown')
252+
expect(result.segments.every(s => s.status === 'unknown')).toBe(true)
253+
})
254+
255+
it('should handle error when both Prometheus query and name lookup fail', async () => {
256+
const mockPrometheus = {
257+
id: '1',
258+
name: 'Plex Server',
259+
url: 'http://prometheus:9090',
260+
query: 'up{job="plex"}',
261+
isActive: true,
262+
createdAt: new Date(),
263+
updatedAt: new Date(),
264+
}
265+
266+
mockPrisma.prometheus.findFirst.mockResolvedValueOnce(mockPrometheus)
267+
mockQueryPrometheusRange.mockRejectedValue(new Error('Network error'))
268+
269+
// Mock the second findFirst call in error handler to also fail
270+
mockPrisma.prometheus.findFirst.mockRejectedValueOnce(new Error('Database error'))
271+
272+
const result = await getPrometheusStatus()
273+
274+
// When name lookup also fails, isConfigured should be false
275+
expect(result.isConfigured).toBe(false)
276+
expect(result.serviceName).toBe('')
277+
expect(result.overallStatus).toBe('unknown')
278+
})
279+
280+
it('should generate correct number of hourly segments for 7 days', async () => {
281+
const mockPrometheus = {
282+
id: '1',
283+
name: 'Plex Server',
284+
url: 'http://prometheus:9090',
285+
query: 'up{job="plex"}',
286+
isActive: true,
287+
createdAt: new Date(),
288+
updatedAt: new Date(),
289+
}
290+
291+
mockPrisma.prometheus.findFirst.mockResolvedValue(mockPrometheus)
292+
293+
// Create mock data with all hours having data
294+
const now = new Date('2024-01-15T12:00:00Z')
295+
const endTime = Math.floor(now.getTime() / 1000)
296+
const startTime = endTime - 7 * 24 * 60 * 60
297+
const values: [number, string][] = []
298+
299+
let currentTime = Math.floor(startTime / 3600) * 3600
300+
const endHour = Math.floor(endTime / 3600) * 3600
301+
while (currentTime <= endHour) {
302+
values.push([currentTime, '1'])
303+
currentTime += 3600
304+
}
305+
306+
mockQueryPrometheusRange.mockResolvedValue({
307+
success: true,
308+
data: {
309+
resultType: 'matrix',
310+
result: [{
311+
metric: { job: 'plex' },
312+
values,
313+
}],
314+
},
315+
})
316+
317+
const result = await getPrometheusStatus()
318+
319+
// 7 days * 24 hours = 168 hours, plus current hour = 169 segments
320+
// (accounting for the hour boundary calculation)
321+
expect(result.segments.length).toBeGreaterThanOrEqual(168)
322+
expect(result.segments.length).toBeLessThanOrEqual(170)
323+
})
324+
325+
it('should call queryPrometheusRange with correct parameters', async () => {
326+
const mockPrometheus = {
327+
id: '1',
328+
name: 'Plex Server',
329+
url: 'http://prometheus:9090',
330+
query: 'up{job="plex"}',
331+
isActive: true,
332+
createdAt: new Date(),
333+
updatedAt: new Date(),
334+
}
335+
336+
mockPrisma.prometheus.findFirst.mockResolvedValue(mockPrometheus)
337+
mockQueryPrometheusRange.mockResolvedValue({
338+
success: true,
339+
data: {
340+
resultType: 'matrix',
341+
result: [],
342+
},
343+
})
344+
345+
await getPrometheusStatus()
346+
347+
expect(mockQueryPrometheusRange).toHaveBeenCalledWith(
348+
{
349+
name: 'Plex Server',
350+
url: 'http://prometheus:9090',
351+
query: 'up{job="plex"}',
352+
},
353+
expect.any(Number), // startTime
354+
expect.any(Number), // endTime
355+
'1h' // step
356+
)
357+
})
358+
359+
it('should handle empty result from Prometheus', async () => {
360+
const mockPrometheus = {
361+
id: '1',
362+
name: 'Plex Server',
363+
url: 'http://prometheus:9090',
364+
query: 'up{job="plex"}',
365+
isActive: true,
366+
createdAt: new Date(),
367+
updatedAt: new Date(),
368+
}
369+
370+
mockPrisma.prometheus.findFirst.mockResolvedValue(mockPrometheus)
371+
mockQueryPrometheusRange.mockResolvedValue({
372+
success: true,
373+
data: {
374+
resultType: 'matrix',
375+
result: [],
376+
},
377+
})
378+
379+
const result = await getPrometheusStatus()
380+
381+
expect(result.isConfigured).toBe(true)
382+
// New logic: All segments should be unknown (not down) when no data returned
383+
expect(result.segments.every(s => s.status === 'unknown')).toBe(true)
384+
// Overall status should be unknown when no known data
385+
expect(result.overallStatus).toBe('unknown')
386+
})
387+
})
388+
})

actions/admin/admin-servers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,9 @@ export async function updatePrometheus(data: { name: string; url: string; query:
376376
return { success: false, error: queryTest.error || "Invalid PromQL query" }
377377
}
378378

379-
await prisma.$transaction(async (tx) => {
379+
await prisma.$transaction(async (txClient) => {
380+
// Type assertion needed for Prisma 7 with driver adapters
381+
const tx = txClient as unknown as typeof prisma
380382
// Deactivate existing active server
381383
await tx.prometheus.updateMany({
382384
where: { isActive: true },

0 commit comments

Comments
 (0)