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
@@ -0,0 +1,39 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import axios from 'axios'
import { API_ROUTES, API_URL } from 'data-services/constants'
import { getAuthHeader } from 'data-services/utils'
import { useUser } from 'utils/user/userContext'

interface GenerateAPIKeyResponse {
api_key: string
prefix: string
message: string
}

export const useGenerateAPIKey = (projectId?: string) => {
const { user } = useUser()
const queryClient = useQueryClient()
const params = projectId ? `?project_id=${projectId}` : ''

const { mutateAsync, isLoading, isSuccess, error, data } = useMutation({
mutationFn: (id: string) =>
axios.post<GenerateAPIKeyResponse>(
`${API_URL}/${API_ROUTES.PROCESSING_SERVICES}/${id}/generate_key/${params}`,
undefined,
{
headers: getAuthHeader(user),
}
),
onSuccess: () => {
queryClient.invalidateQueries([API_ROUTES.PROCESSING_SERVICES])
},
})

return {
generateAPIKey: mutateAsync,
isLoading,
isSuccess,
error,
apiKey: data?.data.api_key,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,23 @@ const convertServerRecord = (record: ServerProcessingService) =>
new ProcessingService(record)

export const useProcessingServiceDetails = (
processingServiceId: string
processingServiceId: string,
projectId?: string
): {
processingService?: ProcessingService
isLoading: boolean
isFetching: boolean
error?: unknown
} => {
const params = projectId ? `?project_id=${projectId}` : ''
const { data, isLoading, isFetching, error } =
useAuthorizedQuery<ProcessingService>({
queryKey: [API_ROUTES.PROCESSING_SERVICES, processingServiceId],
url: `${API_URL}/${API_ROUTES.PROCESSING_SERVICES}/${processingServiceId}`,
queryKey: [
API_ROUTES.PROCESSING_SERVICES,
processingServiceId,
projectId,
],
url: `${API_URL}/${API_ROUTES.PROCESSING_SERVICES}/${processingServiceId}/${params}`,
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hook hand-builds the details URL and always introduces a trailing slash when projectId is undefined (.../${processingServiceId}/). Elsewhere the UI uses getFetchDetailsUrl() which omits the trailing slash when there are no query params and adds /?... only when needed. Please switch to getFetchDetailsUrl({ collection, itemId, projectId }) (or mirror its behavior) to keep URL formatting consistent and avoid possible redirect/404 differences between /id vs /id/.

Suggested change
url: `${API_URL}/${API_ROUTES.PROCESSING_SERVICES}/${processingServiceId}/${params}`,
url: `${API_URL}/${API_ROUTES.PROCESSING_SERVICES}/${processingServiceId}${params}`,

Copilot uses AI. Check for mistakes.
})

const processingService = useMemo(
Expand Down
21 changes: 20 additions & 1 deletion ui/src/data-services/models/processing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ export class ProcessingService extends Entity {
return this._processingService.last_seen_live ?? false
}

get apiKeyPrefix(): string | undefined {
return this._processingService.api_key_prefix ?? undefined
}

get lastSeenClientInfo():
| {
hostname?: string
software?: string
version?: string
platform?: string
ip?: string
user_agent?: string
}
| undefined {
return this._processingService.last_seen_client_info ?? undefined
}

get numPiplinesAdded(): number {
return this._pipelines.length
}
Expand All @@ -80,7 +97,9 @@ export class ProcessingService extends Entity {
color: string
} {
if (this.isAsync) {
return ProcessingService.getStatusInfo('UNKNOWN')
// Async services derive status from heartbeat
const status_code = this.lastSeenLive ? 'ONLINE' : 'UNKNOWN'
return ProcessingService.getStatusInfo(status_code)
}
const status_code = this.lastSeenLive ? 'ONLINE' : 'OFFLINE'
return ProcessingService.getStatusInfo(status_code)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ProcessingService } from 'data-services/models/processing-service'
import * as Dialog from 'design-system/components/dialog/dialog'
import { InputValue } from 'design-system/components/input/input'
import _ from 'lodash'
import { GenerateAPIKey } from 'pages/project/processing-services/processing-services-actions'
import { useNavigate, useParams } from 'react-router-dom'
import { APP_ROUTES } from 'utils/constants'
import { getAppRoute } from 'utils/getAppRoute'
Expand All @@ -14,8 +15,10 @@ import styles from './styles.module.scss'
export const ProcessingServiceDetailsDialog = ({ id }: { id: string }) => {
const navigate = useNavigate()
const { projectId } = useParams()
const { processingService, isLoading, error } =
useProcessingServiceDetails(id)
const { processingService, isLoading, error } = useProcessingServiceDetails(
id,
projectId
)

return (
<Dialog.Root
Expand Down Expand Up @@ -82,7 +85,6 @@ const ProcessingServiceDetailsContent = ({
value={processingService.lastSeen}
/>
</FormRow>

<FormRow>
<InputValue
label={translate(STRING.FIELD_LABEL_CREATED_AT)}
Expand All @@ -94,6 +96,51 @@ const ProcessingServiceDetailsContent = ({
/>
</FormRow>
</FormSection>
<FormSection title="Authentication">
<FormRow>
<InputValue
label="API Key Prefix"
value={processingService.apiKeyPrefix ?? 'No key generated'}
/>
<InputValue
label="Mode"
value={processingService.isAsync ? 'Pull (async)' : 'Push (sync)'}
/>
</FormRow>
<GenerateAPIKey processingService={processingService} />
</FormSection>
{processingService.lastSeenClientInfo && (
<FormSection title="Last Known Worker">
<FormRow>
<InputValue
label="Hostname"
value={processingService.lastSeenClientInfo.hostname}
/>
<InputValue
label="Software"
value={
processingService.lastSeenClientInfo.software &&
processingService.lastSeenClientInfo.version
? `${processingService.lastSeenClientInfo.software} ${processingService.lastSeenClientInfo.version}`
: processingService.lastSeenClientInfo.software ||
(processingService.lastSeenClientInfo.version
? `v${processingService.lastSeenClientInfo.version}`
: undefined)
}
/>
</FormRow>
<FormRow>
<InputValue
label="Platform"
value={processingService.lastSeenClientInfo.platform}
/>
<InputValue
label="Remote Address"
value={processingService.lastSeenClientInfo.ip}
/>
</FormRow>
</FormSection>
)}
{processingService.pipelines.length > 0 && (
<FormSection title={translate(STRING.PIPELINES)}>
<div className={styles.tableContainer}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@ const config: FormConfig = {
},
endpoint_url: {
label: 'Endpoint URL',
description: 'Processing service endpoint.',
rules: {
required: true,
},
description:
'Processing service endpoint. Leave empty for pull-mode services that register themselves.',
},
description: {
label: translate(STRING.FIELD_LABEL_DESCRIPTION),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import classNames from 'classnames'
import { useGenerateAPIKey } from 'data-services/hooks/processing-services/useGenerateAPIKey'
import { usePopulateProcessingService } from 'data-services/hooks/processing-services/usePopulateProcessingService'
import { ProcessingService } from 'data-services/models/processing-service'
import { BasicTooltip } from 'design-system/components/tooltip/basic-tooltip'
import { AlertCircleIcon, Loader2 } from 'lucide-react'
import { AlertCircleIcon, Eye, EyeOff, KeyRound, Loader2 } from 'lucide-react'
import { Button } from 'nova-ui-kit'
import { useState } from 'react'
import { useParams } from 'react-router-dom'
import { STRING, translate } from 'utils/language'

export const PopulateProcessingService = ({
Expand Down Expand Up @@ -37,3 +40,83 @@ export const PopulateProcessingService = ({
</BasicTooltip>
)
}

export const GenerateAPIKey = ({
processingService,
}: {
processingService: ProcessingService
}) => {
const { projectId } = useParams()
const { generateAPIKey, isLoading, error, apiKey } =
useGenerateAPIKey(projectId)
const [copied, setCopied] = useState(false)
const [visible, setVisible] = useState(false)

const handleCopy = async () => {
if (apiKey) {
await navigator.clipboard.writeText(apiKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
Comment on lines +55 to +60
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleCopy awaits navigator.clipboard.writeText(apiKey) without any error handling. In non-secure contexts or when clipboard permissions are denied, this will throw and can result in an unhandled promise rejection (and the UI never reflects the failure). Wrap the clipboard call in try/catch (and/or guard on navigator.clipboard) and provide a fallback/error message; also consider clearing the 2s timeout on unmount to avoid setting state after the dialog closes.

Copilot uses AI. Check for mistakes.
}
Comment on lines +55 to +61
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for clipboard API.

navigator.clipboard.writeText() can fail (e.g., in insecure contexts, or if permission is denied). The rejected promise is unhandled.

Suggested fix with error handling
   const handleCopy = async () => {
     if (apiKey) {
-      await navigator.clipboard.writeText(apiKey)
-      setCopied(true)
-      setTimeout(() => setCopied(false), 2000)
+      try {
+        await navigator.clipboard.writeText(apiKey)
+        setCopied(true)
+        setTimeout(() => setCopied(false), 2000)
+      } catch {
+        // Optionally show an error state or fallback
+        console.error('Failed to copy to clipboard')
+      }
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleCopy = async () => {
if (apiKey) {
await navigator.clipboard.writeText(apiKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const handleCopy = async () => {
if (apiKey) {
try {
await navigator.clipboard.writeText(apiKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Optionally show an error state or fallback
console.error('Failed to copy to clipboard')
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/pages/project/processing-services/processing-services-actions.tsx`
around lines 54 - 60, The handleCopy function currently calls
navigator.clipboard.writeText(apiKey) without handling rejections; wrap that
call in try/catch (or attach .catch) inside handleCopy to handle failures, log
or surface the error via an existing logger or UI (e.g., set an error state or
show a toast), and only setCopied(true) when the write succeeds; also keep the
existing timeout to clear the copied flag. Target the handleCopy function and
update its clipboard write invocation accordingly.


if (apiKey) {
return (
<div className="flex flex-col gap-2 p-3 border rounded-md bg-muted/50">
<p className="text-sm font-medium">
API Key (shown once, copy it now):
</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-background px-2 py-1 rounded border break-all flex-1 font-mono">
{visible ? apiKey : '\u2022'.repeat(20)}
</code>
<Button
aria-label={visible ? 'Hide API key' : 'Show API key'}
onClick={() => setVisible((v) => !v)}
size="small"
variant="ghost"
>
{visible ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
<Button onClick={handleCopy} size="small" variant="outline">
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
</div>
)
}

return (
<BasicTooltip
asChild
content={
error
? 'Could not generate API key.'
: processingService.apiKeyPrefix
? `Current key prefix: ${processingService.apiKeyPrefix}. Generating a new key will revoke the current one.`
: 'Generate an API key for this service to authenticate with.'
}
>
<Button
className={classNames({ 'text-destructive': error })}
disabled={isLoading}
onClick={() => generateAPIKey(processingService.id)}
size="small"
variant="outline"
>
{error ? <AlertCircleIcon className="w-4 h-4" /> : null}
<KeyRound className="w-4 h-4" />
<span>
{processingService.apiKeyPrefix
? 'Regenerate API Key'
: 'Generate API Key'}
</span>
{isLoading ? <Loader2 className="w-4 h-4 ml-2 animate-spin" /> : null}
</Button>
</BasicTooltip>
)
}