diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1b037d45..b1dd02e7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -11,6 +11,7 @@ import { Link2, Menu, Play, + Radar, Rocket, Settings, X, @@ -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) @@ -369,6 +372,15 @@ export function RootLayout() { Runs + + + Server traffic + Runs + + Server traffic + Settings diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index c057879a..2d1c97d8 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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 } @@ -1157,6 +1158,57 @@ export function disconnectGa(project: string): Promise { }) } +// ── 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 { + return apiFetch(`/projects/${encodeURIComponent(project)}/traffic/sources`) +} + +export function fetchServerTrafficSource(project: string, sourceId: string): Promise { + 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 { + const search: Record = {} + 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 { + 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 { + 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 { diff --git a/apps/web/src/components/server-traffic/ConnectCloudRunDrawer.tsx b/apps/web/src/components/server-traffic/ConnectCloudRunDrawer.tsx new file mode 100644 index 00000000..618803e8 --- /dev/null +++ b/apps/web/src/components/server-traffic/ConnectCloudRunDrawer.tsx @@ -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(null) + const [success, setSuccess] = useState(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 ( + { + onOpenChange(next) + if (!next) reset() + }}> + + + Connect Cloud Run traffic source + + v1 supports service-account JSON only. The private key is stored in ~/.canonry/config.yaml on the server and never echoed back to the dashboard. + + + +
+ + + + + + 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" + /> + + + + 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" + /> + + + + 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" + /> + + + + 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" + /> + + + +