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
15 changes: 15 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Link2,
Menu,
Play,
Radar,
Rocket,
Settings,
X,
Expand Down Expand Up @@ -316,6 +317,8 @@ export function RootLayout() {
if (path === '/settings') return 'Settings'
if (path === '/setup') return 'Setup'
if (path === '/backlinks') return 'Backlinks'
if (path === '/traffic') return 'Server traffic'
if (path.startsWith('/traffic/')) return 'Server traffic'
if (path.startsWith('/projects/')) {
// Try to find project name
const segments = path.split('/').filter(Boolean)
Expand Down Expand Up @@ -369,6 +372,15 @@ export function RootLayout() {
<Play className="sidebar-icon" />
<span>Runs</span>
</Link>
<Link
to="/traffic"
className="sidebar-link"
activeProps={{ className: 'sidebar-link sidebar-link-active' }}
activeOptions={{ exact: false }}
>
<Radar className="sidebar-icon" />
<span>Server traffic</span>
</Link>
<Link
to="/backlinks"
className="sidebar-link"
Expand Down Expand Up @@ -518,6 +530,9 @@ export function RootLayout() {
<Link to="/runs" className="mobile-nav-link" activeProps={{ className: 'mobile-nav-link mobile-nav-link-active' }} activeOptions={{ exact: true }}>
Runs
</Link>
<Link to="/traffic" className="mobile-nav-link" activeProps={{ className: 'mobile-nav-link mobile-nav-link-active' }} activeOptions={{ exact: false }}>
Server traffic
</Link>
<Link to="/settings" className="mobile-nav-link" activeProps={{ className: 'mobile-nav-link mobile-nav-link-active' }} activeOptions={{ exact: true }}>
Settings
</Link>
Expand Down
54 changes: 53 additions & 1 deletion apps/web/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ErrorCode, GroundingSource, ProjectOverviewDto, ScheduleDto, NotificationDto, GscCoverageSummaryDto, GscCoverageSnapshotDto, IndexingRequestResultDto, MetricsWindow, GA4AiReferralHistoryEntry, GA4SessionHistoryEntry, GA4SocialReferralHistoryEntry, InsightDto, HealthSnapshotDto, ProjectReportDto, ReportAudience, RunKind, RunStatus, RunTrigger, RunErrorDto, CitationState, CitationVisibilityResponse, ComputedTransition, BacklinkSummaryDto, BacklinkDomainDto, BacklinkListResponse, BacklinkHistoryEntry, BacklinksInstallStatusDto, BacklinksInstallResultDto, CcAvailableRelease, CcCachedRelease, CcReleaseSyncDto } from '@ainyc/canonry-contracts'
import type { ErrorCode, GroundingSource, ProjectOverviewDto, ScheduleDto, NotificationDto, GscCoverageSummaryDto, GscCoverageSnapshotDto, IndexingRequestResultDto, MetricsWindow, GA4AiReferralHistoryEntry, GA4SessionHistoryEntry, GA4SocialReferralHistoryEntry, InsightDto, HealthSnapshotDto, ProjectReportDto, ReportAudience, RunKind, RunStatus, RunTrigger, RunErrorDto, CitationState, CitationVisibilityResponse, ComputedTransition, BacklinkSummaryDto, BacklinkDomainDto, BacklinkListResponse, BacklinkHistoryEntry, BacklinksInstallStatusDto, BacklinksInstallResultDto, CcAvailableRelease, CcCachedRelease, CcReleaseSyncDto, TrafficSourceDto, TrafficSourceDetailDto, TrafficSourceListResponse, TrafficEventsResponse, TrafficConnectCloudRunRequest, TrafficSyncResponse } from '@ainyc/canonry-contracts'
export type { ProjectOverviewDto }
export type { BacklinkSummaryDto, BacklinkDomainDto, BacklinkListResponse, BacklinkHistoryEntry, BacklinksInstallStatusDto, BacklinksInstallResultDto, CcAvailableRelease, CcCachedRelease, CcReleaseSyncDto }
export type { TrafficSourceDto, TrafficSourceDetailDto, TrafficSourceListResponse, TrafficEventsResponse, TrafficConnectCloudRunRequest, TrafficSyncResponse }

export type { GroundingSource }

Expand Down Expand Up @@ -1157,6 +1158,57 @@ export function disconnectGa(project: string): Promise<void> {
})
}

// ── Server traffic (Cloud Run / log-based ingestion) ────────────────────────

export type ApiTrafficSource = TrafficSourceDto
export type ApiTrafficSourceDetail = TrafficSourceDetailDto
export type ApiTrafficSourceList = TrafficSourceListResponse
export type ApiTrafficEvents = TrafficEventsResponse
export type ApiTrafficSyncResult = TrafficSyncResponse

export function fetchServerTrafficSources(project: string): Promise<TrafficSourceListResponse> {
return apiFetch(`/projects/${encodeURIComponent(project)}/traffic/sources`)
}

export function fetchServerTrafficSource(project: string, sourceId: string): Promise<TrafficSourceDetailDto> {
return apiFetch(`/projects/${encodeURIComponent(project)}/traffic/sources/${encodeURIComponent(sourceId)}`)
}

export function fetchServerTrafficEvents(
project: string,
params?: { since?: string; until?: string; kind?: 'all' | 'crawler' | 'ai-referral'; sourceId?: string; limit?: number },
): Promise<TrafficEventsResponse> {
const search: Record<string, string> = {}
if (params?.since) search.since = params.since
if (params?.until) search.until = params.until
if (params?.kind) search.kind = params.kind
if (params?.sourceId) search.sourceId = params.sourceId
if (params?.limit !== undefined) search.limit = String(params.limit)
const qs = Object.keys(search).length ? '?' + new URLSearchParams(search).toString() : ''
return apiFetch(`/projects/${encodeURIComponent(project)}/traffic/events${qs}`)
}

export function connectServerTrafficCloudRun(
project: string,
body: TrafficConnectCloudRunRequest,
): Promise<TrafficSourceDto> {
return apiFetch(`/projects/${encodeURIComponent(project)}/traffic/connect/cloud-run`, {
method: 'POST',
body: JSON.stringify(body),
})
}

export function triggerServerTrafficSync(
project: string,
sourceId: string,
body?: { sinceMinutes?: number },
): Promise<TrafficSyncResponse> {
return apiFetch(`/projects/${encodeURIComponent(project)}/traffic/sources/${encodeURIComponent(sourceId)}/sync`, {
method: 'POST',
body: JSON.stringify(body ?? {}),
})
}

// ── Intelligence ────────────────────────────────────────────────────────────

export function fetchInsights(project: string, runId?: string): Promise<InsightDto[]> {
Expand Down
220 changes: 220 additions & 0 deletions apps/web/src/components/server-traffic/ConnectCloudRunDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { useState } from 'react'

import { Button } from '../ui/button.js'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../ui/sheet.js'
import { ApiError } from '../../api.js'
import { useConnectServerTrafficCloudRun } from '../../queries/server-traffic.js'

export function ConnectCloudRunDrawer({
open,
onOpenChange,
projectName,
}: {
open: boolean
onOpenChange: (open: boolean) => void
projectName: string
}) {
const [gcpProjectId, setGcpProjectId] = useState('')
const [serviceName, setServiceName] = useState('')
const [location, setLocation] = useState('')
const [displayName, setDisplayName] = useState('')
const [keyJson, setKeyJson] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)

const connect = useConnectServerTrafficCloudRun(projectName || null)

const reset = () => {
setGcpProjectId('')
setServiceName('')
setLocation('')
setDisplayName('')
setKeyJson('')
setError(null)
setSuccess(null)
}

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSuccess(null)
if (!gcpProjectId.trim()) {
setError('GCP project ID is required.')
return
}
if (!keyJson.trim()) {
setError('Service-account JSON content is required.')
return
}
try {
const result = await connect.mutateAsync({
gcpProjectId: gcpProjectId.trim(),
serviceName: serviceName.trim() || undefined,
location: location.trim() || undefined,
displayName: displayName.trim() || undefined,
keyJson: keyJson.trim(),
})
// Don't keep the private-key payload around in memory after submit.
setKeyJson('')
setSuccess(`Connected ${result.displayName}.`)
} catch (e) {
const message = e instanceof ApiError ? e.message : e instanceof Error ? e.message : String(e)
setError(message)
}
}

const handleFile = async (file: File | null) => {
if (!file) return
const text = await file.text()
setKeyJson(text)
}

return (
<Sheet open={open} onOpenChange={(next) => {
onOpenChange(next)
if (!next) reset()
}}>
<SheetContent>
<SheetHeader>
<SheetTitle>Connect Cloud Run traffic source</SheetTitle>
<SheetDescription>
v1 supports service-account JSON only. The private key is stored in <code>~/.canonry/config.yaml</code> on the server and never echoed back to the dashboard.
</SheetDescription>
</SheetHeader>

<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-5 overflow-y-auto pr-1">
<Field
label="Project"
description="Canonry project this source attaches to."
>
<input
type="text"
value={projectName}
disabled
className="w-full rounded border border-zinc-700 bg-zinc-900/50 px-2 py-1.5 text-sm text-zinc-300"
/>
</Field>

<Field
label="GCP project ID"
description="The Google Cloud project hosting the Cloud Run service (e.g. my-prod-foo)."
required
>
<input
type="text"
value={gcpProjectId}
onChange={(e) => setGcpProjectId(e.target.value)}
required
autoComplete="off"
className="w-full rounded border border-zinc-700 bg-transparent px-2 py-1.5 text-sm text-zinc-200 placeholder-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</Field>

<Field
label="Service name (optional)"
description="Restrict log pulls to a specific Cloud Run service. Omit to pull all services in the project."
>
<input
type="text"
value={serviceName}
onChange={(e) => setServiceName(e.target.value)}
autoComplete="off"
className="w-full rounded border border-zinc-700 bg-transparent px-2 py-1.5 text-sm text-zinc-200 placeholder-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</Field>

<Field
label="Location (optional)"
description="Region of the Cloud Run service (e.g. us-central1). Helpful when multiple regions emit logs."
>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
autoComplete="off"
className="w-full rounded border border-zinc-700 bg-transparent px-2 py-1.5 text-sm text-zinc-200 placeholder-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</Field>

<Field
label="Display name (optional)"
description="Friendly label shown in the dashboard. Defaults to the project + service combo."
>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoComplete="off"
className="w-full rounded border border-zinc-700 bg-transparent px-2 py-1.5 text-sm text-zinc-200 placeholder-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</Field>

<Field
label="Service-account JSON"
description="Paste the contents of the SA key (JSON). The SA needs roles/logging.viewer (or any role granting logging.logEntries.list)."
required
>
<textarea
value={keyJson}
onChange={(e) => setKeyJson(e.target.value)}
rows={6}
spellCheck={false}
autoComplete="off"
className="w-full rounded border border-zinc-700 bg-transparent px-2 py-1.5 font-mono text-[11px] text-zinc-200 placeholder-zinc-600 focus:border-zinc-500 focus:outline-none"
placeholder='{"type":"service_account","project_id":"…","private_key":"…"}'
required
/>
<label className="mt-2 inline-flex cursor-pointer items-center gap-2 text-xs text-zinc-400 hover:text-zinc-200">
<input
type="file"
accept="application/json,.json"
className="hidden"
onChange={(e) => void handleFile(e.target.files?.[0] ?? null)}
/>
<span className="rounded-md border border-zinc-800 px-2 py-1">Or upload a key file</span>
</label>
</Field>

{error ? (
<p className="rounded-md border border-rose-800/50 bg-rose-950/30 px-3 py-2 text-xs text-rose-200">{error}</p>
) : null}
{success ? (
<p className="rounded-md border border-emerald-800/50 bg-emerald-950/30 px-3 py-2 text-xs text-emerald-200">{success}</p>
) : null}

<div className="mt-2 flex items-center justify-end gap-2 border-t border-zinc-800/60 pt-4">
<Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button type="submit" disabled={connect.isPending} size="sm">
{connect.isPending ? 'Connecting…' : 'Connect'}
</Button>
</div>
</form>
</SheetContent>
</Sheet>
)
}

function Field({
label,
description,
required,
children,
}: {
label: string
description: string
required?: boolean
children: React.ReactNode
}) {
return (
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-zinc-200">
{label}
{required ? <span className="ml-1 text-rose-400">*</span> : null}
</span>
{children}
<span className="text-[11px] text-zinc-500">{description}</span>
</label>
)
}
Loading
Loading