From 7070231c3e9e1defb404c0b542a550dedb5d728e Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 30 Mar 2026 00:15:02 +0300 Subject: [PATCH 1/5] feat: migrate alpine ui into app shell --- TASKS.md | 14 +- apps/ui/Dockerfile | 5 + apps/ui/css/catalog.css | 579 +++++++ apps/ui/css/layout.css | 231 +++ apps/ui/css/login.css | 192 +++ apps/ui/css/logs.css | 125 ++ apps/ui/css/pages.css | 1458 ++++++++++++++++ apps/ui/css/settings.css | 144 ++ apps/ui/css/usage.css | 122 ++ apps/ui/css/variables.css | 64 + apps/ui/css/wizard.css | 1884 +++++++++++++++++++++ apps/ui/css/workspace-setup.css | 223 +++ apps/ui/data/agents.json | 58 + apps/ui/data/keys.json | 15 + apps/ui/data/operations.json | 134 ++ apps/ui/html/agents.html | 380 +++++ apps/ui/html/api-keys.html | 281 +++ apps/ui/html/fragments/lang-switcher.html | 13 + apps/ui/html/login.html | 78 + apps/ui/html/logs.html | 148 ++ apps/ui/html/settings.html | 597 +++++++ apps/ui/html/usage.html | 228 +++ apps/ui/html/wizard/index.html | 238 +++ apps/ui/html/wizard/step1.html | 95 ++ apps/ui/html/wizard/step2.html | 157 ++ apps/ui/html/wizard/step3-graphql.html | 92 + apps/ui/html/wizard/step3-grpc.html | 274 +++ apps/ui/html/wizard/step3-rest.html | 93 + apps/ui/html/wizard/step4.html | 162 ++ apps/ui/html/wizard/step5.html | 102 ++ apps/ui/html/workspace-setup.html | 327 ++++ apps/ui/icons/agents/agent.svg | 9 + apps/ui/icons/agents/eye.svg | 7 + apps/ui/icons/agents/key.svg | 6 + apps/ui/icons/general/arrow-down.svg | 6 + apps/ui/icons/general/arrow-left.svg | 6 + apps/ui/icons/general/arrow-right.svg | 6 + apps/ui/icons/general/arrow-up.svg | 6 + apps/ui/icons/general/bell.svg | 6 + apps/ui/icons/general/check-sm.svg | 6 + apps/ui/icons/general/check.svg | 6 + apps/ui/icons/general/chevron-down.svg | 6 + apps/ui/icons/general/close.svg | 6 + apps/ui/icons/general/copy.svg | 6 + apps/ui/icons/general/crank-logo.svg | 8 + apps/ui/icons/general/download.svg | 6 + apps/ui/icons/general/edit.svg | 6 + apps/ui/icons/general/external.svg | 6 + apps/ui/icons/general/grid.svg | 6 + apps/ui/icons/general/info-circle.svg | 7 + apps/ui/icons/general/info.svg | 6 + apps/ui/icons/general/logout.svg | 7 + apps/ui/icons/general/plus-sm.svg | 6 + apps/ui/icons/general/plus.svg | 6 + apps/ui/icons/general/refresh.svg | 6 + apps/ui/icons/general/search.svg | 6 + apps/ui/icons/general/settings.svg | 7 + apps/ui/icons/general/trash.svg | 6 + apps/ui/icons/general/user.svg | 6 + apps/ui/icons/general/warning.svg | 7 + apps/ui/icons/login/google.svg | 9 + apps/ui/icons/wizard/clock.svg | 7 + apps/ui/icons/wizard/code.svg | 6 + apps/ui/icons/wizard/file.svg | 6 + apps/ui/icons/wizard/graphql-protocol.svg | 12 + apps/ui/icons/wizard/grpc-protocol.svg | 8 + apps/ui/icons/wizard/reflection.svg | 7 + apps/ui/icons/wizard/rest-protocol.svg | 6 + apps/ui/icons/wizard/server.svg | 9 + apps/ui/icons/wizard/upload.svg | 6 + apps/ui/index.html | 494 +++++- apps/ui/js/agents.js | 208 +++ apps/ui/js/api-keys.js | 241 +++ apps/ui/js/catalog.js | 333 ++++ apps/ui/js/config.js | 20 + apps/ui/js/i18n.js | 289 ++++ apps/ui/js/login.js | 34 + apps/ui/js/logs.js | 487 ++++++ apps/ui/js/nav.js | 102 ++ apps/ui/js/settings.js | 73 + apps/ui/js/usage.js | 270 +++ apps/ui/js/wizard.js | 1041 ++++++++++++ apps/ui/js/workspace-setup.js | 193 +++ apps/ui/js/workspace.js | 64 + 84 files changed, 12530 insertions(+), 92 deletions(-) create mode 100644 apps/ui/css/catalog.css create mode 100644 apps/ui/css/layout.css create mode 100644 apps/ui/css/login.css create mode 100644 apps/ui/css/logs.css create mode 100644 apps/ui/css/pages.css create mode 100644 apps/ui/css/settings.css create mode 100644 apps/ui/css/usage.css create mode 100644 apps/ui/css/variables.css create mode 100644 apps/ui/css/wizard.css create mode 100644 apps/ui/css/workspace-setup.css create mode 100644 apps/ui/data/agents.json create mode 100644 apps/ui/data/keys.json create mode 100644 apps/ui/data/operations.json create mode 100644 apps/ui/html/agents.html create mode 100644 apps/ui/html/api-keys.html create mode 100644 apps/ui/html/fragments/lang-switcher.html create mode 100644 apps/ui/html/login.html create mode 100644 apps/ui/html/logs.html create mode 100644 apps/ui/html/settings.html create mode 100644 apps/ui/html/usage.html create mode 100644 apps/ui/html/wizard/index.html create mode 100644 apps/ui/html/wizard/step1.html create mode 100644 apps/ui/html/wizard/step2.html create mode 100644 apps/ui/html/wizard/step3-graphql.html create mode 100644 apps/ui/html/wizard/step3-grpc.html create mode 100644 apps/ui/html/wizard/step3-rest.html create mode 100644 apps/ui/html/wizard/step4.html create mode 100644 apps/ui/html/wizard/step5.html create mode 100644 apps/ui/html/workspace-setup.html create mode 100644 apps/ui/icons/agents/agent.svg create mode 100644 apps/ui/icons/agents/eye.svg create mode 100644 apps/ui/icons/agents/key.svg create mode 100644 apps/ui/icons/general/arrow-down.svg create mode 100644 apps/ui/icons/general/arrow-left.svg create mode 100644 apps/ui/icons/general/arrow-right.svg create mode 100644 apps/ui/icons/general/arrow-up.svg create mode 100644 apps/ui/icons/general/bell.svg create mode 100644 apps/ui/icons/general/check-sm.svg create mode 100644 apps/ui/icons/general/check.svg create mode 100644 apps/ui/icons/general/chevron-down.svg create mode 100644 apps/ui/icons/general/close.svg create mode 100644 apps/ui/icons/general/copy.svg create mode 100644 apps/ui/icons/general/crank-logo.svg create mode 100644 apps/ui/icons/general/download.svg create mode 100644 apps/ui/icons/general/edit.svg create mode 100644 apps/ui/icons/general/external.svg create mode 100644 apps/ui/icons/general/grid.svg create mode 100644 apps/ui/icons/general/info-circle.svg create mode 100644 apps/ui/icons/general/info.svg create mode 100644 apps/ui/icons/general/logout.svg create mode 100644 apps/ui/icons/general/plus-sm.svg create mode 100644 apps/ui/icons/general/plus.svg create mode 100644 apps/ui/icons/general/refresh.svg create mode 100644 apps/ui/icons/general/search.svg create mode 100644 apps/ui/icons/general/settings.svg create mode 100644 apps/ui/icons/general/trash.svg create mode 100644 apps/ui/icons/general/user.svg create mode 100644 apps/ui/icons/general/warning.svg create mode 100644 apps/ui/icons/login/google.svg create mode 100644 apps/ui/icons/wizard/clock.svg create mode 100644 apps/ui/icons/wizard/code.svg create mode 100644 apps/ui/icons/wizard/file.svg create mode 100644 apps/ui/icons/wizard/graphql-protocol.svg create mode 100644 apps/ui/icons/wizard/grpc-protocol.svg create mode 100644 apps/ui/icons/wizard/reflection.svg create mode 100644 apps/ui/icons/wizard/rest-protocol.svg create mode 100644 apps/ui/icons/wizard/server.svg create mode 100644 apps/ui/icons/wizard/upload.svg create mode 100644 apps/ui/js/agents.js create mode 100644 apps/ui/js/api-keys.js create mode 100644 apps/ui/js/catalog.js create mode 100644 apps/ui/js/config.js create mode 100644 apps/ui/js/i18n.js create mode 100644 apps/ui/js/login.js create mode 100644 apps/ui/js/logs.js create mode 100644 apps/ui/js/nav.js create mode 100644 apps/ui/js/settings.js create mode 100644 apps/ui/js/usage.js create mode 100644 apps/ui/js/wizard.js create mode 100644 apps/ui/js/workspace-setup.js create mode 100644 apps/ui/js/workspace.js diff --git a/TASKS.md b/TASKS.md index 38b6cfb..959ed3d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,19 +2,19 @@ ## Current -### `feat/observability-api` +### `feat/alpine-ui` -Status: completed +Status: in_progress DoD: -- `Logs` page и `Usage` page имеют backend-контракт на реальных данных -- `test-runs` и `MCP tools/call` пишут продуктовые invocation logs -- usage endpoints отдают summary, timeline, breakdown по operation и agent -- observability не смешана с application logs +- `apps/ui` заменен на новый Alpine.js UI из `test-ui` +- `test-ui` остается нетронутым как fallback +- UI продолжает раздаваться как статический контейнер через текущий deployment path +- legacy React/Vite артефакты удалены из `apps/ui` ## Next -- `feat/alpine-ui` +- `feat/demo-assets` ## Backlog diff --git a/apps/ui/Dockerfile b/apps/ui/Dockerfile index 8be8867..419229c 100644 --- a/apps/ui/Dockerfile +++ b/apps/ui/Dockerfile @@ -2,6 +2,11 @@ FROM nginx:1.27-alpine COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf COPY apps/ui/index.html /usr/share/nginx/html/index.html +COPY apps/ui/css /usr/share/nginx/html/css +COPY apps/ui/data /usr/share/nginx/html/data +COPY apps/ui/html /usr/share/nginx/html/html +COPY apps/ui/icons /usr/share/nginx/html/icons +COPY apps/ui/js /usr/share/nginx/html/js COPY Crank.png /usr/share/nginx/html/Crank.png EXPOSE 3000 diff --git a/apps/ui/css/catalog.css b/apps/ui/css/catalog.css new file mode 100644 index 0000000..20003fd --- /dev/null +++ b/apps/ui/css/catalog.css @@ -0,0 +1,579 @@ +/* ── Page header ── */ +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 32px; +} +.page-heading { + font-size: 24px; + font-weight: 700; + letter-spacing: -0.5px; + color: var(--text-primary); + margin-bottom: 4px; +} +.page-subheading { font-size: 14px; color: var(--text-muted); } + +.btn-new { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 9px 18px; + border-radius: var(--radius); + background: var(--accent); + color: #fff; + font-size: 13.5px; + font-weight: 600; + border: 1px solid var(--accent-dim); + box-shadow: 0 1px 4px rgba(13,148,136,0.4); + transition: all 0.15s; + white-space: nowrap; + text-decoration: none; +} +.btn-new:hover { + background: var(--accent-dim); + box-shadow: 0 2px 10px rgba(13,148,136,0.5); + transform: translateY(-1px); +} + +/* ── Stats ── */ +.stats-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 28px; +} +.stat-card { background: var(--bg-surface); padding: 18px 22px; } +.stat-label { + font-size: 11px; + color: var(--text-muted); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 6px; +} +.stat-value { + font-size: 26px; + font-weight: 700; + letter-spacing: -1px; + color: var(--text-primary); +} +.stat-delta { font-size: 12px; color: var(--text-muted); margin-top: 3px; } +.stat-delta.up { color: var(--green); } +.stat-delta.down { color: var(--red); } + +/* ── Tabs ── */ +.tab-row { + display: flex; + align-items: center; + border-bottom: 1px solid var(--border); + margin-bottom: 20px; +} +.tab-item { + display: flex; + align-items: center; + gap: 7px; + padding: 10px 18px; + font-size: 14px; + font-weight: 450; + color: var(--text-muted); + cursor: pointer; + border: none; + background: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.1s; +} +.tab-item:hover { color: var(--text-secondary); } +.tab-item.active { + color: var(--text-primary); + font-weight: 600; + border-bottom-color: var(--accent-bright); +} +.tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0 6px; + height: 18px; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + min-width: 20px; +} +.tab-item.active .tab-count { + background: var(--accent); + color: #fff; + border-color: var(--accent-dim); +} + +/* ── Filter bar ── */ +.filter-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.search-box { + display: flex; + align-items: center; + gap: 8px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 7px 12px; + width: 220px; + transition: border-color 0.15s, box-shadow 0.15s; +} +.search-box:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); +} +.search-box input { + border: none; + background: none; + outline: none; + font-size: 13.5px; + color: var(--text-primary); + width: 100%; + font-family: inherit; +} +.search-box input::placeholder { color: var(--text-muted); } +.search-box svg { color: var(--text-muted); flex-shrink: 0; } + +/* ── Dropdown filter chip ── */ +.filter-dropdown { position: relative; } + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-surface); + font-size: 13px; + color: var(--text-secondary); + transition: all 0.1s; + white-space: nowrap; +} +.filter-chip:hover { border-color: var(--bg-muted); background: var(--bg-overlay); color: var(--text-primary); } +.filter-chip.has-value { + border-color: var(--accent); + color: var(--accent-bright); + background: var(--accent-glow); +} +.filter-chip .chevron { + opacity: 0.6; + transition: transform 0.15s; +} +.filter-chip.open .chevron { transform: rotate(180deg); } + +.dropdown-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 180px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + z-index: 150; + overflow: hidden; + padding: 4px; +} +.dropdown-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-radius: var(--radius-sm); + font-size: 13.5px; + color: var(--text-secondary); + border: none; + background: none; + width: 100%; + text-align: left; + cursor: pointer; + transition: all 0.1s; +} +.dropdown-item:hover { background: var(--bg-overlay); color: var(--text-primary); } +.dropdown-item.selected { color: var(--accent-bright); } +.dropdown-item .check { + opacity: 0; + color: var(--accent); +} +.dropdown-item.selected .check { opacity: 1; } +.dropdown-divider { + height: 1px; + background: var(--border-subtle); + margin: 4px 0; +} +.dropdown-clear { + display: flex; + align-items: center; + padding: 7px 10px; + font-size: 12px; + color: var(--text-muted); + border: none; + background: none; + width: 100%; + text-align: left; + cursor: pointer; + border-radius: var(--radius-sm); + transition: all 0.1s; +} +.dropdown-clear:hover { color: var(--red); background: var(--red-bg); } + +/* ── Filter right side ── */ +.filter-right { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; +} +.results-count { font-size: 13px; color: var(--text-muted); white-space: nowrap; } + +.sort-dropdown { position: relative; } +.sort-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-surface); + font-size: 13px; + color: var(--text-secondary); + transition: all 0.1s; + white-space: nowrap; +} +.sort-chip:hover { border-color: var(--bg-muted); background: var(--bg-overlay); color: var(--text-primary); } +.sort-chip .chevron { opacity: 0.6; transition: transform 0.15s; } +.sort-chip.open .chevron { transform: rotate(180deg); } + +/* ── Table ── */ +.table-wrap { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} +.table-wrap table { + width: 100%; + border-collapse: collapse; +} +.table-wrap thead tr { + background: var(--bg-canvas); + border-bottom: 1px solid var(--border); +} +.table-wrap th { + padding: 10px 16px; + text-align: left; + font-size: 11.5px; + font-weight: 700; + color: var(--text-muted); + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; + user-select: none; +} +.th-sortable { cursor: pointer; } +.th-sortable:hover { color: var(--text-secondary); } +.th-sortable.sort-active { color: var(--text-secondary); } +.sort-arrow { margin-left: 4px; opacity: 0.5; font-size: 11px; } +.sort-arrow.active { opacity: 1; color: var(--accent-bright); } + +.table-wrap tbody tr { + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s; +} +.table-wrap tbody tr:last-child { border-bottom: none; } +.table-wrap tbody tr:hover { background: var(--bg-overlay); } +.table-wrap td { padding: 14px 16px; vertical-align: middle; } + +.op-name { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 13px; font-weight: 500; color: var(--text-primary); } +.op-display { font-size: 12px; color: var(--text-muted); margin-top: 3px; } + +/* ── Badges ── */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: var(--radius-sm); + font-size: 11.5px; + font-weight: 700; + white-space: nowrap; + border: 1px solid; + letter-spacing: 0.02em; +} +.badge-rest { color: var(--blue); background: var(--blue-bg); border-color: var(--blue-border); } +.badge-graphql { color: var(--purple); background: var(--purple-bg); border-color: var(--purple-border); } +.badge-grpc { color: #14b8a6; background: rgba(13,148,136,0.1); border-color: rgba(13,148,136,0.25); } + +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: var(--radius-sm); + font-size: 12.5px; + font-weight: 500; +} +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} +.status-active { color: var(--green); background: var(--green-bg); } +.status-active .status-dot { background: var(--green); box-shadow: 0 0 0 2px rgba(63,185,80,0.2); } +.status-draft { color: var(--amber); background: var(--amber-bg); } +.status-draft .status-dot { background: var(--amber); } +.status-error { color: var(--red); background: var(--red-bg); } +.status-error .status-dot { background: var(--red); box-shadow: 0 0 0 2px rgba(248,81,73,0.2); } +.status-inactive { color: var(--text-muted); background: var(--bg-overlay); } +.status-inactive .status-dot { background: var(--text-muted); } + +.target-url-cell { + display: flex; + align-items: center; + gap: 6px; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 12px; + color: var(--text-secondary); + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.target-url-cell a { color: inherit; text-decoration: none; } +.target-url-cell svg { flex-shrink: 0; opacity: 0.5; } + +.date-cell { font-size: 13px; color: var(--text-muted); white-space: nowrap; } + +.row-actions { display: flex; align-items: center; gap: 4px; justify-content: flex-end; } +.row-btn { + padding: 5px 11px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-surface); + font-size: 12.5px; + color: var(--text-secondary); + transition: all 0.1s; +} +.row-btn:hover { border-color: var(--bg-muted); color: var(--text-primary); background: var(--bg-overlay); } +.row-btn.danger:hover { border-color: var(--red-border); color: var(--red); background: var(--red-bg); } + +.row-btn-edit { + width: 34px; + height: 34px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); +} + +/* ── Empty state ── */ +.empty-state { + padding: 60px 20px; + text-align: center; + color: var(--text-muted); +} +.empty-state h3 { font-size: 16px; color: var(--text-secondary); margin-bottom: 8px; } +.empty-state p { font-size: 13.5px; } + +/* ── Table footer / pagination ── */ +.table-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-top: 1px solid var(--border); + background: var(--bg-canvas); +} +.table-footer-info { font-size: 13px; color: var(--text-muted); } + +.pagination { display: flex; align-items: center; gap: 4px; } +.page-btn { + width: 32px; + height: 32px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-surface); + font-size: 13px; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.1s; +} +.page-btn:hover:not(:disabled) { border-color: var(--bg-muted); color: var(--text-primary); } +.page-btn.active { background: var(--accent); color: #fff; border-color: var(--accent-dim); font-weight: 600; } +.page-btn:disabled { opacity: 0.3; cursor: not-allowed; } + +/* ── Banner ── */ +.banner { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 16px; + border-radius: var(--radius); + background: rgba(13,148,136,0.07); + border: 1px solid rgba(13,148,136,0.2); + color: var(--accent-bright); + font-size: 13.5px; + margin-bottom: 24px; +} +.banner-close { + margin-left: auto; + background: none; + border: none; + color: var(--accent-bright); + font-size: 16px; + opacity: 0.6; + line-height: 1; +} +.banner-close:hover { opacity: 1; } + +/* ═══════════════ RESPONSIVE ═══════════════ */ + +/* Tablet: ≤ 960px */ +@media (max-width: 960px) { + .stats-row { grid-template-columns: repeat(2, 1fr); } + .col-url { display: none; } + .results-count { display: none; } +} + +/* Narrow / mobile: ≤ 720px */ +@media (max-width: 720px) { + /* --- Скрываем колонку CREATED --- */ + .col-created { display: none; } + + /* --- Filter bar → grid --- */ + .filter-bar { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + } + /* Search во всю ширину */ + .search-box { + grid-column: 1 / -1; + width: 100%; + } + /* Каждый dropdown занимает ячейку */ + .filter-dropdown { + width: 100%; + } + .filter-chip { + width: 100%; + justify-content: space-between; + } + /* Sort справа, во всю строку */ + .filter-right { + grid-column: 1 / -1; + margin-left: 0; + justify-content: flex-end; + } + .sort-chip { white-space: nowrap; } + + /* action column compact */ + .row-actions { justify-content: center; } + + /* --- Таблица --- */ + .table-wrap td { padding: 12px 10px; } + .table-wrap th { padding: 8px 10px; } +} + +/* Mobile: ≤ 480px */ +@media (max-width: 480px) { + .tab-item { padding: 10px 8px; font-size: 13px; gap: 5px; } + + .page-header { flex-direction: column; gap: 12px; align-items: flex-start; } + .btn-new { width: 100%; justify-content: center; } + +} + +/* ── Active filter chips ── */ +.active-filters { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 8px 0 4px; +} +.active-filter-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px 3px 10px; + background: rgba(13,148,136,0.12); + border: 1px solid rgba(13,148,136,0.3); + border-radius: 20px; + font-size: 12px; + color: var(--accent); +} +.active-filter-chip-remove { + display: flex; + align-items: center; + background: none; + border: none; + padding: 1px; + cursor: pointer; + color: var(--accent); + opacity: 0.7; + border-radius: 50%; + transition: opacity 0.1s; +} +.active-filter-chip-remove:hover { opacity: 1; } +.active-filter-clear-all { + background: none; + border: none; + font-size: 12px; + color: var(--text-muted); + cursor: pointer; + padding: 3px 6px; + border-radius: 4px; + transition: color 0.1s; +} +.active-filter-clear-all:hover { color: var(--text-secondary); } + +/* ── Operation agent badges ── */ +.op-agent-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} +.op-agent-badge { + display: inline-block; + padding: 1px 6px; + font-size: 10px; + font-weight: 500; + background: rgba(99,102,241,0.12); + border: 1px solid rgba(99,102,241,0.25); + border-radius: 3px; + color: #818cf8; + white-space: nowrap; +} + +/* ── Row delete button ── */ +.row-btn-delete { + color: var(--text-muted); + transition: color 0.1s, background 0.1s; +} +.row-btn-delete:hover { + color: var(--red, #f87171); + background: rgba(248,113,113,0.08); +} diff --git a/apps/ui/css/layout.css b/apps/ui/css/layout.css new file mode 100644 index 0000000..2092915 --- /dev/null +++ b/apps/ui/css/layout.css @@ -0,0 +1,231 @@ +/* ── Navbar ── */ +.navbar { + height: 60px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 40px; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 1px 0 var(--border-subtle), 0 4px 16px rgba(0,0,0,0.4); +} + +.nav-logo { + display: flex; + align-items: center; + gap: 9px; + margin-right: 36px; + text-decoration: none; +} +.nav-logo-mark { + width: 28px; + height: 28px; + background: linear-gradient(135deg, #0d9488, #0891b2); + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 13px; + font-weight: 800; + flex-shrink: 0; +} +.nav-logo-text { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.4px; + color: var(--text-primary); +} + +.nav-links { + display: flex; + align-items: center; + gap: 2px; + flex: 1; +} +.nav-link { + padding: 6px 14px; + border-radius: var(--radius); + font-size: 14px; + font-weight: 450; + color: var(--text-muted); + text-decoration: none; + border: none; + background: none; + transition: all 0.1s; +} +.nav-link:hover { background: var(--bg-overlay); color: var(--text-secondary); } +.nav-link.active { color: var(--text-primary); font-weight: 550; background: var(--bg-overlay); } + +.nav-right { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; +} +.nav-kbd { + display: flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 4px 9px; + font-size: 12px; + color: var(--text-muted); + background: var(--bg-overlay); + cursor: pointer; +} +.nav-icon-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); + border: none; + background: none; + color: var(--text-muted); + transition: all 0.1s; + position: relative; +} +.nav-icon-btn:hover { background: var(--bg-overlay); color: var(--text-secondary); } +.nav-divider { width: 1px; height: 20px; background: var(--border); } + +.nav-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, #0d9488, #6366f1); + color: #fff; + font-size: 11.5px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: 2px solid transparent; + transition: border-color 0.15s; +} +.nav-avatar:hover { border-color: var(--accent); } + +/* ── User dropdown ── */ +.user-menu { + position: relative; +} +.user-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 180px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + overflow: hidden; + z-index: 200; +} +.user-dropdown-header { + padding: 12px 14px; + border-bottom: 1px solid var(--border-subtle); +} +.user-dropdown-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} +.user-dropdown-role { + font-size: 11.5px; + color: var(--text-muted); + margin-top: 2px; +} +.user-dropdown-item { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 14px; + font-size: 13.5px; + color: var(--text-secondary); + border: none; + background: none; + width: 100%; + text-align: left; + transition: all 0.1s; +} +.user-dropdown-item:hover { background: var(--bg-overlay); color: var(--text-primary); } +.user-dropdown-item.danger:hover { background: var(--red-bg); color: var(--red); } +.user-dropdown-item svg { flex-shrink: 0; opacity: 0.7; } + +/* ── Page container ── */ +.page { + max-width: 1160px; + margin: 0 auto; + padding: 36px 40px 60px; +} + +/* ── Hamburger button (hidden on desktop) ── */ +.nav-hamburger { + display: none; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 5px; + width: 32px; + height: 32px; + border: none; + background: none; + cursor: pointer; + padding: 4px; + border-radius: var(--radius); + transition: background 0.1s; + order: 10; /* after other right items */ +} +.nav-hamburger:hover { background: var(--bg-overlay); } +.nav-hamburger span { + display: block; + width: 18px; + height: 1.5px; + background: var(--text-muted); + border-radius: 1px; + transition: all 0.2s; + transform-origin: center; +} +.nav-hamburger.open span:nth-child(1) { transform: translateY(6.5px) rotate(45deg); } +.nav-hamburger.open span:nth-child(2) { opacity: 0; } +.nav-hamburger.open span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); } + +/* ── Mobile nav dropdown ── */ +.mobile-nav { + display: none; + position: sticky; + top: 60px; + z-index: 99; + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + padding: 8px 16px 12px; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +} +.mobile-nav-link { + display: block; + padding: 10px 12px; + border-radius: var(--radius); + font-size: 14px; + font-weight: 500; + color: var(--text-muted); + text-decoration: none; + transition: all 0.1s; +} +.mobile-nav-link:hover { background: var(--bg-overlay); color: var(--text-secondary); } +.mobile-nav-link.active { color: var(--text-primary); background: var(--bg-overlay); } + +/* ── Responsive breakpoints ── */ +@media (max-width: 720px) { + .navbar { padding: 0 16px; } + .nav-links { display: none; } + .nav-kbd { display: none; } + .nav-hamburger { display: flex; } + .mobile-nav { display: block; } + + .page { padding: 20px 16px 40px; } +} diff --git a/apps/ui/css/login.css b/apps/ui/css/login.css new file mode 100644 index 0000000..3cb948c --- /dev/null +++ b/apps/ui/css/login.css @@ -0,0 +1,192 @@ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + radial-gradient(ellipse 70% 50% at 50% -20%, rgba(13,148,136,0.12) 0%, transparent 70%), + var(--bg-canvas); +} + +.login-card { + width: 100%; + max-width: 380px; +} + +.login-logo { + display: flex; + align-items: center; + gap: 10px; + justify-content: center; + margin-bottom: 36px; +} + +.login-logo-mark { + width: 36px; + height: 36px; + background: linear-gradient(135deg, #0d9488, #0891b2); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 17px; + font-weight: 800; +} + +.login-logo-text { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.5px; + color: var(--text-primary); +} + +.login-box { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 32px; + box-shadow: 0 8px 40px rgba(0,0,0,0.4); +} + +.login-heading { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.4px; + color: var(--text-primary); + margin-bottom: 4px; +} + +.login-sub { + font-size: 13.5px; + color: var(--text-muted); + margin-bottom: 28px; +} + +.field { + margin-bottom: 16px; +} + +.field-label { + display: block; + font-size: 12.5px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.field-input { + width: 100%; + padding: 9px 12px; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 7px; + font-family: 'Inter', sans-serif; + font-size: 13.5px; + color: var(--text-primary); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.field-input::placeholder { color: var(--text-muted); } + +.field-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(13,148,136,0.25); +} + +.field-footer { + display: flex; + justify-content: flex-end; + margin-top: 5px; +} + +.field-link { + font-size: 12px; + color: var(--accent); + text-decoration: none; +} + +.field-link:hover { text-decoration: underline; } + +.btn-signin { + width: 100%; + padding: 10px; + margin-top: 8px; + background: var(--accent); + color: #fff; + border: 1px solid var(--accent-dim); + border-radius: 7px; + font-family: 'Inter', sans-serif; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + box-shadow: 0 1px 4px rgba(13,148,136,0.4); +} + +.btn-signin:hover { + background: var(--accent-dim); + box-shadow: 0 2px 10px rgba(13,148,136,0.5), 0 0 0 3px rgba(13,148,136,0.25); +} + +.btn-signin:active { transform: translateY(1px); } + +.login-error { + display: none; + background: var(--red-bg); + border: 1px solid var(--red-border); + border-radius: 7px; + padding: 10px 14px; + font-size: 13px; + color: var(--red); + margin-bottom: 16px; +} + +.login-footer { + text-align: center; + margin-top: 20px; + font-size: 12.5px; + color: var(--text-muted); +} + +.login-footer a { color: var(--accent); text-decoration: none; } +.login-footer a:hover { text-decoration: underline; } + +.login-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 22px 0; +} + +.login-divider-line { flex: 1; height: 1px; background: var(--border); } +.login-divider-text { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; } + +.btn-sso { + width: 100%; + padding: 9px; + background: var(--bg-overlay); + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: 7px; + font-family: 'Inter', sans-serif; + font-size: 13.5px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; +} + +.btn-sso:hover { background: var(--bg-muted); color: var(--text-primary); } + +.version-badge { + text-align: center; + margin-top: 28px; + font-size: 11.5px; + color: var(--text-muted); +} diff --git a/apps/ui/css/logs.css b/apps/ui/css/logs.css new file mode 100644 index 0000000..e3f4a04 --- /dev/null +++ b/apps/ui/css/logs.css @@ -0,0 +1,125 @@ +.log-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + border-bottom: 1px solid var(--border-subtle); + flex-wrap: wrap; +} + +.live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--green); + animation: pulse 1.8s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(63,185,80,0.6); } + 50% { opacity: 0.7; box-shadow: 0 0 0 5px rgba(63,185,80,0); } +} + +.live-label { + font-size: 11.5px; + font-weight: 600; + color: var(--green); + letter-spacing: 0.2px; +} + +.toolbar-sep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; } + +.time-range-select { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 5px 28px 5px 10px; + font-family: 'Inter', sans-serif; + font-size: 12.5px; + color: var(--text-secondary); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + cursor: pointer; + outline: none; +} + +.time-range-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); +} + +.log-op-name { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + color: var(--text-muted); + background: var(--bg-overlay); + border: 1px solid var(--border-subtle); + padding: 1px 7px; + border-radius: 4px; + white-space: nowrap; +} + +.log-status { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + font-weight: 600; +} + +.log-status.ok { color: var(--green); } +.log-status.err { color: var(--red); } +.log-status.warn { color: var(--amber); } + +.log-entry-expanded { + padding: 10px 20px 16px calc(20px + 152px + 12px + 58px + 12px); + background: var(--bg-canvas); + border-bottom: 1px solid var(--border-subtle); + display: none; +} + +.log-entry-expanded.open { display: block; } + +.log-detail-block { + background: #161b22; + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 14px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: #c9d1d9; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-all; +} + +.log-detail-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 6px; + margin-top: 12px; +} + +.log-detail-label:first-child { margin-top: 0; } + +.refresh-btn { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 12.5px; + color: var(--text-muted); + cursor: pointer; + font-family: 'Inter', sans-serif; + transition: all 0.12s; +} + +.refresh-btn:hover { color: var(--text-secondary); background: var(--bg-muted); } diff --git a/apps/ui/css/pages.css b/apps/ui/css/pages.css new file mode 100644 index 0000000..1fb23f3 --- /dev/null +++ b/apps/ui/css/pages.css @@ -0,0 +1,1458 @@ +/* ══════════════════════════════════════════════════ + SHARED INNER-PAGE STYLES + Used by: api-keys, logs, usage, settings +══════════════════════════════════════════════════ */ + +/* ── Dropdown divider (used in navbar and elsewhere) ── */ +.dropdown-divider { + height: 1px; + background: var(--border-subtle); + margin: 4px 0; +} + +/* ══════════════════════════════════════════════════ + PAGE HEADER +══════════════════════════════════════════════════ */ +.page-header { + display: flex; + align-items: flex-start; + gap: 16px; + margin-bottom: 28px; +} + +.page-header-text { flex: 1; } + +.page-title { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.4px; + color: var(--text-primary); + line-height: 1.2; +} + +.page-subtitle { + font-size: 13.5px; + color: var(--text-muted); + margin-top: 4px; + line-height: 1.5; +} + +.page-header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* ══════════════════════════════════════════════════ + BUTTONS +══════════════════════════════════════════════════ */ +.btn-primary { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 16px; + background: var(--accent); + color: #fff; + border: 1px solid var(--accent-dim); + border-radius: var(--radius); + font-size: 13.5px; + font-weight: 500; + font-family: 'Inter', sans-serif; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.btn-primary:hover { + background: var(--accent-dim); + box-shadow: 0 0 0 3px var(--accent-ring); +} + +.btn-secondary { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 16px; + background: var(--bg-overlay); + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13.5px; + font-weight: 500; + font-family: 'Inter', sans-serif; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.btn-secondary:hover { background: var(--bg-muted); color: var(--text-primary); } + +.btn-danger { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 16px; + background: transparent; + color: var(--red); + border: 1px solid rgba(248, 81, 73, 0.3); + border-radius: var(--radius); + font-size: 13.5px; + font-weight: 500; + font-family: 'Inter', sans-serif; + cursor: pointer; + transition: all 0.15s; +} + +.btn-danger:hover { background: var(--red-bg); border-color: var(--red-border); } + +.btn-icon { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; + flex-shrink: 0; +} + +.btn-icon:hover { background: var(--bg-muted); color: var(--text-secondary); } + +.btn-icon.danger:hover { background: var(--red-bg); color: var(--red); border-color: var(--red-border); } + +/* ══════════════════════════════════════════════════ + SECTION CARD +══════════════════════════════════════════════════ */ +.section-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 20px; +} + +.section-card-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 12px; +} + +.section-card-title { + font-size: 13.5px; + font-weight: 600; + color: var(--text-primary); + flex: 1; +} + +.section-card-subtitle { + font-size: 12px; + color: var(--text-muted); + margin-top: 1px; +} + +.section-card-body { padding: 20px; } + +/* ══════════════════════════════════════════════════ + CALLOUT / INFO BOX +══════════════════════════════════════════════════ */ +.callout { + display: flex; + gap: 12px; + padding: 13px 16px; + border-radius: var(--radius); + margin-bottom: 20px; + border: 1px solid; + font-size: 13px; + line-height: 1.55; +} + +.callout.info { + background: rgba(88, 166, 255, 0.07); + border-color: var(--blue-border); + color: var(--text-secondary); +} + +.callout.warning { + background: var(--amber-bg); + border-color: var(--amber-border); + color: var(--text-secondary); +} + +.callout.danger { + background: var(--red-bg); + border-color: var(--red-border); + color: var(--text-secondary); +} + +.callout-icon { flex-shrink: 0; margin-top: 1px; } +.callout strong { color: var(--text-primary); } + +/* ══════════════════════════════════════════════════ + STAT CARDS +══════════════════════════════════════════════════ */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px 18px; +} + +.stat-label { + font-size: 11.5px; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.stat-value { + font-size: 28px; + font-weight: 700; + letter-spacing: -1px; + color: var(--text-primary); + line-height: 1; + margin-bottom: 4px; + font-variant-numeric: tabular-nums; +} + +.stat-delta { + font-size: 12px; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 4px; +} + +.stat-delta.up { color: var(--green); } +.stat-delta.down { color: var(--red); } + +/* ══════════════════════════════════════════════════ + DATA TABLE +══════════════════════════════════════════════════ */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13.5px; +} + +.data-table th { + padding: 10px 16px; + text-align: left; + font-size: 11.5px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.4px; + background: var(--bg-canvas); + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.data-table td { + padding: 12px 16px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; +} + +.data-table tbody tr:last-child td { border-bottom: none; } + +.data-table tbody tr:hover td { background: var(--bg-overlay); } + +.data-table .col-name { color: var(--text-primary); font-weight: 500; } +.data-table .col-mono { + font-family: 'JetBrains Mono', monospace; + font-size: 12.5px; + color: var(--text-secondary); +} + +.data-table .col-actions { + text-align: right; + white-space: nowrap; +} + +.data-table .col-actions { display: flex; justify-content: flex-end; gap: 6px; } + +/* ══════════════════════════════════════════════════ + BADGES +══════════════════════════════════════════════════ */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + font-size: 11.5px; + font-weight: 500; + border: 1px solid; + white-space: nowrap; +} + +.badge-active { background: var(--green-bg); border-color: var(--green-border); color: var(--green); } +.badge-revoked { background: var(--red-bg); border-color: var(--red-border); color: var(--red); } +.badge-draft { background: var(--amber-bg); border-color: var(--amber-border); color: var(--amber); } +.badge-scope { + background: var(--bg-overlay); + border-color: var(--border); + color: var(--text-muted); + font-size: 10.5px; + padding: 1px 6px; +} + +.log-level { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.3px; + text-transform: uppercase; + border: 1px solid; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.log-level.info { background: rgba(88,166,255,0.08); border-color: var(--blue-border); color: var(--blue); } +.log-level.warn { background: var(--amber-bg); border-color: var(--amber-border); color: var(--amber); } +.log-level.error { background: var(--red-bg); border-color: var(--red-border); color: var(--red); } +.log-level.debug { background: var(--bg-overlay); border-color: var(--border); color: var(--text-muted); } + +/* ══════════════════════════════════════════════════ + PAGE TABS +══════════════════════════════════════════════════ */ +.page-tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--border); + margin-bottom: 20px; +} + +.page-tab { + padding: 8px 16px; + font-size: 13.5px; + font-weight: 450; + color: var(--text-muted); + border: none; + background: none; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: all 0.15s; + font-family: 'Inter', sans-serif; + display: flex; + align-items: center; + gap: 7px; +} + +.page-tab:hover { color: var(--text-secondary); } +.page-tab.active { color: var(--text-primary); border-bottom-color: var(--accent); font-weight: 550; } + +.page-tab-count { + font-size: 11px; + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0 6px; + color: var(--text-muted); + font-weight: 500; + min-width: 18px; + text-align: center; +} + +.page-tab.active .page-tab-count { + background: var(--accent-glow); + border-color: rgba(13,148,136,0.2); + color: var(--accent); +} + +/* ══════════════════════════════════════════════════ + FILTER BAR +══════════════════════════════════════════════════ */ +.filter-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.filter-bar-search { + position: relative; + flex: 1; + min-width: 180px; + max-width: 320px; +} + +.filter-bar-search svg { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + pointer-events: none; +} + +.filter-bar-search input { + width: 100%; + padding: 7px 12px 7px 32px; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius); + font-family: 'Inter', sans-serif; + font-size: 13px; + color: var(--text-primary); + outline: none; + transition: border-color 0.15s; +} + +.filter-bar-search input::placeholder { color: var(--text-muted); } +.filter-bar-search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); } + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 12.5px; + font-weight: 450; + color: var(--text-muted); + cursor: pointer; + transition: all 0.12s; + font-family: 'Inter', sans-serif; +} + +.filter-chip:hover { background: var(--bg-muted); color: var(--text-secondary); } +.filter-chip.active { background: var(--accent-glow); border-color: rgba(13,148,136,0.3); color: var(--accent); } + +/* ══════════════════════════════════════════════════ + LOG ENTRY +══════════════════════════════════════════════════ */ +.log-list { + display: flex; + flex-direction: column; +} + +.log-entry { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 20px; + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s; + font-size: 13px; + cursor: pointer; +} + +.log-entry:last-child { border-bottom: none; } +.log-entry:hover { background: var(--bg-overlay); } + +.log-time { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + color: var(--text-muted); + white-space: nowrap; + padding-top: 2px; + min-width: 152px; + flex-shrink: 0; +} + +.log-msg { + flex: 1; + line-height: 1.5; + color: var(--text-secondary); + word-break: break-word; +} + +.log-msg strong { color: var(--text-primary); font-weight: 500; } + +.log-duration { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; + padding-top: 2px; + min-width: 54px; + text-align: right; +} + +.log-duration.slow { color: var(--amber); } +.log-duration.error { color: var(--red); } + +/* ══════════════════════════════════════════════════ + SETTINGS LAYOUT +══════════════════════════════════════════════════ */ +.settings-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 32px; + align-items: flex-start; +} + +.settings-nav { + position: sticky; + top: 80px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.settings-nav-item { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 12px; + border-radius: var(--radius); + font-size: 13.5px; + color: var(--text-muted); + text-decoration: none; + transition: all 0.1s; + cursor: pointer; + border: none; + background: none; + font-family: 'Inter', sans-serif; + width: 100%; + text-align: left; +} + +.settings-nav-item svg { flex-shrink: 0; opacity: 0.7; } +.settings-nav-item:hover { background: var(--bg-overlay); color: var(--text-secondary); } +.settings-nav-item.active { background: var(--bg-overlay); color: var(--text-primary); font-weight: 500; } +.settings-nav-item.danger { color: var(--red); opacity: 0.8; } +.settings-nav-item.danger:hover { background: var(--red-bg); opacity: 1; } + +.settings-nav-divider { height: 1px; background: var(--border-subtle); margin: 6px 4px; } + +/* ── Form elements (for settings forms) ── */ +.field-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; } +.field-label { + font-size: 12.5px; + font-weight: 500; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 6px; +} +.field-hint { font-size: 12px; color: var(--text-muted); line-height: 1.55; } +.field-input, +.field-select, +.field-textarea { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; + font-family: 'Inter', sans-serif; + font-size: 13.5px; + color: var(--text-primary); + width: 100%; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} +.field-input::placeholder, .field-textarea::placeholder { color: var(--text-muted); } +.field-input:focus, .field-select:focus, .field-textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); +} +.field-select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 11px center; + padding-right: 32px; + cursor: pointer; +} +.field-textarea { resize: vertical; min-height: 80px; line-height: 1.6; } +.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } + +/* ── Section divider ── */ +.settings-section-divider { + height: 1px; + background: var(--border); + margin: 24px 0; +} + +/* ── API key display ── */ +.key-reveal { + display: flex; + align-items: center; + gap: 8px; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + color: var(--text-secondary); + letter-spacing: 0.2px; +} + +.key-reveal .key-mask { flex: 1; letter-spacing: 2px; color: var(--text-muted); } +.key-reveal .key-copy { flex-shrink: 0; cursor: pointer; color: var(--text-muted); } +.key-reveal .key-copy:hover { color: var(--accent); } + +/* ══════════════════════════════════════════════════ + EMPTY STATE +══════════════════════════════════════════════════ */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 24px; + text-align: center; +} + +.empty-state-icon { + width: 44px; + height: 44px; + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + margin-bottom: 14px; +} + +.empty-state-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; +} + +.empty-state-text { + font-size: 13.5px; + color: var(--text-muted); + max-width: 320px; + line-height: 1.6; + margin-bottom: 20px; +} + +/* ══════════════════════════════════════════════════ + MODAL OVERLAY +══════════════════════════════════════════════════ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + z-index: 500; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + display: none; +} + +.modal-overlay.open { display: flex; } + +.modal { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 12px; + width: 100%; + max-width: 480px; + box-shadow: 0 8px 40px rgba(0,0,0,0.6); + overflow: hidden; +} + +.modal-header { + padding: 18px 20px 14px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 12px; +} + +.modal-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + flex: 1; +} + +.modal-close { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + border-radius: var(--radius); + transition: all 0.1s; +} +.modal-close:hover { background: var(--bg-overlay); color: var(--text-primary); } + +.modal-body { padding: 20px; } +.modal-footer { padding: 14px 20px; border-top: 1px solid var(--border-subtle); display: flex; justify-content: flex-end; gap: 8px; } + +/* ══════════════════════════════════════════════════ + RESPONSIVE +══════════════════════════════════════════════════ */ +@media (max-width: 960px) { + .stats-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 720px) { + .page-header { flex-direction: column; gap: 12px; } + .page-header-actions { width: 100%; } + .settings-layout { grid-template-columns: 1fr; } + .settings-nav { position: static; flex-direction: row; flex-wrap: wrap; } + .filter-bar { flex-direction: column; align-items: stretch; } + .filter-bar-search { max-width: none; } + .stats-grid { grid-template-columns: 1fr 1fr; } + .field-row { grid-template-columns: 1fr; } +} + +@media (max-width: 480px) { + .stats-grid { grid-template-columns: 1fr; } +} + +/* ── Language switcher ── */ +.lang-switcher { + display: flex; + gap: 8px; +} + +.lang-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 18px; + background: var(--bg-canvas); + border: 1.5px solid var(--border); + border-radius: 8px; + font-family: 'Inter', sans-serif; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} + +.lang-btn:hover { + border-color: var(--accent); + color: var(--text-primary); +} + +.lang-btn.active { + border-color: var(--accent); + background: var(--accent-glow); + color: #fff; + font-weight: 600; +} + +.lang-flag { + font-size: 16px; + line-height: 1; +} + + +/* ═══════════════════════════════════════════════════ + Workspace switcher +═══════════════════════════════════════════════════ */ +.ws-switcher { + position: relative; + display: flex; + align-items: center; + margin-left: 4px; +} + +.ws-switcher-trigger { + display: flex; + align-items: center; + gap: 7px; + padding: 4px 8px 4px 4px; + background: none; + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + transition: border-color 0.15s, background 0.15s; + max-width: 200px; + white-space: nowrap; +} +.ws-switcher-trigger:hover { border-color: var(--border-muted, #444c56); background: rgba(255,255,255,0.03); } + +.ws-dot { + width: 22px; + height: 22px; + border-radius: 5px; + background: #0d9488; + color: #fff; + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.ws-current-name { + overflow: hidden; + text-overflow: ellipsis; + max-width: 140px; +} + +.ws-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 230px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 8px 28px rgba(0,0,0,0.45); + z-index: 200; + overflow: hidden; +} + +.ws-dropdown-label { + padding: 10px 14px 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.ws-dropdown-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + cursor: pointer; + transition: background 0.1s; +} +.ws-dropdown-item:hover { background: rgba(255,255,255,0.04); } +.ws-dropdown-item.active { background: rgba(56,139,253,0.08); } + +.ws-item-dot { + width: 30px; + height: 30px; + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.ws-item-info { flex: 1; min-width: 0; } +.ws-item-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ws-item-role { font-size: 11px; color: var(--text-muted); } + +.ws-dropdown-footer { border-top: 1px solid var(--border-subtle); padding: 6px 8px; } +.ws-dropdown-create { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + background: none; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: var(--text-secondary); + transition: background 0.1s, color 0.1s; +} +.ws-dropdown-create:hover { background: rgba(255,255,255,0.04); color: var(--text-primary); } + + +/* ═══════════════════════════════════════════════════ + Agents page — info callout +═══════════════════════════════════════════════════ */ +.agents-info-callout { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: rgba(56,139,253,0.07); + border: 1px solid rgba(56,139,253,0.2); + border-radius: 8px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: 24px; +} +.agents-info-callout strong { color: var(--text-primary); } + + +/* ═══════════════════════════════════════════════════ + Agent cards grid +═══════════════════════════════════════════════════ */ +.agents-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 16px; + margin-bottom: 40px; +} + +.agent-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 14px; + transition: border-color 0.15s; +} +.agent-card:hover { border-color: var(--border-muted, #444c56); } + +.agent-card-header { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.agent-card-icon { + width: 38px; + height: 38px; + border-radius: 9px; + background: rgba(56,139,253,0.1); + border: 1px solid rgba(56,139,253,0.2); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--accent); +} + +.agent-card-title-wrap { flex: 1; min-width: 0; } +.agent-card-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.agent-card-slug { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +.agent-status-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 20px; + white-space: nowrap; + flex-shrink: 0; +} +.agent-status-active { background: rgba(63,185,80,0.15); color: #3fb950; border: 1px solid rgba(63,185,80,0.3); } +.agent-status-draft { background: rgba(139,148,158,0.15); color: #8b949e; border: 1px solid rgba(139,148,158,0.3); } +.agent-status-inactive{ background: rgba(248,81,73,0.1); color: #f85149; border: 1px solid rgba(248,81,73,0.2); } + +.agent-card-desc { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.55; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.agent-card-stats { + display: flex; + gap: 16px; + flex-wrap: wrap; +} +.agent-stat { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--text-muted); +} +.agent-stat-value { font-weight: 600; color: var(--text-secondary); } + +.agent-endpoint-row { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 6px; +} +.agent-endpoint-url { + flex: 1; + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.agent-endpoint-copy { + background: none; + border: none; + padding: 2px 4px; + cursor: pointer; + color: var(--text-muted); + border-radius: 4px; + display: flex; + align-items: center; + transition: color 0.1s, background 0.1s; + flex-shrink: 0; +} +.agent-endpoint-copy:hover { color: var(--accent); background: rgba(56,139,253,0.1); } + +.agent-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 12px; + border-top: 1px solid var(--border-subtle); +} +.agent-card-date { font-size: 11px; color: var(--text-muted); } + +.agent-card-actions { display: flex; gap: 6px; } +.agent-action-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + background: none; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.agent-action-btn:hover { border-color: var(--border-muted, #444c56); color: var(--text-primary); background: rgba(255,255,255,0.03); } +.agent-action-btn.danger:hover { border-color: var(--error, #f87171); color: var(--error, #f87171); background: rgba(248,113,113,0.06); } + + +/* ═══════════════════════════════════════════════════ + Drawer (side panel) +═══════════════════════════════════════════════════ */ +.drawer-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.45); + z-index: 150; + backdrop-filter: blur(2px); +} + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 520px; + max-width: 100vw; + background: var(--bg-surface); + border-left: 1px solid var(--border); + z-index: 151; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} +.drawer.open { transform: translateX(0); } + +.drawer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 24px 24px 20px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} +.drawer-title { font-size: 16px; font-weight: 600; color: var(--text-primary); } +.drawer-subtitle{ font-size: 13px; color: var(--text-muted); margin-top: 3px; } +.drawer-close { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px; + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + transition: border-color 0.15s, color 0.15s; + flex-shrink: 0; +} +.drawer-close:hover { border-color: var(--error, #f87171); color: var(--error, #f87171); } + +.drawer-body { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 28px; +} + +.drawer-section {} +.drawer-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; +} +.drawer-section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 14px; +} +.drawer-section-header .drawer-section-title { margin-bottom: 0; } +.drawer-section-count { + font-size: 12px; + font-weight: 500; + color: var(--accent); + background: rgba(56,139,253,0.1); + padding: 2px 8px; + border-radius: 20px; +} +.drawer-section-sub { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 12px; + line-height: 1.5; +} + +.drawer-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 16px 24px; + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; + background: var(--bg-surface); +} + + +/* ═══════════════════════════════════════════════════ + Agent status toggle (in drawer) +═══════════════════════════════════════════════════ */ +.agent-status-toggle { + display: flex; + gap: 0; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 8px; + padding: 3px; +} +.agent-status-opt { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 12px; + background: none; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.agent-status-opt.active { + background: var(--bg-surface); + color: var(--text-primary); + box-shadow: 0 1px 3px rgba(0,0,0,0.25); +} +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + + +/* ═══════════════════════════════════════════════════ + Operations picker (in drawer) +═══════════════════════════════════════════════════ */ +.ops-picker { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.ops-picker-search { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-canvas); +} +.ops-picker-search-input { + flex: 1; + background: none; + border: none; + outline: none; + font-size: 13px; + color: var(--text-primary); + font-family: inherit; +} +.ops-picker-search-input::placeholder { color: var(--text-muted); } +.ops-picker-clear { + background: none; + border: none; + padding: 2px; + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + border-radius: 3px; +} +.ops-picker-clear:hover { color: var(--text-secondary); } + +.ops-picker-list { + max-height: 260px; + overflow-y: auto; +} + +.ops-picker-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + cursor: pointer; + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s; +} +.ops-picker-item:last-child { border-bottom: none; } +.ops-picker-item:hover { background: rgba(255,255,255,0.03); } +.ops-picker-item.selected { background: rgba(56,139,253,0.07); } + +.ops-picker-check { + width: 16px; + height: 16px; + border: 1.5px solid var(--border); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: border-color 0.1s, background 0.1s; +} +.ops-picker-item.selected .ops-picker-check { + border-color: var(--accent); + background: rgba(56,139,253,0.12); +} + +.ops-picker-info { flex: 1; min-width: 0; } +.ops-picker-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ops-picker-slug { + font-family: var(--font-mono, monospace); + font-size: 10px; + color: var(--text-muted); + margin-top: 1px; +} + +.ops-picker-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} +.ops-status-active { background: #3fb950; } +.ops-status-draft { background: #8b949e; } +.ops-status-error { background: #f85149; } +.ops-status-inactive { background: #8b949e; } + +.ops-picker-empty { + padding: 20px; + text-align: center; + font-size: 13px; + color: var(--text-muted); +} + +.ops-picker-footer { + padding: 8px 12px; + border-top: 1px solid var(--border-subtle); + background: var(--bg-canvas); + font-size: 12px; + color: var(--text-secondary); + display: flex; + align-items: center; +} + + +/* ═══════════════════════════════════════════════════ + Agents — recommendation callout +═══════════════════════════════════════════════════ */ +.agents-rec-callout { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 10px; + padding: 10px 12px; + background: rgba(210,153,34,0.08); + border: 1px solid rgba(210,153,34,0.25); + border-radius: 7px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; +} +.agents-rec-callout svg { flex-shrink: 0; color: #d2991f; margin-top: 1px; } +.agents-rec-callout strong { color: var(--text-primary); } + + +/* ═══════════════════════════════════════════════════ + Settings — Members enhanced +═══════════════════════════════════════════════════ */ +.member-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border-subtle); +} +.member-row:last-child { border-bottom: none; } + +.member-last-active { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + min-width: 80px; + text-align: right; +} + +.member-role-select { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + padding: 4px 8px; + cursor: pointer; + outline: none; + transition: border-color 0.15s; + min-width: 110px; +} +.member-role-select:hover { border-color: var(--accent); } +.member-role-select:focus { border-color: var(--accent); } + +.member-remove-btn { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 6px; + cursor: pointer; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s, color 0.15s, background 0.15s; + flex-shrink: 0; +} +.member-remove-btn:hover { + border-color: var(--error, #f87171); + color: var(--error, #f87171); + background: rgba(248,113,113,0.07); +} + +.member-role-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 9px; + border-radius: 20px; + white-space: nowrap; + flex-shrink: 0; +} +.role-owner { background: rgba(56,139,253,0.15); color: #58a6ff; border: 1px solid rgba(56,139,253,0.3); } +.role-admin { background: rgba(139,92,246,0.15); color: #a78bfa; border: 1px solid rgba(139,92,246,0.3); } +.role-developer { background: rgba(63,185,80,0.12); color: #3fb950; border: 1px solid rgba(63,185,80,0.25); } +.role-viewer { background: rgba(139,148,158,0.15); color: #8b949e; border: 1px solid rgba(139,148,158,0.3); } + +.role-def-row { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 11px 20px; + border-bottom: 1px solid var(--border-subtle); +} +.role-def-row:last-child { border-bottom: none; } +.role-def-text { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; +} + +/* ── Workspace dropdown management links ── */ +.ws-dropdown-divider { + height: 1px; + background: var(--border-subtle); + margin: 4px 0; +} +.ws-dropdown-mgmt-link { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + font-size: 13px; + color: var(--text-secondary); + text-decoration: none; + transition: background 0.1s, color 0.1s; + cursor: pointer; +} +.ws-dropdown-mgmt-link:hover { + background: rgba(255,255,255,0.04); + color: var(--text-primary); +} +.ws-dropdown-mgmt-link svg { + flex-shrink: 0; + opacity: 0.7; +} diff --git a/apps/ui/css/settings.css b/apps/ui/css/settings.css new file mode 100644 index 0000000..452ddc1 --- /dev/null +++ b/apps/ui/css/settings.css @@ -0,0 +1,144 @@ +.avatar-upload { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 4px; +} + +.avatar-large { + width: 52px; + height: 52px; + border-radius: 50%; + background: linear-gradient(135deg, #0d9488, #6366f1); + color: #fff; + font-size: 18px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border: 2px solid var(--border); +} + +.avatar-upload-actions { flex: 1; } +.avatar-name { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 3px; } +.avatar-sub { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; } + +.save-bar { + position: sticky; + bottom: 0; + background: rgba(13,17,23,0.9); + backdrop-filter: blur(12px); + border-top: 1px solid var(--border); + padding: 12px 20px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-top: auto; + display: none; +} + +.save-bar.visible { display: flex; } + +.section-anchor { scroll-margin-top: 80px; } + +.danger-zone-action { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.danger-zone-action:last-child { border-bottom: none; } + +.danger-zone-text { flex: 1; } +.danger-zone-title { font-size: 13.5px; font-weight: 500; color: var(--text-primary); margin-bottom: 3px; } +.danger-zone-desc { font-size: 12.5px; color: var(--text-muted); line-height: 1.5; } + +.toggle-setting-row { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.toggle-setting-row:last-child { border-bottom: none; } + +.toggle-sm { + width: 36px; + height: 20px; + border-radius: 10px; + background: var(--bg-muted); + border: 1px solid var(--border); + position: relative; + flex-shrink: 0; + cursor: pointer; + transition: background 0.2s; +} + +.toggle-sm.on { background: var(--accent); border-color: var(--accent-dim); } + +.toggle-sm::after { + content: ''; + position: absolute; + left: 2px; top: 1px; + width: 16px; height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + transition: transform 0.2s; +} + +.toggle-sm.on::after { transform: translateX(16px); } + +.toggle-setting-text { flex: 1; } +.toggle-setting-label { font-size: 13.5px; font-weight: 500; color: var(--text-primary); } +.toggle-setting-desc { font-size: 12px; color: var(--text-muted); margin-top: 2px; } + +.member-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.member-row:last-child { border-bottom: none; } + +.member-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--bg-muted); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + color: var(--text-secondary); + flex-shrink: 0; +} + +.member-info { flex: 1; } +.member-name { font-size: 13.5px; color: var(--text-primary); font-weight: 500; } +.member-email { font-size: 12px; color: var(--text-muted); } + +.member-role-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid; +} + +.role-admin { background: rgba(13,148,136,0.1); border-color: rgba(13,148,136,0.25); color: var(--accent); } +.role-member { background: var(--bg-overlay); border-color: var(--border); color: var(--text-muted); } +.role-viewer { background: rgba(88,166,255,0.07); border-color: var(--blue-border); color: var(--blue); } diff --git a/apps/ui/css/usage.css b/apps/ui/css/usage.css new file mode 100644 index 0000000..9785ebe --- /dev/null +++ b/apps/ui/css/usage.css @@ -0,0 +1,122 @@ +.period-select { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 7px 28px 7px 11px; + font-family: 'Inter', sans-serif; + font-size: 13px; + color: var(--text-secondary); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + cursor: pointer; + outline: none; + transition: border-color 0.15s; +} + +.period-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); +} + +.chart-placeholder { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + height: 200px; + display: flex; + align-items: flex-end; + padding: 12px 16px; + gap: 4px; + overflow: hidden; + margin-bottom: 24px; + position: relative; +} + +.chart-bar { + flex: 1; + border-radius: 3px 3px 0 0; + min-width: 0; + transition: opacity 0.15s; + position: relative; + cursor: pointer; +} + +.chart-bar:hover { opacity: 0.8; } +.chart-bar:hover::after { + content: attr(data-tip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 5px; + padding: 4px 8px; + font-size: 11px; + color: var(--text-primary); + white-space: nowrap; + pointer-events: none; + margin-bottom: 4px; +} + +.chart-bar.success { background: linear-gradient(180deg, var(--accent) 0%, rgba(13,148,136,0.4) 100%); } +.chart-bar.error { background: linear-gradient(180deg, var(--red) 0%, rgba(248,81,73,0.3) 100%); } + +.chart-label { + position: absolute; + bottom: -20px; + left: 0; + right: 0; + text-align: center; + font-size: 10px; + color: var(--text-muted); +} + +.chart-wrap { + position: relative; + padding-bottom: 24px; +} + +.chart-y-grid { + position: absolute; + left: 0; right: 0; top: 0; bottom: 24px; + display: flex; + flex-direction: column; + justify-content: space-between; + pointer-events: none; +} + +.chart-y-line { + width: 100%; + height: 1px; + background: var(--border-subtle); +} + +.quota-bar-wrap { + height: 8px; + background: var(--bg-muted); + border-radius: 4px; + overflow: hidden; + margin: 8px 0 4px; +} + +.quota-bar-fill { + height: 100%; + border-radius: 4px; + background: linear-gradient(90deg, var(--accent), #7c3aed); +} + +.latency-bar { + display: flex; + height: 12px; + border-radius: 3px; + overflow: hidden; + gap: 2px; +} + +.latency-seg { border-radius: 2px; } +.latency-p50 { background: var(--accent); } +.latency-p95 { background: var(--amber); } +.latency-p99 { background: var(--red); } diff --git a/apps/ui/css/variables.css b/apps/ui/css/variables.css new file mode 100644 index 0000000..b48a1a7 --- /dev/null +++ b/apps/ui/css/variables.css @@ -0,0 +1,64 @@ +:root { + --bg-canvas: #0d1117; + --bg-surface: #161b22; + --bg-overlay: #21262d; + --bg-muted: #30363d; + --border: rgba(48, 54, 61, 0.9); + --border-subtle: rgba(33, 38, 45, 0.9); + + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #6e7681; + + --accent: #0d9488; + --accent-dim: #0f766e; + --accent-bright: #14b8a6; + --accent-glow: rgba(13, 148, 136, 0.15); + --accent-ring: rgba(13, 148, 136, 0.25); + + --red: #f85149; + --red-bg: rgba(248, 81, 73, 0.10); + --red-border: rgba(248, 81, 73, 0.25); + --amber: #d29922; + --amber-bg: rgba(210, 153, 34, 0.10); + --amber-border: rgba(210, 153, 34, 0.25); + --green: #3fb950; + --green-bg: rgba(63, 185, 80, 0.10); + --green-border: rgba(63, 185, 80, 0.25); + --blue: #58a6ff; + --blue-bg: rgba(88, 166, 255, 0.08); + --blue-border: rgba(88, 166, 255, 0.20); + --purple: #bc8cff; + --purple-bg: rgba(188, 140, 255, 0.08); + --purple-border: rgba(188, 140, 255, 0.20); + + --radius-sm: 5px; + --radius: 7px; + --radius-lg: 10px; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3); +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + min-width: 360px; + font-family: 'Inter', system-ui, sans-serif; + background: var(--bg-canvas); + color: var(--text-primary); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { color: inherit; text-decoration: none; } +button { cursor: pointer; font-family: inherit; } + +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--bg-muted); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #484f58; } +::selection { background: var(--accent-glow); color: var(--text-primary); } diff --git a/apps/ui/css/wizard.css b/apps/ui/css/wizard.css new file mode 100644 index 0000000..0cfa730 --- /dev/null +++ b/apps/ui/css/wizard.css @@ -0,0 +1,1884 @@ +/* ══════════════════════════════════════════════════ + WIZARD — shell +══════════════════════════════════════════════════ */ +.wizard-shell { + min-height: calc(100vh - 60px); + display: flex; + flex-direction: column; +} + +/* ══════════════════════════════════════════════════ + TOP PROGRESS STRIP +══════════════════════════════════════════════════ */ +.progress-strip { + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + padding: 0 40px; + display: flex; + align-items: center; + height: 56px; + gap: 20px; + position: sticky; + top: 60px; + z-index: 30; + box-shadow: 0 1px 0 var(--border-subtle), 0 4px 16px rgba(0, 0, 0, 0.4); +} + +.back-to-catalog-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + background: none; + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + font-family: 'Inter', sans-serif; + transition: all 0.15s; + white-space: nowrap; + text-decoration: none; +} + +.back-to-catalog-btn:hover { + background: var(--bg-overlay); + color: var(--text-primary); + border-color: var(--bg-muted); +} + +.progress-divider { + width: 1px; + height: 20px; + background: var(--border); + margin: 0 4px; +} + +.progress-label { + font-size: 13px; + color: var(--text-secondary); + white-space: nowrap; +} + +.progress-bar-wrap { + flex: 1; + height: 4px; + background: var(--bg-muted); + border-radius: 10px; + overflow: hidden; + min-width: 80px; +} + +.progress-bar-fill { + height: 100%; + width: 20%; + background: linear-gradient(90deg, var(--accent), #7c3aed); + border-radius: 10px; + transition: width 0.4s ease; + position: relative; +} + +.progress-bar-fill::after { + content: ''; + position: absolute; + right: -1px; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + border-radius: 50%; + background: #7c3aed; + box-shadow: 0 0 0 2px var(--bg-surface), 0 0 8px rgba(124, 58, 237, 0.6); +} + +.progress-pct { + font-size: 12px; + font-weight: 600; + color: var(--accent); + white-space: nowrap; + min-width: 34px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.progress-close { + margin-left: 4px; + width: 28px; + height: 28px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; +} + +.progress-close:hover { + background: var(--bg-overlay); + color: var(--text-primary); + border-color: var(--bg-muted); +} + +/* ══════════════════════════════════════════════════ + WIZARD BODY +══════════════════════════════════════════════════ */ +.wizard-body { + flex: 1; + display: flex; + max-width: 1100px; + width: 100%; + margin: 0 auto; + padding: 40px 40px 140px; + gap: 36px; + align-items: flex-start; +} + +/* ══════════════════════════════════════════════════ + STEP SIDEBAR +══════════════════════════════════════════════════ */ +.step-sidebar { + width: 248px; + flex-shrink: 0; + position: sticky; + top: 136px; +} + +.step-sidebar-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} + +.step-sidebar-header { + padding: 16px 18px 14px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 10px; +} + +.step-sidebar-logo-mark { + width: 24px; + height: 24px; + border-radius: 6px; + background: linear-gradient(135deg, var(--accent), #7c3aed); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 11px; + font-weight: 800; + flex-shrink: 0; +} + +.step-sidebar-brand { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.2px; +} + +.step-sidebar-brand span { + color: var(--text-muted); + font-weight: 400; +} + +.step-sidebar-body { + padding: 18px 14px 14px; +} + +.step-sidebar-section-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--text-muted); + padding: 0 4px; + margin-bottom: 14px; +} + +/* Step list */ +.steps-list { + display: flex; + flex-direction: column; + position: relative; +} + +.steps-list::before { + content: ''; + position: absolute; + left: 17px; + top: 32px; + bottom: 32px; + width: 1px; + background: var(--border); + z-index: 0; +} + +.step-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 9px 10px; + border-radius: 8px; + cursor: pointer; + position: relative; + z-index: 1; + transition: background 0.15s; + border: 1px solid transparent; +} + +.step-item:hover { background: var(--bg-overlay); } + +.step-item.active { + background: var(--accent-glow); + border-color: rgba(13, 148, 136, 0.2); +} + +.step-item.pending:hover { background: rgba(33, 38, 45, 0.6); } + +/* Step indicator circle */ +.step-indicator { + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 12px; + font-weight: 700; + border: 2px solid var(--step-pending); + background: var(--bg-overlay); + color: var(--text-muted); + transition: all 0.2s; + position: relative; + z-index: 2; +} + +.step-item.done .step-indicator { + background: var(--step-done); + border-color: var(--step-done); + color: var(--bg-canvas); + box-shadow: 0 0 0 3px rgba(63, 185, 80, 0.15); +} + +.step-item.active .step-indicator { + background: var(--bg-surface); + border-color: var(--step-active); + color: var(--accent); + box-shadow: 0 0 0 4px var(--accent-ring); + font-weight: 700; +} + +.step-item.pending .step-indicator { + background: var(--bg-overlay); + border-color: var(--step-pending); + color: var(--text-muted); +} + +/* Step text content */ +.step-content { + flex: 1; + min-width: 0; + padding-top: 3px; +} + +.step-number { + font-size: 10.5px; + color: var(--text-muted); + font-weight: 500; + margin-bottom: 1px; + letter-spacing: 0.2px; +} + +.step-name { + font-size: 13.5px; + font-weight: 500; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.step-item.done .step-name { color: var(--text-primary); } +.step-item.active .step-name { color: var(--accent-bright); font-weight: 600; } +.step-item.pending .step-name { color: var(--text-muted); } + +.step-status-text { + font-size: 11px; + margin-top: 2px; +} + +.step-item.done .step-status-text { color: var(--step-done); } +.step-item.active .step-status-text { color: var(--accent); } +.step-item.pending .step-status-text { color: var(--text-muted); } + +/* Sidebar footer help card */ +.sidebar-help { + margin-top: 4px; + background: var(--bg-overlay); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 14px 16px; +} + +.sidebar-help-title { + font-size: 12.5px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 6px; +} + +.sidebar-help-title svg { opacity: 0.7; flex-shrink: 0; } + +.sidebar-help-text { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.6; +} + +.sidebar-help-link { + color: var(--accent); + text-decoration: none; + font-weight: 500; +} + +.sidebar-help-link:hover { text-decoration: underline; } + +/* ══════════════════════════════════════════════════ + MAIN STEP PANEL +══════════════════════════════════════════════════ */ +.step-panel { + flex: 1; + min-width: 0; +} + +.step-panel-header { + margin-bottom: 28px; +} + +.step-panel-eyebrow { + display: flex; + align-items: center; + gap: 8px; + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; + color: var(--accent); + margin-bottom: 10px; +} + +.step-panel-eyebrow-line { + width: 20px; + height: 2px; + background: var(--accent); + border-radius: 1px; + flex-shrink: 0; +} + +.step-panel-title { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.6px; + color: var(--text-primary); + margin-bottom: 8px; + line-height: 1.2; +} + +.step-panel-subtitle { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.65; + max-width: 580px; +} + +/* ── Protocol cards ── */ +.protocol-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 24px; +} + +.protocol-card { + background: var(--bg-surface); + border: 1.5px solid var(--border); + border-radius: 10px; + padding: 20px 18px 18px; + cursor: pointer; + transition: all 0.18s ease; + position: relative; + overflow: hidden; +} + +.protocol-card:hover { + border-color: var(--accent); + background: rgba(13, 148, 136, 0.06); + transform: translateY(-1px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(13, 148, 136, 0.15); +} + +.protocol-card.selected { + border-color: var(--accent); + background: var(--accent-glow); + box-shadow: + 0 0 0 1px var(--accent), + 0 4px 24px rgba(0, 0, 0, 0.5), + 0 0 20px rgba(13, 148, 136, 0.12); +} + +.protocol-card-check { + position: absolute; + top: 12px; + right: 12px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--accent); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transform: scale(0.6); + transition: all 0.18s ease; + flex-shrink: 0; +} + +.protocol-card.selected .protocol-card-check { + opacity: 1; + transform: scale(1); +} + +.protocol-card-check svg { + width: 10px; + height: 10px; + stroke: #fff; + stroke-width: 2.5; + fill: none; +} + +.protocol-icon-wrap { + width: 44px; + height: 44px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 14px; + flex-shrink: 0; + transition: all 0.18s; +} + +.protocol-card.rest .protocol-icon-wrap { + background: rgba(88, 166, 255, 0.12); + border: 1px solid rgba(88, 166, 255, 0.2); +} + +.protocol-card.graphql .protocol-icon-wrap { + background: rgba(188, 140, 255, 0.12); + border: 1px solid rgba(188, 140, 255, 0.2); +} + +.protocol-card.grpc .protocol-icon-wrap { + background: rgba(13, 148, 136, 0.12); + border: 1px solid rgba(13, 148, 136, 0.2); +} + +.protocol-card.selected.rest .protocol-icon-wrap { + background: rgba(88, 166, 255, 0.18); + border-color: rgba(88, 166, 255, 0.35); +} + +.protocol-card.selected.graphql .protocol-icon-wrap { + background: rgba(188, 140, 255, 0.18); + border-color: rgba(188, 140, 255, 0.35); +} + +.protocol-card.selected.grpc .protocol-icon-wrap { + background: rgba(13, 148, 136, 0.2); + border-color: rgba(13, 148, 136, 0.4); +} + +.protocol-card-name { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 4px; + letter-spacing: -0.2px; +} + +.protocol-card.selected .protocol-card-name { color: #fff; } + +.protocol-card-tagline { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 14px; + line-height: 1.5; +} + +.protocol-card.selected .protocol-card-tagline { color: var(--accent-bright); } + +.protocol-card-tags { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.protocol-tag { + font-size: 10.5px; + font-weight: 500; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg-overlay); + color: var(--text-muted); + letter-spacing: 0.1px; + transition: all 0.15s; +} + +.protocol-card.selected .protocol-tag { + background: rgba(13, 148, 136, 0.12); + border-color: rgba(13, 148, 136, 0.3); + color: var(--accent-bright); +} + +.protocol-card.rest.selected .protocol-tag { + background: rgba(88, 166, 255, 0.1); + border-color: rgba(88, 166, 255, 0.25); + color: #58a6ff; +} + +.protocol-card.graphql.selected .protocol-tag { + background: rgba(188, 140, 255, 0.1); + border-color: rgba(188, 140, 255, 0.25); + color: #bc8cff; +} + +/* ── REST method picker ── */ +.method-section { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + margin-bottom: 20px; +} + +.method-section-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 10px; +} + +.method-section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.method-section-subtitle { + font-size: 12px; + color: var(--text-muted); + margin-left: auto; +} + +.method-section-body { + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.method-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; +} + +.method-btn { + padding: 10px 8px; + border-radius: 7px; + border: 1.5px solid var(--border); + background: var(--bg-overlay); + cursor: pointer; + transition: all 0.15s; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + font-family: 'Inter', sans-serif; +} + +.method-btn:hover { border-color: var(--bg-muted); background: rgba(33, 38, 45, 0.8); } + +.method-btn.selected-get { border-color: #3fb950; background: rgba(63, 185, 80, 0.08); } +.method-btn.selected-post { border-color: #58a6ff; background: rgba(88, 166, 255, 0.08); } +.method-btn.selected-put { border-color: #d29922; background: rgba(210, 153, 34, 0.08); } +.method-btn.selected-patch { border-color: #bc8cff; background: rgba(188, 140, 255, 0.08); } +.method-btn.selected-delete { border-color: #f85149; background: rgba(248, 81, 73, 0.08); } + +.method-verb { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + font-variant-numeric: tabular-nums; + color: var(--text-muted); +} + +.method-btn.selected-get .method-verb { color: #3fb950; } +.method-btn.selected-post .method-verb { color: #58a6ff; } +.method-btn.selected-put .method-verb { color: #d29922; } +.method-btn.selected-patch .method-verb { color: #bc8cff; } +.method-btn.selected-delete .method-verb { color: #f85149; } + +.method-label { font-size: 10px; color: var(--text-muted); } + +/* ── Info callout ── */ +.info-callout { + display: flex; + gap: 12px; + padding: 14px 16px; + background: rgba(13, 148, 136, 0.07); + border: 1px solid rgba(13, 148, 136, 0.2); + border-radius: 8px; +} + +.info-callout-icon { + width: 18px; + height: 18px; + flex-shrink: 0; + margin-top: 1px; + color: var(--accent); +} + +.info-callout-body { flex: 1; } + +.info-callout-title { + font-size: 12.5px; + font-weight: 600; + color: var(--accent-bright); + margin-bottom: 4px; +} + +.info-callout-text { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.65; +} + +.info-callout-text a { + color: var(--accent); + text-decoration: none; + font-weight: 500; +} + +.info-callout-text a:hover { text-decoration: underline; } + +/* ── Config card ── */ +.config-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + margin-bottom: 20px; +} + +.config-card-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: 12px; +} + +.config-card-header-icon { + width: 28px; + height: 28px; + border-radius: 7px; + background: var(--bg-overlay); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + flex-shrink: 0; +} + +.config-card-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.config-card-subtitle { + font-size: 12px; + color: var(--text-muted); + margin-top: 1px; +} + +.config-card-optional { + margin-left: auto; + font-size: 10.5px; + font-weight: 500; + color: var(--text-muted); + background: var(--bg-overlay); + border: 1px solid var(--border-subtle); + padding: 2px 8px; + border-radius: 10px; +} + +.config-card-body { + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Form fields */ +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.form-group { display: flex; flex-direction: column; gap: 6px; } + +.form-label { + font-size: 12.5px; + font-weight: 500; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 6px; +} + +.form-label-required { + font-size: 10px; + font-weight: 600; + padding: 1px 5px; + border-radius: 4px; + background: rgba(248, 81, 73, 0.12); + color: var(--red); + border: 1px solid rgba(248, 81, 73, 0.2); + letter-spacing: 0.2px; +} + +.form-label-optional { + font-size: 11px; + color: var(--text-muted); + font-weight: 400; +} + +.form-hint { + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.55; +} + +.form-input, +.form-textarea, +.form-select { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 6px; + padding: 9px 12px; + font-family: 'Inter', sans-serif; + font-size: 13.5px; + color: var(--text-primary); + width: 100%; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + appearance: none; + -webkit-appearance: none; +} + +.form-input::placeholder, +.form-textarea::placeholder { color: var(--text-muted); } + +.form-input:focus, +.form-textarea:focus, +.form-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-ring); +} + +.form-select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 11px center; + padding-right: 32px; + cursor: pointer; +} + +.form-textarea { + resize: vertical; + min-height: 90px; + line-height: 1.6; +} + +/* Toggle switch */ +.toggle-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 8px; + background: var(--bg-overlay); + border: 1px solid var(--border-subtle); + cursor: pointer; + transition: background 0.15s; +} + +.toggle-row:hover { background: rgba(33, 38, 45, 0.8); } + +.toggle { + width: 36px; + height: 20px; + border-radius: 10px; + background: var(--bg-muted); + border: 1px solid var(--border); + position: relative; + flex-shrink: 0; + transition: background 0.2s; + cursor: pointer; +} + +.toggle.on { + background: var(--accent); + border-color: var(--accent-dim); +} + +.toggle::after { + content: ''; + position: absolute; + left: 2px; + top: 1px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + transition: transform 0.2s ease; +} + +.toggle.on::after { transform: translateX(16px); } + +.toggle-text { flex: 1; } +.toggle-label { font-size: 13px; font-weight: 500; color: var(--text-primary); } +.toggle-desc { font-size: 11.5px; color: var(--text-muted); margin-top: 1px; } + +/* ══════════════════════════════════════════════════ + BOTTOM ACTION BAR — frosted dark glass +══════════════════════════════════════════════════ */ +.action-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: rgba(13, 17, 23, 0.82); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); + border-top: 1px solid rgba(48, 54, 61, 0.7); + z-index: 50; + box-shadow: + 0 -1px 0 rgba(255, 255, 255, 0.03), + 0 -8px 32px rgba(0, 0, 0, 0.6); +} + +.action-bar-inner { + max-width: 1100px; + margin: 0 auto; + padding: 0 40px; + height: 70px; + display: flex; + align-items: center; + gap: 12px; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 18px; + border-radius: 7px; + font-family: 'Inter', sans-serif; + font-size: 13.5px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + border: none; + white-space: nowrap; +} + +.btn-back { + background: var(--bg-overlay); + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.btn-back:hover { + background: var(--bg-muted); + color: var(--text-primary); + border-color: var(--bg-muted); +} + +.btn-save-draft { + background: none; + color: var(--text-muted); + border: none; + font-size: 13px; + font-weight: 400; + cursor: pointer; + font-family: 'Inter', sans-serif; + padding: 9px 12px; + border-radius: 6px; + transition: all 0.15s; +} + +.btn-save-draft:hover { + color: var(--text-secondary); + background: rgba(33, 38, 45, 0.6); +} + +.action-bar-spacer { flex: 1; } + +.step-counter { + font-size: 12.5px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.step-counter strong { + color: var(--text-secondary); + font-weight: 600; +} + +.btn-continue { + background: var(--accent); + color: #fff; + padding: 10px 24px; + font-size: 14px; + border: 1px solid var(--accent-dim); + box-shadow: 0 1px 4px rgba(13, 148, 136, 0.4); + transition: all 0.15s; +} + +.btn-continue:hover { + background: var(--accent-dim); + border-color: transparent; + box-shadow: + 0 2px 10px rgba(13, 148, 136, 0.5), + 0 0 0 3px var(--accent-ring); + transform: translateY(-1px); +} + +.btn-continue:active { + transform: translateY(0); + box-shadow: 0 1px 4px rgba(13, 148, 136, 0.3); +} + +/* ── Code editor ── */ +.code-block { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.code-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + background: #1e2330; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.code-dots { display: flex; gap: 5px; } +.code-dots span { width: 10px; height: 10px; border-radius: 50%; } +.code-dots span:nth-child(1) { background: #ff5f56; } +.code-dots span:nth-child(2) { background: #ffbd2e; } +.code-dots span:nth-child(3) { background: #27c93f; } + +.code-toolbar-label { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #8b949e; +} + +.code-textarea { + background: #161b22 !important; + color: #c9d1d9 !important; + font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace !important; + font-size: 12.5px !important; + line-height: 1.65 !important; + border: none !important; + border-radius: 0 !important; + resize: vertical; + min-height: 160px; +} + +.code-textarea:focus { + border-color: transparent !important; + box-shadow: none !important; +} + +/* ── Section divider ── */ +.section-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 8px 0; +} + +.section-divider-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--text-muted); + white-space: nowrap; +} + +.section-divider-line { + flex: 1; + height: 1px; + background: var(--border); +} + +/* ── Step pane ── */ +.step-pane { display: flex; flex-direction: column; } + +/* ── Monospace input variant ── */ +.input-mono { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 13px; + letter-spacing: -0.2px; +} + +/* ── Step-state colours (wizard-specific) ── */ +:root { + --step-done: var(--green); + --step-active: var(--accent); + --step-pending: var(--bg-muted); +} + +/* ══════════════════════════════════════════════ + Upstream selector (Step 3) + ══════════════════════════════════════════════ */ + +/* ── Combobox trigger ── */ +.upstream-combobox { + position: relative; +} + +.upstream-combobox-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 7px; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; + user-select: none; +} + +.upstream-combobox-trigger:hover { border-color: var(--accent); } + +.upstream-combobox.open .upstream-combobox-trigger { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.upstream-combobox-value { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} + +.upstream-combobox-placeholder { + font-size: 13.5px; + color: var(--text-muted); +} + +.upstream-combobox-chevron { + flex-shrink: 0; + transition: transform 0.2s; +} +.upstream-combobox.open .upstream-combobox-chevron { transform: rotate(180deg); } + +/* Value when upstream is selected (shown inside trigger) */ +.upstream-trigger-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + letter-spacing: -0.2px; +} + +.upstream-trigger-url { + font-size: 11.5px; + color: var(--text-muted); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + letter-spacing: -0.2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Dropdown panel ── */ +.upstream-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 200; + background: var(--bg-surface); + border: 1px solid var(--accent); + border-top: none; + border-bottom-left-radius: 7px; + border-bottom-right-radius: 7px; + box-shadow: 0 8px 24px rgba(0,0,0,0.35); + overflow: hidden; +} + +/* Search row */ +.upstream-search-wrap { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.upstream-search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-family: 'Inter', sans-serif; + font-size: 13px; + color: var(--text-primary); +} +.upstream-search-input::placeholder { color: var(--text-muted); } + +/* List of upstream items */ +.upstream-dropdown-list { + max-height: 220px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.upstream-dropdown-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + cursor: pointer; + transition: background 0.12s; +} +.upstream-dropdown-item:hover { background: rgba(13, 148, 136, 0.07); } +.upstream-dropdown-item.selected { background: rgba(13, 148, 136, 0.1); } + +.upstream-dropdown-item-info { flex: 1; min-width: 0; } + +.upstream-dropdown-item-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + letter-spacing: -0.2px; +} + +.upstream-dropdown-item-url { + font-size: 11px; + color: var(--text-muted); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + letter-spacing: -0.2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.upstream-dropdown-item-check { + opacity: 0; + flex-shrink: 0; +} +.upstream-dropdown-item.selected .upstream-dropdown-item-check { opacity: 1; } + +/* Empty state */ +.upstream-dropdown-empty { + padding: 16px 14px; + font-size: 12.5px; + color: var(--text-muted); + text-align: center; +} + +/* Footer: Register new */ +/* ── Register new upstream trigger row ── */ +.upstream-new-trigger { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border: 1.5px dashed var(--border); + border-radius: 8px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: var(--accent); + transition: border-color 0.15s, background 0.15s; + user-select: none; +} +.upstream-new-trigger:hover { + border-color: var(--accent); + background: rgba(13, 148, 136, 0.04); +} +.upstream-new-trigger.active { + border-style: solid; + border-color: var(--accent); + background: rgba(13, 148, 136, 0.06); +} + +.upstream-new-trigger-radio { + width: 16px; + height: 16px; + border-radius: 50%; + border: 1.5px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: border-color 0.15s; +} +.upstream-new-trigger.active .upstream-new-trigger-radio { + border-color: var(--accent); +} +.upstream-new-trigger-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); + opacity: 0; + transform: scale(0.5); + transition: opacity 0.15s, transform 0.15s; +} +.upstream-new-trigger.active .upstream-new-trigger-dot { + opacity: 1; + transform: scale(1); +} + +/* ── Auth badges ── */ +.upstream-auth-badge { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.2px; + padding: 2px 8px; + border-radius: 4px; + white-space: nowrap; + flex-shrink: 0; +} +.upstream-auth-badge.auth-bearer { + background: rgba(88, 166, 255, 0.1); + color: #58a6ff; + border: 1px solid rgba(88, 166, 255, 0.2); +} +.upstream-auth-badge.auth-apikey { + background: rgba(188, 140, 255, 0.1); + color: #bc8cff; + border: 1px solid rgba(188, 140, 255, 0.2); +} +.upstream-auth-badge.auth-none { + background: rgba(139, 148, 158, 0.08); + color: var(--text-muted); + border: 1px solid var(--border-subtle); +} + +/* ── Selected upstream preview (under combobox) ── */ +.upstream-preview { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--accent-glow); + border: 1px solid rgba(13, 148, 136, 0.3); + border-radius: 7px; +} + +.upstream-preview-name { + font-size: 13px; + font-weight: 600; + color: #fff; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + letter-spacing: -0.2px; +} + +.upstream-preview-url { + font-size: 11.5px; + color: var(--accent-bright, var(--accent)); + font-family: 'JetBrains Mono', 'Fira Code', monospace; + letter-spacing: -0.2px; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.upstream-preview-change { + font-size: 11.5px; + font-weight: 500; + color: var(--text-muted); + background: none; + border: 1px solid var(--border); + border-radius: 5px; + padding: 3px 10px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; + flex-shrink: 0; +} +.upstream-preview-change:hover { border-color: var(--text-secondary); color: var(--text-primary); } + +/* ── Register new upstream inline form ── */ +.upstream-new-form { + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.upstream-new-form-inner { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ── Small action buttons ── */ +.btn-primary-sm { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 16px; + font-family: 'Inter', sans-serif; + font-size: 12.5px; + font-weight: 600; + border-radius: 6px; + border: none; + cursor: pointer; + background: var(--accent); + color: #fff; + transition: opacity 0.15s; +} +.btn-primary-sm:hover { opacity: 0.88; } + +.btn-ghost-sm { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 16px; + font-family: 'Inter', sans-serif; + font-size: 12.5px; + font-weight: 500; + border-radius: 6px; + border: 1px solid var(--border); + cursor: pointer; + background: transparent; + color: var(--text-secondary); + transition: border-color 0.15s, color 0.15s; +} +.btn-ghost-sm:hover { border-color: var(--text-secondary); color: var(--text-primary); } + +/* ══════════════════════════════════════════════ + HTTP method picker (Step 4 REST) + ══════════════════════════════════════════════ */ + +.method-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; +} + +.method-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 14px 8px; + background: var(--bg-canvas); + border: 1.5px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; + font-family: 'Inter', sans-serif; +} + +.method-card:hover { + border-color: var(--accent); + background: rgba(13, 148, 136, 0.04); +} + +.method-card.active { + border-color: var(--accent); + background: var(--accent-glow); + box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15); +} + +.method-name { + font-size: 14px; + font-weight: 700; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + color: var(--text-primary); + letter-spacing: 0.5px; +} + +.method-card.active .method-name { color: #fff; } + +.method-desc { + font-size: 11px; + color: var(--text-muted); + font-weight: 400; +} + +.method-card.active .method-desc { color: var(--accent-bright, var(--accent)); } + +.method-callout { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 11px 14px; + background: rgba(13, 148, 136, 0.07); + border: 1px solid rgba(13, 148, 136, 0.25); + border-radius: 7px; + font-size: 12.5px; + color: var(--text-secondary); + line-height: 1.55; + margin-top: 4px; +} + +/* ══════════════════════════════════════════════ + GraphQL operation type cards (Step 4 GraphQL) + ══════════════════════════════════════════════ */ + +.gql-type-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.gql-type-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 16px 18px; + background: var(--bg-canvas); + border: 1.5px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; + text-align: left; + font-family: 'Inter', sans-serif; +} + +.gql-type-card:hover { + border-color: var(--accent); + background: rgba(13, 148, 136, 0.04); +} + +.gql-type-card.active { + border-color: var(--accent); + background: var(--accent-glow); + box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15); +} + +.gql-type-keyword { + font-size: 15px; + font-weight: 700; + font-family: 'JetBrains Mono', 'Fira Code', monospace; + color: var(--accent); + letter-spacing: -0.3px; +} + +.gql-type-card.active .gql-type-keyword { color: #5eead4; } + +.gql-type-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; +} + +.gql-type-card.active .gql-type-desc { color: var(--text-secondary); } + + +/* ══════════════════════════════════════════════ + Protocol card — disabled tag (Streaming) +══════════════════════════════════════════════ */ +.protocol-tag-disabled { + text-decoration: line-through; + color: var(--error, #f87171); + opacity: 0.75; +} + + +/* ══════════════════════════════════════════════ + gRPC — Proto upload +══════════════════════════════════════════════ */ +.proto-dropzone { + border: 1.5px dashed var(--border); + border-radius: 10px; + padding: 36px 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + background: var(--bg-canvas); +} +.proto-dropzone:hover, +.proto-dropzone.drag-over { + border-color: var(--accent); + background: rgba(56,139,253,0.05); +} +.proto-dropzone.drag-over { border-style: solid; } + +.proto-dropzone-title { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} +.proto-dropzone-sub { + font-size: 12px; + color: var(--text-muted); +} + +.proto-file-info { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: rgba(56,139,253,0.07); + border: 1px solid rgba(56,139,253,0.25); + border-radius: 8px; +} +.proto-file-name { + font-family: var(--font-mono, monospace); + font-size: 13px; + color: var(--accent); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.proto-file-size { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} +.proto-file-change { + background: none; + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text-secondary); + font-size: 11px; + padding: 3px 9px; + cursor: pointer; + white-space: nowrap; + transition: border-color 0.15s, color 0.15s; +} +.proto-file-change:hover { border-color: var(--accent); color: var(--accent); } + + +/* ══════════════════════════════════════════════ + gRPC — Service/method list +══════════════════════════════════════════════ */ +.proto-service-group { + padding-top: 16px; +} +.proto-service-group + .proto-service-group { + border-top: 1px solid var(--border-subtle); + margin-top: 4px; +} + +.proto-service-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-bottom: 10px; +} +.proto-service-label svg { opacity: 0.5; } + +.proto-method-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.proto-method-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 14px; + background: var(--bg-canvas); + border: 1.5px solid var(--border); + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: border-color 0.15s, background 0.15s; + min-width: 160px; +} +.proto-method-card:hover { + border-color: var(--border-muted, var(--accent)); + background: rgba(56,139,253,0.04); +} +.proto-method-card.active { + border-color: var(--accent); + background: rgba(56,139,253,0.08); +} + +.proto-method-name { + font-family: var(--font-mono, monospace); + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} +.proto-method-card.active .proto-method-name { color: var(--accent); } + +.proto-method-types { + font-size: 11px; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 4px; +} +.proto-method-types svg { opacity: 0.5; flex-shrink: 0; } + +.proto-empty { + padding: 24px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + + +/* ══════════════════════════════════════════════ + gRPC — Method signature detail +══════════════════════════════════════════════ */ +.proto-path-row { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 6px; +} +.proto-path-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + white-space: nowrap; +} +.proto-path-value { + font-family: var(--font-mono, monospace); + font-size: 12px; + color: var(--accent); + word-break: break-all; +} + +.proto-sig-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +@media (max-width: 680px) { + .proto-sig-grid { grid-template-columns: 1fr; } +} + +.proto-sig-col { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--border-subtle); + border-radius: 8px; + overflow: hidden; +} + +.proto-sig-col-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-subtle); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); +} +.proto-sig-col-header svg { opacity: 0.6; } + +.proto-type-badge { + font-family: var(--font-mono, monospace); + font-size: 11px; + background: rgba(56,139,253,0.12); + color: var(--accent); + padding: 1px 6px; + border-radius: 4px; + margin-left: auto; +} + +.proto-field-list { + padding: 6px 0; +} + +.proto-field-row { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 5px 12px; + font-size: 12px; + border-bottom: 1px solid var(--border-subtle); +} +.proto-field-row:last-child { border-bottom: none; } + +.proto-field-name { + font-family: var(--font-mono, monospace); + color: var(--text-primary); + font-size: 12px; +} +.proto-field-type { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-muted); +} + +.proto-field-empty { + padding: 16px 12px; + font-size: 12px; + color: var(--text-secondary); + text-align: center; + line-height: 1.5; +} + + +/* ══════════════════════════════════════════════ + gRPC — Source tabs +══════════════════════════════════════════════ */ +.grpc-source-tabs { + display: flex; + gap: 0; + margin-bottom: 20px; + background: var(--bg-canvas); + border: 1px solid var(--border); + border-radius: 9px; + padding: 3px; +} + +.grpc-source-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 14px; + background: none; + border: none; + border-radius: 7px; + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} +.grpc-source-btn svg { flex-shrink: 0; opacity: 0.7; } +.grpc-source-btn:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); } +.grpc-source-btn.active { + background: var(--bg-surface); + color: var(--text-primary); + box-shadow: 0 1px 3px rgba(0,0,0,0.3); +} +.grpc-source-btn.active svg { opacity: 1; } + + +/* ══════════════════════════════════════════════ + gRPC — Reflection panel +══════════════════════════════════════════════ */ +.grpc-reflect-upstream { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 8px; +} +.grpc-reflect-upstream-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.grpc-reflect-upstream-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} +.grpc-reflect-upstream-url { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-muted); +} + +.grpc-reflect-status { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-secondary); +} +.grpc-reflect-spinner { + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: grpc-spin 0.7s linear infinite; + flex-shrink: 0; +} +@keyframes grpc-spin { to { transform: rotate(360deg); } } + +.grpc-reflect-error-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--error, #f87171); +} diff --git a/apps/ui/css/workspace-setup.css b/apps/ui/css/workspace-setup.css new file mode 100644 index 0000000..3638106 --- /dev/null +++ b/apps/ui/css/workspace-setup.css @@ -0,0 +1,223 @@ +.ws-setup-page { + min-height: 100vh; + background: var(--bg-canvas); + display: flex; + flex-direction: column; +} +.ws-setup-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 32px; + border-bottom: 1px solid var(--border-subtle); +} +.ws-setup-logo { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: var(--text-primary); + font-size: 15px; + font-weight: 600; +} +.ws-setup-logo-mark { + width: 30px; + height: 30px; + background: #0d9488; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; +} +.ws-setup-back { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-muted); + text-decoration: none; + padding: 6px 10px; + border-radius: 6px; + transition: color 0.15s, background 0.15s; +} +.ws-setup-back:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); } + +.ws-setup-body { + flex: 1; + display: flex; + justify-content: center; + padding: 48px 24px 80px; +} +.ws-setup-container { + width: 100%; + max-width: 600px; +} +.ws-setup-header { margin-bottom: 32px; } +.ws-setup-title { + font-size: 24px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; +} +.ws-setup-subtitle { + font-size: 14px; + color: var(--text-muted); + line-height: 1.5; +} + +.ws-form-section { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + margin-bottom: 16px; +} +.ws-form-section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 18px; +} + +/* Avatar */ +.ws-avatar-row { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} +.ws-avatar-preview { + width: 52px; + height: 52px; + border-radius: 12px; + background: #0d9488; + color: #fff; + font-size: 22px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.2s; +} +.ws-avatar-hint { font-size: 12px; color: var(--text-muted); line-height: 1.5; } +.ws-color-swatches { display: flex; gap: 6px; margin-top: 6px; } +.ws-color-swatch { + width: 20px; + height: 20px; + border-radius: 5px; + cursor: pointer; + border: 2px solid transparent; + transition: transform 0.1s, border-color 0.1s; +} +.ws-color-swatch:hover { transform: scale(1.15); } +.ws-color-swatch.active { border-color: var(--text-primary); } + +/* Invite rows */ +.invite-rows { display: flex; flex-direction: column; gap: 8px; } +.invite-row { display: flex; align-items: center; gap: 8px; } +.invite-row .form-input { margin-bottom: 0; } +.invite-row-remove { + background: none; + border: none; + padding: 6px; + cursor: pointer; + color: var(--text-muted); + border-radius: 5px; + display: flex; + align-items: center; + transition: color 0.1s, background 0.1s; + flex-shrink: 0; +} +.invite-row-remove:hover { color: var(--error, #f87171); background: rgba(248,113,113,0.08); } + +/* Members */ +.member-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + border-bottom: 1px solid var(--border-subtle); +} +.member-row:last-child { border-bottom: none; } +.member-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--bg-muted); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + color: var(--text-secondary); + flex-shrink: 0; +} +.member-info { flex: 1; } +.member-name { font-size: 13.5px; color: var(--text-primary); font-weight: 500; } +.member-email { font-size: 12px; color: var(--text-muted); } +.member-last-active { font-size: 12px; color: var(--text-muted); white-space: nowrap; } + +/* Danger zone */ +.danger-zone-action { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 16px 24px; + border-bottom: 1px solid var(--border-subtle); +} +.danger-zone-action:last-child { border-bottom: none; } +.danger-zone-text { flex: 1; } +.danger-zone-title { font-size: 13.5px; font-weight: 500; color: var(--text-primary); margin-bottom: 3px; } +.danger-zone-desc { font-size: 12.5px; color: var(--text-muted); line-height: 1.5; } + +/* Lang switcher */ +.lang-switcher { display: flex; gap: 6px; margin-top: 6px; } +.lang-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg-canvas); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.lang-btn.active { + background: rgba(13,148,136,0.1); + border-color: rgba(13,148,136,0.35); + color: var(--accent); +} +.lang-btn:hover:not(.active) { background: rgba(255,255,255,0.04); color: var(--text-primary); } +.lang-flag { font-size: 14px; } + +/* Actions row */ +.ws-setup-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + margin-top: 24px; +} +.ws-setup-footer-note { + font-size: 12px; + color: var(--text-muted); + text-align: center; + margin-top: 20px; + line-height: 1.5; +} + +/* Roles reference */ +.role-def-row { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 11px 0; + border-bottom: 1px solid var(--border-subtle); +} +.role-def-row:last-child { border-bottom: none; } +.role-def-text { font-size: 13px; color: var(--text-secondary); line-height: 1.5; } diff --git a/apps/ui/data/agents.json b/apps/ui/data/agents.json new file mode 100644 index 0000000..e78cea5 --- /dev/null +++ b/apps/ui/data/agents.json @@ -0,0 +1,58 @@ +[ + { + "id": "ag-001", + "slug": "customer-support", + "display_name": "Customer Support", + "description": "Handles ticket creation, status lookups, and customer data retrieval. Powers the support team's Claude integration.", + "status": "active", + "operation_count": 5, + "operation_ids": ["op_01", "op_02", "op_07", "op_09", "op_10"], + "key_count": 2, + "created_at": "2024-11-15T10:22:00Z", + "created_by": "AT", + "last_called_at": "2025-03-28T18:44:00Z", + "calls_today": 312 + }, + { + "id": "ag-002", + "slug": "sales-pipeline", + "display_name": "Sales Pipeline", + "description": "CRM operations for the sales team: lead creation, deal updates, contact lookup, and company enrichment.", + "status": "active", + "operation_count": 5, + "operation_ids": ["op_01", "op_06", "op_08", "op_11", "op_12"], + "key_count": 1, + "created_at": "2024-11-28T14:10:00Z", + "created_by": "AT", + "last_called_at": "2025-03-29T09:01:00Z", + "calls_today": 541 + }, + { + "id": "ag-003", + "slug": "internal-devops", + "display_name": "Internal DevOps", + "description": "Infrastructure tooling: deployment triggers, service health checks, and incident management workflows.", + "status": "draft", + "operation_count": 3, + "operation_ids": ["op_04", "op_05", "op_11"], + "key_count": 0, + "created_at": "2025-01-08T09:45:00Z", + "created_by": "AT", + "last_called_at": null, + "calls_today": 0 + }, + { + "id": "ag-004", + "slug": "product-analytics", + "display_name": "Product Analytics", + "description": "Queries usage metrics, feature adoption data, and user behaviour reports from the analytics stack.", + "status": "active", + "operation_count": 4, + "operation_ids": ["op_02", "op_03", "op_08", "op_12"], + "key_count": 3, + "created_at": "2025-02-01T11:30:00Z", + "created_by": "AT", + "last_called_at": "2025-03-29T11:22:00Z", + "calls_today": 87 + } +] diff --git a/apps/ui/data/keys.json b/apps/ui/data/keys.json new file mode 100644 index 0000000..146eb79 --- /dev/null +++ b/apps/ui/data/keys.json @@ -0,0 +1,15 @@ +{ + "acme-workspace": [ + { "id": "key_01", "name": "Production", "prefix": "mcp_prod_4f8a…", "scopes": ["read","write","deploy"], "created": "2025-11-15", "lastUsed": "Today", "status": "active" }, + { "id": "key_02", "name": "CI / CD pipeline", "prefix": "mcp_ci_b21c…", "scopes": ["read","deploy"], "created": "2025-12-01", "lastUsed": "2 days ago", "status": "active" }, + { "id": "key_03", "name": "Dev local", "prefix": "mcp_dev_7e3f…", "scopes": ["read","write"], "created": "2026-01-08", "lastUsed": "Never", "status": "active" }, + { "id": "key_04", "name": "Legacy integration", "prefix": "mcp_leg_c90d…", "scopes": ["read"], "created": "2025-09-22", "lastUsed": "45 days ago", "status": "revoked" } + ], + "startup-demo": [ + { "id": "key_11", "name": "Backend service", "prefix": "mcp_back_9a1b…", "scopes": ["read","write"], "created": "2026-01-20", "lastUsed": "Today", "status": "active" }, + { "id": "key_12", "name": "Analytics pipeline", "prefix": "mcp_anal_3c7d…", "scopes": ["read"], "created": "2026-02-05", "lastUsed": "3 days ago", "status": "active" } + ], + "personal": [ + { "id": "key_21", "name": "Local experiments", "prefix": "mcp_loc_f2e8…", "scopes": ["read","write","deploy"], "created": "2026-03-01", "lastUsed": "Yesterday", "status": "active" } + ] +} diff --git a/apps/ui/data/operations.json b/apps/ui/data/operations.json new file mode 100644 index 0000000..c0d4cd9 --- /dev/null +++ b/apps/ui/data/operations.json @@ -0,0 +1,134 @@ +[ + { + "id": "op_01", + "name": "create_crm_lead", + "display_name": "Create CRM Lead", + "protocol": "rest", + "method": "POST", + "status": "active", + "category": "CRM", + "target_url": "https://api.acme.com/v2/crm/leads", + "created_at": "2026-03-21" + }, + { + "id": "op_02", + "name": "get_user_profile", + "display_name": "Get User Profile", + "protocol": "rest", + "method": "GET", + "status": "active", + "category": "Users", + "target_url": "https://api.acme.com/v1/users/{id}", + "created_at": "2026-03-18" + }, + { + "id": "op_03", + "name": "search_knowledge_base", + "display_name": "Search Knowledge Base", + "protocol": "graphql", + "method": null, + "status": "active", + "category": "Search", + "target_url": "https://graph.notion-int.acme.com/graphql", + "created_at": "2026-03-14" + }, + { + "id": "op_04", + "name": "send_slack_message", + "display_name": "Send Slack Message", + "protocol": "rest", + "method": "POST", + "status": "error", + "category": "Notifications", + "target_url": "https://slack.com/api/chat.postMessage", + "created_at": "2026-03-10" + }, + { + "id": "op_05", + "name": "stream_telemetry", + "display_name": "Stream Telemetry Events", + "protocol": "grpc", + "method": null, + "status": "draft", + "category": "Observability", + "target_url": "grpc://telemetry.internal.acme.com:9090", + "created_at": "2026-03-05" + }, + { + "id": "op_06", + "name": "update_deal_stage", + "display_name": "Update Deal Stage", + "protocol": "rest", + "method": "PATCH", + "status": "inactive", + "category": "CRM", + "target_url": "https://api.acme.com/v2/crm/deals/{id}", + "created_at": "2026-02-28" + }, + { + "id": "op_07", + "name": "fetch_invoice", + "display_name": "Fetch Invoice", + "protocol": "rest", + "method": "GET", + "status": "active", + "category": "Billing", + "target_url": "https://billing.acme.com/v1/invoices/{id}", + "created_at": "2026-02-20" + }, + { + "id": "op_08", + "name": "list_products", + "display_name": "List Products", + "protocol": "graphql", + "method": null, + "status": "active", + "category": "Catalog", + "target_url": "https://shop.acme.com/graphql", + "created_at": "2026-02-15" + }, + { + "id": "op_09", + "name": "create_support_ticket", + "display_name": "Create Support Ticket", + "protocol": "rest", + "method": "POST", + "status": "active", + "category": "Support", + "target_url": "https://support.acme.com/api/tickets", + "created_at": "2026-02-10" + }, + { + "id": "op_10", + "name": "get_order_status", + "display_name": "Get Order Status", + "protocol": "rest", + "method": "GET", + "status": "draft", + "category": "Orders", + "target_url": "https://orders.acme.com/v2/status/{id}", + "created_at": "2026-02-05" + }, + { + "id": "op_11", + "name": "sync_contacts", + "display_name": "Sync Contacts", + "protocol": "grpc", + "method": null, + "status": "active", + "category": "CRM", + "target_url": "grpc://contacts.internal.acme.com:9091", + "created_at": "2026-01-28" + }, + { + "id": "op_12", + "name": "send_email_campaign", + "display_name": "Send Email Campaign", + "protocol": "rest", + "method": "POST", + "status": "active", + "category": "Marketing", + "target_url": "https://mail.acme.com/api/v3/campaigns/send", + "created_at": "2026-01-20" + } +] diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html new file mode 100644 index 0000000..61bc34f --- /dev/null +++ b/apps/ui/html/agents.html @@ -0,0 +1,380 @@ + + + + + + + Crank — Agents + + + + + + + + + + + + + + + + +
+ Operations + Agents + API Keys + Logs + Usage +
+ + +
+ + + + + +
+
+
Total agents
+
+
across this workspace
+
+
+
Active
+
+
+
+
+
Operations exposed
+
+
+
+
+
Calls today
+
+
all agents combined
+
+
+ + +
+ +
+ + of agents + +
+
+ + +
+ + Each agent gets its own MCP endpoint. Connect your LLM client to a specific agent so it only sees the tools it needs — not all ? operations in this workspace. +
+ + +
+
+ Loading agents… +
+ + +
+
+ +
+
No agents yet
+
Create your first agent to get a dedicated MCP endpoint with a curated set of tools.
+ +
+ + +
+
No agents match ""
+
Try a different search term.
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + + diff --git a/apps/ui/html/api-keys.html b/apps/ui/html/api-keys.html new file mode 100644 index 0000000..daeff65 --- /dev/null +++ b/apps/ui/html/api-keys.html @@ -0,0 +1,281 @@ + + + + + + + Crank — API Keys + + + + + + + + + + + + + + + +
+ Operations + Agents + API Keys + Logs + Usage +
+ + +
+ + + +
+ + + + +
+ Keys are only shown once. Copy and store them securely immediately after creation — Crank stores only a hash. Revoked keys cannot be restored. +
+
+ +
+
+
+
Active keys
+
3 active · 1 revoked
+
+ +
+ +
+ + + + + + + + + + + + + + +
NameKey prefixScopesCreatedLast usedStatus
+
+
+ +
+
+
Scope reference
+
+
+
+
+ read +
+
List operations, read configs, query logs and usage metrics.
+
+
+
+ write +
+
Create, update, and delete operations and their configurations.
+
+
+
+ deploy +
+
Publish draft operations, rollback deployments, and manage traffic splits.
+
+
+
+ +
+ + + + + + + + + + + + + + + diff --git a/apps/ui/html/fragments/lang-switcher.html b/apps/ui/html/fragments/lang-switcher.html new file mode 100644 index 0000000..96a8403 --- /dev/null +++ b/apps/ui/html/fragments/lang-switcher.html @@ -0,0 +1,13 @@ +
+ +
+ + +
+
diff --git a/apps/ui/html/login.html b/apps/ui/html/login.html new file mode 100644 index 0000000..e987eab --- /dev/null +++ b/apps/ui/html/login.html @@ -0,0 +1,78 @@ + + + + + + + Crank — Sign in + + + + + + + + +
+ +
+ + + + + diff --git a/apps/ui/html/logs.html b/apps/ui/html/logs.html new file mode 100644 index 0000000..efbdc0c --- /dev/null +++ b/apps/ui/html/logs.html @@ -0,0 +1,148 @@ + + + + + + + Crank — Logs + + + + + + + + + + + + + + +
+ Operations + Agents + API Keys + Logs + Usage +
+ +
+ + + +
+
+
+ Live +
+ + + +
+ + + + + + + + + +
+ +
+
+
+ +
+ + + + + + diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html new file mode 100644 index 0000000..b6a8d1c --- /dev/null +++ b/apps/ui/html/settings.html @@ -0,0 +1,597 @@ + + + + + + + Crank — Settings + + + + + + + + + + + + + + +
+ Operations + Agents + API Keys + Logs + Usage +
+ +
+ + + +
+ + + + + +
+ + + + +
+
+
+
Profile
+
+
+
+
AT
+
+
Operator
+
operator@acme-workspace
+ +
+
+
Avatar will appear in the console and team views.
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
Changing email requires re-verification. You'll receive a confirmation link.
+
+ +
+ +
+
+
+
+ + + + + + + +
+
+
+
Security
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+
Two-factor authentication
+ +
+
+
TOTP authenticator app
+
Use Google Authenticator, 1Password, or any TOTP-compatible app.
+
+ Enabled + +
+ +
+
+
Hardware security key
+
FIDO2 / WebAuthn passkey or YubiKey.
+
+ +
+
+
+ +
+
+
Active sessions
+
+
+
+
+
Chrome · macOS — current
+
Last active: now · 93.184.216.34 (US)
+
+
+
+
+
Safari · iPhone
+
Last active: 2 days ago · 93.184.216.34 (US)
+
+ +
+
+
+
Firefox · Linux
+
Last active: 8 days ago · 172.16.0.4 (Internal)
+
+ +
+
+
+
+ + +
+
+
+
Notification preferences
+
+
+
+
+
Operation error spike
+
Alert when error rate exceeds 5% over a 5-minute window.
+
+
+
+
+
+
Upstream latency degradation
+
Alert when p99 latency exceeds the baseline by 3×.
+
+
+
+
+
+
Quota approaching limit
+
Email when any operation reaches 80% of its monthly quota.
+
+
+
+
+
+
New team member joined
+
Notify workspace admins when someone accepts an invite.
+
+
+
+
+
+
Weekly usage digest
+
Email summary of invocations, errors and latency each Monday.
+
+
+
+
+
+
+ + + + +
+
+ +
+ + + + + + diff --git a/apps/ui/html/usage.html b/apps/ui/html/usage.html new file mode 100644 index 0000000..88380bb --- /dev/null +++ b/apps/ui/html/usage.html @@ -0,0 +1,228 @@ + + + + + + + Crank — Usage + + + + + + + + + + + + + + +
+ Operations + Agents + API Keys + Logs + Usage +
+ +
+ + + + +
+
+
Total invocations
+
28,341
+
+ + +12% vs prior period +
+
+
+
Success rate
+
98.6%
+
+ + +0.3pp vs prior period +
+
+
+
Median latency (p50)
+
124ms
+
+ + -18ms vs prior period +
+
+
+
p99 latency
+
2.1s
+
+ + +340ms vs prior period +
+
+
+ + +
+
+
Invocations over time
+
+ Success + Error +
+
+
+
+
+
+ + +
+
+
+
By operation
+
Breakdown for last 7 days
+
+
+ + + + + + + + + + + + + + +
OperationProtocolCallsErrorsError rateLatency (p50 / p95 / p99)Quota
+
+ +
+ + + + + + + + + + + + diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html new file mode 100644 index 0000000..8862771 --- /dev/null +++ b/apps/ui/html/wizard/index.html @@ -0,0 +1,238 @@ + + + + + + Crank — New Operation + + + + + + + + + + + + + + + +
+ Operations + Agents + API Keys + Logs + Usage +
+ + +
+ + +
+ +
+ Create operation +
+
+
+ 20% + +
+ + +
+ + +
+
+ +
+
M
+
+ Create operation +
+
+ +
+ + +
+ +
+
1
+
+
Step 1
+
Protocol
+
In progress
+
+
+ +
+
2
+
+
Step 2
+
Upstream target
+
Not started
+
+
+ + + +
+
4
+
+
Step 4
+
Tool config
+
Not started
+
+
+ +
+
5
+
+
Step 5
+
Mapping
+
Not started
+
+
+ +
+ + +
+
+
+ + +
+
+ + + + +
+ + + + + + diff --git a/apps/ui/html/wizard/step1.html b/apps/ui/html/wizard/step1.html new file mode 100644 index 0000000..3a843e5 --- /dev/null +++ b/apps/ui/html/wizard/step1.html @@ -0,0 +1,95 @@ + +
+
+
+
+ Step 1 of 5 +
+

Choose a protocol

+

Select the transport protocol your upstream service uses. This determines how Crank will communicate with your API and which configuration options are available in subsequent steps.

+
+ + +
+ + + + + + + +
+ + +
+ + + + +
+
You can change this later
+
+ Switching protocol after configuring subsequent steps will clear schema and mapping data. Save your operation as a draft before experimenting. See protocol migration guide for details. +
+
+
+ +
diff --git a/apps/ui/html/wizard/step2.html b/apps/ui/html/wizard/step2.html new file mode 100644 index 0000000..a5e02a0 --- /dev/null +++ b/apps/ui/html/wizard/step2.html @@ -0,0 +1,157 @@ + +
+
+
+
+ Step 2 of 5 +
+

Select upstream & endpoint

+

Choose an existing upstream — a shared host with its auth config — or register a new one. Then define the specific endpoint path this operation will call.

+
+ + +
+
+
+ + + + + + +
+
+
Upstream
+
Shared host, base URL, and authentication
+
+ Required +
+
+ + +
+
+
+ Select an upstream… +
+ + + +
+ + + +
+ + + + + +
+
+
+
+ + + + Register new upstream +
+ + + + +
+
+ + +
+
+
+ + + +
+
+
Endpoint
+
Path and HTTP method for this specific operation
+
+ Required +
+
+
+ + +
Appended to the upstream base URL. Use {param} for path variables.
+
+
+
+ +
+ + + + + + diff --git a/apps/ui/html/wizard/step3-graphql.html b/apps/ui/html/wizard/step3-graphql.html new file mode 100644 index 0000000..c2335dd --- /dev/null +++ b/apps/ui/html/wizard/step3-graphql.html @@ -0,0 +1,92 @@ +
+
+
+
+ Step 3 of 5 +
+

GraphQL operation

+

Define the GraphQL operation this tool will execute. All requests are sent as HTTP POST to the endpoint configured in the previous step. Variables are populated from MCP tool arguments at runtime.

+
+ + +
+
+
+ + + + +
+
+
Operation type
+
GraphQL operation kind — subscriptions are not supported
+
+ Required +
+
+
+ + +
+
+
+ + +
+
+
+ + + + +
+
+
Query document
+
GraphQL document with typed variable declarations
+
+ Required +
+
+
+
+
+ graphql / query +
+ +
+
+
+ + +
+ + + + +
+
Variables are mapped in step 5
+
+ Declare all variables in the query document using the $variable: Type syntax. In step 5 you will map MCP input fields to these variables. Crank serialises the final {"query","variables"} payload automatically. +
+
+
+ +
diff --git a/apps/ui/html/wizard/step3-grpc.html b/apps/ui/html/wizard/step3-grpc.html new file mode 100644 index 0000000..0146128 --- /dev/null +++ b/apps/ui/html/wizard/step3-grpc.html @@ -0,0 +1,274 @@ +
+
+
+
+ Step 3 of 5 +
+

RPC method

+

Choose how to describe the gRPC service contract. Upload a .proto file, discover methods from a live server via reflection, or enter the route manually. Only unary RPCs are supported — streaming connections cannot be held open during MCP tool invocations.

+
+ + +
+ + + +
+ + +
+
+
+
+ +
+
+
Proto definition
+
Upload or paste your .proto file
+
+ Required +
+
+
+ + + + +
Drop .proto file here
+
or click to browse
+ +
+ +
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + +
+ + + + + + + + + diff --git a/apps/ui/html/wizard/step3-rest.html b/apps/ui/html/wizard/step3-rest.html new file mode 100644 index 0000000..de94ea3 --- /dev/null +++ b/apps/ui/html/wizard/step3-rest.html @@ -0,0 +1,93 @@ + +
+
+
+
+ Step 3 of 5 +
+

HTTP method & format

+

Choose the HTTP verb this operation sends and configure content negotiation headers. Crank will serialize MCP tool arguments into the appropriate request body format.

+
+ + +
+
+
+ + + +
+
+
HTTP method
+
Verb sent to the upstream on every tool invocation
+
+ Required +
+
+
+ + + + + +
+ +
+
+ + +
+
+
+ + + + +
+
+
Request format
+
Content negotiation and serialisation
+
+ Optional +
+
+
+
+ + +
How the request body is encoded when sent to the upstream.
+
+
+ + +
Accepted response content types from the upstream server.
+
+
+
+
+ +
diff --git a/apps/ui/html/wizard/step4.html b/apps/ui/html/wizard/step4.html new file mode 100644 index 0000000..a14377f --- /dev/null +++ b/apps/ui/html/wizard/step4.html @@ -0,0 +1,162 @@ +
+
+
+
+ Step 4 of 5 +
+

Tool config

+

Name your tool, write the LLM-facing description, and define the input/output schemas. The description is the primary signal the model uses when deciding whether to call this tool.

+
+ +
+
+
+ + + +
+
+
Tool identity
+
Machine-readable name and LLM-facing description
+
+
+
+ +
+ + +
Machine-readable identifier used in MCP tool calls. Only lowercase letters, numbers and underscores. Cannot be changed after publishing.
+
+ +
+
+ + +
Human-readable name shown in the console and audit log.
+
+
+ + +
Short imperative sentence shown in the MCP tool manifest.
+
+
+ +
+ + +
LLM-facing description. Be precise — this is the primary signal the model uses to decide whether to invoke this tool.
+
+ +
+
+ +
+ + + + +
+
Writing effective descriptions
+
+ Start with a verb ("Creates", "Fetches", "Updates"). Mention key input requirements. Describe what a successful response looks like. Avoid vague phrasing like "handles" or "manages". See description best practices → +
+
+
+ +
+
+
+ + + + +
+
+
Input schema
+
Parameters the LLM passes when calling this tool
+
+ JSON Schema draft-07 +
+
+
+
+
+ json / input-schema +
+ +
+
+
+ +
+
+
+ + + + +
+
+
Output schema
+
Shape of the data returned to the LLM after a successful call
+
+ JSON Schema draft-07 +
+
+
+
+
+ json / output-schema +
+ +
+
+
+ +
+ + + + +
+
Schemas are protocol-agnostic
+
+ These schemas describe the MCP contract, not the upstream wire format. Field mapping in Step 5 translates between the two. You can upload a sample JSON response to auto-generate the output schema. +
+
+
+
diff --git a/apps/ui/html/wizard/step5.html b/apps/ui/html/wizard/step5.html new file mode 100644 index 0000000..13c4d25 --- /dev/null +++ b/apps/ui/html/wizard/step5.html @@ -0,0 +1,102 @@ +
+
+
+
+ Step 5 of 5 +
+

Mapping and execution

+

Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 3.

+
+ +
+ Input → Request +
+
+ +
+
+
+
+
+ yaml / input-mapping +
+ +
+
+
+ +
+ Response → Output +
+
+ +
+
+
+
+
+ yaml / output-mapping +
+ +
+
+
+ +
+ Execution +
+
+ +
+
+
+ + + + +
+
+
Execution config
+
Timeouts, retry policy and auth profile reference
+
+ Optional +
+
+
+
+
+ yaml / exec-config +
+ +
+
+
+ +
+ + + + +
+
Operation will be saved as a draft
+
+ Clicking "Create operation" saves the contract in draft status. You can run tests, inspect metadata and publish from the operation workspace. Drafts are never exposed to MCP clients. +
+
+
+
diff --git a/apps/ui/html/workspace-setup.html b/apps/ui/html/workspace-setup.html new file mode 100644 index 0000000..6aab8a5 --- /dev/null +++ b/apps/ui/html/workspace-setup.html @@ -0,0 +1,327 @@ + + + + + + + Crank — Workspace + + + + + + + + + + + +
+ + + + + +
+
+ +
+
Workspace settings
+
Configure your workspace identity, default settings, and team members.
+
+ + +
+
Workspace identity
+ +
+
A
+
+
Avatar is derived from your workspace name.
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
+ +
+ +
+ mcp.crank.io/ + +
+
Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.
+
+ +
+ + +
+
+ + +
+
Default settings
+ +
+
+ + +
Pre-selected in the operation wizard.
+
+
+ + +
+
+ +
+ +
+ + +
+
+
+ + + + + + + + +
+ Cancel + +
+ + + + + + +
+
+ +
+ + + + + diff --git a/apps/ui/icons/agents/agent.svg b/apps/ui/icons/agents/agent.svg new file mode 100644 index 0000000..7abbba5 --- /dev/null +++ b/apps/ui/icons/agents/agent.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/ui/icons/agents/eye.svg b/apps/ui/icons/agents/eye.svg new file mode 100644 index 0000000..f843b1b --- /dev/null +++ b/apps/ui/icons/agents/eye.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/agents/key.svg b/apps/ui/icons/agents/key.svg new file mode 100644 index 0000000..c208db3 --- /dev/null +++ b/apps/ui/icons/agents/key.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/arrow-down.svg b/apps/ui/icons/general/arrow-down.svg new file mode 100644 index 0000000..7577633 --- /dev/null +++ b/apps/ui/icons/general/arrow-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/arrow-left.svg b/apps/ui/icons/general/arrow-left.svg new file mode 100644 index 0000000..a34bc94 --- /dev/null +++ b/apps/ui/icons/general/arrow-left.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/arrow-right.svg b/apps/ui/icons/general/arrow-right.svg new file mode 100644 index 0000000..71e439f --- /dev/null +++ b/apps/ui/icons/general/arrow-right.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/arrow-up.svg b/apps/ui/icons/general/arrow-up.svg new file mode 100644 index 0000000..85d9676 --- /dev/null +++ b/apps/ui/icons/general/arrow-up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/bell.svg b/apps/ui/icons/general/bell.svg new file mode 100644 index 0000000..7ccd650 --- /dev/null +++ b/apps/ui/icons/general/bell.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/check-sm.svg b/apps/ui/icons/general/check-sm.svg new file mode 100644 index 0000000..3449e27 --- /dev/null +++ b/apps/ui/icons/general/check-sm.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/check.svg b/apps/ui/icons/general/check.svg new file mode 100644 index 0000000..250df56 --- /dev/null +++ b/apps/ui/icons/general/check.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/chevron-down.svg b/apps/ui/icons/general/chevron-down.svg new file mode 100644 index 0000000..07aaf73 --- /dev/null +++ b/apps/ui/icons/general/chevron-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/close.svg b/apps/ui/icons/general/close.svg new file mode 100644 index 0000000..648c2e9 --- /dev/null +++ b/apps/ui/icons/general/close.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/copy.svg b/apps/ui/icons/general/copy.svg new file mode 100644 index 0000000..3a586b9 --- /dev/null +++ b/apps/ui/icons/general/copy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/crank-logo.svg b/apps/ui/icons/general/crank-logo.svg new file mode 100644 index 0000000..b33e89f --- /dev/null +++ b/apps/ui/icons/general/crank-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/ui/icons/general/download.svg b/apps/ui/icons/general/download.svg new file mode 100644 index 0000000..494bbb5 --- /dev/null +++ b/apps/ui/icons/general/download.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/edit.svg b/apps/ui/icons/general/edit.svg new file mode 100644 index 0000000..0a2419e --- /dev/null +++ b/apps/ui/icons/general/edit.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/external.svg b/apps/ui/icons/general/external.svg new file mode 100644 index 0000000..893d2df --- /dev/null +++ b/apps/ui/icons/general/external.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/grid.svg b/apps/ui/icons/general/grid.svg new file mode 100644 index 0000000..98effa2 --- /dev/null +++ b/apps/ui/icons/general/grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/info-circle.svg b/apps/ui/icons/general/info-circle.svg new file mode 100644 index 0000000..cdc8fd5 --- /dev/null +++ b/apps/ui/icons/general/info-circle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/general/info.svg b/apps/ui/icons/general/info.svg new file mode 100644 index 0000000..1270118 --- /dev/null +++ b/apps/ui/icons/general/info.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/logout.svg b/apps/ui/icons/general/logout.svg new file mode 100644 index 0000000..a769622 --- /dev/null +++ b/apps/ui/icons/general/logout.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/general/plus-sm.svg b/apps/ui/icons/general/plus-sm.svg new file mode 100644 index 0000000..bd555c4 --- /dev/null +++ b/apps/ui/icons/general/plus-sm.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/plus.svg b/apps/ui/icons/general/plus.svg new file mode 100644 index 0000000..f192806 --- /dev/null +++ b/apps/ui/icons/general/plus.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/refresh.svg b/apps/ui/icons/general/refresh.svg new file mode 100644 index 0000000..a64b2de --- /dev/null +++ b/apps/ui/icons/general/refresh.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/search.svg b/apps/ui/icons/general/search.svg new file mode 100644 index 0000000..ac71407 --- /dev/null +++ b/apps/ui/icons/general/search.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/settings.svg b/apps/ui/icons/general/settings.svg new file mode 100644 index 0000000..556e1cd --- /dev/null +++ b/apps/ui/icons/general/settings.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/general/trash.svg b/apps/ui/icons/general/trash.svg new file mode 100644 index 0000000..658080d --- /dev/null +++ b/apps/ui/icons/general/trash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/user.svg b/apps/ui/icons/general/user.svg new file mode 100644 index 0000000..e9ef4cd --- /dev/null +++ b/apps/ui/icons/general/user.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/general/warning.svg b/apps/ui/icons/general/warning.svg new file mode 100644 index 0000000..3b96ca1 --- /dev/null +++ b/apps/ui/icons/general/warning.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/login/google.svg b/apps/ui/icons/login/google.svg new file mode 100644 index 0000000..19c999b --- /dev/null +++ b/apps/ui/icons/login/google.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/ui/icons/wizard/clock.svg b/apps/ui/icons/wizard/clock.svg new file mode 100644 index 0000000..2b3ba0b --- /dev/null +++ b/apps/ui/icons/wizard/clock.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/wizard/code.svg b/apps/ui/icons/wizard/code.svg new file mode 100644 index 0000000..31327ef --- /dev/null +++ b/apps/ui/icons/wizard/code.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/wizard/file.svg b/apps/ui/icons/wizard/file.svg new file mode 100644 index 0000000..aee4e64 --- /dev/null +++ b/apps/ui/icons/wizard/file.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/wizard/graphql-protocol.svg b/apps/ui/icons/wizard/graphql-protocol.svg new file mode 100644 index 0000000..08a5fa5 --- /dev/null +++ b/apps/ui/icons/wizard/graphql-protocol.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/ui/icons/wizard/grpc-protocol.svg b/apps/ui/icons/wizard/grpc-protocol.svg new file mode 100644 index 0000000..bde9d0a --- /dev/null +++ b/apps/ui/icons/wizard/grpc-protocol.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/ui/icons/wizard/reflection.svg b/apps/ui/icons/wizard/reflection.svg new file mode 100644 index 0000000..19f923f --- /dev/null +++ b/apps/ui/icons/wizard/reflection.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/ui/icons/wizard/rest-protocol.svg b/apps/ui/icons/wizard/rest-protocol.svg new file mode 100644 index 0000000..97bc44e --- /dev/null +++ b/apps/ui/icons/wizard/rest-protocol.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/icons/wizard/server.svg b/apps/ui/icons/wizard/server.svg new file mode 100644 index 0000000..e58ca74 --- /dev/null +++ b/apps/ui/icons/wizard/server.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/ui/icons/wizard/upload.svg b/apps/ui/icons/wizard/upload.svg new file mode 100644 index 0000000..1dc7232 --- /dev/null +++ b/apps/ui/icons/wizard/upload.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/ui/index.html b/apps/ui/index.html index 81d9d27..88c2b92 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -1,94 +1,418 @@ - + - - - - Crank Console - - - -
- Crank logo -
UI replacement in progress
-

Crank Console

-

- The legacy React/Vite UI has been removed. A new operator console is being integrated on top of the same - deployment path. -

-

- Backend services, MCP endpoints and deployment contracts remain intact. Replace this static entrypoint with - the new Alpine.js UI when it is ready. -

-
- - + +
+ + +
+
+
Operator
+
Admin · acme-workspace
+
+ + + +
+
+ + + + +
+ Operations + Agents + API Keys + Logs + Usage +
+ + +
+ + + + + +
+
+
Total operations
+
+
+3 this month
+
+
+
Active
+
+
+
+
+
Requests today
+
4,821
+
+12% vs yesterday
+
+
+
Avg. latency
+
187ms
+
+23ms this week
+
+
+ + +
+ +
+ + +
+ + + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + Showing of + + + +
+ + +
+
+ +
+ + +
+ + +
+ + +
+ + +
+

Loading operations…

+
+ + +
+

No operations found

+

Try adjusting your search or filters.

+
+ + + + + + + + + + + + + + + + +
+ NAME + + + + PROTOCOLSTATUS + CREATED + + + + TARGET URL
+ + + + +
+ +
+ + + + + + diff --git a/apps/ui/js/agents.js b/apps/ui/js/agents.js new file mode 100644 index 0000000..3bcb663 --- /dev/null +++ b/apps/ui/js/agents.js @@ -0,0 +1,208 @@ +document.addEventListener('alpine:init', () => { + Alpine.data('agents', () => ({ + agents: [], + operations: [], + loading: true, + + agentSearch: '', + openDropdown: null, + + // drawer state + drawerOpen: false, + drawerMode: 'create', // 'create' | 'edit' + editingId: null, + + // form fields + form: { + display_name: '', + slug: '', + description: '', + status: 'active', + selectedOps: [], + }, + + // operations picker + opSearch: '', + slugManuallyEdited: false, + + async init() { + const [agentsRes, opsRes] = await Promise.all([ + fetch((window.DATA_URL||'data/')+'agents.json'), + fetch((window.DATA_URL||'data/')+'operations.json'), + ]); + this.agents = await agentsRes.json(); + this.operations = await opsRes.json(); + this.loading = false; + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.drawerOpen) this.closeDrawer(); + }); + + document.addEventListener('click', () => { this.openDropdown = null; }); + }, + + // ── computed ── + get filteredAgents() { + const q = this.agentSearch.toLowerCase().trim(); + if (!q) return this.agents; + return this.agents.filter(a => + a.display_name.toLowerCase().includes(q) || + a.slug.toLowerCase().includes(q) || + (a.description || '').toLowerCase().includes(q) + ); + }, + + get totalCalls() { + return this.agents.reduce((s, a) => s + (a.calls_today || 0), 0); + }, + get activeCount() { + return this.agents.filter(a => a.status === 'active').length; + }, + get totalOpsExposed() { + return this.agents.reduce((s, a) => s + (a.operation_count || 0), 0); + }, + + get filteredOps() { + const q = this.opSearch.toLowerCase(); + if (!q) return this.operations; + return this.operations.filter(op => + op.name.toLowerCase().includes(q) || + op.display_name.toLowerCase().includes(q) + ); + }, + + // ── drawer ── + openCreate() { + this.drawerMode = 'create'; + this.editingId = null; + this.form = { display_name: '', slug: '', description: '', status: 'active', selectedOps: [] }; + this.opSearch = ''; + this.slugManuallyEdited = false; + this.drawerOpen = true; + }, + + openEdit(agent) { + this.drawerMode = 'edit'; + this.editingId = agent.id; + this.form = { + display_name: agent.display_name, + slug: agent.slug, + description: agent.description, + status: agent.status, + selectedOps: [...(agent.operation_ids || [])], + }; + this.opSearch = ''; + this.slugManuallyEdited = true; + this.drawerOpen = true; + }, + + closeDrawer() { + this.drawerOpen = false; + }, + + // ── slug auto-derive ── + onNameInput(val) { + this.form.display_name = val; + if (!this.slugManuallyEdited) { + this.form.slug = val.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); + } + }, + + onSlugInput(val) { + this.form.slug = val.toLowerCase().replace(/[^a-z0-9-]/g, ''); + this.slugManuallyEdited = true; + }, + + // ── operations picker ── + toggleOp(opId) { + const idx = this.form.selectedOps.indexOf(opId); + if (idx === -1) this.form.selectedOps.push(opId); + else this.form.selectedOps.splice(idx, 1); + }, + isOpSelected(opId) { + return this.form.selectedOps.includes(opId); + }, + + // ── save ── + saveAgent() { + if (!this.form.display_name.trim() || !this.form.slug.trim()) return; + + const ws = (localStorage.getItem('crank_workspace') || 'acme-workspace'); + const endpoint = '/mcp/v1/' + ws + '/' + this.form.slug; + + if (this.drawerMode === 'create') { + this.agents.unshift({ + id: 'ag-' + Date.now(), + slug: this.form.slug, + display_name: this.form.display_name, + description: this.form.description, + status: this.form.status, + operation_count: this.form.selectedOps.length, + operation_ids: [...this.form.selectedOps], + key_count: 0, + created_at: new Date().toISOString(), + created_by: 'AT', + last_called_at: null, + calls_today: 0, + mcp_endpoint: endpoint, + }); + } else { + const idx = this.agents.findIndex(a => a.id === this.editingId); + if (idx !== -1) { + this.agents[idx] = Object.assign({}, this.agents[idx], { + display_name: this.form.display_name, + slug: this.form.slug, + description: this.form.description, + status: this.form.status, + operation_count: this.form.selectedOps.length, + operation_ids: [...this.form.selectedOps], + mcp_endpoint: endpoint, + }); + } + } + this.closeDrawer(); + }, + + // ── delete ── + deleteAgent(id) { + this.agents = this.agents.filter(a => a.id !== id); + }, + + // ── helpers ── + mcpEndpoint(agent) { + if (agent.mcp_endpoint) return agent.mcp_endpoint; + const ws = localStorage.getItem('crank_workspace') || 'acme-workspace'; + return '/mcp/v1/' + ws + '/' + agent.slug; + }, + + copyEndpoint(agent) { + navigator.clipboard.writeText(this.mcpEndpoint(agent)).catch(() => {}); + }, + + statusClass(status) { + return 'agent-status-badge agent-status-' + status; + }, + + protocolBadge(op) { + if (op.protocol === 'rest') return 'badge badge-rest'; + if (op.protocol === 'graphql') return 'badge badge-graphql'; + return 'badge badge-grpc'; + }, + + protocolLabel(op) { + if (op.protocol === 'rest') return 'REST'; + if (op.protocol === 'graphql') return 'GQL'; + return 'gRPC'; + }, + + formatDate(dateStr) { + if (!dateStr) return '—'; + return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + }, + + formatCalls(n) { + if (!n) return '0'; + return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); + }, + })); +}); diff --git a/apps/ui/js/api-keys.js b/apps/ui/js/api-keys.js new file mode 100644 index 0000000..f21cc5a --- /dev/null +++ b/apps/ui/js/api-keys.js @@ -0,0 +1,241 @@ +/* ═══════════════════════════════════════════════════ + Crank — API Keys page + Data: GET /api/workspaces/{slug}/keys + ═══════════════════════════════════════════════════ */ + +var KEYS = []; +var currentWorkspaceSlug = ''; + +var SCOPES = ['read', 'write', 'deploy']; +var SCOPE_DESC = { + read: 'Read operations, logs, and usage', + write: 'Create and update operations', + deploy: 'Publish and rollback deployments', +}; + +var selectedScopes = new Set(['read']); +var search = ''; + +// ── API helpers ────────────────────────────────────────────────────────────── + +function apiBase() { + return '/api/workspaces/' + currentWorkspaceSlug + '/keys'; +} + +// In production: replace fetch(mockUrl()) with fetch(apiBase()) +function mockUrl() { + return (window.DATA_URL || 'data/') + 'keys.json'; +} + +// ── Load keys for current workspace ───────────────────────────────────────── + +function loadKeys() { + currentWorkspaceSlug = getCurrentWs().slug; + + setTableLoading(true); + + // Production: fetch(apiBase()) + fetch(mockUrl()) + .then(function(r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }) + .then(function(data) { + // Mock JSON is keyed by workspace slug; real API returns array directly + KEYS = Array.isArray(data) ? data : (data[currentWorkspaceSlug] || []); + setTableLoading(false); + renderTable(); + }) + .catch(function() { + KEYS = []; + setTableLoading(false); + renderTable(); + }); +} + +function setTableLoading(on) { + var tbody = document.getElementById('keys-tbody'); + if (!on) return; + tbody.innerHTML = ''; + var tr = document.createElement('tr'); + var td = document.createElement('td'); + td.colSpan = 7; + td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);'; + td.textContent = 'Loading…'; + tr.appendChild(td); + tbody.appendChild(tr); +} + +// ── Mutations (in production: real fetch calls) ────────────────────────────── + +function revokeKey(id) { + if (!confirm('Revoke this key? All requests using it will fail immediately.')) return; + + // Production: fetch(apiBase() + '/' + id, { method: 'PATCH', body: JSON.stringify({ status: 'revoked' }) }) + var k = KEYS.find(function(k) { return k.id === id; }); + if (k) { k.status = 'revoked'; renderTable(); } +} + +function deleteKey(id) { + // Production: fetch(apiBase() + '/' + id, { method: 'DELETE' }) + KEYS.splice(KEYS.findIndex(function(k) { return k.id === id; }), 1); + renderTable(); +} + +function createKey(name, scopes) { + // Production: fetch(apiBase(), { method: 'POST', body: JSON.stringify({ name, scopes }) }) + // → response: { key: 'mcp_...', prefix: 'mcp_...', id: '...' } + var rand = Array.from({length: 32}, function() { return Math.random().toString(36)[2]; }).join(''); + var rawKey = 'mcp_' + name.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4) + '_' + rand; + var prefix = rawKey.slice(0, 12) + '…'; + var newKey = { + id: 'key_' + Date.now(), + name: name, + prefix: prefix, + scopes: scopes, + created: new Date().toISOString().slice(0, 10), + lastUsed: 'Never', + status: 'active', + }; + KEYS.unshift(newKey); + return rawKey; +} + +// ── Render ─────────────────────────────────────────────────────────────────── + +function renderScopes() { + var el = document.getElementById('scope-checkboxes'); + var tmpl = document.getElementById('tmpl-scope-checkbox'); + el.innerHTML = ''; + SCOPES.forEach(function(s) { + var node = tmpl.content.cloneNode(true); + var input = node.querySelector('input'); + input.dataset.scope = s; + if (selectedScopes.has(s)) input.checked = true; + node.querySelector('.scope-checkbox-name').textContent = s; + node.querySelector('.scope-checkbox-desc').textContent = SCOPE_DESC[s]; + input.addEventListener('change', function() { + if (this.checked) selectedScopes.add(this.dataset.scope); + else selectedScopes.delete(this.dataset.scope); + }); + el.appendChild(node); + }); +} + +function renderTable() { + var q = search.toLowerCase(); + var tbody = document.getElementById('keys-tbody'); + var rows = KEYS.filter(function(k) { + return !q || k.name.toLowerCase().includes(q) || k.prefix.includes(q); + }); + + tbody.innerHTML = ''; + + if (!rows.length) { + var empty = document.createElement('tr'); + var td = document.createElement('td'); + td.colSpan = 7; + td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);'; + td.textContent = KEYS.length ? 'No keys match your search' : 'No API keys yet'; + empty.appendChild(td); + tbody.appendChild(empty); + return; + } + + var tmpl = document.getElementById('tmpl-key-row'); + rows.forEach(function(k) { + var node = tmpl.content.cloneNode(true); + node.querySelector('.col-name').textContent = k.name; + node.querySelector('.col-mono').textContent = k.prefix; + node.querySelector('.col-created').textContent = k.created; + node.querySelector('.col-last-used').textContent = k.lastUsed; + + var scopesEl = node.querySelector('.col-scopes'); + k.scopes.forEach(function(s) { + var span = document.createElement('span'); + span.className = 'badge badge-scope'; + span.textContent = s; + scopesEl.appendChild(span); + }); + + var badge = document.createElement('span'); + badge.className = k.status === 'active' ? 'badge badge-active' : 'badge badge-revoked'; + badge.textContent = k.status === 'active' ? 'Active' : 'Revoked'; + node.querySelector('.col-status').appendChild(badge); + + var actionsActive = node.querySelector('.actions-active'); + var actionsRevoked = node.querySelector('.actions-revoked'); + if (k.status === 'active') { + actionsActive.querySelector('[title="Copy key prefix"]') + .addEventListener('click', function() { copyPrefix(k.prefix); }); + actionsActive.querySelector('[title="Revoke key"]') + .addEventListener('click', function() { revokeKey(k.id); }); + } else { + actionsActive.style.display = 'none'; + actionsRevoked.style.display = ''; + actionsRevoked.querySelector('[title="Delete"]') + .addEventListener('click', function() { deleteKey(k.id); }); + } + + tbody.appendChild(node); + }); +} + +// ── Modal ──────────────────────────────────────────────────────────────────── + +var modal = document.getElementById('modal-create'); + +function openModal() { + document.getElementById('modal-form-body').style.display = ''; + document.getElementById('modal-reveal-body').style.display = 'none'; + document.getElementById('modal-footer-create').style.display = ''; + document.getElementById('modal-footer-done').style.display = 'none'; + document.getElementById('new-key-name').value = ''; + selectedScopes = new Set(['read']); + renderScopes(); + modal.classList.add('open'); + setTimeout(function() { document.getElementById('new-key-name').focus(); }, 50); +} + +function closeModal() { modal.classList.remove('open'); } + +document.getElementById('btn-create-key').addEventListener('click', openModal); +document.getElementById('modal-close-btn').addEventListener('click', closeModal); +document.getElementById('modal-cancel-btn').addEventListener('click', closeModal); +modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); }); + +document.getElementById('modal-confirm-btn').addEventListener('click', function() { + var name = document.getElementById('new-key-name').value.trim(); + if (!name) { document.getElementById('new-key-name').focus(); return; } + if (!selectedScopes.size) { alert('Select at least one scope.'); return; } + + var rawKey = createKey(name, Array.from(selectedScopes)); + + document.getElementById('reveal-key-value').textContent = rawKey; + document.getElementById('modal-form-body').style.display = 'none'; + document.getElementById('modal-reveal-body').style.display = ''; + document.getElementById('modal-footer-create').style.display = 'none'; + document.getElementById('modal-footer-done').style.display = ''; + renderTable(); +}); + +document.getElementById('modal-done-btn').addEventListener('click', closeModal); + +document.getElementById('copy-key-btn').addEventListener('click', function() { + var val = document.getElementById('reveal-key-value').textContent; + navigator.clipboard && navigator.clipboard.writeText(val); + this.innerHTML = ''; +}); + +document.getElementById('key-search').addEventListener('input', function() { + search = this.value; + renderTable(); +}); + +// ── Init ───────────────────────────────────────────────────────────────────── + +function copyPrefix(prefix) { + navigator.clipboard && navigator.clipboard.writeText(prefix); +} + +document.addEventListener('DOMContentLoaded', loadKeys); diff --git a/apps/ui/js/catalog.js b/apps/ui/js/catalog.js new file mode 100644 index 0000000..c9bd37c --- /dev/null +++ b/apps/ui/js/catalog.js @@ -0,0 +1,333 @@ +const PAGE_SIZE_DESKTOP = 6; +const PAGE_SIZE_MOBILE = 4; +const MOBILE_BREAKPOINT = 960; + +function getSortOptions() { + return [ + { value: 'created_desc', label: (window.t && t('sort.created_desc')) || 'Sort: Last created' }, + { value: 'created_asc', label: (window.t && t('sort.created_asc')) || 'Sort: Oldest first' }, + { value: 'name_asc', label: (window.t && t('sort.name_asc')) || 'Sort: Name A–Z' }, + { value: 'name_desc', label: (window.t && t('sort.name_desc')) || 'Sort: Name Z–A' }, + ]; +} + +function getTabDefs() { + return [ + { id: 'all', label: (window.t && t('tab.all')) || 'All' }, + { id: 'active', label: (window.t && t('tab.active')) || 'Active' }, + { id: 'draft', label: (window.t && t('tab.draft')) || 'Draft' }, + { id: 'error', label: (window.t && t('tab.error')) || 'Error' }, + { id: 'inactive', label: (window.t && t('tab.inactive')) || 'Inactive' }, + ]; +} + +document.addEventListener('alpine:init', () => { + + Alpine.data('catalog', () => ({ + // ── raw data ── + operations: [], + agents: [], + loading: true, + + // ── page size (responsive) ── + pageSize: PAGE_SIZE_DESKTOP, + + // ── filter state ── + search: '', + tab: 'all', + filterProtocol: null, + filterCategory: null, + filterAgent: null, + sort: 'created_desc', + page: 1, + + // ── dropdown open state ── + openDropdown: null, + + // ── mobile nav ── + navOpen: false, + + // ── derived option lists (built after data loads) ── + categoryOptions: [], + + async init() { + const [opsRes, agentsRes] = await Promise.all([ + fetch((window.DATA_URL||'data/')+'operations.json'), + fetch((window.DATA_URL||'data/')+'agents.json'), + ]); + let ops = await opsRes.json(); + this.agents = await agentsRes.json(); + + // Apply localStorage overrides (from wizard create/edit) + try { + const overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]'); + if (overrides.length) { + const existingIds = new Set(ops.map(o => o.id)); + ops = ops.map(op => { + const ov = overrides.find(o => o.id === op.id); + return ov ? Object.assign({}, op, ov) : op; + }); + overrides + .filter(o => !existingIds.has(o.id)) + .forEach(o => ops.push(o)); + } + } catch (e) {} + + this.operations = ops; + this.categoryOptions = [...new Set(this.operations.map(op => op.category))].filter(Boolean).sort(); + this.loading = false; + + // Responsive page size + const updatePageSize = () => { + this.pageSize = window.innerWidth <= MOBILE_BREAKPOINT ? PAGE_SIZE_MOBILE : PAGE_SIZE_DESKTOP; + this.page = 1; + }; + updatePageSize(); + window.addEventListener('resize', updatePageSize); + + // Re-render labels on language change + window.addEventListener('crank:langchange', () => { + this.sort = this.sort; + applyLang(); + }); + + // Close dropdowns and mobile nav on outside click + document.addEventListener('click', (e) => { + if (!e.target.closest('.filter-dropdown, .sort-dropdown, .user-menu')) { + this.openDropdown = null; + } + if (!e.target.closest('.navbar')) { + this.navOpen = false; + } + }); + }, + + // ── helpers for filtering (shared between filtered getter and tabCount) ── + _applyNonStatusFilters(ops) { + if (this.search.trim()) { + const q = this.search.toLowerCase(); + ops = ops.filter(op => + op.name.toLowerCase().includes(q) || + op.display_name.toLowerCase().includes(q) || + (op.target_url || '').toLowerCase().includes(q) + ); + } + if (this.filterProtocol) { + ops = ops.filter(op => op.protocol === this.filterProtocol); + } + if (this.filterCategory) { + ops = ops.filter(op => op.category === this.filterCategory); + } + if (this.filterAgent) { + const agent = this.agents.find(a => a.id === this.filterAgent); + const ids = agent ? agent.operation_ids : []; + ops = ops.filter(op => ids.includes(op.id)); + } + return ops; + }, + + // ── computed: filtered + sorted + paginated ── + get filtered() { + let ops = this._applyNonStatusFilters(this.operations); + + if (this.tab !== 'all') { + ops = ops.filter(op => op.status === this.tab); + } + + ops = [...ops].sort((a, b) => { + switch (this.sort) { + case 'created_desc': return new Date(b.created_at) - new Date(a.created_at); + case 'created_asc': return new Date(a.created_at) - new Date(b.created_at); + case 'name_asc': return a.name.localeCompare(b.name); + case 'name_desc': return b.name.localeCompare(a.name); + default: return 0; + } + }); + + return ops; + }, + + get totalFiltered() { return this.filtered.length; }, + get totalPages() { return Math.max(1, Math.ceil(this.totalFiltered / this.pageSize)); }, + + get paginated() { + const start = (this.page - 1) * this.pageSize; + return this.filtered.slice(start, start + this.pageSize); + }, + + get pageStart() { return this.totalFiltered === 0 ? 0 : (this.page - 1) * this.pageSize + 1; }, + get pageEnd() { return Math.min(this.page * this.pageSize, this.totalFiltered); }, + + // ── tab counts (respect non-status filters so numbers are consistent) ── + tabCount(tab) { + const ops = this._applyNonStatusFilters(this.operations); + if (tab === 'all') return ops.length; + return ops.filter(op => op.status === tab).length; + }, + + // ── active filter chips (for dismissible chip row) ── + get activeChips() { + const chips = []; + if (this.filterProtocol) { + chips.push({ key: 'protocol', label: 'Protocol: ' + this.filterProtocol.toUpperCase() }); + } + if (this.filterCategory) { + chips.push({ key: 'category', label: 'Category: ' + this.filterCategory }); + } + if (this.filterAgent) { + const a = this.agents.find(ag => ag.id === this.filterAgent); + chips.push({ key: 'agent', label: 'Agent: ' + (a ? a.display_name : '') }); + } + return chips; + }, + + get hasActiveFilters() { + return !!(this.filterProtocol || this.filterCategory || this.filterAgent || this.search.trim()); + }, + + clearChip(key) { + if (key === 'protocol') this.filterProtocol = null; + if (key === 'category') this.filterCategory = null; + if (key === 'agent') this.filterAgent = null; + this.page = 1; + }, + + clearAllFilters() { + this.filterProtocol = null; + this.filterCategory = null; + this.filterAgent = null; + this.search = ''; + this.tab = 'all'; + this.page = 1; + }, + + // ── agent lookup per operation (for badges in rows) ── + get agentsByOpId() { + const map = {}; + this.agents.forEach(a => { + a.operation_ids.forEach(id => { + if (!map[id]) map[id] = []; + map[id].push(a); + }); + }); + return map; + }, + + // ── active agents only (for filter dropdown) ── + get activeAgents() { + return this.agents.filter(a => a.status === 'active'); + }, + + // ── sort label ── + get sortLabel() { + return getSortOptions().find(o => o.value === this.sort)?.label ?? 'Sort'; + }, + + // ── actions ── + setTab(tab) { + this.tab = tab; + this.page = 1; + }, + + setSearch(val) { + this.search = val; + this.page = 1; + }, + + setProtocol(val) { + this.filterProtocol = this.filterProtocol === val ? null : val; + this.openDropdown = null; + this.page = 1; + }, + + setCategory(val) { + this.filterCategory = this.filterCategory === val ? null : val; + this.openDropdown = null; + this.page = 1; + }, + + setSort(val) { + this.sort = val; + this.openDropdown = null; + }, + + setAgent(val) { + this.filterAgent = this.filterAgent === val ? null : val; + this.openDropdown = null; + this.page = 1; + }, + + clearProtocol() { this.filterProtocol = null; this.openDropdown = null; this.page = 1; }, + clearCategory() { this.filterCategory = null; this.openDropdown = null; this.page = 1; }, + clearAgent() { this.filterAgent = null; this.openDropdown = null; this.page = 1; }, + + toggleDropdown(name) { + this.openDropdown = this.openDropdown === name ? null : name; + }, + + goPage(p) { + if (p >= 1 && p <= this.totalPages) this.page = p; + }, + + // ── operation CRUD ── + editOperation(op) { + try { sessionStorage.setItem('wizard_edit', JSON.stringify(op)); } catch (e) {} + window.location.href = (window.APP_BASE||'')+'html/wizard/?mode=edit'; + }, + + deleteOperation(id) { + if (!confirm('Delete this operation? This cannot be undone.')) return; + this.operations = this.operations.filter(op => op.id !== id); + // Persist deletion in overrides as a tombstone + try { + const overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]'); + const filtered = overrides.filter(o => o.id !== id); + filtered.push({ id, _deleted: true }); + localStorage.setItem('crank_ops_overrides', JSON.stringify(filtered)); + } catch (e) {} + }, + + // ── helpers для шаблона ── + protocolLabel(op) { + if (op.protocol === 'rest') return `REST · ${op.method}`; + if (op.protocol === 'graphql') return 'GraphQL'; + return 'gRPC'; + }, + + protocolClass(op) { + return `badge badge-${op.protocol}`; + }, + + statusClass(op) { + return `status-badge status-${op.status}`; + }, + + statusLabel(op) { + return op.status.charAt(0).toUpperCase() + op.status.slice(1); + }, + + formatDate(dateStr) { + if (!dateStr) return '—'; + return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + }, + + truncateUrl(url) { + if (!url) return ''; + return url.length > 42 ? url.slice(0, 42) + '…' : url; + }, + + get sortOptions() { return getSortOptions(); }, + get tabDefs() { return getTabDefs(); }, + + handleNewOperation() { + sessionStorage.removeItem('wizard_edit'); + window.location.href = (window.APP_BASE||'')+'html/wizard/'; + }, + + handleLogout() { + localStorage.removeItem('crank_user'); + window.location.href = (window.APP_BASE||'')+'html/login.html'; + }, + })); + +}); diff --git a/apps/ui/js/config.js b/apps/ui/js/config.js new file mode 100644 index 0000000..6021737 --- /dev/null +++ b/apps/ui/js/config.js @@ -0,0 +1,20 @@ +/** + * config.js — auto-detects base paths based on current page location. + * Must be loaded before workspace.js and other navigation-using scripts. + */ +(function() { + var p = window.location.pathname; + // html/wizard/ is two levels deep from root + if (/\/html\/wizard\//.test(p)) { + window.APP_BASE = '../../'; + window.DATA_URL = '../../data/'; + // html/*.html is one level deep from root + } else if (/\/html\//.test(p)) { + window.APP_BASE = '../'; + window.DATA_URL = '../data/'; + // root level (index.html) + } else { + window.APP_BASE = ''; + window.DATA_URL = 'data/'; + } +}()); diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js new file mode 100644 index 0000000..bef0ad1 --- /dev/null +++ b/apps/ui/js/i18n.js @@ -0,0 +1,289 @@ +/* ═══════════════════════════════════════════════ + Crank — i18n + Reads `crank_lang` from localStorage ('en'|'ru') + ═══════════════════════════════════════════════ */ + +var TRANSLATIONS = { + en: { + // Nav + 'nav.operations': 'Operations', + 'nav.agents': 'Agents', + 'nav.apikeys': 'API Keys', + 'nav.logs': 'Logs', + 'nav.usage': 'Usage', + 'nav.settings': 'Settings', + 'nav.logout': 'Log out', + + // Operations page + 'ops.title': 'Operations', + 'ops.subtitle': 'Tool contracts that expose your upstream APIs as MCP tools', + 'ops.new': 'New operation', + 'ops.loading': 'Loading operations…', + 'ops.empty.title': 'No operations found', + 'ops.empty.sub': 'Try adjusting your search or filters.', + + // Stats + 'stats.total': 'Total operations', + 'stats.active': 'Active', + 'stats.requests': 'Requests today', + 'stats.latency': 'Avg. latency', + + // Tabs + 'tab.all': 'All', + 'tab.active': 'Active', + 'tab.draft': 'Draft', + 'tab.error': 'Error', + 'tab.inactive': 'Inactive', + + // Filters & sort + 'filter.search': 'Search operations...', + 'filter.protocol': 'Protocol', + 'filter.status': 'Status', + 'filter.category': 'Category', + 'filter.showing': 'Showing', + 'filter.of': 'of', + 'filter.clear': 'Clear filter', + 'sort.created_desc':'Sort: Last created', + 'sort.created_asc': 'Sort: Oldest first', + 'sort.name_asc': 'Sort: Name A–Z', + 'sort.name_desc': 'Sort: Name Z–A', + + // Table headers + 'th.name': 'NAME', + 'th.protocol': 'PROTOCOL', + 'th.status': 'STATUS', + 'th.created': 'CREATED', + 'th.url': 'TARGET URL', + + // Pagination + 'page.showing': 'Showing', + 'page.of': 'of', + 'page.ops': 'operations', + + // API Keys page + 'apikeys.title': 'API Keys', + 'apikeys.subtitle': 'Manage access tokens for external integrations', + 'apikeys.new': 'Create key', + 'apikeys.th.name': 'NAME', + 'apikeys.th.scope': 'SCOPE', + 'apikeys.th.created':'CREATED', + 'apikeys.th.last': 'LAST USED', + + // Logs page + 'logs.title': 'Logs', + 'logs.subtitle': 'Real-time request and event log', + 'logs.search': 'Search logs...', + 'logs.level.all': 'All', + 'logs.level.info': 'Info', + 'logs.level.warn': 'Warn', + 'logs.level.error': 'Error', + 'logs.level.debug': 'Debug', + + // Usage page + 'usage.title': 'Usage', + 'usage.subtitle': 'Request volume and performance metrics', + + // Settings sidebar + 'settings.title': 'Settings', + 'settings.nav.workspace': 'Workspace', + 'settings.nav.profile': 'Profile', + 'settings.nav.members': 'Members', + 'settings.nav.security': 'Security', + 'settings.nav.notif': 'Notifications', + 'settings.nav.danger': 'Danger zone', + + // Settings — workspace section + 'settings.ws.title': 'Workspace settings', + 'settings.ws.subtitle': 'General information about your workspace', + 'settings.ws.name': 'Workspace name', + 'settings.ws.display': 'Display name', + 'settings.ws.desc': 'Description', + 'settings.ws.tz': 'Timezone', + 'settings.ws.protocol': 'Default protocol', + + // Settings — language + 'settings.lang.title': 'Language', + 'settings.lang.subtitle': 'Interface display language', + 'settings.lang.en': 'English', + 'settings.lang.ru': 'Русский', + + // Settings — profile section + 'settings.profile.title': 'Profile', + 'settings.profile.name': 'Full name', + 'settings.profile.email': 'Email', + 'settings.profile.role': 'Role', + + // Common buttons + 'btn.save': 'Save changes', + 'btn.cancel': 'Cancel', + 'btn.create': 'Create', + 'btn.delete': 'Delete', + 'btn.revoke': 'Revoke', + 'btn.copy': 'Copy', + + // Login + 'login.title': 'Sign in', + 'login.subtitle': 'Welcome back to your workspace', + 'login.email': 'Email', + 'login.password': 'Password', + 'login.submit': 'Sign in', + 'login.sso': 'Continue with Google', + }, + + ru: { + // Nav + 'nav.operations': 'Операции', + 'nav.agents': 'Агенты', + 'nav.apikeys': 'API Ключи', + 'nav.logs': 'Логи', + 'nav.usage': 'Использование', + 'nav.settings': 'Настройки', + 'nav.logout': 'Выйти', + + // Operations page + 'ops.title': 'Операции', + 'ops.subtitle': 'Контракты инструментов, открывающие ваши API как MCP-инструменты', + 'ops.new': 'Новая операция', + 'ops.loading': 'Загрузка операций…', + 'ops.empty.title': 'Операции не найдены', + 'ops.empty.sub': 'Попробуйте изменить поисковый запрос или фильтры.', + + // Stats + 'stats.total': 'Всего операций', + 'stats.active': 'Активные', + 'stats.requests': 'Запросов сегодня', + 'stats.latency': 'Ср. задержка', + + // Tabs + 'tab.all': 'Все', + 'tab.active': 'Активные', + 'tab.draft': 'Черновик', + 'tab.error': 'Ошибка', + 'tab.inactive': 'Неактивные', + + // Filters & sort + 'filter.search': 'Поиск операций...', + 'filter.protocol': 'Протокол', + 'filter.status': 'Статус', + 'filter.category': 'Категория', + 'filter.showing': 'Показано', + 'filter.of': 'из', + 'filter.clear': 'Сбросить', + 'sort.created_desc':'Сначала новые', + 'sort.created_asc': 'Сначала старые', + 'sort.name_asc': 'Название А–Я', + 'sort.name_desc': 'Название Я–А', + + // Table headers + 'th.name': 'НАЗВАНИЕ', + 'th.protocol': 'ПРОТОКОЛ', + 'th.status': 'СТАТУС', + 'th.created': 'СОЗДАН', + 'th.url': 'ЦЕЛЕВОЙ URL', + + // Pagination + 'page.showing': 'Показано', + 'page.of': 'из', + 'page.ops': 'операций', + + // API Keys page + 'apikeys.title': 'API Ключи', + 'apikeys.subtitle': 'Управление токенами доступа для внешних интеграций', + 'apikeys.new': 'Создать ключ', + 'apikeys.th.name': 'НАЗВАНИЕ', + 'apikeys.th.scope': 'ОБЛАСТЬ', + 'apikeys.th.created':'СОЗДАН', + 'apikeys.th.last': 'ПОСЛЕДНЕЕ ИСПОЛЬЗОВАНИЕ', + + // Logs page + 'logs.title': 'Логи', + 'logs.subtitle': 'Журнал запросов и событий в реальном времени', + 'logs.search': 'Поиск в логах...', + 'logs.level.all': 'Все', + 'logs.level.info': 'Info', + 'logs.level.warn': 'Warn', + 'logs.level.error': 'Error', + 'logs.level.debug': 'Debug', + + // Usage page + 'usage.title': 'Использование', + 'usage.subtitle': 'Объём запросов и метрики производительности', + + // Settings sidebar + 'settings.title': 'Настройки', + 'settings.nav.workspace': 'Воркспейс', + 'settings.nav.profile': 'Профиль', + 'settings.nav.members': 'Участники', + 'settings.nav.security': 'Безопасность', + 'settings.nav.notif': 'Уведомления', + 'settings.nav.danger': 'Опасная зона', + + // Settings — workspace section + 'settings.ws.title': 'Настройки воркспейса', + 'settings.ws.subtitle': 'Общая информация о вашем воркспейсе', + 'settings.ws.name': 'Имя воркспейса', + 'settings.ws.display': 'Отображаемое имя', + 'settings.ws.desc': 'Описание', + 'settings.ws.tz': 'Часовой пояс', + 'settings.ws.protocol': 'Протокол по умолчанию', + + // Settings — language + 'settings.lang.title': 'Язык', + 'settings.lang.subtitle': 'Язык отображения интерфейса', + 'settings.lang.en': 'English', + 'settings.lang.ru': 'Русский', + + // Settings — profile section + 'settings.profile.title': 'Профиль', + 'settings.profile.name': 'Полное имя', + 'settings.profile.email': 'Email', + 'settings.profile.role': 'Роль', + + // Common buttons + 'btn.save': 'Сохранить', + 'btn.cancel': 'Отмена', + 'btn.create': 'Создать', + 'btn.delete': 'Удалить', + 'btn.revoke': 'Отозвать', + 'btn.copy': 'Копировать', + + // Login + 'login.title': 'Войти', + 'login.subtitle': 'Добро пожаловать обратно', + 'login.email': 'Email', + 'login.password': 'Пароль', + 'login.submit': 'Войти', + 'login.sso': 'Войти через Google', + } +}; + +function t(key) { + var lang = localStorage.getItem('crank_lang') || 'en'; + var tr = TRANSLATIONS[lang] || TRANSLATIONS.en; + return tr[key] !== undefined ? tr[key] : (TRANSLATIONS.en[key] !== undefined ? TRANSLATIONS.en[key] : key); +} + +function applyLang() { + // text content + document.querySelectorAll('[data-i18n]').forEach(function(el) { + el.textContent = t(el.getAttribute('data-i18n')); + }); + // placeholder + document.querySelectorAll('[data-i18n-ph]').forEach(function(el) { + el.placeholder = t(el.getAttribute('data-i18n-ph')); + }); + // highlight active language button in settings + var lang = localStorage.getItem('crank_lang') || 'en'; + document.querySelectorAll('.lang-btn').forEach(function(btn) { + btn.classList.toggle('active', btn.dataset.lang === lang); + }); +} + +function setLang(lang) { + localStorage.setItem('crank_lang', lang); + applyLang(); + // Notify Alpine components to re-render reactive getters + window.dispatchEvent(new CustomEvent('crank:langchange', { detail: { lang: lang } })); +} + +document.addEventListener('DOMContentLoaded', applyLang); diff --git a/apps/ui/js/login.js b/apps/ui/js/login.js new file mode 100644 index 0000000..ca5e702 --- /dev/null +++ b/apps/ui/js/login.js @@ -0,0 +1,34 @@ +// If already logged in, redirect to home +try { + if (localStorage.getItem('crank_user')) { + window.location.replace((window.APP_BASE||'')+'index.html'); + } +} catch (e) {} + +document.getElementById('login-form').addEventListener('submit', function (e) { + e.preventDefault(); + var email = document.getElementById('email').value.trim(); + var password = document.getElementById('password').value; + var errEl = document.getElementById('login-error'); + + if (!email || !password) { + errEl.style.display = 'block'; + errEl.textContent = 'Please enter your email and password.'; + return; + } + + // Demo: any non-empty credentials succeed + errEl.style.display = 'none'; + var initials = email.split('@')[0].slice(0, 2).toUpperCase(); + var displayName = email.split('@')[0].replace(/[._]/g, ' ') + .split(' ').map(function(w) { return w.charAt(0).toUpperCase() + w.slice(1); }).join(' '); + var user = { + name: displayName, + email: email, + role: 'Admin', + workspace: 'acme-workspace', + initials: initials, + }; + try { localStorage.setItem('crank_user', JSON.stringify(user)); } catch (ex) {} + window.location.href = (window.APP_BASE||'')+'index.html'; +}); diff --git a/apps/ui/js/logs.js b/apps/ui/js/logs.js new file mode 100644 index 0000000..2ef3086 --- /dev/null +++ b/apps/ui/js/logs.js @@ -0,0 +1,487 @@ +// logs.js — Crank log viewer + +document.addEventListener('DOMContentLoaded', function () { + + // ── Helpers ──────────────────────────────────────────────────────────────── + + function minsAgo(mins) { + return new Date(Date.now() - mins * 60 * 1000); + } + + function fmtTs(date) { + var d = (date instanceof Date) ? date : new Date(date); + var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; + return ( + d.getFullYear() + '-' + + pad(d.getMonth() + 1) + '-' + + pad(d.getDate()) + ' ' + + pad(d.getHours()) + ':' + + pad(d.getMinutes()) + ':' + + pad(d.getSeconds()) + '.' + + pad(d.getMilliseconds(), 3) + ); + } + + // ── Seed log data ────────────────────────────────────────────────────────── + // Timestamps are computed relative to Date.now() so time-range filtering works. + + var LOGS = [ + { + id: 1, ts: minsAgo(2), level: 'info', op: 'create_crm_lead', + msg: 'Tool invoked — POST /v1/leads', duration: '142ms', status: 201, + detail: { + req: '{"first_name":"Alice","last_name":"Smith","email":"alice@acme.com"}', + res: '{"lead_id":"ld_9f3a","status":"new","created_at":"2026-03-27T14:32:11Z"}' + } + }, + { + id: 2, ts: minsAgo(5), level: 'info', op: 'get_user_profile', + msg: 'Tool invoked — GET /v1/users/me', duration: '38ms', status: 200, + detail: { + req: '{"user_id":"usr_0042"}', + res: '{"id":"usr_0042","name":"Alice Smith","email":"alice@acme.com","role":"admin"}' + } + }, + { + id: 3, ts: minsAgo(12), level: 'warn', op: 'send_slack_message', + msg: 'Rate limited by Slack API — retrying after back-off', duration: '1842ms', status: 429, + detail: { + req: '{"channel":"#ops","text":"Deploy finished"}', + res: '{"error":"ratelimited","retry_after":30}' + } + }, + { + id: 4, ts: minsAgo(18), level: 'info', op: 'fetch_invoice', + msg: 'Tool invoked — GET /invoices/inv_003', duration: '95ms', status: 200, + detail: { + req: '{"invoice_id":"inv_003"}', + res: '{"id":"inv_003","amount":2400,"currency":"USD","status":"paid"}' + } + }, + { + id: 5, ts: minsAgo(26), level: 'error', op: 'stream_telemetry', + msg: 'Upstream unavailable', duration: '0ms', status: 503, + detail: { + req: '{"stream_id":"st_881","since":"2026-03-27T14:00:00Z"}', + res: '503 Service Unavailable\nRetry-After: 60' + } + }, + { + id: 6, ts: minsAgo(38), level: 'info', op: 'create_crm_lead', + msg: 'Tool invoked — POST /v1/leads', duration: '167ms', status: 201, + detail: { + req: '{"first_name":"Bob","last_name":"Jones","email":"bob@example.com"}', + res: '{"lead_id":"ld_9f3b","status":"new"}' + } + }, + { + id: 7, ts: minsAgo(52), level: 'debug', op: 'get_user_profile', + msg: 'Schema validation passed — 4 fields mapped', duration: '41ms', status: 200, + detail: { + req: '{"user_id":"usr_0041"}', + res: '{"id":"usr_0041","name":"Bob Jones","email":"bob@example.com","role":"viewer"}' + } + }, + { + id: 8, ts: minsAgo(75), level: 'info', op: 'list_products', + msg: 'Tool invoked — GET /api/products', duration: '210ms', status: 200, + detail: { + req: '{"limit":20,"page":1}', + res: '{"total":142,"items":[{"id":"p01","name":"Mechanical Keyboard","price":149.99},...]}' + } + }, + { + id: 9, ts: minsAgo(110), level: 'info', op: 'create_support_ticket', + msg: 'Tool invoked — POST /v1/tickets', duration: '178ms', status: 201, + detail: { + req: '{"subject":"Login issue","priority":"high","user_id":"usr_0042"}', + res: '{"ticket_id":"tkt_5521","status":"open","created_at":"2026-03-27T12:42:11Z"}' + } + }, + { + id: 10, ts: minsAgo(190), level: 'error', op: 'send_slack_message', + msg: 'Upstream returned 503 Service Unavailable — retried 3×', duration: '0ms', status: 503, + detail: { + req: '{"channel":"#alerts","text":"DB failover triggered"}', + res: '503 Service Unavailable\nRetry-After: 30' + } + }, + { + id: 11, ts: minsAgo(280), level: 'info', op: 'fetch_invoice', + msg: 'Tool invoked — GET /invoices/inv_002', duration: '88ms', status: 200, + detail: { + req: '{"invoice_id":"inv_002"}', + res: '{"id":"inv_002","amount":1250,"currency":"USD","status":"open"}' + } + }, + { + id: 12, ts: minsAgo(420), level: 'warn', op: 'get_order_status', + msg: 'Validation error: missing field order_id', duration: '55ms', status: 422, + detail: { + req: '{"customer_id":"cus_009"}', + res: '{"error":"validation_failed","fields":["order_id"]}' + } + }, + { + id: 13, ts: minsAgo(720), level: 'info', op: 'create_crm_lead', + msg: 'Tool invoked — POST /v1/leads', duration: '155ms', status: 200, + detail: { + req: '{"first_name":"Carol","last_name":"Davis","email":"carol@co.io"}', + res: '{"lead_id":"ld_9f3c","status":"new"}' + } + }, + { + id: 14, ts: minsAgo(2200), level: 'info', op: 'sync_contacts', + msg: 'Tool invoked — POST /v1/contacts/sync', duration: '340ms', status: 200, + detail: { + req: '{"source":"hubspot","since":"2026-03-25T00:00:00Z"}', + res: '{"synced":48,"skipped":2,"errors":0}' + } + }, + { + id: 15, ts: minsAgo(7200), level: 'debug', op: 'list_products', + msg: 'Cache miss — fetching from origin', duration: '198ms', status: 200, + detail: { + req: '{"limit":10,"page":1}', + res: '{"total":142,"items":[...]}' + } + } + ]; + + // ── State ────────────────────────────────────────────────────────────────── + + var levelFilter = 'all'; + var searchText = ''; + var timeRange = '7d'; + var openId = null; + var liveMode = true; + var liveTimer = null; + var nextId = 100; + + // ── DOM refs ─────────────────────────────────────────────────────────────── + + var logList = document.getElementById('log-list'); + var logSearch = document.getElementById('log-search'); + var refreshBtn = document.getElementById('refresh-btn'); + var timeRangeSel = document.getElementById('time-range'); + var liveDot = document.querySelector('.live-dot'); + var liveLabel = document.querySelector('.live-label'); + + // ── Debug chip injection ─────────────────────────────────────────────────── + // The HTML has All/Info/Warn/Error chips. We add Debug after Error. + + var chipError = document.getElementById('chip-error'); + if (chipError && !document.getElementById('chip-debug')) { + var debugChip = document.createElement('button'); + debugChip.className = 'filter-chip'; + debugChip.setAttribute('data-level', 'debug'); + debugChip.id = 'chip-debug'; + var debugSpan = document.createElement('span'); + debugSpan.className = 'log-level debug'; + debugSpan.style.cssText = 'padding:0 4px;font-size:10px;'; + debugSpan.textContent = 'DEBUG'; + debugChip.appendChild(debugSpan); + chipError.parentNode.insertBefore(debugChip, chipError.nextSibling); + } + + // ── Time-range helpers ───────────────────────────────────────────────────── + + function rangeMs(range) { + var map = { '30m': 30, '1h': 60, '6h': 360, '24h': 1440, '7d': 10080 }; + var mins = map[range] || 10080; + return mins * 60 * 1000; + } + + // ── Render ───────────────────────────────────────────────────────────────── + + function statusClass(s) { + if (!s) return ''; + if (s < 300) return 'ok'; + if (s < 500) return 'warn'; + return 'err'; + } + + function escText(str) { + // Safely return string; all user-facing dynamic strings go through textContent, + // but for pre blocks in detail we use textContent assignment too (see below). + return str; + } + + function renderLogs() { + var q = searchText.toLowerCase(); + var cutoff = Date.now() - rangeMs(timeRange); + + var rows = LOGS.filter(function (l) { + var ts = (l.ts instanceof Date) ? l.ts.getTime() : new Date(l.ts).getTime(); + var matchTime = ts >= cutoff; + var matchLevel = levelFilter === 'all' || l.level === levelFilter; + var matchSearch = !q || + l.msg.toLowerCase().indexOf(q) !== -1 || + l.op.indexOf(q) !== -1; + return matchTime && matchLevel && matchSearch; + }); + + // Build DOM nodes instead of innerHTML for XSS safety where data is dynamic. + // For static/controlled detail payloads we still use textContent on pre elements. + var frag = document.createDocumentFragment(); + + if (!rows.length) { + var empty = document.createElement('div'); + empty.style.cssText = 'text-align:center;padding:40px;color:var(--text-muted);font-size:13.5px;'; + empty.textContent = 'No log entries match the current filter'; + frag.appendChild(empty); + } + + rows.forEach(function (l) { + var tsDate = (l.ts instanceof Date) ? l.ts : new Date(l.ts); + var timeStr = fmtTs(tsDate).slice(11, 23); // HH:MM:SS.mmm + + // ── Row ── + var row = document.createElement('div'); + row.className = 'log-entry'; + row.setAttribute('data-id', l.id); + + var timeEl = document.createElement('span'); + timeEl.className = 'log-time'; + timeEl.textContent = timeStr; + row.appendChild(timeEl); + + var levelEl = document.createElement('span'); + levelEl.className = 'log-level ' + l.level; + levelEl.textContent = l.level.toUpperCase(); + row.appendChild(levelEl); + + var msgDiv = document.createElement('div'); + msgDiv.className = 'log-msg'; + + var opSpan = document.createElement('span'); + opSpan.className = 'log-op-name'; + opSpan.textContent = l.op; + msgDiv.appendChild(opSpan); + + msgDiv.appendChild(document.createTextNode('\u00a0\u00a0')); // two nbsps + + if (l.status) { + var statusEl = document.createElement('span'); + statusEl.className = 'log-status ' + statusClass(l.status); + statusEl.textContent = l.status; + msgDiv.appendChild(statusEl); + msgDiv.appendChild(document.createTextNode(' \u00b7 ')); // · separator + } + + var msgText = document.createTextNode(l.msg); + msgDiv.appendChild(msgText); + row.appendChild(msgDiv); + + var durEl = document.createElement('span'); + var durSlow = (l.duration.indexOf('s') !== -1 && parseFloat(l.duration) > 1); + var durClass = durSlow ? 'slow' : (l.level === 'error' ? 'error' : ''); + durEl.className = 'log-duration' + (durClass ? ' ' + durClass : ''); + durEl.textContent = l.duration; + row.appendChild(durEl); + + frag.appendChild(row); + + // ── Expanded detail ── + if (l.detail) { + var expDiv = document.createElement('div'); + expDiv.className = 'log-entry-expanded' + (l.id === openId ? ' open' : ''); + expDiv.setAttribute('data-exp', l.id); + + if (l.detail.req) { + var reqLabel = document.createElement('div'); + reqLabel.className = 'log-detail-label'; + reqLabel.textContent = 'Request body'; + expDiv.appendChild(reqLabel); + + var reqPre = document.createElement('pre'); + reqPre.className = 'log-detail-block'; + reqPre.textContent = l.detail.req; + expDiv.appendChild(reqPre); + } + + if (l.detail.res) { + var resLabel = document.createElement('div'); + resLabel.className = 'log-detail-label'; + resLabel.textContent = 'Response'; + expDiv.appendChild(resLabel); + + var resPre = document.createElement('pre'); + resPre.className = 'log-detail-block'; + resPre.textContent = l.detail.res; + expDiv.appendChild(resPre); + } + + frag.appendChild(expDiv); + } + }); + + logList.innerHTML = ''; + logList.appendChild(frag); + + // Attach click handlers for expand/collapse + logList.querySelectorAll('.log-entry').forEach(function (el) { + el.addEventListener('click', function () { + var id = parseInt(this.getAttribute('data-id'), 10); + openId = (openId === id) ? null : id; + logList.querySelectorAll('.log-entry-expanded').forEach(function (exp) { + var expId = parseInt(exp.getAttribute('data-exp'), 10); + exp.classList.toggle('open', expId === openId); + }); + }); + }); + } + + // ── Level chip wiring ────────────────────────────────────────────────────── + + function wireChips() { + document.querySelectorAll('.filter-chip[data-level]').forEach(function (btn) { + btn.addEventListener('click', function () { + levelFilter = this.getAttribute('data-level'); + document.querySelectorAll('.filter-chip[data-level]').forEach(function (b) { + b.classList.remove('active'); + }); + this.classList.add('active'); + renderLogs(); + }); + }); + } + + wireChips(); + + // ── Search ───────────────────────────────────────────────────────────────── + + logSearch.addEventListener('input', function () { + searchText = this.value; + renderLogs(); + }); + + // ── Refresh ──────────────────────────────────────────────────────────────── + + refreshBtn.addEventListener('click', renderLogs); + + // ── Time-range select ────────────────────────────────────────────────────── + // The HTML uses a ' + + '' + + ''; + rows.appendChild(div); +} + +function removeInviteRow(btn) { + btn.closest('.invite-row').remove(); +} + +// Members management (edit mode) +function toggleInviteForm() { + var f = document.getElementById('invite-form'); + var s = document.getElementById('invite-success'); + if (f.style.display === 'none') { + f.style.display = ''; + s.style.display = 'none'; + document.getElementById('invite-email').focus(); + } else { + f.style.display = 'none'; + } +} + +function sendInvite() { + var email = document.getElementById('invite-email').value.trim(); + if (!email || !email.includes('@')) return; + document.getElementById('invite-success').style.display = ''; + document.getElementById('invite-email').value = ''; +} + +function updateRole(select, email) { + console.log('Role updated:', email, '->', select.value); +} + +function removeMember(btn, name) { + if (!confirm('Remove ' + name + ' from this workspace?')) return; + btn.closest('.member-row').remove(); + var rows = document.querySelectorAll('#members-list .member-row'); + var label = document.getElementById('members-count-label'); + if (label) label.textContent = rows.length + ' member' + (rows.length !== 1 ? 's' : ''); +} + +function revokeInvite(btn) { + btn.closest('.member-row').remove(); + var pending = document.getElementById('pending-invites'); + if (pending && !pending.querySelector('.member-row')) pending.style.display = 'none'; +} + +document.addEventListener('DOMContentLoaded', function() { + initPage(); + if (isCreateMode) document.getElementById('ws-name').focus(); +}); + +window.addEventListener('beforeunload', function(e) { + if (formDirty) { + e.preventDefault(); + e.returnValue = ''; + } +}); diff --git a/apps/ui/js/workspace.js b/apps/ui/js/workspace.js new file mode 100644 index 0000000..833da4a --- /dev/null +++ b/apps/ui/js/workspace.js @@ -0,0 +1,64 @@ +/* ═══════════════════════════════════════════════════ + Crank — workspace switcher + ═══════════════════════════════════════════════════ */ + +var WS_LIST = [ + { slug: 'acme-workspace', name: 'acme-workspace', role: 'Owner', letter: 'A', color: '#0d9488' }, + { slug: 'startup-demo', name: 'startup-demo', role: 'Developer', letter: 'S', color: '#7c3aed' }, + { slug: 'personal', name: 'personal', role: 'Owner', letter: 'P', color: '#0891b2' }, +]; + +function getCurrentWs() { + var slug = localStorage.getItem('crank_workspace') || 'acme-workspace'; + return WS_LIST.find(function(w) { return w.slug === slug; }) || WS_LIST[0]; +} + +function initWorkspaceSwitcher() { + var ws = getCurrentWs(); + var nameEl = document.getElementById('ws-current-name'); + var dotEl = document.getElementById('ws-dot'); + if (nameEl) nameEl.textContent = ws.name; + if (dotEl) { dotEl.textContent = ws.letter; dotEl.style.background = ws.color; } + + var list = document.getElementById('ws-dropdown-list'); + if (!list) return; + + list.innerHTML = WS_LIST.map(function(w) { + var active = w.slug === ws.slug; + return '
' + + '
' + w.letter + '
' + + '
' + + '
' + w.name + '
' + + '
' + w.role + '
' + + '
' + + (active ? '' : '') + + '
'; + }).join('') + + '
' + + '' + + '' + + 'Workspace settings' + + ''; +} + +function toggleWsSwitcher(e) { + e.stopPropagation(); + var dd = document.getElementById('ws-dropdown'); + if (dd) dd.style.display = dd.style.display === 'none' ? '' : 'none'; +} + +function switchWorkspace(slug) { + localStorage.setItem('crank_workspace', slug); + var dd = document.getElementById('ws-dropdown'); + if (dd) dd.style.display = 'none'; + initWorkspaceSwitcher(); +} + +document.addEventListener('DOMContentLoaded', initWorkspaceSwitcher); + +document.addEventListener('click', function(e) { + if (!e.target.closest('#ws-switcher')) { + var dd = document.getElementById('ws-dropdown'); + if (dd) dd.style.display = 'none'; + } +}); From 4721bc19488e6ac7873262491a1ecf3ba45c7f2f Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 30 Mar 2026 00:19:07 +0300 Subject: [PATCH 2/5] docs: define alpine ui integration plan --- README.md | 1 + docs/alpine-ui-integration-plan.md | 476 +++++++++++++++++++++++++++++ 2 files changed, 477 insertions(+) create mode 100644 docs/alpine-ui-integration-plan.md diff --git a/README.md b/README.md index e728264..9c6d41c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Crank - платформа для публикации внешних API в в - `docs/as-is-to-be.md` - переход `as is -> to be`, page-by-page gap analysis и архитектурные конфликты. - `docs/backend-gap-plan.md` - конкретный backend-план: сущности, API, БД и порядок реализации. - `docs/operations-workspace-contracts.md` - точные `workspace-scoped` контракты для экранов `Operations` и `Wizard`. +- `docs/alpine-ui-integration-plan.md` - постраничный план подключения нового Alpine UI к реальному backend. - `docs/module-decomposition.md` - декомпозиция crates и модулей. - `docs/data-model.md` - целевая модель данных. - `docs/database-schema.md` - целевая схема БД. diff --git a/docs/alpine-ui-integration-plan.md b/docs/alpine-ui-integration-plan.md new file mode 100644 index 0000000..e94bfe8 --- /dev/null +++ b/docs/alpine-ui-integration-plan.md @@ -0,0 +1,476 @@ +# Alpine UI Integration Plan + +## 1. Назначение документа + +Этот документ фиксирует, как переносимый Alpine UI из `apps/ui` должен подключаться к реальному backend. + +Его задача: + +- разложить UI по страницам; +- показать, что уже поддержано текущим backend; +- зафиксировать отсутствующие endpoint-ы и контрактные разрывы; +- определить порядок интеграции без хаотичных правок. + +## 2. Текущее состояние + +Сейчас `apps/ui` уже заменен на Alpine.js UI, перенесенный из `test-ui`. + +Важно: + +- `apps/ui` теперь является основной UI-кодовой базой; +- `test-ui` остается fallback-источником и не должен использоваться как рабочая папка интеграции; +- новый UI пока опирается на `localStorage`, mock JSON и локальные сценарии; +- backend уже поддерживает `workspace`, `operations`, `agents`, `platform access`, `logs`, `usage`, но не все UI-flow закрыты полностью. + +## 3. Принципы интеграции + +- сначала подключаем страницы, уже совпадающие с текущим backend-контрактом; +- затем добавляем недостающие endpoint-ы под уже существующие UI-flow; +- только после этого убираем mock JSON и `localStorage`-костыли; +- если UI противоречит текущему backend, приоритет у целевой продуктовой модели, но конфликт должен быть разобран явно. + +## 4. Page-by-page integration matrix + +### 4.1. Operations catalog + +UI-файлы: + +- `apps/ui/index.html` +- `apps/ui/js/catalog.js` + +Что UI хочет: + +- список операций; +- фильтры по protocol/category/agent/status; +- карточки верхних метрик; +- переход в wizard create/edit; +- удаление/архивирование; +- отображение агентных привязок. + +Что уже можно подключить: + +- `GET /api/admin/workspaces/{workspace_id}/operations` +- `GET /api/admin/workspaces/{workspace_id}/agents` +- `GET /api/admin/workspaces/{workspace_id}/usage` + +Что еще не хватает: + +- `PATCH /api/admin/workspaces/{workspace_id}/operations/{operation_id}` +- `DELETE /api/admin/workspaces/{workspace_id}/operations/{operation_id}` +- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive` +- server-side shape для list response с готовыми agent bindings и компактными summary fields + +Что убрать из UI после интеграции: + +- `crank_ops_overrides` в `localStorage` +- merge поверх `data/operations.json` +- локальный tombstone/delete flow + +Простой итог: + +- эту страницу можно интегрировать первой; +- базовые list/fetch данные backend уже умеет; +- надо только добить update/delete/archive и перестать хранить изменения в браузере. + +### 4.2. Wizard + +UI-файлы: + +- `apps/ui/html/wizard/index.html` +- `apps/ui/html/wizard/step*.html` +- `apps/ui/js/wizard.js` + +Что UI хочет: + +- create operation; +- edit operation; +- draft/save flow; +- test run; +- publish; +- загрузку samples; +- генерацию draft mapping; +- gRPC descriptor upload; +- import/export. + +Что уже можно подключить: + +- `POST /api/admin/workspaces/{workspace_id}/operations` +- `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}` +- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions` +- `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions/{version}` +- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/test-runs` +- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/input-json` +- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/output-json` +- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/drafts/generate` +- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/descriptors/proto` +- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/descriptors/descriptor-set` +- `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/grpc/services` +- `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/export` +- `POST /api/admin/workspaces/{workspace_id}/operations/import` + +Что еще не хватает: + +- `PATCH /api/admin/workspaces/{workspace_id}/operations/{operation_id}` для edit mode +- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive` +- единая DTO-форма для create/edit, чтобы UI не собирал разные payload вручную по шагам + +Что убрать из UI после интеграции: + +- `sessionStorage`-передачу `wizard_edit` +- draft-overrides через `localStorage` +- локальное создание operation без backend + +Простой итог: + +- wizard почти готов для реального backend; +- это основной экран второй очереди после catalog; +- ключевой разрыв сейчас только в полноценном edit/update контракте. + +### 4.3. Agents + +UI-файлы: + +- `apps/ui/html/agents.html` +- `apps/ui/js/agents.js` + +Что UI хочет: + +- список агентов; +- create/edit agent; +- выбор операций для агента; +- publish agent; +- MCP endpoint на агента; +- calls today / operation count / key count. + +Что уже можно подключить: + +- `GET /api/admin/workspaces/{workspace_id}/agents` +- `POST /api/admin/workspaces/{workspace_id}/agents` +- `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}` +- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings` +- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/publish` +- `GET /api/admin/workspaces/{workspace_id}/usage` + +Что еще не хватает: + +- `PATCH /api/admin/workspaces/{workspace_id}/agents/{agent_id}` +- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}` +- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/versions` +- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings/{operation_id}` +- более богатый `GET /agents` response: + - `calls_today` + - `key_count` + - `operation_count` + - `mcp_endpoint` + +Отдельный конфликт: + +- UI считает, что агент можно редактировать прямо как рабочую сущность; +- backend пока ближе к publish-модели с version snapshot; +- нужно решить, edit агента мутирует draft напрямую или всегда создает новую version. + +Простой итог: + +- база для agents уже есть; +- но UI требует более полного lifecycle, чем backend поддерживает сейчас. + +### 4.4. API Keys + +UI-файлы: + +- `apps/ui/html/api-keys.html` +- `apps/ui/js/api-keys.js` + +Что UI хочет: + +- список platform API keys; +- create; +- one-time reveal; +- revoke; +- delete; +- scopes. + +Что уже можно подключить: + +- `GET /api/admin/workspaces/{workspace_id}/platform-api-keys` +- `POST /api/admin/workspaces/{workspace_id}/platform-api-keys` +- `POST /api/admin/workspaces/{workspace_id}/platform-api-keys/{key_id}/revoke` +- `DELETE /api/admin/workspaces/{workspace_id}/platform-api-keys/{key_id}` + +Что еще не хватает: + +- финальная договоренность по scope model; +- согласование response shape с UI: + - `secret` + - `prefix` + - `status` + - `last_used_at` + +Отдельный конфликт: + +- UI сейчас обещает scope `deploy` и формулировки про rollback/deployments; +- backend этого продуктово не поддерживает; +- scope-тексты надо выровнять до интеграции. + +Простой итог: + +- эта страница почти закрывается текущим backend; +- здесь нужен не новый слой, а выравнивание payload и текстов. + +### 4.5. Logs + +UI-файлы: + +- `apps/ui/html/logs.html` +- `apps/ui/js/logs.js` + +Что UI хочет: + +- список invocation logs; +- фильтры; +- detail row expansion; +- live refresh; +- status/error/duration. + +Что уже можно подключить: + +- `GET /api/admin/workspaces/{workspace_id}/logs` +- `GET /api/admin/workspaces/{workspace_id}/logs/{log_id}` + +Что еще не хватает: + +- frontend adapter с трансформацией backend response в текущую log-row форму; +- polling strategy вместо локального seeded массива; +- явная поддержка `debug/info/warn/error` в UI без локального seed; +- если нужен live mode, то либо polling, либо отдельный future streaming endpoint + +Простой итог: + +- backend foundation для logs уже есть; +- страница интегрируется быстро, если заменить seeded `LOGS` на API polling. + +### 4.6. Usage + +UI-файлы: + +- `apps/ui/html/usage.html` +- `apps/ui/js/usage.js` + +Что UI хочет: + +- верхние summary cards; +- timeline; +- breakdown по operations; +- p50/p95/p99; +- breakdown по agents; +- CSV export. + +Что уже можно подключить: + +- `GET /api/admin/workspaces/{workspace_id}/usage` +- `GET /api/admin/workspaces/{workspace_id}/usage/operations/{operation_id}` +- `GET /api/admin/workspaces/{workspace_id}/usage/agents/{agent_id}` + +Что еще не хватает: + +- frontend mapping из backend `summary/timeline/operations/agents` в текущие widgets; +- `CSV export` endpoint или серверная договоренность, если экспорт не хотим делать на клиенте; +- согласование полей latency percentiles в одном формате + +Простой итог: + +- usage page можно подключать сразу после logs; +- главная работа здесь на frontend adapter, а не на новом backend-ядре. + +### 4.7. Workspace setup + +UI-файлы: + +- `apps/ui/html/workspace-setup.html` +- `apps/ui/js/workspace-setup.js` +- `apps/ui/js/workspace.js` + +Что UI хочет: + +- create workspace; +- edit workspace; +- список участников; +- приглашения; +- смену текущего workspace. + +Что уже можно подключить: + +- `GET /api/admin/workspaces` +- `POST /api/admin/workspaces` +- `GET /api/admin/workspaces/{workspace_id}` +- `PATCH /api/admin/workspaces/{workspace_id}` +- `GET /api/admin/workspaces/{workspace_id}/members` +- `GET /api/admin/workspaces/{workspace_id}/invitations` +- `POST /api/admin/workspaces/{workspace_id}/invitations` +- `DELETE /api/admin/workspaces/{workspace_id}/invitations/{invitation_id}` + +Что еще не хватает: + +- `switch current workspace` как backend-aware UI state, а не `localStorage`; +- нормальная серверная модель current user + memberships; +- endpoint на удаление участника или изменение роли, если UI хочет это поддерживать + +Отдельный конфликт: + +- UI создает workspace локально и сразу считает его текущим; +- backend уже умеет создавать workspace, но у нас нет полноценной user-session модели; +- значит выбор текущего workspace пока придется держать на клиенте, но список и metadata брать с сервера. + +Простой итог: + +- create/edit workspace уже можно подключать; +- members/invites тоже частично готовы; +- но полноценный switch и role management еще не закрыты. + +### 4.8. Settings + +UI-файлы: + +- `apps/ui/html/settings.html` +- `apps/ui/js/settings.js` + +Что UI хочет: + +- профиль пользователя; +- security/preferences; +- workspace settings; +- language switcher. + +Что уже можно подключить: + +- частично `GET /api/admin/workspaces/{workspace_id}` +- `PATCH /api/admin/workspaces/{workspace_id}` + +Что еще не хватает: + +- current user endpoint; +- user profile update endpoint; +- preferences endpoint; +- security/auth settings model + +Отдельный конфликт: + +- UI считает, что у нас уже есть app-level user profile; +- backend пока не имеет session/user profile API; +- страницу стоит разбить: + - workspace settings подключать раньше; + - profile/security оставить временно mock или read-only. + +Простой итог: + +- эту страницу нельзя подключать целиком сразу; +- сначала надо отделить workspace settings от пользовательского профиля. + +### 4.9. Login + +UI-файлы: + +- `apps/ui/html/login.html` +- `apps/ui/js/login.js` + +Что UI хочет: + +- app-level sign in; +- mock SSO button; +- user session. + +Что уже есть реально: + +- внешний `Basic Auth` на `nginx` + +Что отсутствует: + +- session auth backend; +- login endpoint; +- logout endpoint; +- current user endpoint + +Отдельный конфликт: + +- это не баг реализации, а отсутствие целого auth слоя; +- login page пока не должна позиционироваться как production-ready flow. + +Простой итог: + +- пока не интегрировать как реальный backend flow; +- оставить как временный mock screen до отдельного решения по auth. + +## 5. Отдельные UI-vs-backend конфликты + +### 5.1. Login vs Basic Auth + +UI предполагает встроенный login, backend сейчас защищен внешним `Basic Auth`. + +Решение: + +- не делать вид, что login уже рабочий; +- отложить интеграцию login до отдельного auth этапа. + +### 5.2. Agent draft/edit lifecycle + +UI редактирует агента напрямую, backend идет к version/publish модели. + +Решение: + +- зафиксировать draft agent как редактируемую сущность; +- publish должен фиксировать snapshot; +- UI не должен скрывать разницу между draft и published. + +### 5.3. API key scopes + +UI вводит scope `deploy`, backend пока не отражает полноценную deploy-подсистему. + +Решение: + +- либо урезать scope-тексты; +- либо позже расширять platform access под реальный deploy scope. + +### 5.4. Workspace switching + +UI хранит current workspace в `localStorage`. + +Решение: + +- на ближайшем этапе оставить client-side current workspace; +- данные workspace брать с backend; +- позже заменить на session-aware current workspace model. + +## 6. Порядок интеграции + +### Wave 1 + +- `Operations catalog` +- `Wizard` + +### Wave 2 + +- `Agents` +- `API Keys` + +### Wave 3 + +- `Logs` +- `Usage` + +### Wave 4 + +- `Workspace setup` +- частичный `Settings` + +### Wave 5 + +- `Login` +- полноценный session/auth layer + +## 7. Ближайший практический шаг + +Следующий рабочий этап: + +- реализовать frontend adapters для `Operations catalog` и `Wizard`; +- добить backend `PATCH/DELETE/archive` по operations; +- убрать `localStorage` overrides и mock `operations.json` для первых двух экранов. From 23ca2cda599bbafa74afd6be0ed14f6acf973806 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 30 Mar 2026 00:42:41 +0300 Subject: [PATCH 3/5] feat: add operations mutation api for alpine ui --- TASKS.md | 2 + apps/admin-api/src/app.rs | 175 +++++++++++- apps/admin-api/src/error.rs | 5 + apps/admin-api/src/routes/operations.rs | 54 +++- apps/admin-api/src/service.rs | 346 +++++++++++++++++++++++- apps/mcp-server/src/main.rs | 3 + crates/crank-core/src/operation.rs | 8 + crates/crank-registry/src/error.rs | 2 + crates/crank-registry/src/lib.rs | 13 +- crates/crank-registry/src/migrations.rs | 6 + crates/crank-registry/src/model.rs | 17 ++ crates/crank-registry/src/postgres.rs | 306 +++++++++++++++++++-- crates/crank-runtime/src/executor.rs | 3 + docs/alpine-ui-integration-plan.md | 23 +- 14 files changed, 907 insertions(+), 56 deletions(-) diff --git a/TASKS.md b/TASKS.md index 959ed3d..643552d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -11,6 +11,8 @@ DoD: - `test-ui` остается нетронутым как fallback - UI продолжает раздаваться как статический контейнер через текущий deployment path - legacy React/Vite артефакты удалены из `apps/ui` +- backend для `Operations/Wizard` поддерживает server-side `PATCH`, `DELETE`, `archive`, `category`, `usage_summary`, `agent_refs` +- первые экраны Alpine UI можно сажать на реальные catalog/detail/mutation endpoints без расширения backend-контракта ## Next diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index ebf8a7f..d80adf7 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -1,6 +1,6 @@ use axum::{ Router, - routing::{get, post}, + routing::{delete, get, post}, }; use crate::{ @@ -16,10 +16,10 @@ use crate::{ auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs}, operations::{ - create_operation, create_version, export_operation, generate_draft, get_operation, - get_operation_version, list_grpc_services, list_operations, publish_operation, - run_test, upload_descriptor_set, upload_input_json, upload_output_json, - upload_proto_descriptor, + archive_operation, create_operation, create_version, delete_operation, + export_operation, generate_draft, get_operation, get_operation_version, + list_grpc_services, list_operations, publish_operation, run_test, update_operation, + upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_descriptor, }, workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace}, }, @@ -30,7 +30,12 @@ pub fn build_app(state: AppState) -> Router { let workspace_router = Router::new() .route("/operations", get(list_operations).post(create_operation)) .route("/operations/import", post(import_operation)) - .route("/operations/{operation_id}", get(get_operation)) + .route( + "/operations/{operation_id}", + get(get_operation) + .patch(update_operation) + .delete(delete_operation), + ) .route("/operations/{operation_id}/versions", post(create_version)) .route( "/operations/{operation_id}/versions/{version}", @@ -40,6 +45,10 @@ pub fn build_app(state: AppState) -> Router { "/operations/{operation_id}/publish", post(publish_operation), ) + .route( + "/operations/{operation_id}/archive", + post(archive_operation), + ) .route("/operations/{operation_id}/test-runs", post(run_test)) .route( "/operations/{operation_id}/samples/input-json", @@ -84,10 +93,7 @@ pub fn build_app(state: AppState) -> Router { "/invitations", get(list_invitations).post(create_invitation), ) - .route( - "/invitations/{invitation_id}", - axum::routing::delete(delete_invitation), - ) + .route("/invitations/{invitation_id}", delete(delete_invitation)) .route( "/platform-api-keys", get(list_platform_api_keys).post(create_platform_api_key), @@ -98,7 +104,7 @@ pub fn build_app(state: AppState) -> Router { ) .route( "/platform-api-keys/{key_id}", - axum::routing::delete(delete_platform_api_key), + delete(delete_platform_api_key), ) .route("/logs", get(list_logs)) .route("/logs/{log_id}", get(get_log)) @@ -213,6 +219,150 @@ mod tests { assert_eq!(test_run["response_preview"]["id"], "lead_123"); } + #[tokio::test] + async fn updates_archives_and_deletes_operation() { + let registry = test_registry().await; + let storage_root = test_storage_root("operation_mutations"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = reqwest::Client::new(); + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "crm_mutable_operation", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let listed = client + .get(format!("{base_url}/operations")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(listed["total"], 1); + assert_eq!(listed["items"][0]["category"], "sales"); + + let updated = client + .patch(format!("{base_url}/operations/{operation_id}")) + .json(&json!({ + "display_name": "Create Lead Updated", + "category": "marketing", + "target": { + "kind": "rest", + "base_url": upstream_base_url, + "method": "POST", + "path_template": "/crm/leads", + "static_headers": {} + }, + "input_schema": { + "type": "object", + "required": true, + "nullable": false, + "fields": { + "email": { + "type": "string", + "required": true, + "nullable": false + } + } + }, + "output_schema": { + "type": "object", + "required": true, + "nullable": false, + "fields": { + "id": { + "type": "string", + "required": true, + "nullable": false + } + } + }, + "input_mapping": { + "rules": [ + { + "source": "$.mcp.email", + "target": "$.request.body.email", + "required": true + } + ] + }, + "output_mapping": { + "rules": [ + { + "source": "$.response.body.id", + "target": "$.output.id", + "required": true + } + ] + }, + "execution_config": { + "timeout_ms": 1000, + "headers": {} + }, + "tool_description": { + "title": "Create Lead Updated", + "description": "Creates a CRM lead", + "tags": ["crm"] + } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(updated["status"], "draft"); + + let detail = client + .get(format!("{base_url}/operations/{operation_id}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(detail["display_name"], "Create Lead Updated"); + assert_eq!(detail["category"], "marketing"); + + let archived = client + .post(format!("{base_url}/operations/{operation_id}/archive")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(archived["status"], "archived"); + + let deleted = client + .delete(format!("{base_url}/operations/{operation_id}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(deleted["operation_id"], operation_id); + + let missing = client + .get(format!("{base_url}/operations/{operation_id}")) + .send() + .await + .unwrap(); + assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND); + } + #[tokio::test] async fn creates_binds_and_publishes_agent() { let registry = test_registry().await; @@ -984,6 +1134,7 @@ mod tests { OperationPayload { name: name.to_owned(), display_name: "Create Lead".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Rest, target: Target::Rest(RestTarget { base_url: base_url.to_owned(), @@ -1035,6 +1186,7 @@ mod tests { OperationPayload { name: name.to_owned(), display_name: "Create Lead GraphQL".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Graphql, target: Target::Graphql(GraphqlTarget { endpoint: endpoint.to_owned(), @@ -1089,6 +1241,7 @@ mod tests { OperationPayload { name: name.to_owned(), display_name: "Unary Echo gRPC".to_owned(), + category: "support".to_owned(), protocol: Protocol::Grpc, target: Target::Grpc(GrpcTarget { server_addr: server_addr.to_owned(), diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index b962ea4..0ee380e 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -126,6 +126,11 @@ impl From for ApiError { } => Self::not_found(format!( "operation version {version} for {operation_id} was not found" )), + RegistryError::OperationHasPublishedAgentBindings { operation_id } => { + Self::conflict(format!( + "operation {operation_id} cannot be deleted while it is bound to a published agent" + )) + } RegistryError::AuthProfileNotFound { auth_profile_id } => { Self::not_found(format!("auth profile {auth_profile_id} was not found")) } diff --git a/apps/admin-api/src/routes/operations.rs b/apps/admin-api/src/routes/operations.rs index 59aa591..6820ffb 100644 --- a/apps/admin-api/src/routes/operations.rs +++ b/apps/admin-api/src/routes/operations.rs @@ -12,7 +12,7 @@ use crate::{ error::ApiError, service::{ ExportQuery, GenerateDraftPayload, ImportQuery, NewVersionPayload, OperationPayload, - PublishPayload, TestRunPayload, + PublishPayload, TestRunPayload, UpdateOperationPayload, }, state::AppState, }; @@ -43,7 +43,13 @@ pub async fn list_operations( .service .list_operations(&path.workspace_id.as_str().into()) .await?; - Ok(Json(json!({ "items": items }))) + let total = items.len(); + Ok(Json(json!({ + "items": items, + "page": 1, + "page_size": total, + "total": total + }))) } pub async fn create_operation( @@ -72,6 +78,36 @@ pub async fn get_operation( Ok(Json(json!(operation))) } +pub async fn update_operation( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let result = state + .service + .update_operation( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + payload, + ) + .await?; + Ok(Json(json!(result))) +} + +pub async fn delete_operation( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let result = state + .service + .delete_operation( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + ) + .await?; + Ok(Json(json!(result))) +} + pub async fn get_operation_version( Path(path): Path, State(state): State, @@ -119,6 +155,20 @@ pub async fn publish_operation( Ok(Json(json!(published))) } +pub async fn archive_operation( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let archived = state + .service + .archive_operation( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + ) + .await?; + Ok(Json(json!(archived))) +} + pub async fn run_test( Path(path): Path, State(state): State, diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index ecdbe27..2f75082 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::path::PathBuf; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; @@ -15,11 +16,12 @@ use crank_registry::{ AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, - MembershipRecord, OperationSampleMetadata, OperationSummary, OperationVersionRecord, - PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, - SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, - SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, - UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, WorkspaceRecord, + MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, + OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PostgresRegistry, + PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, SaveAgentBindingsRequest, + SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, + UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, + UsageSummary, UsageTimelinePoint, WorkspaceRecord, }; use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation}; use crank_schema::Schema; @@ -43,6 +45,8 @@ pub struct AdminService { pub struct OperationPayload { pub name: String, pub display_name: String, + #[serde(default = "default_operation_category")] + pub category: String, pub protocol: Protocol, pub target: Target, pub input_schema: Schema, @@ -240,6 +244,7 @@ pub struct CreatedOperationResponse { pub workspace_id: String, pub version: u32, pub status: OperationStatus, + pub updated_at: String, } #[derive(Clone, Debug, Serialize)] @@ -250,6 +255,86 @@ pub struct PublishResponse { pub published_at: String, } +#[derive(Clone, Debug, Deserialize)] +pub struct UpdateOperationPayload { + pub display_name: String, + #[serde(default = "default_operation_category")] + pub category: String, + pub target: Target, + pub input_schema: Schema, + pub output_schema: Schema, + pub input_mapping: MappingSet, + pub output_mapping: MappingSet, + pub execution_config: crank_core::ExecutionConfig, + pub tool_description: crank_core::ToolDescription, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OperationUsageSummaryView { + pub calls_today: u64, + pub error_rate_pct: f64, + pub avg_latency_ms: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OperationAgentRefView { + pub agent_id: String, + pub agent_slug: String, + pub display_name: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OperationSummaryView { + pub id: String, + pub workspace_id: String, + pub name: String, + pub display_name: String, + pub category: String, + pub protocol: Protocol, + pub status: OperationStatus, + pub current_draft_version: u32, + pub latest_published_version: Option, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, + pub usage_summary: OperationUsageSummaryView, + pub agent_refs: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OperationDetailView { + pub id: String, + pub workspace_id: String, + pub name: String, + pub display_name: String, + pub category: String, + pub protocol: Protocol, + pub status: OperationStatus, + pub current_draft_version: u32, + pub latest_published_version: Option, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, + pub draft_version_ref: VersionRef, + pub published_version_ref: Option, + pub agent_refs: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct VersionRef { + pub version: u32, + pub status: OperationStatus, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OperationMutationResult { + pub operation_id: String, + pub workspace_id: String, + pub version: u32, + pub status: OperationStatus, + pub updated_at: String, +} + #[derive(Clone, Debug, Serialize)] pub struct ImportResponse { pub operation_id: String, @@ -280,6 +365,10 @@ pub struct GrpcMethodSummary { pub output_schema: Schema, } +fn default_operation_category() -> String { + "general".to_owned() +} + impl AdminService { pub fn new(registry: PostgresRegistry, storage_root: PathBuf) -> Self { Self { @@ -630,9 +719,37 @@ impl AdminService { pub async fn list_operations( &self, workspace_id: &WorkspaceId, - ) -> Result, ApiError> { + ) -> Result, ApiError> { self.ensure_workspace_exists(workspace_id).await?; - Ok(self.registry.list_operations(workspace_id).await?) + let summaries = self.registry.list_operations(workspace_id).await?; + let usage = self + .registry + .list_operation_usage_summaries(workspace_id, &today_start_utc()?) + .await?; + let agent_refs = self + .registry + .list_operation_agent_refs(workspace_id) + .await?; + let usage_by_operation = usage_map(usage); + let refs_by_operation = agent_ref_map(agent_refs); + + Ok(summaries + .into_iter() + .map(|summary| { + let operation_id = summary.id.as_str().to_owned(); + enrich_operation_summary( + summary, + usage_by_operation + .get(&operation_id) + .cloned() + .unwrap_or_else(default_usage_summary), + refs_by_operation + .get(&operation_id) + .cloned() + .unwrap_or_default(), + ) + }) + .collect()) } #[instrument(skip(self))] @@ -640,13 +757,45 @@ impl AdminService { &self, workspace_id: &WorkspaceId, operation_id: &OperationId, - ) -> Result { - self.registry + ) -> Result { + let summary = self + .registry .get_operation_summary(workspace_id, operation_id) .await? .ok_or_else(|| { ApiError::not_found(format!("operation {} was not found", operation_id.as_str())) - }) + })?; + let agent_refs = self + .registry + .list_operation_agent_refs(workspace_id) + .await?; + let refs = agent_ref_map(agent_refs) + .remove(operation_id.as_str()) + .unwrap_or_default(); + + Ok(OperationDetailView { + id: summary.id.as_str().to_owned(), + workspace_id: summary.workspace_id.as_str().to_owned(), + name: summary.name, + display_name: summary.display_name, + category: summary.category, + protocol: summary.protocol, + status: summary.status, + current_draft_version: summary.current_draft_version, + latest_published_version: summary.latest_published_version, + created_at: summary.created_at, + updated_at: summary.updated_at, + published_at: summary.published_at, + draft_version_ref: VersionRef { + version: summary.current_draft_version, + status: summary.status, + }, + published_version_ref: summary.latest_published_version.map(|version| VersionRef { + version, + status: OperationStatus::Published, + }), + agent_refs: refs, + }) } #[instrument(skip(self))] @@ -693,6 +842,7 @@ impl AdminService { id: operation_id.clone(), name: payload.name, display_name: payload.display_name, + category: payload.category, protocol: payload.protocol, status: OperationStatus::Draft, version: 1, @@ -724,6 +874,7 @@ impl AdminService { workspace_id: workspace_id.as_str().to_owned(), version: 1, status: OperationStatus::Draft, + updated_at: snapshot.updated_at, }) } @@ -743,6 +894,7 @@ impl AdminService { id: operation_id.clone(), name: payload.operation.name, display_name: payload.operation.display_name, + category: payload.operation.category, protocol: payload.operation.protocol, status: OperationStatus::Draft, version, @@ -779,6 +931,62 @@ impl AdminService { workspace_id: workspace_id.as_str().to_owned(), version, status: OperationStatus::Draft, + updated_at: snapshot.updated_at, + }) + } + + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str()))] + pub async fn update_operation( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + payload: UpdateOperationPayload, + ) -> Result { + let existing = self + .get_operation_version( + workspace_id, + operation_id, + self.get_operation(workspace_id, operation_id) + .await? + .current_draft_version, + ) + .await?; + + let updated_at = now_string()?; + let snapshot = RegistryOperation { + id: operation_id.clone(), + name: existing.snapshot.name, + display_name: payload.display_name, + category: payload.category, + protocol: existing.snapshot.protocol, + status: OperationStatus::Draft, + version: existing.version, + target: payload.target, + input_schema: payload.input_schema, + output_schema: payload.output_schema, + input_mapping: payload.input_mapping, + output_mapping: payload.output_mapping, + execution_config: payload.execution_config, + tool_description: payload.tool_description, + samples: existing.snapshot.samples, + generated_draft: existing.snapshot.generated_draft, + config_export: existing.snapshot.config_export, + created_at: existing.snapshot.created_at, + updated_at: updated_at.clone(), + published_at: existing.snapshot.published_at, + }; + + self.validate_registry_operation(&snapshot)?; + self.registry + .update_operation_draft(workspace_id, &snapshot) + .await?; + + Ok(OperationMutationResult { + operation_id: operation_id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + version: snapshot.version, + status: snapshot.status, + updated_at, }) } @@ -809,6 +1017,48 @@ impl AdminService { }) } + #[instrument(skip(self), fields(operation_id = %operation_id.as_str()))] + pub async fn archive_operation( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + ) -> Result { + let summary = self.get_operation(workspace_id, operation_id).await?; + let updated_at = now_string()?; + self.registry + .archive_operation(workspace_id, operation_id, &updated_at) + .await?; + + Ok(OperationMutationResult { + operation_id: operation_id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + version: summary.current_draft_version, + status: OperationStatus::Archived, + updated_at, + }) + } + + #[instrument(skip(self), fields(operation_id = %operation_id.as_str()))] + pub async fn delete_operation( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + ) -> Result { + let summary = self.get_operation(workspace_id, operation_id).await?; + let updated_at = now_string()?; + self.registry + .delete_operation(workspace_id, operation_id) + .await?; + + Ok(OperationMutationResult { + operation_id: operation_id.as_str().to_owned(), + workspace_id: workspace_id.as_str().to_owned(), + version: summary.current_draft_version, + status: summary.status, + updated_at, + }) + } + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), version = payload.version))] pub async fn run_test( &self, @@ -1328,6 +1578,7 @@ impl AdminService { let payload = OperationPayload { name: document.operation.name.clone(), display_name: document.operation.display_name.clone(), + category: document.operation.category.clone(), protocol: document.operation.protocol, target: document.operation.target.clone(), input_schema: document.operation.input_schema.clone(), @@ -1515,6 +1766,13 @@ impl AdminService { Ok(()) } + fn validate_registry_operation(&self, operation: &RegistryOperation) -> Result<(), ApiError> { + validate_protocol_target(operation.protocol, &operation.target)?; + operation.input_mapping.validate_paths()?; + operation.output_mapping.validate_paths()?; + Ok(()) + } + async fn find_operation_by_name( &self, workspace_id: &WorkspaceId, @@ -1755,6 +2013,74 @@ fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket )) } +fn today_start_utc() -> Result { + OffsetDateTime::now_utc() + .replace_time(time::Time::MIDNIGHT) + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string())) +} + +fn default_usage_summary() -> OperationUsageSummaryView { + OperationUsageSummaryView { + calls_today: 0, + error_rate_pct: 0.0, + avg_latency_ms: 0, + } +} + +fn usage_map(items: Vec) -> BTreeMap { + items + .into_iter() + .map(|item| { + ( + item.operation_id.as_str().to_owned(), + OperationUsageSummaryView { + calls_today: item.calls_today, + error_rate_pct: item.error_rate_pct, + avg_latency_ms: item.avg_latency_ms, + }, + ) + }) + .collect() +} + +fn agent_ref_map(items: Vec) -> BTreeMap> { + let mut map = BTreeMap::>::new(); + for item in items { + map.entry(item.operation_id.as_str().to_owned()) + .or_default() + .push(OperationAgentRefView { + agent_id: item.agent_id.as_str().to_owned(), + agent_slug: item.agent_slug, + display_name: item.display_name, + }); + } + map +} + +fn enrich_operation_summary( + summary: OperationSummary, + usage_summary: OperationUsageSummaryView, + agent_refs: Vec, +) -> OperationSummaryView { + OperationSummaryView { + id: summary.id.as_str().to_owned(), + workspace_id: summary.workspace_id.as_str().to_owned(), + name: summary.name, + display_name: summary.display_name, + category: summary.category, + protocol: summary.protocol, + status: summary.status, + current_draft_version: summary.current_draft_version, + latest_published_version: summary.latest_published_version, + created_at: summary.created_at, + updated_at: summary.updated_at, + published_at: summary.published_at, + usage_summary, + agent_refs, + } +} + fn proto_service_summary(service: ProtoService) -> Result { let methods = service .unary_methods() diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 91db26e..f66de57 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -706,6 +706,7 @@ mod tests { id: OperationId::new(format!("op_{name}")), name: name.to_owned(), display_name: "Create Lead".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Rest, status: OperationStatus::Published, version: 1, @@ -766,6 +767,7 @@ mod tests { id: OperationId::new(format!("op_{name}")), name: name.to_owned(), display_name: "Create Lead GraphQL".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Graphql, status: OperationStatus::Published, version: 1, @@ -829,6 +831,7 @@ mod tests { id: OperationId::new(format!("op_{name}")), name: name.to_owned(), display_name: "Unary Echo gRPC".to_owned(), + category: "support".to_owned(), protocol: Protocol::Grpc, status: OperationStatus::Published, version: 1, diff --git a/crates/crank-core/src/operation.rs b/crates/crank-core/src/operation.rs index 48d9f39..99c42a3 100644 --- a/crates/crank-core/src/operation.rs +++ b/crates/crank-core/src/operation.rs @@ -8,6 +8,10 @@ use crate::{ protocol::{ExportMode, GraphqlOperationType, HttpMethod, Protocol}, }; +fn default_operation_category() -> String { + "general".to_owned() +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OperationStatus { @@ -144,6 +148,8 @@ pub struct Operation { pub id: OperationId, pub name: String, pub display_name: String, + #[serde(default = "default_operation_category")] + pub category: String, pub protocol: Protocol, pub status: OperationStatus, pub version: u32, @@ -241,6 +247,7 @@ mod tests { id: OperationId::new("op_01"), name: "crm_create_lead".to_owned(), display_name: "Create Lead".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Rest, status: OperationStatus::Draft, version: 1, @@ -315,6 +322,7 @@ mod tests { id: OperationId::new("op_01"), name: "crm_create_lead".to_owned(), display_name: "Create Lead".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Rest, status: OperationStatus::Published, version: 3, diff --git a/crates/crank-registry/src/error.rs b/crates/crank-registry/src/error.rs index cb5d1e6..f407c4c 100644 --- a/crates/crank-registry/src/error.rs +++ b/crates/crank-registry/src/error.rs @@ -29,6 +29,8 @@ pub enum RegistryError { OperationNotFound { operation_id: String }, #[error("operation version {version} for {operation_id} was not found")] OperationVersionNotFound { operation_id: String, version: u32 }, + #[error("operation {operation_id} cannot be deleted while it is bound to a published agent")] + OperationHasPublishedAgentBindings { operation_id: String }, #[error("operation {operation_id} must start with version 1, got {version}")] InvalidInitialVersion { operation_id: String, version: u32 }, #[error("operation {operation_id} expected next version {expected}, got {actual}")] diff --git a/crates/crank-registry/src/lib.rs b/crates/crank-registry/src/lib.rs index d5e2cfa..99278fd 100644 --- a/crates/crank-registry/src/lib.rs +++ b/crates/crank-registry/src/lib.rs @@ -9,11 +9,12 @@ pub use model::{ CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, - OperationSampleMetadata, OperationSummary, OperationVersionRecord, PlatformApiKeyRecord, - PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation, SampleKind, - SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, - SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, - UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint, - WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, + OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary, + OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, + PublishedAgentTool, RegistryOperation, SampleKind, SaveAgentBindingsRequest, + SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, + UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, + UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceRecord, YamlImportJob, + YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, }; pub use postgres::PostgresRegistry; diff --git a/crates/crank-registry/src/migrations.rs b/crates/crank-registry/src/migrations.rs index 355beaf..12089e2 100644 --- a/crates/crank-registry/src/migrations.rs +++ b/crates/crank-registry/src/migrations.rs @@ -165,6 +165,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { workspace_id text null references workspaces(id) on delete cascade, name text not null, display_name text not null, + category text not null default 'general', protocol text not null, status text not null, current_draft_version integer not null default 1, @@ -180,6 +181,11 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { query("alter table operations add column if not exists workspace_id text null references workspaces(id) on delete cascade") .execute(pool) .await?; + query( + "alter table operations add column if not exists category text not null default 'general'", + ) + .execute(pool) + .await?; query("update operations set workspace_id = 'ws_default' where workspace_id is null") .execute(pool) .await?; diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index c159ffd..311db22 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -155,6 +155,7 @@ pub struct OperationSummary { pub workspace_id: WorkspaceId, pub name: String, pub display_name: String, + pub category: String, pub protocol: Protocol, pub status: OperationStatus, pub current_draft_version: u32, @@ -164,6 +165,22 @@ pub struct OperationSummary { pub published_at: Option, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct OperationUsageSummary { + pub operation_id: OperationId, + pub calls_today: u64, + pub error_rate_pct: f64, + pub avg_latency_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperationAgentRef { + pub operation_id: OperationId, + pub agent_id: AgentId, + pub agent_slug: String, + pub display_name: String, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct OperationVersionRecord { pub operation_id: OperationId, diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index fe3820b..f6b3ed8 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -18,13 +18,14 @@ use crate::{ AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord, - InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationSampleMetadata, - OperationSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, - PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest, - SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, - UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, - UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceRecord, YamlImportJob, - YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, + InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, + OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, + PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool, + RegistryOperation, SaveAgentBindingsRequest, SaveAuthProfileRequest, + SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest, + UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, + UsageTimelinePoint, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, + YamlImportJobId, YamlImportJobStatus, }, }; @@ -1084,6 +1085,7 @@ impl PostgresRegistry { o.id, o.name, o.display_name, + o.category, o.protocol, to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, @@ -1192,6 +1194,7 @@ impl PostgresRegistry { workspace_id, name, display_name, + category, protocol, status, current_draft_version, @@ -1200,16 +1203,17 @@ impl PostgresRegistry { updated_at, published_at ) values ( - $1, $2, $3, $4, $5, $6, $7, $8, - $9::timestamptz, + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz, - $11::timestamptz + $11::timestamptz, + $12::timestamptz )", ) .bind(snapshot.id.as_str()) .bind(workspace_id.as_str()) .bind(&snapshot.name) .bind(&snapshot.display_name) + .bind(&snapshot.category) .bind(serialize_enum_text(&snapshot.protocol, "protocol")?) .bind(serialize_enum_text(&snapshot.status, "status")?) .bind(to_db_version(snapshot.version)) @@ -1283,6 +1287,203 @@ impl PostgresRegistry { Ok(()) } + pub async fn update_operation_draft( + &self, + workspace_id: &WorkspaceId, + snapshot: &RegistryOperation, + ) -> Result<(), RegistryError> { + let Some(summary) = self + .get_operation_summary(workspace_id, &snapshot.id) + .await? + else { + return Err(RegistryError::OperationNotFound { + operation_id: snapshot.id.as_str().to_owned(), + }); + }; + + if snapshot.version != summary.current_draft_version { + return Err(RegistryError::InvalidVersionSequence { + operation_id: snapshot.id.as_str().to_owned(), + expected: summary.current_draft_version, + actual: snapshot.version, + }); + } + + assert_immutable_fields(&summary, snapshot)?; + + let mut tx = self.pool.begin().await?; + + sqlx::query( + "update operations + set display_name = $1, + category = $2, + status = $3, + updated_at = $4::timestamptz + where workspace_id = $5 and id = $6", + ) + .bind(&snapshot.display_name) + .bind(&snapshot.category) + .bind(serialize_enum_text(&snapshot.status, "status")?) + .bind(&snapshot.updated_at) + .bind(workspace_id.as_str()) + .bind(snapshot.id.as_str()) + .execute(&mut *tx) + .await?; + + sqlx::query( + "update operation_versions + set status = $1, + target_json = $2, + input_schema_json = $3, + output_schema_json = $4, + input_mapping_json = $5, + output_mapping_json = $6, + execution_config_json = $7, + tool_description_json = $8, + samples_json = $9, + generated_draft_json = $10, + config_export_json = $11 + where operation_id = $12 and version = $13", + ) + .bind(serialize_enum_text(&snapshot.status, "status")?) + .bind(Json(serialize_json_value(&snapshot.target)?)) + .bind(Json(serialize_json_value(&snapshot.input_schema)?)) + .bind(Json(serialize_json_value(&snapshot.output_schema)?)) + .bind(Json(serialize_json_value(&snapshot.input_mapping)?)) + .bind(Json(serialize_json_value(&snapshot.output_mapping)?)) + .bind(Json(serialize_json_value(&snapshot.execution_config)?)) + .bind(Json(serialize_json_value(&snapshot.tool_description)?)) + .bind(Json( + snapshot + .samples + .clone() + .map(|value| serialize_json_value(&value)) + .transpose()?, + )) + .bind(Json( + snapshot + .generated_draft + .clone() + .map(|value| serialize_json_value(&value)) + .transpose()?, + )) + .bind(Json( + snapshot + .config_export + .clone() + .map(|value| serialize_json_value(&value)) + .transpose()?, + )) + .bind(snapshot.id.as_str()) + .bind(to_db_version(snapshot.version)) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) + } + + pub async fn archive_operation( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + archived_at: &str, + ) -> Result<(), RegistryError> { + let Some(summary) = self + .get_operation_summary(workspace_id, operation_id) + .await? + else { + return Err(RegistryError::OperationNotFound { + operation_id: operation_id.as_str().to_owned(), + }); + }; + + let mut tx = self.pool.begin().await?; + + sqlx::query( + "update operations + set status = $1, + updated_at = $2::timestamptz + where workspace_id = $3 and id = $4", + ) + .bind(serialize_enum_text(&OperationStatus::Archived, "status")?) + .bind(archived_at) + .bind(workspace_id.as_str()) + .bind(operation_id.as_str()) + .execute(&mut *tx) + .await?; + + sqlx::query( + "update operation_versions + set status = $1 + where operation_id = $2 and version = $3", + ) + .bind(serialize_enum_text(&OperationStatus::Archived, "status")?) + .bind(operation_id.as_str()) + .bind(to_db_version(summary.current_draft_version)) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) + } + + pub async fn delete_operation( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + ) -> Result<(), RegistryError> { + if self + .has_published_agent_bindings_for_operation(workspace_id, operation_id) + .await? + { + return Err(RegistryError::OperationHasPublishedAgentBindings { + operation_id: operation_id.as_str().to_owned(), + }); + } + + let deleted = sqlx::query( + "delete from operations + where workspace_id = $1 and id = $2", + ) + .bind(workspace_id.as_str()) + .bind(operation_id.as_str()) + .execute(&self.pool) + .await? + .rows_affected(); + + if deleted == 0 { + return Err(RegistryError::OperationNotFound { + operation_id: operation_id.as_str().to_owned(), + }); + } + + Ok(()) + } + + pub async fn has_published_agent_bindings_for_operation( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + ) -> Result { + let row = sqlx::query( + "select 1 + from agents a + join published_agents pa on pa.agent_id = a.id + join agent_operation_bindings b + on b.agent_id = a.id and b.agent_version = pa.version + where a.workspace_id = $1 + and b.operation_id = $2 + limit 1", + ) + .bind(workspace_id.as_str()) + .bind(operation_id.as_str()) + .fetch_optional(&self.pool) + .await?; + + Ok(row.is_some()) + } + pub async fn list_operations( &self, workspace_id: &WorkspaceId, @@ -1293,6 +1494,7 @@ impl PostgresRegistry { workspace_id, name, display_name, + category, protocol, status, current_draft_version, @@ -1311,6 +1513,57 @@ impl PostgresRegistry { rows.iter().map(map_operation_summary).collect() } + pub async fn list_operation_usage_summaries( + &self, + workspace_id: &WorkspaceId, + created_after: &str, + ) -> Result, RegistryError> { + let rows = sqlx::query( + "select + operation_id, + count(*)::bigint as calls_today, + coalesce( + round((sum(case when status = 'error' then 1 else 0 end)::numeric / nullif(count(*), 0)) * 100, 2), + 0 + )::float8 as error_rate_pct, + coalesce(round(avg(duration_ms))::bigint, 0) as avg_latency_ms + from invocation_logs + where workspace_id = $1 + and created_at >= $2::timestamptz + group by operation_id", + ) + .bind(workspace_id.as_str()) + .bind(created_after) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_operation_usage_summary).collect() + } + + pub async fn list_operation_agent_refs( + &self, + workspace_id: &WorkspaceId, + ) -> Result, RegistryError> { + let rows = sqlx::query( + "select + b.operation_id, + a.id as agent_id, + a.slug as agent_slug, + a.display_name + from agents a + join published_agents pa on pa.agent_id = a.id + join agent_operation_bindings b + on b.agent_id = a.id and b.agent_version = pa.version + where a.workspace_id = $1 + order by a.display_name asc, b.tool_name asc", + ) + .bind(workspace_id.as_str()) + .fetch_all(&self.pool) + .await?; + + rows.iter().map(map_operation_agent_ref).collect() + } + pub async fn get_operation_summary( &self, workspace_id: &WorkspaceId, @@ -1322,6 +1575,7 @@ impl PostgresRegistry { workspace_id, name, display_name, + category, protocol, status, current_draft_version, @@ -1352,6 +1606,7 @@ impl PostgresRegistry { o.workspace_id, o.name, o.display_name, + o.category, o.protocol, to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, @@ -1395,6 +1650,7 @@ impl PostgresRegistry { o.workspace_id, o.name, o.display_name, + o.category, o.protocol, to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, @@ -1504,6 +1760,7 @@ impl PostgresRegistry { o.workspace_id, o.name, o.display_name, + o.category, o.protocol, to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, @@ -1545,6 +1802,7 @@ impl PostgresRegistry { o.workspace_id, o.name, o.display_name, + o.category, o.protocol, to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at, to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at, @@ -2058,13 +2316,6 @@ fn assert_immutable_fields( }); } - if summary.display_name != snapshot.display_name { - return Err(RegistryError::ImmutableOperationFieldChanged { - operation_id: snapshot.id.as_str().to_owned(), - field: "display_name", - }); - } - if summary.protocol != snapshot.protocol { return Err(RegistryError::ImmutableOperationFieldChanged { operation_id: snapshot.id.as_str().to_owned(), @@ -2201,6 +2452,7 @@ fn map_operation_summary(row: &PgRow) -> Result workspace_id: WorkspaceId::new(row.try_get::("workspace_id")?), name: row.try_get("name")?, display_name: row.try_get("display_name")?, + category: row.try_get("category")?, protocol: deserialize_enum_text(&row.try_get::("protocol")?, "protocol")?, status: deserialize_enum_text(&row.try_get::("status")?, "status")?, current_draft_version: from_db_version( @@ -2217,6 +2469,24 @@ fn map_operation_summary(row: &PgRow) -> Result }) } +fn map_operation_usage_summary(row: &PgRow) -> Result { + Ok(OperationUsageSummary { + operation_id: OperationId::new(row.try_get::("operation_id")?), + calls_today: to_u64(row.try_get::("calls_today")?, "calls_today")?, + error_rate_pct: row.try_get("error_rate_pct")?, + avg_latency_ms: to_u64(row.try_get::("avg_latency_ms")?, "avg_latency_ms")?, + }) +} + +fn map_operation_agent_ref(row: &PgRow) -> Result { + Ok(OperationAgentRef { + operation_id: OperationId::new(row.try_get::("operation_id")?), + agent_id: AgentId::new(row.try_get::("agent_id")?), + agent_slug: row.try_get("agent_slug")?, + display_name: row.try_get("display_name")?, + }) +} + fn map_operation_version_record(row: &PgRow) -> Result { let operation_id = OperationId::new(row.try_get::("id")?); let version = from_db_version(row.try_get("version")?, "version")?; @@ -2234,6 +2504,7 @@ fn map_operation_version_record(row: &PgRow) -> Result("protocol")?, "protocol")?, status, version, @@ -2809,6 +3080,7 @@ mod tests { id: OperationId::new(id), name: format!("{id}_tool"), display_name: format!("Display {id}"), + category: "general".to_owned(), protocol: Protocol::Rest, status, version, diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index 34642c1..08ffbff 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -444,6 +444,7 @@ mod tests { id: OperationId::new("op_rest_runtime"), name: "crm_create_lead".to_owned(), display_name: "Create Lead".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Rest, status: OperationStatus::Published, version: 1, @@ -505,6 +506,7 @@ mod tests { id: OperationId::new("op_graphql_runtime"), name: "crm_create_lead_graphql".to_owned(), display_name: "Create Lead GraphQL".to_owned(), + category: "sales".to_owned(), protocol: Protocol::Graphql, status: OperationStatus::Published, version: 1, @@ -579,6 +581,7 @@ mod tests { id: OperationId::new("op_grpc_runtime"), name: "echo_unary_grpc".to_owned(), display_name: "Unary Echo gRPC".to_owned(), + category: "support".to_owned(), protocol: Protocol::Grpc, status: OperationStatus::Published, version: 1, diff --git a/docs/alpine-ui-integration-plan.md b/docs/alpine-ui-integration-plan.md index e94bfe8..87cf0f3 100644 --- a/docs/alpine-ui-integration-plan.md +++ b/docs/alpine-ui-integration-plan.md @@ -52,13 +52,15 @@ UI-файлы: - `GET /api/admin/workspaces/{workspace_id}/operations` - `GET /api/admin/workspaces/{workspace_id}/agents` - `GET /api/admin/workspaces/{workspace_id}/usage` - -Что еще не хватает: - - `PATCH /api/admin/workspaces/{workspace_id}/operations/{operation_id}` - `DELETE /api/admin/workspaces/{workspace_id}/operations/{operation_id}` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive` -- server-side shape для list response с готовыми agent bindings и компактными summary fields + +Что еще не хватает: + +- frontend data adapter, который заменит mock merge на реальные `items/page/page_size/total`; +- wiring server-side filters `protocol/category/agent/status/search`; +- переключение карточек верхних метрик с seeded summary на реальный usage payload. Что убрать из UI после интеграции: @@ -69,8 +71,8 @@ UI-файлы: Простой итог: - эту страницу можно интегрировать первой; -- базовые list/fetch данные backend уже умеет; -- надо только добить update/delete/archive и перестать хранить изменения в браузере. +- backend уже отдает server-side category, usage summary и agent refs; +- дальше нужно убрать `localStorage`-состояние и посадить UI на реальные list/detail/mutation вызовы. ### 4.2. Wizard @@ -96,8 +98,10 @@ UI-файлы: - `POST /api/admin/workspaces/{workspace_id}/operations` - `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}` +- `PATCH /api/admin/workspaces/{workspace_id}/operations/{operation_id}` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions` - `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions/{version}` +- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/test-runs` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/input-json` - `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/output-json` @@ -110,9 +114,8 @@ UI-файлы: Что еще не хватает: -- `PATCH /api/admin/workspaces/{workspace_id}/operations/{operation_id}` для edit mode -- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive` -- единая DTO-форма для create/edit, чтобы UI не собирал разные payload вручную по шагам +- frontend adapter поверх `OperationDetail` и `OperationVersionDocument`, чтобы wizard перестал хранить edit-state в браузере; +- финальное выравнивание step payload c canonical DTO, чтобы create/edit/export/import использовали одну модель на фронте. Что убрать из UI после интеграции: @@ -124,7 +127,7 @@ UI-файлы: - wizard почти готов для реального backend; - это основной экран второй очереди после catalog; -- ключевой разрыв сейчас только в полноценном edit/update контракте. +- backend уже закрывает create/edit/archive/test/import/export, дальше нужен только аккуратный frontend adapter. ### 4.3. Agents From f45ace378a2546f1309e38e7db5d307048eeeed9 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 30 Mar 2026 01:03:17 +0300 Subject: [PATCH 4/5] feat: connect alpine operations and wizard to admin api --- TASKS.md | 2 + apps/ui/html/wizard/index.html | 2 + apps/ui/html/wizard/step2.html | 2 +- apps/ui/index.html | 38 +- apps/ui/js/api.js | 114 +++++ apps/ui/js/catalog.js | 662 ++++++++++++++----------- apps/ui/js/wizard.js | 763 ++++++++++++++++++++++------- apps/ui/js/workspace.js | 144 ++++-- docs/alpine-ui-integration-plan.md | 6 +- 9 files changed, 1213 insertions(+), 520 deletions(-) create mode 100644 apps/ui/js/api.js diff --git a/TASKS.md b/TASKS.md index 643552d..b073fb1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -13,6 +13,8 @@ DoD: - legacy React/Vite артефакты удалены из `apps/ui` - backend для `Operations/Wizard` поддерживает server-side `PATCH`, `DELETE`, `archive`, `category`, `usage_summary`, `agent_refs` - первые экраны Alpine UI можно сажать на реальные catalog/detail/mutation endpoints без расширения backend-контракта +- `Operations` page читает live catalog из `admin-api` и удаляет операции через backend +- `Wizard` использует live create/get/update flow вместо `localStorage` и `sessionStorage` ## Next diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html index 8862771..194cecc 100644 --- a/apps/ui/html/wizard/index.html +++ b/apps/ui/html/wizard/index.html @@ -13,6 +13,8 @@ + + diff --git a/apps/ui/html/wizard/step2.html b/apps/ui/html/wizard/step2.html index a5e02a0..b5da464 100644 --- a/apps/ui/html/wizard/step2.html +++ b/apps/ui/html/wizard/step2.html @@ -94,7 +94,7 @@
json / auth-headers - diff --git a/apps/ui/index.html b/apps/ui/index.html index 88c2b92..53f3358 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -13,6 +13,7 @@ + @@ -118,23 +119,23 @@
Total operations
-
-
+3 this month
+
+
Active
-
-
+
+
Requests today
-
4,821
-
+12% vs yesterday
+
+
Avg. latency
-
187ms
-
+23ms this week
+
+
@@ -233,15 +234,15 @@ :class="{ 'has-value': filterAgent, open: openDropdown === 'agent' }" @click="toggleDropdown('agent')" > - +