Files
crank/apps/ui/index.html
T
2026-03-30 00:15:02 +03:00

419 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>try{if(!localStorage.getItem('crank_user'))window.location.replace('html/login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/workspace.js"></script>
</head>
<body x-data="catalog()">
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
<nav class="navbar">
<a href="index.html" 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" style="display:none">
<div class="ws-dropdown-label">Your workspaces</div>
<div id="ws-dropdown-list"></div>
<div class="ws-dropdown-footer">
<button class="ws-dropdown-create" onclick="window.location.href=(window.APP_BASE||'')+'html/workspace-setup.html?mode=create'">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
Create workspace
</button>
</div>
</div>
</div>
<div class="nav-links">
<a href="index.html" class="nav-link active" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="nav-link" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" 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 }" aria-label="Menu">
<span></span><span></span><span></span>
</button>
<div class="nav-kbd">
<svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg>
<span>⌘K</span>
</div>
<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')" title="Account">AT</div>
<div class="user-dropdown" x-show="openDropdown === 'user'" x-transition>
<div class="user-dropdown-header">
<div class="user-dropdown-name">Operator</div>
<div class="user-dropdown-role">Admin · acme-workspace</div>
</div>
<button class="user-dropdown-item" @click="openDropdown = null; window.location.href='html/settings.html'">
<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="index.html" class="mobile-nav-link active" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" 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>
New operation
</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="operations.length"></div>
<div class="stat-delta up">+3 this month</div>
</div>
<div class="stat-card">
<div class="stat-label" data-i18n="stats.active">Active</div>
<div class="stat-value" x-text="operations.filter(o => o.status === 'active').length"></div>
<div class="stat-delta" x-text="operations.length ? Math.round(operations.filter(o=>o.status==='active').length/operations.length*100)+'% of total' : ''"></div>
</div>
<div class="stat-card">
<div class="stat-label" data-i18n="stats.requests">Requests today</div>
<div class="stat-value">4,821</div>
<div class="stat-delta up">+12% vs yesterday</div>
</div>
<div class="stat-card">
<div class="stat-label" data-i18n="stats.latency">Avg. latency</div>
<div class="stat-value">187ms</div>
<div class="stat-delta down">+23ms this week</div>
</div>
</div>
<!-- Tabs -->
<div class="tab-row">
<template x-for="t in tabDefs" :key="t.id">
<button
class="tab-item"
:class="{ active: tab === t.id }"
@click="setTab(t.id)"
>
<span x-text="t.label"></span>
<span class="tab-count" x-text="tabCount(t.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"
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() : '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','graphql','grpc']" :key="p">
<button
class="dropdown-item"
:class="{ selected: filterProtocol === p }"
@click="setProtocol(p)"
>
<span x-text="p === 'rest' ? 'REST' : p === 'graphql' ? 'GraphQL' : 'gRPC'"></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()">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 ?? '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()">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.id === filterAgent)?.display_name : '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.id">
<button
class="dropdown-item"
:class="{ selected: filterAgent === a.id }"
@click="setAgent(a.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;">No active agents</div>
</template>
<template x-if="filterAgent">
<div>
<div class="dropdown-divider"></div>
<button class="dropdown-clear" @click="clearAgent()">Clear filter</button>
</div>
</template>
</div>
</div>
<!-- Right side: count + sort -->
<div class="filter-right">
<span class="results-count">
Showing <span x-text="totalFiltered"></span> of <span x-text="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)" 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">
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 && totalFiltered === 0">
<h3 data-i18n="ops.empty.title">No operations found</h3>
<p data-i18n="ops.empty.sub">Try adjusting your search or filters.</p>
</div>
<!-- Table itself -->
<table x-show="!loading && totalFiltered > 0">
<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')">
CREATED
<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.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">
<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="Edit operation" @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="Delete operation" @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">
<span class="table-footer-info">
Showing <span x-text="pageStart"></span><span x-text="pageEnd"></span> of <span x-text="totalFiltered"></span> operations
</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="js/catalog.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
</body>
</html>