feat: enhance embed block resizing, improve bookmark viewMode reactivity, and redesign tests panel UI - Fixed bookmark block viewMode not updating on block changes by converting to setter/getter pattern with cached property - Enhanced embed block with persistent resize handles using opacity transitions instead of DOM changes to prevent iframe flashing - Improved YouTube URL handling to support watch, youtu.be, shorts, and embed formats with normalized embed URLs - Added iframe permissions (
1196 lines
40 KiB
TypeScript
1196 lines
40 KiB
TypeScript
import { Component, inject, signal } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { HttpClient } from '@angular/common/http';
|
|
|
|
interface TestResult {
|
|
endpoint: string;
|
|
method: string;
|
|
status: 'pending' | 'running' | 'success' | 'error';
|
|
duration?: number;
|
|
response?: any;
|
|
error?: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-tests-panel',
|
|
standalone: true,
|
|
imports: [CommonModule, FormsModule],
|
|
template: `
|
|
<div class="tests-panel" [class.tests-panel--fullscreen]="isFullscreen()">
|
|
<div class="panel-header">
|
|
<h2>🧪 API Tests Panel</h2>
|
|
<div class="header-actions">
|
|
<button type="button" class="test-btn test-btn--neutral" (click)="toggleFullscreen()">
|
|
{{ isFullscreen() ? 'Quitter plein écran' : 'Plein écran' }}
|
|
</button>
|
|
</div>
|
|
<p class="panel-description">
|
|
Test all ObsiViewer APIs and validate functionality. Each test shows execution time and response details.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="panel-layout">
|
|
<div class="panel-content">
|
|
<!-- Test Sections -->
|
|
<div class="test-sections">
|
|
<div class="test-section">
|
|
<h3>⚡ Quick checks</h3>
|
|
<div class="quick-actions">
|
|
<button type="button" class="test-btn test-btn--primary" (click)="runHealthCheck()" [disabled]="isRunning()">
|
|
/api/health
|
|
</button>
|
|
<button type="button" class="test-btn test-btn--secondary" (click)="runVaultMetadata()" [disabled]="isRunning()">
|
|
/api/vault/metadata
|
|
</button>
|
|
<button type="button" class="test-btn test-btn--secondary" (click)="runVaultPaginated()" [disabled]="isRunning()">
|
|
/api/vault/metadata/paginated
|
|
</button>
|
|
<button type="button" class="test-btn test-btn--info" (click)="runLogTest()" [disabled]="isRunning()">
|
|
Logging
|
|
</button>
|
|
<button type="button" class="test-btn test-btn--accent" (click)="runSimulateEvent()" [disabled]="isRunning()">
|
|
Simulate event
|
|
</button>
|
|
<button type="button" class="test-btn test-btn--warning quick-actions__full" (click)="runBatchTest()" [disabled]="isRunning()">
|
|
Full smoke suite
|
|
</button>
|
|
</div>
|
|
<p class="quick-actions__hint">
|
|
Results and timings appear on the right. Use filters there to focus on failed calls.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="test-section">
|
|
<h3>🔭 API Explorer</h3>
|
|
<div class="file-test-form" style="margin-bottom: 12px;">
|
|
<div class="form-row">
|
|
<label for="apiSearch">Search endpoint</label>
|
|
<input id="apiSearch" type="text" class="form-input" [(ngModel)]="builderSearchTerm" placeholder="search by name or path..." />
|
|
</div>
|
|
<div class="form-row">
|
|
<label for="apiSelect">Endpoint</label>
|
|
<select id="apiSelect" class="form-input" [ngModel]="selectedEndpointKey()" (ngModelChange)="onSelectEndpoint($event)">
|
|
<option *ngFor="let ep of getFilteredEndpoints()" [value]="ep.key">{{ ep.group }} • {{ ep.label }} ({{ ep.method }})</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-row" *ngIf="currentEndpoint">
|
|
<div class="text-xs text-muted">Path: <code>{{ currentEndpoint.path }}</code></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="file-test-form" *ngIf="currentEndpoint">
|
|
<div class="form-row" *ngIf="currentEndpoint.pathParams?.length">
|
|
<label>Path params</label>
|
|
<div class="test-group">
|
|
<ng-container *ngFor="let p of currentEndpoint.pathParams">
|
|
<div>
|
|
<input class="form-input" [placeholder]="p" [ngModel]="paramValues()[p] || ''" (ngModelChange)="updateParamValue(p, $event)" />
|
|
</div>
|
|
</ng-container>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-row" *ngIf="currentEndpoint.query?.length">
|
|
<label>Query params</label>
|
|
<div class="test-group">
|
|
<ng-container *ngFor="let q of currentEndpoint.query">
|
|
<div>
|
|
<input class="form-input" [placeholder]="q.name + (q.required ? ' *' : '')" [ngModel]="paramValues()[q.name] || ''" (ngModelChange)="updateParamValue(q.name, $event)" />
|
|
</div>
|
|
</ng-container>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-row" *ngIf="currentEndpoint.headersSupported">
|
|
<label>Headers (JSON)</label>
|
|
<textarea class="form-textarea" rows="3" [(ngModel)]="headersText"></textarea>
|
|
</div>
|
|
|
|
<div class="form-row" *ngIf="currentEndpoint.key === 'files.putBlob'">
|
|
<label>Upload file (PNG/SVG)</label>
|
|
<input type="file" (change)="onFileChosen($event)" />
|
|
<div class="text-xs text-muted" *ngIf="selectedFileName">Selected: {{ selectedFileName }}</div>
|
|
</div>
|
|
|
|
<div class="form-row" *ngIf="showBodyEditor()">
|
|
<label>Body {{ currentEndpoint.contentType ? '(' + currentEndpoint.contentType + ')' : '' }}</label>
|
|
<textarea class="form-textarea" rows="6" [(ngModel)]="bodyText"></textarea>
|
|
</div>
|
|
|
|
<div class="test-group">
|
|
<button type="button" class="test-btn test-btn--primary" (click)="runDynamicRequest()" [disabled]="isRunning()">Run Request</button>
|
|
<div class="text-xs text-muted" style="align-self:center;">
|
|
<span>Preview:</span>
|
|
<code>{{ buildPreviewUrl() }}</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Results Panel -->
|
|
<div class="results-panel">
|
|
<div class="results-header">
|
|
<div>
|
|
<h3>Test Results</h3>
|
|
<p class="results-subtitle" *ngIf="results().length === 0">No tests yet. Use a quick check or the explorer to start.</p>
|
|
<p class="results-subtitle" *ngIf="results().length > 0">Newest first • {{ results().length }} result{{ results().length > 1 ? 's' : '' }}</p>
|
|
</div>
|
|
<div class="results-tools">
|
|
<div class="results-filters">
|
|
<button type="button" class="filter-chip" [class.filter-chip--active]="isStatusFilterActive('all')" (click)="setStatusFilter('all')">All</button>
|
|
<button type="button" class="filter-chip filter-chip--success" [class.filter-chip--active]="isStatusFilterActive('success')" (click)="setStatusFilter('success')">Success</button>
|
|
<button type="button" class="filter-chip filter-chip--error" [class.filter-chip--active]="isStatusFilterActive('error')" (click)="setStatusFilter('error')">Errors</button>
|
|
<button type="button" class="filter-chip filter-chip--running" [class.filter-chip--active]="isStatusFilterActive('running')" (click)="setStatusFilter('running')">Running</button>
|
|
</div>
|
|
<button type="button" class="clear-btn" (click)="clearResults()" [disabled]="results().length === 0">
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="results-list" *ngIf="getFilteredResults().length > 0; else noResults">
|
|
<div *ngFor="let result of getFilteredResults()" class="result-item" [ngClass]="getResultClass(result)">
|
|
<div class="result-header">
|
|
<span class="result-method">{{ result.method }}</span>
|
|
<span class="result-endpoint">{{ result.endpoint }}</span>
|
|
<span class="result-status">{{ result.status.toUpperCase() }}</span>
|
|
<span class="result-duration" *ngIf="result.duration">({{ result.duration }}ms)</span>
|
|
</div>
|
|
<div class="result-meta">
|
|
<span class="result-timestamp">{{ result.timestamp | date:'mediumTime' }}</span>
|
|
<button type="button" class="result-toggle" (click)="toggleResultDetails(result)">
|
|
{{ isResultExpanded(result) ? 'Hide payload' : 'View payload' }}
|
|
</button>
|
|
</div>
|
|
<div class="result-details" *ngIf="isResultExpanded(result) && (result.response || result.error)">
|
|
<pre class="result-json">{{ result.response ? (result.response | json) : result.error }}</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ng-template #noResults>
|
|
<div class="no-results">
|
|
<p>No tests run yet.</p>
|
|
<p>Start with a quick health check or run the full smoke suite.</p>
|
|
</div>
|
|
</ng-template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
styles: [`
|
|
.tests-panel {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
font-family: var(--font-mono), 'Monaco', 'Menlo', monospace;
|
|
font-size: 14px;
|
|
color: var(--text-main);
|
|
background: var(--bg);
|
|
}
|
|
|
|
.panel-header {
|
|
margin-bottom: 30px;
|
|
text-align: center;
|
|
}
|
|
|
|
.panel-header .header-actions {
|
|
position: absolute;
|
|
right: 20px;
|
|
top: 20px;
|
|
}
|
|
|
|
.panel-header h2 {
|
|
margin: 0 0 10px 0;
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.panel-description {
|
|
margin: 0;
|
|
color: var(--text-muted);
|
|
font-size: 16px;
|
|
max-width: 600px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.panel-layout {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.test-sections {
|
|
display: grid;
|
|
gap: 20px;
|
|
}
|
|
|
|
.test-section {
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
background: var(--card);
|
|
}
|
|
|
|
.test-section h3 {
|
|
margin: 0 0 15px 0;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.quick-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
}
|
|
|
|
.quick-actions__full {
|
|
flex: 1 1 100%;
|
|
}
|
|
|
|
.quick-actions__hint {
|
|
margin-top: 8px;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.test-group {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
}
|
|
|
|
.test-btn {
|
|
padding: 8px 16px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
transition: all 0.2s;
|
|
min-width: 120px;
|
|
}
|
|
|
|
.test-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.test-btn--primary {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.test-btn--primary:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--primary) 85%, black 15%);
|
|
}
|
|
|
|
.test-btn--secondary {
|
|
background: var(--muted);
|
|
color: white;
|
|
}
|
|
|
|
.test-btn--secondary:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--muted) 85%, black 15%);
|
|
}
|
|
|
|
.test-btn--info {
|
|
background: var(--info);
|
|
color: white;
|
|
}
|
|
|
|
.test-btn--info:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--info) 85%, black 15%);
|
|
}
|
|
|
|
.test-btn--warning {
|
|
background: var(--warning);
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.test-btn--warning:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--warning) 85%, black 15%);
|
|
}
|
|
|
|
.test-btn--danger {
|
|
background: var(--danger);
|
|
color: white;
|
|
}
|
|
|
|
.test-btn--danger:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--danger) 85%, black 15%);
|
|
}
|
|
|
|
.test-btn--accent {
|
|
background: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.test-btn--accent:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--accent) 85%, black 15%);
|
|
}
|
|
|
|
.test-btn--neutral {
|
|
background: var(--secondary);
|
|
color: white;
|
|
}
|
|
|
|
.test-btn--neutral:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--secondary) 85%, black 15%);
|
|
}
|
|
|
|
.file-test-form {
|
|
display: grid;
|
|
gap: 15px;
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
gap: 5px;
|
|
}
|
|
|
|
.form-row label {
|
|
font-weight: 600;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.form-input,
|
|
.form-textarea {
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
font-family: inherit;
|
|
font-size: 14px;
|
|
background: var(--card);
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.form-input:focus,
|
|
.form-textarea:focus {
|
|
outline: none;
|
|
border-color: var(--ring);
|
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 25%, transparent);
|
|
}
|
|
|
|
.results-panel {
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--card);
|
|
display: grid;
|
|
grid-template-rows: auto 1fr;
|
|
position: static;
|
|
z-index: 0;
|
|
}
|
|
|
|
.results-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 20px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.results-header h3 {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.results-subtitle {
|
|
margin: 4px 0 0;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.results-tools {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
|
|
.results-filters {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
|
|
.filter-chip {
|
|
padding: 2px 10px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface-1);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.filter-chip--success {
|
|
border-color: color-mix(in srgb, var(--success) 70%, var(--border));
|
|
color: var(--success);
|
|
}
|
|
|
|
.filter-chip--error {
|
|
border-color: color-mix(in srgb, var(--danger) 70%, var(--border));
|
|
color: var(--danger);
|
|
}
|
|
|
|
.filter-chip--running {
|
|
border-color: color-mix(in srgb, var(--warning) 70%, var(--border));
|
|
color: var(--warning);
|
|
}
|
|
|
|
.filter-chip--active {
|
|
background: color-mix(in srgb, var(--primary) 25%, transparent);
|
|
color: var(--text-main);
|
|
border-color: color-mix(in srgb, var(--primary) 80%, var(--border));
|
|
}
|
|
|
|
.clear-btn {
|
|
padding: 6px 12px;
|
|
background: var(--danger);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.clear-btn:hover {
|
|
background: color-mix(in srgb, var(--danger) 85%, black 15%);
|
|
}
|
|
|
|
.clear-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.results-list {
|
|
overflow-y: auto;
|
|
max-height: 500px;
|
|
}
|
|
|
|
.result-item {
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 15px 20px;
|
|
}
|
|
|
|
.result-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.result-item--success {
|
|
background: color-mix(in srgb, var(--success) 15%, transparent);
|
|
border-left: 4px solid var(--success);
|
|
}
|
|
|
|
.result-item--error {
|
|
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
|
border-left: 4px solid var(--danger);
|
|
}
|
|
|
|
.result-item--running {
|
|
background: color-mix(in srgb, var(--warning) 15%, transparent);
|
|
border-left: 4px solid var(--warning);
|
|
}
|
|
|
|
.result-item--pending {
|
|
background: var(--surface-1);
|
|
border-left: 4px solid var(--muted);
|
|
}
|
|
|
|
.result-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.result-method {
|
|
font-weight: bold;
|
|
color: var(--primary);
|
|
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.result-endpoint {
|
|
font-family: monospace;
|
|
font-size: 14px;
|
|
color: var(--text-main);
|
|
flex: 1;
|
|
}
|
|
|
|
.result-status {
|
|
font-weight: bold;
|
|
padding: 2px 8px;
|
|
border-radius: 12px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.result-item--success .result-status {
|
|
background: color-mix(in srgb, var(--success) 20%, transparent);
|
|
color: var(--success);
|
|
}
|
|
|
|
.result-item--error .result-status {
|
|
background: color-mix(in srgb, var(--danger) 20%, transparent);
|
|
color: var(--danger);
|
|
}
|
|
|
|
.result-item--running .result-status {
|
|
background: color-mix(in srgb, var(--warning) 20%, transparent);
|
|
color: var(--warning);
|
|
}
|
|
|
|
.result-item--pending .result-status {
|
|
background: var(--surface-1);
|
|
color: var(--muted);
|
|
}
|
|
|
|
.result-duration {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.result-meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.result-timestamp {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.result-toggle {
|
|
padding: 2px 10px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--border);
|
|
background: transparent;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.result-details {
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.result-json {
|
|
background: var(--surface-1);
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
margin: 0;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.no-results {
|
|
text-align: center;
|
|
padding: 40px 20px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.no-results p {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.sse-events {
|
|
margin-top: 15px;
|
|
padding: 15px;
|
|
background: var(--surface-1);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.sse-events h4 {
|
|
margin: 0 0 10px 0;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.events-list {
|
|
max-height: 150px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.event-item {
|
|
display: flex;
|
|
gap: 10px;
|
|
padding: 5px 0;
|
|
font-size: 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.event-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.event-type {
|
|
font-weight: bold;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.event-data {
|
|
flex: 1;
|
|
font-family: monospace;
|
|
color: var(--text-main);
|
|
}
|
|
|
|
.event-time {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
@media (max-width: 1024px) {
|
|
.results-list {
|
|
max-height: 400px;
|
|
}
|
|
}
|
|
|
|
.tests-panel--fullscreen {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 50;
|
|
background: var(--bg);
|
|
width: 100vw;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
padding: 12px 12px 12px 12px;
|
|
}
|
|
|
|
.tests-panel--fullscreen .panel-layout {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
|
gap: 24px;
|
|
align-items: start;
|
|
height: calc(100vh - 100px);
|
|
}
|
|
|
|
.tests-panel--fullscreen .test-sections {
|
|
overflow: auto;
|
|
}
|
|
|
|
.tests-panel--fullscreen .results-panel {
|
|
align-self: stretch;
|
|
}
|
|
`]
|
|
})
|
|
export class TestsPanelComponent {
|
|
private http = inject(HttpClient);
|
|
|
|
// Test form data
|
|
testFilePath = signal('');
|
|
testFileContent = signal('');
|
|
testNoteId = signal('');
|
|
|
|
// Test state
|
|
results = signal<TestResult[]>([]);
|
|
sseEvents = signal<Array<{type: string, data: any, timestamp: Date}>>([]);
|
|
isRunning = signal(false);
|
|
|
|
// UI state for results panel
|
|
private statusFilter = signal<'all' | 'success' | 'error' | 'running'>('all');
|
|
private expandedResults = signal<Set<TestResult>>(new Set<TestResult>());
|
|
|
|
// SSE connection
|
|
private sseConnection: EventSource | null = null;
|
|
isFullscreen = signal(false);
|
|
|
|
// ---------- API Explorer (dynamic) ----------
|
|
builderSearchTerm = '';
|
|
selectedEndpointKey = signal<string>('health.get');
|
|
paramValues = signal<Record<string, string>>({});
|
|
bodyText = signal<string>('{}');
|
|
headersText = signal<string>('{}');
|
|
selectedFile: File | null = null;
|
|
selectedFileName = '';
|
|
|
|
endpoints: Array<{
|
|
key: string;
|
|
group: string;
|
|
label: string;
|
|
method: 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'SSE';
|
|
path: string; // may include {param}
|
|
pathParams?: string[];
|
|
query?: Array<{ name: string; required?: boolean }>;
|
|
contentType?: 'json'|'text'|'binary';
|
|
responseType?: 'json'|'text';
|
|
headersSupported?: boolean;
|
|
sampleBody?: any;
|
|
}> = [
|
|
{ key: 'health.get', group: 'System', label: 'Health', method: 'GET', path: '/api/health' },
|
|
{ key: 'perf.get', group: 'System', label: 'Performance', method: 'GET', path: '/__perf' },
|
|
{ key: 'settings.get', group: 'System', label: 'Get Settings', method: 'GET', path: '/api/settings' },
|
|
{ key: 'settings.put', group: 'System', label: 'Update Settings', method: 'PUT', path: '/api/settings', contentType: 'json', sampleBody: { enableBackups: true } },
|
|
{ key: 'events.sse', group: 'Events', label: 'Vault Events (SSE)', method: 'SSE', path: '/api/vault/events' },
|
|
|
|
{ key: 'vault.notesRaw', group: 'Vault', label: 'Load Vault Notes (raw)', method: 'GET', path: '/api/vault' },
|
|
{ key: 'vault.metadata', group: 'Vault', label: 'Metadata', method: 'GET', path: '/api/vault/metadata' },
|
|
{ key: 'vault.metadataPaginated', group: 'Vault', label: 'Metadata (paginated)', method: 'GET', path: '/api/vault/metadata/paginated', query: [
|
|
{ name: 'limit' }, { name: 'cursor' }, { name: 'search' }, { name: 'folder' }
|
|
]},
|
|
|
|
{ key: 'files.list', group: 'Files', label: 'List from Meili', method: 'GET', path: '/api/files/list' },
|
|
{ key: 'files.metadata', group: 'Files', label: 'Metadata (mixed)', method: 'GET', path: '/api/files/metadata', query: [ { name: 'source' } ] },
|
|
{ key: 'files.byDate', group: 'Files', label: 'By Date', method: 'GET', path: '/api/files/by-date', query: [ { name: 'date', required: true } ] },
|
|
{ key: 'files.byDateRange', group: 'Files', label: 'By Date Range', method: 'GET', path: '/api/files/by-date-range', query: [ { name: 'start', required: true }, { name: 'end' } ] },
|
|
{ key: 'files.get', group: 'Files', label: 'Read File', method: 'GET', path: '/api/files', query: [ { name: 'path', required: true } ], responseType: 'text' },
|
|
{ key: 'files.put', group: 'Files', label: 'Write File', method: 'PUT', path: '/api/files', query: [ { name: 'path', required: true } ], contentType: 'text', sampleBody: '# Title\n\nContent...' },
|
|
{ key: 'files.putBlob', group: 'Files', label: 'Write Binary Sidecar', method: 'PUT', path: '/api/files/blob', query: [ { name: 'path', required: true } ], contentType: 'binary' },
|
|
{ key: 'files.rename', group: 'Files', label: 'Rename File', method: 'PUT', path: '/api/files/rename', contentType: 'json', sampleBody: { oldPath: 'folder/old.md', newName: 'new.md' } },
|
|
|
|
{ key: 'notes.create', group: 'Notes', label: 'Create Note', method: 'POST', path: '/api/vault/notes', contentType: 'json', sampleBody: { fileName: 'Nouvelle note.md', folderPath: '', frontmatter: { title: 'Nouvelle note' }, content: '# Nouvelle note' } },
|
|
{ key: 'notes.updateFrontmatter', group: 'Notes', label: 'Update Frontmatter', method: 'PATCH', path: '/api/vault/notes/{id}', pathParams: ['id'], contentType: 'json', sampleBody: { frontmatter: { title: 'Updated' } } },
|
|
{ key: 'notes.delete', group: 'Notes', label: 'Delete (to .trash)', method: 'DELETE', path: '/api/vault/notes/{id}', pathParams: ['id'] },
|
|
{ key: 'notes.move', group: 'Notes', label: 'Move Note', method: 'POST', path: '/api/vault/notes/move', contentType: 'json', sampleBody: { notePath: 'folder/a.md', newFolderPath: 'folder2' } },
|
|
{ key: 'notes.tags', group: 'Notes', label: 'Update Tags', method: 'PUT', path: '/api/notes/{idOrPath}/tags', pathParams: ['idOrPath'], contentType: 'json', sampleBody: { tags: ['home','docs'] } },
|
|
|
|
{ key: 'folders.list', group: 'Folders', label: 'List Folders', method: 'GET', path: '/api/folders/list' },
|
|
{ key: 'folders.create', group: 'Folders', label: 'Create Folder', method: 'POST', path: '/api/folders', contentType: 'json', sampleBody: { path: 'new-folder' } },
|
|
{ key: 'folders.rename', group: 'Folders', label: 'Rename Folder', method: 'PUT', path: '/api/folders/rename', contentType: 'json', sampleBody: { oldPath: 'old-folder', newName: 'renamed-folder' } },
|
|
{ key: 'folders.delete', group: 'Folders', label: 'Delete Folder', method: 'DELETE', path: '/api/folders', query: [ { name: 'path', required: true } ] },
|
|
{ key: 'folders.duplicate', group: 'Folders', label: 'Duplicate Folder', method: 'POST', path: '/api/folders/duplicate', contentType: 'json', sampleBody: { sourcePath: 'src', destinationPath: 'dst' } },
|
|
{ key: 'folders.deletePages', group: 'Folders', label: 'Delete Pages in Folder', method: 'DELETE', path: '/api/folders/pages', query: [ { name: 'path', required: true } ] },
|
|
|
|
{ key: 'attachments.resolve', group: 'Attachments', label: 'Resolve Attachment', method: 'GET', path: '/api/attachments/resolve', query: [ { name: 'name', required: true }, { name: 'note' }, { name: 'base' } ] },
|
|
|
|
{ key: 'search.query', group: 'Search', label: 'Meili Search', method: 'GET', path: '/api/search', query: [ { name: 'q', required: true }, { name: 'limit' }, { name: 'offset' }, { name: 'sort' }, { name: 'highlight' } ] },
|
|
{ key: 'meili.reindex', group: 'Search', label: 'Manual Reindex', method: 'POST', path: '/api/reindex' },
|
|
|
|
{ key: 'graph.get', group: 'Config', label: 'Get Graph', method: 'GET', path: '/api/vault/graph' },
|
|
{ key: 'graph.put', group: 'Config', label: 'Update Graph', method: 'PUT', path: '/api/vault/graph', headersSupported: true, contentType: 'json', sampleBody: { search: '', showTags: false } },
|
|
{ key: 'bookmarks.get', group: 'Config', label: 'Get Bookmarks', method: 'GET', path: '/api/vault/bookmarks' },
|
|
{ key: 'bookmarks.put', group: 'Config', label: 'Update Bookmarks', method: 'PUT', path: '/api/vault/bookmarks', headersSupported: true, contentType: 'json', sampleBody: { items: [] } },
|
|
|
|
{ key: 'quicklinks.counts', group: 'Quick Links', label: 'Counts', method: 'GET', path: '/api/quick-links/counts' },
|
|
|
|
{ key: 'logs.single', group: 'Logs', label: 'Post Log', method: 'POST', path: '/api/log', contentType: 'json', sampleBody: { event: 'test_api_call', level: 'info', context: { route: '/api/test' }, data: { result: 'success' } } },
|
|
{ key: 'logs.batch', group: 'Logs', label: 'Post Log (alt)', method: 'POST', path: '/api/logs', contentType: 'json', sampleBody: { source: 'frontend', level: 'info', message: 'hello', data: { any: true } } },
|
|
];
|
|
|
|
get currentEndpoint() {
|
|
const key = this.selectedEndpointKey();
|
|
return this.endpoints.find(e => e.key === key) || null;
|
|
}
|
|
|
|
getFilteredEndpoints() {
|
|
const q = (this.builderSearchTerm || '').toLowerCase();
|
|
const list = this.endpoints.slice();
|
|
if (!q) return list;
|
|
return list.filter(e =>
|
|
e.key.toLowerCase().includes(q) ||
|
|
e.label.toLowerCase().includes(q) ||
|
|
e.group.toLowerCase().includes(q) ||
|
|
e.path.toLowerCase().includes(q)
|
|
);
|
|
}
|
|
|
|
onSelectEndpoint(key: string): void {
|
|
this.selectedEndpointKey.set(key);
|
|
const ep = this.currentEndpoint;
|
|
const initParams: Record<string, string> = {};
|
|
(ep?.pathParams || []).forEach(p => initParams[p] = '');
|
|
(ep?.query || []).forEach(q => { if (q.required) initParams[q.name] = ''; });
|
|
this.paramValues.set(initParams);
|
|
this.headersText.set(ep?.headersSupported ? '{\n "If-Match": "rev-or-etag"\n}' : '{}');
|
|
if (ep?.contentType === 'json' && ep.sampleBody !== undefined) {
|
|
this.bodyText.set(JSON.stringify(ep.sampleBody, null, 2));
|
|
} else if (ep?.contentType === 'text' && typeof ep.sampleBody === 'string') {
|
|
this.bodyText.set(String(ep.sampleBody));
|
|
} else {
|
|
this.bodyText.set(ep?.contentType === 'json' ? '{}' : '');
|
|
}
|
|
this.selectedFile = null;
|
|
this.selectedFileName = '';
|
|
}
|
|
|
|
updateParamValue(name: string, value: string): void {
|
|
this.paramValues.update(map => ({ ...map, [name]: value }));
|
|
}
|
|
|
|
onFileChosen(evt: Event): void {
|
|
const input = evt.target as HTMLInputElement;
|
|
const file = input?.files?.[0] || null;
|
|
this.selectedFile = file;
|
|
this.selectedFileName = file ? `${file.name} (${file.type || 'application/octet-stream'}, ${file.size} bytes)` : '';
|
|
}
|
|
|
|
showBodyEditor(): boolean {
|
|
const ep = this.currentEndpoint;
|
|
if (!ep) return false;
|
|
if (ep.contentType === 'binary') return false;
|
|
return ep.method === 'POST' || ep.method === 'PUT' || ep.method === 'PATCH';
|
|
}
|
|
|
|
buildPreviewUrl(): string {
|
|
const ep = this.currentEndpoint;
|
|
if (!ep) return '';
|
|
const path = this.replacePathParams(ep.path, this.paramValues());
|
|
const qs = this.buildQueryString(ep.query, this.paramValues());
|
|
return qs ? `${path}?${qs}` : path;
|
|
}
|
|
|
|
private replacePathParams(path: string, params: Record<string, string>): string {
|
|
return path.replace(/\{(\w+)\}/g, (_m, p1) => encodeURIComponent(params[p1] || ''));
|
|
}
|
|
|
|
private buildQueryString(query: Array<{ name: string; required?: boolean }> | undefined, params: Record<string, string>): string {
|
|
if (!query || query.length === 0) return '';
|
|
const sp = new URLSearchParams();
|
|
for (const q of query) {
|
|
const v = params[q.name];
|
|
if (v !== undefined && v !== '') sp.append(q.name, v);
|
|
}
|
|
return sp.toString();
|
|
}
|
|
|
|
// ---------- Results helpers ----------
|
|
|
|
setStatusFilter(filter: 'all' | 'success' | 'error' | 'running'): void {
|
|
this.statusFilter.set(filter);
|
|
}
|
|
|
|
isStatusFilterActive(filter: 'all' | 'success' | 'error' | 'running'): boolean {
|
|
return this.statusFilter() === filter;
|
|
}
|
|
|
|
getFilteredResults(): TestResult[] {
|
|
const filter = this.statusFilter();
|
|
const all = this.results();
|
|
if (filter === 'all') {
|
|
return all;
|
|
}
|
|
return all.filter(r => r.status === filter);
|
|
}
|
|
|
|
toggleResultDetails(result: TestResult): void {
|
|
const current = new Set(this.expandedResults());
|
|
if (current.has(result)) {
|
|
current.delete(result);
|
|
} else {
|
|
current.add(result);
|
|
}
|
|
this.expandedResults.set(current);
|
|
}
|
|
|
|
isResultExpanded(result: TestResult): boolean {
|
|
return this.expandedResults().has(result);
|
|
}
|
|
|
|
async runDynamicRequest(): Promise<void> {
|
|
const ep = this.currentEndpoint;
|
|
if (!ep) return;
|
|
|
|
if (ep.method === 'SSE' || ep.path === '/api/vault/events') {
|
|
this.runSSETest();
|
|
return;
|
|
}
|
|
|
|
// Validate required params
|
|
for (const p of (ep.pathParams || [])) {
|
|
if (!this.paramValues()[p]?.trim()) {
|
|
this.addResult({ endpoint: ep.path, method: ep.method, status: 'error', error: `Missing path param: ${p}`, timestamp: Date.now() });
|
|
return;
|
|
}
|
|
}
|
|
for (const q of (ep.query || [])) {
|
|
if (q.required && !this.paramValues()[q.name]?.trim()) {
|
|
this.addResult({ endpoint: ep.path, method: ep.method, status: 'error', error: `Missing query param: ${q.name}`, timestamp: Date.now() });
|
|
return;
|
|
}
|
|
}
|
|
|
|
const urlPath = this.replacePathParams(ep.path, this.paramValues());
|
|
const qs = this.buildQueryString(ep.query, this.paramValues());
|
|
const fullUrl = qs ? `${urlPath}?${qs}` : urlPath;
|
|
|
|
let headers: Record<string, string> | undefined;
|
|
if (this.headersText().trim() && this.headersText().trim() !== '{}') {
|
|
try { headers = JSON.parse(this.headersText()); } catch (e: any) {
|
|
this.addResult({ endpoint: fullUrl, method: ep.method, status: 'error', error: `Invalid headers JSON: ${e.message || e}`, timestamp: Date.now() });
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (ep.contentType === 'binary') {
|
|
if (!this.selectedFile) {
|
|
this.addResult({ endpoint: fullUrl, method: ep.method, status: 'error', error: 'Please choose a file to upload', timestamp: Date.now() });
|
|
return;
|
|
}
|
|
await this.runTest(ep.method, fullUrl, async () => {
|
|
return this.http.put(fullUrl, this.selectedFile as any, {
|
|
headers: { 'Content-Type': (this.selectedFile as File).type || 'application/octet-stream', ...(headers || {}) }
|
|
}).toPromise();
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Prepare body
|
|
let body: any = undefined;
|
|
let contentTypeHeader: Record<string, string> = {};
|
|
if (ep.method === 'POST' || ep.method === 'PUT' || ep.method === 'PATCH') {
|
|
if (ep.contentType === 'json') {
|
|
const txt = this.bodyText();
|
|
try { body = txt ? JSON.parse(txt) : {}; } catch (e: any) {
|
|
this.addResult({ endpoint: fullUrl, method: ep.method, status: 'error', error: `Invalid JSON body: ${e.message || e}`, timestamp: Date.now() });
|
|
return;
|
|
}
|
|
contentTypeHeader = { 'Content-Type': 'application/json; charset=utf-8' };
|
|
} else if (ep.contentType === 'text') {
|
|
body = this.bodyText();
|
|
contentTypeHeader = { 'Content-Type': 'text/markdown; charset=utf-8' };
|
|
}
|
|
}
|
|
|
|
const responseType = ep.responseType === 'text' ? 'text' : 'json';
|
|
await this.runTest(ep.method, fullUrl, async () => {
|
|
return this.http.request(ep.method, fullUrl, {
|
|
body,
|
|
headers: { ...(contentTypeHeader || {}), ...(headers || {}) },
|
|
responseType: responseType as any
|
|
}).toPromise();
|
|
});
|
|
}
|
|
|
|
toggleFullscreen(): void {
|
|
this.isFullscreen.update(v => !v);
|
|
}
|
|
|
|
async runHealthCheck(): Promise<void> {
|
|
await this.runTest('GET', '/api/health', async () => {
|
|
return this.http.get('/api/health').toPromise();
|
|
});
|
|
}
|
|
|
|
async runVaultMetadata(): Promise<void> {
|
|
await this.runTest('GET', '/api/vault/metadata', async () => {
|
|
return this.http.get('/api/vault/metadata').toPromise();
|
|
});
|
|
}
|
|
|
|
async runVaultPaginated(): Promise<void> {
|
|
await this.runTest('GET', '/api/vault/metadata/paginated', async () => {
|
|
return this.http.get('/api/vault/metadata/paginated').toPromise();
|
|
});
|
|
}
|
|
|
|
async runFileRead(): Promise<void> {
|
|
const path = this.testFilePath().trim();
|
|
if (!path) return;
|
|
|
|
await this.runTest('GET', `/api/files?path=${encodeURIComponent(path)}`, async () => {
|
|
return this.http.get(`/api/files?path=${encodeURIComponent(path)}`, { responseType: 'text' }).toPromise();
|
|
});
|
|
}
|
|
|
|
async runFileWrite(): Promise<void> {
|
|
const path = this.testFilePath().trim();
|
|
const content = this.testFileContent().trim();
|
|
if (!path || !content) return;
|
|
|
|
await this.runTest('PUT', `/api/files?path=${encodeURIComponent(path)}`, async () => {
|
|
return this.http.put(`/api/files?path=${encodeURIComponent(path)}`, content, {
|
|
headers: { 'Content-Type': 'text/markdown; charset=utf-8' }
|
|
}).toPromise();
|
|
});
|
|
}
|
|
|
|
async runFileDelete(): Promise<void> {
|
|
const path = this.testFilePath().trim();
|
|
if (!path) return;
|
|
|
|
await this.runTest('DELETE', `/api/files?path=${encodeURIComponent(path)}`, async () => {
|
|
return this.http.delete(`/api/files?path=${encodeURIComponent(path)}`).toPromise();
|
|
});
|
|
}
|
|
|
|
async runTogglePublish(): Promise<void> {
|
|
const noteId = this.testNoteId().trim();
|
|
if (!noteId) return;
|
|
|
|
await this.runTest('PUT', `/api/notes/${encodeURIComponent(noteId)}/states/publish`, async () => {
|
|
return this.http.put(`/api/notes/${encodeURIComponent(noteId)}/states/publish`, { value: true }).toPromise();
|
|
});
|
|
}
|
|
|
|
async runToggleFavorite(): Promise<void> {
|
|
const noteId = this.testNoteId().trim();
|
|
if (!noteId) return;
|
|
|
|
await this.runTest('PUT', `/api/notes/${encodeURIComponent(noteId)}/states/favoris`, async () => {
|
|
return this.http.put(`/api/notes/${encodeURIComponent(noteId)}/states/favoris`, { value: true }).toPromise();
|
|
});
|
|
}
|
|
|
|
async runToggleDraft(): Promise<void> {
|
|
const noteId = this.testNoteId().trim();
|
|
if (!noteId) return;
|
|
|
|
await this.runTest('PUT', `/api/notes/${encodeURIComponent(noteId)}/states/draft`, async () => {
|
|
return this.http.put(`/api/notes/${encodeURIComponent(noteId)}/states/draft`, { value: true }).toPromise();
|
|
});
|
|
}
|
|
|
|
runSSETest(): void {
|
|
if (this.sseConnection) {
|
|
this.sseConnection.close();
|
|
this.sseConnection = null;
|
|
}
|
|
|
|
this.sseConnection = new EventSource('/api/vault/events');
|
|
this.sseEvents.set([]);
|
|
|
|
this.sseConnection.onopen = () => {
|
|
this.addResult({
|
|
endpoint: '/api/vault/events',
|
|
method: 'SSE',
|
|
status: 'success',
|
|
response: 'SSE connection established',
|
|
timestamp: Date.now()
|
|
});
|
|
};
|
|
|
|
this.sseConnection.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
this.sseEvents.update(events => [{
|
|
type: data.event || 'message',
|
|
data,
|
|
timestamp: new Date()
|
|
}, ...events.slice(0, 9)]); // Keep last 10 events
|
|
} catch (e) {
|
|
// Ignore parse errors
|
|
}
|
|
};
|
|
|
|
this.sseConnection.onerror = (error) => {
|
|
this.addResult({
|
|
endpoint: '/api/vault/events',
|
|
method: 'SSE',
|
|
status: 'error',
|
|
error: 'SSE connection failed',
|
|
timestamp: Date.now()
|
|
});
|
|
this.sseConnection?.close();
|
|
this.sseConnection = null;
|
|
};
|
|
}
|
|
|
|
async runSimulateEvent(): Promise<void> {
|
|
await this.runTest('POST', '/api/simulate-event', async () => {
|
|
return this.http.post('/api/simulate-event', { event: 'test', data: { message: 'Test event' } }).toPromise();
|
|
});
|
|
}
|
|
|
|
async runLogTest(): Promise<void> {
|
|
const testLogs = [
|
|
{ event: 'test_api_call', level: 'info', context: { route: '/api/test' }, data: { result: 'success' } },
|
|
{ event: 'test_error', level: 'error', context: { route: '/api/test' }, data: { error: 'Test error' } }
|
|
];
|
|
|
|
await this.runTest('POST', '/api/log', async () => {
|
|
return this.http.post('/api/log', testLogs[0]).toPromise();
|
|
});
|
|
|
|
await this.runTest('POST', '/api/logs', async () => {
|
|
return this.http.post('/api/logs', testLogs[1]).toPromise();
|
|
});
|
|
}
|
|
|
|
async runBatchTest(): Promise<void> {
|
|
this.isRunning.set(true);
|
|
|
|
try {
|
|
// Health check
|
|
await this.runHealthCheck();
|
|
|
|
// Vault APIs
|
|
await this.runVaultMetadata();
|
|
await this.runVaultPaginated();
|
|
|
|
// File operations (using a test file)
|
|
this.testFilePath.set('test-file.md');
|
|
this.testFileContent.set('# Test File\n\nThis is a test file for API testing.');
|
|
await this.runFileWrite();
|
|
await this.runFileRead();
|
|
|
|
// State operations (using home note)
|
|
this.testNoteId.set('home');
|
|
await this.runTogglePublish();
|
|
await this.runToggleFavorite();
|
|
await this.runToggleDraft();
|
|
|
|
// SSE
|
|
this.runSSETest();
|
|
|
|
// Logging
|
|
await this.runLogTest();
|
|
|
|
// Clean up
|
|
await this.runFileDelete();
|
|
|
|
} catch (error) {
|
|
console.error('Batch test error:', error);
|
|
} finally {
|
|
this.isRunning.set(false);
|
|
}
|
|
}
|
|
|
|
private async runTest(method: string, endpoint: string, operation: () => Promise<any>): Promise<void> {
|
|
const startTime = Date.now();
|
|
const result: TestResult = {
|
|
endpoint,
|
|
method,
|
|
status: 'running',
|
|
timestamp: startTime
|
|
};
|
|
|
|
this.results.update(results => [result, ...results]);
|
|
|
|
try {
|
|
const response = await operation();
|
|
result.status = 'success';
|
|
result.response = response;
|
|
} catch (error: any) {
|
|
result.status = 'error';
|
|
result.error = error.message || error.toString();
|
|
}
|
|
|
|
result.duration = Date.now() - startTime;
|
|
this.results.update(results => results.map(r => r === result ? { ...result } : r));
|
|
}
|
|
|
|
private addResult(result: TestResult): void {
|
|
this.results.update(results => [result, ...results]);
|
|
}
|
|
|
|
clearResults(): void {
|
|
this.results.set([]);
|
|
this.sseEvents.set([]);
|
|
if (this.sseConnection) {
|
|
this.sseConnection.close();
|
|
this.sseConnection = null;
|
|
}
|
|
}
|
|
|
|
getResultClass(result: TestResult): string {
|
|
return `result-item--${result.status}`;
|
|
}
|
|
}
|