Files
crank/apps/ui/index.html
T
github-ops 58cfd86d90
Deploy / deploy (push) Successful in 2m33s
CI / Rust Checks (push) Successful in 5m35s
CI / UI Checks (push) Successful in 5s
CI / Deployment Manifests (push) Successful in 2s
CI / Frontend E2E (push) Successful in 4m19s
Limit community to single-user workspace
2026-06-19 17:41:50 +00:00

413 lines
19 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">
<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>
<!-- Workspace switcher -->
<div class="ws-switcher" id="ws-switcher">
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
<div class="ws-dot" id="ws-dot">A</div>
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button>
<div class="ws-dropdown" id="ws-dropdown" hidden>
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
<div id="ws-dropdown-list"></div>
</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>
<button class="nav-icon-btn" title="Notifications">
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
</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">Tool contracts that expose your upstream APIs as MCP tools</p>
</div>
<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>
<!-- 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>
<!-- Protocol dropdown -->
<div class="filter-dropdown" @click.stop>
<button
class="filter-chip"
:class="{ 'has-value': filterProtocol, open: openDropdown === 'protocol' }"
@click="toggleDropdown('protocol')"
>
<span x-text="filterProtocol ? filterProtocol.toUpperCase() : tKey('filter.protocol')"></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 === 'protocol'" x-transition>
<template x-for="p in ['rest']" :key="p">
<button
class="dropdown-item"
:class="{ selected: filterProtocol === p }"
@click="setProtocol(p)"
>
<span x-text="p === 'rest' ? 'REST' : p.toUpperCase()"></span>
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
</button>
</template>
<template x-if="filterProtocol">
<div>
<div class="dropdown-divider"></div>
<button class="dropdown-clear" @click="clearProtocol()" data-i18n="filter.clear">Clear filter</button>
</div>
</template>
</div>
</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 -->
<script src="%CRANK_BUNDLE_OPERATIONS%"></script>
</body>
</html>