Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/Containerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.13-slim
FROM docker.io/library/python:3.13-slim

WORKDIR /app

Expand Down
4 changes: 2 additions & 2 deletions dashboard/Containerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:22.12-alpine AS build
FROM docker.io/library/node:22.12-alpine AS build

WORKDIR /app

Expand All @@ -12,7 +12,7 @@ COPY . .
# Build the application
RUN npm run build

FROM nginx:1.27-alpine
FROM docker.io/library/nginx:1.27-alpine

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
Expand Down
10 changes: 7 additions & 3 deletions dashboard/src/components/IngestHealth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { api } from '../api';
import type { IngestHealth as Health } from '../types';
import { Card, EmptyState } from './ui';
import { silenceText, silenceTone } from '../helpers/ingest';
import { silenceText, silenceTone, splitDormant } from '../helpers/ingest';
import { fmtInt } from '../helpers/format';
import { t } from '../i18n';

Expand All @@ -21,11 +21,12 @@ export default function IngestHealth() {
}, []);

if (!health) return null;
const { active, dormant } = splitDormant(health.projects);
return (
<Card title={t('Recepción de telemetría')} subtitle={t('Cuándo llegó la última traza de cada proyecto')}>
{health.projects.length === 0 ? <EmptyState title={t('Aún no llega telemetría')} /> : (
{active.length === 0 && dormant.length === 0 ? <EmptyState title={t('Aún no llega telemetría')} /> : (
<ul className="divide-y divide-line text-xs">
{health.projects.map((p) => (
{active.map((p) => (
<li key={p.project_id} className="flex flex-wrap items-center gap-3 py-2">
<span aria-hidden className={`size-2 rounded-full ${DOT[silenceTone(p.silence_minutes, p.traces_1h)]}`} />
<span className="font-mono text-ink">{p.project_id}</span>
Expand All @@ -35,6 +36,9 @@ export default function IngestHealth() {
))}
</ul>
)}
{dormant.length > 0 && (
<p className="pt-2 text-xs text-faint">{t('Sin trazas desde hace más de 7 días: {names}', { names: dormant.map((p) => p.project_id).join(', ') })}</p>
)}
</Card>
);
}
9 changes: 9 additions & 0 deletions dashboard/src/helpers/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,12 @@ export function silenceTone(minutes: number | null, tracesLastHour: number): Ing
if (tracesLastHour === 0 && minutes > 60) return 'neutral';
return minutes > 60 ? 'bad' : 'warn';
}

/** A project silent for longer than this is tucked away instead of listed (an old test project). */
export const DORMANT_AFTER_MINUTES = 7 * 1440;

/** Splits projects into the ones worth listing and the dormant ones (never sent, or silent for 7+ days). */
export function splitDormant<T extends { silence_minutes: number | null }>(projects: T[]): { active: T[]; dormant: T[] } {
const dormant = projects.filter((p) => p.silence_minutes != null && p.silence_minutes > DORMANT_AFTER_MINUTES);
return { active: projects.filter((p) => !dormant.includes(p)), dormant };
}
1 change: 1 addition & 0 deletions dashboard/src/i18n-en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ export const EN: Record<string, string> = {
'Recepción de telemetría': 'Telemetry intake',
'Cuándo llegó la última traza de cada proyecto': 'When the last trace of each project arrived',
'Aún no llega telemetría': 'No telemetry yet',
'Sin trazas desde hace más de 7 días: {names}': 'No traces for over 7 days: {names}',
'hace {x}': '{x} ago',
'{a} en 5 min · {b} en 1 h · {c} en 24 h': '{a} in 5 min · {b} in 1 h · {c} in 24 h',
'Minutos sin recibir trazas': 'Minutes without traces',
Expand Down
12 changes: 11 additions & 1 deletion dashboard/tests/ingest.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { silenceText, silenceTone } from '../src/helpers/ingest.ts';
import { silenceText, silenceTone, splitDormant } from '../src/helpers/ingest.ts';

test('silenceText speaks in the right unit', () => {
assert.equal(silenceText(null), '–');
Expand All @@ -20,3 +20,13 @@ test('an already idle project is neutral, not an alarm', () => {
assert.equal(silenceTone(500, 0), 'neutral');
assert.equal(silenceTone(null, 0), 'neutral');
});

test('projects silent for over a week are set apart, never-sent ones stay listed', () => {
const { active, dormant } = splitDormant([
{ id: 'a', silence_minutes: 5 },
{ id: 'b', silence_minutes: 125808 },
{ id: 'c', silence_minutes: null },
]);
assert.deepEqual(active.map((p) => p.id), ['a', 'c']);
assert.deepEqual(dormant.map((p) => p.id), ['b']);
});
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
postgres:
image: postgres:16-alpine
image: docker.io/library/postgres:16-alpine
container_name: llm-observatory-postgres
environment:
POSTGRES_USER: postgres
Expand Down
3 changes: 3 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,6 @@ spool and a circuit breaker.
PostgreSQL with no default password, key lending limited to local requests, size limits on the
generic ingestion routes, rate-limit identity by validated key or IP, MCP attribute allow-list,
internal hand-off notes removed. See `docs/security.md`.
- 2026-10-16: decision on reply text: Manitos keeps exporting metadata only, so phrase checks stay
`unknown` (the operator chose privacy over text evaluation). The ingestion card sets projects
silent for over a week apart. Compose files verified on podman and image names fully qualified.
11 changes: 11 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ in front of it.
when it is empty. Anyone who can reach an unauthenticated backend can read, change and delete
data.

## Checked (2026-10-16)

* The podman stack was brought up for real (`podman-compose`, rootless podman 5.7): the backend
migrated PostgreSQL to head, answered `/health`, the dashboard proxied the API, only
`127.0.0.1` ports were listening and PostgreSQL had none. Without `POSTGRES_PASSWORD` it refuses
to start. This found that the Containerfiles and compose files used short image names, which
podman cannot resolve without a configured registry; they are now `docker.io/library/...`.
* `pip-audit` (backend) and `npm audit --omit=dev` (dashboard) reported no known vulnerabilities on
that date. Repeat them; this is a snapshot.
* The SDK spool (`spool_dir`) stores spans unencrypted with mode 0600; keep it on a private disk.

## Before exposing it on a network

1. Set `OBSERVATORY_API_KEY` (clients send `Authorization: Bearer <key>`).
Expand Down
2 changes: 1 addition & 1 deletion podman-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
postgres:
image: postgres:16-alpine
image: docker.io/library/postgres:16-alpine
container_name: llm-observatory-postgres
environment:
POSTGRES_USER: postgres
Expand Down
Loading