Skip to content

Commit 3de37a5

Browse files
committed
chore: add response headers tab to external squads drawer
1 parent 9a98327 commit 3de37a5

File tree

6 files changed

+300
-13
lines changed

6 files changed

+300
-13
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"@monaco-editor/react": "^4.7.0",
6060
"@noble/post-quantum": "^0.5.2",
6161
"@paralleldrive/cuid2": "2.2.2",
62-
"@remnawave/backend-contract": "2.2.32",
62+
"@remnawave/backend-contract": "2.2.34",
6363
"@simplewebauthn/browser": "^13.2.2",
6464
"@stablelib/base64": "^2.0.1",
6565
"@stablelib/x25519": "^2.0.1",
@@ -194,4 +194,4 @@
194194
"inquirer": "9.3.5"
195195
}
196196
}
197-
}
197+
}

public/locales/en/remnawave.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1791,5 +1791,10 @@
17911791
"feature": {
17921792
"reset-traffic": "Reset Traffic"
17931793
}
1794+
},
1795+
"external-squads-response-headers": {
1796+
"widget": {
1797+
"response-headers": "Headers"
1798+
}
17941799
}
17951800
}

src/widgets/dashboard/external-squads/external-squads-drawer/external-squads.drawer.widget.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import {
1313
Tooltip,
1414
Transition
1515
} from '@mantine/core'
16+
import { TbFolder, TbPrescription, TbSettings, TbWebhook } from 'react-icons/tb'
1617
import { PiCheck, PiCopy, PiListChecks, PiUsers } from 'react-icons/pi'
17-
import { TbFolder, TbSettings, TbWebhook } from 'react-icons/tb'
1818
import { useTranslation } from 'react-i18next'
1919
import { memo, useState } from 'react'
2020

@@ -28,12 +28,14 @@ import {
2828
ExternalSquadsSettingsTabWidget,
2929
ExternalSquadsTemplatesTabWidget
3030
} from './tabs'
31+
import { ExternalSquadsResponseHeadersTabWidget } from './tabs/external-squads-response-headers.widget'
3132
import classes from './external-squads.module.css'
3233

3334
const TAB_TYPE = {
3435
settings: 'settings',
3536
templates: 'templates',
36-
hosts: 'hosts'
37+
hosts: 'hosts',
38+
responseHeaders: 'responseHeaders'
3739
} as const
3840

3941
type TabType = (typeof TAB_TYPE)[keyof typeof TAB_TYPE]
@@ -176,6 +178,12 @@ export const ExternalSquadsDrawer = memo(() => {
176178
>
177179
{t('constants.hosts')}
178180
</Tabs.Tab>
181+
<Tabs.Tab
182+
leftSection={<TbPrescription size={px('1.2rem')} />}
183+
value={TAB_TYPE.responseHeaders}
184+
>
185+
{t('external-squads-response-headers.widget.response-headers')}
186+
</Tabs.Tab>
179187
</Tabs.List>
180188

181189
<Tabs.Panel pt="xl" value={TAB_TYPE.templates}>
@@ -231,6 +239,24 @@ export const ExternalSquadsDrawer = memo(() => {
231239
)}
232240
</Transition>
233241
</Tabs.Panel>
242+
243+
<Tabs.Panel pt="xl" value={TAB_TYPE.responseHeaders}>
244+
<Transition
245+
duration={200}
246+
mounted={activeTab === TAB_TYPE.responseHeaders}
247+
timingFunction="linear"
248+
transition="fade"
249+
>
250+
{(styles) => (
251+
<Stack gap="lg" style={styles}>
252+
<ExternalSquadsResponseHeadersTabWidget
253+
externalSquad={externalSquad}
254+
isOpen={isOpen}
255+
/>
256+
</Stack>
257+
)}
258+
</Transition>
259+
</Tabs.Panel>
234260
</Tabs>
235261
</Stack>
236262
)

src/widgets/dashboard/external-squads/external-squads-drawer/external-squads.module.css

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,21 @@
99

1010
.tab {
1111
position: relative;
12-
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
13-
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
14-
12+
border: 1px solid var(--mantine-color-dark-4);
13+
background-color: var(--mantine-color-dark-6);
1514
border-radius: 8px;
1615
margin: 4px;
1716

1817
@mixin hover {
19-
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
18+
background-color: var(--mantine-color-dark-5);
2019
}
2120

2221
&[data-active] {
2322
z-index: 1;
2423
background-color: transparent;
25-
border: 2px solid var(--mantine-color-cyan-filled);
24+
border: 1px solid transparent;
25+
outline: 2px solid var(--mantine-color-cyan-filled);
26+
outline-offset: -2px;
2627

2728
@mixin hover {
2829
background-color: var(--mantine-color-cyan-outline-hover);
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { ActionIcon, Alert, Button, Group, Paper, Stack, Text, TextInput } from '@mantine/core'
2+
import { GetExternalSquadByUuidCommand } from '@remnawave/backend-contract'
3+
import { useCallback, useEffect, useRef, useState } from 'react'
4+
import { useAutoAnimate } from '@formkit/auto-animate/react'
5+
import { PiInfo, PiPlus, PiTrash } from 'react-icons/pi'
6+
import { TbDeviceFloppy } from 'react-icons/tb'
7+
import { useTranslation } from 'react-i18next'
8+
9+
import { TemplateInfoPopoverShared } from '@shared/ui/popovers/template-info-popover/template-info-popover.shared'
10+
import { QueryKeys, useUpdateExternalSquad } from '@shared/api/hooks'
11+
import { queryClient } from '@shared/api'
12+
13+
interface HeaderItem {
14+
key: string
15+
value: string
16+
}
17+
18+
interface IProps {
19+
externalSquad: GetExternalSquadByUuidCommand.Response['response']
20+
isOpen: boolean
21+
}
22+
23+
const HEADER_NAME_REGEX = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/
24+
const HEADER_VALUE_REGEX = /^[\x21-\x7E]([\x20-\x7E]*[\x21-\x7E])?$/
25+
26+
export const ExternalSquadsResponseHeadersTabWidget = (props: IProps) => {
27+
const { externalSquad, isOpen } = props
28+
const { t } = useTranslation()
29+
30+
const [headers, setHeaders] = useState<HeaderItem[]>([])
31+
const [localHeaders, setLocalHeaders] = useState<HeaderItem[]>(headers)
32+
33+
const isInitializedRef = useRef(false)
34+
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
35+
36+
const [error, setError] = useState<null | string>(null)
37+
const [parent] = useAutoAnimate({
38+
duration: 100,
39+
easing: 'ease-in-out',
40+
disrespectUserMotionPreference: false
41+
})
42+
43+
const updateHeaders = useCallback((newHeaders: HeaderItem[]) => {
44+
setHeaders(newHeaders)
45+
}, [])
46+
47+
const { mutate: updateExternalSquad, isPending: isUpdatingExternalSquad } =
48+
useUpdateExternalSquad({
49+
mutationFns: {
50+
onSuccess: (data) => {
51+
queryClient.setQueryData(
52+
QueryKeys.externalSquads.getExternalSquad({
53+
uuid: data.uuid
54+
}).queryKey,
55+
data
56+
)
57+
setError(null)
58+
},
59+
onError: (err) => {
60+
setError(err instanceof Error ? err.message : 'Unknown error occurred')
61+
}
62+
}
63+
})
64+
65+
const handleUpdateExternalSquad = () => {
66+
if (!externalSquad?.uuid) return
67+
68+
const headersFiltered = headers
69+
.map((header) => ({
70+
key: header.key.trim(),
71+
value: header.value.trim()
72+
}))
73+
.filter((header) => header.key !== '')
74+
75+
const seen = new Set<string>()
76+
const uniqueHeaders: HeaderItem[] = []
77+
for (let i = headersFiltered.length - 1; i >= 0; i--) {
78+
const header = headersFiltered[i]
79+
if (!seen.has(header.key)) {
80+
uniqueHeaders.unshift(header)
81+
seen.add(header.key)
82+
}
83+
}
84+
85+
for (const header of uniqueHeaders) {
86+
if (!HEADER_NAME_REGEX.test(header.key)) {
87+
setError(`Invalid header name: ${header.key}`)
88+
return
89+
}
90+
if (!HEADER_VALUE_REGEX.test(header.value)) {
91+
setError(`Invalid header value: ${header.value}`)
92+
return
93+
}
94+
}
95+
96+
const responseHeaders: Record<string, string> = {}
97+
uniqueHeaders.forEach((header) => {
98+
responseHeaders[header.key] = header.value
99+
})
100+
101+
setLocalHeaders(uniqueHeaders)
102+
103+
updateExternalSquad({
104+
variables: {
105+
uuid: externalSquad.uuid,
106+
responseHeaders
107+
}
108+
})
109+
}
110+
111+
useEffect(() => {
112+
if (isOpen && externalSquad) {
113+
if (
114+
externalSquad.responseHeaders &&
115+
typeof externalSquad.responseHeaders === 'object' &&
116+
externalSquad.responseHeaders !== null
117+
) {
118+
const headerItems = Object.entries(externalSquad.responseHeaders).map(
119+
([key, value]) => ({ key, value: String(value) })
120+
)
121+
setHeaders(headerItems)
122+
} else {
123+
setHeaders([])
124+
}
125+
}
126+
}, [isOpen, externalSquad])
127+
128+
useEffect(() => {
129+
if (!isInitializedRef.current && headers.length > 0) {
130+
if (!(headers.length === 1 && headers[0].key === '' && headers[0].value === '')) {
131+
setLocalHeaders(headers)
132+
}
133+
isInitializedRef.current = true
134+
}
135+
}, [headers])
136+
137+
useEffect(() => {
138+
if (timeoutRef.current) {
139+
clearTimeout(timeoutRef.current)
140+
}
141+
142+
timeoutRef.current = setTimeout(() => {
143+
updateHeaders(localHeaders)
144+
}, 100)
145+
146+
return () => {
147+
if (timeoutRef.current) {
148+
clearTimeout(timeoutRef.current)
149+
}
150+
}
151+
}, [localHeaders, updateHeaders])
152+
153+
const addLocalHeader = useCallback(() => {
154+
setLocalHeaders((prev) => [...prev, { key: '', value: '' }])
155+
}, [])
156+
157+
const removeLocalHeader = useCallback((index: number) => {
158+
setLocalHeaders((prev) => {
159+
const newHeaders = [...prev]
160+
newHeaders.splice(index, 1)
161+
return newHeaders
162+
})
163+
}, [])
164+
165+
const updateLocalHeaderKey = useCallback((index: number, key: string) => {
166+
setLocalHeaders((prev) => {
167+
const newHeaders = [...prev]
168+
newHeaders[index] = { ...newHeaders[index], key }
169+
return newHeaders
170+
})
171+
}, [])
172+
173+
const updateLocalHeaderValue = useCallback((index: number, value: string) => {
174+
setLocalHeaders((prev) => {
175+
const newHeaders = [...prev]
176+
newHeaders[index] = { ...newHeaders[index], value }
177+
return newHeaders
178+
})
179+
}, [])
180+
181+
return (
182+
<Paper bg="dark.6" p="md" shadow="sm" withBorder>
183+
<Stack gap="md">
184+
<Text fw={600} size="md">
185+
{t('external-squads-response-headers.widget.response-headers')}
186+
</Text>
187+
<Text c="dimmed" size="sm">
188+
{t(
189+
'subscription-tabs.widget.headers-that-will-be-sent-with-subscription-content'
190+
)}
191+
</Text>
192+
193+
<Stack gap="xs" ref={parent}>
194+
{localHeaders.map((header, index) => (
195+
<Group align="flex-start" gap="sm" key={index}>
196+
<TextInput
197+
onChange={(e) => updateLocalHeaderKey(index, e.target.value)}
198+
placeholder={t('headers-manager.widget.key')}
199+
style={{ flex: '0 0 35%' }}
200+
value={header.key}
201+
/>
202+
<TextInput
203+
leftSection={
204+
<TemplateInfoPopoverShared showHostDescription={false} />
205+
}
206+
onChange={(e) => updateLocalHeaderValue(index, e.target.value)}
207+
placeholder={t('headers-manager.widget.value')}
208+
style={{ flex: '1' }}
209+
value={header.value}
210+
/>
211+
<ActionIcon
212+
color="red"
213+
onClick={() => removeLocalHeader(index)}
214+
size="input-sm"
215+
variant="light"
216+
>
217+
<PiTrash size="16px" />
218+
</ActionIcon>
219+
</Group>
220+
))}
221+
</Stack>
222+
223+
{error && (
224+
<Alert color="red" icon={<PiInfo />}>
225+
{error}
226+
</Alert>
227+
)}
228+
229+
<Group justify="flex-end" mt="md">
230+
<Button
231+
leftSection={<PiPlus size="16px" />}
232+
onClick={addLocalHeader}
233+
size="md"
234+
variant="light"
235+
>
236+
{t('headers-manager.widget.add-header')}
237+
</Button>
238+
<Button
239+
color="teal"
240+
leftSection={<TbDeviceFloppy size="1.2rem" />}
241+
loading={isUpdatingExternalSquad}
242+
onClick={handleUpdateExternalSquad}
243+
size="md"
244+
style={{
245+
transition: 'all 0.2s ease'
246+
}}
247+
variant="light"
248+
>
249+
{t('common.save')}
250+
</Button>
251+
</Group>
252+
</Stack>
253+
</Paper>
254+
)
255+
}

0 commit comments

Comments
 (0)