) => (
+
+ {Object.entries(defaults).map(([k, v]) => (
+ {k} → {v}
+ ))}
+
+ );
+
+ return (
+
+
+ Metric Schema Configuration
+
+ Map your metric names and labels to Depsera fields. Leave empty to use defaults.
+
+
+
+ {/* Metric Mappings */}
+
+
+ Metric Mappings
+ Your metric name → Depsera field
+
+ {renderDefaults(metricDefaults)}
+
+ {metricRows.map((row, index) => (
+
+ handleMetricKeyChange(index, e.target.value)}
+ placeholder="Your metric name"
+ disabled={disabled}
+ aria-label={`Metric name ${index + 1}`}
+ />
+ →
+ handleMetricFieldChange(index, e.target.value)}
+ disabled={disabled}
+ aria-label={`Metric target field ${index + 1}`}
+ >
+ {METRIC_FIELDS.map((f) => (
+
+ {f}
+
+ ))}
+
+ handleRemoveMetric(index)}
+ disabled={disabled}
+ aria-label={`Remove metric mapping ${index + 1}`}
+ >
+ ×
+
+
+ ))}
+
+
+ + Add metric mapping
+
+
+
+
+
+ {/* Label Mappings */}
+
+
+ Label Mappings
+ Your label / attribute name → Depsera field
+
+ {renderDefaults(labelDefaults)}
+
+ {labelRows.map((row, index) => (
+
+ handleLabelKeyChange(index, e.target.value)}
+ placeholder="Your label name"
+ disabled={disabled}
+ aria-label={`Label name ${index + 1}`}
+ />
+ →
+ handleLabelFieldChange(index, e.target.value)}
+ disabled={disabled}
+ aria-label={`Label target field ${index + 1}`}
+ >
+ {LABEL_FIELDS.map((f) => (
+
+ {f}
+
+ ))}
+
+ handleRemoveLabel(index)}
+ disabled={disabled}
+ aria-label={`Remove label mapping ${index + 1}`}
+ >
+ ×
+
+
+ ))}
+
+
+ + Add label mapping
+
+
+
+
+
+ {/* Options row: Latency Unit + Healthy Value side by side */}
+
+
+
+
+ Healthy Value
+ handleHealthyValueChange(e.target.value)}
+ placeholder="1"
+ disabled={disabled}
+ aria-label="Healthy value"
+ />
+ Metric value that means healthy (default: 1)
+
+
+
+ );
+}
+
+export default MetricSchemaConfigEditor;
diff --git a/client/src/components/pages/Services/ServiceDetail.test.tsx b/client/src/components/pages/Services/ServiceDetail.test.tsx
index bd9094b..62d36fa 100644
--- a/client/src/components/pages/Services/ServiceDetail.test.tsx
+++ b/client/src/components/pages/Services/ServiceDetail.test.tsx
@@ -150,6 +150,7 @@ function renderServiceDetail(
/** Helper to click a tab by its role */
async function switchTab(name: string) {
+ // eslint-disable-next-line security/detect-non-literal-regexp
const tab = screen.getByRole('tab', { name: new RegExp(name) });
fireEvent.click(tab);
}
diff --git a/client/src/components/pages/Services/ServiceDetail.tsx b/client/src/components/pages/Services/ServiceDetail.tsx
index 8b37bd7..208d66e 100644
--- a/client/src/components/pages/Services/ServiceDetail.tsx
+++ b/client/src/components/pages/Services/ServiceDetail.tsx
@@ -132,6 +132,14 @@ function ServiceDetail() {
{service.name}
+ {service.health_endpoint_format && service.health_endpoint_format !== 'default' && (
+
+ {service.health_endpoint_format === 'schema' ? 'Custom Schema' :
+ service.health_endpoint_format === 'prometheus' ? 'Prometheus' :
+ service.health_endpoint_format === 'otlp' ? 'OTLP' :
+ service.health_endpoint_format}
+
+ )}
{service.manifest_managed === 1 && (
M
)}
@@ -189,14 +197,21 @@ function ServiceDetail() {
Team
{service.team.name}
-
+ {service.health_endpoint_format === 'otlp' ? (
+
+ Ingestion
+ Push via OTLP
+
+ ) : (
+
+ )}
{service.metrics_endpoint && (
Metrics Endpoint
diff --git a/client/src/components/pages/Services/ServiceForm.test.tsx b/client/src/components/pages/Services/ServiceForm.test.tsx
index 9df81f3..7c179b6 100644
--- a/client/src/components/pages/Services/ServiceForm.test.tsx
+++ b/client/src/components/pages/Services/ServiceForm.test.tsx
@@ -25,6 +25,7 @@ const mockService = {
health_endpoint: 'https://example.com/health',
metrics_endpoint: 'https://example.com/metrics',
schema_config: null,
+ health_endpoint_format: 'default' as const,
is_active: 1,
last_poll_success: 1,
last_poll_error: null,
@@ -119,6 +120,7 @@ describe('ServiceForm', () => {
team_id: 't1',
health_endpoint: 'https://example.com/health',
schema_config: null,
+ health_endpoint_format: 'default',
}),
})
);
@@ -154,6 +156,7 @@ describe('ServiceForm', () => {
health_endpoint: 'https://example.com/health',
metrics_endpoint: 'https://example.com/metrics',
schema_config: null,
+ health_endpoint_format: 'default',
}),
})
);
@@ -184,6 +187,7 @@ describe('ServiceForm', () => {
metrics_endpoint: 'https://example.com/metrics',
is_active: true,
schema_config: null,
+ health_endpoint_format: 'default',
}),
})
);
@@ -308,24 +312,43 @@ describe('ServiceForm', () => {
});
describe('schema config integration', () => {
- it('renders Health Endpoint Format section', () => {
+ // Helper to select a format from the format dropdown
+ const selectFormat = (format: string) => {
+ fireEvent.change(screen.getByLabelText(/Format/), { target: { value: format } });
+ };
+
+ it('renders format selector with all options', () => {
render( );
- expect(screen.getByText('Health Endpoint Format')).toBeInTheDocument();
- expect(screen.getByText('proactive-deps (default)')).toBeInTheDocument();
- expect(screen.getByText('Custom schema')).toBeInTheDocument();
+ const formatSelect = screen.getByLabelText(/Format/) as HTMLSelectElement;
+ expect(formatSelect).toBeInTheDocument();
+ expect(formatSelect.value).toBe('default');
+
+ const options = Array.from(formatSelect.options).map(o => o.value);
+ expect(options).toEqual(['default', 'schema', 'prometheus', 'otlp']);
});
- it('defaults to proactive-deps mode for new service', () => {
+ it('hides schema editor in default format', () => {
render( );
- // Guided fields should not be visible in default mode
+ // Schema editor should not be visible in default mode
+ expect(screen.queryByText('Health Endpoint Format')).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Path to dependencies/)).not.toBeInTheDocument();
});
- it('shows guided form when Custom schema is selected', () => {
+ it('shows schema editor when Custom Schema format is selected', () => {
render( );
+ selectFormat('schema');
+
+ // SchemaConfigEditor should be visible
+ expect(screen.getByText('Health Endpoint Format')).toBeInTheDocument();
+ });
+
+ it('shows guided form when Custom schema is selected inside schema editor', () => {
+ render( );
+
+ selectFormat('schema');
fireEvent.click(screen.getByText('Custom schema'));
expect(screen.getByLabelText(/Path to dependencies/)).toBeInTheDocument();
@@ -337,9 +360,10 @@ describe('ServiceForm', () => {
expect(screen.getByLabelText(/Description field/)).toBeInTheDocument();
});
- it('hides guided form when switching back to default', () => {
+ it('hides guided form when switching back to default inside schema editor', () => {
render( );
+ selectFormat('schema');
fireEvent.click(screen.getByText('Custom schema'));
expect(screen.getByLabelText(/Path to dependencies/)).toBeInTheDocument();
@@ -357,7 +381,8 @@ describe('ServiceForm', () => {
fireEvent.change(screen.getByLabelText(/Team/), { target: { value: 't1' } });
fireEvent.change(screen.getByLabelText(/Health Endpoint/), { target: { value: 'https://example.com/health' } });
- // Switch to custom schema
+ // Switch to custom schema format then configure schema
+ selectFormat('schema');
fireEvent.click(screen.getByText('Custom schema'));
// Fill schema fields
@@ -384,6 +409,7 @@ describe('ServiceForm', () => {
healthy: { field: 'status', equals: 'UP' },
},
}),
+ health_endpoint_format: 'schema',
}),
})
);
@@ -410,6 +436,7 @@ describe('ServiceForm', () => {
it('populates schema editor from existing service schema_config', () => {
const serviceWithSchema = {
...mockService,
+ health_endpoint_format: 'schema' as const,
schema_config: JSON.stringify({
root: 'data.checks',
fields: {
@@ -422,7 +449,7 @@ describe('ServiceForm', () => {
render( );
- // Should show custom schema mode with populated fields
+ // Should show custom schema mode with populated fields (SchemaConfigEditor auto-detects from value)
expect(screen.getByLabelText(/Path to dependencies/)).toHaveValue('data.checks');
expect(screen.getByLabelText(/Name field/)).toHaveValue('serviceName');
expect(screen.getByLabelText(/Healthy field/)).toHaveValue('status');
@@ -430,16 +457,18 @@ describe('ServiceForm', () => {
expect(screen.getByLabelText(/Latency field/)).toHaveValue('responseTimeMs');
});
- it('shows proactive-deps mode for service without schema_config', () => {
+ it('does not show schema editor for service without schema_config', () => {
render( );
- // Should not show guided fields
+ // Default format — no schema editor
+ expect(screen.queryByText('Health Endpoint Format')).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Path to dependencies/)).not.toBeInTheDocument();
});
it('shows Test mapping button when in custom schema mode', () => {
render( );
+ selectFormat('schema');
fireEvent.click(screen.getByText('Custom schema'));
expect(screen.getByText('Test mapping')).toBeInTheDocument();
@@ -448,6 +477,7 @@ describe('ServiceForm', () => {
it('disables Test mapping button when health endpoint is empty', () => {
render( );
+ selectFormat('schema');
fireEvent.click(screen.getByText('Custom schema'));
const testButton = screen.getByText('Test mapping');
@@ -457,6 +487,7 @@ describe('ServiceForm', () => {
it('toggles between guided form and raw JSON editor', () => {
render( );
+ selectFormat('schema');
fireEvent.click(screen.getByText('Custom schema'));
// Should show guided form by default
@@ -492,6 +523,7 @@ describe('ServiceForm', () => {
fireEvent.change(screen.getByLabelText(/Health Endpoint/), { target: { value: 'https://example.com/health' } });
// Switch to custom schema
+ selectFormat('schema');
fireEvent.click(screen.getByText('Custom schema'));
// Fill schema fields
@@ -517,6 +549,7 @@ describe('ServiceForm', () => {
render( );
fireEvent.change(screen.getByLabelText(/Health Endpoint/), { target: { value: 'https://example.com/health' } });
+ selectFormat('schema');
fireEvent.click(screen.getByText('Custom schema'));
fireEvent.change(screen.getByLabelText(/Path to dependencies/), { target: { value: 'checks' } });
@@ -539,6 +572,7 @@ describe('ServiceForm', () => {
fireEvent.change(screen.getByLabelText(/Team/), { target: { value: 't1' } });
fireEvent.change(screen.getByLabelText(/Health Endpoint/), { target: { value: 'https://example.com/health' } });
+ selectFormat('schema');
fireEvent.click(screen.getByText('Custom schema'));
fireEvent.change(screen.getByLabelText(/Path to dependencies/), { target: { value: 'checks' } });
@@ -556,6 +590,89 @@ describe('ServiceForm', () => {
});
});
+ describe('format selector', () => {
+ it('selecting OTLP hides health endpoint URL', () => {
+ render( );
+
+ // Default: health endpoint visible
+ expect(screen.getByLabelText(/Health Endpoint/)).toBeInTheDocument();
+
+ // Switch to OTLP
+ fireEvent.change(screen.getByLabelText(/Format/), { target: { value: 'otlp' } });
+
+ // Health endpoint and metrics endpoint should be hidden
+ expect(screen.queryByLabelText(/Health Endpoint/)).not.toBeInTheDocument();
+ expect(screen.queryByLabelText(/Metrics Endpoint/)).not.toBeInTheDocument();
+ });
+
+ it('selecting Prometheus shows health endpoint URL', () => {
+ render( );
+
+ fireEvent.change(screen.getByLabelText(/Format/), { target: { value: 'prometheus' } });
+
+ expect(screen.getByLabelText(/Health Endpoint/)).toBeInTheDocument();
+ });
+
+ it('OTLP format does not require health endpoint for validation', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse({ id: 's2', name: 'New OTLP Service' }));
+
+ render( );
+
+ fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'New OTLP Service' } });
+ fireEvent.change(screen.getByLabelText(/Team/), { target: { value: 't1' } });
+ fireEvent.change(screen.getByLabelText(/Format/), { target: { value: 'otlp' } });
+
+ fireEvent.click(screen.getByText('Create Service'));
+
+ // Should NOT show validation error for health endpoint
+ expect(screen.queryByText('Health endpoint is required')).not.toBeInTheDocument();
+
+ await waitFor(() => {
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+ expect(body.health_endpoint).toBe('');
+ expect(body.health_endpoint_format).toBe('otlp');
+ });
+ });
+
+ it('format is included in submit payload', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse({ id: 's2', name: 'Prometheus Service' }));
+
+ render( );
+
+ fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'Prometheus Service' } });
+ fireEvent.change(screen.getByLabelText(/Team/), { target: { value: 't1' } });
+ fireEvent.change(screen.getByLabelText(/Health Endpoint/), { target: { value: 'https://example.com/metrics' } });
+ fireEvent.change(screen.getByLabelText(/Format/), { target: { value: 'prometheus' } });
+
+ fireEvent.click(screen.getByText('Create Service'));
+
+ await waitFor(() => {
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+ expect(body.health_endpoint_format).toBe('prometheus');
+ });
+ });
+
+ it('shows OTLP info message when OTLP format is selected', () => {
+ render( );
+
+ fireEvent.change(screen.getByLabelText(/Format/), { target: { value: 'otlp' } });
+
+ expect(screen.getByText(/receives pushed metrics via OTLP/)).toBeInTheDocument();
+ });
+
+ it('schema editor hidden when switching from schema to default format', () => {
+ render( );
+
+ // Select schema format
+ fireEvent.change(screen.getByLabelText(/Format/), { target: { value: 'schema' } });
+ expect(screen.getByText('Health Endpoint Format')).toBeInTheDocument();
+
+ // Switch back to default
+ fireEvent.change(screen.getByLabelText(/Format/), { target: { value: 'default' } });
+ expect(screen.queryByText('Health Endpoint Format')).not.toBeInTheDocument();
+ });
+ });
+
describe('manifest warning banner', () => {
it('shows warning banner when editing a manifest-managed service', () => {
const manifestService = { ...mockService, manifest_managed: 1 };
diff --git a/client/src/components/pages/Services/ServiceForm.tsx b/client/src/components/pages/Services/ServiceForm.tsx
index 80c3285..bc1fcfc 100644
--- a/client/src/components/pages/Services/ServiceForm.tsx
+++ b/client/src/components/pages/Services/ServiceForm.tsx
@@ -6,8 +6,11 @@ import type {
CreateServiceInput,
UpdateServiceInput,
SchemaMapping,
+ MetricSchemaConfig,
+ HealthEndpointFormat,
} from '../../../types/service';
import SchemaConfigEditor from './SchemaConfigEditor';
+import MetricSchemaConfigEditor from './MetricSchemaConfigEditor';
import styles from './ServiceForm.module.css';
interface ServiceFormProps {
@@ -43,6 +46,26 @@ function parseSchemaConfig(raw: string | null): SchemaMapping | null {
}
}
+function parseMetricSchemaConfig(raw: string | null): MetricSchemaConfig | null {
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw);
+ if (parsed && (parsed.metrics || parsed.labels || parsed.latency_unit)) {
+ return parsed as MetricSchemaConfig;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+const FORMAT_OPTIONS: { value: HealthEndpointFormat; label: string }[] = [
+ { value: 'default', label: 'Default' },
+ { value: 'schema', label: 'Custom Schema' },
+ { value: 'prometheus', label: 'Prometheus' },
+ { value: 'otlp', label: 'OTLP (Push)' },
+];
+
function ServiceForm({ teams, service, onSuccess, onCancel }: ServiceFormProps) {
const isEdit = !!service;
@@ -52,10 +75,14 @@ function ServiceForm({ teams, service, onSuccess, onCancel }: ServiceFormProps)
health_endpoint: service?.health_endpoint ?? '',
metrics_endpoint: service?.metrics_endpoint ?? '',
is_active: service?.is_active === 1,
+ health_endpoint_format: (service?.health_endpoint_format ?? 'default') as HealthEndpointFormat,
});
const [schemaConfig, setSchemaConfig] = useState(
parseSchemaConfig(service?.schema_config ?? null)
);
+ const [metricSchemaConfig, setMetricSchemaConfig] = useState(
+ parseMetricSchemaConfig(service?.schema_config ?? null)
+ );
const [errors, setErrors] = useState({});
const [submitError, setSubmitError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -65,6 +92,14 @@ function ServiceForm({ teams, service, onSuccess, onCancel }: ServiceFormProps)
setErrors((prev) => ({ ...prev, schema_config: undefined }));
}, []);
+ const handleMetricSchemaChange = useCallback((value: MetricSchemaConfig | null) => {
+ setMetricSchemaConfig(value);
+ }, []);
+
+ const requiresHealthEndpoint = formData.health_endpoint_format !== 'otlp';
+ const showSchemaEditor = formData.health_endpoint_format === 'schema';
+ const showMetricSchemaEditor = formData.health_endpoint_format === 'prometheus' || formData.health_endpoint_format === 'otlp';
+
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
@@ -76,10 +111,12 @@ function ServiceForm({ teams, service, onSuccess, onCancel }: ServiceFormProps)
newErrors.team_id = 'Team is required';
}
- if (!formData.health_endpoint.trim()) {
- newErrors.health_endpoint = 'Health endpoint is required';
- } else if (!isValidUrl(formData.health_endpoint)) {
- newErrors.health_endpoint = 'Must be a valid HTTP or HTTPS URL';
+ if (requiresHealthEndpoint) {
+ if (!formData.health_endpoint.trim()) {
+ newErrors.health_endpoint = 'Health endpoint is required';
+ } else if (!isValidUrl(formData.health_endpoint)) {
+ newErrors.health_endpoint = 'Must be a valid HTTP or HTTPS URL';
+ }
}
if (formData.metrics_endpoint && !isValidUrl(formData.metrics_endpoint)) {
@@ -101,25 +138,32 @@ function ServiceForm({ teams, service, onSuccess, onCancel }: ServiceFormProps)
setIsSubmitting(true);
try {
- const schemaConfigJson = schemaConfig ? JSON.stringify(schemaConfig) : null;
+ const schemaConfigJson = showSchemaEditor && schemaConfig
+ ? JSON.stringify(schemaConfig)
+ : showMetricSchemaEditor && metricSchemaConfig
+ ? JSON.stringify(metricSchemaConfig)
+ : null;
+ const healthEndpoint = requiresHealthEndpoint ? formData.health_endpoint : '';
if (isEdit && service) {
const updateData: UpdateServiceInput = {
name: formData.name,
team_id: formData.team_id,
- health_endpoint: formData.health_endpoint,
+ health_endpoint: healthEndpoint,
metrics_endpoint: formData.metrics_endpoint || undefined,
is_active: formData.is_active,
schema_config: schemaConfigJson,
+ health_endpoint_format: formData.health_endpoint_format,
};
await updateService(service.id, updateData);
} else {
const createData: CreateServiceInput = {
name: formData.name,
team_id: formData.team_id,
- health_endpoint: formData.health_endpoint,
+ health_endpoint: healthEndpoint,
metrics_endpoint: formData.metrics_endpoint || undefined,
schema_config: schemaConfigJson,
+ health_endpoint_format: formData.health_endpoint_format,
};
await createService(createData);
}
@@ -199,55 +243,98 @@ function ServiceForm({ teams, service, onSuccess, onCancel }: ServiceFormProps)
-
- Health Endpoint *
+
+ Format *
- setFormData({ ...formData, health_endpoint: e.target.value })}
- className={`${styles.input} ${errors.health_endpoint ? styles.inputError : ''}`}
- placeholder="https://example.com/dependencies"
+ setFormData({ ...formData, health_endpoint_format: e.target.value as HealthEndpointFormat })}
+ className={styles.select}
disabled={isSubmitting}
- aria-describedby={errors.health_endpoint ? 'health-endpoint-error' : undefined}
- />
- {errors.health_endpoint && (
-
- {errors.health_endpoint}
-
- )}
- URL that returns dependency health status
+ >
+ {FORMAT_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ {formData.health_endpoint_format === 'otlp'
+ ? 'This service receives pushed metrics via OTLP. Configure an API key in Team Settings.'
+ : formData.health_endpoint_format === 'prometheus'
+ ? 'Expects Prometheus text exposition format with dependency_health_* metrics.'
+ : formData.health_endpoint_format === 'schema'
+ ? 'Uses a custom schema mapping to extract dependency data from your endpoint.'
+ : 'Expects a JSON array of dependency status objects.'}
+
-
-
- Metrics Endpoint
-
-
setFormData({ ...formData, metrics_endpoint: e.target.value })}
- className={`${styles.input} ${errors.metrics_endpoint ? styles.inputError : ''}`}
- placeholder="https://example.com/metrics"
+ {requiresHealthEndpoint && (
+
+
+ Health Endpoint *
+
+ setFormData({ ...formData, health_endpoint: e.target.value })}
+ className={`${styles.input} ${errors.health_endpoint ? styles.inputError : ''}`}
+ placeholder="https://example.com/dependencies"
+ disabled={isSubmitting}
+ aria-describedby={errors.health_endpoint ? 'health-endpoint-error' : undefined}
+ />
+ {errors.health_endpoint && (
+
+ {errors.health_endpoint}
+
+ )}
+ URL that returns dependency health status
+
+ )}
+
+ {requiresHealthEndpoint && (
+
+
+ Metrics Endpoint
+
+ setFormData({ ...formData, metrics_endpoint: e.target.value })}
+ className={`${styles.input} ${errors.metrics_endpoint ? styles.inputError : ''}`}
+ placeholder="https://example.com/metrics"
+ disabled={isSubmitting}
+ aria-describedby={errors.metrics_endpoint ? 'metrics-endpoint-error' : undefined}
+ />
+ {errors.metrics_endpoint && (
+
+ {errors.metrics_endpoint}
+
+ )}
+ Optional URL for metrics data
+
+ )}
+
+ {showSchemaEditor && (
+
- {errors.metrics_endpoint && (
-
- {errors.metrics_endpoint}
-
- )}
-
Optional URL for metrics data
-
+ )}
-
+ {showMetricSchemaEditor && (
+
+ )}
{isEdit && (
diff --git a/client/src/components/pages/Services/Services.module.css b/client/src/components/pages/Services/Services.module.css
index 06e3e22..51a9b4f 100644
--- a/client/src/components/pages/Services/Services.module.css
+++ b/client/src/components/pages/Services/Services.module.css
@@ -653,6 +653,19 @@
margin-left: 0.5rem;
}
+.formatBadge {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.125rem 0.5rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--color-text-secondary);
+ background-color: var(--color-bg-hover);
+ border: 1px solid var(--color-border);
+ border-radius: 9999px;
+ margin-left: 0.5rem;
+}
+
/* Responsive */
@media (max-width: 640px) {
.container {
diff --git a/client/src/components/pages/Teams/ApiKeys.module.css b/client/src/components/pages/Teams/ApiKeys.module.css
new file mode 100644
index 0000000..cacd1d5
--- /dev/null
+++ b/client/src/components/pages/Teams/ApiKeys.module.css
@@ -0,0 +1,375 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+}
+
+.title {
+ font-size: var(--font-lg);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
+ margin: 0;
+}
+
+.subtitle {
+ font-size: var(--font-sm);
+ color: var(--color-text-muted);
+ margin: 0.25rem 0 0;
+}
+
+.createButton {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.375rem 0.75rem;
+ font-size: var(--font-sm);
+ font-weight: 500;
+ color: var(--color-text-inverse);
+ background-color: var(--color-accent);
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: background-color var(--duration-fast);
+}
+
+.createButton:hover {
+ background-color: var(--color-accent-hover);
+}
+
+/* Revealed key card */
+.revealedKeyCard {
+ padding: var(--space-4);
+ background-color: var(--color-success-bg, rgba(34, 197, 94, 0.08));
+ border: 1px solid var(--color-success-border, rgba(34, 197, 94, 0.3));
+ border-radius: var(--radius-md);
+}
+
+.revealedKeyHeader {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--color-healthy);
+ margin-bottom: 0.5rem;
+}
+
+.revealedKeyWarning {
+ font-size: var(--font-sm);
+ color: var(--color-text-muted);
+ margin: 0 0 0.75rem;
+}
+
+.revealedKeyValue {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background-color: var(--color-bg-input);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ margin-bottom: 0.75rem;
+ overflow-x: auto;
+}
+
+.revealedKeyValue code {
+ font-size: var(--font-sm);
+ word-break: break-all;
+ flex: 1;
+}
+
+.copyButton {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem;
+ color: var(--color-text-muted);
+ background: none;
+ border: none;
+ cursor: pointer;
+ border-radius: var(--radius-sm);
+ transition: color var(--duration-fast);
+ flex-shrink: 0;
+}
+
+.copyButton:hover {
+ color: var(--color-text);
+}
+
+.dismissButton {
+ padding: 0.375rem 0.75rem;
+ font-size: var(--font-sm);
+ font-weight: 500;
+ color: var(--color-text);
+ background-color: var(--color-bg-card);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+}
+
+.dismissButton:hover {
+ background-color: var(--color-bg-hover);
+}
+
+/* Create form */
+.createForm {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding: var(--space-3);
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+}
+
+.nameInput {
+ padding: 0.5rem 0.75rem;
+ font-size: var(--font-sm);
+ border: 1px solid var(--color-border-input);
+ border-radius: var(--radius-md);
+ background-color: var(--color-bg-input);
+ color: var(--color-text);
+}
+
+.nameInput:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.createActions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+}
+
+.cancelButton {
+ padding: 0.375rem 0.75rem;
+ font-size: var(--font-sm);
+ font-weight: 500;
+ color: var(--color-text);
+ background-color: var(--color-bg-card);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+}
+
+.cancelButton:hover:not(:disabled) {
+ background-color: var(--color-bg-hover);
+}
+
+.generateButton {
+ padding: 0.375rem 0.75rem;
+ font-size: var(--font-sm);
+ font-weight: 500;
+ color: var(--color-text-inverse);
+ background-color: var(--color-accent);
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+}
+
+.generateButton:hover:not(:disabled) {
+ background-color: var(--color-accent-hover);
+}
+
+.generateButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Empty state */
+.emptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--space-6);
+ color: var(--color-text-muted);
+ text-align: center;
+}
+
+.emptyState p {
+ margin: 0.5rem 0 0;
+}
+
+.emptyIcon {
+ opacity: 0.4;
+}
+
+.emptyHint {
+ font-size: var(--font-sm);
+}
+
+/* Key list */
+.keyList {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.keyItem {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: 0.75rem 1rem;
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+}
+
+.keyInfo {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.keyName {
+ font-size: var(--font-sm);
+ font-weight: 500;
+ color: var(--color-text);
+}
+
+.keyPrefix {
+ font-size: var(--font-xs);
+ color: var(--color-text-muted);
+}
+
+/* Rate limit column */
+.rateLimit {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ flex-shrink: 0;
+}
+
+.rateLimitValue {
+ font-size: var(--font-sm);
+ color: var(--color-text);
+ font-variant-numeric: tabular-nums;
+}
+
+.rateLimitLabel {
+ font-size: var(--font-xs);
+ color: var(--color-text-muted);
+}
+
+.lockIcon {
+ color: var(--color-text-muted);
+ flex-shrink: 0;
+}
+
+.editButton {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem;
+ color: var(--color-text-muted);
+ background: none;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: color var(--duration-fast), border-color var(--duration-fast);
+ flex-shrink: 0;
+}
+
+.editButton:hover {
+ color: var(--color-accent);
+ border-color: var(--color-accent);
+}
+
+/* Rate limit edit dialog */
+.rateLimitDialog {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.rateLimitDialogDesc {
+ font-size: var(--font-sm);
+ color: var(--color-text-secondary);
+ margin: 0;
+ line-height: 1.5;
+}
+
+.rateLimitDialogLabel {
+ font-size: var(--font-sm);
+ font-weight: 500;
+ color: var(--color-text);
+}
+
+.rateLimitDialogError {
+ font-size: var(--font-xs);
+ color: var(--color-critical);
+ margin: -0.25rem 0 0;
+}
+
+.rateLimitDialogActions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 0.25rem;
+}
+
+.rateLimitDialogRight {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.keyMeta {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ font-size: var(--font-xs);
+ color: var(--color-text-muted);
+ text-align: right;
+ flex-shrink: 0;
+}
+
+.deleteButton {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.375rem;
+ color: var(--color-text-muted);
+ background: none;
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: color var(--duration-fast), border-color var(--duration-fast);
+ flex-shrink: 0;
+}
+
+.deleteButton:hover {
+ color: var(--color-critical);
+ border-color: var(--color-critical);
+}
+
+/* Help section */
+.helpSection {
+ padding: var(--space-3);
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+}
+
+.helpTitle {
+ font-size: var(--font-sm);
+ font-weight: 500;
+ color: var(--color-text);
+ margin: 0 0 0.5rem;
+}
+
+.codeBlock {
+ padding: 0.75rem 1rem;
+ font-size: var(--font-xs);
+ line-height: 1.6;
+ color: var(--color-text-secondary);
+ background-color: var(--color-bg-input);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ overflow-x: auto;
+ margin: 0;
+}
diff --git a/client/src/components/pages/Teams/ApiKeys.test.tsx b/client/src/components/pages/Teams/ApiKeys.test.tsx
new file mode 100644
index 0000000..f1b241d
--- /dev/null
+++ b/client/src/components/pages/Teams/ApiKeys.test.tsx
@@ -0,0 +1,379 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import ApiKeys from './ApiKeys';
+
+// Mock HTMLDialogElement for ConfirmDialog
+beforeAll(() => {
+ HTMLDialogElement.prototype.showModal = jest.fn();
+ HTMLDialogElement.prototype.close = jest.fn();
+});
+
+const mockFetch = jest.fn();
+global.fetch = mockFetch;
+
+function jsonResponse(data: unknown, status = 200) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: () => Promise.resolve(data),
+ };
+}
+
+const mockKeys = [
+ {
+ id: 'k1',
+ team_id: 't1',
+ name: 'Production Collector',
+ key_prefix: 'dps_a1b2c3d4',
+ last_used_at: '2026-03-14T10:00:00Z',
+ created_at: '2026-03-01T10:00:00Z',
+ created_by: 'u1',
+ },
+ {
+ id: 'k2',
+ team_id: 't1',
+ name: 'Staging Collector',
+ key_prefix: 'dps_e5f6g7h8',
+ last_used_at: null,
+ created_at: '2026-03-10T10:00:00Z',
+ created_by: 'u1',
+ },
+];
+
+beforeEach(() => {
+ mockFetch.mockReset();
+});
+
+describe('ApiKeys', () => {
+ it('renders key list with prefix and dates', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(mockKeys));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Production Collector')).toBeInTheDocument();
+ expect(screen.getByText('Staging Collector')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('dps_a1b2c3d4...')).toBeInTheDocument();
+ expect(screen.getByText('dps_e5f6g7h8...')).toBeInTheDocument();
+ expect(screen.getByText('Never used')).toBeInTheDocument();
+ });
+
+ it('renders empty state correctly', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('No API keys yet.')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText(/Create a key to start pushing OTLP metrics/)).toBeInTheDocument();
+ });
+
+ it('empty state does not show create hint for non-managers', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('No API keys yet.')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByText(/Create a key to start pushing OTLP metrics/)).not.toBeInTheDocument();
+ });
+
+ it('create shows raw key once with copy button', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(mockKeys)); // initial load
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse({
+ id: 'k3',
+ team_id: 't1',
+ name: 'New Key',
+ key_prefix: 'dps_newkey12',
+ rawKey: 'dps_newkey1234567890abcdef1234567890abcdef',
+ last_used_at: null,
+ created_at: '2026-03-15T10:00:00Z',
+ created_by: 'u1',
+ }, 201)
+ );
+ mockFetch.mockResolvedValueOnce(jsonResponse([...mockKeys, { id: 'k3', team_id: 't1', name: 'New Key', key_prefix: 'dps_newkey12', last_used_at: null, created_at: '2026-03-15T10:00:00Z', created_by: 'u1' }])); // reload
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Production Collector')).toBeInTheDocument();
+ });
+
+ // Click create button
+ fireEvent.click(screen.getByText('Create Key'));
+
+ // Fill name and generate
+ fireEvent.change(screen.getByPlaceholderText(/Key name/), { target: { value: 'New Key' } });
+ fireEvent.click(screen.getByText('Generate'));
+
+ await waitFor(() => {
+ expect(screen.getByText('API Key Created')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('dps_newkey1234567890abcdef1234567890abcdef')).toBeInTheDocument();
+ expect(screen.getByText(/will not be shown again/)).toBeInTheDocument();
+ });
+
+ it('delete shows confirm dialog', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(mockKeys));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Production Collector')).toBeInTheDocument();
+ });
+
+ // Click delete button on first key
+ const deleteButtons = screen.getAllByTitle('Revoke key');
+ fireEvent.click(deleteButtons[0]);
+
+ // Confirm dialog should appear
+ expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
+ expect(screen.getByText(/Any collectors using it will no longer be able to push metrics/)).toBeInTheDocument();
+ });
+
+ it('non-manager cannot see Create Key button', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(mockKeys));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Production Collector')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByText('Create Key')).not.toBeInTheDocument();
+ });
+
+ it('non-manager cannot see delete buttons', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(mockKeys));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Production Collector')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByTitle('Revoke key')).not.toBeInTheDocument();
+ });
+
+ it('shows collector configuration help text', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Collector Configuration')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText(/otlphttp/)).toBeInTheDocument();
+ expect(screen.getByText(/Bearer dps_/)).toBeInTheDocument();
+ });
+
+ it('handles API error when loading keys', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'Unauthorized' }, 401));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Unauthorized')).toBeInTheDocument();
+ });
+ });
+
+ it('cancel button hides create form', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Create Key')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText('Create Key'));
+ expect(screen.getByPlaceholderText(/Key name/)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(screen.queryByPlaceholderText(/Key name/)).not.toBeInTheDocument();
+ });
+
+ it('generate button is disabled when name is empty', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Create Key')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText('Create Key'));
+
+ const generateButton = screen.getByText('Generate');
+ expect(generateButton).toBeDisabled();
+ });
+
+ // --- Rate limit column tests (DPS-102b/c/d) ---
+
+ it('displays (default) when rate_limit_rpm is null', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([
+ { ...mockKeys[0], rate_limit_rpm: null, rate_limit_admin_locked: 0 },
+ ]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('(default)')).toBeInTheDocument();
+ });
+ });
+
+ it('displays (custom) when rate_limit_rpm is set', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([
+ { ...mockKeys[0], rate_limit_rpm: 50000, rate_limit_admin_locked: 0 },
+ ]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('(custom)')).toBeInTheDocument();
+ });
+ });
+
+ it('renders lock icon when rate_limit_admin_locked is 1', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([
+ { ...mockKeys[0], rate_limit_rpm: 50000, rate_limit_admin_locked: 1 },
+ ]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Locked by admin')).toBeInTheDocument();
+ });
+ });
+
+ it('does not render edit icon when rate_limit_admin_locked is 1', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([
+ { ...mockKeys[0], rate_limit_rpm: 50000, rate_limit_admin_locked: 1 },
+ ]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Locked by admin')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByTitle('Edit rate limit')).not.toBeInTheDocument();
+ });
+
+ it('renders edit icon when canManage and key is not locked', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([
+ { ...mockKeys[0], rate_limit_rpm: null, rate_limit_admin_locked: 0 },
+ ]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Edit rate limit')).toBeInTheDocument();
+ });
+ });
+
+ it('does not render edit icon when canManage is false', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([
+ { ...mockKeys[0], rate_limit_rpm: null, rate_limit_admin_locked: 0 },
+ ]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Production Collector')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByTitle('Edit rate limit')).not.toBeInTheDocument();
+ });
+
+ it('rate limit edit dialog validates and saves correctly', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([
+ { ...mockKeys[0], rate_limit_rpm: null, rate_limit_admin_locked: 0 },
+ ]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Edit rate limit')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTitle('Edit rate limit'));
+
+ expect(screen.getByText('Edit Rate Limit')).toBeInTheDocument();
+
+ const input = screen.getByPlaceholderText('150000');
+ fireEvent.change(input, { target: { value: '80000' } });
+
+ // Mock PATCH and reload
+ mockFetch.mockResolvedValueOnce(jsonResponse({ ok: true }));
+ mockFetch.mockResolvedValueOnce(jsonResponse([
+ { ...mockKeys[0], rate_limit_rpm: 80000, rate_limit_admin_locked: 0 },
+ ]));
+
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => {
+ const patchCall = mockFetch.mock.calls.find(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (c: any[]) => c[1]?.method === 'PATCH'
+ );
+ expect(patchCall).toBeDefined();
+ expect(JSON.parse(patchCall![1].body)).toEqual({ rate_limit_rpm: 80000 });
+ });
+ });
+
+ it('Reset to default sends null in rate limit edit dialog', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([
+ { ...mockKeys[0], rate_limit_rpm: 50000, rate_limit_admin_locked: 0 },
+ ]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Edit rate limit')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTitle('Edit rate limit'));
+
+ // Mock PATCH and reload
+ mockFetch.mockResolvedValueOnce(jsonResponse({ ok: true }));
+ mockFetch.mockResolvedValueOnce(jsonResponse([
+ { ...mockKeys[0], rate_limit_rpm: null, rate_limit_admin_locked: 0 },
+ ]));
+
+ fireEvent.click(screen.getByText('Reset to default'));
+
+ await waitFor(() => {
+ const patchCall = mockFetch.mock.calls.find(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (c: any[]) => c[1]?.method === 'PATCH'
+ );
+ expect(patchCall).toBeDefined();
+ expect(JSON.parse(patchCall![1].body)).toEqual({ rate_limit_rpm: null });
+ });
+ });
+
+ it('displays Unlimited with (admin) when rate_limit_rpm is 0', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse([
+ { ...mockKeys[0], rate_limit_rpm: 0, rate_limit_admin_locked: 0 },
+ ]));
+
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByText('Unlimited')).toBeInTheDocument();
+ expect(screen.getByText('(admin)')).toBeInTheDocument();
+ });
+
+ // Edit button should not show for unlimited keys (rpm=0)
+ expect(screen.queryByTitle('Edit rate limit')).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/pages/Teams/ApiKeys.tsx b/client/src/components/pages/Teams/ApiKeys.tsx
new file mode 100644
index 0000000..8332bd0
--- /dev/null
+++ b/client/src/components/pages/Teams/ApiKeys.tsx
@@ -0,0 +1,407 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Key, Trash2, Copy, Check, Plus, Lock, Pencil } from 'lucide-react';
+import { listApiKeys, createApiKey, deleteApiKey } from '../../../api/apiKeys';
+import type { ApiKey } from '../../../api/apiKeys';
+import { updateApiKeyRateLimit } from '../../../api/otlpStats';
+import { formatRelativeTime } from '../../../utils/formatting';
+import ConfirmDialog from '../../common/ConfirmDialog';
+import Modal from '../../common/Modal';
+import styles from './Teams.module.css';
+import apiKeyStyles from './ApiKeys.module.css';
+
+const DEFAULT_RATE_LIMIT_RPM = 150_000;
+
+interface ApiKeysProps {
+ teamId: string;
+ canManage: boolean;
+}
+
+function ApiKeys({ teamId, canManage }: ApiKeysProps) {
+ const [keys, setKeys] = useState
([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [newKeyName, setNewKeyName] = useState('');
+ const [isCreating, setIsCreating] = useState(false);
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [revealedKey, setRevealedKey] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const [deleteKeyId, setDeleteKeyId] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ // Rate limit edit dialog state
+ const [editRateLimitKey, setEditRateLimitKey] = useState(null);
+ const [rateLimitInput, setRateLimitInput] = useState('');
+ const [rateLimitError, setRateLimitError] = useState(null);
+ const [isSavingRateLimit, setIsSavingRateLimit] = useState(false);
+
+ const loadKeys = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const result = await listApiKeys(teamId);
+ setKeys(result);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load API keys');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [teamId]);
+
+ useEffect(() => {
+ loadKeys();
+ }, [loadKeys]);
+
+ const handleCreate = async () => {
+ if (!newKeyName.trim()) return;
+ try {
+ setIsCreating(true);
+ const result = await createApiKey(teamId, newKeyName.trim());
+ setRevealedKey(result.rawKey);
+ setNewKeyName('');
+ setShowCreateForm(false);
+ await loadKeys();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to create API key');
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!deleteKeyId) return;
+ try {
+ setIsDeleting(true);
+ await deleteApiKey(teamId, deleteKeyId);
+ setDeleteKeyId(null);
+ await loadKeys();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to delete API key');
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const handleCopy = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // Fallback: select the text
+ }
+ };
+
+ const dismissRevealedKey = () => {
+ setRevealedKey(null);
+ };
+
+ const openRateLimitDialog = (key: ApiKey) => {
+ setEditRateLimitKey(key);
+ setRateLimitInput(key.rate_limit_rpm !== null ? String(key.rate_limit_rpm) : '');
+ setRateLimitError(null);
+ };
+
+ const closeRateLimitDialog = () => {
+ setEditRateLimitKey(null);
+ setRateLimitInput('');
+ setRateLimitError(null);
+ };
+
+ const validateRateLimitInput = (value: string): string | null => {
+ if (value === '') return null; // empty = will reset to default
+ const num = Number(value);
+ if (!Number.isInteger(num) || num <= 0) return 'Must be a positive integer';
+ if (num > 1_500_000) return 'Cannot exceed 1,500,000 req/min';
+ return null;
+ };
+
+ const handleSaveRateLimit = async () => {
+ if (!editRateLimitKey) return;
+ const trimmed = rateLimitInput.trim();
+ const validationError = validateRateLimitInput(trimmed);
+ if (validationError) {
+ setRateLimitError(validationError);
+ return;
+ }
+ const newLimit = trimmed === '' ? null : Number(trimmed);
+ try {
+ setIsSavingRateLimit(true);
+ await updateApiKeyRateLimit(teamId, editRateLimitKey.id, newLimit);
+ closeRateLimitDialog();
+ await loadKeys();
+ } catch (err) {
+ setRateLimitError(err instanceof Error ? err.message : 'Failed to update rate limit');
+ } finally {
+ setIsSavingRateLimit(false);
+ }
+ };
+
+ const handleResetToDefault = async () => {
+ if (!editRateLimitKey) return;
+ try {
+ setIsSavingRateLimit(true);
+ await updateApiKeyRateLimit(teamId, editRateLimitKey.id, null);
+ closeRateLimitDialog();
+ await loadKeys();
+ } catch (err) {
+ setRateLimitError(err instanceof Error ? err.message : 'Failed to reset rate limit');
+ } finally {
+ setIsSavingRateLimit(false);
+ }
+ };
+
+ const getEffectiveRateLimit = (key: ApiKey): number => {
+ return key.rate_limit_rpm ?? DEFAULT_RATE_LIMIT_RPM;
+ };
+
+ const isCustomRateLimit = (key: ApiKey): boolean => {
+ return key.rate_limit_rpm !== null;
+ };
+
+ const isAdminLocked = (key: ApiKey): boolean => {
+ return key.rate_limit_admin_locked === 1;
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
Loading API keys...
+
+
+ );
+ }
+
+ return (
+
+
+
+
API Keys
+
+ Authenticate OTLP metric pushes from your collectors.
+
+
+ {canManage && !showCreateForm && !revealedKey && (
+
setShowCreateForm(true)}
+ className={apiKeyStyles.createButton}
+ >
+
+ Create Key
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {revealedKey && (
+
+
+
+ API Key Created
+
+
+ Copy this key now. It will not be shown again.
+
+
+ {revealedKey}
+ handleCopy(revealedKey)}
+ className={apiKeyStyles.copyButton}
+ title="Copy to clipboard"
+ >
+ {copied ? : }
+
+
+
+ Done
+
+
+ )}
+
+ {canManage && showCreateForm && (
+
+
setNewKeyName(e.target.value)}
+ placeholder="Key name (e.g., Production Collector)"
+ className={apiKeyStyles.nameInput}
+ disabled={isCreating}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleCreate();
+ }}
+ />
+
+ {
+ setShowCreateForm(false);
+ setNewKeyName('');
+ }}
+ className={apiKeyStyles.cancelButton}
+ disabled={isCreating}
+ >
+ Cancel
+
+
+ {isCreating ? 'Generating...' : 'Generate'}
+
+
+
+ )}
+
+ {keys.length === 0 ? (
+
+
+
No API keys yet.
+ {canManage &&
Create a key to start pushing OTLP metrics.
}
+
+ ) : (
+
+ {keys.map((key) => (
+
+
+ {key.name}
+ {key.key_prefix}...
+
+
+
+ {key.rate_limit_rpm === 0
+ ? 'Unlimited'
+ : `${getEffectiveRateLimit(key).toLocaleString()} req/min`}
+
+
+ {key.rate_limit_rpm === 0
+ ? '(admin)'
+ : isCustomRateLimit(key) ? '(custom)' : '(default)'}
+
+ {isAdminLocked(key) && (
+
+
+
+ )}
+ {canManage && !isAdminLocked(key) && key.rate_limit_rpm !== 0 && (
+
openRateLimitDialog(key)}
+ className={apiKeyStyles.editButton}
+ title="Edit rate limit"
+ >
+
+
+ )}
+
+
+ Created {formatRelativeTime(key.created_at)}
+ {key.last_used_at ? `Last used ${formatRelativeTime(key.last_used_at)}` : 'Never used'}
+
+ {canManage && (
+
setDeleteKeyId(key.id)}
+ className={apiKeyStyles.deleteButton}
+ title="Revoke key"
+ >
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+
Collector Configuration
+
+{`exporters:
+ otlphttp:
+ endpoint: "https:///v1/metrics"
+ headers:
+ Authorization: "Bearer dps_..."`}
+
+
+
+
setDeleteKeyId(null)}
+ onConfirm={handleDelete}
+ title="Revoke API Key"
+ message="Are you sure you want to revoke this API key? Any collectors using it will no longer be able to push metrics."
+ confirmLabel="Revoke"
+ isDestructive
+ isLoading={isDeleting}
+ />
+
+
+ {editRateLimitKey && (
+
+
+ Set the rate limit for {editRateLimitKey.name} ({editRateLimitKey.key_prefix}...).
+ Leave empty to use the system default ({DEFAULT_RATE_LIMIT_RPM.toLocaleString()} req/min).
+
+
+ Rate limit (req/min)
+
+
{
+ setRateLimitInput(e.target.value);
+ setRateLimitError(null);
+ }}
+ placeholder={String(DEFAULT_RATE_LIMIT_RPM)}
+ className={apiKeyStyles.nameInput}
+ min={1}
+ disabled={isSavingRateLimit}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleSaveRateLimit();
+ }}
+ />
+ {rateLimitError && (
+
{rateLimitError}
+ )}
+
+
+ Reset to default
+
+
+
+ Cancel
+
+
+ {isSavingRateLimit ? 'Saving...' : 'Save'}
+
+
+
+
+ )}
+
+
+ );
+}
+
+export default ApiKeys;
diff --git a/client/src/components/pages/Teams/OtlpStats.module.css b/client/src/components/pages/Teams/OtlpStats.module.css
new file mode 100644
index 0000000..7ff2c39
--- /dev/null
+++ b/client/src/components/pages/Teams/OtlpStats.module.css
@@ -0,0 +1,437 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+}
+
+.title {
+ font-size: var(--font-lg);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
+ margin: 0;
+}
+
+.subtitle {
+ font-size: var(--font-sm);
+ color: var(--color-text-muted);
+ margin: 0.25rem 0 0;
+}
+
+.summaryGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
+ gap: var(--space-3);
+}
+
+.summaryCard {
+ padding: var(--space-3);
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ text-align: center;
+}
+
+.summaryValue {
+ font-size: var(--font-2xl);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
+ margin: 0;
+}
+
+.summaryLabel {
+ font-size: var(--font-xs);
+ color: var(--color-text-muted);
+ margin: 0.25rem 0 0;
+}
+
+.summaryCardWarning {
+ composes: summaryCard;
+ border-color: var(--color-warning-border, rgba(234, 179, 8, 0.3));
+ background-color: var(--color-warning-bg, rgba(234, 179, 8, 0.06));
+}
+
+.summaryCardError {
+ composes: summaryCard;
+ border-color: var(--color-critical-border, rgba(239, 68, 68, 0.3));
+ background-color: var(--color-critical-bg, rgba(239, 68, 68, 0.06));
+}
+
+.sectionTitle {
+ font-size: var(--font-base);
+ font-weight: var(--font-semibold);
+ color: var(--color-text);
+ margin: 0;
+}
+
+.tableWrapper {
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ overflow-x: auto;
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-sm);
+}
+
+.table th {
+ text-align: left;
+ padding: 0.5rem 0.75rem;
+ font-weight: 500;
+ color: var(--color-text-muted);
+ border-bottom: 1px solid var(--color-border);
+ background-color: var(--color-surface);
+ white-space: nowrap;
+}
+
+.table td {
+ padding: 0.5rem 0.75rem;
+ color: var(--color-text);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.table tbody tr:hover {
+ background-color: var(--color-bg-hover);
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.125rem 0.5rem;
+ font-size: var(--font-xs);
+ font-weight: 500;
+ border-radius: 9999px;
+ white-space: nowrap;
+}
+
+.badgeSuccess {
+ composes: badge;
+ color: var(--color-healthy);
+ background-color: var(--color-success-bg, rgba(34, 197, 94, 0.08));
+}
+
+.badgeError {
+ composes: badge;
+ color: var(--color-critical);
+ background-color: var(--color-critical-bg, rgba(239, 68, 68, 0.06));
+}
+
+.badgeNeutral {
+ composes: badge;
+ color: var(--color-text-muted);
+ background-color: var(--color-surface);
+}
+
+.badgeInactive {
+ composes: badge;
+ color: var(--color-text-muted);
+ background-color: var(--color-bg-hover);
+}
+
+.warningsList {
+ margin: 0;
+ padding: 0 0 0 1rem;
+ font-size: var(--font-xs);
+ color: var(--color-text-muted);
+}
+
+.warningsList li {
+ margin: 0.125rem 0;
+}
+
+.errorText {
+ font-size: var(--font-xs);
+ color: var(--color-critical);
+ max-width: 20rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.errorText:hover {
+ white-space: normal;
+ overflow: visible;
+}
+
+.emptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--space-6);
+ color: var(--color-text-muted);
+ text-align: center;
+}
+
+.emptyState p {
+ margin: 0.5rem 0 0;
+}
+
+.emptyIcon {
+ opacity: 0.4;
+}
+
+.keyList {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.keyCard {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ font-size: var(--font-sm);
+}
+
+.keyCardHeader {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--space-3);
+}
+
+.keyCardInfo {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ min-width: 0;
+}
+
+.keyName {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-weight: 500;
+ color: var(--color-text);
+}
+
+.keyPrefix {
+ font-size: var(--font-xs);
+ color: var(--color-text-muted);
+}
+
+.keyMeta {
+ font-size: var(--font-xs);
+ color: var(--color-text-muted);
+ text-align: right;
+ flex-shrink: 0;
+}
+
+/* Warning badges */
+.badgeWarning {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.125rem 0.5rem;
+ font-size: var(--font-xs);
+ font-weight: 500;
+ border-radius: 9999px;
+ white-space: nowrap;
+ color: var(--color-warning, #d97706);
+ background-color: var(--color-warning-bg, rgba(234, 179, 8, 0.08));
+}
+
+.badgeMutedWarning {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.125rem 0.5rem;
+ font-size: var(--font-xs);
+ font-weight: 500;
+ border-radius: 9999px;
+ white-space: nowrap;
+ color: var(--color-text-muted);
+ background-color: var(--color-bg-hover);
+}
+
+/* Usage summary row */
+.usageSummary {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: var(--font-xs);
+ color: var(--color-text-muted);
+}
+
+.rejectedWarning {
+ color: var(--color-warning, #d97706);
+ font-weight: 500;
+}
+
+/* Rate limit row */
+.rateLimitRow {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: var(--font-xs);
+}
+
+.rateLimitText {
+ color: var(--color-text-muted);
+}
+
+.rateLimitSuffix {
+ color: var(--color-text-muted);
+ opacity: 0.7;
+}
+
+.lockIcon {
+ color: var(--color-text-muted);
+ flex-shrink: 0;
+}
+
+.editButton {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem;
+ color: var(--color-text-muted);
+ background: none;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: color var(--duration-fast), border-color var(--duration-fast);
+ flex-shrink: 0;
+}
+
+.editButton:hover {
+ color: var(--color-accent);
+ border-color: var(--color-accent);
+}
+
+.expandButton {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ margin-left: auto;
+ padding: 0.25rem 0.5rem;
+ font-size: var(--font-xs);
+ color: var(--color-text-muted);
+ background: none;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: color var(--duration-fast), border-color var(--duration-fast);
+}
+
+.expandButton:hover {
+ color: var(--color-accent);
+ border-color: var(--color-accent);
+}
+
+/* Chart container */
+.chartContainer {
+ border-top: 1px solid var(--color-border);
+ padding-top: 0.5rem;
+}
+
+/* Rate limit edit dialog */
+.rateLimitDialog {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.rateLimitDialogDesc {
+ font-size: var(--font-sm);
+ color: var(--color-text-secondary);
+ margin: 0;
+ line-height: 1.5;
+}
+
+.rateLimitDialogLabel {
+ font-size: var(--font-sm);
+ font-weight: 500;
+ color: var(--color-text);
+}
+
+.rateLimitDialogInput {
+ padding: 0.5rem 0.75rem;
+ font-size: var(--font-sm);
+ border: 1px solid var(--color-border-input);
+ border-radius: var(--radius-md);
+ background-color: var(--color-bg-input);
+ color: var(--color-text);
+}
+
+.rateLimitDialogInput:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.rateLimitDialogError {
+ font-size: var(--font-xs);
+ color: var(--color-critical);
+ margin: -0.25rem 0 0;
+}
+
+.rateLimitDialogActions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 0.25rem;
+}
+
+.rateLimitDialogRight {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.dialogSecondaryButton {
+ padding: 0.375rem 0.75rem;
+ font-size: var(--font-sm);
+ font-weight: 500;
+ color: var(--color-text);
+ background-color: var(--color-bg-card);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+}
+
+.dialogSecondaryButton:hover:not(:disabled) {
+ background-color: var(--color-bg-hover);
+}
+
+.dialogSecondaryButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.dialogPrimaryButton {
+ padding: 0.375rem 0.75rem;
+ font-size: var(--font-sm);
+ font-weight: 500;
+ color: var(--color-text-inverse);
+ background-color: var(--color-accent);
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+}
+
+.dialogPrimaryButton:hover:not(:disabled) {
+ background-color: var(--color-accent-hover);
+}
+
+.dialogPrimaryButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.monoText {
+ font-family: var(--font-mono, monospace);
+ font-size: var(--font-xs);
+}
diff --git a/client/src/components/pages/Teams/OtlpStats.test.tsx b/client/src/components/pages/Teams/OtlpStats.test.tsx
new file mode 100644
index 0000000..75939fa
--- /dev/null
+++ b/client/src/components/pages/Teams/OtlpStats.test.tsx
@@ -0,0 +1,366 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import OtlpStats from './OtlpStats';
+
+beforeAll(() => {
+ HTMLDialogElement.prototype.showModal = jest.fn();
+ HTMLDialogElement.prototype.close = jest.fn();
+});
+
+const mockFetch = jest.fn();
+global.fetch = mockFetch;
+
+function jsonResponse(data: unknown, status = 200) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: () => Promise.resolve(data),
+ };
+}
+
+const baseKey = {
+ id: 'k1',
+ name: 'Prod Key',
+ key_prefix: 'dps_abc123',
+ last_used_at: '2026-04-04T10:00:00Z',
+ created_at: '2026-03-01T10:00:00Z',
+ rate_limit_rpm: 150000,
+ rate_limit_is_custom: false,
+ rate_limit_admin_locked: false,
+ usage_1h: 500,
+ usage_24h: 8000,
+ usage_7d: 50000,
+ rejected_24h: 0,
+ rejected_7d: 0,
+};
+
+function makeStatsResponse(keyOverrides = {}) {
+ return {
+ services: [
+ {
+ id: 's1',
+ name: 'my-service',
+ is_active: 1,
+ last_push_success: 1,
+ last_push_error: null,
+ last_push_warnings: null,
+ last_push_at: '2026-04-04T09:00:00Z',
+ dependency_count: 3,
+ errors_24h: 0,
+ schema_config: null,
+ },
+ ],
+ apiKeys: [{ ...baseKey, ...keyOverrides }],
+ summary: {
+ total_otlp_services: 1,
+ active_services: 1,
+ services_with_errors: 0,
+ services_never_pushed: 0,
+ },
+ };
+}
+
+beforeEach(() => {
+ mockFetch.mockReset();
+ localStorage.clear();
+});
+
+describe('OtlpStats', () => {
+ // --- Warning badge tests (DPS-102d) ---
+
+ it('renders warning badge when rejected_24h > 0', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse({ rejected_24h: 42 })));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('Approaching limit')).toBeInTheDocument();
+ });
+ });
+
+ it('does not render warning badge when rejected_24h is 0', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse({ rejected_24h: 0, rejected_7d: 0 })));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('Prod Key')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByText('Approaching limit')).not.toBeInTheDocument();
+ });
+
+ it('renders muted 7d rejection text when rejected_7d > 0 but rejected_24h is 0', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse({ rejected_24h: 0, rejected_7d: 15 })));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('15 rejected in 7d')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByText('Approaching limit')).not.toBeInTheDocument();
+ });
+
+ it('renders rejected_24h count in usage summary', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse({ rejected_24h: 10 })));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('10 rejected in 24h')).toBeInTheDocument();
+ });
+ });
+
+ // --- Rate limit display tests ---
+
+ it('renders edit button for team lead on unlocked key', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse()));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Edit rate limit')).toBeInTheDocument();
+ });
+ });
+
+ it('does not render edit button when rate_limit_admin_locked is true', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse({ rate_limit_admin_locked: true })));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('Prod Key')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByTitle('Edit rate limit')).not.toBeInTheDocument();
+ });
+
+ it('renders lock icon when rate_limit_admin_locked is true', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse({ rate_limit_admin_locked: true })));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Locked by admin')).toBeInTheDocument();
+ });
+ });
+
+ it('does not render edit button when canManage is false', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse()));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('Prod Key')).toBeInTheDocument();
+ });
+
+ expect(screen.queryByTitle('Edit rate limit')).not.toBeInTheDocument();
+ });
+
+ it('displays (default) suffix for non-custom rate limit', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse()));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('(default)')).toBeInTheDocument();
+ });
+ });
+
+ it('displays (custom) suffix for custom rate limit', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse({
+ rate_limit_rpm: 50000,
+ rate_limit_is_custom: true,
+ })));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('(custom)')).toBeInTheDocument();
+ });
+ });
+
+ // --- Rate limit edit dialog tests (DPS-102b) ---
+
+ it('opens rate limit dialog on pencil click', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse()));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Edit rate limit')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTitle('Edit rate limit'));
+
+ expect(screen.getByText('Edit Rate Limit')).toBeInTheDocument();
+ expect(screen.getByText(/Set the rate limit for/)).toBeInTheDocument();
+ });
+
+ it('Save button is disabled when input is non-integer', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse()));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Edit rate limit')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTitle('Edit rate limit'));
+
+ const input = screen.getByPlaceholderText('150000');
+ fireEvent.change(input, { target: { value: '-5' } });
+
+ expect(screen.getByText('Save')).toBeDisabled();
+ });
+
+ it('Save button is enabled when input is empty (reset to default)', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse()));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Edit rate limit')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTitle('Edit rate limit'));
+
+ // Empty input = reset to default, should be valid
+ const input = screen.getByPlaceholderText('150000');
+ fireEvent.change(input, { target: { value: '' } });
+
+ expect(screen.getByText('Save')).not.toBeDisabled();
+ });
+
+ it('Reset to default calls updateApiKeyRateLimit with null', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(makeStatsResponse({
+ rate_limit_rpm: 50000,
+ rate_limit_is_custom: true,
+ })));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Edit rate limit')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTitle('Edit rate limit'));
+
+ // Mock the PATCH call and the stats reload
+ mockFetch.mockResolvedValueOnce(jsonResponse({ ok: true }));
+ mockFetch.mockResolvedValueOnce(jsonResponse(makeStatsResponse()));
+
+ fireEvent.click(screen.getByText('Reset to default'));
+
+ await waitFor(() => {
+ // Verify PATCH was called with null rate_limit_rpm
+ const patchCall = mockFetch.mock.calls.find(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (c: any[]) => c[1]?.method === 'PATCH'
+ );
+ expect(patchCall).toBeDefined();
+ expect(JSON.parse(patchCall![1].body)).toEqual({ rate_limit_rpm: null });
+ });
+ });
+
+ it('Reset to default is disabled when key uses default rate limit', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse({
+ rate_limit_is_custom: false,
+ })));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Edit rate limit')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTitle('Edit rate limit'));
+
+ expect(screen.getByText('Reset to default')).toBeDisabled();
+ });
+
+ it('saving a valid value calls updateApiKeyRateLimit with correct integer', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse(makeStatsResponse()));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTitle('Edit rate limit')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByTitle('Edit rate limit'));
+
+ const input = screen.getByPlaceholderText('150000');
+ fireEvent.change(input, { target: { value: '75000' } });
+
+ // Mock the PATCH call and reload
+ mockFetch.mockResolvedValueOnce(jsonResponse({ ok: true }));
+ mockFetch.mockResolvedValueOnce(jsonResponse(makeStatsResponse()));
+
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => {
+ const patchCall = mockFetch.mock.calls.find(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (c: any[]) => c[1]?.method === 'PATCH'
+ );
+ expect(patchCall).toBeDefined();
+ expect(JSON.parse(patchCall![1].body)).toEqual({ rate_limit_rpm: 75000 });
+ });
+ });
+
+ // --- Expand/collapse chart tests ---
+
+ it('chart is not present before View usage button is clicked', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse()));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('Prod Key')).toBeInTheDocument();
+ });
+
+ // The chart title should not be in the DOM
+ expect(screen.queryByText('Prod Key (dps_abc123) — Usage')).not.toBeInTheDocument();
+ });
+
+ it('View usage button mounts ApiKeyUsageChart on click', async () => {
+ // Initial stats load
+ mockFetch.mockResolvedValueOnce(jsonResponse(makeStatsResponse()));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('Prod Key')).toBeInTheDocument();
+ });
+
+ // Mock the chart's API call
+ mockFetch.mockResolvedValue(jsonResponse({
+ api_key_id: 'k1',
+ granularity: 'minute',
+ from: '2026-04-03T10:00:00Z',
+ to: '2026-04-04T10:00:00Z',
+ buckets: [],
+ }));
+
+ fireEvent.click(screen.getByTitle('View usage graph'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Prod Key (dps_abc123) — Usage')).toBeInTheDocument();
+ });
+ });
+
+ // --- Usage summary display ---
+
+ it('renders usage summary row with push counts', async () => {
+ mockFetch.mockResolvedValue(jsonResponse(makeStatsResponse()));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/500 pushes in last hour/)).toBeInTheDocument();
+ expect(screen.getByText(/8,000 in 24h/)).toBeInTheDocument();
+ expect(screen.getByText(/50,000 in 7d/)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/client/src/components/pages/Teams/OtlpStats.tsx b/client/src/components/pages/Teams/OtlpStats.tsx
new file mode 100644
index 0000000..f439b56
--- /dev/null
+++ b/client/src/components/pages/Teams/OtlpStats.tsx
@@ -0,0 +1,430 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Activity, Lock, Pencil, ChevronDown, ChevronUp } from 'lucide-react';
+import { getTeamOtlpStats, updateApiKeyRateLimit } from '../../../api/otlpStats';
+import type { OtlpStatsResponse, OtlpApiKeyStats } from '../../../types/otlpStats';
+import { ApiKeyUsageChart } from '../../Charts';
+import Modal from '../../common/Modal';
+import { formatRelativeTime } from '../../../utils/formatting';
+import teamStyles from './Teams.module.css';
+import styles from './OtlpStats.module.css';
+
+const DEFAULT_RATE_LIMIT_RPM = 150_000;
+
+interface OtlpStatsProps {
+ teamId: string;
+ canManage?: boolean;
+}
+
+function OtlpStats({ teamId, canManage = false }: OtlpStatsProps) {
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [expandedCharts, setExpandedCharts] = useState>(new Set());
+
+ // Rate limit edit dialog state
+ const [editRateLimitKey, setEditRateLimitKey] = useState(null);
+ const [rateLimitInput, setRateLimitInput] = useState('');
+ const [rateLimitError, setRateLimitError] = useState(null);
+ const [isSavingRateLimit, setIsSavingRateLimit] = useState(false);
+
+ const loadStats = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const result = await getTeamOtlpStats(teamId);
+ setData(result);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load OTLP stats');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [teamId]);
+
+ useEffect(() => {
+ loadStats();
+ }, [loadStats]);
+
+ const toggleChart = useCallback((keyId: string) => {
+ setExpandedCharts(prev => {
+ const next = new Set(prev);
+ if (next.has(keyId)) next.delete(keyId);
+ else next.add(keyId);
+ return next;
+ });
+ }, []);
+
+ const openRateLimitDialog = (key: OtlpApiKeyStats) => {
+ setEditRateLimitKey(key);
+ setRateLimitInput(key.rate_limit_is_custom ? String(key.rate_limit_rpm) : '');
+ setRateLimitError(null);
+ };
+
+ const closeRateLimitDialog = () => {
+ setEditRateLimitKey(null);
+ setRateLimitInput('');
+ setRateLimitError(null);
+ };
+
+ const validateRateLimitInput = (value: string): string | null => {
+ if (value === '') return null;
+ const num = Number(value);
+ if (!Number.isInteger(num) || num <= 0) return 'Must be a positive integer';
+ if (num > 1_500_000) return 'Cannot exceed 1,500,000 req/min';
+ return null;
+ };
+
+ const handleSaveRateLimit = async () => {
+ if (!editRateLimitKey) return;
+ const trimmed = rateLimitInput.trim();
+ const validationError = validateRateLimitInput(trimmed);
+ if (validationError) {
+ setRateLimitError(validationError);
+ return;
+ }
+ const newLimit = trimmed === '' ? null : Number(trimmed);
+ try {
+ setIsSavingRateLimit(true);
+ await updateApiKeyRateLimit(teamId, editRateLimitKey.id, newLimit);
+ closeRateLimitDialog();
+ await loadStats();
+ } catch (err) {
+ setRateLimitError(err instanceof Error ? err.message : 'Failed to update rate limit');
+ } finally {
+ setIsSavingRateLimit(false);
+ }
+ };
+
+ const handleResetToDefault = async () => {
+ if (!editRateLimitKey) return;
+ try {
+ setIsSavingRateLimit(true);
+ await updateApiKeyRateLimit(teamId, editRateLimitKey.id, null);
+ closeRateLimitDialog();
+ await loadStats();
+ } catch (err) {
+ setRateLimitError(err instanceof Error ? err.message : 'Failed to reset rate limit');
+ } finally {
+ setIsSavingRateLimit(false);
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+
Loading OTLP stats...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!data) return null;
+
+ const { services, apiKeys, summary } = data;
+
+ return (
+
+
+
+
OTLP Push Stats
+
+ Status of services receiving data via OTLP push.
+
+
+
+
+ {/* Summary Cards */}
+
+
+
{summary.total_otlp_services}
+
Total OTLP Services
+
+
+
{summary.active_services}
+
Active
+
+
0 ? styles.summaryCardError : styles.summaryCard}>
+
{summary.services_with_errors}
+
With Errors
+
+
0 ? styles.summaryCardWarning : styles.summaryCard}>
+
{summary.services_never_pushed}
+
Never Pushed
+
+
+
+ {/* Services Table */}
+ {services.length === 0 ? (
+
+
+
No OTLP services configured for this team.
+
+ ) : (
+ <>
+
Services
+
+
+
+
+ Name
+ Status
+ Last Push
+ Errors (24h)
+ Dependencies
+ Warnings
+
+
+
+ {services.map(s => (
+
+
+ {s.name}
+ {!s.is_active && (
+
+ Inactive
+
+ )}
+
+
+ {s.last_push_success === null ? (
+ Never pushed
+ ) : s.last_push_success ? (
+ OK
+ ) : (
+ Error
+ )}
+
+
+ {s.last_push_at ? formatRelativeTime(s.last_push_at) : '—'}
+
+
+ {s.errors_24h > 0 ? (
+ {s.errors_24h}
+ ) : (
+ '0'
+ )}
+
+ {s.dependency_count}
+
+ {s.last_push_warnings && s.last_push_warnings.length > 0 ? (
+
+ {s.last_push_warnings.map((w, i) => (
+ {w}
+ ))}
+
+ ) : (
+ '—'
+ )}
+
+
+ ))}
+
+
+
+ >
+ )}
+
+ {/* API Keys Section */}
+ {apiKeys.length > 0 && (
+ <>
+
API Keys
+
+ {apiKeys.map(k => (
+
+
+
+
+ {k.name}
+ {k.rejected_24h > 0 && (
+ Approaching limit
+ )}
+ {k.rejected_24h === 0 && k.rejected_7d > 0 && (
+
+ {k.rejected_7d.toLocaleString()} rejected in 7d
+
+ )}
+
+ {k.key_prefix}...
+
+
+ {k.last_used_at ? `Last used ${formatRelativeTime(k.last_used_at)}` : 'Never used'}
+
+
+
+ {/* Usage summary row */}
+
+
+ {k.usage_1h.toLocaleString()} pushes in last hour
+ {' \u00b7 '}{k.usage_24h.toLocaleString()} in 24h
+ {' \u00b7 '}{k.usage_7d.toLocaleString()} in 7d
+
+ {k.rejected_24h > 0 && (
+
+ {k.rejected_24h.toLocaleString()} rejected in 24h
+
+ )}
+
+
+ {/* Rate limit display */}
+
+
+ Rate limit: {k.rate_limit_rpm === 0
+ ? 'Unlimited'
+ : `${k.rate_limit_rpm.toLocaleString()} req/min`}
+ {' '}
+
+ {k.rate_limit_rpm === 0
+ ? '(admin)'
+ : k.rate_limit_is_custom ? '(custom)' : '(default)'}
+
+
+ {k.rate_limit_admin_locked && (
+
+
+
+ )}
+ {canManage && !k.rate_limit_admin_locked && k.rate_limit_rpm !== 0 && (
+
openRateLimitDialog(k)}
+ className={styles.editButton}
+ title="Edit rate limit"
+ >
+
+
+ )}
+
toggleChart(k.id)}
+ className={styles.expandButton}
+ title={expandedCharts.has(k.id) ? 'Hide usage graph' : 'View usage graph'}
+ >
+ {expandedCharts.has(k.id) ? (
+ <> Hide graph>
+ ) : (
+ <> View usage>
+ )}
+
+
+
+ {/* Expandable usage chart */}
+ {expandedCharts.has(k.id) && (
+
+ )}
+
+ ))}
+
+ >
+ )}
+
+ {/* Error details */}
+ {services.some(s => s.last_push_error) && (
+ <>
+
Recent Errors
+
+
+
+
+ Service
+ Error
+
+
+
+ {services
+ .filter(s => s.last_push_error)
+ .map(s => (
+
+ {s.name}
+
+ {s.last_push_error}
+
+
+ ))}
+
+
+
+ >
+ )}
+
+ {editRateLimitKey && (
+
+
+ Set the rate limit for {editRateLimitKey.name} ({editRateLimitKey.key_prefix}...).
+ Leave empty to use the system default ({DEFAULT_RATE_LIMIT_RPM.toLocaleString()} req/min).
+
+
+ Rate limit (req/min)
+
+
{
+ setRateLimitInput(e.target.value);
+ setRateLimitError(null);
+ }}
+ placeholder={String(DEFAULT_RATE_LIMIT_RPM)}
+ className={styles.rateLimitDialogInput}
+ min={1}
+ disabled={isSavingRateLimit}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleSaveRateLimit();
+ }}
+ />
+ {rateLimitError && (
+
{rateLimitError}
+ )}
+
+
+ Reset to default
+
+
+
+ Cancel
+
+
+ {isSavingRateLimit ? 'Saving...' : 'Save'}
+
+
+
+
+ )}
+
+
+ );
+}
+
+export default OtlpStats;
diff --git a/client/src/components/pages/Teams/TeamDetail.tsx b/client/src/components/pages/Teams/TeamDetail.tsx
index bf0c8d2..ca21d4d 100644
--- a/client/src/components/pages/Teams/TeamDetail.tsx
+++ b/client/src/components/pages/Teams/TeamDetail.tsx
@@ -14,6 +14,8 @@ import AlertChannels from './AlertChannels';
import AlertRules from './AlertRules';
import AlertHistory from './AlertHistory';
import AlertMutes from './AlertMutes';
+import ApiKeys from './ApiKeys';
+import OtlpStats from './OtlpStats';
import TeamOverviewStats from './TeamOverviewStats';
import ManifestList from '../Manifest/ManifestList';
import { useAlertChannels } from '../../../hooks/useAlertChannels';
@@ -149,6 +151,8 @@ function TeamDetail() {
Services ({team.services.length})
Alerts Config
+ {canManageAlerts && API Keys }
+ {canManageAlerts && OTLP }
{/* Overview Tab */}
@@ -406,6 +410,19 @@ function TeamDetail() {