Angular 20 SPA. Multi-tenant dashboard with a lazy-loaded src.palm.vision sub-app for MPOB-standard palm oil fruit bunch (FFB) ripeness detection. Three inference engines: browser ONNX WASM, NestJS remote (WebSocket), and n8n edge passthrough.
npm start # Dev server at 0.0.0.0:4200
npm run build # Production build → dist/fisapp-ui
npm run build:dev # Dev build → dist/dev
npm run build:prod # Prod build → dist/rc
npm run build:leave:prod # Leave-module variant
npm run build:quot:prod # Quotation variant
npm run build:maf:quot:prod # MAF quotation variant
npm run test # Karma + Jasmine
npm run clean # Clear Angular + npm caches
src/app/)app.config.ts — ApplicationConfig: bootstraps NGXS root store (withNgxsStoragePlugin, withNgxsReduxDevtoolsPlugin), Angular Router (hash location), HTTP client, service worker.app.routes.ts — Root routes: dashboard, auth, leave, tender, src.palm.vision (all lazy-loaded except dashboard).app.component.ts — Root component: session timeout, PWA install prompt, theme management.src/src.palm.vision/)Lazy-loaded at /src.palm.vision. Provides its own NGXS VisionState via provideStates([VisionState]) in the feature module.
Sub-routes:
| Path | Component | Purpose |
|---|---|---|
analyzer (default) |
AnalyzerComponent |
Inference UI — file/camera input, bounding box canvas |
vault |
HistoryComponent |
Batch history vault with canvas thumbnails |
chat |
ChatbotComponent |
Industrial Intelligence Portal (n8n chat proxy) |
src/src.palm.vision/services/)InferenceService — Local WASM inference:
analyze(file, batchId?) → Observable<InferenceFrame> — preprocesses image → posts tensor to Web Worker → emits resultpreprocessImage(dataUrl) — resizes to 640×640, converts RGBA→CHW, normalizes [0.0, 1.0]results$: Subject<InferenceFrame> — all completed framesqueueDepth$: BehaviorSubject<number> — pending worker framesRemoteInferenceService — WebSocket bridge to NestJS backend:
analyze(file, sourceLabel?, batchId?) → Observable<InferenceFrame> — sends PalmVision:analyze via FIS envelopegetHistory() → Observable<any[]> — History:getAlldeleteRecord(archiveId) → Observable<{ deleted: boolean }> — History:deleteclearHistory() → Observable<{ deleted: number }> — History:clearAllgetImage(archiveId) → Observable<{ archiveId, image_data }> — PalmHistory:GetImagesaveExternalResult(payload) → Observable<any> — PalmHistory:SaveExternalResultConnection config loaded from src/src.palm.vision/config/config.json:
{ "connection": { "uacp": "http://localhost:3000", "uacp_ws": "ws://localhost:3000/socket.io", "uacpEmulation": "on" } }
src/src.palm.vision/store/)VisionStateModel:
{ items: any[]; loading: boolean; expandedBatchIds: string[]; currentInference: InferenceFrame | null }
Selectors: VisionState.items, VisionState.loading, VisionState.expandedBatchIds, VisionState.currentInference
Actions (vision.actions.ts):
| Action | Payload | Effect |
|---|---|---|
SubmitBatchAnalysis |
{ files: File[]; mode: 'local'\|'remote'\|'n8n' } |
Run batch inference |
ToggleBatchGroup |
{ batchId: string } |
Expand/collapse history accordion |
LoadGroupImages |
{ batchId: string } |
Lazy-load archived images for a batch |
LoadHistory |
— | Fetch last 50 records from SQLite |
DeleteHistoryRecord |
{ id: string } |
Delete record + disk image |
ClearAllHistory |
— | Wipe all records |
AnalyzerComponent — Three-mode inference UI:
local (WASM), remote (NestJS), n8n (edge)getUserMedia, environment-facing)renderPredictionsWithBoxes(frame) — draws bounding boxes + confidence labelsRipe #4caf50, Unripe #ff9800, Underripe #ffeb3b, Overripe #9c27b0, Abnormal #f44336, Empty_Bunch #607d8bHistoryComponent — Batch vault:
items by batch_id into BatchGroup[] via combineLatest([items$, expandedBatchIds$])@ViewChildren('thumbCanvas') — canvas thumbnails rendered via renderThumbnailWithBoxes() using norm_box coordinatespaintAllVisibleCanvases() with setTimeout(0) deferral; data-archive-id attribute maps canvas elements to data itemslocal (WASM), remote (Server), n8nChatbotComponent — Thin wrapper around angularlib/chat/chat.component with title "Industrial Intelligence Portal".
src/src.palm.vision/workers/inference.worker.ts)Receives { frameId, batchId, imageDataUrl, tensor, processingStart }, runs ONNX inference, returns InferenceFrame. Keeps inference off the main thread for WASM local mode.
interface DetectionResult {
bunch_id: number;
class: string; // MPOB class name
confidence: number; // [0, 1]
is_health_alert: boolean; // true if Abnormal | Empty_Bunch
box: [x1, y1, x2, y2]; // pixel coords
norm_box?: [nx1, ny1, nx2, ny2]; // normalized [0,1] coords
}
interface InferenceFrame {
frameId: string;
batchId?: string;
imageDataUrl: string;
detections: DetectionResult[];
inference_ms: number;
processing_ms: number;
total_count: number;
industrial_summary: Record<string, number>;
source: 'wasm-local' | 'remote' | 'n8n';
}
src/dependencies/)| Alias | Path | Contents |
|---|---|---|
angularlib/* |
src/dependencies/angularlib/ |
UI components, auth, forms, chat |
dp-ui/* |
src/dependencies/dp-ui/ |
Custom Material extensions, NgxSocketService |
fis/* |
src/dependencies/fis/ |
Domain modules (leave, tender, approval) |
fis-commons/* |
src/dependencies/fis-commons/ |
CDN-hosted shared utilities |
src/assets/models/onnx/best.onnx — YOLOv8 ONNX model for browser WASM inferencesrc/assets/models/tflite/best_float16.tflite / best_float32.tflite — TFLite variantssrc/assets/wasm/ — ONNX Runtime JS WASM bundlessrc/assets/tflite-wasm/ — TensorFlow Lite WASM runtimeangular.json defines four production configurations that swap assets and menu via fileReplacements:
| Config | Output | Swaps |
|---|---|---|
production |
dist/rc |
— |
leave-prod |
dist/leave |
menu.ts → menu.leave.ts, leave assets |
quotation-prod |
dist/quotation |
menu.ts → menu.quotation.ts, quotation assets |
maf-quot-prod |
dist/maf/quotation |
MAF quotation assets |
Development build uses a mock socket service (dp.service.t.ts) for offline development.
Service worker registered immediately (registerImmediately) via ngsw-config.json. PWA install prompt handled in AppComponent. Three language packs: en_US, ms_MY, zh_Hans.
Frontend connects to NestJS backend at http://localhost:3000 (configurable via config.json). Backend CORS whitelist is hardcoded in server-desktop/src/main.ts — add new device IPs there.