ResourceWarningsTab.tsx232 lines · main
1'use client'
2
3import { useQueryClient } from '@tanstack/react-query'
4import { useParams } from 'common'
5import { useEffect, useRef, useState } from 'react'
6import { cn } from 'ui'
7
8import { usageKeys } from '@/data/usage/keys'
9import type { ResourceWarning } from '@/data/usage/resource-warnings-query'
10import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
11
12type Severity = 'warning' | 'critical' | null
13
14const WARNING_TYPES = [
15 { key: 'disk_io_exhaustion', label: 'Disk IO', hasCritical: true },
16 { key: 'cpu_exhaustion', label: 'CPU', hasCritical: true },
17 { key: 'memory_and_swap_exhaustion', label: 'Memory & Swap', hasCritical: true },
18 { key: 'disk_space_exhaustion', label: 'Disk Space', hasCritical: true },
19 { key: 'auth_rate_limit_exhaustion', label: 'Auth Rate Limit', hasCritical: false },
20] as const
21
22type WarningKey = (typeof WARNING_TYPES)[number]['key']
23type WarningState = Record<WarningKey, Severity>
24
25const INITIAL_STATE: WarningState = {
26 disk_io_exhaustion: null,
27 cpu_exhaustion: null,
28 memory_and_swap_exhaustion: null,
29 disk_space_exhaustion: null,
30 auth_rate_limit_exhaustion: null,
31}
32
33export const ResourceWarningsTab = () => {
34 const { ref } = useParams()
35 const queryClient = useQueryClient()
36 const { data: selectedOrg, isLoading: isOrgLoading } = useSelectedOrganizationQuery()
37 const orgSlug = selectedOrg?.slug
38 const [isReadOnly, setIsReadOnly] = useState(false)
39 const [severities, setSeverities] = useState<WarningState>(INITIAL_STATE)
40
41 // Track latest ref/orgSlug so the unmount cleanup always invalidates the
42 // correct cache keys, even when orgSlug was still loading at mount time.
43 const latestValues = useRef({ ref, orgSlug })
44 useEffect(() => {
45 latestValues.current = { ref, orgSlug }
46 })
47
48 // Only invalidate on unmount if overrides were actually applied to avoid
49 // unnecessary refetches when the user opens the toolbar but never uses
50 // the Warnings tab.
51 const hasOverridesRef = useRef(false)
52
53 // Invalidate both cache keys on unmount so banners revert to real data
54 // when the toolbar sheet closes (which unmounts this component).
55 useEffect(() => {
56 return () => {
57 if (!hasOverridesRef.current) return
58 const { ref: latestRef, orgSlug: latestOrgSlug } = latestValues.current
59 queryClient.invalidateQueries({ queryKey: usageKeys.resourceWarnings(undefined, latestRef) })
60 if (latestOrgSlug) {
61 queryClient.invalidateQueries({
62 queryKey: usageKeys.resourceWarnings(latestOrgSlug, undefined),
63 })
64 }
65 }
66 // eslint-disable-next-line react-hooks/exhaustive-deps
67 }, [])
68
69 // When navigating to a different project: reset UI state and invalidate the
70 // departing project's cache keys (the cleanup closure captures the old ref).
71 // This component never unmounts during client-side navigation, so without
72 // this project A's mocked banners would linger on project B.
73 useEffect(() => {
74 return () => {
75 if (!hasOverridesRef.current) return
76 queryClient.invalidateQueries({ queryKey: usageKeys.resourceWarnings(undefined, ref) })
77 const { orgSlug: currentOrgSlug } = latestValues.current
78 if (currentOrgSlug) {
79 queryClient.invalidateQueries({
80 queryKey: usageKeys.resourceWarnings(currentOrgSlug, undefined),
81 })
82 }
83 setSeverities(INITIAL_STATE)
84 setIsReadOnly(false)
85 hasOverridesRef.current = false
86 }
87 // eslint-disable-next-line react-hooks/exhaustive-deps
88 }, [ref])
89
90 const applyOverrides = (nextSeverities: WarningState, nextIsReadOnly: boolean) => {
91 if (!orgSlug || !ref) return
92 const mockWarning: ResourceWarning = {
93 project: ref,
94 is_readonly_mode_enabled: nextIsReadOnly,
95 ...nextSeverities,
96 auth_email_offender: null,
97 auth_restricted_email_sending: null,
98 need_pitr: null,
99 }
100 // Write to both cache keys: ref-based (ResourceExhaustionWarningBanner) and
101 // slug-based (ProjectLayout, TopSection, ProjectList) consumers.
102 queryClient.setQueryData(usageKeys.resourceWarnings(undefined, ref), [mockWarning])
103 queryClient.setQueryData(usageKeys.resourceWarnings(orgSlug, undefined), [mockWarning])
104 hasOverridesRef.current = true
105 }
106
107 const handleSeverityChange = (key: WarningKey, value: Severity) => {
108 const next = { ...severities, [key]: value }
109 setSeverities(next)
110 applyOverrides(next, isReadOnly)
111 }
112
113 const handleReadOnlyChange = (value: boolean) => {
114 setIsReadOnly(value)
115 applyOverrides(severities, value)
116 }
117
118 const handleReset = () => {
119 setSeverities(INITIAL_STATE)
120 setIsReadOnly(false)
121 hasOverridesRef.current = false
122 queryClient.invalidateQueries({ queryKey: usageKeys.resourceWarnings(undefined, ref) })
123 if (orgSlug) {
124 queryClient.invalidateQueries({ queryKey: usageKeys.resourceWarnings(orgSlug, undefined) })
125 }
126 }
127
128 // Disabled when org is loading, org slug is unavailable, or we're not on a
129 // project page (ref is undefined on org-level pages like /org/[slug]/settings).
130 const isDisabled = isOrgLoading || !orgSlug || !ref
131
132 return (
133 <div className="p-6 space-y-4">
134 <div className="flex items-center justify-between">
135 <p className="text-sm text-foreground-light">
136 Override resource warning banners for the current project.
137 </p>
138 <button
139 onClick={handleReset}
140 disabled={isDisabled}
141 className="text-xs text-foreground-lighter hover:text-foreground transition underline disabled:opacity-50 disabled:cursor-not-allowed"
142 >
143 Reset to real data
144 </button>
145 </div>
146
147 {isDisabled && (
148 <p className="text-xs text-foreground-muted">
149 {!ref ? 'Navigate to a project page to use this tab.' : 'Loading org context...'}
150 </p>
151 )}
152
153 <div className={cn('space-y-3', isDisabled && 'opacity-50 pointer-events-none')}>
154 <div className="flex items-center justify-between py-2 border-b border-overlay">
155 <span className="text-sm font-medium">Read-only mode</span>
156 <div className="flex gap-1">
157 <SeverityButton
158 active={!isReadOnly}
159 variant="off"
160 onClick={() => handleReadOnlyChange(false)}
161 >
162 Off
163 </SeverityButton>
164 <SeverityButton
165 active={isReadOnly}
166 variant="critical"
167 onClick={() => handleReadOnlyChange(true)}
168 >
169 On
170 </SeverityButton>
171 </div>
172 </div>
173
174 {WARNING_TYPES.map(({ key, label, hasCritical }) => (
175 <div key={key} className="flex items-center justify-between">
176 <span className="text-sm text-foreground-light">{label}</span>
177 <div className="flex gap-1">
178 <SeverityButton
179 active={severities[key] === null}
180 variant="off"
181 onClick={() => handleSeverityChange(key, null)}
182 >
183 Off
184 </SeverityButton>
185 <SeverityButton
186 active={severities[key] === 'warning'}
187 variant="warning"
188 onClick={() => handleSeverityChange(key, 'warning')}
189 >
190 Warn
191 </SeverityButton>
192 {hasCritical && (
193 <SeverityButton
194 active={severities[key] === 'critical'}
195 variant="critical"
196 onClick={() => handleSeverityChange(key, 'critical')}
197 >
198 Crit
199 </SeverityButton>
200 )}
201 </div>
202 </div>
203 ))}
204 </div>
205 </div>
206 )
207}
208
209interface SeverityButtonProps {
210 active: boolean
211 variant: 'off' | 'warning' | 'critical'
212 onClick: () => void
213 children: React.ReactNode
214}
215
216const SeverityButton = ({ active, variant, onClick, children }: SeverityButtonProps) => (
217 <button
218 onClick={onClick}
219 className={cn(
220 'px-1.5 py-0.5 rounded-sm text-xs font-mono transition border',
221 active
222 ? variant === 'off'
223 ? 'bg-surface-300 text-foreground border-strong'
224 : variant === 'warning'
225 ? 'bg-warning/20 text-warning border-warning'
226 : 'bg-destructive/20 text-destructive border-destructive'
227 : 'bg-transparent text-foreground-muted border-transparent hover:border-border'
228 )}
229 >
230 {children}
231 </button>
232)