Files
crank/apps/ui/index.html
github-ops d739f17393
CI / Rust Checks (push) Successful in 5m49s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 4m23s
CI / Deploy (push) Successful in 1m29s
Polish OpenAPI import modal
2026-06-24 07:18:02 +00:00

454 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Operations</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/catalog.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/openapi-import.css">
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
</head>
<body x-data="catalog()">
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
<nav class="navbar">
<a href="/" class="nav-logo">
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
<span class="nav-logo-text">Crank</span>
</a>
<!-- Single workspace indicator -->
<div class="ws-switcher" id="ws-switcher">
<div class="ws-switcher-trigger">
<div class="ws-dot" id="ws-dot">A</div>
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
</div>
</div>
<div class="nav-links">
<a href="/" class="nav-link active" data-i18n="nav.operations">Operations</a>
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="nav-right">
<!-- hamburger (mobile only) -->
<button class="nav-hamburger" @click.stop="navOpen = !navOpen" :class="{ open: navOpen }" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu">
<span></span><span></span><span></span>
</button>
<div class="nav-divider"></div>
<!-- User menu -->
<div class="user-menu" @click.stop>
<div class="nav-avatar" @click="toggleDropdown('user')" data-i18n-title="nav.account" title="Account">AT</div>
<div class="user-dropdown" x-show="openDropdown === 'user'" x-transition>
<div class="user-dropdown-header">
<div class="user-dropdown-name">Crank</div>
<div class="user-dropdown-role"></div>
</div>
<button class="user-dropdown-item" @click="openDropdown = null; window.location.href='/settings'">
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
<span data-i18n="nav.settings">Settings</span>
</button>
<div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" @click="handleLogout()">
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
<span data-i18n="nav.logout">Log out</span>
</button>
</div>
</div>
</div>
</nav>
<!-- Mobile nav dropdown -->
<div class="mobile-nav" x-show="navOpen" x-transition @click.stop>
<a href="/" class="mobile-nav-link active" data-i18n="nav.operations">Operations</a>
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
</div>
<!-- ═══════════════════ PAGE ═══════════════════ -->
<div class="page">
<!-- Page header -->
<div class="page-header">
<div>
<h1 class="page-heading" data-i18n="ops.title">Operations</h1>
<p class="page-subheading" data-i18n="ops.subtitle">Catalog of tools. List of created MCP tools for API endpoints.</p>
</div>
<div class="page-header-actions">
<button class="btn-secondary openapi-import-trigger" @click="handleOpenApiImport()">
<svg width="13" height="13"><use href="icons/wizard/upload.svg#icon"/></svg>
<span>Импорт OpenAPI</span>
</button>
<button class="btn-new" @click="handleNewOperation()">
<svg width="13" height="13"><use href="icons/general/plus.svg#icon"/></svg>
<span data-i18n="ops.new">New operation</span>
</button>
</div>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-label" data-i18n="stats.total">Total operations</div>
<div class="stat-value" x-text="formatMetricNumber(stats.total)"></div>
<div class="stat-delta" x-text="stats.total ? tKey('ops.stats.synced') : tKey('ops.stats.none')"></div>
</div>
<div class="stat-card">
<div class="stat-label" data-i18n="stats.active">Active</div>
<div class="stat-value" x-text="formatMetricNumber(stats.active)"></div>
<div class="stat-delta" x-text="stats.total ? tfKey('ops.stats.share', { value: Math.round((stats.active / stats.total) * 100) }) : tKey('ops.stats.no_published')"></div>
</div>
<div class="stat-card">
<div class="stat-label" data-i18n="stats.requests">Requests today</div>
<div class="stat-value" x-text="formatMetricNumber(stats.requestsToday)"></div>
<div class="stat-delta" x-text="stats.requestsToday ? tKey('ops.stats.requests_rollup') : tKey('ops.stats.no_traffic')"></div>
</div>
<div class="stat-card">
<div class="stat-label" data-i18n="stats.latency">Avg. latency</div>
<div class="stat-value" x-text="stats.averageLatencyMs ? stats.averageLatencyMs + 'ms' : '—'"></div>
<div class="stat-delta" x-text="stats.averageLatencyMs ? tKey('ops.stats.latency_weighted') : tKey('ops.stats.latency_waiting')"></div>
</div>
</div>
<!-- Tabs -->
<div class="tab-row">
<template x-for="tabDef in tabDefs" :key="tabDef.id">
<button
class="tab-item"
:class="{ active: tab === tabDef.id }"
@click="setTab(tabDef.id)"
>
<span x-text="tabDef.label"></span>
<span class="tab-count" x-text="tabCount(tabDef.id)"></span>
</button>
</template>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<!-- Search -->
<div class="search-box">
<svg width="14" height="14"><use href="icons/general/search.svg#icon"/></svg>
<input
type="text"
data-i18n-ph="filter.search"
placeholder="Search operations..."
:value="search"
@input="setSearch($event.target.value)"
>
</div>
<!-- Category dropdown -->
<div class="filter-dropdown" @click.stop>
<button
class="filter-chip"
:class="{ 'has-value': filterCategory, open: openDropdown === 'category' }"
@click="toggleDropdown('category')"
>
<span x-text="filterCategory ?? tKey('filter.category')"></span>
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
</button>
<div class="dropdown-menu" x-show="openDropdown === 'category'" x-transition>
<template x-for="c in categoryOptions" :key="c">
<button
class="dropdown-item"
:class="{ selected: filterCategory === c }"
@click="setCategory(c)"
>
<span x-text="c"></span>
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
</button>
</template>
<template x-if="filterCategory">
<div>
<div class="dropdown-divider"></div>
<button class="dropdown-clear" @click="clearCategory()" data-i18n="filter.clear">Clear filter</button>
</div>
</template>
</div>
</div>
<!-- Agent dropdown -->
<div class="filter-dropdown" @click.stop>
<button
class="filter-chip"
:class="{ 'has-value': filterAgent, open: openDropdown === 'agent' }"
@click="toggleDropdown('agent')"
>
<span x-text="filterAgent ? activeAgents.find(a => a.agent_id === filterAgent)?.display_name : tKey('filter.agent')"></span>
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
</button>
<div class="dropdown-menu" x-show="openDropdown === 'agent'" x-transition>
<template x-for="a in activeAgents" :key="a.agent_id">
<button
class="dropdown-item"
:class="{ selected: filterAgent === a.agent_id }"
@click="setAgent(a.agent_id)"
>
<span x-text="a.display_name"></span>
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
</button>
</template>
<template x-if="!activeAgents.length">
<div class="dropdown-item" style="color:var(--text-muted);cursor:default;" data-i18n="ops.no_active_agents">No active agents</div>
</template>
<template x-if="filterAgent">
<div>
<div class="dropdown-divider"></div>
<button class="dropdown-clear" @click="clearAgent()" data-i18n="filter.clear">Clear filter</button>
</div>
</template>
</div>
</div>
<!-- Right side: count + sort -->
<div class="filter-right">
<span class="results-count">
<span x-text="tfKey('ops.results', { shown: totalFiltered, total: operations.length })"></span>
</span>
<!-- Sort dropdown -->
<div class="sort-dropdown" @click.stop>
<button
class="sort-chip"
:class="{ open: openDropdown === 'sort' }"
@click="toggleDropdown('sort')"
>
<span x-text="sortLabel"></span>
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
</button>
<div class="dropdown-menu" style="right:0;left:auto;min-width:200px" x-show="openDropdown === 'sort'" x-transition>
<template x-for="opt in sortOptions" :key="opt.value">
<button
class="dropdown-item"
:class="{ selected: sort === opt.value }"
@click="setSort(opt.value)"
>
<span x-text="opt.label"></span>
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
</button>
</template>
</div>
</div>
</div>
</div><!-- /filter-bar -->
<!-- Active filter chips -->
<div class="active-filters" x-show="hasActiveFilters" x-transition>
<template x-for="chip in activeChips" :key="chip.key">
<div class="active-filter-chip">
<span x-text="chip.label"></span>
<button class="active-filter-chip-remove" @click="clearChip(chip.key)" data-i18n-title="filter.remove" title="Remove filter">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="7" y2="7"/><line x1="7" y1="1" x2="1" y2="7"/></svg>
</button>
</div>
</template>
<button class="active-filter-clear-all" @click="clearAllFilters()" x-show="activeChips.length > 1" data-i18n="filter.clear_all">
Clear all
</button>
</div>
<!-- Table -->
<div class="table-wrap">
<!-- Loading -->
<div class="empty-state" x-show="loading">
<p data-i18n="ops.loading">Loading operations…</p>
</div>
<!-- Empty state -->
<div class="empty-state" x-show="!loading && !loadError && totalFiltered === 0">
<h3 x-text="operations.length === 0 ? tKey('ops.empty.initial.title') : tKey('ops.empty.title')">No operations found</h3>
<p x-text="operations.length === 0 ? tKey('ops.empty.initial.sub') : tKey('ops.empty.sub')">Try adjusting your search or filters.</p>
</div>
<div class="empty-state" x-show="!loading && !!loadError">
<h3 data-i18n="ops.error.title">Backend connection failed</h3>
<p x-text="loadError"></p>
</div>
<!-- Table itself -->
<table x-show="!loading && totalFiltered > 0 && !loadError">
<thead>
<tr>
<th class="th-sortable" :class="{ 'sort-active': sort.startsWith('name') }" @click="setSort(sort === 'name_asc' ? 'name_desc' : 'name_asc')">
<span data-i18n="th.name">NAME</span>
<span class="sort-arrow" :class="{ active: sort.startsWith('name') }">
<span x-text="sort === 'name_asc' ? '↑' : sort === 'name_desc' ? '↓' : '↕'"></span>
</span>
</th>
<th data-i18n="th.protocol">PROTOCOL</th>
<th data-i18n="th.status">STATUS</th>
<th class="th-sortable col-created" :class="{ 'sort-active': sort.startsWith('created') }" @click="setSort(sort === 'created_asc' ? 'created_desc' : 'created_asc')">
<span data-i18n="th.created">CREATED</span>
<span class="sort-arrow" :class="{ active: sort.startsWith('created') }">
<span x-text="sort === 'created_asc' ? '↑' : sort === 'created_desc' ? '↓' : '↕'"></span>
</span>
</th>
<th class="col-url" data-i18n="th.url">TARGET URL</th>
<th></th>
</tr>
</thead>
<tbody>
<template x-for="op in paginated" :key="op.id">
<tr>
<td>
<div class="op-name" x-text="op.name"></div>
<div class="op-display" x-text="op.display_name"></div>
<div class="op-agent-badges" x-show="(agentsByOpId[op.id] || []).length > 0">
<template x-for="a in (agentsByOpId[op.id] || [])" :key="a.agent_id">
<span class="op-agent-badge" x-text="a.display_name"></span>
</template>
</div>
</td>
<td>
<span :class="protocolClass(op)" x-text="protocolLabel(op)"></span>
</td>
<td>
<span :class="statusClass(op)">
<span class="status-dot"></span>
<span x-text="statusLabel(op)"></span>
</span>
</td>
<td class="col-created"><span class="date-cell" x-text="formatDate(op.created_at)"></span></td>
<td class="col-url">
<div class="target-url-cell">
<span x-text="truncateUrl(op.target_url)" :title="op.target_url"></span>
<a :href="op.target_url && op.target_url.startsWith('http') ? op.target_url : '#'" target="_blank" rel="noopener" :style="!op.target_url ? 'opacity:.35;pointer-events:none;' : ''">
<svg width="11" height="11"><use href="icons/general/external.svg#icon"/></svg>
</a>
</div>
</td>
<td>
<div class="row-actions">
<button class="row-btn row-btn-edit" :title="tKey('ops.action.edit')" @click="editOperation(op)">
<svg width="14" height="14"><use href="icons/general/edit.svg#icon"/></svg>
</button>
<button class="row-btn row-btn-delete" :title="tKey('ops.action.delete')" @click="deleteOperation(op.id)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Footer / pagination -->
<div class="table-footer" x-show="!loading && totalFiltered > 0 && !loadError">
<span class="table-footer-info">
<span x-text="tfKey('ops.range', { from: pageStart, to: pageEnd, total: totalFiltered })"></span>
</span>
<div class="pagination">
<button class="page-btn" :disabled="page === 1" @click="goPage(page - 1)"></button>
<template x-for="p in totalPages" :key="p">
<button
class="page-btn"
:class="{ active: page === p }"
@click="goPage(p)"
x-text="p"
></button>
</template>
<button class="page-btn" :disabled="page === totalPages" @click="goPage(page + 1)"></button>
</div>
</div>
</div><!-- /table-wrap -->
</div><!-- /page -->
<div class="openapi-import-modal" id="openapi-import-modal" hidden>
<div class="openapi-import-backdrop" data-openapi-close></div>
<section class="openapi-import-dialog" role="dialog" aria-modal="true" aria-labelledby="openapi-import-title">
<div class="openapi-import-header">
<div>
<h2 id="openapi-import-title">Импорт OpenAPI</h2>
<p>Загрузите OpenAPI/Swagger документ, выберите методы и создайте черновики MCP-инструментов.</p>
</div>
<button class="modal-close" type="button" data-openapi-close aria-label="Закрыть">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="1" y1="1" x2="11" y2="11"></line><line x1="11" y1="1" x2="1" y2="11"></line>
</svg>
</button>
</div>
<div class="openapi-import-body">
<div class="openapi-import-upload">
<label class="openapi-file-label">
<span>Файл OpenAPI/Swagger</span>
<span class="openapi-file-control">
<span class="btn-secondary openapi-file-button">Выбрать файл</span>
<span class="openapi-file-name" id="openapi-import-file-name">Файл не выбран</span>
</span>
<input id="openapi-import-file" type="file" accept=".yaml,.yml,.json,application/json,text/yaml">
</label>
<textarea id="openapi-import-document" spellcheck="false" placeholder="Вставьте openapi.yaml или swagger.json"></textarea>
<div class="openapi-import-actions">
<button class="btn-primary" id="openapi-import-preview" type="button">Разобрать документ</button>
<button class="btn-secondary" id="openapi-import-reset" type="button">Сбросить</button>
</div>
</div>
<div class="openapi-import-status" id="openapi-import-status"></div>
<div class="openapi-import-preview" id="openapi-import-preview-panel" hidden>
<div class="openapi-import-source" id="openapi-import-source"></div>
<div class="openapi-import-server-row">
<label for="openapi-import-server">Base URL</label>
<select id="openapi-import-server"></select>
<input id="openapi-import-server-custom" class="openapi-import-server-custom" type="url" placeholder="Или укажите свой base URL, например https://api.example.com">
</div>
<div class="openapi-import-server-row">
<label for="openapi-import-conflict-mode">Если операция уже существует</label>
<select id="openapi-import-conflict-mode">
<option value="rename" selected>Создать копию с новым именем</option>
<option value="skip">Пропустить</option>
</select>
</div>
<div class="openapi-import-toolbar">
<div class="openapi-import-filter">
<label for="openapi-import-search">Поиск методов</label>
<input id="openapi-import-search" type="search" placeholder="Название, operationId или путь">
</div>
<div class="openapi-import-filter">
<label for="openapi-import-method-filter">HTTP-метод</label>
<select id="openapi-import-method-filter">
<option value="">Все методы</option>
</select>
</div>
<div class="openapi-import-bulk-actions">
<button class="btn-secondary" id="openapi-import-select-visible" type="button">Выбрать видимые</button>
<button class="btn-secondary" id="openapi-import-clear-visible" type="button">Снять видимые</button>
</div>
</div>
<div class="openapi-import-groups" id="openapi-import-groups"></div>
<div class="openapi-import-footer">
<span id="openapi-import-selection">Выбрано: 0</span>
<button class="btn-primary" id="openapi-import-create" type="button">Создать черновики</button>
</div>
<div class="openapi-import-result" id="openapi-import-result" hidden></div>
</div>
</div>
</section>
</div>
<script src="%CRANK_BUNDLE_OPERATIONS%"></script>
</body>
</html>