Skip to content
Merged
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
12 changes: 12 additions & 0 deletions frontend/src/features/api-keys/hooks/useApiKeysList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { apiKeysHttpService } from '../services/api-keys-http.service'

export const API_KEYS_LIST_QUERY_KEY = ['api-keys', 'list'] as const

export function useApiKeysList() {
return useQuery({
queryKey: API_KEYS_LIST_QUERY_KEY,
queryFn: () => apiKeysHttpService.list(1, 200),
staleTime: 30_000,
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function CustomSetup({ integration }: { integration: Integration }) {
// slug if the catalog row has no explicit data type.
const name = integration.dataType || integration.moduleName?.toLowerCase() || 'my-integration'

const [selection, setSelection] = useState<RemoteEnableSelection>({ proto: 'udp', port: '7100' })
const [selection, setSelection] = useState<RemoteEnableSelection>({ proto: 'udp', port: '7100', isMaster: false, apiKey: null })
const { proto, port } = selection

const isHttp = proto === 'http' || proto === 'https'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, Plus } from 'lucide-react'
import type { ApiKey } from '@/features/api-keys/types/api-key.types'

export interface ApiKeyPickerProps {
keys: ApiKey[]
value: number | null
onChange: (id: number) => void
onAddNew: () => void
addLabel: string
placeholder: string
emptyLabel: string
disabled?: boolean
}

export function ApiKeyPicker({ keys, value, onChange, onAddNew, addLabel, placeholder, emptyLabel, disabled }: ApiKeyPickerProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const selected = keys.find((k) => k.id === value) ?? null

useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => ref.current && !ref.current.contains(e.target as Node) && setOpen(false)
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [open])

return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={disabled}
className="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-border bg-background px-3 text-sm text-foreground disabled:opacity-70"
>
<span className="truncate">{selected ? selected.name : placeholder}</span>
<ChevronDown size={14} className="shrink-0 text-muted-foreground" />
</button>
{open && (
<div className="absolute left-0 top-full z-30 mt-1 w-full rounded-md border border-border bg-popover py-1 shadow-lg">
{keys.length === 0 && (
<span className="block px-3 py-1.5 text-sm text-muted-foreground">{emptyLabel}</span>
)}
{keys.map((k) => (
<button
key={k.id}
type="button"
onClick={() => { onChange(k.id); setOpen(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-muted"
>
<span className="truncate">{k.name}</span>
</button>
))}
<button
type="button"
onClick={() => { onAddNew(); setOpen(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-muted"
>
<Plus size={12} className="shrink-0 text-muted-foreground" />
<span className="truncate">{addLabel}</span>
</button>
</div>
)}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useMemo, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDown, Forward, Server, ShieldCheck } from 'lucide-react'
import { cn } from '@/shared/lib/utils'
import { Section } from '@/features/integrations/components/ui/Section'
import { CodeBlock } from '@/features/integrations/components/ui/CodeBlock'
import { useConectionKey } from '@/features/integrations/hooks/useConnectionKey'
import { FlowNode, FlowEdge } from '@/shared/components/ui/flow-diagram'
import { useCollectorIntegration } from '@/features/integrations/hooks/useCollectorIntegration'
import { RemoteEnablePanel, type RemoteEnableSelection } from './RemoteEnablePanel'
import { availableProtosFor, defaultPortFor, type Proto } from './protoCatalog'

Expand Down Expand Up @@ -66,6 +65,24 @@ function FlowDiagram({ source, port }: { source: string; port: string }) {
)
}

// ── Master command (POST endpoint + auth header) ─────────────────────────────

function MasterCommandSection({ selection }: { selection: RemoteEnableSelection }) {
const { t } = useTranslation()
if (!selection.apiKey) return null
const host = forwarderHost()
// ponytail: secret is generated once server-side; user must paste it themselves
const cmd = `POST ${selection.proto}://${host}:8080/v1/logs
Authorization: Bearer <YOUR_API_KEY_SECRET>
Content-Type: application/json`
return (
<Section title={t(`${SHARED}.masterHeader.title`)} step={2}>
<p className="text-sm text-foreground/90">{t(`${SHARED}.masterHeader.body`, { name: selection.apiKey.name })}</p>
<CodeBlock code={cmd} />
</Section>
)
}

// ── Manual (CLI) command — collapsible, reactive to the RemoteEnablePanel ────
// selection above it. Replaces what used to be a fixed "Optional — Enable TLS
// encryption" block: the command shown here always matches whatever
Expand Down Expand Up @@ -115,22 +132,22 @@ function ManualCommandSection({ sourceType, selection }: { sourceType: string; s

// ── Forwarder install section ─────────────────────────────────────────────────

export function ForwarderInstall({ source }: { source: string }) {
const { t } = useTranslation()
const { key } = useConectionKey()
const host = forwarderHost()

// Token is always shown as ••••••• in the UI; the real value is copied on click.
// Falls back to "*******" if the key hasn't loaded or the request failed.
const token = key.data?.connectionKey ?? '*'.repeat(7)
const installCmd = `sudo bash -c "
function buildForwarderInstallCmd(host: string, token: string): string {
return `sudo bash -c "
apt update -y && apt install wget -y && \\
mkdir -p /opt/utmstack-forwarder && \\
wget --no-check-certificate -P /opt/utmstack-forwarder \\
https://${host}:9001/private/dependencies/collector/forwarder/utmstack_forwarder && \\
chmod 755 /opt/utmstack-forwarder/utmstack_forwarder && \\
/opt/utmstack-forwarder/utmstack_forwarder install ${host} <secret>${token}</secret> yes
"`
}

export function ForwarderInstall({ source }: { source: string }) {
const { t } = useTranslation()
const { key } = useConectionKey()
const token = key.data?.connectionKey ?? '*'.repeat(7)
const installCmd = buildForwarderInstallCmd(forwarderHost(), token)

return (
<Section title={t(`${SHARED}.install.title`)} step={1}>
Expand Down Expand Up @@ -160,14 +177,21 @@ interface ForwarderGuideProps {

export function ForwarderGuide({ source, port, sourceType, defaultProto, children }: ForwarderGuideProps) {
const { t } = useTranslation()
const { forwarders } = useCollectorIntegration()
const hasOnlineForwarder = (forwarders.data ?? []).some((f) => f.status === 'online')
const availableProtos = useMemo(() => availableProtosFor(sourceType), [sourceType])
const initialProto = defaultProto ?? availableProtos[0]
const [selection, setSelection] = useState<RemoteEnableSelection>(() => ({
proto: initialProto,
port: defaultPortFor(sourceType, initialProto) || port,
isMaster: false,
apiKey: null,
}))
const [installOpen, setInstallOpen] = useState(false)
const installRef = useRef<HTMLDivElement>(null)

const handleAddCollector = () => {
setInstallOpen(true)
setTimeout(() => installRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50)
}

return (
<div className="space-y-4">
Expand All @@ -179,21 +203,56 @@ export function ForwarderGuide({ source, port, sourceType, defaultProto, childre
</p>
</Section>

{!forwarders.isLoading && !hasOnlineForwarder && <ForwarderInstall source={source} />}

<RemoteEnablePanel
dataType={sourceType}
availableProtos={availableProtos}
defaultProto={defaultProto}
step={2}
step={1}
onSelectionChange={setSelection}
onRequestAddCollector={handleAddCollector}
/>

{/* Vendor-specific steps (device-side config). */}
{children}
{selection.isMaster && selection.apiKey && <MasterCommandSection selection={selection} />}

{/* Vendor-specific steps (device-side config). Hidden in master mode — it targets the forwarder, not master. */}
{!selection.isMaster && children}

<ManualCommandSection sourceType={sourceType} selection={selection} />
<ForwarderUninstallSection />
{!selection.isMaster && (
<div ref={installRef}>
<ForwarderInstallSection source={source} open={installOpen} onToggle={() => setInstallOpen((o) => !o)} />
</div>
)}
{!selection.isMaster && <ManualCommandSection sourceType={sourceType} selection={selection} />}
{!selection.isMaster && <ForwarderUninstallSection />}
</div>
)
}

function ForwarderInstallSection({ source, open, onToggle }: { source: string; open: boolean; onToggle: () => void }) {
const { t } = useTranslation()
const { key } = useConectionKey()

const token = key.data?.connectionKey ?? '*'.repeat(7)
const installCmd = buildForwarderInstallCmd(forwarderHost(), token)

return (
<div className="overflow-hidden rounded-lg border border-border">
<button
onClick={onToggle}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium hover:bg-muted/40 transition-colors"
>
<span>{t(`${SHARED}.install.title`)}</span>
<ChevronDown size={14} className={cn('shrink-0 text-muted-foreground transition-transform duration-200', open && 'rotate-180')} />
</button>
{open && (
<div className="space-y-3 border-t border-border px-4 pb-4 pt-3">
<p className="text-sm text-foreground/90">{t(`${SHARED}.install.body`)}</p>
<CodeBlock code={installCmd} />
<p className="rounded-md bg-muted/40 px-3 py-2 text-[11px] text-muted-foreground">
{t(`${SHARED}.install.reuse`, { source })}
</p>
</div>
)}
</div>
)
}
Expand Down
Loading
Loading