Просмотр исходного кода

feat: actualize web UI with API documentation integration

- Add comprehensive Documentation page with interactive endpoint browser
- Integrate new documentation API endpoints (/doc, /doc/openapi, /doc/endpoints)
- Add DocumentationPage component with:
  - Live endpoint statistics and filtering by tags
  - OpenAPI 3.0 specification viewer with copy functionality
  - Direct links to HTML documentation
  - Responsive design with method badges and auth indicators
- Update ApiService with new documentation methods (no auth required)
- Add Documentation navigation item with icon to main sidebar
- Update router and layout to support /documentation route
- Add missing icons: documentation, external-link, code, alert-circle
- Build and deploy updated web UI with component-based API integration

The web UI now provides full access to the new auto-generated API documentation
system, allowing developers to browse all 21 endpoints with complete metadata.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh 8 месяцев назад
Родитель
Сommit
b28fcd1595

+ 4 - 0
web/src/app/Router.ts

@@ -7,6 +7,7 @@ import { JobsPage } from '../components/pages/JobsPage';
 import { ContentPage } from '../components/pages/ContentPage';
 import { SchedulesPage } from '../components/pages/SchedulesPage';
 import { WebhooksPage } from '../components/pages/WebhooksPage';
+import { DocumentationPage } from '../components/pages/DocumentationPage';
 
 export interface RouteConfig {
   path: string;
@@ -68,6 +69,8 @@ export class Router {
       component = new SchedulesPage(this.apiService, this.toastService, this);
     } else if (path === '/webhooks') {
       component = new WebhooksPage(this.apiService, this.toastService, this);
+    } else if (path === '/documentation') {
+      component = new DocumentationPage(this.apiService, this.toastService);
     }
 
     if (component) {
@@ -99,6 +102,7 @@ export class Router {
     if (path === '/content') return 'Content';
     if (path === '/schedules') return 'Schedules';
     if (path === '/webhooks') return 'Webhooks';
+    if (path === '/documentation') return 'API Documentation';
     return '';
   }
 }

+ 2 - 0
web/src/components/Layout.ts

@@ -16,6 +16,7 @@ export class Layout {
     { name: 'Content', href: '/content', icon: 'content', current: false },
     { name: 'Schedules', href: '/schedules', icon: 'schedules', current: false },
     { name: 'Webhooks', href: '/webhooks', icon: 'webhooks', current: false },
+    { name: 'Documentation', href: '/documentation', icon: 'documentation', current: false },
   ];
 
   constructor(
@@ -312,6 +313,7 @@ export class Layout {
     if (path === '/content') return 'Content';
     if (path === '/schedules') return 'Schedules';
     if (path === '/webhooks') return 'Webhooks';
+    if (path === '/documentation') return 'API Documentation';
     return 'Dashboard';
   }
 

+ 332 - 0
web/src/components/pages/DocumentationPage.ts

@@ -0,0 +1,332 @@
+import { ApiService } from '../../services/ApiService';
+import { ToastService } from '../../services/ToastService';
+import { getIcon } from '../../utils/icons';
+
+export class DocumentationPage {
+  private element: HTMLElement | null = null;
+  private endpoints: any[] = [];
+
+  constructor(
+    private apiService: ApiService,
+    private toastService: ToastService
+  ) {}
+
+  render(): HTMLElement {
+    this.element = document.createElement('div');
+    this.element.className = 'space-y-6';
+
+    this.element.innerHTML = `
+      <!-- Header -->
+      <div class="md:flex md:items-center md:justify-between">
+        <div class="flex-1 min-w-0">
+          <h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-white sm:text-3xl sm:truncate">
+            API Documentation
+          </h2>
+          <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
+            Interactive documentation for all API endpoints
+          </p>
+        </div>
+        <div class="mt-4 flex md:mt-0 md:ml-4 space-x-3">
+          <button
+            type="button"
+            class="btn btn-secondary"
+            id="refresh-endpoints"
+          >
+            <div class="w-4 h-4 mr-2">
+              ${getIcon('refresh')}
+            </div>
+            Refresh
+          </button>
+          <button
+            type="button"
+            class="btn btn-secondary"
+            id="view-openapi"
+          >
+            <div class="w-4 h-4 mr-2">
+              ${getIcon('code')}
+            </div>
+            OpenAPI Spec
+          </button>
+          <button
+            type="button"
+            class="btn btn-primary"
+            id="view-html-docs"
+          >
+            <div class="w-4 h-4 mr-2">
+              ${getIcon('external-link')}
+            </div>
+            View Full Docs
+          </button>
+        </div>
+      </div>
+
+      <!-- Statistics Cards -->
+      <div class="grid grid-cols-1 md:grid-cols-3 gap-6" id="stats-container">
+        <div class="card">
+          <div class="card-body text-center">
+            <div class="text-2xl font-bold text-primary-600 dark:text-primary-400" id="total-endpoints">-</div>
+            <div class="text-sm text-gray-600 dark:text-gray-400">Total Endpoints</div>
+          </div>
+        </div>
+        <div class="card">
+          <div class="card-body text-center">
+            <div class="text-2xl font-bold text-green-600 dark:text-green-400" id="public-endpoints">-</div>
+            <div class="text-sm text-gray-600 dark:text-gray-400">Public Endpoints</div>
+          </div>
+        </div>
+        <div class="card">
+          <div class="card-body text-center">
+            <div class="text-2xl font-bold text-orange-600 dark:text-orange-400" id="auth-endpoints">-</div>
+            <div class="text-sm text-gray-600 dark:text-gray-400">Auth Required</div>
+          </div>
+        </div>
+      </div>
+
+      <!-- OpenAPI Modal -->
+      <div class="fixed inset-0 z-50 hidden" id="openapi-modal">
+        <div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
+          <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
+          <div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
+            <div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6">
+              <div class="sm:flex sm:items-start">
+                <div class="w-full">
+                  <h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-4">
+                    OpenAPI 3.0 Specification
+                  </h3>
+                  <div class="flex justify-between items-center mb-4">
+                    <div class="text-sm text-gray-600 dark:text-gray-400">
+                      Copy the JSON specification below
+                    </div>
+                    <button
+                      type="button"
+                      class="btn btn-secondary btn-sm"
+                      id="copy-openapi"
+                    >
+                      <div class="w-4 h-4 mr-1">
+                        ${getIcon('copy')}
+                      </div>
+                      Copy
+                    </button>
+                  </div>
+                  <pre class="bg-gray-100 dark:bg-gray-700 p-4 rounded-lg overflow-auto max-h-96 text-xs">
+                    <code id="openapi-content">Loading...</code>
+                  </pre>
+                </div>
+              </div>
+            </div>
+            <div class="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
+              <button
+                type="button"
+                class="btn btn-secondary sm:w-auto"
+                id="close-openapi-modal"
+              >
+                Close
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- Endpoints List -->
+      <div class="card">
+        <div class="card-body">
+          <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">API Endpoints</h3>
+          <div id="endpoints-container">
+            <div class="flex items-center justify-center py-12">
+              <div class="w-8 h-8 spinner"></div>
+              <span class="ml-3 text-gray-600 dark:text-gray-400">Loading endpoints...</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    `;
+
+    this.bindEvents();
+    this.loadEndpoints();
+
+    return this.element;
+  }
+
+  private bindEvents(): void {
+    if (!this.element) return;
+
+    const refreshBtn = this.element.querySelector('#refresh-endpoints');
+    const openApiBtn = this.element.querySelector('#view-openapi');
+    const htmlDocsBtn = this.element.querySelector('#view-html-docs');
+    const openApiModal = this.element.querySelector('#openapi-modal');
+    const closeModalBtn = this.element.querySelector('#close-openapi-modal');
+    const copyBtn = this.element.querySelector('#copy-openapi');
+
+    refreshBtn?.addEventListener('click', () => {
+      this.loadEndpoints();
+    });
+
+    openApiBtn?.addEventListener('click', async () => {
+      await this.showOpenApiSpec();
+    });
+
+    htmlDocsBtn?.addEventListener('click', () => {
+      window.open('/doc', '_blank');
+    });
+
+    closeModalBtn?.addEventListener('click', () => {
+      openApiModal?.classList.add('hidden');
+    });
+
+    copyBtn?.addEventListener('click', async () => {
+      const content = this.element?.querySelector('#openapi-content')?.textContent;
+      if (content) {
+        try {
+          await navigator.clipboard.writeText(content);
+          this.toastService.success('Copied!', 'OpenAPI specification copied to clipboard');
+        } catch (error) {
+          this.toastService.error('Copy Failed', 'Could not copy to clipboard');
+        }
+      }
+    });
+
+    // Click outside to close modal
+    openApiModal?.addEventListener('click', (e) => {
+      if (e.target === openApiModal) {
+        openApiModal.classList.add('hidden');
+      }
+    });
+  }
+
+  private async loadEndpoints(): Promise<void> {
+    try {
+      const response = await this.apiService.getEndpointsList();
+
+      if (response.success && response.data) {
+        this.endpoints = response.data.endpoints;
+        this.updateStats();
+        this.renderEndpoints();
+      } else {
+        this.toastService.error('Load Error', response.error || 'Failed to load endpoints');
+        this.renderError();
+      }
+    } catch (error) {
+      this.toastService.error('Error', 'Failed to load endpoints');
+      this.renderError();
+    }
+  }
+
+  private updateStats(): void {
+    const totalEl = this.element?.querySelector('#total-endpoints');
+    const publicEl = this.element?.querySelector('#public-endpoints');
+    const authEl = this.element?.querySelector('#auth-endpoints');
+
+    if (totalEl && publicEl && authEl) {
+      const total = this.endpoints.length;
+      const publicEndpoints = this.endpoints.filter(e => !e.requiresAuth).length;
+      const authEndpoints = this.endpoints.filter(e => e.requiresAuth).length;
+
+      totalEl.textContent = total.toString();
+      publicEl.textContent = publicEndpoints.toString();
+      authEl.textContent = authEndpoints.toString();
+    }
+  }
+
+  private renderEndpoints(): void {
+    const container = this.element?.querySelector('#endpoints-container');
+    if (!container) return;
+
+    // Group endpoints by tags
+    const grouped: { [key: string]: any[] } = {};
+    for (const endpoint of this.endpoints) {
+      const tag = endpoint.tags?.[0] || 'Other';
+      if (!grouped[tag]) {
+        grouped[tag] = [];
+      }
+      grouped[tag].push(endpoint);
+    }
+
+    const sections = Object.entries(grouped).map(([tag, endpoints]) => {
+      const endpointItems = endpoints.map(endpoint => `
+        <div class="border border-gray-200 dark:border-gray-600 rounded-lg p-4 mb-3">
+          <div class="flex items-center justify-between mb-2">
+            <div class="flex items-center space-x-3">
+              <span class="method-badge method-${endpoint.method.toLowerCase()}">${endpoint.method}</span>
+              <code class="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">${endpoint.path}</code>
+              ${endpoint.requiresAuth ? '<span class="badge badge-warning">🔒 Auth Required</span>' : '<span class="badge badge-success">🌐 Public</span>'}
+            </div>
+          </div>
+          <p class="text-sm text-gray-600 dark:text-gray-400">${endpoint.summary}</p>
+        </div>
+      `).join('');
+
+      return `
+        <div class="mb-6">
+          <h4 class="text-md font-semibold text-gray-900 dark:text-white mb-3 pb-2 border-b border-gray-200 dark:border-gray-600">
+            ${tag} (${endpoints.length})
+          </h4>
+          ${endpointItems}
+        </div>
+      `;
+    }).join('');
+
+    container.innerHTML = `
+      <style>
+        .method-badge {
+          padding: 2px 8px;
+          border-radius: 4px;
+          font-size: 11px;
+          font-weight: bold;
+          color: white;
+          min-width: 60px;
+          text-align: center;
+        }
+        .method-get { background-color: #61affe; }
+        .method-post { background-color: #49cc90; }
+        .method-put { background-color: #fca130; }
+        .method-patch { background-color: #50e3c2; }
+        .method-delete { background-color: #f93e3e; }
+      </style>
+      ${sections}
+    `;
+  }
+
+  private renderError(): void {
+    const container = this.element?.querySelector('#endpoints-container');
+    if (!container) return;
+
+    container.innerHTML = `
+      <div class="text-center py-12">
+        <div class="w-16 h-16 bg-red-100 dark:bg-red-900 rounded-lg flex items-center justify-center mx-auto mb-4">
+          <div class="w-8 h-8 text-red-600 dark:text-red-400">
+            ${getIcon('alert-circle')}
+          </div>
+        </div>
+        <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Failed to load endpoints</h3>
+        <p class="text-gray-500 dark:text-gray-400 mb-6">
+          Could not retrieve API documentation
+        </p>
+        <button class="btn btn-primary" onclick="window.location.reload()">
+          Try Again
+        </button>
+      </div>
+    `;
+  }
+
+  private async showOpenApiSpec(): Promise<void> {
+    const modal = this.element?.querySelector('#openapi-modal');
+    const content = this.element?.querySelector('#openapi-content');
+
+    if (!modal || !content) return;
+
+    modal.classList.remove('hidden');
+    content.textContent = 'Loading...';
+
+    try {
+      const response = await this.apiService.getOpenApiSpec();
+
+      if (response.success && response.data) {
+        content.textContent = JSON.stringify(response.data, null, 2);
+      } else {
+        content.textContent = `Error loading OpenAPI spec: ${response.error}`;
+      }
+    } catch (error) {
+      content.textContent = 'Error loading OpenAPI specification';
+    }
+  }
+}

+ 58 - 0
web/src/services/ApiService.ts

@@ -205,4 +205,62 @@ export class ApiService {
       method: 'DELETE',
     });
   }
+
+  // Documentation (no auth required)
+  async getApiDocumentation(): Promise<ApiResponse<string>> {
+    try {
+      const response = await fetch('/doc');
+      if (!response.ok) {
+        throw new Error(`HTTP ${response.status}`);
+      }
+      const html = await response.text();
+      return { data: html, success: true };
+    } catch (error) {
+      return {
+        error: error instanceof Error ? error.message : 'Unknown error',
+        success: false,
+      };
+    }
+  }
+
+  async getOpenApiSpec(): Promise<ApiResponse<any>> {
+    try {
+      const response = await fetch('/doc/openapi');
+      if (!response.ok) {
+        throw new Error(`HTTP ${response.status}`);
+      }
+      const data = await response.json();
+      return { data, success: true };
+    } catch (error) {
+      return {
+        error: error instanceof Error ? error.message : 'Unknown error',
+        success: false,
+      };
+    }
+  }
+
+  async getEndpointsList(): Promise<ApiResponse<{
+    endpoints: Array<{
+      method: string;
+      path: string;
+      summary: string;
+      tags: string[];
+      requiresAuth: boolean;
+    }>;
+    total: number;
+  }>> {
+    try {
+      const response = await fetch('/doc/endpoints');
+      if (!response.ok) {
+        throw new Error(`HTTP ${response.status}`);
+      }
+      const data = await response.json();
+      return { data, success: true };
+    } catch (error) {
+      return {
+        error: error instanceof Error ? error.message : 'Unknown error',
+        success: false,
+      };
+    }
+  }
 }

+ 4 - 0
web/src/utils/icons.ts

@@ -7,6 +7,7 @@ export const icons = {
   content: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>',
   schedules: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>',
   webhooks: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path></svg>',
+  documentation: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>',
 
   // Actions
   plus: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>',
@@ -29,6 +30,9 @@ export const icons = {
   sort: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"></path></svg>',
   external: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>',
   eye: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>',
+  'external-link': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>',
+  code: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>',
+  'alert-circle': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>',
 
   // Theme
   sun: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>',