Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions frontend/src/components/AppIcon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,13 @@ defineProps<{
<path d="M13 10v4" />
<path d="M16 10v4" />
</template>
<template v-else-if="name === 'monitoring'">
<path d="M3.5 3.5v17h17" />
<path d="M7 16l3.5-4 3 3 5-7" />
<circle cx="7" cy="16" r="0.8" fill="currentColor" stroke="none" />
<circle cx="10.5" cy="12" r="0.8" fill="currentColor" stroke="none" />
<circle cx="13.5" cy="15" r="0.8" fill="currentColor" stroke="none" />
<circle cx="18.5" cy="8" r="0.8" fill="currentColor" stroke="none" />
</template>
</svg>
</template>
1 change: 1 addition & 0 deletions frontend/src/components/layout/AppShell.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const items: NavItem[] = [
{ label: 'Dashboard', to: '/dashboard', minRole: 'viewer', icon: 'dashboard' },
{ label: 'Environment', to: '/environment', minRole: 'viewer', icon: 'environment' },
{ label: 'Fleet', to: '/fleet', minRole: 'viewer', icon: 'fleet' },
{ label: 'Monitoring', to: '/monitoring', minRole: 'viewer', icon: 'monitoring' },
{ label: 'Suites', to: '/suites', minRole: 'viewer', icon: 'suites' },
{ label: 'Logs', to: '/logs', minRole: 'viewer', icon: 'logs' },
{ label: 'Workspace', to: '/workspace', minRole: 'viewer', icon: 'workspace' },
Expand Down
115 changes: 115 additions & 0 deletions frontend/src/lib/monitoring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type {
MonitoringReachability,
MonitoringSuiteHint,
PromQLInstantResponse,
PromQLVectorSample,
SuiteSummary,
} from '../types'

export const PROMETHEUS_BASE = '/monitoring/prometheus'
export const GRAFANA_BASE = '/monitoring/grafana'

const DEFAULT_TIMEOUT_MS = 4000

function fetchWithTimeout(path: string, timeoutMs = DEFAULT_TIMEOUT_MS, init: RequestInit = {}): Promise<Response> {
const controller = new AbortController()
const timer = window.setTimeout(() => controller.abort(), timeoutMs)
return fetch(path, {
credentials: 'include',
...init,
signal: controller.signal,
}).finally(() => {
window.clearTimeout(timer)
})
}

export async function probeMonitoring(): Promise<MonitoringReachability> {
const [prom, graf] = await Promise.all([probePrometheus(), probeGrafana()])
return { prometheus: prom, grafana: graf }
}

async function probePrometheus(): Promise<MonitoringReachability['prometheus']> {
try {
const response = await fetchWithTimeout(`${PROMETHEUS_BASE}/-/healthy`)
if (!response.ok) {
return { reachable: false, error: `HTTP ${response.status}` }
}
let version: string | undefined
try {
const build = await fetchWithTimeout(`${PROMETHEUS_BASE}/api/v1/status/buildinfo`)
if (build.ok) {
const payload = (await build.json()) as { data?: { version?: string } }
version = payload.data?.version
}
} catch {
// buildinfo is best-effort; reachability was already confirmed
}
return { reachable: true, version }
} catch (err) {
return { reachable: false, error: err instanceof Error ? err.message : 'unreachable' }
}
}

async function probeGrafana(): Promise<MonitoringReachability['grafana']> {
try {
const response = await fetchWithTimeout(`${GRAFANA_BASE}/api/health`)
if (!response.ok) {
return { reachable: false, error: `HTTP ${response.status}` }
}
const payload = (await response.json()) as { version?: string; database?: string }
return { reachable: true, version: payload.version }
} catch (err) {
return { reachable: false, error: err instanceof Error ? err.message : 'unreachable' }
}
}

export async function promQuery(expr: string): Promise<PromQLInstantResponse> {
const response = await fetchWithTimeout(
`${PROMETHEUS_BASE}/api/v1/query?query=${encodeURIComponent(expr)}`,
DEFAULT_TIMEOUT_MS,
{ headers: { Accept: 'application/json' } },
)
if (!response.ok) {
return { status: 'error', errorType: 'http', error: `HTTP ${response.status}` }
}
return (await response.json()) as PromQLInstantResponse
}

export function firstVectorValue(payload: PromQLInstantResponse): number | null {
if (payload.status !== 'success' || !payload.data) {
return null
}
if (payload.data.resultType !== 'vector') {
return null
}
const samples = payload.data.result as PromQLVectorSample[]
if (!samples.length) {
return null
}
const raw = samples[0].value[1]
const value = Number(raw)
return Number.isFinite(value) ? value : null
}

export function detectMonitoringSuites(suites: SuiteSummary[]): MonitoringSuiteHint[] {
const hints: MonitoringSuiteHint[] = []
for (const suite of suites) {
const relevant = (suite.containers ?? []).filter((container) => {
const name = container.name.toLowerCase()
return name.includes('prometheus') || name.includes('grafana')
})
if (relevant.length === 0) {
continue
}
hints.push({
suite: suite.name,
state: suite.state,
containers: relevant.map((container) => ({
name: container.name,
role: container.role,
status: container.status,
})),
})
}
return hints
}
2 changes: 2 additions & 0 deletions frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import CheckView from '../views/CheckView.vue'
import LabView from '../views/LabView.vue'
import LoginView from '../views/LoginView.vue'
import LogsView from '../views/LogsView.vue'
import MonitoringView from '../views/MonitoringView.vue'
import SettingsView from '../views/SettingsView.vue'
import SuitesView from '../views/SuitesView.vue'
import SystemView from '../views/SystemView.vue'
Expand Down Expand Up @@ -40,6 +41,7 @@ export const router = createRouter({
{ path: '/check', component: CheckView, meta: { minRole: 'viewer', title: 'Check' } },
{ path: '/doctor', redirect: '/check' },
{ path: '/lab', component: LabView, meta: { minRole: 'viewer', title: 'Gradient Lab' } },
{ path: '/monitoring', component: MonitoringView, meta: { minRole: 'viewer', title: 'Monitoring' } },
{ path: '/teams', component: TeamsView, meta: { minRole: 'admin', title: 'Teams' } },
{ path: '/users', component: UsersView, meta: { minRole: 'admin', title: 'Users' } },
{ path: '/system', component: SystemView, meta: { minRole: 'admin', title: 'System' } },
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,4 +214,51 @@ export interface WebSettings {
api_base_url: string
bind_addr: string
port: number
prometheus_url: string
grafana_url: string
}

export type PromQLResultType = 'vector' | 'scalar' | 'matrix' | 'string'

export interface PromQLVectorSample {
metric: Record<string, string>
value: [number, string]
}

export interface PromQLMatrixSample {
metric: Record<string, string>
values: Array<[number, string]>
}

export interface PromQLInstantResponse {
status: 'success' | 'error'
errorType?: string
error?: string
data?: {
resultType: PromQLResultType
result: PromQLVectorSample[] | PromQLMatrixSample[] | [number, string]
}
}

export interface MonitoringReachability {
prometheus: {
reachable: boolean
version?: string
error?: string
}
grafana: {
reachable: boolean
version?: string
error?: string
}
}

export interface MonitoringSuiteHint {
suite: string
state: string
containers: Array<{
name: string
role: string
status: string
}>
}
Loading