@skyhook-io/radar-app 1.14.6 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +6 -5
- package/src/App.tsx +65 -17
- package/src/RadarApp.tsx +51 -21
- package/src/api/apiResources.test.ts +19 -1
- package/src/api/apiResources.ts +18 -3
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +269 -204
- package/src/api/diagnose.test.ts +102 -1
- package/src/api/diagnose.ts +42 -11
- package/src/api/drain-plan.test.ts +0 -10
- package/src/api/httpErrors.test.ts +95 -0
- package/src/api/httpErrors.ts +101 -0
- package/src/api/policy.ts +33 -5
- package/src/api/radarFeatures.render.test.tsx +190 -0
- package/src/api/radarFeatures.test.ts +133 -0
- package/src/api/radarFeatures.ts +147 -0
- package/src/api/timelineSource.test.ts +2 -10
- package/src/api/timelineSource.ts +3 -2
- package/src/api/traffic.ts +4 -3
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +2 -2
- package/src/components/CloudFunnelButton.tsx +99 -55
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +95 -0
- package/src/components/applications/ApplicationsView.tsx +20 -6
- package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
- package/src/components/audit/UpgradeReadinessView.tsx +9 -13
- package/src/components/capacity/CapacityView.test.tsx +13 -4
- package/src/components/capacity/shared.tsx +14 -13
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/cost/ApplicationCostTab.tsx +9 -1
- package/src/components/cost/CostConnectionAction.tsx +30 -0
- package/src/components/cost/CostTrendChart.tsx +2 -0
- package/src/components/cost/CostView.tsx +4 -24
- package/src/components/cost/WorkloadCostTab.tsx +7 -1
- package/src/components/cost/source.test.ts +30 -3
- package/src/components/cost/source.ts +37 -2
- package/src/components/curl/ServiceCurlButton.tsx +7 -3
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/execution/BatchExecutionView.tsx +1 -1
- package/src/components/execution/JobSetAdmission.test.tsx +160 -37
- package/src/components/execution/JobSetAdmission.tsx +16 -7
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
- package/src/components/home/HomeView.tsx +7 -3
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +25 -3
- package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
- package/src/components/resource/PVCUsageBar.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
- package/src/components/resources/ImageFilesystemModal.tsx +2 -1
- package/src/components/resources/PodFilePreview.tsx +4 -1
- package/src/components/resources/PodFilesystemModal.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +2 -1
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
- package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
- package/src/components/settings/FormSaveActions.tsx +76 -0
- package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
- package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
- package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +393 -571
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/currency-options.test.ts +1 -1
- package/src/components/settings/currency-options.ts +1 -1
- package/src/components/settings/settings-state.test.ts +17 -10
- package/src/components/settings/settings-state.ts +21 -12
- package/src/components/timeline/TimelineList.test.ts +56 -0
- package/src/components/timeline/TimelineList.tsx +7 -1
- package/src/components/traffic/TrafficFlowList.tsx +5 -21
- package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
- package/src/components/traffic/TrafficGraph.tsx +79 -48
- package/src/components/traffic/TrafficView.tsx +61 -37
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/traffic/trafficFilters.test.ts +154 -1
- package/src/components/traffic/trafficFilters.ts +141 -0
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +17 -1
- package/src/components/ui/UpdateNotification.tsx +70 -21
- package/src/components/ui/command-items.ts +14 -0
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.tsx +47 -14
- package/src/components/workload/historyPaging.test.ts +72 -0
- package/src/components/workload/historyPaging.ts +147 -0
- package/src/components/workload/useHistoryPaging.test.tsx +81 -0
- package/src/context/ConnectionContext.tsx +2 -1
- package/src/context/RadarUpgrade.tsx +30 -0
- package/src/context/RadarUpgradeHost.tsx +23 -0
- package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
- package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
- package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
- package/src/index.ts +1 -0
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/navigation.test.ts +17 -1
- package/src/utils/navigation.ts +16 -0
- package/src/utils/version.ts +11 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useOpenCostSummary, usePrometheusStatus, type useArgoStatus } from '../../api/client'
|
|
2
|
+
import { costIntegrationUnavailableMessage, costSourceLabel } from '../cost/source'
|
|
3
|
+
import type { IntegrationKind, IntegrationProfile } from './LocalConnectionSettings'
|
|
4
|
+
import { Badge, Collapse } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { useRef } from 'react'
|
|
6
|
+
|
|
7
|
+
export function LocalIntegrationStatus({ kind, profile, argo, busy, changedAt }: {
|
|
8
|
+
kind: IntegrationKind
|
|
9
|
+
profile: IntegrationProfile
|
|
10
|
+
argo: ReturnType<typeof useArgoStatus>
|
|
11
|
+
busy: boolean
|
|
12
|
+
changedAt: number
|
|
13
|
+
}) {
|
|
14
|
+
const prom = usePrometheusStatus(kind === 'metrics')
|
|
15
|
+
const cost = useOpenCostSummary(kind === 'cost')
|
|
16
|
+
const query = kind === 'metrics' ? prom : kind === 'cost' ? cost : argo
|
|
17
|
+
// Background polls keep the last status; only the first load, or a result
|
|
18
|
+
// older than the latest saved integration change, reads as "Checking…".
|
|
19
|
+
const checking = busy || query.isPlaceholderData || (query.isFetching && !query.data) ||
|
|
20
|
+
Math.max(query.dataUpdatedAt, query.errorUpdatedAt) < changedAt
|
|
21
|
+
let status = 'Checking…'
|
|
22
|
+
let detail: string | undefined
|
|
23
|
+
if (!checking) {
|
|
24
|
+
if (query.isError) status = 'Status unavailable'
|
|
25
|
+
else if (kind === 'metrics' && prom.data) {
|
|
26
|
+
status = prom.data.connected ? 'Connected' : prom.data.discovering ? 'Discovering…' : 'Not connected'
|
|
27
|
+
detail = prom.data.connected ? !profile.url ? `Discovered: ${prom.data.address}` : undefined : prom.data.error
|
|
28
|
+
} else if (kind === 'argocd' && argo.data) {
|
|
29
|
+
status = argo.data.connected ? argo.data.anonymous ? 'Connected · no token needed' : 'Connected' : 'Not connected'
|
|
30
|
+
detail = argo.data.connected ? !profile.url ? `Discovered: ${argo.data.address}` : undefined : argo.data.reason
|
|
31
|
+
} else if (kind === 'cost' && cost.data) {
|
|
32
|
+
status = cost.data.available ? `Available · ${costSourceLabel(cost.data.source)}`
|
|
33
|
+
: ['no_prometheus', 'no_cost_source', 'no_metrics'].includes(cost.data.reason ?? '') ? 'No cost data' : 'Unavailable'
|
|
34
|
+
detail = cost.data.reason ? costIntegrationUnavailableMessage(cost.data.reason)
|
|
35
|
+
?? (cost.data.reason === 'query_error' ? 'The active cost source query failed.' : undefined) : undefined
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const lastDetail = useRef({ text: detail, visible: !!detail })
|
|
39
|
+
if (!checking) lastDetail.current = { text: detail ?? lastDetail.current.text, visible: !!detail }
|
|
40
|
+
const configured = !!(profile.url || profile.secretSet || profile.headerKeys.length || profile.clusterId || profile.mode === 'prometheus')
|
|
41
|
+
const failed = !checking && (query.isError || configured && ['Not connected', 'Unavailable', 'No cost data'].includes(status))
|
|
42
|
+
return (
|
|
43
|
+
<div role="group" className="contents text-xs text-theme-text-secondary" aria-label="Current connection">
|
|
44
|
+
<Badge severity={status.startsWith('Connected') || status.startsWith('Available') ? 'success' : failed ? 'warning' : 'neutral'}>{status}</Badge>
|
|
45
|
+
<Collapse open={lastDetail.current.visible} className="basis-full min-w-0">
|
|
46
|
+
<p className={`break-words text-theme-text-tertiary ${checking ? 'invisible' : ''}`}>{lastDetail.current.text}</p>
|
|
47
|
+
</Collapse>
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import type { UsageDataStatus } from '../../api/usage-data'
|
|
5
|
+
import { exampleReport, exampleStatus } from '../../api/usage-data.fixtures'
|
|
6
|
+
|
|
7
|
+
let current: UsageDataStatus
|
|
8
|
+
|
|
9
|
+
vi.mock('../../api/usage-data', async (orig) => ({
|
|
10
|
+
...(await orig<typeof import('../../api/usage-data')>()),
|
|
11
|
+
useUsageData: () => ({ data: current, isLoading: false, error: null }),
|
|
12
|
+
useSetUsageData: () => ({ mutate: vi.fn(), isPending: false }),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
import { PrivacySection } from './PrivacySection'
|
|
16
|
+
|
|
17
|
+
function make(patch: Partial<UsageDataStatus>): UsageDataStatus {
|
|
18
|
+
return exampleStatus({ preview: exampleReport({ views: { topology: 3 } }), ...patch })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function render(status: UsageDataStatus) {
|
|
22
|
+
current = status
|
|
23
|
+
return renderToString(
|
|
24
|
+
<QueryClientProvider client={new QueryClient()}>
|
|
25
|
+
<PrivacySection active />
|
|
26
|
+
</QueryClientProvider>,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('PrivacySection', () => {
|
|
31
|
+
it('shows an example report and promises nothing is sent while off', () => {
|
|
32
|
+
const html = render(make({}))
|
|
33
|
+
expect(html).toContain('See an example report')
|
|
34
|
+
expect(html).toContain('What isn')
|
|
35
|
+
expect(html).not.toContain('usage-report.json')
|
|
36
|
+
expect(html).toContain('aria-checked="false"')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('shows the pending report and where it lives once on', () => {
|
|
40
|
+
const html = render(make({ state: 'on', source: 'user', nextReportAt: '2026-09-30T12:00:00Z' }))
|
|
41
|
+
expect(html).toContain('See the next report')
|
|
42
|
+
expect(html).toContain('usage-report.json')
|
|
43
|
+
expect(html).toContain('"topology": 3')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('names the control that fixed the choice', () => {
|
|
47
|
+
expect(render(make({ state: 'off', source: 'deployment', canChange: false }))).toContain('Radar Cloud manages')
|
|
48
|
+
const helm = make({ state: 'on', source: 'env', canChange: false, shared: true })
|
|
49
|
+
expect(render({ ...helm, preview: { ...helm.preview, mode: 'in-cluster' } })).toContain('Helm value usageReporting.enabled')
|
|
50
|
+
expect(render(make({ state: 'off', source: 'env', canChange: false }))).toContain('RADAR_USAGE_REPORTING=off')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('PrivacySection intro', () => {
|
|
55
|
+
it('does not claim data stays on this machine for an in-cluster install', () => {
|
|
56
|
+
const base = make({ state: 'off', source: 'deployment', canChange: false })
|
|
57
|
+
const html = render({ ...base, preview: { ...base.preview, mode: 'in-cluster' } })
|
|
58
|
+
expect(html).toContain('from inside it')
|
|
59
|
+
expect(html).not.toContain('from this machine')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('PrivacySection on a shared Radar', () => {
|
|
64
|
+
it('points at the Helm value while nothing set it', () => {
|
|
65
|
+
const base = make({ shared: true, canChange: false })
|
|
66
|
+
const html = render({ ...base, preview: { ...base.preview, mode: 'in-cluster' } })
|
|
67
|
+
expect(html).toContain('Applies to everyone using this Radar.')
|
|
68
|
+
expect(html).toContain('usageReporting.enabled')
|
|
69
|
+
expect(html).toContain('aria-checked="false"')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('uses the environment variable outside a cluster', () => {
|
|
73
|
+
expect(render(make({ shared: true, canChange: false }))).toContain('RADAR_USAGE_REPORTING=on')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('PrivacySection storage and log wording', () => {
|
|
78
|
+
it('says logs, not sends, for development builds', () => {
|
|
79
|
+
const html = render(make({ state: 'on', source: 'user', developmentBuild: true, nextReportAt: '2026-09-25T12:00:00Z' }))
|
|
80
|
+
expect(html).toContain('logs')
|
|
81
|
+
expect(html).toContain('reports go to the log, never sent')
|
|
82
|
+
expect(render(make({ state: 'on', source: 'user', nextReportAt: '2026-09-25T12:00:00Z' }))).toContain('sends')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { ExternalLink } from 'lucide-react'
|
|
3
|
+
import { Collapse, CollapseChevron, useDisclosure } from '@skyhook-io/k8s-ui/components/ui/Collapse'
|
|
4
|
+
import { ConfigToggle, SubHeading } from './controls'
|
|
5
|
+
import { isRecording, useSetUsageData, useUsageData, type UsageDataStatus } from '../../api/usage-data'
|
|
6
|
+
|
|
7
|
+
const DOCS_URL = 'https://radarhq.io/docs/configuration/usage-stats'
|
|
8
|
+
|
|
9
|
+
export function PrivacySection({ active }: { active: boolean }) {
|
|
10
|
+
const { data: status, isLoading, error, refetch } = useUsageData(active, { fresh: true })
|
|
11
|
+
const setUsageData = useSetUsageData()
|
|
12
|
+
|
|
13
|
+
// The pane stays mounted while hidden, so refetch each time it is revealed.
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (active) void refetch()
|
|
16
|
+
}, [active, refetch])
|
|
17
|
+
|
|
18
|
+
if (isLoading) {
|
|
19
|
+
return <p className="text-sm text-theme-text-tertiary">Loading…</p>
|
|
20
|
+
}
|
|
21
|
+
if (error || !status) {
|
|
22
|
+
return <p className="text-sm text-theme-text-secondary">Couldn't load privacy settings.</p>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const recording = isRecording(status)
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="space-y-6">
|
|
29
|
+
<p className="text-sm text-theme-text-secondary">
|
|
30
|
+
{status.preview.mode === 'in-cluster' || status.preview.mode === 'cloud'
|
|
31
|
+
? 'Radar reads this cluster from inside it and keeps what it reads there.'
|
|
32
|
+
: 'Radar reads your cluster from this machine and keeps what it reads here.'}
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
<section className="space-y-3">
|
|
36
|
+
<SubHeading>Usage stats</SubHeading>
|
|
37
|
+
<ConfigToggle
|
|
38
|
+
label="Send anonymous usage stats"
|
|
39
|
+
description={<>Shows us which features get used, so we know what to build next.<br />Sent straight to us, with no third-party trackers.</>}
|
|
40
|
+
value={recording}
|
|
41
|
+
disabled={!status.canChange || setUsageData.isPending}
|
|
42
|
+
onChange={(v) => setUsageData.mutate(v)}
|
|
43
|
+
/>
|
|
44
|
+
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-xs">
|
|
45
|
+
<dt className="text-theme-text-tertiary">What's sent</dt>
|
|
46
|
+
<dd className="text-theme-text-secondary">
|
|
47
|
+
The views you open, the actions and MCP tools you use, and each cluster's version,
|
|
48
|
+
platform, size range and known integrations.
|
|
49
|
+
</dd>
|
|
50
|
+
<dt className="text-theme-text-tertiary">What isn't</dt>
|
|
51
|
+
<dd className="text-theme-text-secondary">
|
|
52
|
+
Names, contents, logs, identifiers, timestamps, or the resources in your clusters.
|
|
53
|
+
</dd>
|
|
54
|
+
</dl>
|
|
55
|
+
<ManagedReason status={status} />
|
|
56
|
+
<SharedNote status={status} />
|
|
57
|
+
<ReportPreview status={status} />
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ManagedReason({ status }: { status: UsageDataStatus }) {
|
|
65
|
+
let text: string | null = null
|
|
66
|
+
switch (status.source) {
|
|
67
|
+
case 'env':
|
|
68
|
+
text = status.shared && status.preview.mode === 'in-cluster'
|
|
69
|
+
? 'Set for everyone by the Helm value usageReporting.enabled.'
|
|
70
|
+
: `Set by RADAR_USAGE_REPORTING=${status.state}.`
|
|
71
|
+
break
|
|
72
|
+
case 'deployment':
|
|
73
|
+
text = 'Radar Cloud manages usage data for this installation.'
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
if (!text && status.developmentBuild && isRecording(status)) {
|
|
77
|
+
text = 'Development build: reports go to the log, never sent.'
|
|
78
|
+
}
|
|
79
|
+
return text ? <p className="text-xs text-theme-text-tertiary">{text}</p> : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A shared Radar is decided by its configuration; say where, while nothing set it.
|
|
83
|
+
function SharedNote({ status }: { status: UsageDataStatus }) {
|
|
84
|
+
if (!status.shared || status.source !== 'default') return null
|
|
85
|
+
return (
|
|
86
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
87
|
+
Applies to everyone using this Radar. Set it with{' '}
|
|
88
|
+
{status.preview.mode === 'in-cluster'
|
|
89
|
+
? <>the Helm value <code className="inline-code">usageReporting.enabled</code></>
|
|
90
|
+
: <code className="inline-code">RADAR_USAGE_REPORTING=on</code>}.
|
|
91
|
+
</p>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ReportPreview({ status }: { status: UsageDataStatus }) {
|
|
96
|
+
const recording = isRecording(status)
|
|
97
|
+
const [open, setOpen] = useState(false)
|
|
98
|
+
const disclosure = useDisclosure(open)
|
|
99
|
+
// Development builds write the report to the log instead of sending it.
|
|
100
|
+
const logsOnly = status.developmentBuild
|
|
101
|
+
const next = status.nextReportAt ? new Date(status.nextReportAt) : null
|
|
102
|
+
return (
|
|
103
|
+
<div className="space-y-1.5">
|
|
104
|
+
<div className="flex items-center justify-between gap-3">
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => setOpen(o => !o)}
|
|
108
|
+
{...disclosure.buttonProps}
|
|
109
|
+
className="flex items-center gap-1 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
|
|
110
|
+
>
|
|
111
|
+
<CollapseChevron open={open} />
|
|
112
|
+
{recording ? 'See the next report' : 'See an example report'}
|
|
113
|
+
{recording && next && (
|
|
114
|
+
<span className="font-normal text-theme-text-tertiary">
|
|
115
|
+
{' '}· {logsOnly ? 'logs' : 'sends'} {next.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
|
116
|
+
</span>
|
|
117
|
+
)}
|
|
118
|
+
</button>
|
|
119
|
+
<a
|
|
120
|
+
href={DOCS_URL}
|
|
121
|
+
target="_blank"
|
|
122
|
+
rel="noopener noreferrer"
|
|
123
|
+
className="inline-flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:underline"
|
|
124
|
+
>
|
|
125
|
+
How it works <ExternalLink className="w-3 h-3" aria-hidden />
|
|
126
|
+
</a>
|
|
127
|
+
</div>
|
|
128
|
+
<Collapse open={open} id={disclosure.panelId}>
|
|
129
|
+
<pre className="max-h-80 overflow-auto rounded-md border border-theme-border bg-theme-elevated px-3 py-2 text-[11px] leading-relaxed font-mono text-theme-text-primary">
|
|
130
|
+
{JSON.stringify(status.preview, null, 2)}
|
|
131
|
+
</pre>
|
|
132
|
+
</Collapse>
|
|
133
|
+
{recording && status.canChange && (
|
|
134
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
135
|
+
Waiting in <code className="inline-code">~/.radar/usage-report.json</code>. Turning this off deletes it.
|
|
136
|
+
</p>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useEffect, useRef, type ComponentProps } from 'react'
|
|
2
|
+
import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
3
|
+
import { PrometheusConnectionForm, type PrometheusApplyResult } from './PrometheusConnectionForm'
|
|
4
|
+
|
|
5
|
+
export function PrometheusConfigField({ onBusyChange, ...props }: Omit<ComponentProps<typeof PrometheusConnectionForm>, 'onApply'> & { onBusyChange?: (busy: boolean) => void }) {
|
|
6
|
+
const request = useRef<AbortController | null>(null)
|
|
7
|
+
useEffect(() => () => request.current?.abort(), [])
|
|
8
|
+
useEffect(() => () => onBusyChange?.(false), [onBusyChange])
|
|
9
|
+
const apply = async (url: string, headers?: Record<string, string>): Promise<PrometheusApplyResult> => {
|
|
10
|
+
if (request.current) throw new Error('A connection update is already in progress.')
|
|
11
|
+
const controller = new AbortController(); request.current = controller
|
|
12
|
+
const base = getApiBase()
|
|
13
|
+
onBusyChange?.(true)
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(apiUrl('/integrations/prometheus'), {
|
|
16
|
+
method: 'PUT', signal: controller.signal, credentials: getCredentialsMode(),
|
|
17
|
+
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
18
|
+
body: JSON.stringify({ prometheusUrl: url, ...(headers !== undefined ? { headers } : {}) }),
|
|
19
|
+
})
|
|
20
|
+
const data = await response.json().catch(() => ({})) as PrometheusApplyResult
|
|
21
|
+
if (controller.signal.aborted || base !== getApiBase()) throw new Error('Cluster changed; reload Settings.')
|
|
22
|
+
if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`)
|
|
23
|
+
return data
|
|
24
|
+
} finally {
|
|
25
|
+
if (request.current === controller) request.current = null
|
|
26
|
+
if (!controller.signal.aborted) onBusyChange?.(false)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return <PrometheusConnectionForm {...props} onApply={apply} />
|
|
30
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useId, type ReactNode } from 'react'
|
|
2
|
+
import { Check, Info, Loader2, Plug, Plus, X } from 'lucide-react'
|
|
3
|
+
import { Collapse, Input } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
5
|
+
import { prometheusHeadersFromRows } from './settings-state'
|
|
6
|
+
import { ConnectionHeadersEditor, type HeaderOperation } from './ConnectionHeadersEditor'
|
|
7
|
+
import { FormSaveActions, type FormFeedback } from './FormSaveActions'
|
|
8
|
+
|
|
9
|
+
export interface PrometheusApplyResult { connected: boolean; address?: string; error?: string }
|
|
10
|
+
|
|
11
|
+
type ApplyState =
|
|
12
|
+
| { status: 'idle' }
|
|
13
|
+
| { status: 'applying' }
|
|
14
|
+
| { status: 'connected'; address: string }
|
|
15
|
+
| { status: 'unreachable'; error: string } // persisted, but the probe failed
|
|
16
|
+
| { status: 'failed'; error: string } // request itself failed — nothing saved
|
|
17
|
+
|
|
18
|
+
type HeaderRow = { key: string; value: string }
|
|
19
|
+
|
|
20
|
+
export function PrometheusConnectionForm({
|
|
21
|
+
local,
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
configuredHeaderKeys,
|
|
25
|
+
serverManaged,
|
|
26
|
+
headersManaged,
|
|
27
|
+
urlFromFlag,
|
|
28
|
+
onApplied,
|
|
29
|
+
onApply,
|
|
30
|
+
onDirtyChange,
|
|
31
|
+
onApplyOperations,
|
|
32
|
+
environmentHeaderKeys = [],
|
|
33
|
+
dirty = false,
|
|
34
|
+
onDiscard,
|
|
35
|
+
connectionAction,
|
|
36
|
+
feedback,
|
|
37
|
+
}: {
|
|
38
|
+
local: boolean
|
|
39
|
+
value: string
|
|
40
|
+
onChange: (value: string) => void
|
|
41
|
+
configuredHeaderKeys: string[]
|
|
42
|
+
serverManaged: boolean
|
|
43
|
+
headersManaged: boolean
|
|
44
|
+
urlFromFlag: boolean
|
|
45
|
+
onApplied?: (url: string) => void
|
|
46
|
+
onApply: (url: string, headers?: Record<string, string>) => Promise<PrometheusApplyResult>
|
|
47
|
+
onDirtyChange?: (dirty: boolean) => void
|
|
48
|
+
onApplyOperations?: (url: string, operations: HeaderOperation[]) => Promise<PrometheusApplyResult | null>
|
|
49
|
+
environmentHeaderKeys?: string[]
|
|
50
|
+
dirty?: boolean
|
|
51
|
+
onDiscard?: () => void
|
|
52
|
+
connectionAction?: ReactNode
|
|
53
|
+
feedback?: FormFeedback
|
|
54
|
+
}) {
|
|
55
|
+
const mounted = useRef(true)
|
|
56
|
+
const urlId = useId()
|
|
57
|
+
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
|
58
|
+
const [apply, setApply] = useState<ApplyState>({ status: 'idle' })
|
|
59
|
+
// null = not editing headers (preserve what's stored). A non-null array means
|
|
60
|
+
// the user opened the editor; see handleApply for how rows become a
|
|
61
|
+
// replacement (values are write-only, so the server never sends them back).
|
|
62
|
+
const [headerRows, setHeaderRows] = useState<HeaderRow[] | null>(null)
|
|
63
|
+
// Show the server's configured header keys, but let a successful apply override
|
|
64
|
+
// optimistically (config isn't refetched). Derived from the prop — not a
|
|
65
|
+
// mount-time snapshot — so it stays correct as config loads asynchronously.
|
|
66
|
+
const [appliedKeys, setAppliedKeys] = useState<string[] | null>(null)
|
|
67
|
+
const [operations, setOperations] = useState<HeaderOperation[]>([])
|
|
68
|
+
const storedKeys = appliedKeys ?? configuredHeaderKeys
|
|
69
|
+
useEffect(() => { onDirtyChange?.(headerRows !== null || operations.some(operation => operation.action !== 'keep')) }, [headerRows, operations, onDirtyChange])
|
|
70
|
+
|
|
71
|
+
const clearStatus = () => {
|
|
72
|
+
if (apply.status !== 'applying') setApply({ status: 'idle' })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Footer Reset (and any external edit) clears the URL field without a keystroke;
|
|
76
|
+
// drop a stale "Connected"/"Saved" status so it doesn't describe an emptied field.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
setApply((s) => (s.status === 'idle' || s.status === 'applying' ? s : { status: 'idle' }))
|
|
79
|
+
}, [value])
|
|
80
|
+
|
|
81
|
+
const handleApply = async () => {
|
|
82
|
+
setApply({ status: 'applying' })
|
|
83
|
+
// Decide what to do with headers. undefined = leave them untouched. Only send
|
|
84
|
+
// a replacement when the editor has real content, or {} when the user emptied
|
|
85
|
+
// every row (explicit clear) — blank in-progress rows must NOT wipe stored
|
|
86
|
+
// secrets just because the editor happens to be open for a URL-only change.
|
|
87
|
+
try {
|
|
88
|
+
const editedHeaders = prometheusHeadersFromRows(headerRows)
|
|
89
|
+
const editedOperations = operations.filter(operation => operation.key.trim() || operation.value)
|
|
90
|
+
if (onApplyOperations) {
|
|
91
|
+
const names = new Set<string>()
|
|
92
|
+
for (const operation of editedOperations) {
|
|
93
|
+
const name = operation.key.trim().toLowerCase()
|
|
94
|
+
if (!name) throw new Error('Enter a name for each header with a value.')
|
|
95
|
+
if (names.has(name)) throw new Error(`Header names must be unique: ${operation.key.trim()}.`)
|
|
96
|
+
if (operation.action === 'set' && !operation.value) throw new Error(`Enter a value for ${operation.key.trim()}.`)
|
|
97
|
+
names.add(name)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const data = onApplyOperations ? await onApplyOperations(value.trim(), editedOperations) : await onApply(value.trim(), editedHeaders)
|
|
101
|
+
if (!mounted.current) return
|
|
102
|
+
if (!data) { setApply({ status: 'idle' }); return }
|
|
103
|
+
onApplied?.(value.trim())
|
|
104
|
+
if (editedHeaders !== undefined) {
|
|
105
|
+
setAppliedKeys(Object.keys(editedHeaders).sort())
|
|
106
|
+
}
|
|
107
|
+
if (headerRows !== null) {
|
|
108
|
+
setHeaderRows(null)
|
|
109
|
+
}
|
|
110
|
+
setOperations([])
|
|
111
|
+
if (data.connected) {
|
|
112
|
+
setApply({ status: 'connected', address: data.address || value.trim() })
|
|
113
|
+
} else {
|
|
114
|
+
setApply({ status: 'unreachable', error: data.error || 'not reachable' })
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (mounted.current) setApply({ status: 'failed', error: err instanceof Error ? err.message : String(err) })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div>
|
|
123
|
+
<div className="mb-4 space-y-1">
|
|
124
|
+
<p className="text-sm text-theme-text-secondary">
|
|
125
|
+
Connect a Prometheus-compatible backend, such as Prometheus, VictoriaMetrics, Thanos or Grafana Mimir.
|
|
126
|
+
</p>
|
|
127
|
+
</div>
|
|
128
|
+
<div className="flex flex-wrap items-center gap-1 mb-1">
|
|
129
|
+
<label htmlFor={urlId} className="text-sm font-medium text-theme-text-primary">Metrics backend URL</label>
|
|
130
|
+
<Tooltip content="Use a base URL reachable from Radar, not your browser. Include the backend path prefix, but not /api/v1/query. Available charts depend on the metrics collected.">
|
|
131
|
+
<button type="button" aria-label="Metrics URL help" className="text-theme-text-tertiary"><Info className="w-3.5 h-3.5" /></button>
|
|
132
|
+
</Tooltip>
|
|
133
|
+
</div>
|
|
134
|
+
<p className="text-xs text-theme-text-tertiary mb-1">
|
|
135
|
+
Leave empty to discover a backend in this cluster.
|
|
136
|
+
</p>
|
|
137
|
+
<div className="flex items-center gap-2">
|
|
138
|
+
<Input
|
|
139
|
+
id={urlId}
|
|
140
|
+
value={value}
|
|
141
|
+
disabled={apply.status === 'applying'}
|
|
142
|
+
onChange={(e) => onChange(e.target.value)}
|
|
143
|
+
placeholder="http://prometheus-server.monitoring:9090"
|
|
144
|
+
className="flex-1 min-w-0 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
{connectionAction}
|
|
148
|
+
{!onApplyOperations && <p className="mt-2 text-xs text-theme-text-tertiary">
|
|
149
|
+
{local ? 'Applies only to this cluster.' : 'Changes affect this Radar installation. Use deployment settings for configuration that survives Pod replacement.'}
|
|
150
|
+
</p>}
|
|
151
|
+
{value.startsWith('http://') && (configuredHeaderKeys.length > 0 || operations.some(operation => operation.action === 'set')) && (
|
|
152
|
+
<p className="mt-2 text-xs text-warning-text">Headers will travel over unencrypted HTTP. Prefer HTTPS outside a trusted private network.</p>
|
|
153
|
+
)}
|
|
154
|
+
{serverManaged && (
|
|
155
|
+
<p className="mt-2 text-xs text-theme-text-secondary">
|
|
156
|
+
Server controlled by startup configuration. To set or change the server,
|
|
157
|
+
{local ? ' update the startup flags or environment references and restart Radar.' : ' update the deployment configuration (such as Helm values) and restart Radar.'}
|
|
158
|
+
</p>
|
|
159
|
+
)}
|
|
160
|
+
{urlFromFlag && (
|
|
161
|
+
<p className="mt-1 text-xs text-theme-text-secondary">
|
|
162
|
+
Path edits apply until restart; the URL supplied at launch will then be restored.
|
|
163
|
+
</p>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{onApplyOperations ? <ConnectionHeadersEditor keys={configuredHeaderKeys} environmentKeys={environmentHeaderKeys} value={operations} onChange={setOperations} /> : <section className="mt-5 border-t border-theme-border pt-4">
|
|
167
|
+
<h4 className="text-sm font-medium text-theme-text-primary mb-2">Authentication headers</h4>
|
|
168
|
+
<p className="text-xs text-theme-text-tertiary mb-3">
|
|
169
|
+
Optional authentication or tenant headers, such as Authorization or X-Scope-OrgID. Requires a backend URL.
|
|
170
|
+
{!headersManaged && storedKeys.length > 0 && ' Changing servers requires replacing or clearing the saved headers.'}
|
|
171
|
+
</p>
|
|
172
|
+
{headerRows === null ? (
|
|
173
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
174
|
+
<span className="min-w-0 break-words text-xs text-theme-text-tertiary">
|
|
175
|
+
{storedKeys.length > 0
|
|
176
|
+
? <><span className="text-theme-text-secondary">{storedKeys.join(', ')}</span> (values hidden)</>
|
|
177
|
+
: 'No headers configured'}
|
|
178
|
+
</span>
|
|
179
|
+
{!headersManaged && <button
|
|
180
|
+
onClick={() => { setHeaderRows([{ key: '', value: '' }]); clearStatus() }}
|
|
181
|
+
disabled={apply.status === 'applying'}
|
|
182
|
+
className="shrink-0 text-xs font-medium text-accent-text hover:underline"
|
|
183
|
+
>
|
|
184
|
+
{storedKeys.length > 0 ? 'Edit headers' : 'Add auth headers'}
|
|
185
|
+
</button>}
|
|
186
|
+
</div>
|
|
187
|
+
) : (
|
|
188
|
+
<div className="rounded-md border border-theme-border bg-theme-elevated/40 p-2.5 space-y-2">
|
|
189
|
+
{headerRows.map((row, i) => (
|
|
190
|
+
<div key={i} className="flex items-center gap-2">
|
|
191
|
+
<Input
|
|
192
|
+
value={row.key}
|
|
193
|
+
disabled={apply.status === 'applying'}
|
|
194
|
+
onChange={(e) => {
|
|
195
|
+
setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, key: e.target.value } : r))
|
|
196
|
+
clearStatus()
|
|
197
|
+
}}
|
|
198
|
+
placeholder="Header (e.g. Authorization)"
|
|
199
|
+
className="flex-1 min-w-0 px-2.5 py-1.5 text-xs bg-theme-base border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
|
|
200
|
+
/>
|
|
201
|
+
<input
|
|
202
|
+
type="password"
|
|
203
|
+
value={row.value}
|
|
204
|
+
disabled={apply.status === 'applying'}
|
|
205
|
+
onChange={(e) => {
|
|
206
|
+
setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, value: e.target.value } : r))
|
|
207
|
+
clearStatus()
|
|
208
|
+
}}
|
|
209
|
+
placeholder="Value (e.g. Bearer …)"
|
|
210
|
+
className="flex-1 min-w-0 px-2.5 py-1.5 text-xs bg-theme-base border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
|
|
211
|
+
/>
|
|
212
|
+
<Tooltip content="Remove header" wrapperClassName="shrink-0">
|
|
213
|
+
<button
|
|
214
|
+
onClick={() => setHeaderRows((rows) => rows!.filter((_, j) => j !== i))}
|
|
215
|
+
disabled={apply.status === 'applying'}
|
|
216
|
+
className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
|
|
217
|
+
>
|
|
218
|
+
<X className="w-3.5 h-3.5" />
|
|
219
|
+
</button>
|
|
220
|
+
</Tooltip>
|
|
221
|
+
</div>
|
|
222
|
+
))}
|
|
223
|
+
<div className="flex items-center justify-between gap-2">
|
|
224
|
+
<button
|
|
225
|
+
onClick={() => setHeaderRows((rows) => [...rows!, { key: '', value: '' }])}
|
|
226
|
+
disabled={apply.status === 'applying'}
|
|
227
|
+
className="flex items-center gap-1 text-xs font-medium text-accent-text hover:underline"
|
|
228
|
+
>
|
|
229
|
+
<Plus className="w-3 h-3" /> Add header
|
|
230
|
+
</button>
|
|
231
|
+
<button
|
|
232
|
+
onClick={() => { setHeaderRows(null); clearStatus() }}
|
|
233
|
+
disabled={apply.status === 'applying'}
|
|
234
|
+
className="text-xs text-theme-text-tertiary hover:text-theme-text-primary"
|
|
235
|
+
>
|
|
236
|
+
Cancel
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
240
|
+
Saved when you click Apply now. Entered headers replace all stored
|
|
241
|
+
ones — values are hidden, so re-enter any you want to keep. Leave
|
|
242
|
+
all rows blank to keep existing headers unchanged. To remove all saved
|
|
243
|
+
headers, remove every row and click Apply now.
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
{headersManaged ? (
|
|
248
|
+
<p className="mt-2 text-xs text-theme-text-secondary">
|
|
249
|
+
Headers are controlled by startup configuration. Change them at their source and restart Radar.
|
|
250
|
+
</p>
|
|
251
|
+
) : storedKeys.length > 0 && (
|
|
252
|
+
<button
|
|
253
|
+
onClick={() => { setHeaderRows([]); clearStatus() }}
|
|
254
|
+
disabled={apply.status === 'applying'}
|
|
255
|
+
className="mt-2 text-xs text-theme-text-secondary hover:underline"
|
|
256
|
+
>
|
|
257
|
+
Remove saved headers
|
|
258
|
+
</button>
|
|
259
|
+
)}
|
|
260
|
+
{headerRows?.length === 0 && (
|
|
261
|
+
<p className="mt-1 text-xs text-warning-text">Headers will be removed when you click Apply now.</p>
|
|
262
|
+
)}
|
|
263
|
+
</section>}
|
|
264
|
+
<div className="mt-4 space-y-1">
|
|
265
|
+
{onDiscard ? <FormSaveActions dirty={dirty} busy={apply.status === 'applying'} onSave={() => void handleApply()} onDiscard={onDiscard} feedback={feedback} error={apply.status === 'failed' ? apply.error : undefined} /> : <Tooltip content="Save and apply this connection, then check reachability. Applying clears any workload scope override and resumes automatic identity matching.">
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
onClick={handleApply}
|
|
269
|
+
disabled={apply.status === 'applying'}
|
|
270
|
+
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium btn-brand rounded-md disabled:opacity-50"
|
|
271
|
+
>
|
|
272
|
+
{apply.status === 'applying'
|
|
273
|
+
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
274
|
+
: <Plug className="w-3.5 h-3.5" />}
|
|
275
|
+
Apply now
|
|
276
|
+
</button>
|
|
277
|
+
</Tooltip>}
|
|
278
|
+
{!onDiscard && <Collapse open={apply.status === 'failed' || (!onApplyOperations && apply.status !== 'applying')}>
|
|
279
|
+
{onApplyOperations && (apply.status === 'connected' || apply.status === 'unreachable') ? null : apply.status === 'connected' ? (
|
|
280
|
+
<p role="status" className="flex items-center gap-1 text-xs text-theme-text-secondary">
|
|
281
|
+
<Check className="w-3 h-3 shrink-0 text-[var(--color-success-dark)] dark:text-[var(--color-success-light)]" />
|
|
282
|
+
Connected to {apply.address} — applied, no restart needed
|
|
283
|
+
</p>
|
|
284
|
+
) : apply.status === 'unreachable' ? (
|
|
285
|
+
<p role="status" className="text-xs text-warning-text">Saved, but not reachable: {apply.error}</p>
|
|
286
|
+
) : apply.status === 'failed' ? (
|
|
287
|
+
<p role="alert" className="text-xs text-semantic-error">Couldn't apply: {apply.error}</p>
|
|
288
|
+
) : !onApplyOperations ? (
|
|
289
|
+
<p className="text-xs text-theme-text-tertiary">Saves and applies before checking the connection.</p>
|
|
290
|
+
) : null}
|
|
291
|
+
</Collapse>}
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
)
|
|
295
|
+
}
|