17 KiB
17 KiB
🏗️ ARCHITECTURE CIBLE & SCHÉMAS
1. Diagramme Architecture Globale
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND (Angular 20) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ UI Layer │ │ Main Thread │ │ Web Workers │ │
│ │ │ │ │ │ │ │
│ │ • Components │ │ • Services │ │ • Markdown │ │
│ │ • OnPush CD │ │ • Signals │ │ Parser │ │
│ │ • Virtual │ │ • HTTP │ │ • Search │ │
│ │ Scroll │ │ • State Mgmt │ │ Index │ │
│ │ │ │ │ │ • Graph │ │
│ └──────────────┘ └──────────────┘ │ Layout │ │
│ │ │ └──────────────┘ │
│ └──────────────────┴─────────────────┘ │
│ │ │
└────────────────────────────┼────────────────────────────────────┘
│ HTTP/JSON
▼
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND (Node.js Express) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ API Routes │ │ Services │ │ Watchers │ │
│ │ │ │ │ │ │ │
│ │ • /api/vault │ │ • VaultSvc │ │ • Chokidar │ │
│ │ • /api/search│ │ • SearchSvc │ │ • SSE Events │ │
│ │ • /api/log │ │ • LogSvc │ │ │ │
│ │ • /api/health│ │ • CacheSvc │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │
│ └──────────────────┴─────────────────┐ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Meilisearch │ │
│ │ (Search Engine)│ │
│ │ │ │
│ │ • Index vault_{} │ │
│ │ • Typo-tolerance │ │
│ │ • Facets │ │
│ │ • Highlights │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────┐
│ Filesystem / Volumes │
│ │
│ • /app/vault (Obsidian) │
│ • /app/db (logs, cache) │
│ • /app/tmp │
└──────────────────────────┘
2. Schéma d'Index Meilisearch
Index par Voûte: vault_{vaultId}
Configuration Index:
{
"primaryKey": "docId",
"searchableAttributes": [
"title",
"content",
"headings",
"tags",
"fileName",
"path"
],
"filterableAttributes": [
"tags",
"folder",
"ext",
"hasAttachment",
"mtime",
"size"
],
"sortableAttributes": [
"mtime",
"title",
"size"
],
"rankingRules": [
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness"
],
"typoTolerance": {
"enabled": true,
"minWordSizeForTypos": {
"oneTypo": 4,
"twoTypos": 8
},
"disableOnWords": [],
"disableOnAttributes": []
},
"faceting": {
"maxValuesPerFacet": 100
},
"pagination": {
"maxTotalHits": 1000
}
}
Structure Document:
interface MeilisearchDocument {
docId: string; // Format: {vaultId}:{relativePath}
vaultId: string; // Ex: "primary", "work", "personal"
title: string; // Note title
fileName: string; // Ex: "home.md"
path: string; // Ex: "folder1/subfolder/note"
folder: string; // Ex: "folder1/subfolder"
ext: string; // Ex: "md"
content: string; // Body markdown (stripped frontmatter)
contentPreview: string; // First 200 chars
headings: string[]; // ["# Heading 1", "## Heading 2"]
tags: string[]; // ["#project", "#important"]
properties: Record<string, any>; // Frontmatter key-value
hasAttachment: boolean; // Contains ![[image.png]]
linkCount: number; // Number of outgoing links
backlinksCount: number; // Number of incoming links
mtime: number; // Unix timestamp
size: number; // Bytes
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
}
3. Mapping Opérateurs Obsidian → Meilisearch
Opérateur Obsidian | Meilisearch Query/Filter | Exemple |
---|---|---|
tag:#project |
filter: "tags = '#project'" |
?filter=tags%20%3D%20%27%23project%27 |
path:folder1/ |
filter: "folder = 'folder1'" |
?filter=folder%20%3D%20%27folder1%27 |
file:home |
filter: "fileName = 'home.md'" |
?filter=fileName%20%3D%20%27home.md%27 |
-tag:#archive |
filter: "tags != '#archive'" |
?filter=tags%20!%3D%20%27%23archive%27 |
content:search term |
q=search term&attributesToSearchOn=content |
Default search |
section:"My Heading" |
q=My Heading&attributesToSearchOn=headings |
Section-specific |
task:TODO |
Custom backend filter (tasks extracted) | - |
has:attachment |
filter: "hasAttachment = true" |
?filter=hasAttachment%20%3D%20true |
Query combinée exemple:
Obsidian: tag:#project -tag:#archive path:work/ important
Meilisearch:
POST /indexes/vault_primary/search
{
"q": "important",
"filter": [
"tags = '#project'",
"tags != '#archive'",
"folder = 'work'"
],
"attributesToHighlight": ["content", "title"],
"highlightPreTag": "<mark>",
"highlightPostTag": "</mark>",
"limit": 50
}
4. Routes API Backend
/api/search
- Recherche unifiée
Endpoint: POST /api/search
Request:
interface SearchRequest {
query: string; // Raw Obsidian query
vaultId?: string; // Default: "primary"
options?: {
limit?: number; // Default: 50
offset?: number; // Pagination
attributesToRetrieve?: string[];
attributesToHighlight?: string[];
};
}
Response:
interface SearchResponse {
hits: Array<{
docId: string;
title: string;
path: string;
_formatted?: { // With highlights
title: string;
content: string;
};
_matchesPosition?: MatchRange[];
}>;
estimatedTotalHits: number;
processingTimeMs: number;
query: string;
facetDistribution?: Record<string, Record<string, number>>;
}
/api/search/suggest
- Autocomplete
Endpoint: GET /api/search/suggest?q=term&type=tag
Response:
interface SuggestResponse {
suggestions: string[];
type: 'tag' | 'file' | 'path' | 'property';
}
/api/search/facets
- Facettes disponibles
Endpoint: GET /api/search/facets
Response:
interface FacetsResponse {
tags: Array<{ name: string; count: number }>;
folders: Array<{ name: string; count: number }>;
extensions: Array<{ name: string; count: number }>;
}
/api/search/reindex
- Réindexation manuelle
Endpoint: POST /api/search/reindex
Request:
interface ReindexRequest {
vaultId: string;
full?: boolean; // true = rebuild complet, false = delta
}
Response:
interface ReindexResponse {
taskId: string; // Meilisearch task UID
status: 'enqueued' | 'processing' | 'succeeded' | 'failed';
indexedDocuments?: number;
durationMs?: number;
}
/api/log
- Logging endpoint
Endpoint: POST /api/log
Request:
interface LogBatchRequest {
records: LogRecord[];
}
interface LogRecord {
ts: string; // ISO 8601
level: 'debug' | 'info' | 'warn' | 'error';
app: string; // "ObsiViewer"
sessionId: string; // UUID
userAgent: string;
context: {
version: string;
route?: string;
theme?: 'light' | 'dark';
vault?: string;
};
event: LogEvent;
data: Record<string, unknown>;
}
Response:
interface LogResponse {
accepted: number;
rejected: number;
errors?: string[];
}
5. Plan /api/log - Événements Standardisés (12+)
Événements Frontend
Event | Trigger | Data Fields | Exemple |
---|---|---|---|
APP_START |
App bootstrap | viewport: {width, height} |
User opens app |
APP_STOP |
beforeunload | - | User closes tab |
PAGE_VIEW |
Route change | route: string, previousRoute?: string |
Navigate to /graph |
SEARCH_EXECUTED |
Search submit | query: string, queryLength: number, resultsCount?: number |
User searches "tag:#project" |
SEARCH_OPTIONS_APPLIED |
Filter toggle | options: {caseSensitive, regex, wholeWord} |
Enable case-sensitive |
GRAPH_VIEW_OPEN |
Graph tab click | - | User opens graph view |
GRAPH_INTERACTION |
Node click/hover | action: 'click'|'hover', nodeId: string |
Click node in graph |
BOOKMARKS_OPEN |
Bookmarks panel | - | Open bookmarks |
BOOKMARKS_MODIFY |
Add/edit/delete | action: 'add'|'update'|'delete', path: string |
Bookmark note |
CALENDAR_SEARCH_EXECUTED |
Calendar date select | resultsCount: number, dateRange?: string |
Select date range |
ERROR_BOUNDARY |
Uncaught error | message: string, stack?: string, componentStack?: string |
React error boundary |
PERFORMANCE_METRIC |
Web Vitals | metric: 'LCP'|'FID'|'CLS', value: number |
Lighthouse metrics |
Événements Backend (à implémenter)
Event | Trigger | Data Fields |
---|---|---|
VAULT_INDEXED |
Meilisearch indexation complète | vaultId: string, documentsCount: number, durationMs: number |
VAULT_WATCH_ERROR |
Chokidar error | vaultId: string, error: string |
SEARCH_BACKEND_EXECUTED |
Meilisearch query | query: string, hitsCount: number, processingTimeMs: number |
6. Stratégie Worker/WebGL pour Graph - Critères Anti-Gel
Problème actuel
- Graph layout calcul dans Web Worker ✅ (bon)
- Rendu Canvas dans main thread ❌ (peut bloquer)
- Pas de LOD (Level of Detail) ❌
- Pas de capping nodes/links ❌
Solution cible
A) Web Worker pour Layout (déjà implémenté)
graph-layout.worker.ts
calcule positions avec d3-force- Communication via
postMessage
✅ - Garde-fou: Timeout 30s, max iterations 1000
B) Canvas Rendering Optimisé
Throttle redraw:
private lastDrawTime = 0;
private readonly MIN_FRAME_INTERVAL = 16; // 60fps max
private scheduleRedraw(): void {
const now = performance.now();
if (now - this.lastDrawTime < this.MIN_FRAME_INTERVAL) {
return; // Skip frame
}
requestAnimationFrame(() => {
this.draw();
this.lastDrawTime = performance.now();
});
}
LOD (Level of Detail):
private draw(): void {
const zoom = this.transform().k;
// Adaptive rendering based on zoom
if (zoom < 0.5) {
// Far view: circles only, no labels, no arrows
this.drawNodesSimple(ctx, nodes);
} else if (zoom < 1.5) {
// Medium: circles + labels
this.drawNodes(ctx, nodes);
this.drawLabels(ctx, nodes);
} else {
// Close: full detail
this.drawLinks(ctx, links, settings);
this.drawNodes(ctx, nodes);
this.drawLabels(ctx, nodes);
}
}
Capping nodes/links:
private readonly MAX_VISIBLE_NODES = 500;
private readonly MAX_VISIBLE_LINKS = 1000;
private cullNodes(nodes: SimulationNode[]): SimulationNode[] {
// Show only visible nodes in viewport + margin
const visible = nodes.filter(n => this.isInViewport(n));
if (visible.length > this.MAX_VISIBLE_NODES) {
// Sort by importance (link count, selection state)
return visible
.sort((a, b) => b.importance - a.importance)
.slice(0, this.MAX_VISIBLE_NODES);
}
return visible;
}
Clustering pour grands graphes (>1000 nodes):
interface ClusterNode {
id: string;
type: 'cluster';
children: string[]; // Node IDs in cluster
x: number;
y: number;
radius: number;
}
// Use force-cluster layout for >1000 nodes
if (nodes.length > 1000) {
const clusters = this.clusterByFolder(nodes, settings.clusterThreshold);
this.renderClusters(clusters);
}
Critères d'arrêt du gel UI:
- ✅ Aucune frame >50ms (pas de janky scroll)
- ✅ Redraw budget: <16ms par frame (60fps)
- ✅ Worker layout timeout: 30s max
- ✅ Progressive rendering: 100 nodes/frame si >500 total
- ✅ User interaction priority: stopper animation si clic détecté
7. Docker Multi-Stage + Healthcheck
Dockerfile optimisé:
# syntax=docker/dockerfile:1
# ========== Stage 1: Builder ==========
FROM node:20-alpine AS builder
WORKDIR /build
# Install dependencies (leverage layer cache)
COPY package*.json ./
RUN npm ci --only=production=false
# Copy source
COPY . .
# Build Angular (production)
RUN npx ng build --configuration=production
# Prune dev dependencies
RUN npm prune --omit=dev
# ========== Stage 2: Runtime ==========
FROM node:20-alpine AS runtime
# Security: non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
# Install curl for healthcheck
RUN apk add --no-cache curl
# Copy artifacts from builder
COPY --from=builder --chown=nodejs:nodejs /build/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /build/server ./server
COPY --from=builder --chown=nodejs:nodejs /build/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /build/package*.json ./
COPY --from=builder --chown=nodejs:nodejs /build/db ./db
# Create volumes
RUN mkdir -p /app/vault /app/tmp /app/logs && \
chown -R nodejs:nodejs /app
USER nodejs
# Expose port
EXPOSE 4000
# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:4000/api/health || exit 1
# Environment defaults
ENV NODE_ENV=production \
PORT=4000 \
VAULT_PATH=/app/vault \
LOG_LEVEL=info
# Start server
CMD ["node", "./server/index.mjs"]
Variables d'env clés:
# .env.example
PORT=4000
NODE_ENV=production
VAULT_PATH=/app/vault
VAULT_ID=primary
MEILISEARCH_URL=http://meilisearch:7700
MEILISEARCH_KEY=masterKey
LOG_LEVEL=info
LOG_ENDPOINT=http://localhost:4000/api/log
ENABLE_SEARCH_CACHE=true
CACHE_TTL_SECONDS=300