chore(observability): scrape mana-mcp at :3069

Pairs with c94ab01c6 which added the real /metrics endpoint. Without a
scrape job the policy_decisions_total counter has nowhere to go and
the soak period is flying blind.

30s interval to match mana-ai. Same job shape as mana-ai — any Grafana
dashboard that auto-discovers services via labels will pick this up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 14:24:13 +02:00
parent c94ab01c69
commit d087b4744a
5 changed files with 237 additions and 14 deletions

View file

@ -0,0 +1,129 @@
<!--
Reference-picker for the Picture generator. Lists every me-image the
user has explicitly flagged with `usage.aiReference=true` (plan M2)
and lets the caller pick up to 4 to feed into /generate-with-reference.
The component holds no identity of its own — parents own the
selectedIds via `bind:` so the generator page can switch its fetch
endpoint + persist the ids on the resulting LocalImage.
-->
<script lang="ts">
import { Check, UserCircle } from '@mana/shared-icons';
import { useReferenceImages } from '$lib/modules/profile/queries';
import type { MeImage } from '$lib/modules/profile/types';
interface Props {
selectedIds: string[];
maxSelection?: number;
}
let { selectedIds = $bindable([]), maxSelection = 4 }: Props = $props();
const referenceImages$ = useReferenceImages();
const referenceImages = $derived(referenceImages$.value ?? []);
const loading = $derived(referenceImages$.loading);
const KIND_LABELS: Record<string, string> = {
face: 'Gesicht',
fullbody: 'Ganzkörper',
halfbody: 'Halbkörper',
hands: 'Hände',
reference: 'Referenz',
};
function isSelected(id: string): boolean {
return selectedIds.includes(id);
}
function toggle(img: MeImage) {
if (isSelected(img.id)) {
selectedIds = selectedIds.filter((id) => id !== img.id);
return;
}
// At the cap: silently ignore rather than shuffling the earliest
// pick out — easier to reason about than a "rolling window" and
// matches the visual "disabled" hint we show below.
if (selectedIds.length >= maxSelection) return;
selectedIds = [...selectedIds, img.id];
}
function clear() {
selectedIds = [];
}
</script>
{#if loading && referenceImages.length === 0}
<p class="text-xs text-muted-foreground">Lade Referenz-Bilder…</p>
{:else if referenceImages.length === 0}
<div
class="flex items-start gap-3 rounded-md border border-dashed border-border bg-background/50 p-3 text-xs text-muted-foreground"
>
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0" />
<div class="space-y-1">
<p class="text-foreground">Noch keine Referenzbilder freigegeben.</p>
<p>
Lade ein Gesichts- oder Ganzkörperbild hoch und aktiviere "KI darf nutzen" unter
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
Meine Bilder
</a>.
</p>
</div>
</div>
{:else}
<div class="space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">
{selectedIds.length} von {maxSelection} ausgewählt
</span>
{#if selectedIds.length > 0}
<button
type="button"
onclick={clear}
class="font-medium text-muted-foreground hover:text-foreground"
>
Zurücksetzen
</button>
{/if}
</div>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
{#each referenceImages as img (img.id)}
{@const selected = isSelected(img.id)}
{@const disabled = !selected && selectedIds.length >= maxSelection}
<button
type="button"
onclick={() => toggle(img)}
{disabled}
aria-pressed={selected}
aria-label={selected ? 'Auswahl aufheben' : 'Auswählen'}
class="group relative aspect-square overflow-hidden rounded-md border bg-muted transition-all {selected
? 'border-primary ring-2 ring-primary'
: 'border-border hover:border-primary/50'} {disabled
? 'cursor-not-allowed opacity-40'
: 'cursor-pointer'}"
>
{#if img.thumbnailUrl || img.publicUrl}
<img
src={img.thumbnailUrl ?? img.publicUrl}
alt={img.label ?? KIND_LABELS[img.kind]}
class="h-full w-full object-cover"
loading="lazy"
/>
{/if}
<span
class="absolute left-1 top-1 rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground backdrop-blur-sm"
>
{KIND_LABELS[img.kind] ?? img.kind}
</span>
{#if selected}
<span
class="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm"
>
<Check size={12} weight="bold" />
</span>
{/if}
</button>
{/each}
</div>
</div>
{/if}

View file

@ -45,6 +45,8 @@ export function toImage(local: LocalImage): Image {
isArchived: local.isArchived ?? undefined,
generationId: local.generationId ?? undefined,
sourceImageId: local.sourceImageId ?? undefined,
referenceImageIds: local.referenceImageIds ?? undefined,
generationMode: local.generationMode ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};

View file

@ -4,6 +4,14 @@
import type { BaseRecord } from '@mana/local-store';
/**
* How the image was created. 'text' is the classic prompt-only
* generation via /picture/generate; 'reference' is a multi-image edit
* via /picture/generate-with-reference (plan M3) the latter carries
* `referenceImageIds` pointing at the meImages that fed the edit.
*/
export type ImageGenerationMode = 'text' | 'reference';
export interface LocalImage extends BaseRecord {
prompt: string;
negativePrompt?: string | null;
@ -24,6 +32,9 @@ export interface LocalImage extends BaseRecord {
isArchived?: boolean;
generationId?: string | null;
sourceImageId?: string | null;
/** mana-media ids of the me-images that fed a reference-edit. */
referenceImageIds?: string[] | null;
generationMode?: ImageGenerationMode | null;
}
export interface LocalBoard extends BaseRecord {
@ -83,6 +94,8 @@ export interface Image {
isArchived?: boolean;
generationId?: string;
sourceImageId?: string;
referenceImageIds?: string[];
generationMode?: ImageGenerationMode;
createdAt: string;
updatedAt: string;
}

View file

@ -5,6 +5,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { imagesStore } from '$lib/modules/picture/stores/images.svelte';
import type { LocalImage } from '$lib/modules/picture/types';
import ReferenceImagePicker from '$lib/modules/picture/components/ReferenceImagePicker.svelte';
import { ModuleShell } from '$lib/components/shell';
type ProviderOption = {
@ -42,6 +43,7 @@
let quality = $state<'low' | 'medium' | 'high'>('medium');
let aspectId = $state<string>(ASPECT_RATIOS[0].id);
let batchCount = $state<1 | 2 | 4>(1);
let selectedReferenceIds = $state<string[]>([]);
let isGenerating = $state(false);
let generationError = $state('');
let lastImageUrls = $state<string[]>([]);
@ -55,6 +57,24 @@
currentProvider.supportsQuality ? creditsPerImage * effectiveBatch : 10
);
// Reference edits only work through OpenAI — the plan (M3) rejects
// non-OpenAI models server-side. Flip the model automatically the
// first time the user adds a reference so they don't get a 400 at
// submit time; flipping back is their choice.
const isReferenceMode = $derived(selectedReferenceIds.length > 0);
$effect(() => {
if (isReferenceMode && !modelId.startsWith('openai/')) {
modelId = 'openai/gpt-image-2';
}
});
/** Map the current aspect-ratio tuple to OpenAI's size literal. */
function resolveSize(width: number, height: number): '1024x1024' | '1536x1024' | '1024x1536' {
if (width > height) return '1536x1024';
if (height > width) return '1024x1536';
return '1024x1024';
}
async function handleGenerate() {
if (!prompt.trim()) return;
@ -66,21 +86,39 @@
const token = await authStore.getValidToken();
if (!token) throw new Error('Nicht angemeldet');
const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate`, {
// Two server paths: /generate is text-to-image, the M3 edit
// endpoint takes reference mediaIds + forwards them multipart
// to OpenAI /v1/images/edits. Which one we hit is purely
// driven by whether the user added references.
const endpoint = isReferenceMode
? '/api/v1/picture/generate-with-reference'
: '/api/v1/picture/generate';
const body = isReferenceMode
? {
prompt: prompt.trim(),
referenceMediaIds: selectedReferenceIds,
model: modelId,
quality: currentProvider.supportsQuality ? quality : undefined,
size: resolveSize(currentAspect.width, currentAspect.height),
n: effectiveBatch,
}
: {
prompt: prompt.trim(),
negativePrompt: negativePrompt.trim() || undefined,
model: modelId,
quality: currentProvider.supportsQuality ? quality : undefined,
width: currentAspect.width,
height: currentAspect.height,
n: effectiveBatch,
};
const res = await fetch(`${getManaApiUrl()}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
prompt: prompt.trim(),
negativePrompt: negativePrompt.trim() || undefined,
model: modelId,
quality: currentProvider.supportsQuality ? quality : undefined,
width: currentAspect.width,
height: currentAspect.height,
n: effectiveBatch,
}),
body: JSON.stringify(body),
});
if (!res.ok) {
@ -111,7 +149,7 @@
const local: LocalImage = {
id: crypto.randomUUID(),
prompt: data.prompt,
negativePrompt: negativePrompt.trim() || null,
negativePrompt: isReferenceMode ? null : negativePrompt.trim() || null,
model: data.model,
publicUrl: img.imageUrl,
storagePath: img.mediaId ?? img.imageUrl,
@ -122,6 +160,8 @@
isPublic: false,
isFavorite: false,
downloadCount: 0,
generationMode: isReferenceMode ? 'reference' : 'text',
referenceImageIds: isReferenceMode ? [...selectedReferenceIds] : null,
createdAt: now,
updatedAt: now,
};
@ -197,17 +237,43 @@
<div>
<label for="negative-prompt" class="mb-1.5 block text-sm font-medium text-foreground">
Negativ-Prompt <span class="text-muted-foreground">(optional)</span>
{#if isReferenceMode}
<span class="ml-1 text-xs text-muted-foreground">
· wird im Referenz-Modus ignoriert
</span>
{/if}
</label>
<input
id="negative-prompt"
type="text"
bind:value={negativePrompt}
disabled={isReferenceMode}
placeholder="unscharf, verzerrt, niedrige Qualität…"
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</section>
<!-- Reference images section -->
<section class="space-y-3 rounded-lg border border-border bg-background/50 p-3">
<div class="flex items-baseline justify-between gap-2">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Referenz-Bilder
<span class="ml-1 normal-case text-muted-foreground">(optional)</span>
</h2>
{#if isReferenceMode}
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
Referenz-Modus
</span>
{/if}
</div>
<p class="text-xs text-muted-foreground">
Wähle bis zu 4 deiner freigegebenen Bilder — das erzeugte Bild wird dich enthalten
(Outfit-Anprobe, Brillen, Frisuren). Läuft über OpenAI gpt-image-2.
</p>
<ReferenceImagePicker bind:selectedIds={selectedReferenceIds} />
</section>
<!-- Settings section -->
<section class="space-y-3 rounded-lg border border-border bg-background/50 p-3">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
@ -222,10 +288,12 @@
<select
id="model"
bind:value={modelId}
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-1 focus:ring-primary"
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{#each PROVIDERS as p}
<option value={p.id}>{p.label}</option>
<option value={p.id} disabled={isReferenceMode && !p.id.startsWith('openai/')}>
{p.label}{isReferenceMode && !p.id.startsWith('openai/') ? ' · keine Referenz' : ''}
</option>
{/each}
</select>
<p class="mt-1 text-xs text-muted-foreground">{currentProvider.description}</p>

View file

@ -132,6 +132,17 @@ scrape_configs:
metrics_path: '/metrics'
scrape_interval: 30s
# Mana MCP Gateway (Bun, :3069) — exposes the shared tool-registry
# over Streamable HTTP to external agents. Emits policy-gate
# decisions (`mana_mcp_policy_decisions_total{decision,reason,mode}`)
# and per-tool invocation metrics. Critical during the POLICY_MODE
# log-only soak period to decide when it's safe to flip to enforce.
- job_name: 'mana-mcp'
static_configs:
- targets: ['mana-mcp:3069']
metrics_path: '/metrics'
scrape_interval: 30s
# ============================================
# GPU Server (Windows PC, LAN: 192.168.178.11)
# ============================================