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
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { type RdsMetricData } from '../hooks/use-rds-metrics/use-rds-metrics'
import { formatTimestamp } from './format-timestamp'

interface RdsMetricData {
metric: Record<string, string>
values: [number, string][]
}

// Generic helper function to process metrics data
export function processMetricsData(
metricsData: { data?: { result: RdsMetricData[] } } | undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { type PropsWithChildren, useEffect, useRef, useState } from 'react'

export function LazyChart({ children, className }: PropsWithChildren<{ className?: string }>) {
const ref = useRef<HTMLDivElement>(null)
const [inView, setInView] = useState(false)

useEffect(() => {
const el = ref.current
if (!el) return

const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true)
observer.disconnect()
}
},
{ rootMargin: '300px' }
)

observer.observe(el)
return () => observer.disconnect()
}, [])

return (
<div ref={ref} className={className}>
{inView ? children : <div className="min-h-72" />}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,15 @@ export function EventSidebar({ events, service, isLoading = false }: EventSideba
<span className="font-medium">{event.reason}</span>
<span className="text-neutral-350">{timestamp.fullTimeString}</span>
</div>
{event.type === 'event' && (
<>
<span className="text-neutral-350">
{service?.serviceType === 'CONTAINER' ? 'Image name' : 'Repository'}: {event.repository}
</span>
<span className="text-neutral-350">
{service?.serviceType === 'CONTAINER' ? 'Tag' : 'Version'}: {event.version?.slice(0, 8)}
</span>
</>
{event.type === 'event' && event.repository && (
<span className="text-neutral-350">
{service?.serviceType === 'CONTAINER' ? 'Image name' : 'Repository'}: {event.repository}
</span>
)}
{event.type === 'event' && event.version && (
<span className="text-neutral-350">
{service?.serviceType === 'CONTAINER' ? 'Tag' : 'Version'}: {event.version.slice(0, 8)}
</span>
)}
{event.description && <span className="text-neutral-350">{event.description}</span>}
{event.type === 'exit-code' && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,16 +178,16 @@ describe('CardInstanceStatus', () => {
expect(useInstantMetrics).toHaveBeenCalledTimes(2)
expect(useInstantMetrics).toHaveBeenCalledWith({
clusterId: 'test-cluster-id',
query: expect.stringContaining('kube_pod_container_status_restarts_total'),
query: expect.stringContaining('k8s_event_logger_q_k8s_events_total'),
startTimestamp: expect.any(String),
endTimestamp: expect.any(String),
boardShortName: 'service_overview',
metricShortName: 'card_instance_status_error_count',
})

const call = useInstantMetrics.mock.calls[0][0].query
expect(call).toContain('test-container-name')
expect(call).toContain('kube_pod_container_status_waiting_reason')
expect(call).toContain('test-service-id')
expect(call).toContain('k8s_event_logger_q_k8s_events_total')
})

it('should always show modal link', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,34 @@ import { useDashboardContext } from '../../../util-filter/dashboard-context'
import { CardMetricButton } from '../card-metric/card-metric'
import { InstanceStatusChart } from '../instance-status-chart/instance-status-chart'

const query = (timeRange: string, selector: string) => `
sum(increase(kube_pod_container_status_restarts_total{${selector}}[${timeRange}]))
+
count(
max by (pod) (
max_over_time(
kube_pod_container_status_waiting_reason{
${selector},
reason!~"ContainerCreating|PodInitializing|Completed"
}[${timeRange}]
)
) > 0
)
const query = (timeRange: string, subQueryTimeRange: string, selector: string, serviceId: string) => `
(sum by () (
sum_over_time(
(
sum by (pod, reason) (
clamp_max(
clamp_min(
(
k8s_event_logger_q_k8s_events_total{
qovery_com_service_id="${serviceId}",
reason=~"Failed|OOMKilled|BackOff|Evicted|FailedScheduling|FailedMount|FailedAttachVolume|Preempted|NodeNotReady|Killing",
type="Warning"
}
-
k8s_event_logger_q_k8s_events_total{
qovery_com_service_id="${serviceId}",
reason=~"Failed|OOMKilled|BackOff|Evicted|FailedScheduling|FailedMount|FailedAttachVolume|Preempted|NodeNotReady|Killing",
type="Warning"
} offset ${subQueryTimeRange}
),
0
),
1
)
)
)[${timeRange}:${subQueryTimeRange}]
)
) or vector(0))
`

// TODO PG think to use recorder rule
Expand Down Expand Up @@ -75,7 +90,7 @@ export function CardInstanceStatus({
const { data: service } = useService({ serviceId })
const { data: metricsInstanceErrors, isLoading: isLoadingMetricsInstanceErrors } = useInstantMetrics({
clusterId,
query: query(queryTimeRange, selector),
query: query(queryTimeRange, subQueryTimeRange, selector, serviceId),
startTimestamp,
endTimestamp,
boardShortName: 'service_overview',
Expand All @@ -100,8 +115,11 @@ export function CardInstanceStatus({
const isLoading = isLoadingMetricsInstanceErrors || isLoadingMetricsAutoscalingReached

const isAutoscalingEnabled = match(service)
.with({ serviceType: 'APPLICATION' }, (s) => s.max_running_instances !== s.min_running_instances)
.with({ serviceType: 'CONTAINER' }, (s) => s.max_running_instances !== s.min_running_instances)
.with(
{ serviceType: 'APPLICATION' },
{ serviceType: 'CONTAINER' },
(s) => s.max_running_instances !== s.min_running_instances
)
.otherwise(() => false)

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { useMemo } from 'react'
import { Heading, Icon, Section, Skeleton, Tooltip } from '@qovery/shared/ui'
import { useMetrics } from '../../../hooks/use-metrics/use-metrics'
import { formatTimestamp } from '../../../util-chart/format-timestamp'
import { useDashboardContext } from '../../../util-filter/dashboard-context'

const REASON_COLOR: Record<string, string> = {
// Errors
DiskPressure: 'text-red-500',
MemoryPressure: 'text-red-500',
PIDPressure: 'text-red-500',
NodeNotReady: 'text-red-500',
FailedDraining: 'text-red-500',
DeletingNodeFailed: 'text-red-500',
InvalidDiskCapacity: 'text-red-500',
TerminationGracePeriodExpiring: 'text-red-500',
// Karpenter lifecycle
Launched: 'text-green-500',
DisruptionLaunching: 'text-green-500',
Finalized: 'text-orange-500',
DisruptionTerminating: 'text-orange-500',
InstanceTerminating: 'text-orange-500',
DeletingNode: 'text-orange-500',
}

interface NodeEvent {
key: string
timestamp: number
fullTime: string
objectKind: string
reason: string
}

export function CardNodeEvents({ clusterId }: { clusterId: string }) {
const { startTimestamp, endTimestamp, useLocalTime, timeRange, subQueryTimeRange } = useDashboardContext()

const { data: metrics, isLoading } = useMetrics({
clusterId,
startTimestamp,
endTimestamp,
timeRange,
query: `sum by (reason, object_kind) (
clamp_max(clamp_min(
k8s_event_logger_q_k8s_events_total{
object_kind=~"Node|NodeClaim",
reason=~"DiskPressure|MemoryPressure|PIDPressure|NodeNotReady|FailedDraining|DeletingNodeFailed|InvalidDiskCapacity|TerminationGracePeriodExpiring|Launched|DisruptionLaunching|Finalized|DisruptionTerminating|InstanceTerminating|DeletingNode"
}
- k8s_event_logger_q_k8s_events_total{
object_kind=~"Node|NodeClaim",
reason=~"DiskPressure|MemoryPressure|PIDPressure|NodeNotReady|FailedDraining|DeletingNodeFailed|InvalidDiskCapacity|TerminationGracePeriodExpiring|Launched|DisruptionLaunching|Finalized|DisruptionTerminating|InstanceTerminating|DeletingNode"
} offset ${subQueryTimeRange},
0), 1)
)`,
boardShortName: 'service_overview',
metricShortName: 'node_events',
})

const events = useMemo((): NodeEvent[] => {
if (!metrics?.data?.result) return []

const result: NodeEvent[] = []

for (const series of metrics.data.result) {
const reason = series.metric?.reason as string
const objectKind = (series.metric?.object_kind as string) ?? ''
const values = series.values as [number, string][]

for (let i = 0; i < values.length; i++) {
if (parseFloat(values[i][1]) > 0) {
const timestamp = values[i][0] * 1000
const { fullTimeString } = formatTimestamp(timestamp, useLocalTime)
result.push({
key: `${reason}-${objectKind}-${timestamp}`,
timestamp,
fullTime: fullTimeString,
objectKind,
reason,
})
}
}
}

return result.sort((a, b) => b.timestamp - a.timestamp).slice(0, 50)
}, [metrics, useLocalTime])

if (isLoading) {
return (
<Section className="w-full cursor-default rounded border border-neutral-250 bg-neutral-50">
<div className="flex items-center gap-1.5 px-5 pb-4 pt-4">
<Skeleton show width={200} height={16} />
</div>
<div className="flex flex-col gap-2 px-5 pb-5">
<Skeleton show width="100%" height={28} />
<Skeleton show width="100%" height={28} />
<Skeleton show width="100%" height={28} />
</div>
</Section>
)
}

if (events.length === 0) return null

return (
<Section className="w-full cursor-default rounded border border-neutral-250 bg-neutral-50">
<div className="flex items-center gap-1.5 px-5 pt-4">
<Heading weight="medium">Node infrastructure events</Heading>
<Tooltip content="Node lifecycle and pressure events over the selected time range">
<span>
<Icon iconName="circle-info" iconStyle="regular" className="text-sm text-neutral-350" />
</span>
</Tooltip>
</div>
<div className="max-h-[300px] overflow-y-auto p-5">
<table className="w-full text-ssm">
<thead>
<tr className="border-b border-neutral-200 text-left text-neutral-400">
<th className="pb-2 pr-4 font-medium">Time</th>
<th className="pb-2 pr-4 font-medium">Kind</th>
<th className="pb-2 font-medium">Reason</th>
</tr>
</thead>
<tbody>
{events.map((event) => (
<tr key={event.key} className="border-b border-neutral-100 last:border-0">
<td className="py-2 pr-4 text-neutral-400">{event.fullTime}</td>
<td className="py-2 pr-4 text-neutral-500">{event.objectKind}</td>
<td className={`py-2 font-medium ${REASON_COLOR[event.reason] ?? 'text-neutral-400'}`}>
{event.reason}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
)
}

export default CardNodeEvents
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useState } from 'react'
import { Heading, Icon, Section, Tooltip } from '@qovery/shared/ui'
import { ModalChart } from '../../../modal-chart/modal-chart'
import { CardMetricButton } from '../card-metric/card-metric'
import { NodeStatusChart } from '../node-status-chart/node-status-chart'

const title = 'Node count'
const description = 'Number of ready and not-ready nodes in the cluster over time, with scale up/down events.'

export function CardNodeStatus({ clusterId, serviceId }: { clusterId: string; serviceId: string }) {
const [isModalOpen, setIsModalOpen] = useState(false)

return (
<>
<Section className="w-full cursor-default rounded border border-neutral-250 bg-neutral-50">
<div className="flex items-center justify-between px-5 pt-4">
<div className="flex items-center gap-1.5">
<Heading weight="medium">{title}</Heading>
<Tooltip content={description}>
<span>
<Icon iconName="circle-info" iconStyle="regular" className="text-sm text-neutral-350" />
</span>
</Tooltip>
</div>
<CardMetricButton onClick={() => setIsModalOpen(true)} hasModalLink />
</div>
<div>
<NodeStatusChart clusterId={clusterId} serviceId={serviceId} />
</div>
</Section>
{isModalOpen && (
<ModalChart title={title} description={description} open={isModalOpen} onOpenChange={setIsModalOpen}>
<div className="grid h-full grid-cols-1">
<NodeStatusChart clusterId={clusterId} serviceId={serviceId} isFullscreen />
</div>
</ModalChart>
)}
</>
)
}

export default CardNodeStatus
Loading