chore: publish clean community baseline
@@ -0,0 +1,18 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY apps/ui/package.json apps/ui/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY apps/ui ./
|
||||
COPY crank-community.png ./crank-community.png
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 3000
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/* ── 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-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-hamburger { display: flex; }
|
||||
.mobile-nav { display: block; }
|
||||
|
||||
.page { padding: 20px 16px 40px; }
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
.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: 12px;
|
||||
}
|
||||
|
||||
.login-note {
|
||||
margin-bottom: 24px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 9px;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.18);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.field-link:hover { text-decoration: underline; }
|
||||
|
||||
.field-link.is-disabled,
|
||||
.field-link:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.field-link.is-disabled:hover,
|
||||
.field-link:disabled:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.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-error.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.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); }
|
||||
|
||||
.btn-sso:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-sso:disabled:hover {
|
||||
background: var(--bg-overlay);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.login-inline-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
text-align: center;
|
||||
margin-top: 28px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.login-footer-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 6px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: not-allowed;
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
.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;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.live-dot.is-paused {
|
||||
animation-play-state: paused;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
@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;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.live-label.is-paused {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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); }
|
||||
@@ -0,0 +1,205 @@
|
||||
.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); }
|
||||
|
||||
.planned-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.planned-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.24);
|
||||
background: rgba(245, 158, 11, 0.09);
|
||||
color: var(--amber);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.planned-badge.small {
|
||||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
.planned-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.planned-item {
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.planned-item-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.planned-item-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.planned-item-desc {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
.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: 248px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
padding: 12px 16px;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chart-col-bars {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 3px 3px 0 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
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-col-label {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 10.5px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.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); }
|
||||
@@ -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); }
|
||||
@@ -0,0 +1,228 @@
|
||||
.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-submit-btn {
|
||||
padding: 9px 22px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.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; }
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,394 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Agents</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/catalog.css">
|
||||
<link rel="stylesheet" href="css/wizard.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body x-data="agents">
|
||||
|
||||
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link active" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="user-menu" @click.stop>
|
||||
<div class="nav-avatar" @click="openDropdown = openDropdown === 'user' ? null : 'user'" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown" x-show="openDropdown === 'user'" x-transition style="display:none">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role" id="user-ws-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" onclick="window.location.href='/settings'">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" onclick="window.CrankAuth.logout()">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link active" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════ PAGE ═══════════════════ -->
|
||||
<div class="page">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-heading" data-i18n="agents.title">Agents</h1>
|
||||
<p class="page-subheading" x-text="subtitleText()">Named MCP endpoints that expose a curated subset of operations to an LLM</p>
|
||||
</div>
|
||||
<button class="btn-new" @click="openCreate()">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="agents.new">New agent</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="agents.stats.total">Total agents</div>
|
||||
<div class="stat-value" x-text="agents.length">—</div>
|
||||
<div class="stat-delta" x-text="tKey('agents.stats.total_sub')">across this workspace</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="agents.stats.published">Published</div>
|
||||
<div class="stat-value" x-text="activeCount">—</div>
|
||||
<div class="stat-delta" x-text="agents.length ? tfKey('agents.stats.published_sub', { value: Math.round(activeCount/agents.length*100) }) : ''">—</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="agents.stats.tools">Operations exposed</div>
|
||||
<div class="stat-value" x-text="totalOpsExposed">—</div>
|
||||
<div class="stat-delta" x-text="tfKey('agents.stats.tools_sub', { count: agents.length })">—</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="agents.stats.calls">Calls today</div>
|
||||
<div class="stat-value" x-text="formatCalls(totalCalls)">—</div>
|
||||
<div class="stat-delta up" x-text="tKey('agents.stats.calls_sub')">all agents combined</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + filter bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="search-box">
|
||||
<svg width="14" height="14"><use href="icons/general/search.svg#icon"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
data-i18n-ph="agents.search"
|
||||
placeholder="Search agents..."
|
||||
:value="agentSearch"
|
||||
@input="agentSearch = $event.target.value"
|
||||
>
|
||||
</div>
|
||||
<div class="filter-right">
|
||||
<span class="results-count" x-show="agentSearch">
|
||||
<span x-text="tfKey('agents.results', { shown: filteredAgents.length, total: agents.length })"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info callout -->
|
||||
<div class="agents-info-callout">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="7"/><path d="M8 11V8M8 5v-.5"/></svg>
|
||||
<span x-text="agentCalloutText()">Each agent gets its own MCP endpoint.</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div class="loading-spinner" x-show="loading">
|
||||
<div class="spinner"></div>
|
||||
<span data-i18n="agents.loading">Loading agents…</span>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" x-show="!loading && loadError">
|
||||
<div class="empty-state-title" data-i18n="agents.error.title">Failed to load agents</div>
|
||||
<div class="empty-state-sub" x-text="loadError"></div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state: no agents at all -->
|
||||
<div class="empty-state" x-show="!loading && !loadError && agents.length === 0">
|
||||
<div class="empty-state-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.35"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 17.5h7M17.5 14v7"/></svg>
|
||||
</div>
|
||||
<div class="empty-state-title" data-i18n="agents.empty.initial.title">No agents yet</div>
|
||||
<div class="empty-state-sub" x-text="emptyStateText()">Create your first agent to get a dedicated MCP endpoint with a curated set of tools.</div>
|
||||
<div class="empty-state-sub" style="margin-top:8px;" x-text="tKey('agents.empty.initial.next_step')">After creation, issue separate machine-access keys for this endpoint on the API Keys page.</div>
|
||||
<button class="btn-new" @click="openCreate()" style="margin-top: 16px;">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="agents.new">New agent</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state: search returned nothing -->
|
||||
<div class="empty-state" x-show="!loading && !loadError && agents.length > 0 && filteredAgents.length === 0">
|
||||
<div class="empty-state-title" x-text="tfKey('agents.empty.search.title', { query: agentSearch })">No agents match</div>
|
||||
<div class="empty-state-sub" data-i18n="agents.empty.search.sub">Try a different search term.</div>
|
||||
</div>
|
||||
|
||||
<!-- Agents grid -->
|
||||
<div class="agents-grid" x-show="!loading && !loadError && filteredAgents.length > 0">
|
||||
<template x-for="agent in filteredAgents" :key="agent.id">
|
||||
<div class="agent-card">
|
||||
|
||||
<!-- Card header -->
|
||||
<div class="agent-card-header">
|
||||
<div class="agent-card-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 17.5h7M17.5 14v7"/></svg>
|
||||
</div>
|
||||
<div class="agent-card-title-wrap">
|
||||
<div class="agent-card-name" x-text="agent.display_name"></div>
|
||||
<div class="agent-card-slug" x-text="agent.slug"></div>
|
||||
</div>
|
||||
<span :class="statusClass(agent.status)" x-text="lifecycleLabel(agent)"></span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="agent-card-desc" x-text="agent.description"></div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="agent-card-stats">
|
||||
<div class="agent-stat">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 5h12M2 8h8M2 11h5"/></svg>
|
||||
<span class="agent-stat-value" x-text="agent.operation_count"></span>
|
||||
<span data-i18n="agents.card.tools">tools</span>
|
||||
</div>
|
||||
<div class="agent-stat">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 2H4a1 1 0 00-1 1v3M7 2l7 7M7 2v5h5"/><path d="M3 9v4a1 1 0 001 1h8a1 1 0 001-1V9"/></svg>
|
||||
<span class="agent-stat-value" x-text="agent.key_count"></span>
|
||||
<span data-i18n="agents.card.keys">API keys</span>
|
||||
</div>
|
||||
<div class="agent-stat" x-show="agent.calls_today > 0">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 8s3-5 7-5 7 5 7 5-3 5-7 5-7-5-7-5z"/><circle cx="8" cy="8" r="2"/></svg>
|
||||
<span class="agent-stat-value" x-text="formatCalls(agent.calls_today)"></span>
|
||||
<span data-i18n="agents.card.calls_today">calls today</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP endpoint -->
|
||||
<div class="agent-endpoint-row">
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8h12M10 4l4 4-4 4"/></svg>
|
||||
<code class="agent-endpoint-url" x-text="mcpEndpoint(agent)"></code>
|
||||
<button class="agent-endpoint-copy" @click.stop="copyEndpoint(agent)" :title="tKey('agents.card.copy_endpoint')">
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="9" height="9" rx="1"/><path d="M5 5V3a1 1 0 011-1h7a1 1 0 011 1v7a1 1 0 01-1 1h-2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="agent-card-date" style="margin-top:8px;" x-text="endpointHelpText(agent)"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="agent-card-footer">
|
||||
<span class="agent-card-date" x-text="tfKey('agents.card.created', { date: formatDate(agent.created_at) })"></span>
|
||||
<div class="agent-card-actions">
|
||||
<button class="agent-action-btn" @click.stop="openEdit(agent)">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 2l3 3-8 8H3v-3l8-8z"/></svg>
|
||||
<span data-i18n="agents.action.edit">Edit</span>
|
||||
</button>
|
||||
<button class="agent-action-btn" @click.stop="applyLifecycle(agent, lifecycleAction(agent).key)">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 3.5v9l8-4.5-8-4.5z"/></svg>
|
||||
<span x-text="lifecycleAction(agent).label"></span>
|
||||
</button>
|
||||
<button class="agent-action-btn" x-show="agent.raw_status !== 'archived'" @click.stop="applyLifecycle(agent, 'archive')">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4.5h11v2l-1 6.5h-8l-1-6.5v-2z"/><path d="M5 4.5v-1A1.5 1.5 0 016.5 2h3A1.5 1.5 0 0111 3.5v1"/></svg>
|
||||
<span data-i18n="agents.action.archive">Archive</span>
|
||||
</button>
|
||||
<button class="agent-action-btn danger" @click.stop="deleteAgent(agent.id)">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
|
||||
<span data-i18n="agents.action.delete">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div><!-- /page -->
|
||||
|
||||
|
||||
<!-- ═══════════════════ DRAWER ═══════════════════ -->
|
||||
<!-- Overlay -->
|
||||
<div class="drawer-overlay" x-show="drawerOpen" x-transition:enter="transition-opacity" @click="closeDrawer()" style="display:none"></div>
|
||||
|
||||
<!-- Drawer panel -->
|
||||
<div class="drawer" :class="{ open: drawerOpen }" style="display:none" x-show="drawerOpen">
|
||||
|
||||
<!-- Drawer header -->
|
||||
<div class="drawer-header">
|
||||
<div>
|
||||
<div class="drawer-title" x-text="drawerMode === 'create' ? tKey('agents.drawer.new_title') : tKey('agents.drawer.edit_title')"></div>
|
||||
<div class="drawer-subtitle" x-text="drawerSubText()"></div>
|
||||
</div>
|
||||
<button class="drawer-close" @click="closeDrawer()">
|
||||
<svg width="14" height="14" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Drawer body -->
|
||||
<div class="drawer-body">
|
||||
|
||||
<!-- Identity section -->
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-section-title" data-i18n="agents.drawer.identity">Identity</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 14px;">
|
||||
<label class="form-label"><span data-i18n="agents.drawer.display_name">Display name</span> <span class="form-label-required" data-i18n="agents.drawer.required">required</span></label>
|
||||
<input class="form-input" type="text" data-i18n-ph="agents.drawer.placeholder.name" placeholder="Customer Support" autocomplete="off"
|
||||
:value="form.display_name" @input="onNameInput($event.target.value)">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 14px;">
|
||||
<label class="form-label"><span data-i18n="agents.drawer.slug">Slug</span> <span class="form-label-required" data-i18n="agents.drawer.required">required</span></label>
|
||||
<input class="form-input input-mono" type="text" data-i18n-ph="agents.drawer.placeholder.slug" placeholder="customer-support" autocomplete="off"
|
||||
:value="form.slug" @input="onSlugInput($event.target.value)">
|
||||
<div class="form-hint" x-show="form.slug">
|
||||
<span data-i18n="agents.drawer.endpoint">MCP endpoint</span>: <code style="font-family:monospace;font-size:11px;color:var(--accent)" x-text="'/mcp/v1/' + (localStorage.getItem('crank_workspace_slug') || 'default') + '/' + form.slug"></code>
|
||||
</div>
|
||||
<div class="form-hint" data-i18n="agents.drawer.slug_hint">Keep the slug stable after clients start using this endpoint. Changing it changes the MCP path.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 14px;">
|
||||
<label class="form-label"><span data-i18n="agents.drawer.description">Description</span> <span class="form-label-optional" data-i18n="agents.drawer.optional">(optional)</span></label>
|
||||
<textarea class="form-textarea" rows="3" data-i18n-ph="agents.drawer.placeholder.description" placeholder="What does this agent do? What LLM or use-case is it for?"
|
||||
x-model="form.description"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="agents.drawer.status">Status</label>
|
||||
<div class="agent-status-toggle">
|
||||
<button class="agent-status-opt" :class="{ active: form.status === 'published' }" @click="form.status = 'published'">
|
||||
<span class="status-dot" style="background: var(--success, #3fb950)"></span> <span data-i18n="agents.lifecycle.published">Published</span>
|
||||
</button>
|
||||
<button class="agent-status-opt" :class="{ active: form.status === 'draft' }" @click="form.status = 'draft'">
|
||||
<span class="status-dot" style="background: var(--text-muted)"></span> <span data-i18n="agents.lifecycle.draft">Draft</span>
|
||||
</button>
|
||||
<button class="agent-status-opt" :class="{ active: form.status === 'archived' }" @click="form.status = 'archived'">
|
||||
<span class="status-dot" style="background: var(--orange, #d29922)"></span> <span data-i18n="agents.lifecycle.archived">Archived</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operations picker section -->
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-section-header">
|
||||
<div class="drawer-section-title" data-i18n="agents.drawer.operations">Operations</div>
|
||||
<span class="drawer-section-count" x-text="tfKey('agents.drawer.selected', { count: form.selectedOps.length })"></span>
|
||||
</div>
|
||||
<div class="drawer-section-sub" x-text="operationsSubText()">Select which operations this agent exposes. LLMs connecting to this agent will only see these tools.</div>
|
||||
|
||||
<div class="ops-picker">
|
||||
<!-- Search -->
|
||||
<div class="ops-picker-search">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round"><circle cx="7" cy="7" r="5"/><path d="M12 12l2.5 2.5"/></svg>
|
||||
<input class="ops-picker-search-input" type="text" data-i18n-ph="agents.drawer.filter_ops" placeholder="Filter operations…" x-model="opSearch" autocomplete="off" spellcheck="false">
|
||||
<button class="ops-picker-clear" x-show="opSearch" @click="opSearch = ''" style="display:none">
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- List -->
|
||||
<div class="ops-picker-list">
|
||||
<template x-for="op in filteredOps" :key="op.id || op.name">
|
||||
<div class="ops-picker-item" :class="{ selected: isOpSelected(op.id || op.name) }" @click="toggleOp(op.id || op.name)">
|
||||
<div class="ops-picker-check">
|
||||
<svg x-show="isOpSelected(op.id || op.name)" width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
|
||||
</div>
|
||||
<div class="ops-picker-info">
|
||||
<div class="ops-picker-name" x-text="op.display_name || op.name"></div>
|
||||
<div class="ops-picker-slug" x-text="op.name"></div>
|
||||
</div>
|
||||
<span :class="protocolBadge(op)" x-text="protocolLabel(op)" style="font-size:10px; padding: 2px 6px;"></span>
|
||||
<div class="ops-picker-status-dot" :class="'ops-status-' + op.status"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ops-picker-empty" x-show="filteredOps.length === 0">
|
||||
<span x-text="tfKey('agents.drawer.ops_no_match', { query: opSearch })"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer count -->
|
||||
<div class="ops-picker-footer" x-show="form.selectedOps.length > 0">
|
||||
<span x-text="tfKey('agents.drawer.ops_selected', { count: form.selectedOps.length })"></span>
|
||||
<button @click="form.selectedOps = []" style="background:none;border:none;color:var(--text-muted);font-size:12px;cursor:pointer;margin-left:8px;" data-i18n="agents.drawer.clear_all">Clear all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommendation callout -->
|
||||
<div class="agents-rec-callout" x-show="form.selectedOps.length > 15">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polygon points="8,1 15,14 1,14" fill="none"/><path d="M8 6v4M8 11.5v.5"/></svg>
|
||||
<span x-text="tfKey('agents.drawer.recommendation', { count: form.selectedOps.length })">You've selected tools.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /drawer-body -->
|
||||
|
||||
<!-- Drawer footer -->
|
||||
<div class="drawer-footer">
|
||||
<button class="btn-ghost-sm" style="padding: 8px 16px; font-size: 13px;" @click="closeDrawer()" data-i18n="agents.drawer.cancel">Cancel</button>
|
||||
<button class="btn-primary-sm" style="padding: 8px 20px; font-size: 13px;"
|
||||
:disabled="!form.display_name.trim() || !form.slug.trim()"
|
||||
@click="saveAgent()"
|
||||
x-text="drawerMode === 'create' ? tKey('agents.drawer.create') : tKey('agents.drawer.save')">
|
||||
Create agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div><!-- /drawer -->
|
||||
|
||||
|
||||
<script src="%CRANK_BUNDLE_AGENTS%"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,319 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Agent Keys</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<style>
|
||||
.scope-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-canvas);
|
||||
}
|
||||
.scope-checkbox-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
.scope-checkbox-desc { font-size: 11.5px; color: var(--text-muted); }
|
||||
.keys-card-list { display: none; }
|
||||
@media (max-width: 720px) {
|
||||
#keys-table-wrap { display: none; }
|
||||
.keys-card-list { display: grid; }
|
||||
}
|
||||
</style>
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link active" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
|
||||
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="nav-divider"></div>
|
||||
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" data-action="settings-link">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link active" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════ PAGE ═══════════════════ -->
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="apikeys.title">Agent Keys</h1>
|
||||
<p class="page-subtitle" data-i18n="apikeys.subtitle">Keys authenticate external MCP clients against a specific AI agent endpoint.</p>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-primary" id="btn-create-key" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||
<span data-i18n="apikeys.new">Create key</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout info">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<path d="M8 11V8M8 5.5V5"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong data-i18n="apikeys.callout.title">Keys are only shown once.</strong>
|
||||
<span data-i18n="apikeys.callout.body">Copy and store them securely immediately after creation — Crank stores only a hash. `Last used` updates after successful MCP authentication.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="apikeys.agent.title">Agent access</div>
|
||||
<div class="section-card-subtitle" id="agent-access-subtitle" data-i18n="apikeys.agent.subtitle">Select the AI agent whose MCP endpoint should accept this key.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="display:grid;gap:12px;">
|
||||
<div class="field-group" style="margin-bottom:0;">
|
||||
<label class="field-label" for="agent-select" data-i18n="apikeys.agent.label">AI agent</label>
|
||||
<select class="field-input" id="agent-select"></select>
|
||||
<div class="field-hint" id="agent-endpoint-hint" data-testid="agent-key-endpoint-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" id="machine-access-title" data-i18n="apikeys.machine_access.title">Machine access modes</div>
|
||||
<div class="section-card-subtitle" id="machine-access-subtitle" data-i18n="apikeys.machine_access.subtitle">This build exposes the stable machine-access contract and shows which modes are available now.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="display:grid;gap:10px;">
|
||||
<div style="font-size:13px;color:var(--text-secondary);line-height:1.65;" id="machine-access-summary" data-testid="machine-access-summary">
|
||||
Community currently supports static AI-agent keys.
|
||||
</div>
|
||||
<div class="callout info">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<path d="M8 11V8M8 5.5V5"/>
|
||||
</svg>
|
||||
<div id="machine-access-note" data-i18n="apikeys.machine_access.note">Short-lived and one-time token issuance uses the same public HTTP surface, but requires a commercial edition.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="apikeys.active.title">Active keys</div>
|
||||
<div class="section-card-subtitle" id="keys-summary-subtitle">3 active · 1 revoked</div>
|
||||
</div>
|
||||
<div class="filter-bar-search" style="max-width: 220px; margin: 0;">
|
||||
<svg width="13" height="13" style="position:absolute; left:9px; top:50%; transform:translateY(-50%); color:var(--text-muted);" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215zM11.5 7a4.499 4.499 0 11-8.997 0A4.499 4.499 0 0111.5 7z"/></svg>
|
||||
<input type="text" id="key-search" data-i18n-ph="apikeys.search" placeholder="Filter keys…" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="keys-table-wrap">
|
||||
<table class="data-table" id="keys-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="apikeys.th.name">Name</th>
|
||||
<th data-i18n="apikeys.th.prefix">Key prefix</th>
|
||||
<th data-i18n="apikeys.th.scopes">Scopes</th>
|
||||
<th data-i18n="apikeys.th.created">Created</th>
|
||||
<th data-i18n="apikeys.th.last">Last used</th>
|
||||
<th data-i18n="apikeys.th.status">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="keys-tbody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="resource-list keys-card-list" id="keys-card-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="apikeys.scope_ref">Scope reference</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
|
||||
<div>
|
||||
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;display:flex;align-items:center;gap:6px;">
|
||||
<span class="badge badge-scope">read</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.read">Initialize MCP sessions, ping the server, and list tools for a workspace agent.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
|
||||
<span class="badge badge-scope">write</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.write">Execute `tools/call` requests against published agent toolsets.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
|
||||
<span class="badge badge-scope">deploy</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.deploy">Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /page -->
|
||||
|
||||
<!-- ══ Create Key Modal ══ -->
|
||||
<div class="modal-overlay" id="modal-create">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" data-i18n="apikeys.modal.title">Create agent key</span>
|
||||
<button class="modal-close" id="modal-close-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-form-body">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="apikeys.modal.name">Key name</label>
|
||||
<input class="field-input" id="new-key-name" type="text" data-i18n-ph="apikeys.modal.name_placeholder" placeholder="e.g. Production, CI pipeline" autocomplete="off">
|
||||
<div class="field-hint" data-i18n="apikeys.modal.name_hint">A descriptive label to identify the key. Only visible to admins.</div>
|
||||
</div>
|
||||
<div class="field-group" style="margin-bottom:0;">
|
||||
<label class="field-label" data-i18n="apikeys.modal.scopes">Scopes</label>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;margin-top:2px;" id="scope-checkboxes">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-reveal-body" hidden>
|
||||
<div class="callout warning" style="margin-bottom:16px;">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--amber)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
|
||||
</svg>
|
||||
<div><strong data-i18n="apikeys.modal.reveal_title">Copy this key now.</strong> <span data-i18n="apikeys.modal.reveal_body">It won't be shown again.</span></div>
|
||||
</div>
|
||||
<div class="key-reveal">
|
||||
<span class="key-mask" id="reveal-key-value">mcp_prod_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</span>
|
||||
<button class="key-copy" id="copy-key-btn" type="button" data-i18n-title="apikeys.modal.copy" title="Copy to clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5" y="5" width="9" height="9" rx="1.5"/>
|
||||
<path d="M11 5V3.5A1.5 1.5 0 009.5 2H3.5A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" id="modal-footer-create">
|
||||
<button class="btn-secondary" id="modal-cancel-btn" type="button" data-i18n="apikeys.modal.cancel">Cancel</button>
|
||||
<button class="btn-primary" id="modal-confirm-btn" type="button" data-i18n="apikeys.modal.create">Create key</button>
|
||||
</div>
|
||||
<div class="modal-footer" id="modal-footer-done" hidden>
|
||||
<button class="btn-primary" id="modal-done-btn" type="button" data-i18n="apikeys.modal.done">Done — I've copied the key</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template: scope checkbox row -->
|
||||
<template id="tmpl-scope-checkbox">
|
||||
<label class="scope-checkbox-label">
|
||||
<input type="checkbox" data-scope="" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;flex-shrink:0;">
|
||||
<div>
|
||||
<div class="scope-checkbox-name"></div>
|
||||
<div class="scope-checkbox-desc"></div>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<!-- Template: API key table row -->
|
||||
<template id="tmpl-key-row">
|
||||
<tr>
|
||||
<td class="col-name"></td>
|
||||
<td class="col-mono"></td>
|
||||
<td><div class="col-scopes" style="display:flex;gap:4px;flex-wrap:wrap;"></div></td>
|
||||
<td class="col-created"></td>
|
||||
<td class="col-last-used"></td>
|
||||
<td class="col-status"></td>
|
||||
<td class="col-actions">
|
||||
<span class="actions-active">
|
||||
<button class="btn-icon" data-i18n-title="apikeys.action.copy_prefix" title="Copy key prefix"><svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M11 5V3.5A1.5 1.5 0 009.5 2H3.5A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5"/></svg></button>
|
||||
<button class="btn-icon danger" data-i18n-title="apikeys.action.revoke" title="Revoke key"><svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.5"/><path d="M5.5 10.5l5-5M10.5 10.5l-5-5"/></svg></button>
|
||||
</span>
|
||||
<span class="actions-revoked" hidden>
|
||||
<button class="btn-icon" data-i18n-title="apikeys.action.delete" title="Delete"><svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2h4M2.5 4h11M5 4l.5 9h5l.5-9"/></svg></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script src="%CRANK_BUNDLE_API_KEYS%"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="field-group" style="margin-top:8px;">
|
||||
<label class="field-label">Interface language</label>
|
||||
<div class="lang-switcher" style="display:flex;gap:6px;margin-top:6px;">
|
||||
<button class="lang-btn" data-lang="en" onclick="setLang('en')">
|
||||
<span class="lang-flag">🇬🇧</span>
|
||||
<span data-i18n="settings.lang.en">English</span>
|
||||
</button>
|
||||
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">
|
||||
<span class="lang-flag">🇷🇺</span>
|
||||
<span data-i18n="settings.lang.ru">Русский</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Sign in</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/login.css">
|
||||
<script src="%CRANK_BUNDLE_LOGIN%"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
|
||||
<div class="login-logo">
|
||||
<div class="login-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="22" height="22" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="login-logo-text">Crank</span>
|
||||
</div>
|
||||
|
||||
<div class="login-box">
|
||||
<div class="login-heading" data-i18n="login.title">Sign in</div>
|
||||
<div class="login-sub" data-i18n="login.subtitle">Welcome back to your workspace</div>
|
||||
<div class="login-note" data-i18n="login.coming_soon">Password reset and SSO will be added later.</div>
|
||||
|
||||
<div class="login-error" id="login-error" data-i18n="login.error.invalid">
|
||||
Invalid email or password. Please try again.
|
||||
</div>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="field">
|
||||
<label class="field-label" for="email" data-i18n="login.email_label">Email address</label>
|
||||
<input class="field-input" type="email" id="email" data-i18n-ph="login.email_placeholder" placeholder="you@acme.com" autocomplete="email" autofocus>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="password" data-i18n="login.password">Password</label>
|
||||
<input class="field-input" type="password" id="password" placeholder="••••••••" autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button class="btn-signin" type="submit" data-i18n="login.submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="version-badge">Crank v0.9.0-beta</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Logs</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/logs.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link active" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" data-action="settings-link">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link active" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="logs.title">Logs</h1>
|
||||
<p class="page-subtitle" data-i18n="logs.subtitle">Real-time invocation log for all operations in this workspace.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="log-toolbar">
|
||||
<div class="live-dot"></div>
|
||||
<span class="live-label" data-i18n="logs.live">Live</span>
|
||||
<div class="toolbar-sep"></div>
|
||||
|
||||
<select class="time-range-select" id="time-range">
|
||||
<option value="30m" data-i18n="logs.range.30m">Last 30 min</option>
|
||||
<option value="1h" selected data-i18n="logs.range.1h">Last 1 hour</option>
|
||||
<option value="6h" data-i18n="logs.range.6h">Last 6 hours</option>
|
||||
<option value="24h" data-i18n="logs.range.24h">Last 24 hours</option>
|
||||
<option value="7d" data-i18n="logs.range.7d">Last 7 days</option>
|
||||
</select>
|
||||
|
||||
<div class="toolbar-sep"></div>
|
||||
|
||||
<button class="filter-chip active" data-level="all" id="chip-all" data-i18n="logs.level.all">All</button>
|
||||
<button class="filter-chip" data-level="info" id="chip-info">
|
||||
<span class="log-level info" style="padding:0 4px;font-size:10px;">INFO</span>
|
||||
</button>
|
||||
<button class="filter-chip" data-level="warn" id="chip-warn">
|
||||
<span class="log-level warn" style="padding:0 4px;font-size:10px;">WARN</span>
|
||||
</button>
|
||||
<button class="filter-chip" data-level="error" id="chip-error">
|
||||
<span class="log-level error" style="padding:0 4px;font-size:10px;">ERROR</span>
|
||||
</button>
|
||||
|
||||
<div class="filter-bar-search" style="max-width:240px;margin:0;">
|
||||
<svg width="13" height="13" style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-muted);" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215zM11.5 7a4.499 4.499 0 11-8.997 0A4.499 4.499 0 0111.5 7z"/></svg>
|
||||
<input type="text" id="log-search" data-i18n-ph="logs.search_messages" placeholder="Search messages…" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<button class="refresh-btn" id="refresh-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 01.834.656 5.5 5.5 0 009.592 2.97l-1.204-1.204a.25.25 0 01.177-.427h3.646a.25.25 0 01.25.25v3.646a.25.25 0 01-.427.177l-1.38-1.38A7.001 7.001 0 011.05 8.84a.75.75 0 01.656-.834zM8 2.5a5.487 5.487 0 00-4.131 1.869l1.204 1.204A.25.25 0 014.896 6H1.25A.25.25 0 011 5.75V2.104a.25.25 0 01.427-.177l1.38 1.38A7.001 7.001 0 0114.95 7.16a.75.75 0 01-1.49.178A5.501 5.501 0 008 2.5z"/></svg>
|
||||
<span data-i18n="logs.refresh">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="log-list" id="log-list">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="%CRANK_BUNDLE_LOGS%"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,242 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Secrets</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<style>
|
||||
.secret-value-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.secret-inline-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.secret-ref-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.secret-ref-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-canvas);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.secret-ref-pill strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.secrets-card-list { display: none; }
|
||||
@media (max-width: 720px) {
|
||||
#secrets-table-wrap { display: none; }
|
||||
.secrets-card-list { display: grid; }
|
||||
}
|
||||
</style>
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name" data-testid="shell-workspace-name">workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link active" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications"><svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg></button>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-testid="shell-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header"><div class="user-dropdown-name" data-testid="shell-user-name">Crank</div><div class="user-dropdown-role" data-testid="shell-user-role">—</div></div>
|
||||
<button class="user-dropdown-item" data-action="settings-link"><svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg><span data-i18n="nav.settings">Settings</span></button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout"><svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg><span data-i18n="nav.logout">Log out</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link active" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="secrets.title">Secrets</h1>
|
||||
<p class="page-subtitle" data-i18n="secrets.subtitle">Encrypted upstream credentials for bearer tokens, basic auth, and API keys used by auth profiles.</p>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-primary" id="btn-create-secret" data-testid="secret-create-button" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||
<span data-i18n="secrets.new">Create secret</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout info">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<path d="M8 11V8M8 5.5V5"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong data-i18n="secrets.callout.title">Secret plaintext is write-only.</strong>
|
||||
<span data-i18n="secrets.callout.body">Crank encrypts secret values with the workspace master key. After create or rotate, the API returns only metadata, so this page focuses on lifecycle and usage references.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="margin-top: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="secrets.active.title">Workspace secrets</div>
|
||||
<div class="section-card-subtitle" id="secrets-summary"></div>
|
||||
</div>
|
||||
<div class="filter-bar-search" style="max-width: 240px; margin: 0;">
|
||||
<svg width="13" height="13" style="position:absolute; left:9px; top:50%; transform:translateY(-50%); color:var(--text-muted);" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215zM11.5 7a4.499 4.499 0 11-8.997 0A4.499 4.499 0 0111.5 7z"/></svg>
|
||||
<input type="text" id="secret-search" data-i18n-ph="secrets.search" placeholder="Filter secrets…" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="padding-top: 0;">
|
||||
<div id="secrets-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="secrets.th.name">Name</th>
|
||||
<th data-i18n="secrets.th.kind">Kind</th>
|
||||
<th data-i18n="secrets.th.version">Version</th>
|
||||
<th data-i18n="secrets.th.last_used">Last used</th>
|
||||
<th data-i18n="secrets.th.used_by">Used by</th>
|
||||
<th data-i18n="secrets.th.status">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="secrets-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="resource-list secrets-card-list" id="secrets-card-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="margin-top: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="secrets.profiles.title">Auth profile references</div>
|
||||
<div class="section-card-subtitle" id="secret-profiles-summary" data-i18n="secrets.profiles.subtitle">Profiles resolve secrets at runtime before upstream execution.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body">
|
||||
<div class="resource-list" id="auth-profiles-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="secret-modal" data-testid="secret-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="secret-modal-title" data-i18n="secrets.modal.create_title">Create secret</span>
|
||||
<button class="modal-close" id="secret-modal-close-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.name">Secret name</label>
|
||||
<input class="field-input" id="secret-name" data-testid="secret-name-input" type="text" autocomplete="off">
|
||||
<div class="field-hint" data-i18n="secrets.modal.name_hint">Use a stable operator-facing label. Plaintext values are never returned after storage.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.kind">Secret kind</label>
|
||||
<select class="field-select" id="secret-kind" data-testid="secret-kind-select">
|
||||
<option value="token" data-i18n="secrets.kind.token">Token</option>
|
||||
<option value="username_password" data-i18n="secrets.kind.username_password">Username/password</option>
|
||||
<option value="header" data-i18n="secrets.kind.header">Header value</option>
|
||||
<option value="generic" data-i18n="secrets.kind.generic">Generic JSON</option>
|
||||
</select>
|
||||
<div class="field-hint" id="secret-kind-hint"></div>
|
||||
</div>
|
||||
|
||||
<div class="secret-value-grid" id="secret-value-fields">
|
||||
<div class="field-group" data-kind-field="string">
|
||||
<label class="field-label" id="secret-string-label" data-i18n="secrets.modal.value">Secret value</label>
|
||||
<input class="field-input" id="secret-string-value" data-testid="secret-value-input" type="text" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field-row" data-kind-field="basic" hidden>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.username">Username</label>
|
||||
<input class="field-input" id="secret-username" data-testid="secret-username-input" type="text" autocomplete="off">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="secrets.modal.password">Password</label>
|
||||
<input class="field-input" id="secret-password" data-testid="secret-password-input" type="password" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group" data-kind-field="json" hidden>
|
||||
<label class="field-label" data-i18n="secrets.modal.json_payload">JSON payload</label>
|
||||
<textarea class="field-textarea code-textarea" id="secret-json-value" data-testid="secret-json-input" rows="8" spellcheck="false"></textarea>
|
||||
<div class="field-hint" data-i18n="secrets.modal.json_hint">Useful for generic secret payloads. The value must be valid JSON.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="secret-inline-note" id="secret-modal-note" data-i18n="secrets.modal.create_note">Creation stores encrypted plaintext and returns metadata only.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" id="secret-modal-cancel-btn" type="button" data-i18n="secrets.modal.cancel">Cancel</button>
|
||||
<button class="btn-primary" id="secret-modal-submit-btn" data-testid="secret-submit-button" type="button" data-i18n="secrets.modal.create_action">Create secret</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="%CRANK_BUNDLE_SECRETS%"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,293 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Settings</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/settings.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" data-action="settings-link">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header" style="margin-bottom: 24px;">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="settings.page.title">Account settings</h1>
|
||||
<p class="page-subtitle" data-i18n="settings.page.subtitle">Manage the live profile, password and workspace settings available in this build. Planned capabilities stay visible, but they do not pretend to be wired yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-layout">
|
||||
|
||||
<!-- ── Sidebar nav ── -->
|
||||
<nav class="settings-nav" id="settings-nav">
|
||||
<button class="settings-nav-item" data-section="workspace">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 5.5h12M4 2.5h8a1.5 1.5 0 011.5 1.5v8A1.5 1.5 0 0112 13.5H4A1.5 1.5 0 012.5 12V4A1.5 1.5 0 014 2.5z"/>
|
||||
</svg>
|
||||
<span data-i18n="settings.nav.workspace">Workspace</span>
|
||||
</button>
|
||||
<button class="settings-nav-item active" data-section="profile">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="5" r="3"/>
|
||||
<path d="M1.5 14.5c0-3.314 2.91-6 6.5-6s6.5 2.686 6.5 6"/>
|
||||
</svg>
|
||||
<span data-i18n="settings.nav.profile">Profile</span>
|
||||
</button>
|
||||
<button class="settings-nav-item" data-section="security">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 1L2 3.5v4.5C2 11.3 4.7 14.4 8 15c3.3-.6 6-3.7 6-7V3.5L8 1z"/>
|
||||
</svg>
|
||||
<span data-i18n="settings.nav.security">Security</span>
|
||||
</button>
|
||||
<button class="settings-nav-item" data-section="notifications">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 1.5a5 5 0 015 5v2.5l1 2H2l1-2V6.5a5 5 0 015-5z"/>
|
||||
<path d="M6.5 13a1.5 1.5 0 003 0"/>
|
||||
</svg>
|
||||
<span data-i18n="settings.nav.notif">Notifications</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- ── Content ── -->
|
||||
<div id="settings-content">
|
||||
|
||||
<div id="section-workspace" class="section-anchor">
|
||||
<div class="section-card" style="margin-bottom: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="settings.ws.title">Workspace settings</div>
|
||||
<div class="section-card-subtitle" data-i18n="settings.ws.subtitle">General information about your workspace</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="padding-bottom:8px;">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.ws.name">Workspace name</label>
|
||||
<input class="field-input" id="settings-ws-slug" type="text" value="acme-workspace" autocomplete="off">
|
||||
<div class="field-hint" data-i18n="settings.ws.slug_hint">Used in API endpoints and across the console. Only lowercase letters, numbers and hyphens.</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.ws.display">Display name</label>
|
||||
<input class="field-input" id="settings-ws-display-name" type="text" value="Acme Corp" autocomplete="off">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label"><span data-i18n="settings.ws.description_optional">Description (optional)</span></label>
|
||||
<textarea class="field-textarea" id="settings-ws-description" rows="2" data-i18n-ph="settings.ws.description_placeholder" placeholder="What does this workspace do?">Primary production workspace for CRM and e-commerce API operations.</textarea>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;padding-top:4px;">
|
||||
<button class="btn-primary" id="settings-ws-save-btn" type="button"><span data-i18n="btn.save">Save changes</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ■ PROFILE ■ -->
|
||||
<div id="section-profile" class="section-anchor">
|
||||
<div class="section-card" style="margin-bottom: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="settings.profile.title">Profile</div>
|
||||
</div>
|
||||
<div class="section-card-body">
|
||||
<div class="avatar-upload">
|
||||
<div class="avatar-large" id="profile-avatar">AT</div>
|
||||
<div class="avatar-upload-actions">
|
||||
<div class="avatar-name" id="profile-display-name">Crank</div>
|
||||
<div class="avatar-sub" id="profile-email">operator@acme-workspace</div>
|
||||
<div class="field-hint" data-i18n="settings.profile.avatar_hint">Your avatar is generated from your display name and email.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-hint" style="margin-bottom:16px;" data-i18n="settings.profile.backend_hint">Your name and email are stored on the backend and used across the console and session identity.</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.profile.first_name">First name</label>
|
||||
<input class="field-input" id="field-firstname" type="text" value="Crank" autocomplete="given-name">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.profile.last_name">Last name</label>
|
||||
<input class="field-input" id="field-lastname" type="text" value="" autocomplete="family-name" data-i18n-ph="settings.profile.last_name_placeholder" placeholder="Optional">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.profile.email_label">Email address</label>
|
||||
<input class="field-input" id="field-email" type="email" value="operator@acme.com" autocomplete="email">
|
||||
<div class="field-hint" data-i18n="settings.profile.email_hint">Changing email updates the login identity for the current account.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-hint" id="settings-profile-status" style="margin-bottom:12px;"></div>
|
||||
<div style="display:flex;justify-content:flex-end;">
|
||||
<button class="btn-primary" id="settings-profile-save-btn" type="button" data-i18n="settings.profile.save">Save profile</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ■ SECURITY ■ -->
|
||||
<div id="section-security" class="section-anchor">
|
||||
<div class="section-card" style="margin-bottom: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="settings.security.title">Security</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="padding-bottom:8px;">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.security.current_password">Current password</label>
|
||||
<input class="field-input" id="security-current-password" type="password" placeholder="••••••••" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.security.new_password">New password</label>
|
||||
<input class="field-input" id="security-new-password" type="password" data-i18n-ph="settings.security.new_password_placeholder" placeholder="min. 12 characters" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.security.confirm_password">Confirm new password</label>
|
||||
<input class="field-input" id="security-confirm-password" type="password" placeholder="••••••••" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-hint" id="settings-password-status" style="margin-bottom:12px;"></div>
|
||||
<div style="display:flex;justify-content:flex-end;padding-bottom:8px;">
|
||||
<button class="btn-primary" id="settings-password-save-btn" type="button" data-i18n="settings.security.change_password">Change password</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 4px 20px 16px;">
|
||||
<div class="settings-section-divider" style="margin-top:0;"></div>
|
||||
<div class="planned-section-title">
|
||||
<span data-i18n="settings.security.capabilities_title">More security options</span>
|
||||
</div>
|
||||
<div class="callout warning" style="margin-bottom:0;">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--amber)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong id="settings-security-capability-title" data-i18n="settings.security.not_available">Available later.</strong> <span id="settings-security-capability-body" data-i18n="settings.security.capabilities_body">Two-factor authentication, passkeys, and multi-session controls will be added later. The current live flow uses password sign-in with one HttpOnly browser session.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-slot="settings.sso_panel"></div>
|
||||
<div data-slot="settings.totp_panel"></div>
|
||||
<div data-slot="settings.audit_panel"></div>
|
||||
<div data-slot="settings.billing_panel"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" style="margin-bottom: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="settings.session.title">Current session</div>
|
||||
</div>
|
||||
<div style="padding: 8px 20px 16px;">
|
||||
<div class="member-row">
|
||||
<div class="member-info">
|
||||
<div class="member-name"><span data-i18n="settings.session.browser">Browser session</span> — <span style="color:var(--green);font-size:12px;" data-i18n="settings.session.current">current</span></div>
|
||||
<div class="member-email" id="settings-session-summary" data-i18n="settings.session.loading">Loading current session details…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ■ NOTIFICATIONS ■ -->
|
||||
<div id="section-notifications" class="section-anchor">
|
||||
<div class="section-card" style="margin-bottom: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="settings.notifications.title">Notifications</div>
|
||||
</div>
|
||||
<div style="padding: 8px 20px 16px;">
|
||||
<div class="callout info" style="margin-bottom:14px;">
|
||||
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<path d="M8 11V8M8 5.5V5"/>
|
||||
</svg>
|
||||
<div>
|
||||
<strong id="settings-notifications-capability-title" data-i18n="settings.notifications.not_ready_title">Not included in this build.</strong> <span id="settings-notifications-capability-body" data-i18n="settings.notifications.not_ready_body">Notification routing and personal preferences are outside the current Community surface.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /settings-content -->
|
||||
</div><!-- /settings-layout -->
|
||||
|
||||
</div><!-- /page -->
|
||||
|
||||
<script src="%CRANK_BUNDLE_SETTINGS%"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,234 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Usage</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/usage.css">
|
||||
<style>
|
||||
.usage-card-list { display: none; }
|
||||
@media (max-width: 720px) {
|
||||
#usage-table-wrap { display: none; }
|
||||
.usage-card-list { display: grid; }
|
||||
}
|
||||
</style>
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link active" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" data-action="settings-link">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link active" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header-text">
|
||||
<h1 class="page-title" data-i18n="usage.title">Usage</h1>
|
||||
<p class="page-subtitle" data-i18n="usage.subtitle">Invocation metrics for all operations in your workspace.</p>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<select class="period-select" id="period">
|
||||
<option value="7d" selected data-i18n="usage.period.7d">Last 7 days</option>
|
||||
<option value="30d" data-i18n="usage.period.30d">Last 30 days</option>
|
||||
<option value="90d" data-i18n="usage.period.90d">Last 90 days</option>
|
||||
<option value="this_month" data-i18n="usage.period.this_month">This month</option>
|
||||
</select>
|
||||
<button class="btn-secondary" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.47 1.72a.75.75 0 011.06 0l4.5 4.5a.75.75 0 01-1.06 1.06L8.75 4.06v8.94a.75.75 0 01-1.5 0V4.06L4.53 7.28a.75.75 0 01-1.06-1.06l4.5-4.5zM1.5 13.75a.75.75 0 01.75-.75h11.5a.75.75 0 010 1.5H2.25a.75.75 0 01-.75-.75z"/></svg>
|
||||
<span data-i18n="usage.export">Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stat cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="usage.stats.total">Total invocations</div>
|
||||
<div class="stat-value">28,341</div>
|
||||
<div class="stat-delta up">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg>
|
||||
+12% vs prior period
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="usage.stats.success">Success rate</div>
|
||||
<div class="stat-value">98.6%</div>
|
||||
<div class="stat-delta up">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg>
|
||||
+0.3pp vs prior period
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="usage.stats.p50">Median latency (p50)</div>
|
||||
<div class="stat-value">124ms</div>
|
||||
<div class="stat-delta up">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg>
|
||||
-18ms vs prior period
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="usage.stats.p99">p99 latency</div>
|
||||
<div class="stat-value">2.1s</div>
|
||||
<div class="stat-delta down">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 10L2 6h3V2h2v4h3l-4 4z"/></svg>
|
||||
+340ms vs prior period
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invocations chart -->
|
||||
<div class="section-card" style="margin-bottom:20px;">
|
||||
<div class="section-card-header">
|
||||
<div class="section-card-title" data-i18n="usage.chart.title">Invocations over time</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;font-size:12px;color:var(--text-muted);">
|
||||
<span style="display:flex;align-items:center;gap:5px;"><span style="width:10px;height:10px;border-radius:2px;background:var(--accent);display:inline-block;"></span><span data-i18n="usage.chart.success">Success</span></span>
|
||||
<span style="display:flex;align-items:center;gap:5px;"><span style="width:10px;height:10px;border-radius:2px;background:var(--red);display:inline-block;"></span><span data-i18n="usage.chart.error">Error</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card-body" style="padding-bottom:28px;">
|
||||
<div class="chart-placeholder" id="chart-bars"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-operation table -->
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
<div class="section-card-title" data-i18n="usage.table.title">By operation</div>
|
||||
<div class="section-card-subtitle">Breakdown for last 7 days</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="usage-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="usage.table.th.operation">Operation</th>
|
||||
<th data-i18n="usage.table.th.protocol">Protocol</th>
|
||||
<th style="text-align:right;" data-i18n="usage.table.th.calls">Calls</th>
|
||||
<th style="text-align:right;" data-i18n="usage.table.th.errors">Errors</th>
|
||||
<th style="text-align:right;" data-i18n="usage.table.th.error_rate">Error rate</th>
|
||||
<th data-i18n="usage.table.th.latency">Latency (p50 / p95 / p99)</th>
|
||||
<th style="text-align:right;" data-i18n="usage.table.th.share">Traffic share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usage-tbody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="resource-list usage-card-list" id="usage-card-list"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Template: chart bar column -->
|
||||
<template id="tmpl-chart-bar">
|
||||
<div class="chart-col">
|
||||
<div class="chart-col-bars">
|
||||
<div class="chart-bar error"></div>
|
||||
<div class="chart-bar success"></div>
|
||||
</div>
|
||||
<div class="chart-col-label"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Template: usage stats table row -->
|
||||
<template id="tmpl-usage-row">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="col-name"></div>
|
||||
<div class="col-op-slug" style="font-family:monospace;font-size:11px;color:var(--text-muted);"></div>
|
||||
</td>
|
||||
<td><span class="badge col-proto" style="border-color:rgba(88,166,255,0.25);background:rgba(88,166,255,0.07);color:var(--blue);"></span></td>
|
||||
<td class="col-calls" style="text-align:right;font-variant-numeric:tabular-nums;"></td>
|
||||
<td class="col-errors" style="text-align:right;font-variant-numeric:tabular-nums;"></td>
|
||||
<td class="col-err-rate" style="text-align:right;font-variant-numeric:tabular-nums;"></td>
|
||||
<td class="col-latency">
|
||||
<div class="latency-bar">
|
||||
<div class="latency-seg latency-p50"></div>
|
||||
<div class="latency-seg latency-p95"></div>
|
||||
<div class="latency-seg latency-p99"></div>
|
||||
</div>
|
||||
<div class="col-latency-text" style="font-size:11px;color:var(--text-muted);margin-top:3px;font-family:monospace;"></div>
|
||||
</td>
|
||||
<td style="text-align:right;min-width:120px;">
|
||||
<div class="col-quota-label" style="font-size:11.5px;color:var(--text-muted);margin-bottom:3px;"></div>
|
||||
<div class="quota-bar-wrap"><div class="quota-bar-fill col-quota-fill"></div></div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script src="%CRANK_BUNDLE_USAGE%"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,267 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — New Operation</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../../css/variables.css">
|
||||
<link rel="stylesheet" href="../../css/layout.css">
|
||||
<link rel="stylesheet" href="../../css/wizard.css">
|
||||
<link rel="stylesheet" href="../../css/pages.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body class="wizard-page">
|
||||
|
||||
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name" data-testid="shell-workspace-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link active" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
|
||||
|
||||
|
||||
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
|
||||
<svg width="15" height="15"><use href="../../icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="nav-divider"></div>
|
||||
|
||||
<div class="user-menu">
|
||||
<div class="nav-avatar" data-testid="shell-avatar" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
<div class="user-dropdown">
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name" data-testid="shell-user-name">Crank</div>
|
||||
<div class="user-dropdown-role" data-testid="shell-user-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" data-action="settings-link">
|
||||
<svg width="13" height="13"><use href="../../icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" data-action="logout">
|
||||
<svg width="13" height="13"><use href="../../icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mobile-nav" hidden>
|
||||
<a href="/" class="mobile-nav-link active" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════ WIZARD SHELL ═══════════════════ -->
|
||||
<div class="wizard-shell">
|
||||
|
||||
<!-- ══ TOP PROGRESS STRIP ══ -->
|
||||
<div class="progress-strip">
|
||||
<button class="back-to-catalog-btn" id="back-to-catalog" data-i18n-title="wizard.back_catalog" title="Back to catalog">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M9.78 12.78a.75.75 0 01-1.06 0L4.47 8.53a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L6.06 8l3.72 3.72a.75.75 0 010 1.06z"/></svg>
|
||||
<span data-i18n="wizard.back_catalog">Operations</span>
|
||||
</button>
|
||||
<div class="progress-divider"></div>
|
||||
<span class="progress-label" data-i18n="wizard.progress.create">Create operation</span>
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-bar-fill"></div>
|
||||
</div>
|
||||
<span class="progress-pct">20%</span>
|
||||
<button class="progress-close" data-i18n-title="wizard.exit" title="Exit wizard" aria-label="Exit wizard">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ══ WIZARD BODY ══ -->
|
||||
<div class="wizard-body">
|
||||
|
||||
<!-- ── Step sidebar ── -->
|
||||
<div class="step-sidebar">
|
||||
<div class="step-sidebar-card">
|
||||
|
||||
<div class="step-sidebar-header">
|
||||
<div class="step-sidebar-logo-mark">M</div>
|
||||
<div class="step-sidebar-brand" data-wizard-brand="create">
|
||||
Create <span>operation</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-sidebar-body">
|
||||
<div class="step-sidebar-section-label" data-i18n="wizard.progress.section">Progress</div>
|
||||
|
||||
<div class="steps-list">
|
||||
|
||||
<div class="step-item active">
|
||||
<div class="step-indicator">1</div>
|
||||
<div class="step-content">
|
||||
<div class="step-number" data-step="1">Step 1</div>
|
||||
<div class="step-name" data-i18n="wizard.step_name.protocol">Protocol</div>
|
||||
<div class="step-status-text" data-i18n="wizard.status.in_progress">In progress</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-item pending">
|
||||
<div class="step-indicator">2</div>
|
||||
<div class="step-content">
|
||||
<div class="step-number" data-step="2">Step 2</div>
|
||||
<div class="step-name" data-i18n="wizard.step_name.upstream">Upstream target</div>
|
||||
<div class="step-status-text" data-i18n="wizard.status.not_started">Not started</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-item pending" id="sidebar-step-3">
|
||||
<div class="step-indicator">3</div>
|
||||
<div class="step-content">
|
||||
<div class="step-number" data-step="3">Step 3</div>
|
||||
<div class="step-name" id="sidebar-step-3-name">Request config</div>
|
||||
<div class="step-status-text" data-i18n="wizard.status.not_started">Not started</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-item pending">
|
||||
<div class="step-indicator">4</div>
|
||||
<div class="step-content">
|
||||
<div class="step-number" data-step="4">Step 4</div>
|
||||
<div class="step-name" data-i18n="wizard.step_name.tool">Tool config</div>
|
||||
<div class="step-status-text" data-i18n="wizard.status.not_started">Not started</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-item pending">
|
||||
<div class="step-indicator">5</div>
|
||||
<div class="step-content">
|
||||
<div class="step-number" data-step="5">Step 5</div>
|
||||
<div class="step-name" data-i18n="wizard.step_name.mapping">Mapping</div>
|
||||
<div class="step-status-text" data-i18n="wizard.status.not_started">Not started</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /steps-list -->
|
||||
|
||||
<div class="sidebar-help" style="margin-top: 20px;">
|
||||
<div class="sidebar-help-title">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/>
|
||||
<path d="M8 12v-4M8 5v-.5"/>
|
||||
</svg>
|
||||
<span data-i18n="wizard.help.title">Need help?</span>
|
||||
</div>
|
||||
<div class="sidebar-help-text">
|
||||
<span data-i18n="wizard.help.body">Unsure which protocol to choose? Read the</span>
|
||||
<a href="#" class="sidebar-help-link" data-i18n="wizard.help.link">protocol comparison guide →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /step-sidebar-body -->
|
||||
</div><!-- /step-sidebar-card -->
|
||||
</div><!-- /step-sidebar -->
|
||||
|
||||
<!-- ── Main step panel ── -->
|
||||
<div class="wizard-main"><div id="step-panel-container"><!-- steps loaded dynamically --></div></div>
|
||||
</div><!-- /wizard-body -->
|
||||
|
||||
<!-- ══ BOTTOM ACTION BAR ══ -->
|
||||
<div class="action-bar" role="toolbar" aria-label="Wizard navigation">
|
||||
<div class="action-bar-inner">
|
||||
|
||||
<button class="btn btn-back" type="button" disabled>
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M9.78 12.78a.75.75 0 01-1.06 0L4.47 8.53a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L6.06 8l3.72 3.72a.75.75 0 010 1.06z"/>
|
||||
</svg>
|
||||
<span data-i18n="wizard.button.back">Back</span>
|
||||
</button>
|
||||
|
||||
<button class="btn-save-draft" type="button" data-i18n="wizard.button.save_draft">Save draft</button>
|
||||
|
||||
<div class="action-bar-spacer"></div>
|
||||
|
||||
<span class="step-counter" data-step-counter>Step <strong>1</strong> of 5</span>
|
||||
|
||||
<button id="btn-continue" class="btn btn-continue" type="button">
|
||||
<span data-i18n="wizard.button.continue">Continue</span>
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /wizard-shell -->
|
||||
|
||||
<div class="modal-overlay" id="quick-secret-modal" data-testid="wizard-quick-secret-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" data-i18n="wizard.step2.quick_secret_title">Quick create secret</span>
|
||||
<button class="modal-close" id="quick-secret-close-btn" type="button">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" style="display:grid; gap:14px;">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="wizard.step2.quick_secret_name">Secret name</label>
|
||||
<input class="field-input" id="quick-secret-name" data-testid="wizard-quick-secret-name" type="text" autocomplete="off">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="wizard.step2.quick_secret_kind">Secret kind</label>
|
||||
<select class="field-select" id="quick-secret-kind" data-testid="wizard-quick-secret-kind">
|
||||
<option value="token" data-i18n="secrets.kind.token">Token</option>
|
||||
<option value="header" data-i18n="secrets.kind.header">Header value</option>
|
||||
<option value="generic" data-i18n="secrets.kind.generic">Generic JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="wizard.step2.quick_secret_value">Secret value</label>
|
||||
<input class="field-input" id="quick-secret-value" data-testid="wizard-quick-secret-value" type="text" autocomplete="off">
|
||||
<div class="field-hint" data-i18n="wizard.step2.quick_secret_hint">The plaintext is encrypted immediately and will not be returned again.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" id="quick-secret-cancel-btn" type="button" data-i18n="btn.cancel">Cancel</button>
|
||||
<button class="btn-primary" id="quick-secret-submit-btn" data-testid="wizard-quick-secret-submit" type="button" data-i18n="wizard.step2.quick_secret_submit">Create secret</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="%CRANK_BUNDLE_WIZARD%"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
<!-- ════════════ STEP 1: Protocol ════════════ -->
|
||||
<div id="step-panel-1" class="step-pane">
|
||||
<div class="step-panel-header">
|
||||
<div class="step-panel-eyebrow">
|
||||
<div class="step-panel-eyebrow-line"></div>
|
||||
<span data-step-counter>Step 1 of 5</span>
|
||||
</div>
|
||||
<h1 class="step-panel-title" data-i18n="wizard.step1.title">Choose a protocol</h1>
|
||||
<p class="step-panel-subtitle" data-i18n="wizard.step1.subtitle">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.</p>
|
||||
</div>
|
||||
|
||||
<!-- Protocol selection cards -->
|
||||
<div class="protocol-grid" data-testid="wizard-protocol-grid">
|
||||
|
||||
<div class="protocol-card rest selected" data-protocol="rest" data-testid="wizard-protocol-rest" role="radio" aria-checked="true" tabindex="0">
|
||||
<div class="protocol-card-check">
|
||||
<svg viewBox="0 0 12 12"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
|
||||
</div>
|
||||
<div class="protocol-icon-wrap">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#58a6ff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path d="M3.6 9h16.8M3.6 15h16.8"/>
|
||||
<path d="M12 3a15.3 15.3 0 010 18M12 3a15.3 15.3 0 000 18"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="protocol-card-name" data-i18n="wizard.step1.rest_name">REST / HTTP</div>
|
||||
<div class="protocol-card-tagline" data-i18n="wizard.step1.rest_tagline">Standard HTTP verbs over a RESTful endpoint. The most broadly supported option.</div>
|
||||
<div class="protocol-card-tags">
|
||||
<span class="protocol-tag">GET</span>
|
||||
<span class="protocol-tag">POST</span>
|
||||
<span class="protocol-tag">PUT</span>
|
||||
<span class="protocol-tag">PATCH</span>
|
||||
<span class="protocol-tag">DELETE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-slot="wizard.protocol_cards.graphql"></div>
|
||||
<div data-slot="wizard.protocol_cards.grpc"></div>
|
||||
<div data-slot="wizard.protocol_cards.soap"></div>
|
||||
<div data-slot="wizard.protocol_cards.websocket"></div>
|
||||
|
||||
</div><!-- /protocol-grid -->
|
||||
|
||||
|
||||
<div class="info-callout" style="margin-bottom: 24px;">
|
||||
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="8,1 15,14 1,14" fill="none"/>
|
||||
<path d="M8 6v4M8 11.5v.5"/>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.step1.tip_title">You can change this later</div>
|
||||
<div class="info-callout-text">
|
||||
<span data-i18n="wizard.step1.tip_body">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.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-callout" id="wizard-edition-protocol-note" hidden>
|
||||
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/>
|
||||
<path d="M8 11V8M8 5v-.5"/>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.step1.community_protocol_title">Community protocol scope</div>
|
||||
<div class="info-callout-text" id="wizard-edition-protocol-note-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /step-pane-1 -->
|
||||
@@ -0,0 +1,224 @@
|
||||
<!-- ════════════ STEP 2: Upstream target ════════════ -->
|
||||
<div id="step-panel-2" class="step-pane">
|
||||
<div class="step-panel-header">
|
||||
<div class="step-panel-eyebrow">
|
||||
<div class="step-panel-eyebrow-line"></div>
|
||||
<span data-step-counter>Step 2 of 5</span>
|
||||
</div>
|
||||
<h1 class="step-panel-title" data-i18n="wizard.step2.title">Select upstream & endpoint</h1>
|
||||
<p class="step-panel-subtitle" data-i18n="wizard.step2.subtitle">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.</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Upstream selector ── -->
|
||||
<div class="config-card" style="margin-bottom: 20px; overflow: visible; position: relative; z-index: 10;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="1" y="3" width="14" height="4" rx="1"/>
|
||||
<rect x="1" y="9" width="14" height="4" rx="1"/>
|
||||
<circle cx="4" cy="5" r="0.8" fill="var(--text-secondary)" stroke="none"/>
|
||||
<circle cx="4" cy="11" r="0.8" fill="var(--text-secondary)" stroke="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step2.upstream_title">Upstream</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step2.upstream_subtitle">Shared host, base URL, and authentication</div>
|
||||
</div>
|
||||
<span class="config-card-optional" style="color: var(--error, #f87171);" data-i18n="wizard.required">Required</span>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 12px; padding: 16px;">
|
||||
|
||||
<!-- Searchable combobox -->
|
||||
<div class="upstream-combobox" id="upstream-combobox">
|
||||
<div class="upstream-combobox-trigger" id="upstream-combobox-trigger" onclick="toggleUpstreamDropdown(event)">
|
||||
<div class="upstream-combobox-value" id="upstream-combobox-value">
|
||||
<span class="upstream-combobox-placeholder" data-i18n="wizard.step2.upstream_placeholder">Select an upstream…</span>
|
||||
</div>
|
||||
<svg class="upstream-combobox-chevron" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 6l4 4 4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown panel -->
|
||||
<div class="upstream-dropdown" id="upstream-dropdown" hidden>
|
||||
<div class="upstream-search-wrap">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round">
|
||||
<circle cx="7" cy="7" r="5"/><path d="M12 12l2.5 2.5"/>
|
||||
</svg>
|
||||
<input class="upstream-search-input" id="upstream-search" type="text" data-i18n-ph="wizard.step2.search_placeholder" placeholder="Search by name or URL…" oninput="filterUpstreams(this.value)" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="upstream-dropdown-list" id="upstream-dropdown-list">
|
||||
<!-- populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected upstream preview -->
|
||||
<div class="upstream-preview" id="upstream-preview" hidden>
|
||||
<div class="upstream-preview-name" id="upstream-preview-name"></div>
|
||||
<div class="upstream-preview-url" id="upstream-preview-url"></div>
|
||||
<span class="upstream-auth-badge" id="upstream-preview-badge"></span>
|
||||
<button class="upstream-preview-change" onclick="beginEditSelectedUpstream(event)" data-i18n="wizard.step2.change">Change</button>
|
||||
</div>
|
||||
|
||||
<!-- Register new upstream trigger row -->
|
||||
<div class="upstream-new-trigger" id="upstream-new-trigger" onclick="startNewUpstream()">
|
||||
<div class="upstream-new-trigger-radio" id="upstream-new-trigger-radio">
|
||||
<div class="upstream-new-trigger-dot"></div>
|
||||
</div>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="2.2" stroke-linecap="round">
|
||||
<path d="M8 3v10M3 8h10"/>
|
||||
</svg>
|
||||
<span data-i18n="wizard.step2.register_new">Register new upstream</span>
|
||||
</div>
|
||||
|
||||
<!-- Register new upstream form (kept as-is per design) -->
|
||||
<div class="upstream-new-form" id="upstream-new-form" hidden>
|
||||
<div class="upstream-new-form-inner">
|
||||
<div class="form-row" style="grid-template-columns: 1fr 2fr;">
|
||||
<div class="form-group">
|
||||
<label class="form-label"><span data-i18n="wizard.step2.name">Name</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<input class="form-input" id="new-upstream-name" type="text" placeholder="e.g. acme-api" autocomplete="off" spellcheck="false">
|
||||
<div class="form-hint" data-i18n="wizard.step2.unique_hint">Unique identifier for this upstream.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label"><span data-i18n="wizard.step2.base_url">Base URL</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<input class="form-input input-mono" id="new-upstream-url" type="text" placeholder="https://api.example.com" autocomplete="off" spellcheck="false">
|
||||
<div class="form-hint" data-i18n="wizard.step2.base_url_hint">Root URL without a trailing slash. Auth is configured separately below.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.auth_selector">Upstream auth</label>
|
||||
<select class="form-select" id="new-upstream-auth-mode" data-testid="wizard-auth-mode-select" onchange="updateUpstreamAuthUi()">
|
||||
<option value="none" data-i18n="wizard.step2.auth_mode.none">No auth</option>
|
||||
<option value="existing" data-i18n="wizard.step2.auth_mode.existing">Use existing auth profile</option>
|
||||
<option value="create" data-i18n="wizard.step2.auth_mode.create">Create auth profile now</option>
|
||||
</select>
|
||||
<div class="form-hint" data-i18n="wizard.step2.auth_selector_hint">Use secrets-backed auth profiles instead of embedding credential headers in the upstream definition.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="upstream-auth-existing-group" hidden>
|
||||
<label class="form-label" data-i18n="wizard.step2.auth_profile">Auth profile</label>
|
||||
<select class="form-select" id="new-upstream-auth-profile" data-testid="wizard-auth-profile-select"></select>
|
||||
<div class="form-hint" id="new-upstream-auth-profile-hint" data-i18n="wizard.step2.auth_profile_hint">Selected profile will be resolved at runtime and attached through `execution_config.auth_profile_ref`.</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" id="upstream-auth-create-group" hidden style="margin-top: 6px;">
|
||||
<div class="config-card-header">
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step2.create_profile_title">Create auth profile</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step2.create_profile_subtitle">Create a reusable profile backed by encrypted workspace secrets.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 14px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label"><span data-i18n="wizard.step2.profile_name">Profile name</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<input class="form-input" id="new-auth-profile-name" type="text" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row" style="grid-template-columns: 1fr 1fr;">
|
||||
<div class="form-group">
|
||||
<label class="form-label"><span data-i18n="wizard.step2.profile_kind">Auth kind</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<select class="form-select" id="new-auth-profile-kind" data-testid="wizard-auth-profile-kind-select" onchange="updateAuthProfileCreateUi()">
|
||||
<option value="bearer" data-i18n="wizard.step2.auth_kind.bearer">Bearer token</option>
|
||||
<option value="basic" data-i18n="wizard.step2.auth_kind.basic">Basic auth</option>
|
||||
<option value="api_key_header" data-i18n="wizard.step2.auth_kind.api_key_header">API key header</option>
|
||||
<option value="api_key_query" data-i18n="wizard.step2.auth_kind.api_key_query">API key query</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="auth-profile-header-name-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.header_name">Header name</label>
|
||||
<input class="form-input input-mono" id="new-auth-profile-header-name" type="text" value="Authorization" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="auth-profile-query-param-group" hidden>
|
||||
<label class="form-label" data-i18n="wizard.step2.query_param">Query param</label>
|
||||
<input class="form-input input-mono" id="new-auth-profile-query-param" type="text" value="api_key" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row" id="auth-profile-basic-secret-row" hidden style="grid-template-columns: 1fr 1fr;">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.username_secret">Username secret</label>
|
||||
<select class="form-select" id="new-auth-profile-username-secret" data-testid="wizard-auth-username-secret-select"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.password_secret">Password secret</label>
|
||||
<select class="form-select" id="new-auth-profile-password-secret" data-testid="wizard-auth-password-secret-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="auth-profile-single-secret-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.secret_value">Secret</label>
|
||||
<select class="form-select" id="new-auth-profile-secret-id" data-testid="wizard-auth-secret-select"></select>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||
<button class="btn-ghost-sm" data-testid="wizard-open-quick-secret" onclick="openQuickSecretModal(event)" data-i18n="wizard.step2.quick_secret">Quick create secret</button>
|
||||
<a class="btn-ghost-sm" href="/secrets" target="_blank" rel="noopener" data-i18n="wizard.step2.manage_secrets">Open secrets page</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step2.static_headers">Static headers (optional)</label>
|
||||
<div class="code-block" style="border-radius: 6px;">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">json / static-headers</span>
|
||||
</div>
|
||||
<textarea class="form-textarea code-textarea" id="new-upstream-static-headers" rows="5">{
|
||||
}</textarea>
|
||||
</div>
|
||||
<div class="form-hint" data-i18n="wizard.step2.static_headers_hint">Optional non-secret headers sent on every request to this upstream. Use auth profiles for credentials.</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-top:4px;">
|
||||
<button class="btn-primary-sm" onclick="saveNewUpstream(event)" data-i18n="wizard.step2.save_upstream">Save upstream</button>
|
||||
<button class="btn-ghost-sm" onclick="cancelNewUpstream(event)" data-i18n="btn.cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Endpoint ── -->
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 8h12M10 4l4 4-4 4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step2.endpoint_title">Endpoint</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step2.endpoint_subtitle">Path and HTTP method for this specific operation</div>
|
||||
</div>
|
||||
<span class="config-card-optional" style="color: var(--error, #f87171);" data-i18n="wizard.required">Required</span>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 16px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<span data-i18n="wizard.step2.path_template">Path template</span>
|
||||
<span class="form-label-required" data-i18n="workspace_setup.required">required</span>
|
||||
</label>
|
||||
<input class="form-input input-mono" id="endpoint-path" type="text" value="/v1/leads" autocomplete="off" spellcheck="false">
|
||||
<div class="form-hint" data-i18n="wizard.step2.path_hint">Appended to the upstream base URL. Use {param} for path variables.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /step-pane-2 -->
|
||||
|
||||
<!-- Template: upstream dropdown item -->
|
||||
<template id="tmpl-upstream-item">
|
||||
<div class="upstream-dropdown-item">
|
||||
<div class="upstream-dropdown-item-info">
|
||||
<div class="upstream-dropdown-item-name"></div>
|
||||
<div class="upstream-dropdown-item-url"></div>
|
||||
</div>
|
||||
<span class="upstream-auth-badge"></span>
|
||||
<svg class="upstream-dropdown-item-check" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8l4 4 6-7"/></svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Template: upstream combobox trigger value -->
|
||||
<template id="tmpl-upstream-trigger-value">
|
||||
<div class="upstream-trigger-name"></div>
|
||||
<div class="upstream-trigger-url"></div>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<!-- REST variant -->
|
||||
<div id="step-panel-3-rest" class="step-pane">
|
||||
<div class="step-panel-header">
|
||||
<div class="step-panel-eyebrow">
|
||||
<div class="step-panel-eyebrow-line"></div>
|
||||
<span data-step-counter>Step 3 of 5</span>
|
||||
</div>
|
||||
<h1 class="step-panel-title" data-i18n="wizard.step3.rest.title">HTTP method & format</h1>
|
||||
<p class="step-panel-subtitle" data-i18n="wizard.step3.rest.subtitle">Choose the HTTP verb this operation sends and configure content negotiation headers. Crank will serialize MCP tool arguments into the appropriate request body format.</p>
|
||||
</div>
|
||||
|
||||
<!-- HTTP method picker -->
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 8h12M10 4l4 4-4 4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step3.rest.method_title">HTTP method</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step3.rest.method_subtitle">Verb sent to the upstream on every tool invocation</div>
|
||||
</div>
|
||||
<span class="config-card-optional" style="color:var(--error,#f87171);" data-i18n="wizard.required">Required</span>
|
||||
</div>
|
||||
<div class="config-card-body">
|
||||
<div class="method-grid">
|
||||
<button class="method-card" data-method="GET" onclick="selectMethod(this)">
|
||||
<span class="method-name">GET</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.read">Read</span>
|
||||
</button>
|
||||
<button class="method-card active" data-method="POST" onclick="selectMethod(this)">
|
||||
<span class="method-name">POST</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.create">Create</span>
|
||||
</button>
|
||||
<button class="method-card" data-method="PUT" onclick="selectMethod(this)">
|
||||
<span class="method-name">PUT</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.replace">Replace</span>
|
||||
</button>
|
||||
<button class="method-card" data-method="PATCH" onclick="selectMethod(this)">
|
||||
<span class="method-name">PATCH</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.update">Update</span>
|
||||
</button>
|
||||
<button class="method-card" data-method="DELETE" onclick="selectMethod(this)">
|
||||
<span class="method-name">DELETE</span>
|
||||
<span class="method-desc" data-i18n="wizard.step3.rest.remove">Remove</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="method-callout" id="method-callout-rest" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request format -->
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
|
||||
<path d="M5 7h6M5 10h4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step3.rest.format_title">Request format</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step3.rest.format_subtitle">Content negotiation and serialisation</div>
|
||||
</div>
|
||||
<span class="config-card-optional" data-i18n="wizard.optional">Optional</span>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 16px;">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.rest.content_type">Content-Type</label>
|
||||
<select class="form-select">
|
||||
<option selected>application/json</option>
|
||||
<option>application/x-www-form-urlencoded</option>
|
||||
<option>multipart/form-data</option>
|
||||
<option>text/plain</option>
|
||||
</select>
|
||||
<div class="form-hint" data-i18n="wizard.step3.rest.content_type_hint">How the request body is encoded when sent to the upstream.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step3.rest.accept">Accept</label>
|
||||
<select class="form-select">
|
||||
<option selected>application/json</option>
|
||||
<option>text/plain</option>
|
||||
<option>*/*</option>
|
||||
</select>
|
||||
<div class="form-hint" data-i18n="wizard.step3.rest.accept_hint">Accepted response content types from the upstream server.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /step-pane-3-rest -->
|
||||
@@ -0,0 +1,158 @@
|
||||
<div id="step-panel-4" class="step-pane">
|
||||
<div class="step-panel-header">
|
||||
<div class="step-panel-eyebrow">
|
||||
<div class="step-panel-eyebrow-line"></div>
|
||||
<span data-step-counter>Step 4 of 5</span>
|
||||
</div>
|
||||
<h1 class="step-panel-title" data-i18n="wizard.step4.title">Tool config</h1>
|
||||
<p class="step-panel-subtitle" data-i18n="wizard.step4.subtitle">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.</p>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 5h12M2 8h8M2 11h5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step4.identity_title">Tool identity</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step4.identity_subtitle">Machine-readable name and LLM-facing description</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 20px;">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<span data-i18n="wizard.step4.tool_name">Tool name</span>
|
||||
<span class="form-label-required" data-i18n="workspace_setup.required">required</span>
|
||||
</label>
|
||||
<input id="tool-name" class="form-input input-mono" type="text" value="create_crm_lead" autocomplete="off" spellcheck="false">
|
||||
<div class="form-hint" data-i18n="wizard.step4.tool_name_hint">Machine-readable identifier used in MCP tool calls. Only lowercase letters, numbers and underscores. Cannot be changed after publishing.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<span data-i18n="wizard.step4.display_name">Display name</span>
|
||||
<span class="form-label-required" data-i18n="workspace_setup.required">required</span>
|
||||
</label>
|
||||
<input id="tool-display-name" class="form-input" type="text" value="Create CRM Lead" autocomplete="off">
|
||||
<div class="form-hint" data-i18n="wizard.step4.display_name_hint">Human-readable name shown in the console and audit log.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<span data-i18n="wizard.step4.tool_title">Tool title</span>
|
||||
<span class="form-label-optional" data-i18n="workspace_setup.optional">(optional)</span>
|
||||
</label>
|
||||
<input id="tool-title" class="form-input" type="text" value="Create a new CRM lead record" autocomplete="off">
|
||||
<div class="form-hint" data-i18n="wizard.step4.tool_title_hint">Short imperative sentence shown in the MCP tool manifest.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<span data-i18n="wizard.step4.description">Description</span>
|
||||
<span class="form-label-required" data-i18n="workspace_setup.required">required</span>
|
||||
</label>
|
||||
<textarea id="tool-description" class="form-textarea" rows="5">Creates a new lead record in the CRM from the provided contact details. Use this tool when the user wants to add a new prospect or contact. Returns the new lead ID and creation timestamp on success.</textarea>
|
||||
<div class="form-hint" data-i18n="wizard.step4.description_hint">LLM-facing description. Be precise — this is the primary signal the model uses to decide whether to invoke this tool.</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-callout" style="margin-bottom: 24px;">
|
||||
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/>
|
||||
<path d="M8 11V8M8 5v-.5"/>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.step4.description_title">Writing effective descriptions</div>
|
||||
<div class="info-callout-text" data-i18n="wizard.step4.description_body">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 →</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 2h8a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V4a2 2 0 012-2z"/>
|
||||
<path d="M6 6l-2 2 2 2M10 6l2 2-2 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step4.input_schema_title">Input schema</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step4.input_schema_subtitle">Parameters the LLM passes when calling this tool</div>
|
||||
</div>
|
||||
<span class="config-card-optional" data-i18n="wizard.step4.schema_draft">JSON Schema draft-07</span>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 0; padding: 0;">
|
||||
<div class="code-block" style="border-radius: 0; border: none;">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">json / input-schema</span>
|
||||
</div>
|
||||
<textarea id="tool-input-schema" class="form-textarea code-textarea" rows="16">{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"required": ["first_name", "last_name", "email"],
|
||||
"properties": {
|
||||
"first_name": { "type": "string", "description": "Contact first name" },
|
||||
"last_name": { "type": "string", "description": "Contact last name" },
|
||||
"email": { "type": "string", "format": "email", "description": "Primary contact email address" },
|
||||
"company": { "type": "string", "description": "Company or organisation name" },
|
||||
"phone": { "type": "string", "description": "Phone number in E.164 format" },
|
||||
"source": { "type": "string", "enum": ["web","api","import"], "default": "api" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 2h8a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V4a2 2 0 012-2z"/>
|
||||
<path d="M5 8h6M5 5h4M5 11h3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step4.output_schema_title">Output schema</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step4.output_schema_subtitle">Shape of the data returned to the LLM after a successful call</div>
|
||||
</div>
|
||||
<span class="config-card-optional" data-i18n="wizard.step4.schema_draft">JSON Schema draft-07</span>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 0; padding: 0;">
|
||||
<div class="code-block" style="border-radius: 0; border: none;">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">json / output-schema</span>
|
||||
</div>
|
||||
<textarea id="tool-output-schema" class="form-textarea code-textarea" rows="14">{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lead_id": { "type": "string", "description": "Unique identifier of the created lead" },
|
||||
"status": { "type": "string", "description": "Initial lead status assigned by the CRM" },
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"owner_id": { "type": "string", "description": "CRM user ID of the assigned owner" }
|
||||
}
|
||||
}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-callout" style="margin-bottom: 24px;">
|
||||
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/>
|
||||
<path d="M8 11V8M8 5v-.5"/>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.step4.protocol_agnostic_title">Schemas are protocol-agnostic</div>
|
||||
<div class="info-callout-text" data-i18n="wizard.step4.protocol_agnostic_body">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.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /step-pane-4 -->
|
||||
@@ -0,0 +1,265 @@
|
||||
<div id="step-panel-5" class="step-pane">
|
||||
<div class="step-panel-header">
|
||||
<div class="step-panel-eyebrow">
|
||||
<div class="step-panel-eyebrow-line"></div>
|
||||
<span data-step-counter>Step 5 of 5</span>
|
||||
</div>
|
||||
<h1 class="step-panel-title" data-i18n="wizard.step5.title">Mapping and execution</h1>
|
||||
<p class="step-panel-subtitle" data-i18n="wizard.step5.subtitle">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 2.</p>
|
||||
</div>
|
||||
|
||||
<div class="section-divider" style="margin-bottom: 16px;">
|
||||
<span class="section-divider-label" data-i18n="wizard.step5.input_request">Input → Request</span>
|
||||
<div class="section-divider-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-body" style="gap: 0; padding: 0;">
|
||||
<div class="code-block" style="border-radius: 0; border: none;">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">yaml / input-mapping</span>
|
||||
</div>
|
||||
<textarea id="tool-input-mapping" class="form-textarea code-textarea" rows="12">first_name: "$.input.first_name"
|
||||
last_name: "$.input.last_name"
|
||||
email: "$.input.email"
|
||||
company: "$.input.company"
|
||||
phone: "$.input.phone"
|
||||
source: "$.input.source"</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider" style="margin-bottom: 16px;">
|
||||
<span class="section-divider-label" data-i18n="wizard.step5.output_response">Response → Output</span>
|
||||
<div class="section-divider-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-body" style="gap: 0; padding: 0;">
|
||||
<div class="code-block" style="border-radius: 0; border: none;">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">yaml / output-mapping</span>
|
||||
</div>
|
||||
<textarea id="tool-output-mapping" class="form-textarea code-textarea" rows="10">lead_id: "$.response.data.id"
|
||||
status: "$.response.data.status"
|
||||
created_at: "$.response.data.created_at"
|
||||
owner_id: "$.response.data.owner.id"</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider" style="margin-bottom: 16px;">
|
||||
<span class="section-divider-label" data-i18n="wizard.step5.execution">Execution</span>
|
||||
<div class="section-divider-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="6"/>
|
||||
<path d="M8 5v3l2 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step5.exec_title">Execution config</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step5.exec_subtitle">Timeouts, retry policy and auth profile reference</div>
|
||||
</div>
|
||||
<span class="config-card-optional" data-i18n="wizard.optional">Optional</span>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 0; padding: 0;">
|
||||
<div class="code-block" style="border-radius: 0; border: none;">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">yaml / exec-config</span>
|
||||
</div>
|
||||
<textarea id="tool-exec-config" class="form-textarea code-textarea" rows="10">timeout_ms: 10000
|
||||
retry:
|
||||
max_attempts: 3
|
||||
backoff_ms: 500
|
||||
auth:
|
||||
profile: acme_bearer
|
||||
tls:
|
||||
verify: true</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-callout" id="wizard-security-level-note" hidden style="margin-bottom: 20px;">
|
||||
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/>
|
||||
<path d="M8 11V8M8 5v-.5"/>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.step5.security_level_title">Operation security in Community</div>
|
||||
<div class="info-callout-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-divider" style="margin-bottom: 16px;">
|
||||
<span class="section-divider-label" data-i18n="wizard.step5.live_title">Live validation and publishing</span>
|
||||
<div class="section-divider-line"></div>
|
||||
</div>
|
||||
|
||||
<div id="wizard-live-status" class="info-callout" hidden style="margin-bottom: 20px;">
|
||||
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/>
|
||||
<path d="M8 11V8M8 5v-.5"/>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" id="wizard-live-status-title" data-i18n="wizard.step5.draft_actions">Draft actions</div>
|
||||
<div class="info-callout-text" id="wizard-live-status-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-callout info-callout-subtle" style="margin-bottom: 20px;">
|
||||
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/>
|
||||
<path d="M8 11V8M8 5v-.5"/>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.step5.live_save_title">Live actions save the draft first</div>
|
||||
<div class="info-callout-text" data-i18n="wizard.step5.live_save_body">Sample uploads, test runs, YAML actions and publish all persist the current draft before calling the backend.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
|
||||
<path d="M5 7h6M5 10h6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step5.samples_title">Samples and draft generation</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step5.samples_subtitle">Persist input/output samples, then generate schemas and mappings from real payloads.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 16px;">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step5.input_sample">Input sample</label>
|
||||
<textarea id="wizard-input-sample" class="form-textarea code-textarea" rows="9" spellcheck="false">{
|
||||
"first_name": "Ada",
|
||||
"last_name": "Lovelace",
|
||||
"email": "ada@example.com"
|
||||
}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step5.output_sample">Output sample</label>
|
||||
<textarea id="wizard-output-sample" class="form-textarea code-textarea" rows="9" spellcheck="false">{
|
||||
"id": "lead_123",
|
||||
"status": "created"
|
||||
}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap: 8px; flex-wrap: wrap;">
|
||||
<button id="wizard-upload-input-sample" class="btn-primary-sm" type="button" data-i18n="wizard.step5.save_input_sample">Save input sample</button>
|
||||
<button id="wizard-upload-output-sample" class="btn-ghost-sm" type="button" data-i18n="wizard.step5.save_output_sample">Save output sample</button>
|
||||
<button id="wizard-generate-draft" class="btn-ghost-sm" type="button" data-i18n="wizard.step5.generate_draft">Generate draft from samples</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 8h12M10 4l4 4-4 4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step5.test_title">Test run</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step5.test_subtitle">Run the current draft against the upstream before publishing it.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 16px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step5.test_input_payload">Test input payload</label>
|
||||
<textarea id="wizard-test-input" class="form-textarea code-textarea" rows="8" spellcheck="false">{
|
||||
"first_name": "Ada",
|
||||
"last_name": "Lovelace",
|
||||
"email": "ada@example.com"
|
||||
}</textarea>
|
||||
</div>
|
||||
<div style="display:flex; gap: 8px; flex-wrap: wrap;">
|
||||
<button id="wizard-run-test" class="btn-primary-sm" type="button" data-i18n="wizard.step5.run_test">Run test</button>
|
||||
<button id="wizard-copy-test-response" class="btn-ghost-sm" type="button" data-i18n="wizard.step5.use_response_output">Use response as output sample</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step5.request_preview">Request preview</label>
|
||||
<textarea id="wizard-test-request-preview" class="form-textarea code-textarea" rows="8" spellcheck="false" readonly></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step5.response_preview">Response preview</label>
|
||||
<textarea id="wizard-test-response-preview" class="form-textarea code-textarea" rows="8" spellcheck="false" readonly></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step5.errors">Errors</label>
|
||||
<textarea id="wizard-test-errors" class="form-textarea code-textarea" rows="4" spellcheck="false" readonly></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 2v12M3 7l5-5 5 5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step5.yaml_title">YAML import and export</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step5.yaml_subtitle">Move operation drafts between environments without leaving the wizard.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 16px;">
|
||||
<div style="display:flex; gap: 8px; flex-wrap: wrap;">
|
||||
<button id="wizard-export-yaml" class="btn-ghost-sm" type="button" data-i18n="wizard.step5.export_yaml">Export YAML</button>
|
||||
<button id="wizard-import-yaml-file-trigger" class="btn-ghost-sm" type="button" data-i18n="wizard.step5.load_yaml_file">Load YAML file</button>
|
||||
<button id="wizard-import-yaml" class="btn-primary-sm" type="button" data-i18n="wizard.step5.import_yaml">Import YAML</button>
|
||||
<input id="wizard-import-yaml-file" type="file" accept=".yaml,.yml,text/yaml" style="display:none">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step5.yaml_document">YAML document</label>
|
||||
<textarea id="wizard-import-yaml-text" class="form-textarea code-textarea" rows="10" spellcheck="false" data-i18n-ph="wizard.step5.yaml_placeholder" placeholder="Paste exported YAML here to create or upsert an operation."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 8l3 3 7-7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="config-card-title" data-i18n="wizard.step5.publish_title">Publish</div>
|
||||
<div class="config-card-subtitle" data-i18n="wizard.step5.publish_subtitle">Save the current draft and expose it to published agents.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 12px;">
|
||||
<div class="form-hint" data-i18n="wizard.step5.publish_hint">Publishing uses the current draft version from this wizard session.</div>
|
||||
<div style="display:flex; gap: 8px; flex-wrap: wrap;">
|
||||
<button id="wizard-publish-operation" class="btn-primary-sm" type="button" data-i18n="wizard.step5.publish_action">Publish operation</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-callout" style="margin-bottom: 24px;">
|
||||
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="8" r="7"/>
|
||||
<path d="M8 11V8M8 5v-.5"/>
|
||||
</svg>
|
||||
<div class="info-callout-body">
|
||||
<div class="info-callout-title" data-i18n="wizard.step5.editable_title">Operation stays editable in this wizard</div>
|
||||
<div class="info-callout-text" data-i18n="wizard.step5.editable_body">Saving creates or updates the current draft in place. Use the live actions above to upload samples, test the draft, export or import YAML, and publish when the upstream contract is ready.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /step-pane-5 -->
|
||||
@@ -0,0 +1,280 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="../">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Workspace</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/wizard.css">
|
||||
<link rel="stylesheet" href="css/workspace-setup.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ws-setup-page">
|
||||
|
||||
<!-- Top bar -->
|
||||
<div class="ws-setup-topbar">
|
||||
<a href="/" class="ws-setup-logo">
|
||||
<div class="ws-setup-logo-mark">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="18" height="18" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/></svg>
|
||||
</div>
|
||||
Crank
|
||||
</a>
|
||||
<a href="javascript:history.back()" class="ws-setup-back">
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg>
|
||||
<span data-i18n="workspace_setup.back">Back</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="ws-setup-body">
|
||||
<div class="ws-setup-container">
|
||||
|
||||
<div class="ws-setup-header">
|
||||
<div class="ws-setup-title" id="page-title" data-i18n="workspace_setup.title">Workspace settings</div>
|
||||
<div class="ws-setup-subtitle" id="page-subtitle" data-i18n="workspace_setup.page_subtitle">Configure your workspace identity and team members.</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Identity ── -->
|
||||
<div class="ws-form-section">
|
||||
<div class="ws-form-section-title" data-i18n="workspace_setup.identity.title">Workspace identity</div>
|
||||
|
||||
<div class="ws-avatar-row">
|
||||
<div class="ws-avatar-preview" id="ws-avatar-preview">A</div>
|
||||
<div>
|
||||
<div class="ws-avatar-hint" data-i18n="workspace_setup.identity.avatar_hint">Avatar is derived from your workspace name.</div>
|
||||
<div class="ws-color-swatches">
|
||||
<div class="ws-color-swatch active" style="background:#0d9488;" data-color="#0d9488" onclick="pickColor(this)" title="Teal"></div>
|
||||
<div class="ws-color-swatch" style="background:#7c3aed;" data-color="#7c3aed" onclick="pickColor(this)" title="Purple"></div>
|
||||
<div class="ws-color-swatch" style="background:#0891b2;" data-color="#0891b2" onclick="pickColor(this)" title="Cyan"></div>
|
||||
<div class="ws-color-swatch" style="background:#d29922;" data-color="#d29922" onclick="pickColor(this)" title="Amber"></div>
|
||||
<div class="ws-color-swatch" style="background:#1a7f37;" data-color="#1a7f37" onclick="pickColor(this)" title="Green"></div>
|
||||
<div class="ws-color-swatch" style="background:#cf222e;" data-color="#cf222e" onclick="pickColor(this)" title="Red"></div>
|
||||
<div class="ws-color-swatch" style="background:#5e6ad2;" data-color="#5e6ad2" onclick="pickColor(this)" title="Indigo"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:14px;">
|
||||
<label class="form-label"><span data-i18n="workspace_setup.name">Workspace name</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<input class="form-input" id="ws-name" type="text" placeholder="Acme Inc" autocomplete="off" oninput="onWsNameInput(this.value)">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:14px;">
|
||||
<label class="form-label"><span data-i18n="workspace_setup.slug">Slug</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
|
||||
<div style="position:relative;">
|
||||
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:13px;color:var(--text-muted);font-family:monospace;pointer-events:none;">mcp.crank.io/</span>
|
||||
<input class="form-input input-mono" id="ws-slug" type="text" placeholder="acme-inc" autocomplete="off" style="padding-left: 112px;" oninput="onWsSlugInput(this.value)">
|
||||
</div>
|
||||
<div class="form-hint" data-i18n="workspace_setup.slug_hint">Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label"><span data-i18n="workspace_setup.description">Description</span> <span class="form-label-optional" data-i18n="workspace_setup.optional">(optional)</span></label>
|
||||
<textarea class="form-textarea" id="ws-desc" rows="2" data-i18n-ph="workspace_setup.description_placeholder" placeholder="What does this workspace do?"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Members (edit mode only) ── -->
|
||||
<div id="section-members" class="ws-form-section" hidden style="padding:0; overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:20px 24px 16px;">
|
||||
<div>
|
||||
<div class="ws-form-section-title" style="margin-bottom:2px;" data-i18n="workspace_setup.members.title">Team members</div>
|
||||
<div style="font-size:12px;color:var(--text-muted);" id="members-count-label">4 members</div>
|
||||
</div>
|
||||
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;" onclick="toggleInviteForm()">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" style="margin-right:4px;"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||
<span data-i18n="workspace_setup.members.invite">Invite member</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Invite form -->
|
||||
<div id="invite-form" hidden style="padding: 0 24px 16px; border-bottom: 1px solid var(--border-subtle);">
|
||||
<div style="display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap;">
|
||||
<div class="form-group" style="flex:1; min-width:200px; margin-bottom:0;">
|
||||
<label class="form-label" data-i18n="workspace_setup.members.email">Email address</label>
|
||||
<input class="form-input" id="invite-email" type="email" placeholder="colleague@company.com" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group" style="width:140px; margin-bottom:0;">
|
||||
<label class="form-label" data-i18n="workspace_setup.members.role">Role</label>
|
||||
<select class="form-input" id="invite-role">
|
||||
<option value="admin" data-i18n="workspace_setup.role.admin">Admin</option>
|
||||
<option value="developer" selected data-i18n="workspace_setup.role.operator">Developer</option>
|
||||
<option value="viewer" data-i18n="workspace_setup.role.viewer">Viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;" onclick="sendInvite()" data-i18n="workspace_setup.members.send_invite">Send invite</button>
|
||||
<button class="btn-ghost-sm" type="button" style="padding:7px 12px;" onclick="toggleInviteForm()" data-i18n="workspace_setup.members.cancel">Cancel</button>
|
||||
</div>
|
||||
<div id="invite-success" hidden style="margin-top:10px; font-size:13px; color: #3fb950;" data-i18n="workspace_setup.members.invite_sent">✓ Invite sent.</div>
|
||||
</div>
|
||||
|
||||
<!-- Members list -->
|
||||
<div style="padding: 4px 24px 8px;" id="members-list">
|
||||
<div class="member-row">
|
||||
<div class="member-avatar" style="background:linear-gradient(135deg,#0d9488,#6366f1);">AT</div>
|
||||
<div class="member-info">
|
||||
<div class="member-name">Crank <span style="font-size:11px;font-weight:400;color:var(--text-muted);" data-i18n="workspace_setup.members.you">(you)</span></div>
|
||||
<div class="member-email">operator@acme.com</div>
|
||||
</div>
|
||||
<div class="member-last-active">Today</div>
|
||||
<span class="member-role-badge role-owner" data-i18n="workspace_setup.role.owner">Owner</span>
|
||||
</div>
|
||||
<div class="member-row">
|
||||
<div class="member-avatar" style="background:linear-gradient(135deg,#7c3aed,#4f46e5);">BS</div>
|
||||
<div class="member-info">
|
||||
<div class="member-name">Blake Smith</div>
|
||||
<div class="member-email">blake@acme.com</div>
|
||||
</div>
|
||||
<div class="member-last-active">2 days ago</div>
|
||||
<select class="member-role-select" onchange="updateRole(this, 'blake@acme.com')">
|
||||
<option value="admin" selected data-i18n="workspace_setup.role.admin">Admin</option>
|
||||
<option value="developer" data-i18n="workspace_setup.role.operator">Developer</option>
|
||||
<option value="viewer" data-i18n="workspace_setup.role.viewer">Viewer</option>
|
||||
</select>
|
||||
<button class="member-remove-btn" onclick="removeMember(this, 'Blake Smith')" data-i18n-title="workspace_setup.members.remove" title="Remove member">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="member-row">
|
||||
<div class="member-avatar" style="background:linear-gradient(135deg,#d29922,#f59e0b);">CL</div>
|
||||
<div class="member-info">
|
||||
<div class="member-name">Carol Lane</div>
|
||||
<div class="member-email">carol@acme.com</div>
|
||||
</div>
|
||||
<div class="member-last-active">1 week ago</div>
|
||||
<select class="member-role-select" onchange="updateRole(this, 'carol@acme.com')">
|
||||
<option value="admin" data-i18n="workspace_setup.role.admin">Admin</option>
|
||||
<option value="developer" selected data-i18n="workspace_setup.role.operator">Developer</option>
|
||||
<option value="viewer" data-i18n="workspace_setup.role.viewer">Viewer</option>
|
||||
</select>
|
||||
<button class="member-remove-btn" onclick="removeMember(this, 'Carol Lane')" data-i18n-title="workspace_setup.members.remove" title="Remove member">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="member-row">
|
||||
<div class="member-avatar" style="background:linear-gradient(135deg,#0891b2,#06b6d4);">DM</div>
|
||||
<div class="member-info">
|
||||
<div class="member-name">Dan Morgan</div>
|
||||
<div class="member-email">dan@acme.com</div>
|
||||
</div>
|
||||
<div class="member-last-active">Never</div>
|
||||
<select class="member-role-select" onchange="updateRole(this, 'dan@acme.com')">
|
||||
<option value="admin" data-i18n="workspace_setup.role.admin">Admin</option>
|
||||
<option value="developer" data-i18n="workspace_setup.role.operator">Developer</option>
|
||||
<option value="viewer" selected data-i18n="workspace_setup.role.viewer">Viewer</option>
|
||||
</select>
|
||||
<button class="member-remove-btn" onclick="removeMember(this, 'Dan Morgan')" data-i18n-title="workspace_setup.members.remove" title="Remove member">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending invites -->
|
||||
<div id="pending-invites" hidden style="border-top: 1px solid var(--border-subtle); padding: 12px 24px 16px;">
|
||||
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);margin-bottom:10px;">
|
||||
<span data-i18n="workspace_setup.members.pending">Pending invites</span> <span id="pending-count" style="background:rgba(139,148,158,0.15);border-radius:10px;padding:1px 7px;font-size:10px;">1</span>
|
||||
</div>
|
||||
<div class="member-row" id="invite-john">
|
||||
<div class="member-avatar" style="background:var(--bg-canvas);border:1.5px dashed var(--border);color:var(--text-muted);font-size:16px;">?</div>
|
||||
<div class="member-info">
|
||||
<div class="member-name">john@startup.com</div>
|
||||
<div class="member-email">Invited 3 days ago · not yet accepted</div>
|
||||
</div>
|
||||
<div class="member-last-active">—</div>
|
||||
<span class="member-role-badge" style="background:rgba(139,148,158,0.12);color:var(--text-muted);border:1px solid var(--border);" data-i18n="workspace_setup.role.operator">Developer</span>
|
||||
<button class="member-remove-btn" onclick="revokeInvite(this)" data-i18n-title="workspace_setup.members.revoke" title="Revoke invite">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="2" x2="14" y2="14"/><line x1="14" y1="2" x2="2" y2="14"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles reference -->
|
||||
<div style="border-top:1px solid var(--border-subtle);padding:16px 24px 20px;">
|
||||
<div style="font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:14px;" data-i18n="workspace_setup.roles.title">Roles & permissions</div>
|
||||
<div class="role-def-row">
|
||||
<span class="member-role-badge role-owner" style="min-width:80px;text-align:center;" data-i18n="workspace_setup.role.owner">Owner</span>
|
||||
<div class="role-def-text" data-i18n="workspace_setup.roles.owner_body">Full workspace control, billing access, can transfer or delete the workspace.</div>
|
||||
</div>
|
||||
<div class="role-def-row">
|
||||
<span class="member-role-badge role-admin" style="min-width:80px;text-align:center;" data-i18n="workspace_setup.role.admin">Admin</span>
|
||||
<div class="role-def-text" data-i18n="workspace_setup.roles.admin_body">Manage members and roles, create and edit all operations and agents, manage API keys.</div>
|
||||
</div>
|
||||
<div class="role-def-row">
|
||||
<span class="member-role-badge role-developer" style="min-width:80px;text-align:center;" data-i18n="workspace_setup.role.operator">Developer</span>
|
||||
<div class="role-def-text" data-i18n="workspace_setup.roles.developer_body">Create and edit operations and agents. Cannot manage members or workspace settings.</div>
|
||||
</div>
|
||||
<div class="role-def-row">
|
||||
<span class="member-role-badge role-viewer" style="min-width:80px;text-align:center;" data-i18n="workspace_setup.role.viewer">Viewer</span>
|
||||
<div class="role-def-text" data-i18n="workspace_setup.roles.viewer_body">Read-only access to operations, agents, and logs.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Invite team members (create mode only) ── -->
|
||||
<div id="section-invite" class="ws-form-section" hidden>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
|
||||
<div class="ws-form-section-title" style="margin-bottom:0;"><span data-i18n="workspace_setup.invite.title">Invite team members</span> <span style="font-size:12px;font-weight:400;color:var(--text-muted);" data-i18n="workspace_setup.optional">(optional)</span></div>
|
||||
<button class="btn-ghost-sm" type="button" onclick="addInviteRow()" style="font-size:12px;padding:4px 10px;" data-i18n="workspace_setup.invite.add_person">+ Add person</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-bottom:14px;" data-i18n="workspace_setup.invite.later">You can invite more people later from Workspace settings.</div>
|
||||
<div class="invite-rows" id="invite-rows">
|
||||
<div class="invite-row">
|
||||
<input class="form-input" type="email" placeholder="colleague@company.com" autocomplete="off" style="flex:1;margin-bottom:0;">
|
||||
<select class="form-input" style="width:130px;margin-bottom:0;">
|
||||
<option value="admin" data-i18n="workspace_setup.role.admin">Admin</option>
|
||||
<option value="developer" selected data-i18n="workspace_setup.role.operator">Developer</option>
|
||||
<option value="viewer" data-i18n="workspace_setup.role.viewer">Viewer</option>
|
||||
</select>
|
||||
<button class="invite-row-remove" onclick="removeInviteRow(this)" data-i18n-title="workspace_setup.members.remove" title="Remove">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Actions ── -->
|
||||
<div class="ws-setup-actions">
|
||||
<a href="javascript:history.back()" class="btn-ghost-sm" style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);" data-i18n="btn.cancel">Cancel</a>
|
||||
<button class="btn-primary ws-submit-btn" id="submit-btn" type="button" onclick="submitForm()" data-i18n="workspace_setup.actions.save">Save changes</button>
|
||||
</div>
|
||||
|
||||
<div class="ws-setup-footer-note" id="footer-note" hidden>
|
||||
<span data-i18n="workspace_setup.create.footer">You'll be the Owner of this workspace. You can invite additional members after creation.</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Danger zone (edit mode only) ── -->
|
||||
<div id="section-danger" hidden style="margin-top:32px;">
|
||||
<div class="ws-form-section" style="border-color: rgba(248,81,73,0.25); padding:0; overflow:hidden;">
|
||||
<div style="padding:16px 24px; border-bottom: 1px solid rgba(248,81,73,0.15);">
|
||||
<div style="font-size:14px;font-weight:600;color:var(--red);" data-i18n="workspace_setup.danger.title">Danger zone</div>
|
||||
</div>
|
||||
<div class="danger-zone-action">
|
||||
<div class="danger-zone-text">
|
||||
<div class="danger-zone-title" data-i18n="workspace_setup.danger.export_title">Export all data</div>
|
||||
<div class="danger-zone-desc" data-i18n="workspace_setup.danger.export_body">Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and platform API keys.</div>
|
||||
</div>
|
||||
<button class="btn-danger" id="export-workspace-btn" type="button" data-i18n="workspace_setup.export">Export</button>
|
||||
</div>
|
||||
<div class="danger-zone-action">
|
||||
<div class="danger-zone-text">
|
||||
<div class="danger-zone-title" data-i18n="workspace_setup.danger.delete_title">Delete workspace</div>
|
||||
<div class="danger-zone-desc" data-i18n="workspace_setup.danger.delete_body">Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.</div>
|
||||
</div>
|
||||
<button class="btn-danger" id="delete-workspace-btn" type="button" data-i18n="workspace_setup.delete">Delete workspace</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="%CRANK_BUNDLE_WORKSPACE_SETUP%"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs><symbol id="icon" viewBox="0 0 24 24">
|
||||
<rect x="2" y="3" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
||||
<rect x="13" y="3" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
||||
<rect x="2" y="14" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
||||
<rect x="13" y="14" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z"/>
|
||||
<circle cx="8" cy="8" r="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 417 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M7 10a4 4 0 100-8 4 4 0 000 8zm5-1l3 3-2 2-1-1-1 1-1-1 1-1-1-1"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 351 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
|
||||
<defs><symbol id="icon" viewBox="0 0 12 12">
|
||||
<path fill="currentColor" d="M6 10L1 5h3V2h4v3h3z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14">
|
||||
<defs><symbol id="icon" viewBox="0 0 14 14">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M9 11L5 7l4-4"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14">
|
||||
<defs><symbol id="icon" viewBox="0 0 14 14">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M5 3l4 4-4 4"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 301 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
|
||||
<defs><symbol id="icon" viewBox="0 0 12 12">
|
||||
<path fill="currentColor" d="M6 2L1 7h3v3h4V7h3z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M8 16a2 2 0 002-2H6a2 2 0 002 2zm.995-14.901a1 1 0 10-1.99 0A5.002 5.002 0 003 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7a5.002 5.002 0 00-4.005-4.901z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
|
||||
<defs><symbol id="icon" viewBox="0 0 12 12">
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M2 6l3 3 5-5"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 299 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
|
||||
<defs><symbol id="icon" viewBox="0 0 12 12">
|
||||
<polyline fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" points="1.5,6 4.5,9 10.5,3"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 316 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
|
||||
<defs><symbol id="icon" viewBox="0 0 10 10">
|
||||
<path fill="currentColor" d="M5 7L1 3h8z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 220 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
|
||||
<defs><symbol id="icon" viewBox="0 0 12 12">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" d="M2 10L10 2M2 2l8 8"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 283 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M5 3H3a1 1 0 00-1 1v9a1 1 0 001 1h7a1 1 0 001-1v-2M6 1h7a1 1 0 011 1v9a1 1 0 01-1 1H6a1 1 0 01-1-1V2a1 1 0 011-1z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 402 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28">
|
||||
<defs><symbol id="icon" viewBox="0 0 28 28">
|
||||
<path fill="white" fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/>
|
||||
<path fill="white" fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/>
|
||||
<path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 910 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M8 2v9M4 8l4 4 4-4M2 13h12"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M12.146.146a.5.5 0 01.708 0l3 3a.5.5 0 010 .708l-10 10a.5.5 0 01-.168.11l-5 2a.5.5 0 01-.65-.65l2-5a.5.5 0 01.11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 01.5.5v.5h.5a.5.5 0 01.5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 015 12.5V12h-.5a.5.5 0 01-.5-.5V11h-.5a.5.5 0 01-.468-.325z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M10.5 1.5a.5.5 0 01.5-.5h4a.5.5 0 01.5.5v4a.5.5 0 01-1 0V2.707L7.354 9.354a.5.5 0 01-.708-.708L13.293 2H11a.5.5 0 01-.5-.5zM2 3.5A1.5 1.5 0 013.5 2h7a.5.5 0 010 1h-7a.5.5 0 00-.5.5v9a.5.5 0 00.5.5h9a.5.5 0 00.5-.5v-7a.5.5 0 011 0v7A1.5 1.5 0 0112.5 14h-9A1.5 1.5 0 012 12.5v-9z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M3.5 2A1.5 1.5 0 002 3.5v1A1.5 1.5 0 003.5 6h1A1.5 1.5 0 006 4.5v-1A1.5 1.5 0 004.5 2h-1zm0 6A1.5 1.5 0 002 9.5v1A1.5 1.5 0 003.5 12h1A1.5 1.5 0 006 10.5v-1A1.5 1.5 0 004.5 8h-1zm5-6A1.5 1.5 0 007 3.5v1A1.5 1.5 0 008.5 6h1A1.5 1.5 0 0011 4.5v-1A1.5 1.5 0 009.5 2h-1zm0 6A1.5 1.5 0 007 9.5v1A1.5 1.5 0 008.5 12h1A1.5 1.5 0 0011 10.5v-1A1.5 1.5 0 009.5 8h-1z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 565 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" d="M8 5v4M8 11v1"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 365 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M8 15A7 7 0 108 1a7 7 0 000 14zm.75-10.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5zm-.75 5.5a.75.75 0 110 1.5.75.75 0 010-1.5z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 338 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M10 12.5a.5.5 0 01-.5.5h-8a.5.5 0 01-.5-.5v-9a.5.5 0 01.5-.5h8a.5.5 0 01.5.5v2a.5.5 0 001 0v-2A1.5 1.5 0 009.5 2h-8A1.5 1.5 0 000 3.5v9A1.5 1.5 0 001.5 14h8a1.5 1.5 0 001.5-1.5v-2a.5.5 0 00-1 0v2z"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M15.854 8.354a.5.5 0 000-.708l-3-3a.5.5 0 10-.708.708L14.293 7.5H5.5a.5.5 0 000 1h8.793l-2.147 2.146a.5.5 0 00.708.708l3-3z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 605 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
|
||||
<defs><symbol id="icon" viewBox="0 0 12 12">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" d="M6 1v10M1 6h10"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 279 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M13.5 8A5.5 5.5 0 112.5 5M2.5 2v3h3"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 324 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M11.742 10.344a6.5 6.5 0 10-1.397 1.398h-.001l3.85 3.85a1 1 0 001.415-1.414l-3.85-3.85a1.007 1.007 0 00-.115-.099zm-5.242 1.656a5.5 5.5 0 110-11 5.5 5.5 0 010 11z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 371 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M8 4.754a3.246 3.246 0 100 6.492 3.246 3.246 0 000-6.492zM5.754 8a2.246 2.246 0 114.492 0 2.246 2.246 0 01-4.492 0z"/>
|
||||
<path fill="currentColor" d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 01-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 01-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 01.52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 011.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 011.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 01.52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 01-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 01-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 002.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 001.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 00-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 00-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 00-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 003.06 4.377l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 004.175.52l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 002.692-1.115l.094-.319z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M6 7v5M10 7v5M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 381 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M8 8a3 3 0 100-6 3 3 0 000 6zm2-3a2 2 0 11-4 0 2 2 0 014 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.029 10 8 10c-2.029 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<polygon fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" points="8,1 15,14 1,14"/>
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" d="M8 6v4M8 11.5v.5"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 420 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs><symbol id="icon" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 792 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.7"/>
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" d="M8 5v3l2 2"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 362 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" d="M4 2h8a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V4a2 2 0 012-2zM6 6l-2 2 2 2M10 6l2 2-2 2"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 378 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" d="M9 2H4a1 1 0 00-1 1v10a1 1 0 001 1h8a1 1 0 001-1V6zM9 2v4h4"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs><symbol id="icon" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="#bc8cff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" d="M12 2l9.5 5.5v11L12 22l-9.5-5.5v-11L12 2z"/>
|
||||
<circle cx="12" cy="2" r="1.5" fill="#bc8cff"/>
|
||||
<circle cx="21.5" cy="7.5" r="1.5" fill="#bc8cff"/>
|
||||
<circle cx="21.5" cy="16.5" r="1.5" fill="#bc8cff"/>
|
||||
<circle cx="12" cy="22" r="1.5" fill="#bc8cff"/>
|
||||
<circle cx="2.5" cy="16.5" r="1.5" fill="#bc8cff"/>
|
||||
<circle cx="2.5" cy="7.5" r="1.5" fill="#bc8cff"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 638 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs><symbol id="icon" viewBox="0 0 24 24">
|
||||
<rect x="2" y="3" width="8" height="18" rx="2" fill="none" stroke="#0d9488" stroke-width="1.8"/>
|
||||
<rect x="14" y="3" width="8" height="18" rx="2" fill="none" stroke="#0d9488" stroke-width="1.8"/>
|
||||
<path fill="none" stroke="#0d9488" stroke-width="1.8" stroke-linecap="round" d="M10 8h4M10 12h4M10 16h4"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 470 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="2.5" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" d="M8 1v2M8 13v2M1 8h2M13 8h2M3.1 3.1l1.4 1.4M11.5 11.5l1.4 1.4M3.1 12.9l1.4-1.4M11.5 4.5l1.4-1.4"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 448 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs><symbol id="icon" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="#58a6ff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0zM3.6 9h16.8M3.6 15h16.8M12 3a15.3 15.3 0 010 18M12 3a15.3 15.3 0 000 18"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs><symbol id="icon" viewBox="0 0 16 16">
|
||||
<rect x="1" y="3" width="14" height="4" rx="1" fill="none" stroke="currentColor" stroke-width="1.7"/>
|
||||
<rect x="1" y="9" width="14" height="4" rx="1" fill="none" stroke="currentColor" stroke-width="1.7"/>
|
||||
<circle cx="4" cy="5" r="0.8" fill="currentColor"/>
|
||||
<circle cx="4" cy="11" r="0.8" fill="currentColor"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 497 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<defs><symbol id="icon" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
|
||||
</symbol></defs>
|
||||
<use href="#icon" color="#6b7280"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,418 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crank — Operations</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/catalog.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body x-data="catalog()">
|
||||
|
||||
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
|
||||
<nav class="navbar">
|
||||
<a href="/" class="nav-logo">
|
||||
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
|
||||
<span class="nav-logo-text">Crank</span>
|
||||
</a>
|
||||
|
||||
<!-- Workspace switcher -->
|
||||
<div class="ws-switcher" id="ws-switcher">
|
||||
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
|
||||
<div class="ws-dot" id="ws-dot">A</div>
|
||||
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" id="ws-dropdown" hidden>
|
||||
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
|
||||
<div id="ws-dropdown-list"></div>
|
||||
<div class="ws-dropdown-footer">
|
||||
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
|
||||
<span data-i18n="nav.create_workspace">Create workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link active" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<!-- hamburger (mobile only) -->
|
||||
<button class="nav-hamburger" @click.stop="navOpen = !navOpen" :class="{ open: navOpen }" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
|
||||
|
||||
<button class="nav-icon-btn" title="Notifications">
|
||||
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="nav-divider"></div>
|
||||
|
||||
<!-- User menu -->
|
||||
<div class="user-menu" @click.stop>
|
||||
<div class="nav-avatar" @click="toggleDropdown('user')" data-i18n-title="nav.account" title="Account">AT</div>
|
||||
|
||||
<div class="user-dropdown" x-show="openDropdown === 'user'" x-transition>
|
||||
<div class="user-dropdown-header">
|
||||
<div class="user-dropdown-name">Crank</div>
|
||||
<div class="user-dropdown-role">—</div>
|
||||
</div>
|
||||
<button class="user-dropdown-item" @click="openDropdown = null; window.location.href='/settings'">
|
||||
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" @click="handleLogout()">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile nav dropdown -->
|
||||
<div class="mobile-nav" x-show="navOpen" x-transition @click.stop>
|
||||
<a href="/" class="mobile-nav-link active" data-i18n="nav.operations">Operations</a>
|
||||
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
|
||||
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
|
||||
<a href="/secrets" class="mobile-nav-link" data-i18n="nav.secrets">Secrets</a>
|
||||
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
|
||||
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════ PAGE ═══════════════════ -->
|
||||
<div class="page">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-heading" data-i18n="ops.title">Operations</h1>
|
||||
<p class="page-subheading" data-i18n="ops.subtitle">Tool contracts that expose your upstream APIs as MCP tools</p>
|
||||
</div>
|
||||
<button class="btn-new" @click="handleNewOperation()">
|
||||
<svg width="13" height="13"><use href="icons/general/plus.svg#icon"/></svg>
|
||||
<span data-i18n="ops.new">New operation</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="stats.total">Total operations</div>
|
||||
<div class="stat-value" x-text="formatMetricNumber(stats.total)">—</div>
|
||||
<div class="stat-delta" x-text="stats.total ? tKey('ops.stats.synced') : tKey('ops.stats.none')"></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="stats.active">Active</div>
|
||||
<div class="stat-value" x-text="formatMetricNumber(stats.active)">—</div>
|
||||
<div class="stat-delta" x-text="stats.total ? tfKey('ops.stats.share', { value: Math.round((stats.active / stats.total) * 100) }) : tKey('ops.stats.no_published')">—</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="stats.requests">Requests today</div>
|
||||
<div class="stat-value" x-text="formatMetricNumber(stats.requestsToday)">—</div>
|
||||
<div class="stat-delta" x-text="stats.requestsToday ? tKey('ops.stats.requests_rollup') : tKey('ops.stats.no_traffic')"></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="stats.latency">Avg. latency</div>
|
||||
<div class="stat-value" x-text="stats.averageLatencyMs ? stats.averageLatencyMs + 'ms' : '—'">—</div>
|
||||
<div class="stat-delta" x-text="stats.averageLatencyMs ? tKey('ops.stats.latency_weighted') : tKey('ops.stats.latency_waiting')"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tab-row">
|
||||
<template x-for="tabDef in tabDefs" :key="tabDef.id">
|
||||
<button
|
||||
class="tab-item"
|
||||
:class="{ active: tab === tabDef.id }"
|
||||
@click="setTab(tabDef.id)"
|
||||
>
|
||||
<span x-text="tabDef.label"></span>
|
||||
<span class="tab-count" x-text="tabCount(tabDef.id)"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="filter-bar">
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-box">
|
||||
<svg width="14" height="14"><use href="icons/general/search.svg#icon"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
data-i18n-ph="filter.search"
|
||||
placeholder="Search operations..."
|
||||
:value="search"
|
||||
@input="setSearch($event.target.value)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Protocol dropdown -->
|
||||
<div class="filter-dropdown" @click.stop>
|
||||
<button
|
||||
class="filter-chip"
|
||||
:class="{ 'has-value': filterProtocol, open: openDropdown === 'protocol' }"
|
||||
@click="toggleDropdown('protocol')"
|
||||
>
|
||||
<span x-text="filterProtocol ? filterProtocol.toUpperCase() : tKey('filter.protocol')"></span>
|
||||
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="dropdown-menu" x-show="openDropdown === 'protocol'" x-transition>
|
||||
<template x-for="p in ['rest']" :key="p">
|
||||
<button
|
||||
class="dropdown-item"
|
||||
:class="{ selected: filterProtocol === p }"
|
||||
@click="setProtocol(p)"
|
||||
>
|
||||
<span x-text="p === 'rest' ? 'REST' : p.toUpperCase()"></span>
|
||||
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="filterProtocol">
|
||||
<div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-clear" @click="clearProtocol()" data-i18n="filter.clear">Clear filter</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category dropdown -->
|
||||
<div class="filter-dropdown" @click.stop>
|
||||
<button
|
||||
class="filter-chip"
|
||||
:class="{ 'has-value': filterCategory, open: openDropdown === 'category' }"
|
||||
@click="toggleDropdown('category')"
|
||||
>
|
||||
<span x-text="filterCategory ?? tKey('filter.category')"></span>
|
||||
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="dropdown-menu" x-show="openDropdown === 'category'" x-transition>
|
||||
<template x-for="c in categoryOptions" :key="c">
|
||||
<button
|
||||
class="dropdown-item"
|
||||
:class="{ selected: filterCategory === c }"
|
||||
@click="setCategory(c)"
|
||||
>
|
||||
<span x-text="c"></span>
|
||||
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="filterCategory">
|
||||
<div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-clear" @click="clearCategory()" data-i18n="filter.clear">Clear filter</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent dropdown -->
|
||||
<div class="filter-dropdown" @click.stop>
|
||||
<button
|
||||
class="filter-chip"
|
||||
:class="{ 'has-value': filterAgent, open: openDropdown === 'agent' }"
|
||||
@click="toggleDropdown('agent')"
|
||||
>
|
||||
<span x-text="filterAgent ? activeAgents.find(a => a.agent_id === filterAgent)?.display_name : tKey('filter.agent')"></span>
|
||||
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="dropdown-menu" x-show="openDropdown === 'agent'" x-transition>
|
||||
<template x-for="a in activeAgents" :key="a.agent_id">
|
||||
<button
|
||||
class="dropdown-item"
|
||||
:class="{ selected: filterAgent === a.agent_id }"
|
||||
@click="setAgent(a.agent_id)"
|
||||
>
|
||||
<span x-text="a.display_name"></span>
|
||||
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="!activeAgents.length">
|
||||
<div class="dropdown-item" style="color:var(--text-muted);cursor:default;" data-i18n="ops.no_active_agents">No active agents</div>
|
||||
</template>
|
||||
<template x-if="filterAgent">
|
||||
<div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-clear" @click="clearAgent()" data-i18n="filter.clear">Clear filter</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side: count + sort -->
|
||||
<div class="filter-right">
|
||||
<span class="results-count">
|
||||
<span x-text="tfKey('ops.results', { shown: totalFiltered, total: operations.length })"></span>
|
||||
</span>
|
||||
|
||||
<!-- Sort dropdown -->
|
||||
<div class="sort-dropdown" @click.stop>
|
||||
<button
|
||||
class="sort-chip"
|
||||
:class="{ open: openDropdown === 'sort' }"
|
||||
@click="toggleDropdown('sort')"
|
||||
>
|
||||
<span x-text="sortLabel"></span>
|
||||
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="dropdown-menu" style="right:0;left:auto;min-width:200px" x-show="openDropdown === 'sort'" x-transition>
|
||||
<template x-for="opt in sortOptions" :key="opt.value">
|
||||
<button
|
||||
class="dropdown-item"
|
||||
:class="{ selected: sort === opt.value }"
|
||||
@click="setSort(opt.value)"
|
||||
>
|
||||
<span x-text="opt.label"></span>
|
||||
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /filter-bar -->
|
||||
|
||||
<!-- Active filter chips -->
|
||||
<div class="active-filters" x-show="hasActiveFilters" x-transition>
|
||||
<template x-for="chip in activeChips" :key="chip.key">
|
||||
<div class="active-filter-chip">
|
||||
<span x-text="chip.label"></span>
|
||||
<button class="active-filter-chip-remove" @click="clearChip(chip.key)" data-i18n-title="filter.remove" title="Remove filter">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="7" y2="7"/><line x1="7" y1="1" x2="1" y2="7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<button class="active-filter-clear-all" @click="clearAllFilters()" x-show="activeChips.length > 1" data-i18n="filter.clear_all">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-wrap">
|
||||
|
||||
<!-- Loading -->
|
||||
<div class="empty-state" x-show="loading">
|
||||
<p data-i18n="ops.loading">Loading operations…</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div class="empty-state" x-show="!loading && !loadError && totalFiltered === 0">
|
||||
<h3 x-text="operations.length === 0 ? tKey('ops.empty.initial.title') : tKey('ops.empty.title')">No operations found</h3>
|
||||
<p x-text="operations.length === 0 ? tKey('ops.empty.initial.sub') : tKey('ops.empty.sub')">Try adjusting your search or filters.</p>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" x-show="!loading && !!loadError">
|
||||
<h3 data-i18n="ops.error.title">Backend connection failed</h3>
|
||||
<p x-text="loadError"></p>
|
||||
</div>
|
||||
|
||||
<!-- Table itself -->
|
||||
<table x-show="!loading && totalFiltered > 0 && !loadError">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="th-sortable" :class="{ 'sort-active': sort.startsWith('name') }" @click="setSort(sort === 'name_asc' ? 'name_desc' : 'name_asc')">
|
||||
<span data-i18n="th.name">NAME</span>
|
||||
<span class="sort-arrow" :class="{ active: sort.startsWith('name') }">
|
||||
<span x-text="sort === 'name_asc' ? '↑' : sort === 'name_desc' ? '↓' : '↕'"></span>
|
||||
</span>
|
||||
</th>
|
||||
<th data-i18n="th.protocol">PROTOCOL</th>
|
||||
<th data-i18n="th.status">STATUS</th>
|
||||
<th class="th-sortable col-created" :class="{ 'sort-active': sort.startsWith('created') }" @click="setSort(sort === 'created_asc' ? 'created_desc' : 'created_asc')">
|
||||
<span data-i18n="th.created">CREATED</span>
|
||||
<span class="sort-arrow" :class="{ active: sort.startsWith('created') }">
|
||||
<span x-text="sort === 'created_asc' ? '↑' : sort === 'created_desc' ? '↓' : '↕'"></span>
|
||||
</span>
|
||||
</th>
|
||||
<th class="col-url" data-i18n="th.url">TARGET URL</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="op in paginated" :key="op.id">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="op-name" x-text="op.name"></div>
|
||||
<div class="op-display" x-text="op.display_name"></div>
|
||||
<div class="op-agent-badges" x-show="(agentsByOpId[op.id] || []).length > 0">
|
||||
<template x-for="a in (agentsByOpId[op.id] || [])" :key="a.agent_id">
|
||||
<span class="op-agent-badge" x-text="a.display_name"></span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="protocolClass(op)" x-text="protocolLabel(op)"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="statusClass(op)">
|
||||
<span class="status-dot"></span>
|
||||
<span x-text="statusLabel(op)"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="col-created"><span class="date-cell" x-text="formatDate(op.created_at)"></span></td>
|
||||
<td class="col-url">
|
||||
<div class="target-url-cell">
|
||||
<span x-text="truncateUrl(op.target_url)" :title="op.target_url"></span>
|
||||
<a :href="op.target_url && op.target_url.startsWith('http') ? op.target_url : '#'" target="_blank" rel="noopener" :style="!op.target_url ? 'opacity:.35;pointer-events:none;' : ''">
|
||||
<svg width="11" height="11"><use href="icons/general/external.svg#icon"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="row-btn row-btn-edit" :title="tKey('ops.action.edit')" @click="editOperation(op)">
|
||||
<svg width="14" height="14"><use href="icons/general/edit.svg#icon"/></svg>
|
||||
</button>
|
||||
<button class="row-btn row-btn-delete" :title="tKey('ops.action.delete')" @click="deleteOperation(op.id)">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Footer / pagination -->
|
||||
<div class="table-footer" x-show="!loading && totalFiltered > 0 && !loadError">
|
||||
<span class="table-footer-info">
|
||||
<span x-text="tfKey('ops.range', { from: pageStart, to: pageEnd, total: totalFiltered })"></span>
|
||||
</span>
|
||||
<div class="pagination">
|
||||
<button class="page-btn" :disabled="page === 1" @click="goPage(page - 1)">←</button>
|
||||
<template x-for="p in totalPages" :key="p">
|
||||
<button
|
||||
class="page-btn"
|
||||
:class="{ active: page === p }"
|
||||
@click="goPage(p)"
|
||||
x-text="p"
|
||||
></button>
|
||||
</template>
|
||||
<button class="page-btn" :disabled="page === totalPages" @click="goPage(page + 1)">→</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /table-wrap -->
|
||||
|
||||
</div><!-- /page -->
|
||||
|
||||
<script src="%CRANK_BUNDLE_OPERATIONS%"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,499 @@
|
||||
function agentUiStatus(status) {
|
||||
if (status === 'published') return 'published';
|
||||
if (status === 'archived') return 'archived';
|
||||
return status || 'draft';
|
||||
}
|
||||
|
||||
function mapAgent(agent) {
|
||||
var mapped = {
|
||||
id: agent.id,
|
||||
slug: agent.slug,
|
||||
display_name: agent.display_name,
|
||||
description: agent.description || '',
|
||||
status: agentUiStatus(agent.status),
|
||||
raw_status: agent.status,
|
||||
operation_count: agent.operation_count || 0,
|
||||
operation_ids: agent.operation_ids || [],
|
||||
key_count: agent.key_count || 0,
|
||||
calls_today: agent.calls_today || 0,
|
||||
created_at: agent.created_at,
|
||||
current_draft_version: agent.current_draft_version || 1,
|
||||
latest_published_version: agent.latest_published_version,
|
||||
mcp_endpoint: agent.mcp_endpoint || '',
|
||||
};
|
||||
|
||||
return window.localizeDemoAgent ? window.localizeDemoAgent(mapped) : mapped;
|
||||
}
|
||||
|
||||
function mapOperation(operation) {
|
||||
var mapped = {
|
||||
id: operation.id,
|
||||
name: operation.name,
|
||||
display_name: operation.display_name,
|
||||
protocol: operation.protocol,
|
||||
status: operation.status,
|
||||
current_draft_version: operation.current_draft_version || 1,
|
||||
latest_published_version: operation.latest_published_version,
|
||||
};
|
||||
|
||||
return window.localizeDemoOperation ? window.localizeDemoOperation(mapped) : mapped;
|
||||
}
|
||||
|
||||
function currentLocale() {
|
||||
return localStorage.getItem('crank_lang') === 'ru' ? 'ru-RU' : 'en-US';
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', function() {
|
||||
Alpine.data('agents', function() {
|
||||
return {
|
||||
agents: [],
|
||||
operations: [],
|
||||
capabilities: null,
|
||||
loading: true,
|
||||
loadError: '',
|
||||
workspaceId: null,
|
||||
|
||||
agentSearch: '',
|
||||
openDropdown: null,
|
||||
drawerOpen: false,
|
||||
drawerMode: 'create',
|
||||
editingId: null,
|
||||
saving: false,
|
||||
|
||||
form: {
|
||||
display_name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
status: 'published',
|
||||
selectedOps: [],
|
||||
},
|
||||
|
||||
opSearch: '',
|
||||
slugManuallyEdited: false,
|
||||
|
||||
async init() {
|
||||
var self = this;
|
||||
|
||||
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
this.workspaceId = workspace ? workspace.id : null;
|
||||
await this.loadCapabilities();
|
||||
await this.reload();
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape' && self.drawerOpen) {
|
||||
self.closeDrawer();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function() {
|
||||
self.openDropdown = null;
|
||||
});
|
||||
|
||||
window.addEventListener('crank:workspacechange', async function(event) {
|
||||
self.workspaceId = event.detail ? event.detail.id : null;
|
||||
await self.loadCapabilities();
|
||||
await self.reload();
|
||||
});
|
||||
},
|
||||
|
||||
async loadCapabilities() {
|
||||
if (!window.CrankApi || typeof window.CrankApi.getCapabilities !== 'function') {
|
||||
this.capabilities = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.capabilities = await window.CrankApi.getCapabilities();
|
||||
} catch (_error) {
|
||||
this.capabilities = null;
|
||||
}
|
||||
},
|
||||
|
||||
async reload() {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
|
||||
if (!this.workspaceId || !window.CrankApi) {
|
||||
this.agents = [];
|
||||
this.operations = [];
|
||||
this.loading = false;
|
||||
this.loadError = this.tKey('agents.error.api');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var responses = await Promise.all([
|
||||
window.CrankApi.listAgents(this.workspaceId),
|
||||
window.CrankApi.listOperations(this.workspaceId),
|
||||
]);
|
||||
this.agents = ((responses[0] && responses[0].items) || []).map(mapAgent);
|
||||
this.operations = ((responses[1] && responses[1].items) || []).map(mapOperation);
|
||||
} catch (error) {
|
||||
this.agents = [];
|
||||
this.operations = [];
|
||||
this.loadError = error.message || this.tKey('agents.error.load');
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
get filteredAgents() {
|
||||
var query = this.agentSearch.toLowerCase().trim();
|
||||
if (!query) return this.agents;
|
||||
return this.agents.filter(function(agent) {
|
||||
return agent.display_name.toLowerCase().includes(query)
|
||||
|| agent.slug.toLowerCase().includes(query)
|
||||
|| (agent.description || '').toLowerCase().includes(query);
|
||||
});
|
||||
},
|
||||
|
||||
get totalCalls() {
|
||||
return this.agents.reduce(function(sum, agent) {
|
||||
return sum + (agent.calls_today || 0);
|
||||
}, 0);
|
||||
},
|
||||
|
||||
get activeCount() {
|
||||
return this.agents.filter(function(agent) {
|
||||
return agent.status === 'published';
|
||||
}).length;
|
||||
},
|
||||
|
||||
get totalOpsExposed() {
|
||||
return this.agents.reduce(function(sum, agent) {
|
||||
return sum + (agent.operation_count || 0);
|
||||
}, 0);
|
||||
},
|
||||
|
||||
get isCommunityBuild() {
|
||||
return !!(this.capabilities && this.capabilities.edition === 'community');
|
||||
},
|
||||
|
||||
agentCalloutText() {
|
||||
if (this.isCommunityBuild) {
|
||||
return this.tfKey('agents.callout.community', { count: this.operations.length });
|
||||
}
|
||||
return this.tfKey('agents.callout.default', { count: this.operations.length });
|
||||
},
|
||||
|
||||
subtitleText() {
|
||||
return this.tKey(this.isCommunityBuild ? 'agents.subtitle.community' : 'agents.subtitle');
|
||||
},
|
||||
|
||||
emptyStateText() {
|
||||
return this.tKey(this.isCommunityBuild ? 'agents.empty.initial.sub_community' : 'agents.empty.initial.sub');
|
||||
},
|
||||
|
||||
drawerSubText() {
|
||||
if (this.drawerMode === 'edit') {
|
||||
return this.tKey('agents.drawer.edit_sub');
|
||||
}
|
||||
return this.tKey(this.isCommunityBuild ? 'agents.drawer.new_sub_community' : 'agents.drawer.new_sub');
|
||||
},
|
||||
|
||||
operationsSubText() {
|
||||
return this.tKey(this.isCommunityBuild ? 'agents.drawer.operations_sub_community' : 'agents.drawer.operations_sub');
|
||||
},
|
||||
|
||||
get filteredOps() {
|
||||
var query = this.opSearch.toLowerCase().trim();
|
||||
if (!query) return this.operations;
|
||||
return this.operations.filter(function(operation) {
|
||||
return operation.name.toLowerCase().includes(query)
|
||||
|| operation.display_name.toLowerCase().includes(query);
|
||||
});
|
||||
},
|
||||
|
||||
openCreate() {
|
||||
this.drawerMode = 'create';
|
||||
this.editingId = null;
|
||||
this.form = {
|
||||
display_name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
status: 'published',
|
||||
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.raw_status || agent.status || 'draft',
|
||||
selectedOps: [].concat(agent.operation_ids || []),
|
||||
};
|
||||
this.opSearch = '';
|
||||
this.slugManuallyEdited = true;
|
||||
this.drawerOpen = true;
|
||||
},
|
||||
|
||||
closeDrawer() {
|
||||
this.drawerOpen = false;
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
onNameInput(value) {
|
||||
this.form.display_name = value;
|
||||
if (!this.slugManuallyEdited) {
|
||||
this.form.slug = value
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '');
|
||||
}
|
||||
},
|
||||
|
||||
onSlugInput(value) {
|
||||
this.form.slug = value.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||
this.slugManuallyEdited = true;
|
||||
},
|
||||
|
||||
toggleOp(operationId) {
|
||||
var index = this.form.selectedOps.indexOf(operationId);
|
||||
if (index === -1) {
|
||||
this.form.selectedOps.push(operationId);
|
||||
} else {
|
||||
this.form.selectedOps.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
isOpSelected(operationId) {
|
||||
return this.form.selectedOps.includes(operationId);
|
||||
},
|
||||
|
||||
async saveAgent() {
|
||||
var self = this;
|
||||
if (
|
||||
this.saving
|
||||
|| !this.workspaceId
|
||||
|| !this.form.display_name.trim()
|
||||
|| !this.form.slug.trim()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
var agentId = this.editingId;
|
||||
var currentVersion = 1;
|
||||
var existingAgent = this.drawerMode === 'edit'
|
||||
? this.agents.find(function(item) { return item.id === agentId; })
|
||||
: null;
|
||||
var previousStatus = existingAgent ? (existingAgent.raw_status || 'draft') : 'draft';
|
||||
|
||||
if (this.drawerMode === 'create') {
|
||||
var created = await window.CrankApi.createAgent(this.workspaceId, {
|
||||
slug: this.form.slug,
|
||||
display_name: this.form.display_name,
|
||||
description: this.form.description,
|
||||
instructions: {},
|
||||
tool_selection_policy: {},
|
||||
});
|
||||
agentId = created.agent_id;
|
||||
currentVersion = created.version || 1;
|
||||
} else {
|
||||
await window.CrankApi.updateAgent(this.workspaceId, this.editingId, {
|
||||
slug: this.form.slug,
|
||||
display_name: this.form.display_name,
|
||||
description: this.form.description,
|
||||
});
|
||||
var agent = await window.CrankApi.getAgent(this.workspaceId, this.editingId);
|
||||
currentVersion = agent.current_draft_version || 1;
|
||||
}
|
||||
|
||||
await window.CrankApi.saveAgentBindings(
|
||||
this.workspaceId,
|
||||
agentId,
|
||||
this.form.selectedOps.map(function(operationId) {
|
||||
var operation = self.operations.find(function(item) {
|
||||
return item.id === operationId;
|
||||
});
|
||||
return {
|
||||
operation_id: operationId,
|
||||
operation_version: operation && operation.latest_published_version
|
||||
? operation.latest_published_version
|
||||
: operation && operation.current_draft_version
|
||||
? operation.current_draft_version
|
||||
: 1,
|
||||
tool_name: operation ? operation.name : operationId,
|
||||
tool_title: operation ? (operation.display_name || operation.name) : operationId,
|
||||
tool_description_override: null,
|
||||
enabled: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (this.form.status === 'published') {
|
||||
await window.CrankApi.publishAgent(this.workspaceId, agentId, {
|
||||
version: currentVersion,
|
||||
});
|
||||
} else if (this.form.status === 'archived') {
|
||||
await window.CrankApi.archiveAgent(this.workspaceId, agentId);
|
||||
} else if (this.drawerMode === 'edit' && previousStatus !== 'draft') {
|
||||
await window.CrankApi.unpublishAgent(this.workspaceId, agentId);
|
||||
}
|
||||
|
||||
await this.reload();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
this.tfKey('agents.toast.saved_message', {
|
||||
name: this.form.display_name,
|
||||
count: this.form.selectedOps.length
|
||||
}),
|
||||
this.drawerMode === 'create'
|
||||
? this.tKey('agents.toast.saved_title_create')
|
||||
: this.tKey('agents.toast.saved_title_update')
|
||||
);
|
||||
}
|
||||
this.closeDrawer();
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || this.tKey('agents.toast.save_error_message'),
|
||||
this.tKey('agents.toast.save_error_title')
|
||||
);
|
||||
}
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAgent(id) {
|
||||
if (!confirm(this.tKey('agents.toast.delete_confirm'))) return;
|
||||
|
||||
try {
|
||||
var agent = this.agents.find(function(item) { return item.id === id; });
|
||||
await window.CrankApi.deleteAgent(this.workspaceId, id);
|
||||
await this.reload();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
this.tfKey('agents.toast.delete_message', { name: agent ? agent.display_name : '' }),
|
||||
this.tKey('agents.toast.delete_title')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || this.tKey('agents.toast.delete_error_message'),
|
||||
this.tKey('agents.toast.delete_error_title')
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async applyLifecycle(agent, action) {
|
||||
if (!this.workspaceId || !window.CrankApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'publish') {
|
||||
await window.CrankApi.publishAgent(this.workspaceId, agent.id, {
|
||||
version: agent.current_draft_version || 1,
|
||||
});
|
||||
} else if (action === 'unpublish') {
|
||||
await window.CrankApi.unpublishAgent(this.workspaceId, agent.id);
|
||||
} else if (action === 'archive') {
|
||||
await window.CrankApi.archiveAgent(this.workspaceId, agent.id);
|
||||
}
|
||||
await this.reload();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
action === 'publish'
|
||||
? this.tfKey('agents.toast.lifecycle_publish', { name: agent.display_name })
|
||||
: action === 'unpublish'
|
||||
? this.tfKey('agents.toast.lifecycle_unpublish', { name: agent.display_name })
|
||||
: this.tfKey('agents.toast.lifecycle_archive', { name: agent.display_name }),
|
||||
this.tKey('agents.toast.lifecycle_title')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || this.tKey('agents.toast.lifecycle_error_message'),
|
||||
this.tKey('agents.toast.lifecycle_error_title')
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
lifecycleAction(agent) {
|
||||
if (agent.raw_status === 'published') {
|
||||
return { key: 'unpublish', label: this.tKey('agents.lifecycle.unpublish') };
|
||||
}
|
||||
if (agent.raw_status === 'archived') {
|
||||
return { key: 'unpublish', label: this.tKey('agents.lifecycle.restore_draft') };
|
||||
}
|
||||
return { key: 'publish', label: this.tKey('agents.lifecycle.publish') };
|
||||
},
|
||||
|
||||
lifecycleLabel(agent) {
|
||||
if (agent.raw_status === 'published') return this.tKey('agents.lifecycle.published');
|
||||
if (agent.raw_status === 'archived') return this.tKey('agents.lifecycle.archived');
|
||||
return this.tKey('agents.lifecycle.draft');
|
||||
},
|
||||
|
||||
mcpEndpoint(agent) {
|
||||
if (agent.mcp_endpoint) return agent.mcp_endpoint;
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
var workspaceSlug = workspace ? workspace.slug : 'default';
|
||||
return '/mcp/v1/' + workspaceSlug + '/' + agent.slug;
|
||||
},
|
||||
|
||||
endpointHelpText(agent) {
|
||||
return this.tfKey(
|
||||
this.isCommunityBuild ? 'agents.card.endpoint_help_community' : 'agents.card.endpoint_help',
|
||||
{ count: agent.key_count || 0 }
|
||||
);
|
||||
},
|
||||
|
||||
copyEndpoint(agent) {
|
||||
var endpoint = this.mcpEndpoint(agent);
|
||||
navigator.clipboard.writeText(endpoint).catch(function() {});
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.info(endpoint, this.tKey('agents.toast.endpoint_title'));
|
||||
}
|
||||
},
|
||||
|
||||
statusClass(status) {
|
||||
return 'agent-status-badge agent-status-' + status;
|
||||
},
|
||||
|
||||
protocolBadge(operation) {
|
||||
return 'badge badge-rest';
|
||||
},
|
||||
|
||||
protocolLabel(operation) {
|
||||
return 'REST';
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString(currentLocale(), {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
},
|
||||
|
||||
formatCalls(value) {
|
||||
if (!value) return '0';
|
||||
return value >= 1000 ? (value / 1000).toFixed(1) + 'k' : String(value);
|
||||
},
|
||||
|
||||
tKey(key) {
|
||||
return window.t ? t(key) : key;
|
||||
},
|
||||
|
||||
tfKey(key, vars) {
|
||||
return window.tf ? tf(key, vars) : this.tKey(key);
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,620 @@
|
||||
var KEYS = [];
|
||||
var AGENTS = [];
|
||||
var capabilities = null;
|
||||
var currentWorkspaceId = null;
|
||||
var currentAgentId = null;
|
||||
var selectedScopes = new Set(['read']);
|
||||
var search = '';
|
||||
|
||||
var SCOPES = ['read', 'write', 'deploy'];
|
||||
var SCOPE_DESC = {
|
||||
read: 'apikeys.scope.read',
|
||||
write: 'apikeys.scope.write',
|
||||
deploy: 'apikeys.scope.deploy',
|
||||
};
|
||||
|
||||
function tKey(key) {
|
||||
return window.t ? t(key) : key;
|
||||
}
|
||||
|
||||
function tfKey(key, vars) {
|
||||
return window.tf ? tf(key, vars) : tKey(key);
|
||||
}
|
||||
|
||||
function capabilityLabel(key) {
|
||||
return tKey('settings.capability.' + key);
|
||||
}
|
||||
|
||||
function buildIconSvg(href, width, height) {
|
||||
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', String(width));
|
||||
svg.setAttribute('height', String(height));
|
||||
var use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use.setAttribute('href', href);
|
||||
svg.appendChild(use);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function mapKeyRecord(record) {
|
||||
var apiKey = record.api_key || {};
|
||||
return {
|
||||
id: apiKey.id,
|
||||
agentId: apiKey.agent_id || null,
|
||||
name: apiKey.name,
|
||||
prefix: apiKey.prefix || '',
|
||||
scopes: apiKey.scopes || [],
|
||||
created: apiKey.created_at ? apiKey.created_at.slice(0, 10) : '—',
|
||||
lastUsed: apiKey.last_used_at ? apiKey.last_used_at.slice(0, 10) : tKey('apikeys.last_used.never'),
|
||||
status: apiKey.status || 'active',
|
||||
};
|
||||
}
|
||||
|
||||
function mapAgentRecord(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
slug: record.slug,
|
||||
displayName: record.display_name || record.slug || record.id,
|
||||
mcpEndpoint: record.mcp_endpoint || '',
|
||||
};
|
||||
}
|
||||
|
||||
function currentWorkspace() {
|
||||
return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
}
|
||||
|
||||
async function loadKeys() {
|
||||
var workspace = currentWorkspace();
|
||||
currentWorkspaceId = workspace ? workspace.id : null;
|
||||
|
||||
setTableLoading(true);
|
||||
|
||||
if (!currentWorkspaceId || !window.CrankApi) {
|
||||
AGENTS = [];
|
||||
KEYS = [];
|
||||
currentAgentId = null;
|
||||
renderAgentPicker();
|
||||
setCreateButtonState();
|
||||
setTableLoading(false);
|
||||
renderTable(tKey('apikeys.error.api'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var response = await window.CrankApi.listAgents(currentWorkspaceId);
|
||||
AGENTS = ((response && response.items) || []).map(mapAgentRecord);
|
||||
if (!AGENTS.length) {
|
||||
currentAgentId = null;
|
||||
KEYS = [];
|
||||
renderAgentPicker();
|
||||
setCreateButtonState();
|
||||
setTableLoading(false);
|
||||
renderTable();
|
||||
return;
|
||||
}
|
||||
if (!currentAgentId || !AGENTS.some(function(agent) { return agent.id === currentAgentId; })) {
|
||||
currentAgentId = AGENTS[0].id;
|
||||
}
|
||||
renderAgentPicker();
|
||||
var keysResponse = await window.CrankApi.listAgentPlatformApiKeys(
|
||||
currentWorkspaceId,
|
||||
currentAgentId
|
||||
);
|
||||
KEYS = ((keysResponse && keysResponse.items) || []).map(mapKeyRecord);
|
||||
setCreateButtonState();
|
||||
setTableLoading(false);
|
||||
renderTable();
|
||||
} catch (error) {
|
||||
AGENTS = [];
|
||||
KEYS = [];
|
||||
currentAgentId = null;
|
||||
renderAgentPicker();
|
||||
setCreateButtonState();
|
||||
setTableLoading(false);
|
||||
renderTable(error.message || tKey('apikeys.error.load'));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCapabilities() {
|
||||
if (!window.CrankApi || typeof window.CrankApi.getCapabilities !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
capabilities = await window.CrankApi.getCapabilities();
|
||||
} catch (_error) {
|
||||
capabilities = null;
|
||||
}
|
||||
|
||||
renderMachineAccessSummary();
|
||||
}
|
||||
|
||||
function renderMachineAccessSummary() {
|
||||
var title = document.getElementById('machine-access-title');
|
||||
var subtitle = document.getElementById('machine-access-subtitle');
|
||||
var summary = document.getElementById('machine-access-summary');
|
||||
var note = document.getElementById('machine-access-note');
|
||||
if (!title || !subtitle || !summary || !note) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!capabilities) {
|
||||
summary.textContent = tKey('apikeys.machine_access.summary_default');
|
||||
note.textContent = tKey('apikeys.machine_access.note');
|
||||
return;
|
||||
}
|
||||
|
||||
var accessModes = (capabilities.machine_access_modes || []).map(capabilityLabel).join(', ');
|
||||
var securityLevels = (capabilities.supported_security_levels || []).map(capabilityLabel).join(', ');
|
||||
|
||||
title.textContent = tKey('apikeys.machine_access.title_' + capabilities.edition);
|
||||
subtitle.textContent = tKey('apikeys.machine_access.subtitle_' + capabilities.edition);
|
||||
summary.textContent = tfKey('apikeys.machine_access.summary', {
|
||||
access_modes: accessModes || capabilityLabel('none'),
|
||||
security_levels: securityLevels || capabilityLabel('none'),
|
||||
});
|
||||
note.textContent = tKey('apikeys.machine_access.note_' + capabilities.edition);
|
||||
}
|
||||
|
||||
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 = tKey('apikeys.loading');
|
||||
tr.appendChild(td);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
async function revokeKey(id) {
|
||||
if (!confirm(tKey('apikeys.confirm.revoke'))) return;
|
||||
if (!currentAgentId) return;
|
||||
|
||||
try {
|
||||
var key = KEYS.find(function(item) { return item.id === id; });
|
||||
await window.CrankApi.revokeAgentPlatformApiKey(currentWorkspaceId, currentAgentId, id);
|
||||
await loadKeys();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tfKey('apikeys.toast.revoke_message', { name: key ? key.name : '' }),
|
||||
tKey('apikeys.toast.revoke_title')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || tKey('apikeys.toast.revoke_error_message'),
|
||||
tKey('apikeys.toast.revoke_error_title')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKey(id) {
|
||||
if (!confirm(tKey('apikeys.confirm.delete'))) return;
|
||||
if (!currentAgentId) return;
|
||||
|
||||
try {
|
||||
var key = KEYS.find(function(item) { return item.id === id; });
|
||||
await window.CrankApi.deleteAgentPlatformApiKey(currentWorkspaceId, currentAgentId, id);
|
||||
await loadKeys();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tfKey('apikeys.toast.delete_message', { name: key ? key.name : '' }),
|
||||
tKey('apikeys.toast.delete_title')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || tKey('apikeys.toast.delete_error_message'),
|
||||
tKey('apikeys.toast.delete_error_title')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createKey(name, scopes) {
|
||||
var created = await window.CrankApi.createAgentPlatformApiKey(currentWorkspaceId, currentAgentId, {
|
||||
name: name,
|
||||
scopes: scopes,
|
||||
});
|
||||
return {
|
||||
rawKey: created.secret,
|
||||
record: mapKeyRecord(created.api_key),
|
||||
};
|
||||
}
|
||||
|
||||
function currentAgent() {
|
||||
return AGENTS.find(function(agent) { return agent.id === currentAgentId; }) || null;
|
||||
}
|
||||
|
||||
function renderAgentPicker() {
|
||||
var select = document.getElementById('agent-select');
|
||||
var hint = document.getElementById('agent-endpoint-hint');
|
||||
if (!select || !hint) return;
|
||||
|
||||
select.innerHTML = '';
|
||||
|
||||
if (!AGENTS.length) {
|
||||
var emptyOption = document.createElement('option');
|
||||
emptyOption.value = '';
|
||||
emptyOption.textContent = tKey('apikeys.agent.empty');
|
||||
select.appendChild(emptyOption);
|
||||
select.disabled = true;
|
||||
hint.textContent = tKey('apikeys.agent.empty_hint');
|
||||
return;
|
||||
}
|
||||
|
||||
AGENTS.forEach(function(agent) {
|
||||
var option = document.createElement('option');
|
||||
option.value = agent.id;
|
||||
option.textContent = agent.displayName;
|
||||
if (agent.id === currentAgentId) option.selected = true;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.disabled = false;
|
||||
|
||||
var agent = currentAgent();
|
||||
hint.textContent = agent && agent.mcpEndpoint
|
||||
? tfKey('apikeys.agent.endpoint', { endpoint: agent.mcpEndpoint })
|
||||
: tKey('apikeys.agent.endpoint_missing');
|
||||
}
|
||||
|
||||
function setCreateButtonState() {
|
||||
var button = document.getElementById('btn-create-key');
|
||||
if (!button) return;
|
||||
button.disabled = !currentAgentId;
|
||||
}
|
||||
|
||||
function renderScopes() {
|
||||
var el = document.getElementById('scope-checkboxes');
|
||||
var tmpl = document.getElementById('tmpl-scope-checkbox');
|
||||
el.innerHTML = '';
|
||||
SCOPES.forEach(function(scope) {
|
||||
var node = tmpl.content.cloneNode(true);
|
||||
var input = node.querySelector('input');
|
||||
input.dataset.scope = scope;
|
||||
if (selectedScopes.has(scope)) input.checked = true;
|
||||
node.querySelector('.scope-checkbox-name').textContent = scope;
|
||||
node.querySelector('.scope-checkbox-desc').textContent = tKey(SCOPE_DESC[scope]);
|
||||
input.addEventListener('change', function() {
|
||||
if (this.checked) selectedScopes.add(this.dataset.scope);
|
||||
else selectedScopes.delete(this.dataset.scope);
|
||||
});
|
||||
el.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(errorMessage) {
|
||||
var q = search.toLowerCase();
|
||||
var tbody = document.getElementById('keys-tbody');
|
||||
var cardList = document.getElementById('keys-card-list');
|
||||
var rows = KEYS.filter(function(key) {
|
||||
return !q || key.name.toLowerCase().includes(q) || key.prefix.toLowerCase().includes(q);
|
||||
});
|
||||
var subtitle = document.getElementById('keys-summary-subtitle');
|
||||
|
||||
tbody.innerHTML = '';
|
||||
if (cardList) {
|
||||
cardList.innerHTML = '';
|
||||
}
|
||||
|
||||
if (subtitle) {
|
||||
var active = KEYS.filter(function(key) { return key.status === 'active'; }).length;
|
||||
var revoked = KEYS.filter(function(key) { return key.status === 'revoked'; }).length;
|
||||
subtitle.textContent = currentAgentId
|
||||
? tfKey('apikeys.active.subtitle', { active: active, revoked: revoked })
|
||||
: tKey('apikeys.agent.empty_hint');
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
var errorRow = document.createElement('tr');
|
||||
var errorCell = document.createElement('td');
|
||||
errorCell.colSpan = 7;
|
||||
errorCell.style.cssText = 'text-align:center;padding:36px;color:var(--danger,#f85149);';
|
||||
errorCell.textContent = errorMessage;
|
||||
errorRow.appendChild(errorCell);
|
||||
tbody.appendChild(errorRow);
|
||||
renderKeyCards([], errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
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 = currentAgentId
|
||||
? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none'))
|
||||
: tKey('apikeys.agent.empty_hint');
|
||||
empty.appendChild(td);
|
||||
tbody.appendChild(empty);
|
||||
renderKeyCards(rows);
|
||||
return;
|
||||
}
|
||||
|
||||
var tmpl = document.getElementById('tmpl-key-row');
|
||||
rows.forEach(function(key) {
|
||||
var node = tmpl.content.cloneNode(true);
|
||||
node.querySelector('.col-name').textContent = key.name;
|
||||
node.querySelector('.col-mono').textContent = key.prefix;
|
||||
node.querySelector('.col-created').textContent = key.created;
|
||||
node.querySelector('.col-last-used').textContent = key.lastUsed;
|
||||
|
||||
var scopesEl = node.querySelector('.col-scopes');
|
||||
key.scopes.forEach(function(scope) {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'badge badge-scope';
|
||||
span.textContent = scope;
|
||||
scopesEl.appendChild(span);
|
||||
});
|
||||
|
||||
var badge = document.createElement('span');
|
||||
badge.className = key.status === 'active' ? 'badge badge-active' : 'badge badge-revoked';
|
||||
badge.textContent = key.status === 'active' ? tKey('apikeys.status.active') : tKey('apikeys.status.revoked');
|
||||
node.querySelector('.col-status').appendChild(badge);
|
||||
|
||||
var actionsActive = node.querySelector('.actions-active');
|
||||
var actionsRevoked = node.querySelector('.actions-revoked');
|
||||
if (key.status === 'active') {
|
||||
actionsActive.querySelector('[title=\"Copy key prefix\"]').addEventListener('click', function() {
|
||||
copyPrefix(key.prefix);
|
||||
});
|
||||
actionsActive.querySelector('[title=\"Revoke key\"]').addEventListener('click', function() {
|
||||
revokeKey(key.id);
|
||||
});
|
||||
} else {
|
||||
actionsActive.hidden = true;
|
||||
actionsRevoked.hidden = false;
|
||||
actionsRevoked.querySelector('[title=\"Delete\"]').addEventListener('click', function() {
|
||||
deleteKey(key.id);
|
||||
});
|
||||
}
|
||||
|
||||
tbody.appendChild(node);
|
||||
});
|
||||
|
||||
renderKeyCards(rows);
|
||||
}
|
||||
|
||||
function renderKeyCards(rows, errorMessage) {
|
||||
var cardList = document.getElementById('keys-card-list');
|
||||
if (!cardList) return;
|
||||
|
||||
cardList.innerHTML = '';
|
||||
|
||||
if (errorMessage) {
|
||||
cardList.appendChild(buildKeyCardMessage(errorMessage, true));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
cardList.appendChild(
|
||||
buildKeyCardMessage(
|
||||
currentAgentId
|
||||
? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none'))
|
||||
: tKey('apikeys.agent.empty_hint'),
|
||||
false
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
rows.forEach(function(key) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'resource-card';
|
||||
|
||||
var header = document.createElement('div');
|
||||
header.className = 'resource-card-header';
|
||||
var headerMain = document.createElement('div');
|
||||
var title = document.createElement('div');
|
||||
title.className = 'resource-card-title';
|
||||
title.textContent = key.name;
|
||||
var subtitle = document.createElement('div');
|
||||
subtitle.className = 'resource-card-subtitle';
|
||||
subtitle.textContent = key.prefix;
|
||||
headerMain.appendChild(title);
|
||||
headerMain.appendChild(subtitle);
|
||||
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'resource-card-actions';
|
||||
|
||||
var statusBadge = document.createElement('span');
|
||||
statusBadge.className = key.status === 'active' ? 'badge badge-active' : 'badge badge-revoked';
|
||||
statusBadge.textContent = key.status === 'active'
|
||||
? tKey('apikeys.status.active')
|
||||
: tKey('apikeys.status.revoked');
|
||||
actions.appendChild(statusBadge);
|
||||
header.appendChild(headerMain);
|
||||
header.appendChild(actions);
|
||||
card.appendChild(header);
|
||||
|
||||
var metaGrid = document.createElement('div');
|
||||
metaGrid.className = 'resource-meta-grid';
|
||||
metaGrid.appendChild(buildMetaItem('apikeys.th.scopes', key.scopes.join(', ') || '—'));
|
||||
metaGrid.appendChild(buildMetaItem('apikeys.th.created', key.created));
|
||||
metaGrid.appendChild(buildMetaItem('apikeys.th.last', key.lastUsed));
|
||||
card.appendChild(metaGrid);
|
||||
|
||||
var actionRow = document.createElement('div');
|
||||
actionRow.className = 'resource-card-actions';
|
||||
if (key.status === 'active') {
|
||||
actionRow.appendChild(buildCardAction('apikeys.action.copy_prefix', function() {
|
||||
copyPrefix(key.prefix);
|
||||
}));
|
||||
actionRow.appendChild(buildCardAction('apikeys.action.revoke', function() {
|
||||
revokeKey(key.id);
|
||||
}, true));
|
||||
} else {
|
||||
actionRow.appendChild(buildCardAction('apikeys.action.delete', function() {
|
||||
deleteKey(key.id);
|
||||
}, true));
|
||||
}
|
||||
card.appendChild(actionRow);
|
||||
|
||||
cardList.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function buildKeyCardMessage(text, isError) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'resource-card';
|
||||
var value = document.createElement('div');
|
||||
value.className = 'resource-meta-value';
|
||||
value.textContent = text;
|
||||
value.style.color = isError ? 'var(--danger,#f85149)' : 'var(--text-muted)';
|
||||
card.appendChild(value);
|
||||
return card;
|
||||
}
|
||||
|
||||
function buildMetaItem(labelKey, valueText) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'resource-meta-item';
|
||||
var label = document.createElement('div');
|
||||
label.className = 'resource-meta-label';
|
||||
label.textContent = tKey(labelKey);
|
||||
var value = document.createElement('div');
|
||||
value.className = 'resource-meta-value';
|
||||
value.textContent = valueText;
|
||||
item.appendChild(label);
|
||||
item.appendChild(value);
|
||||
return item;
|
||||
}
|
||||
|
||||
function buildCardAction(labelKey, onClick, isDanger) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = isDanger ? 'btn-secondary' : 'btn-secondary';
|
||||
button.textContent = tKey(labelKey);
|
||||
button.addEventListener('click', onClick);
|
||||
return button;
|
||||
}
|
||||
|
||||
var modal = document.getElementById('modal-create');
|
||||
|
||||
function openModal() {
|
||||
if (!currentAgentId) return;
|
||||
document.getElementById('modal-form-body').hidden = false;
|
||||
document.getElementById('modal-reveal-body').hidden = true;
|
||||
document.getElementById('modal-footer-create').hidden = false;
|
||||
document.getElementById('modal-footer-done').hidden = true;
|
||||
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(event) {
|
||||
if (event.target === modal) closeModal();
|
||||
});
|
||||
|
||||
document.getElementById('modal-confirm-btn').addEventListener('click', async function() {
|
||||
var button = this;
|
||||
var name = document.getElementById('new-key-name').value.trim();
|
||||
if (!name) {
|
||||
document.getElementById('new-key-name').focus();
|
||||
return;
|
||||
}
|
||||
if (!selectedScopes.size) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.info(
|
||||
tKey('apikeys.toast.scope_missing_message'),
|
||||
tKey('apikeys.toast.scope_missing_title')
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
button.disabled = true;
|
||||
button.textContent = tKey('apikeys.creating');
|
||||
var created = await createKey(name, Array.from(selectedScopes));
|
||||
KEYS.unshift(created.record);
|
||||
document.getElementById('reveal-key-value').textContent = created.rawKey;
|
||||
document.getElementById('modal-form-body').hidden = true;
|
||||
document.getElementById('modal-reveal-body').hidden = false;
|
||||
document.getElementById('modal-footer-create').hidden = true;
|
||||
document.getElementById('modal-footer-done').hidden = false;
|
||||
renderTable();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tKey('apikeys.toast.create_message'),
|
||||
tKey('apikeys.toast.create_title')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || tKey('apikeys.toast.create_error_message'),
|
||||
tKey('apikeys.toast.create_error_title')
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = tKey('apikeys.modal.create');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('modal-done-btn').addEventListener('click', closeModal);
|
||||
|
||||
document.getElementById('copy-key-btn').addEventListener('click', function() {
|
||||
var value = document.getElementById('reveal-key-value').textContent;
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(value).catch(function() {});
|
||||
}
|
||||
this.replaceChildren(
|
||||
buildIconSvg(
|
||||
(window.APP_BASE || '') + 'icons/general/check.svg#icon',
|
||||
13,
|
||||
13
|
||||
)
|
||||
);
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.info(
|
||||
tKey('apikeys.toast.copy_message'),
|
||||
tKey('apikeys.toast.copy_title')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('key-search').addEventListener('input', function() {
|
||||
search = this.value;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
document.getElementById('agent-select').addEventListener('change', async function() {
|
||||
currentAgentId = this.value || null;
|
||||
await loadKeys();
|
||||
});
|
||||
|
||||
function copyPrefix(prefix) {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(prefix).catch(function() {});
|
||||
}
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.info(prefix, tKey('apikeys.toast.prefix_title'));
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
|
||||
await loadCapabilities();
|
||||
await loadKeys();
|
||||
window.addEventListener('crank:workspacechange', function() {
|
||||
loadCapabilities();
|
||||
loadKeys();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
(function() {
|
||||
var API_BASE = '/api/admin';
|
||||
var AUTH_BASE = '/api/auth';
|
||||
|
||||
function headers(extra) {
|
||||
return Object.assign({
|
||||
'Accept': 'application/json',
|
||||
}, extra || {});
|
||||
}
|
||||
|
||||
async function request(path, options) {
|
||||
var response = await fetch(path, Object.assign({
|
||||
credentials: 'same-origin',
|
||||
headers: headers(),
|
||||
}, options || {}));
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var payload = null;
|
||||
var text = await response.text();
|
||||
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') {
|
||||
window.CrankAuth.handleUnauthorized();
|
||||
}
|
||||
var message = payload && payload.error
|
||||
? payload.error.message
|
||||
: payload && payload.message
|
||||
? payload.message
|
||||
: text
|
||||
? text
|
||||
: ('HTTP ' + response.status);
|
||||
var error = new Error(message);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestText(path, options) {
|
||||
var response = await fetch(path, Object.assign({
|
||||
credentials: 'same-origin',
|
||||
headers: headers(),
|
||||
}, options || {}));
|
||||
|
||||
var text = await response.text();
|
||||
var payload = null;
|
||||
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') {
|
||||
window.CrankAuth.handleUnauthorized();
|
||||
}
|
||||
var message = payload && payload.error
|
||||
? payload.error.message
|
||||
: payload && payload.message
|
||||
? payload.message
|
||||
: text
|
||||
? text
|
||||
: ('HTTP ' + response.status);
|
||||
var error = new Error(message);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function get(path) {
|
||||
return request(API_BASE + path);
|
||||
}
|
||||
|
||||
function post(path, body) {
|
||||
return request(API_BASE + path, {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function patch(path, body) {
|
||||
return request(API_BASE + path, {
|
||||
method: 'PATCH',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function del(path) {
|
||||
return request(API_BASE + path, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
function query(params) {
|
||||
var search = new URLSearchParams();
|
||||
Object.keys(params || {}).forEach(function(key) {
|
||||
var value = params[key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
search.set(key, value);
|
||||
});
|
||||
var encoded = search.toString();
|
||||
return encoded ? ('?' + encoded) : '';
|
||||
}
|
||||
|
||||
function postBytes(path, bytes, fileName) {
|
||||
return request(API_BASE + path, {
|
||||
method: 'POST',
|
||||
headers: headers(fileName ? { 'X-File-Name': fileName } : {}),
|
||||
body: bytes,
|
||||
});
|
||||
}
|
||||
|
||||
window.CrankApi = {
|
||||
login: function(payload) {
|
||||
return request(AUTH_BASE + '/login', {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
logout: function() {
|
||||
return request(AUTH_BASE + '/logout', {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
},
|
||||
getSession: function() {
|
||||
return request(AUTH_BASE + '/session');
|
||||
},
|
||||
getProfile: function() {
|
||||
return request(AUTH_BASE + '/profile');
|
||||
},
|
||||
updateProfile: function(payload) {
|
||||
return request(AUTH_BASE + '/profile', {
|
||||
method: 'PATCH',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
changePassword: function(payload) {
|
||||
return request(AUTH_BASE + '/password', {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
setCurrentWorkspace: function(workspaceId) {
|
||||
return request(AUTH_BASE + '/current-workspace', {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ workspace_id: workspaceId }),
|
||||
});
|
||||
},
|
||||
listWorkspaces: function() {
|
||||
return get('/workspaces');
|
||||
},
|
||||
getCapabilities: function() {
|
||||
return get('/capabilities');
|
||||
},
|
||||
createWorkspace: function(payload) {
|
||||
return post('/workspaces', payload);
|
||||
},
|
||||
getWorkspace: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId));
|
||||
},
|
||||
updateWorkspace: function(workspaceId, payload) {
|
||||
return patch('/workspaces/' + encodeURIComponent(workspaceId), payload);
|
||||
},
|
||||
listMemberships: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/members');
|
||||
},
|
||||
updateMembership: function(workspaceId, userId, payload) {
|
||||
return patch('/workspaces/' + encodeURIComponent(workspaceId) + '/members/' + encodeURIComponent(userId), payload);
|
||||
},
|
||||
deleteMembership: function(workspaceId, userId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/members/' + encodeURIComponent(userId));
|
||||
},
|
||||
listInvitations: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations');
|
||||
},
|
||||
createInvitation: function(workspaceId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations', payload);
|
||||
},
|
||||
deleteInvitation: function(workspaceId, invitationId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations/' + encodeURIComponent(invitationId));
|
||||
},
|
||||
exportWorkspace: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/export');
|
||||
},
|
||||
deleteWorkspace: function(workspaceId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId));
|
||||
},
|
||||
listOperations: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations');
|
||||
},
|
||||
getOperation: function(workspaceId, operationId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId));
|
||||
},
|
||||
getOperationVersion: function(workspaceId, operationId, version) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/versions/' + encodeURIComponent(version));
|
||||
},
|
||||
createOperation: function(workspaceId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations', payload);
|
||||
},
|
||||
updateOperation: function(workspaceId, operationId, payload) {
|
||||
return patch('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId), payload);
|
||||
},
|
||||
deleteOperation: function(workspaceId, operationId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId));
|
||||
},
|
||||
archiveOperation: function(workspaceId, operationId) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/archive', {});
|
||||
},
|
||||
publishOperation: function(workspaceId, operationId, version) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/publish', {
|
||||
version: version,
|
||||
});
|
||||
},
|
||||
createOperationVersion: function(workspaceId, operationId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/versions', payload);
|
||||
},
|
||||
runOperationTest: function(workspaceId, operationId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/test-runs', payload);
|
||||
},
|
||||
uploadInputSample: function(workspaceId, operationId, sample) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/samples/input-json', sample);
|
||||
},
|
||||
uploadOutputSample: function(workspaceId, operationId, sample) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/samples/output-json', sample);
|
||||
},
|
||||
generateDraft: function(workspaceId, operationId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/drafts/generate', payload || {});
|
||||
},
|
||||
exportOperation: function(workspaceId, operationId, params) {
|
||||
return requestText(
|
||||
API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/export' + query(params),
|
||||
{
|
||||
headers: headers({ 'Accept': 'application/yaml' }),
|
||||
}
|
||||
);
|
||||
},
|
||||
importOperation: function(workspaceId, yamlDocument, mode) {
|
||||
return request(
|
||||
API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/operations/import' + query({ mode: mode }),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/yaml' }),
|
||||
body: yamlDocument,
|
||||
}
|
||||
);
|
||||
},
|
||||
listAgents: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents');
|
||||
},
|
||||
createAgent: function(workspaceId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents', payload);
|
||||
},
|
||||
getAgent: function(workspaceId, agentId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId));
|
||||
},
|
||||
updateAgent: function(workspaceId, agentId, payload) {
|
||||
return patch('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId), payload);
|
||||
},
|
||||
deleteAgent: function(workspaceId, agentId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId));
|
||||
},
|
||||
saveAgentBindings: function(workspaceId, agentId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/bindings', payload);
|
||||
},
|
||||
publishAgent: function(workspaceId, agentId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', payload);
|
||||
},
|
||||
unpublishAgent: function(workspaceId, agentId) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/unpublish', {});
|
||||
},
|
||||
archiveAgent: function(workspaceId, agentId) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/archive', {});
|
||||
},
|
||||
listAgentPlatformApiKeys: function(workspaceId, agentId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys');
|
||||
},
|
||||
createAgentPlatformApiKey: function(workspaceId, agentId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys', payload);
|
||||
},
|
||||
revokeAgentPlatformApiKey: function(workspaceId, agentId, keyId) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys/' + encodeURIComponent(keyId) + '/revoke', {});
|
||||
},
|
||||
deleteAgentPlatformApiKey: function(workspaceId, agentId, keyId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/platform-api-keys/' + encodeURIComponent(keyId));
|
||||
},
|
||||
listSecrets: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets');
|
||||
},
|
||||
createSecret: function(workspaceId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets', payload);
|
||||
},
|
||||
getSecret: function(workspaceId, secretId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets/' + encodeURIComponent(secretId));
|
||||
},
|
||||
rotateSecret: function(workspaceId, secretId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets/' + encodeURIComponent(secretId) + '/rotate', payload);
|
||||
},
|
||||
deleteSecret: function(workspaceId, secretId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/secrets/' + encodeURIComponent(secretId));
|
||||
},
|
||||
listAuthProfiles: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/auth-profiles');
|
||||
},
|
||||
listUpstreams: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/upstreams');
|
||||
},
|
||||
createUpstream: function(workspaceId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/upstreams', payload);
|
||||
},
|
||||
updateUpstream: function(workspaceId, upstreamId, payload) {
|
||||
return patch('/workspaces/' + encodeURIComponent(workspaceId) + '/upstreams/' + encodeURIComponent(upstreamId), payload);
|
||||
},
|
||||
listLogs: function(workspaceId, params) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs' + query(params));
|
||||
},
|
||||
getLog: function(workspaceId, logId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs/' + encodeURIComponent(logId));
|
||||
},
|
||||
getUsageOverview: function(workspaceId, params) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params));
|
||||
},
|
||||
getProtocolCapabilities: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/protocol-capabilities');
|
||||
},
|
||||
getOperationUsage: function(workspaceId, operationId, params) {
|
||||
return get(
|
||||
'/workspaces/' + encodeURIComponent(workspaceId) + '/usage/operations/' + encodeURIComponent(operationId) + query(params)
|
||||
);
|
||||
},
|
||||
getAgentUsage: function(workspaceId, agentId, params) {
|
||||
return get(
|
||||
'/workspaces/' + encodeURIComponent(workspaceId) + '/usage/agents/' + encodeURIComponent(agentId) + query(params)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
}());
|
||||
@@ -0,0 +1,253 @@
|
||||
(function() {
|
||||
var STORAGE_KEY = 'crank_user';
|
||||
var sessionCache = null;
|
||||
var sessionPromise = null;
|
||||
function tKey(key) {
|
||||
return typeof t === 'function' ? t(key) : key;
|
||||
}
|
||||
function tfKey(key, vars) {
|
||||
return typeof tf === 'function' ? tf(key, vars) : key;
|
||||
}
|
||||
function roleLabel(role) {
|
||||
var normalized = String(role || 'viewer').toLowerCase();
|
||||
if (normalized === 'operator') return tKey('workspace_setup.role.operator');
|
||||
if (normalized === 'owner') return tKey('workspace_setup.role.owner');
|
||||
if (normalized === 'admin') return tKey('workspace_setup.role.admin');
|
||||
if (normalized === 'viewer') return tKey('workspace_setup.role.viewer');
|
||||
return normalized.replace(/^\w/, function(char) { return char.toUpperCase(); });
|
||||
}
|
||||
|
||||
function currentWorkspaceLabel() {
|
||||
if (window.getCurrentWorkspace) {
|
||||
var workspace = window.getCurrentWorkspace();
|
||||
if (workspace && workspace.name) {
|
||||
return workspace.name;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return localStorage.getItem('crank_workspace_slug') || tKey('settings.nav.workspace');
|
||||
} catch (_error) {
|
||||
return tKey('settings.nav.workspace');
|
||||
}
|
||||
}
|
||||
|
||||
function isLoginPage() {
|
||||
return /\/(?:html\/login\.html|login)\/?$/.test(window.location.pathname);
|
||||
}
|
||||
|
||||
function loginUrl() {
|
||||
return (window.CrankRoutes && window.CrankRoutes.login) || '/login';
|
||||
}
|
||||
|
||||
function homeUrl() {
|
||||
return (window.CrankRoutes && window.CrankRoutes.home) || '/';
|
||||
}
|
||||
|
||||
function clearUserMirror() {
|
||||
sessionCache = null;
|
||||
sessionPromise = null;
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (_error) {}
|
||||
renderShellIdentity(null);
|
||||
}
|
||||
|
||||
function primaryMembership(session) {
|
||||
if (!(session && session.memberships && session.memberships.length)) {
|
||||
return null;
|
||||
}
|
||||
if (session.current_workspace_id) {
|
||||
var currentMembership = session.memberships.find(function(item) {
|
||||
return item.workspace && item.workspace.id === session.current_workspace_id;
|
||||
});
|
||||
if (currentMembership) {
|
||||
return currentMembership;
|
||||
}
|
||||
}
|
||||
if (window.getCurrentWorkspace) {
|
||||
var currentWorkspace = window.getCurrentWorkspace();
|
||||
if (currentWorkspace) {
|
||||
var matched = session.memberships.find(function(item) {
|
||||
return item.workspace && item.workspace.id === currentWorkspace.id;
|
||||
});
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
}
|
||||
return session.memberships[0];
|
||||
}
|
||||
|
||||
function initials(displayName, email) {
|
||||
var source = displayName || email || 'Crank';
|
||||
return source
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map(function(part) { return part.charAt(0).toUpperCase(); })
|
||||
.join('') || 'CR';
|
||||
}
|
||||
|
||||
function persistUserMirror(session) {
|
||||
var membership = primaryMembership(session);
|
||||
var user = {
|
||||
id: session.user.id,
|
||||
name: session.user.display_name,
|
||||
email: session.user.email,
|
||||
role: membership ? roleLabel(membership.role) : tKey('workspace_setup.role.viewer'),
|
||||
workspace: membership ? membership.workspace.slug : '',
|
||||
workspaceId: membership ? membership.workspace.id : '',
|
||||
initials: initials(session.user.display_name, session.user.email),
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
function mirroredUser() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY));
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderShellIdentity(session) {
|
||||
var membership = primaryMembership(session);
|
||||
var fallbackUser = mirroredUser();
|
||||
var displayName = session && session.user
|
||||
? (session.user.display_name || session.user.email || 'Crank')
|
||||
: (fallbackUser && fallbackUser.name) || 'Crank';
|
||||
var email = session && session.user
|
||||
? session.user.email
|
||||
: fallbackUser && fallbackUser.email;
|
||||
var role = membership
|
||||
? roleLabel(membership.role)
|
||||
: (fallbackUser && fallbackUser.role) || tKey('workspace_setup.role.viewer');
|
||||
var workspace = membership && membership.workspace
|
||||
? (membership.workspace.display_name || membership.workspace.slug || membership.workspace.id)
|
||||
: currentWorkspaceLabel();
|
||||
var avatarValue = initials(displayName, email);
|
||||
|
||||
document.querySelectorAll('.nav-avatar').forEach(function(element) {
|
||||
element.textContent = avatarValue;
|
||||
});
|
||||
document.querySelectorAll('.user-dropdown-name').forEach(function(element) {
|
||||
element.textContent = displayName;
|
||||
});
|
||||
document.querySelectorAll('.user-dropdown-role').forEach(function(element) {
|
||||
element.textContent = role + ' · ' + workspace;
|
||||
});
|
||||
if (window.CrankDiagnostics && typeof window.CrankDiagnostics.ensureShellTestIds === 'function') {
|
||||
window.CrankDiagnostics.ensureShellTestIds();
|
||||
}
|
||||
}
|
||||
|
||||
function replaceSession(session) {
|
||||
sessionCache = session;
|
||||
persistUserMirror(session);
|
||||
renderShellIdentity(session);
|
||||
window.dispatchEvent(new CustomEvent('crank:sessionchange', { detail: session }));
|
||||
return session;
|
||||
}
|
||||
|
||||
async function fetchSession(force) {
|
||||
if (!force && sessionCache) {
|
||||
return sessionCache;
|
||||
}
|
||||
if (!force && sessionPromise) {
|
||||
return sessionPromise;
|
||||
}
|
||||
|
||||
sessionPromise = window.CrankApi.getSession()
|
||||
.then(function(session) {
|
||||
return replaceSession(session);
|
||||
})
|
||||
.catch(function(error) {
|
||||
clearUserMirror();
|
||||
throw error;
|
||||
})
|
||||
.finally(function() {
|
||||
sessionPromise = null;
|
||||
});
|
||||
|
||||
return sessionPromise;
|
||||
}
|
||||
|
||||
async function guardProtectedPage() {
|
||||
try {
|
||||
return await fetchSession(false);
|
||||
} catch (error) {
|
||||
if (error && error.status === 401) {
|
||||
window.location.replace(loginUrl());
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function guardLoginPage() {
|
||||
try {
|
||||
await fetchSession(false);
|
||||
window.location.replace(homeUrl());
|
||||
} catch (error) {
|
||||
if (!error || error.status !== 401) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function login(email, password) {
|
||||
var session = await window.CrankApi.login({
|
||||
email: email,
|
||||
password: password,
|
||||
});
|
||||
replaceSession(session);
|
||||
window.location.href = homeUrl();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await window.CrankApi.logout();
|
||||
} finally {
|
||||
clearUserMirror();
|
||||
window.location.href = loginUrl();
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnauthorized() {
|
||||
if (isLoginPage()) {
|
||||
clearUserMirror();
|
||||
return;
|
||||
}
|
||||
clearUserMirror();
|
||||
window.location.replace(loginUrl());
|
||||
}
|
||||
|
||||
window.CrankAuth = {
|
||||
fetchSession: fetchSession,
|
||||
replaceSession: replaceSession,
|
||||
getCachedSession: function() { return sessionCache; },
|
||||
renderShellIdentity: function() {
|
||||
renderShellIdentity(sessionCache);
|
||||
},
|
||||
guardProtectedPage: guardProtectedPage,
|
||||
guardLoginPage: guardLoginPage,
|
||||
login: login,
|
||||
logout: logout,
|
||||
handleUnauthorized: handleUnauthorized,
|
||||
};
|
||||
|
||||
window.addEventListener('crank:workspacechange', function() {
|
||||
renderShellIdentity(sessionCache);
|
||||
});
|
||||
|
||||
window.addEventListener('crank:sessionchange', function(event) {
|
||||
renderShellIdentity(event.detail || sessionCache);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
renderShellIdentity(sessionCache);
|
||||
});
|
||||
}());
|
||||
@@ -0,0 +1,535 @@
|
||||
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' },
|
||||
];
|
||||
}
|
||||
|
||||
function uiStatus(rawStatus) {
|
||||
if (rawStatus === 'published') return 'active';
|
||||
if (rawStatus === 'archived') return 'inactive';
|
||||
if (rawStatus === 'testing') return 'active';
|
||||
return rawStatus || 'draft';
|
||||
}
|
||||
|
||||
function currentLocale() {
|
||||
return localStorage.getItem('crank_lang') === 'ru' ? 'ru-RU' : 'en-US';
|
||||
}
|
||||
|
||||
function mapOperation(item) {
|
||||
var mapped = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
display_name: item.display_name,
|
||||
protocol: item.protocol,
|
||||
status: uiStatus(item.status),
|
||||
raw_status: item.status,
|
||||
category: item.category || 'general',
|
||||
created_at: item.created_at,
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
target_url: item.target_url || '',
|
||||
method: item.target_action || '',
|
||||
usage_summary: item.usage_summary || {
|
||||
calls_today: 0,
|
||||
error_rate_pct: 0,
|
||||
avg_latency_ms: 0,
|
||||
},
|
||||
agent_refs: item.agent_refs || [],
|
||||
};
|
||||
|
||||
return window.localizeDemoOperation ? window.localizeDemoOperation(mapped) : mapped;
|
||||
}
|
||||
|
||||
function emptyStats() {
|
||||
return {
|
||||
total: 0,
|
||||
active: 0,
|
||||
requestsToday: 0,
|
||||
averageLatencyMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function computeStats(operations) {
|
||||
if (!operations.length) return emptyStats();
|
||||
|
||||
var requestsToday = operations.reduce(function(sum, operation) {
|
||||
return sum + (operation.usage_summary ? operation.usage_summary.calls_today : 0);
|
||||
}, 0);
|
||||
var latencyBase = operations.filter(function(operation) {
|
||||
return operation.usage_summary && operation.usage_summary.calls_today > 0;
|
||||
});
|
||||
var latencyWeight = latencyBase.reduce(function(sum, operation) {
|
||||
return sum + operation.usage_summary.calls_today;
|
||||
}, 0);
|
||||
var weightedLatency = latencyBase.reduce(function(sum, operation) {
|
||||
return sum + (operation.usage_summary.avg_latency_ms * operation.usage_summary.calls_today);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
total: operations.length,
|
||||
active: operations.filter(function(operation) { return operation.status === 'active'; }).length,
|
||||
requestsToday: requestsToday,
|
||||
averageLatencyMs: latencyWeight > 0 ? Math.round(weightedLatency / latencyWeight) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', function() {
|
||||
Alpine.data('catalog', function() {
|
||||
return {
|
||||
operations: [],
|
||||
loading: true,
|
||||
loadError: '',
|
||||
pageSize: PAGE_SIZE_DESKTOP,
|
||||
search: '',
|
||||
tab: 'all',
|
||||
filterProtocol: null,
|
||||
filterCategory: null,
|
||||
filterAgent: null,
|
||||
sort: 'created_desc',
|
||||
page: 1,
|
||||
openDropdown: null,
|
||||
navOpen: false,
|
||||
categoryOptions: [],
|
||||
workspaceId: null,
|
||||
stats: emptyStats(),
|
||||
_opsVersion: 0,
|
||||
_nonStatusViewCache: null,
|
||||
_nonStatusViewKey: '',
|
||||
_filteredCache: null,
|
||||
_filteredKey: '',
|
||||
_agentsByOpIdCache: null,
|
||||
_agentsByOpIdCacheVersion: -1,
|
||||
_activeAgentsCache: null,
|
||||
_activeAgentsCacheVersion: -1,
|
||||
|
||||
async init() {
|
||||
var self = this;
|
||||
|
||||
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
this.workspaceId = workspace ? workspace.id : null;
|
||||
|
||||
var updatePageSize = function() {
|
||||
self.pageSize = window.innerWidth <= MOBILE_BREAKPOINT ? PAGE_SIZE_MOBILE : PAGE_SIZE_DESKTOP;
|
||||
self.page = 1;
|
||||
};
|
||||
|
||||
updatePageSize();
|
||||
window.addEventListener('resize', updatePageSize);
|
||||
window.addEventListener('crank:langchange', function() {
|
||||
self.sort = self.sort;
|
||||
applyLang();
|
||||
});
|
||||
window.addEventListener('crank:workspacechange', async function(event) {
|
||||
self.workspaceId = event.detail ? event.detail.id : null;
|
||||
await self.reload();
|
||||
});
|
||||
document.addEventListener('click', function(event) {
|
||||
if (!event.target.closest('.filter-dropdown, .sort-dropdown, .user-menu, .ws-switcher')) {
|
||||
self.openDropdown = null;
|
||||
}
|
||||
if (!event.target.closest('.navbar')) {
|
||||
self.navOpen = false;
|
||||
}
|
||||
});
|
||||
|
||||
await this.reload();
|
||||
},
|
||||
|
||||
async reload() {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
|
||||
try {
|
||||
var response = await window.CrankApi.listOperations(this.workspaceId);
|
||||
this.replaceOperations((response && response.items ? response.items : []).map(mapOperation));
|
||||
this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) {
|
||||
return operation.category;
|
||||
}).filter(Boolean))).sort();
|
||||
this.stats = computeStats(this.operations);
|
||||
} catch (error) {
|
||||
this.replaceOperations([]);
|
||||
this.categoryOptions = [];
|
||||
this.stats = emptyStats();
|
||||
this.loadError = error.message || 'Failed to load operations';
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
replaceOperations(operations) {
|
||||
this.operations = operations;
|
||||
this._opsVersion += 1;
|
||||
this._nonStatusViewCache = null;
|
||||
this._nonStatusViewKey = '';
|
||||
this._filteredCache = null;
|
||||
this._filteredKey = '';
|
||||
this._agentsByOpIdCache = null;
|
||||
this._agentsByOpIdCacheVersion = -1;
|
||||
this._activeAgentsCache = null;
|
||||
this._activeAgentsCacheVersion = -1;
|
||||
},
|
||||
|
||||
_applyNonStatusFilters(operations) {
|
||||
var filtered = operations;
|
||||
|
||||
if (this.search.trim()) {
|
||||
var query = this.search.toLowerCase();
|
||||
filtered = filtered.filter(function(operation) {
|
||||
return operation.name.toLowerCase().includes(query)
|
||||
|| operation.display_name.toLowerCase().includes(query)
|
||||
|| operation.category.toLowerCase().includes(query)
|
||||
|| (operation.target_url || '').toLowerCase().includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.filterProtocol) {
|
||||
filtered = filtered.filter(function(operation) { return operation.protocol === this.filterProtocol; }, this);
|
||||
}
|
||||
|
||||
if (this.filterCategory) {
|
||||
filtered = filtered.filter(function(operation) { return operation.category === this.filterCategory; }, this);
|
||||
}
|
||||
|
||||
if (this.filterAgent) {
|
||||
filtered = filtered.filter(function(operation) {
|
||||
return (operation.agent_refs || []).some(function(agent) { return agent.agent_id === this.filterAgent; }, this);
|
||||
}, this);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
|
||||
_nonStatusView() {
|
||||
var key = [
|
||||
this._opsVersion,
|
||||
this.search.trim(),
|
||||
this.filterProtocol || '',
|
||||
this.filterCategory || '',
|
||||
this.filterAgent || '',
|
||||
].join('|');
|
||||
|
||||
if (this._nonStatusViewCache && this._nonStatusViewKey === key) {
|
||||
return this._nonStatusViewCache;
|
||||
}
|
||||
|
||||
var operations = this._applyNonStatusFilters(this.operations);
|
||||
var tabCounts = {
|
||||
all: operations.length,
|
||||
active: 0,
|
||||
draft: 0,
|
||||
error: 0,
|
||||
inactive: 0,
|
||||
};
|
||||
|
||||
operations.forEach(function(operation) {
|
||||
if (Object.prototype.hasOwnProperty.call(tabCounts, operation.status)) {
|
||||
tabCounts[operation.status] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
this._nonStatusViewKey = key;
|
||||
this._nonStatusViewCache = {
|
||||
operations: operations,
|
||||
tabCounts: tabCounts,
|
||||
};
|
||||
return this._nonStatusViewCache;
|
||||
},
|
||||
|
||||
get filtered() {
|
||||
var nonStatusView = this._nonStatusView();
|
||||
var key = [
|
||||
this._nonStatusViewKey,
|
||||
this.tab,
|
||||
this.sort,
|
||||
].join('|');
|
||||
|
||||
if (this._filteredCache && this._filteredKey === key) {
|
||||
return this._filteredCache;
|
||||
}
|
||||
|
||||
var operations = nonStatusView.operations;
|
||||
|
||||
if (this.tab !== 'all') {
|
||||
operations = operations.filter(function(operation) {
|
||||
return operation.status === this.tab;
|
||||
}, this);
|
||||
}
|
||||
|
||||
operations = operations.slice().sort(function(left, right) {
|
||||
if (this.sort === 'created_desc') return new Date(right.created_at) - new Date(left.created_at);
|
||||
if (this.sort === 'created_asc') return new Date(left.created_at) - new Date(right.created_at);
|
||||
if (this.sort === 'name_asc') return left.name.localeCompare(right.name);
|
||||
if (this.sort === 'name_desc') return right.name.localeCompare(left.name);
|
||||
return 0;
|
||||
}.bind(this));
|
||||
|
||||
this._filteredKey = key;
|
||||
this._filteredCache = operations;
|
||||
return this._filteredCache;
|
||||
},
|
||||
|
||||
get totalFiltered() {
|
||||
return this.filtered.length;
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.totalFiltered / this.pageSize));
|
||||
},
|
||||
|
||||
get paginated() {
|
||||
var 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);
|
||||
},
|
||||
|
||||
tabCount(tab) {
|
||||
var tabCounts = this._nonStatusView().tabCounts;
|
||||
return Object.prototype.hasOwnProperty.call(tabCounts, tab) ? tabCounts[tab] : 0;
|
||||
},
|
||||
|
||||
get activeChips() {
|
||||
var chips = [];
|
||||
if (this.filterProtocol) chips.push({ key: 'protocol', label: this.tfKey('ops.filter.protocol', { value: this.filterProtocol.toUpperCase() }) });
|
||||
if (this.filterCategory) chips.push({ key: 'category', label: this.tfKey('ops.filter.category', { value: this.filterCategory }) });
|
||||
if (this.filterAgent) {
|
||||
var agent = this.activeAgents.find(function(item) { return item.agent_id === this.filterAgent; }, this);
|
||||
chips.push({ key: 'agent', label: this.tfKey('ops.filter.agent', { value: agent ? agent.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;
|
||||
},
|
||||
|
||||
get agentsByOpId() {
|
||||
if (this._agentsByOpIdCache && this._agentsByOpIdCacheVersion === this._opsVersion) {
|
||||
return this._agentsByOpIdCache;
|
||||
}
|
||||
var map = {};
|
||||
this.operations.forEach(function(operation) {
|
||||
map[operation.id] = operation.agent_refs || [];
|
||||
});
|
||||
this._agentsByOpIdCacheVersion = this._opsVersion;
|
||||
this._agentsByOpIdCache = map;
|
||||
return this._agentsByOpIdCache;
|
||||
},
|
||||
|
||||
get activeAgents() {
|
||||
if (this._activeAgentsCache && this._activeAgentsCacheVersion === this._opsVersion) {
|
||||
return this._activeAgentsCache;
|
||||
}
|
||||
var seen = {};
|
||||
var agents = [];
|
||||
this.operations.forEach(function(operation) {
|
||||
(operation.agent_refs || []).forEach(function(agent) {
|
||||
if (!seen[agent.agent_id]) {
|
||||
seen[agent.agent_id] = true;
|
||||
agents.push(agent);
|
||||
}
|
||||
});
|
||||
});
|
||||
this._activeAgentsCacheVersion = this._opsVersion;
|
||||
this._activeAgentsCache = agents.sort(function(left, right) {
|
||||
return left.display_name.localeCompare(right.display_name);
|
||||
});
|
||||
return this._activeAgentsCache;
|
||||
},
|
||||
|
||||
get sortLabel() {
|
||||
return getSortOptions().find(function(option) { return option.value === this.sort; }, this)?.label || 'Sort';
|
||||
},
|
||||
|
||||
setTab(tab) {
|
||||
this.tab = tab;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setSearch(value) {
|
||||
this.search = value;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setProtocol(value) {
|
||||
this.filterProtocol = this.filterProtocol === value ? null : value;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setCategory(value) {
|
||||
this.filterCategory = this.filterCategory === value ? null : value;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setSort(value) {
|
||||
this.sort = value;
|
||||
this.openDropdown = null;
|
||||
},
|
||||
|
||||
setAgent(value) {
|
||||
this.filterAgent = this.filterAgent === value ? null : value;
|
||||
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(page) {
|
||||
if (page >= 1 && page <= this.totalPages) this.page = page;
|
||||
},
|
||||
|
||||
editOperation(operation) {
|
||||
window.location.href = ((window.CrankRoutes && window.CrankRoutes.wizard) || '/wizard/')
|
||||
+ '?mode=edit&operationId=' + encodeURIComponent(operation.id);
|
||||
},
|
||||
|
||||
async deleteOperation(id) {
|
||||
if (!confirm(this.tKey('ops.delete.confirm'))) return;
|
||||
try {
|
||||
var operation = this.operations.find(function(item) { return item.id === id; });
|
||||
await window.CrankApi.deleteOperation(this.workspaceId, id);
|
||||
this.replaceOperations(this.operations.filter(function(operation) { return operation.id !== id; }));
|
||||
this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) {
|
||||
return operation.category;
|
||||
}).filter(Boolean))).sort();
|
||||
this.stats = computeStats(this.operations);
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
this.tfKey('ops.delete.success.message', {
|
||||
name: operation ? (operation.display_name || operation.name) : ''
|
||||
}),
|
||||
this.tKey('ops.delete.success.title')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || this.tKey('ops.delete.error.message'),
|
||||
this.tKey('ops.delete.error.title')
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
protocolLabel(operation) {
|
||||
return operation.method ? ('REST · ' + operation.method) : 'REST';
|
||||
},
|
||||
|
||||
protocolClass(operation) {
|
||||
return 'badge badge-' + operation.protocol;
|
||||
},
|
||||
|
||||
statusClass(operation) {
|
||||
return 'status-badge status-' + operation.status;
|
||||
},
|
||||
|
||||
statusLabel(operation) {
|
||||
if (operation.status === 'active') return this.tKey('tab.active');
|
||||
if (operation.status === 'inactive') return this.tKey('tab.inactive');
|
||||
if (operation.status === 'error') return this.tKey('tab.error');
|
||||
return this.tKey('tab.draft');
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString(currentLocale(), { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
},
|
||||
|
||||
truncateUrl(url) {
|
||||
if (!url) return '—';
|
||||
return url.length > 42 ? url.slice(0, 42) + '…' : url;
|
||||
},
|
||||
|
||||
formatMetricNumber(value) {
|
||||
return new Intl.NumberFormat(currentLocale()).format(value || 0);
|
||||
},
|
||||
|
||||
tKey(key) {
|
||||
return window.t ? t(key) : key;
|
||||
},
|
||||
|
||||
tfKey(key, vars) {
|
||||
return window.tf ? tf(key, vars) : this.tKey(key);
|
||||
},
|
||||
|
||||
get sortOptions() {
|
||||
return getSortOptions();
|
||||
},
|
||||
|
||||
get tabDefs() {
|
||||
return getTabDefs();
|
||||
},
|
||||
|
||||
handleNewOperation() {
|
||||
window.location.href = (window.CrankRoutes && window.CrankRoutes.wizard) || '/wizard/';
|
||||
},
|
||||
|
||||
handleLogout() {
|
||||
window.CrankAuth.logout();
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
(function() {
|
||||
window.APP_BASE = '/';
|
||||
window.DATA_URL = '/data/';
|
||||
window.CrankRoutes = {
|
||||
home: '/',
|
||||
login: '/login',
|
||||
agents: '/agents',
|
||||
apiKeys: '/api-keys',
|
||||
secrets: '/secrets',
|
||||
logs: '/logs',
|
||||
usage: '/usage',
|
||||
settings: '/settings',
|
||||
workspaceSetup: '/workspace-setup',
|
||||
wizard: '/wizard/'
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,111 @@
|
||||
(function() {
|
||||
var currentPage = '';
|
||||
|
||||
function messageFor(error) {
|
||||
if (!error) {
|
||||
return 'unknown error';
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function setPage(pageName) {
|
||||
currentPage = pageName || '';
|
||||
document.documentElement.setAttribute('data-crank-page', currentPage || 'unknown');
|
||||
}
|
||||
|
||||
function markBootstrapState(pageName, status, details) {
|
||||
document.documentElement.setAttribute('data-crank-bootstrap-page', pageName || currentPage || 'unknown');
|
||||
document.documentElement.setAttribute('data-crank-bootstrap-state', status);
|
||||
if (status === 'error' && details) {
|
||||
document.documentElement.setAttribute('data-crank-bootstrap-error', details);
|
||||
return;
|
||||
}
|
||||
document.documentElement.removeAttribute('data-crank-bootstrap-error');
|
||||
}
|
||||
|
||||
function report(stage, error, pageName) {
|
||||
var resolvedPage = pageName || currentPage || 'unknown';
|
||||
console.error('[CrankUI][' + resolvedPage + '][' + stage + ']', error);
|
||||
window.dispatchEvent(new CustomEvent('crank:ui-error', {
|
||||
detail: {
|
||||
page: resolvedPage,
|
||||
stage: stage,
|
||||
message: messageFor(error),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function bootstrap(pageName, init) {
|
||||
function run() {
|
||||
setPage(pageName);
|
||||
markBootstrapState(pageName, 'pending');
|
||||
try {
|
||||
var result = init();
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.then(function() {
|
||||
markBootstrapState(pageName, 'ready');
|
||||
}).catch(function(error) {
|
||||
markBootstrapState(pageName, 'error', messageFor(error));
|
||||
report('bootstrap', error, pageName);
|
||||
});
|
||||
return;
|
||||
}
|
||||
markBootstrapState(pageName, 'ready');
|
||||
} catch (error) {
|
||||
markBootstrapState(pageName, 'error', messageFor(error));
|
||||
report('bootstrap', error, pageName);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run, { once: true });
|
||||
return;
|
||||
}
|
||||
run();
|
||||
}
|
||||
|
||||
function ensureTestId(element, value) {
|
||||
if (!element || !value) {
|
||||
return;
|
||||
}
|
||||
element.setAttribute('data-testid', value);
|
||||
}
|
||||
|
||||
function ensureShellTestIds() {
|
||||
document.querySelectorAll('.nav-avatar').forEach(function(element) {
|
||||
ensureTestId(element, 'shell-avatar');
|
||||
});
|
||||
document.querySelectorAll('.user-dropdown-name').forEach(function(element) {
|
||||
ensureTestId(element, 'shell-user-name');
|
||||
});
|
||||
document.querySelectorAll('.user-dropdown-role').forEach(function(element) {
|
||||
ensureTestId(element, 'shell-user-role');
|
||||
});
|
||||
ensureTestId(document.getElementById('ws-current-name'), 'shell-workspace-name');
|
||||
}
|
||||
|
||||
window.addEventListener('error', function(event) {
|
||||
if (!currentPage) {
|
||||
return;
|
||||
}
|
||||
report('runtime', event.error || new Error(event.message || 'window error'));
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
if (!currentPage) {
|
||||
return;
|
||||
}
|
||||
report('unhandledrejection', event.reason || new Error('unhandled rejection'));
|
||||
});
|
||||
|
||||
window.CrankDiagnostics = {
|
||||
bootstrap: bootstrap,
|
||||
ensureTestId: ensureTestId,
|
||||
ensureShellTestIds: ensureShellTestIds,
|
||||
report: report,
|
||||
setPage: setPage,
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,32 @@
|
||||
(function() {
|
||||
function clear(element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyState(title, body) {
|
||||
var root = document.createElement('div');
|
||||
root.className = 'empty-state';
|
||||
|
||||
var titleNode = document.createElement('div');
|
||||
titleNode.className = 'empty-state-title';
|
||||
titleNode.textContent = title;
|
||||
root.appendChild(titleNode);
|
||||
|
||||
var bodyNode = document.createElement('div');
|
||||
bodyNode.className = 'empty-state-text';
|
||||
bodyNode.textContent = body;
|
||||
root.appendChild(bodyNode);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
window.CrankDom = {
|
||||
clear: clear,
|
||||
createEmptyState: createEmptyState,
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,54 @@
|
||||
(function() {
|
||||
function tKey(key) {
|
||||
return window.t ? t(key) : key;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
var errorElement = document.getElementById('login-error');
|
||||
errorElement.classList.add('is-visible');
|
||||
errorElement.textContent = message;
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
var errorElement = document.getElementById('login-error');
|
||||
errorElement.classList.remove('is-visible');
|
||||
}
|
||||
|
||||
function initLoginPage() {
|
||||
window.CrankAuth.guardLoginPage().catch(function(error) {
|
||||
if (window.CrankDiagnostics && typeof window.CrankDiagnostics.report === 'function') {
|
||||
window.CrankDiagnostics.report('guard-login-page', error, 'login');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
var email = document.getElementById('email').value.trim();
|
||||
var password = document.getElementById('password').value;
|
||||
|
||||
if (!email || !password) {
|
||||
showError(tKey('login.error.required'));
|
||||
return;
|
||||
}
|
||||
|
||||
hideError();
|
||||
|
||||
try {
|
||||
await window.CrankAuth.login(email, password);
|
||||
} catch (error) {
|
||||
if (error && error.status === 401) {
|
||||
showError(tKey('login.error.invalid'));
|
||||
return;
|
||||
}
|
||||
showError(tKey('login.error.generic'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.CrankDiagnostics && typeof window.CrankDiagnostics.bootstrap === 'function') {
|
||||
window.CrankDiagnostics.bootstrap('login', initLoginPage);
|
||||
return;
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', initLoginPage);
|
||||
}());
|
||||
@@ -0,0 +1,419 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var state = {
|
||||
logs: [],
|
||||
details: {},
|
||||
level: 'all',
|
||||
search: '',
|
||||
period: '7d',
|
||||
openId: null,
|
||||
liveMode: true,
|
||||
timer: null,
|
||||
workspaceId: null,
|
||||
loading: false,
|
||||
loadError: '',
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
function tKey(key) {
|
||||
return window.t ? t(key) : key;
|
||||
}
|
||||
|
||||
function currentWorkspaceId() {
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
return workspace ? workspace.id : null;
|
||||
}
|
||||
|
||||
function durationLabel(durationMs) {
|
||||
if (!durationMs) {
|
||||
return '0ms';
|
||||
}
|
||||
if (durationMs >= 1000) {
|
||||
return (durationMs / 1000).toFixed(1) + 's';
|
||||
}
|
||||
return durationMs + 'ms';
|
||||
}
|
||||
|
||||
function formatJson(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
var date = new Date(timestamp);
|
||||
return date.toISOString().slice(11, 23);
|
||||
}
|
||||
|
||||
function element(tag, className, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined && text !== null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function statusClass(statusCode) {
|
||||
if (!statusCode) {
|
||||
return '';
|
||||
}
|
||||
if (statusCode < 300) {
|
||||
return 'ok';
|
||||
}
|
||||
if (statusCode < 500) {
|
||||
return 'warn';
|
||||
}
|
||||
return 'err';
|
||||
}
|
||||
|
||||
function normalizeLog(record) {
|
||||
var log = record.log;
|
||||
var agent = window.localizeDemoAgent
|
||||
? window.localizeDemoAgent({
|
||||
slug: record.agent_slug,
|
||||
display_name: record.agent_display_name,
|
||||
description: '',
|
||||
})
|
||||
: null;
|
||||
var operation = window.localizeDemoOperation
|
||||
? window.localizeDemoOperation({
|
||||
name: record.operation_name,
|
||||
operation_display_name: record.operation_display_name,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: log.id,
|
||||
createdAt: log.created_at,
|
||||
level: log.level,
|
||||
source: log.source,
|
||||
status: log.status,
|
||||
statusCode: log.status_code,
|
||||
durationMs: log.duration_ms,
|
||||
toolName: log.tool_name,
|
||||
message: log.message,
|
||||
operationName: record.operation_name,
|
||||
operationDisplayName: operation ? operation.operation_display_name : record.operation_display_name,
|
||||
agentSlug: record.agent_slug,
|
||||
agentDisplayName: agent ? agent.display_name : record.agent_display_name,
|
||||
requestPreview: log.request_preview,
|
||||
responsePreview: log.response_preview,
|
||||
errorKind: log.error_kind,
|
||||
requestId: log.request_id,
|
||||
};
|
||||
}
|
||||
|
||||
function renderEmpty(title, message) {
|
||||
logList.innerHTML = '';
|
||||
var empty = element('div', 'empty-state');
|
||||
empty.appendChild(element('div', 'empty-state-title', title));
|
||||
empty.appendChild(element('div', 'empty-state-text', message));
|
||||
logList.appendChild(empty);
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
if (state.loading && state.logs.length === 0) {
|
||||
renderEmpty(tKey('logs.loading.title'), tKey('logs.loading.sub'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.loadError) {
|
||||
renderEmpty(tKey('logs.error.title'), state.loadError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.logs.length) {
|
||||
renderEmpty(
|
||||
tKey('logs.empty.title'),
|
||||
state.search || state.level !== 'all'
|
||||
? tKey('logs.empty.filtered')
|
||||
: tKey('logs.empty.initial')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var fragment = document.createDocumentFragment();
|
||||
|
||||
state.logs.forEach(function (item) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'log-entry';
|
||||
row.setAttribute('data-id', item.id);
|
||||
|
||||
var timeEl = document.createElement('span');
|
||||
timeEl.className = 'log-time';
|
||||
timeEl.textContent = formatTime(item.createdAt);
|
||||
row.appendChild(timeEl);
|
||||
|
||||
var levelEl = document.createElement('span');
|
||||
levelEl.className = 'log-level ' + item.level;
|
||||
levelEl.textContent = item.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 = item.operationDisplayName || item.operationName;
|
||||
msgDiv.appendChild(opSpan);
|
||||
msgDiv.appendChild(document.createTextNode('\u00a0\u00a0'));
|
||||
|
||||
if (item.statusCode) {
|
||||
var statusEl = document.createElement('span');
|
||||
statusEl.className = 'log-status ' + statusClass(item.statusCode);
|
||||
statusEl.textContent = item.statusCode;
|
||||
msgDiv.appendChild(statusEl);
|
||||
msgDiv.appendChild(document.createTextNode(' \u00b7 '));
|
||||
}
|
||||
|
||||
msgDiv.appendChild(document.createTextNode(item.message));
|
||||
row.appendChild(msgDiv);
|
||||
|
||||
var durationEl = document.createElement('span');
|
||||
var durationClass = item.level === 'error' ? ' error' : (item.durationMs >= 1000 ? ' slow' : '');
|
||||
durationEl.className = 'log-duration' + durationClass;
|
||||
durationEl.textContent = durationLabel(item.durationMs);
|
||||
row.appendChild(durationEl);
|
||||
|
||||
fragment.appendChild(row);
|
||||
|
||||
var expanded = document.createElement('div');
|
||||
expanded.className = 'log-entry-expanded' + (item.id === state.openId ? ' open' : '');
|
||||
expanded.setAttribute('data-exp', item.id);
|
||||
|
||||
if (item.id === state.openId) {
|
||||
var detail = state.details[item.id] || item;
|
||||
|
||||
if (detail.agentDisplayName || detail.agentSlug) {
|
||||
var agentLabel = document.createElement('div');
|
||||
agentLabel.className = 'log-detail-label';
|
||||
agentLabel.textContent = tKey('logs.detail.agent');
|
||||
expanded.appendChild(agentLabel);
|
||||
|
||||
var agentPre = document.createElement('pre');
|
||||
agentPre.className = 'log-detail-block';
|
||||
agentPre.textContent = detail.agentDisplayName || detail.agentSlug;
|
||||
expanded.appendChild(agentPre);
|
||||
}
|
||||
|
||||
var requestLabel = document.createElement('div');
|
||||
requestLabel.className = 'log-detail-label';
|
||||
requestLabel.textContent = tKey('logs.detail.request');
|
||||
expanded.appendChild(requestLabel);
|
||||
|
||||
var requestPre = document.createElement('pre');
|
||||
requestPre.className = 'log-detail-block';
|
||||
requestPre.textContent = formatJson(detail.requestPreview);
|
||||
expanded.appendChild(requestPre);
|
||||
|
||||
var responseLabel = document.createElement('div');
|
||||
responseLabel.className = 'log-detail-label';
|
||||
responseLabel.textContent = tKey('logs.detail.response');
|
||||
expanded.appendChild(responseLabel);
|
||||
|
||||
var responsePre = document.createElement('pre');
|
||||
responsePre.className = 'log-detail-block';
|
||||
responsePre.textContent = formatJson(detail.responsePreview);
|
||||
expanded.appendChild(responsePre);
|
||||
|
||||
if (detail.errorKind || detail.requestId) {
|
||||
var metaLabel = document.createElement('div');
|
||||
metaLabel.className = 'log-detail-label';
|
||||
metaLabel.textContent = tKey('logs.detail.meta');
|
||||
expanded.appendChild(metaLabel);
|
||||
|
||||
var metaPre = document.createElement('pre');
|
||||
metaPre.className = 'log-detail-block';
|
||||
metaPre.textContent = formatJson({
|
||||
request_id: detail.requestId || null,
|
||||
error_kind: detail.errorKind || null,
|
||||
source: detail.source,
|
||||
status: detail.status,
|
||||
});
|
||||
expanded.appendChild(metaPre);
|
||||
}
|
||||
}
|
||||
|
||||
fragment.appendChild(expanded);
|
||||
});
|
||||
|
||||
logList.innerHTML = '';
|
||||
logList.appendChild(fragment);
|
||||
|
||||
logList.querySelectorAll('.log-entry').forEach(function (row) {
|
||||
row.addEventListener('click', async function () {
|
||||
var id = this.getAttribute('data-id');
|
||||
state.openId = state.openId === id ? null : id;
|
||||
renderLogs();
|
||||
if (state.openId) {
|
||||
await loadLogDetail(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function queryParams() {
|
||||
var params = {
|
||||
period: state.period,
|
||||
limit: 100,
|
||||
};
|
||||
if (state.level !== 'all') {
|
||||
params.level = state.level;
|
||||
}
|
||||
if (state.search) {
|
||||
params.search = state.search;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
if (!window.CrankApi) {
|
||||
state.loadError = tKey('logs.error.api');
|
||||
renderLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
state.workspaceId = currentWorkspaceId();
|
||||
if (!state.workspaceId) {
|
||||
state.loadError = tKey('logs.error.workspace');
|
||||
renderLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
state.loading = true;
|
||||
state.loadError = '';
|
||||
renderLogs();
|
||||
|
||||
try {
|
||||
var response = await window.CrankApi.listLogs(state.workspaceId, queryParams());
|
||||
state.logs = (response && response.items ? response.items : []).map(normalizeLog);
|
||||
if (state.openId && !state.logs.some(function (item) { return item.id === state.openId; })) {
|
||||
state.openId = null;
|
||||
}
|
||||
} catch (error) {
|
||||
state.loadError = error.message || tKey('logs.error.load');
|
||||
} finally {
|
||||
state.loading = false;
|
||||
renderLogs();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogDetail(logId) {
|
||||
if (!window.CrankApi || !state.workspaceId || state.details[logId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var record = await window.CrankApi.getLog(state.workspaceId, logId);
|
||||
state.details[logId] = normalizeLog(record);
|
||||
if (state.openId === logId) {
|
||||
renderLogs();
|
||||
}
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
function setLiveState() {
|
||||
if (liveDot) {
|
||||
liveDot.classList.toggle('is-paused', !state.liveMode);
|
||||
}
|
||||
if (liveLabel) {
|
||||
liveLabel.textContent = state.liveMode ? tKey('logs.live') : tKey('logs.paused');
|
||||
liveLabel.classList.toggle('is-paused', !state.liveMode);
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (state.timer) {
|
||||
clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
if (!state.liveMode) {
|
||||
return;
|
||||
}
|
||||
state.timer = setInterval(loadLogs, 4000);
|
||||
}
|
||||
|
||||
function toggleLive() {
|
||||
state.liveMode = !state.liveMode;
|
||||
setLiveState();
|
||||
startPolling();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.info(
|
||||
state.liveMode ? tKey('logs.live.on.body') : tKey('logs.live.off.body'),
|
||||
state.liveMode ? tKey('logs.live.on.title') : tKey('logs.live.off.title')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.filter-chip[data-level]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
state.level = this.getAttribute('data-level');
|
||||
document.querySelectorAll('.filter-chip[data-level]').forEach(function (item) {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
this.classList.add('active');
|
||||
loadLogs();
|
||||
});
|
||||
});
|
||||
|
||||
if (logSearch) {
|
||||
logSearch.addEventListener('input', function () {
|
||||
state.search = this.value.trim();
|
||||
loadLogs();
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', function () {
|
||||
loadLogs().then(function () {
|
||||
if (!state.loadError && window.CrankUi) {
|
||||
window.CrankUi.info(tKey('logs.refresh.body'), tKey('logs.refresh.title'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (timeRangeSel) {
|
||||
timeRangeSel.value = state.period;
|
||||
timeRangeSel.addEventListener('change', function () {
|
||||
state.period = this.value;
|
||||
loadLogs();
|
||||
});
|
||||
}
|
||||
|
||||
if (liveDot) {
|
||||
liveDot.addEventListener('click', toggleLive);
|
||||
}
|
||||
|
||||
if (liveLabel) {
|
||||
liveLabel.addEventListener('click', toggleLive);
|
||||
}
|
||||
|
||||
window.addEventListener('crank:workspacechange', function () {
|
||||
state.details = {};
|
||||
state.openId = null;
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
setLiveState();
|
||||
startPolling();
|
||||
|
||||
if (window.whenWorkspacesReady) {
|
||||
window.whenWorkspacesReady().finally(loadLogs);
|
||||
} else {
|
||||
loadLogs();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
(function () {
|
||||
function initNav() {
|
||||
if (window.CrankAuth && typeof window.CrankAuth.renderShellIdentity === 'function') {
|
||||
window.CrankAuth.renderShellIdentity();
|
||||
}
|
||||
|
||||
var dropdown = document.querySelector('.user-dropdown');
|
||||
var avatar = document.querySelector('.nav-avatar');
|
||||
var hamburger = document.querySelector('.nav-hamburger');
|
||||
var mobileNav = document.querySelector('.mobile-nav');
|
||||
|
||||
if (dropdown) {
|
||||
dropdown.hidden = true;
|
||||
}
|
||||
if (mobileNav) {
|
||||
mobileNav.hidden = true;
|
||||
}
|
||||
|
||||
if (avatar && dropdown) {
|
||||
avatar.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
dropdown.hidden = !dropdown.hidden;
|
||||
});
|
||||
}
|
||||
|
||||
if (hamburger && mobileNav) {
|
||||
hamburger.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
mobileNav.hidden = !mobileNav.hidden;
|
||||
hamburger.classList.toggle('open', !mobileNav.hidden);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
if (dropdown && !event.target.closest('.user-menu')) {
|
||||
dropdown.hidden = true;
|
||||
}
|
||||
if (mobileNav && !event.target.closest('.navbar') && !event.target.closest('.mobile-nav')) {
|
||||
mobileNav.hidden = true;
|
||||
if (hamburger) {
|
||||
hamburger.classList.remove('open');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-action="logout"]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
window.CrankAuth.logout();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-action="profile"]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
if (dropdown) {
|
||||
dropdown.hidden = true;
|
||||
}
|
||||
window.location.href = ((window.CrankRoutes && window.CrankRoutes.settings) || '/settings') + '#profile';
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-action="settings-link"]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
if (dropdown) {
|
||||
dropdown.hidden = true;
|
||||
}
|
||||
window.location.href = (window.CrankRoutes && window.CrankRoutes.settings) || '/settings';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initNav);
|
||||
} else {
|
||||
initNav();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,75 @@
|
||||
(function() {
|
||||
var manifestPromise = null;
|
||||
var loadedScripts = Object.create(null);
|
||||
|
||||
function overlayBasePath() {
|
||||
return (window.APP_BASE || '/') + 'overlay/';
|
||||
}
|
||||
|
||||
function fetchManifest() {
|
||||
return fetch(overlayBasePath() + 'manifest.json', { cache: 'no-store' })
|
||||
.then(function(response) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('overlay manifest request failed');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.catch(function() {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function loadScript(path) {
|
||||
if (loadedScripts[path]) {
|
||||
return loadedScripts[path];
|
||||
}
|
||||
loadedScripts[path] = new Promise(function(resolve, reject) {
|
||||
var script = document.createElement('script');
|
||||
script.src = overlayBasePath() + path.replace(/^\/+/, '');
|
||||
script.async = false;
|
||||
script.onload = function() { resolve(); };
|
||||
script.onerror = function() { reject(new Error('overlay script load failed')); };
|
||||
document.head.appendChild(script);
|
||||
}).catch(function() {
|
||||
return null;
|
||||
});
|
||||
return loadedScripts[path];
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (manifestPromise) {
|
||||
return manifestPromise;
|
||||
}
|
||||
manifestPromise = fetchManifest().then(function(manifest) {
|
||||
if (!(manifest && manifest.slots && window.CrankUiSlots)) {
|
||||
return null;
|
||||
}
|
||||
var tasks = Object.entries(manifest.slots)
|
||||
.filter(function(entry) {
|
||||
return window.CrankUiSlots.isAllowed(entry[0]) && typeof entry[1] === 'string';
|
||||
})
|
||||
.map(function(entry) {
|
||||
return loadScript(entry[1]);
|
||||
});
|
||||
return Promise.all(tasks).then(function() {
|
||||
return manifest;
|
||||
});
|
||||
});
|
||||
return manifestPromise;
|
||||
}
|
||||
|
||||
async function render(root, context) {
|
||||
await load();
|
||||
if (window.CrankUiSlots) {
|
||||
window.CrankUiSlots.renderAll(root, context);
|
||||
}
|
||||
}
|
||||
|
||||
window.CrankOverlay = {
|
||||
load: load,
|
||||
render: render,
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,709 @@
|
||||
function initSecretsPage() {
|
||||
var state = {
|
||||
workspaceId: null,
|
||||
secrets: [],
|
||||
profiles: [],
|
||||
derived: {
|
||||
activeSecretCount: 0,
|
||||
secretsById: {},
|
||||
usageBySecret: {},
|
||||
},
|
||||
search: '',
|
||||
loading: false,
|
||||
error: '',
|
||||
modalMode: 'create',
|
||||
modalSecretId: null,
|
||||
};
|
||||
|
||||
var modal = document.getElementById('secret-modal');
|
||||
var tbody = document.getElementById('secrets-tbody');
|
||||
var cardList = document.getElementById('secrets-card-list');
|
||||
var profilesList = document.getElementById('auth-profiles-list');
|
||||
var summary = document.getElementById('secrets-summary');
|
||||
var profilesSummary = document.getElementById('secret-profiles-summary');
|
||||
var searchInput = document.getElementById('secret-search');
|
||||
var modalTitle = document.getElementById('secret-modal-title');
|
||||
var modalName = document.getElementById('secret-name');
|
||||
var modalKind = document.getElementById('secret-kind');
|
||||
var modalNote = document.getElementById('secret-modal-note');
|
||||
var modalSubmit = document.getElementById('secret-modal-submit-btn');
|
||||
var kindHint = document.getElementById('secret-kind-hint');
|
||||
var stringLabel = document.getElementById('secret-string-label');
|
||||
var stringField = document.getElementById('secret-string-value');
|
||||
var usernameField = document.getElementById('secret-username');
|
||||
var passwordField = document.getElementById('secret-password');
|
||||
var jsonField = document.getElementById('secret-json-value');
|
||||
|
||||
function tKey(key) {
|
||||
return typeof t === 'function' ? t(key) : key;
|
||||
}
|
||||
|
||||
function tfKey(key, vars) {
|
||||
return typeof tf === 'function' ? tf(key, vars) : tKey(key);
|
||||
}
|
||||
|
||||
function currentWorkspaceId() {
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
return workspace ? workspace.id : null;
|
||||
}
|
||||
|
||||
function currentWorkspace() {
|
||||
return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
}
|
||||
|
||||
function secretKindLabel(kind) {
|
||||
return tKey('secrets.kind.' + String(kind || '').toLowerCase());
|
||||
}
|
||||
|
||||
function authKindLabel(kind) {
|
||||
return tKey('secrets.auth_kind.' + String(kind || '').toLowerCase());
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
|
||||
function secretIdsForProfile(profile) {
|
||||
var config = profile && profile.config ? profile.config : {};
|
||||
if (config.bearer) return [config.bearer.secret_id];
|
||||
if (config.basic) return [config.basic.username_secret_id, config.basic.password_secret_id];
|
||||
if (config.api_key_header) return [config.api_key_header.secret_id];
|
||||
if (config.api_key_query) return [config.api_key_query.secret_id];
|
||||
return [];
|
||||
}
|
||||
|
||||
function profileSummary(profile, secretsById) {
|
||||
var config = profile && profile.config ? profile.config : {};
|
||||
if (config.bearer) {
|
||||
return tfKey('secrets.profiles.summary_bearer', {
|
||||
header: config.bearer.header_name,
|
||||
secret: secretName(secretsById, config.bearer.secret_id),
|
||||
});
|
||||
}
|
||||
if (config.basic) {
|
||||
return tfKey('secrets.profiles.summary_basic', {
|
||||
username: secretName(secretsById, config.basic.username_secret_id),
|
||||
password: secretName(secretsById, config.basic.password_secret_id),
|
||||
});
|
||||
}
|
||||
if (config.api_key_header) {
|
||||
return tfKey('secrets.profiles.summary_api_key_header', {
|
||||
header: config.api_key_header.header_name,
|
||||
secret: secretName(secretsById, config.api_key_header.secret_id),
|
||||
});
|
||||
}
|
||||
if (config.api_key_query) {
|
||||
return tfKey('secrets.profiles.summary_api_key_query', {
|
||||
param: config.api_key_query.param_name,
|
||||
secret: secretName(secretsById, config.api_key_query.secret_id),
|
||||
});
|
||||
}
|
||||
return tKey('secrets.profiles.summary_unknown');
|
||||
}
|
||||
|
||||
function secretName(secretsById, secretId) {
|
||||
var secret = secretsById[secretId];
|
||||
return secret ? secret.name : secretId;
|
||||
}
|
||||
|
||||
function buildUsageBySecret() {
|
||||
var usage = {};
|
||||
state.profiles.forEach(function (profile) {
|
||||
secretIdsForProfile(profile).forEach(function (secretId) {
|
||||
if (!secretId) return;
|
||||
if (!usage[secretId]) usage[secretId] = [];
|
||||
usage[secretId].push(profile);
|
||||
});
|
||||
});
|
||||
return usage;
|
||||
}
|
||||
|
||||
function recomputeDerivedState() {
|
||||
var secretsById = {};
|
||||
var activeSecretCount = 0;
|
||||
|
||||
state.secrets.forEach(function (secret) {
|
||||
secretsById[secret.id] = secret;
|
||||
if (secret.status === 'active') {
|
||||
activeSecretCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
state.derived = {
|
||||
activeSecretCount: activeSecretCount,
|
||||
secretsById: secretsById,
|
||||
usageBySecret: buildUsageBySecret(),
|
||||
};
|
||||
}
|
||||
|
||||
function resetModalFields() {
|
||||
modalName.value = '';
|
||||
modalKind.value = 'token';
|
||||
stringField.value = '';
|
||||
usernameField.value = '';
|
||||
passwordField.value = '';
|
||||
jsonField.value = '{\n "value": ""\n}';
|
||||
}
|
||||
|
||||
function openModal(mode, secret) {
|
||||
state.modalMode = mode;
|
||||
state.modalSecretId = secret ? secret.id : null;
|
||||
resetModalFields();
|
||||
|
||||
if (mode === 'rotate' && secret) {
|
||||
modalTitle.textContent = tKey('secrets.modal.rotate_title');
|
||||
modalName.value = secret.name;
|
||||
modalName.disabled = true;
|
||||
modalKind.value = secret.kind;
|
||||
modalKind.disabled = true;
|
||||
modalSubmit.textContent = tKey('secrets.modal.rotate_action');
|
||||
modalNote.textContent = tKey('secrets.modal.rotate_note');
|
||||
} else {
|
||||
modalTitle.textContent = tKey('secrets.modal.create_title');
|
||||
modalName.disabled = false;
|
||||
modalKind.disabled = false;
|
||||
modalSubmit.textContent = tKey('secrets.modal.create_action');
|
||||
modalNote.textContent = tKey('secrets.modal.create_note');
|
||||
}
|
||||
|
||||
updateKindFields();
|
||||
modal.classList.add('open');
|
||||
if (window.CrankDiagnostics && typeof window.CrankDiagnostics.ensureTestId === 'function') {
|
||||
window.CrankDiagnostics.ensureTestId(modal, mode === 'rotate' ? 'secret-rotate-modal' : 'secret-create-modal');
|
||||
}
|
||||
setTimeout(function () {
|
||||
if (mode === 'rotate') {
|
||||
stringField.focus();
|
||||
} else {
|
||||
modalName.focus();
|
||||
}
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove('open');
|
||||
state.modalMode = 'create';
|
||||
state.modalSecretId = null;
|
||||
}
|
||||
|
||||
function updateKindFields() {
|
||||
var kind = modalKind.value;
|
||||
document.querySelectorAll('[data-kind-field="string"]').forEach(function (element) {
|
||||
element.hidden = !(kind === 'token' || kind === 'header');
|
||||
});
|
||||
document.querySelectorAll('[data-kind-field="basic"]').forEach(function (element) {
|
||||
element.hidden = kind !== 'username_password';
|
||||
});
|
||||
document.querySelectorAll('[data-kind-field="json"]').forEach(function (element) {
|
||||
element.hidden = kind !== 'generic';
|
||||
});
|
||||
|
||||
if (kind === 'token') {
|
||||
stringLabel.textContent = tKey('secrets.modal.value');
|
||||
} else if (kind === 'header') {
|
||||
stringLabel.textContent = tKey('secrets.modal.header_value');
|
||||
} else {
|
||||
stringLabel.textContent = tKey('secrets.modal.value');
|
||||
}
|
||||
kindHint.textContent = tKey('secrets.hint.' + kind);
|
||||
}
|
||||
|
||||
function buildSecretValue() {
|
||||
var kind = modalKind.value;
|
||||
if (kind === 'token' || kind === 'header') {
|
||||
var value = stringField.value.trim();
|
||||
if (!value) {
|
||||
throw new Error(tKey('secrets.validation.value_required'));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (kind === 'username_password') {
|
||||
var username = usernameField.value.trim();
|
||||
var password = passwordField.value;
|
||||
if (!username || !password) {
|
||||
throw new Error(tKey('secrets.validation.username_password_required'));
|
||||
}
|
||||
return {
|
||||
username: username,
|
||||
password: password,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(jsonField.value);
|
||||
} catch (_error) {
|
||||
throw new Error(tKey('secrets.validation.json_invalid'));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSecrets() {
|
||||
var usage = state.derived.usageBySecret;
|
||||
var query = state.search.toLowerCase();
|
||||
var rows = state.secrets.filter(function (secret) {
|
||||
return !query || secret.name.toLowerCase().includes(query) || String(secret.kind || '').toLowerCase().includes(query);
|
||||
});
|
||||
|
||||
summary.textContent = tfKey('secrets.active.subtitle', {
|
||||
active: state.derived.activeSecretCount,
|
||||
total: state.secrets.length,
|
||||
});
|
||||
|
||||
window.CrankDom.clear(tbody);
|
||||
if (cardList) {
|
||||
window.CrankDom.clear(cardList);
|
||||
}
|
||||
|
||||
if (state.loading && state.secrets.length === 0) {
|
||||
var loadingRow = document.createElement('tr');
|
||||
var loadingCell = document.createElement('td');
|
||||
loadingCell.colSpan = 7;
|
||||
loadingCell.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
|
||||
loadingCell.textContent = tKey('secrets.loading');
|
||||
loadingRow.appendChild(loadingCell);
|
||||
tbody.appendChild(loadingRow);
|
||||
renderSecretCards([], tKey('secrets.loading'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
var errorRow = document.createElement('tr');
|
||||
var errorCell = document.createElement('td');
|
||||
errorCell.colSpan = 7;
|
||||
errorCell.style.cssText = 'text-align:center;padding:36px;color:var(--danger,#f85149);';
|
||||
errorCell.textContent = state.error;
|
||||
errorRow.appendChild(errorCell);
|
||||
tbody.appendChild(errorRow);
|
||||
renderSecretCards([], state.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
var emptyRow = document.createElement('tr');
|
||||
var emptyCell = document.createElement('td');
|
||||
emptyCell.colSpan = 7;
|
||||
emptyCell.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
|
||||
emptyCell.textContent = state.secrets.length ? tKey('secrets.empty.search') : tKey('secrets.empty.none');
|
||||
emptyRow.appendChild(emptyCell);
|
||||
tbody.appendChild(emptyRow);
|
||||
renderSecretCards(rows);
|
||||
return;
|
||||
}
|
||||
|
||||
rows.forEach(function (secret) {
|
||||
var tr = document.createElement('tr');
|
||||
var references = usage[secret.id] || [];
|
||||
|
||||
var nameCell = document.createElement('td');
|
||||
nameCell.className = 'col-name';
|
||||
nameCell.textContent = secret.name;
|
||||
tr.appendChild(nameCell);
|
||||
|
||||
var kindCell = document.createElement('td');
|
||||
kindCell.textContent = secretKindLabel(secret.kind);
|
||||
tr.appendChild(kindCell);
|
||||
|
||||
var versionCell = document.createElement('td');
|
||||
versionCell.className = 'col-mono';
|
||||
versionCell.textContent = 'v' + secret.current_version;
|
||||
tr.appendChild(versionCell);
|
||||
|
||||
var lastUsedCell = document.createElement('td');
|
||||
lastUsedCell.textContent = secret.last_used_at ? formatDate(secret.last_used_at) : tKey('secrets.last_used.never');
|
||||
tr.appendChild(lastUsedCell);
|
||||
|
||||
var usedByCell = document.createElement('td');
|
||||
usedByCell.textContent = window.tPlural('secrets.used_by_count', references.length, { count: references.length });
|
||||
tr.appendChild(usedByCell);
|
||||
|
||||
var statusCell = document.createElement('td');
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge ' + (secret.status === 'disabled' ? 'badge-revoked' : 'badge-active');
|
||||
badge.textContent = tKey('secrets.status.' + secret.status);
|
||||
statusCell.appendChild(badge);
|
||||
tr.appendChild(statusCell);
|
||||
|
||||
var actions = document.createElement('td');
|
||||
actions.className = 'col-actions';
|
||||
var rotateButton = document.createElement('button');
|
||||
rotateButton.type = 'button';
|
||||
rotateButton.className = 'btn-secondary table-action-btn';
|
||||
rotateButton.textContent = tKey('secrets.action.rotate');
|
||||
rotateButton.setAttribute('data-testid', 'secret-rotate-action');
|
||||
rotateButton.setAttribute('data-secret-id', secret.id);
|
||||
rotateButton.addEventListener('click', function () {
|
||||
openModal('rotate', secret);
|
||||
});
|
||||
actions.appendChild(rotateButton);
|
||||
|
||||
var deleteButton = document.createElement('button');
|
||||
deleteButton.type = 'button';
|
||||
deleteButton.className = 'btn-danger table-action-btn';
|
||||
deleteButton.textContent = tKey('secrets.action.delete');
|
||||
deleteButton.setAttribute('data-testid', 'secret-delete-action');
|
||||
deleteButton.setAttribute('data-secret-id', secret.id);
|
||||
deleteButton.addEventListener('click', async function () {
|
||||
await deleteSecret(secret);
|
||||
});
|
||||
actions.appendChild(deleteButton);
|
||||
tr.appendChild(actions);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
renderSecretCards(rows);
|
||||
}
|
||||
|
||||
function renderSecretCards(rows, statusText) {
|
||||
if (!cardList) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.CrankDom.clear(cardList);
|
||||
|
||||
if (statusText && !rows.length) {
|
||||
var messageCard = document.createElement('div');
|
||||
messageCard.className = 'resource-card';
|
||||
var message = document.createElement('div');
|
||||
message.className = 'resource-meta-value';
|
||||
message.textContent = statusText;
|
||||
message.style.color = state.error ? 'var(--danger,#f85149)' : 'var(--text-muted)';
|
||||
messageCard.appendChild(message);
|
||||
cardList.appendChild(messageCard);
|
||||
return;
|
||||
}
|
||||
|
||||
rows.forEach(function(secret) {
|
||||
var references = state.derived.usageBySecret[secret.id] || [];
|
||||
var card = document.createElement('div');
|
||||
card.className = 'resource-card';
|
||||
|
||||
var header = document.createElement('div');
|
||||
header.className = 'resource-card-header';
|
||||
var headerBody = document.createElement('div');
|
||||
var title = document.createElement('div');
|
||||
title.className = 'resource-card-title';
|
||||
title.textContent = secret.name;
|
||||
var subtitle = document.createElement('div');
|
||||
subtitle.className = 'resource-card-subtitle';
|
||||
subtitle.textContent = secretKindLabel(secret.kind);
|
||||
headerBody.appendChild(title);
|
||||
headerBody.appendChild(subtitle);
|
||||
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'resource-card-actions';
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge ' + (secret.status === 'disabled' ? 'badge-revoked' : 'badge-active');
|
||||
badge.textContent = tKey('secrets.status.' + secret.status);
|
||||
actions.appendChild(badge);
|
||||
header.appendChild(headerBody);
|
||||
header.appendChild(actions);
|
||||
card.appendChild(header);
|
||||
|
||||
var metaGrid = document.createElement('div');
|
||||
metaGrid.className = 'resource-meta-grid';
|
||||
metaGrid.appendChild(buildSecretMetaItem('secrets.th.version', 'v' + secret.current_version));
|
||||
metaGrid.appendChild(buildSecretMetaItem(
|
||||
'secrets.th.last_used',
|
||||
secret.last_used_at ? formatDate(secret.last_used_at) : tKey('secrets.last_used.never')
|
||||
));
|
||||
metaGrid.appendChild(buildSecretMetaItem(
|
||||
'secrets.th.used_by',
|
||||
window.tPlural('secrets.used_by_count', references.length, { count: references.length })
|
||||
));
|
||||
card.appendChild(metaGrid);
|
||||
|
||||
var actionRow = document.createElement('div');
|
||||
actionRow.className = 'resource-card-actions';
|
||||
var rotateButton = document.createElement('button');
|
||||
rotateButton.className = 'btn-secondary';
|
||||
rotateButton.type = 'button';
|
||||
rotateButton.textContent = tKey('secrets.action.rotate');
|
||||
rotateButton.addEventListener('click', function() {
|
||||
openModal('rotate', secret);
|
||||
});
|
||||
actionRow.appendChild(rotateButton);
|
||||
|
||||
var deleteButton = document.createElement('button');
|
||||
deleteButton.className = 'btn-secondary';
|
||||
deleteButton.type = 'button';
|
||||
deleteButton.textContent = tKey('secrets.action.delete');
|
||||
deleteButton.addEventListener('click', async function() {
|
||||
await deleteSecret(secret);
|
||||
});
|
||||
actionRow.appendChild(deleteButton);
|
||||
card.appendChild(actionRow);
|
||||
|
||||
cardList.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function buildSecretMetaItem(labelKey, valueText) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'resource-meta-item';
|
||||
var label = document.createElement('div');
|
||||
label.className = 'resource-meta-label';
|
||||
label.textContent = tKey(labelKey);
|
||||
var value = document.createElement('div');
|
||||
value.className = 'resource-meta-value';
|
||||
value.textContent = valueText;
|
||||
item.appendChild(label);
|
||||
item.appendChild(value);
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderProfiles() {
|
||||
var usage = state.derived.usageBySecret;
|
||||
var secretsById = state.derived.secretsById;
|
||||
|
||||
profilesSummary.textContent = window.tPlural(
|
||||
'secrets.profiles.subtitle_count',
|
||||
state.profiles.length,
|
||||
{ count: state.profiles.length }
|
||||
);
|
||||
|
||||
window.CrankDom.clear(profilesList);
|
||||
|
||||
if (state.error) {
|
||||
profilesList.appendChild(window.CrankDom.createEmptyState(
|
||||
tKey('secrets.profiles.error_title'),
|
||||
state.error
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.loading && state.profiles.length === 0) {
|
||||
profilesList.appendChild(window.CrankDom.createEmptyState(
|
||||
tKey('secrets.profiles.loading_title'),
|
||||
tKey('secrets.profiles.loading_body')
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.profiles.length) {
|
||||
profilesList.appendChild(window.CrankDom.createEmptyState(
|
||||
tKey('secrets.profiles.empty_title'),
|
||||
tKey('secrets.profiles.empty_body')
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
state.profiles.forEach(function (profile) {
|
||||
var secretIds = secretIdsForProfile(profile);
|
||||
var card = document.createElement('div');
|
||||
card.className = 'resource-card';
|
||||
|
||||
var header = document.createElement('div');
|
||||
header.className = 'resource-card-header';
|
||||
var headerBody = document.createElement('div');
|
||||
|
||||
var title = document.createElement('div');
|
||||
title.className = 'resource-card-title';
|
||||
title.textContent = profile.name;
|
||||
headerBody.appendChild(title);
|
||||
|
||||
var subtitle = document.createElement('div');
|
||||
subtitle.className = 'resource-card-subtitle';
|
||||
subtitle.textContent = profileSummary(profile, secretsById);
|
||||
headerBody.appendChild(subtitle);
|
||||
|
||||
var pillRow = document.createElement('div');
|
||||
pillRow.className = 'resource-pill-row';
|
||||
var statusPill = document.createElement('span');
|
||||
statusPill.className = 'resource-status-pill active';
|
||||
statusPill.textContent = authKindLabel(profile.kind);
|
||||
pillRow.appendChild(statusPill);
|
||||
headerBody.appendChild(pillRow);
|
||||
header.appendChild(headerBody);
|
||||
card.appendChild(header);
|
||||
|
||||
var metaGrid = document.createElement('div');
|
||||
metaGrid.className = 'resource-meta-grid';
|
||||
[
|
||||
[tKey('secrets.profiles.meta.created'), formatDate(profile.created_at)],
|
||||
[tKey('secrets.profiles.meta.updated'), formatDate(profile.updated_at)]
|
||||
].forEach(function(entry) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'resource-meta-item';
|
||||
var label = document.createElement('div');
|
||||
label.className = 'resource-meta-label';
|
||||
label.textContent = entry[0];
|
||||
var value = document.createElement('div');
|
||||
value.className = 'resource-meta-value';
|
||||
value.textContent = entry[1];
|
||||
item.appendChild(label);
|
||||
item.appendChild(value);
|
||||
metaGrid.appendChild(item);
|
||||
});
|
||||
card.appendChild(metaGrid);
|
||||
|
||||
var detail = document.createElement('div');
|
||||
detail.className = 'resource-detail-block';
|
||||
var detailTitle = document.createElement('div');
|
||||
detailTitle.className = 'resource-detail-title';
|
||||
detailTitle.textContent = tKey('secrets.profiles.references');
|
||||
detail.appendChild(detailTitle);
|
||||
|
||||
var refList = document.createElement('div');
|
||||
refList.className = 'secret-ref-list';
|
||||
secretIds.forEach(function(secretId) {
|
||||
var users = usage[secretId] || [];
|
||||
var pill = document.createElement('span');
|
||||
pill.className = 'secret-ref-pill';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = secretName(secretsById, secretId);
|
||||
var count = document.createElement('span');
|
||||
count.textContent = tfKey('secrets.profiles.reference_count', { count: users.length });
|
||||
pill.appendChild(strong);
|
||||
pill.appendChild(count);
|
||||
refList.appendChild(pill);
|
||||
});
|
||||
detail.appendChild(refList);
|
||||
card.appendChild(detail);
|
||||
|
||||
profilesList.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
state.workspaceId = currentWorkspaceId();
|
||||
state.loading = true;
|
||||
state.error = '';
|
||||
renderSecrets();
|
||||
renderProfiles();
|
||||
|
||||
if (!state.workspaceId) {
|
||||
state.secrets = [];
|
||||
state.profiles = [];
|
||||
recomputeDerivedState();
|
||||
state.loading = false;
|
||||
state.error = tKey('secrets.error.workspace');
|
||||
renderSecrets();
|
||||
renderProfiles();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
window.CrankApi.listSecrets(state.workspaceId),
|
||||
window.CrankApi.listAuthProfiles(state.workspaceId),
|
||||
]);
|
||||
state.secrets = (results[0] && results[0].items) || [];
|
||||
state.profiles = (results[1] && results[1].items) || [];
|
||||
recomputeDerivedState();
|
||||
} catch (error) {
|
||||
state.error = error.message || tKey('secrets.error.load');
|
||||
state.secrets = [];
|
||||
state.profiles = [];
|
||||
recomputeDerivedState();
|
||||
} finally {
|
||||
state.loading = false;
|
||||
renderSecrets();
|
||||
renderProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSecret(secret) {
|
||||
if (!confirm(tfKey('secrets.confirm.delete', { name: secret.name }))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.CrankApi.deleteSecret(state.workspaceId, secret.id);
|
||||
await load();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tfKey('secrets.toast.delete_message', { name: secret.name }),
|
||||
tKey('secrets.toast.delete_title')
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || tKey('secrets.toast.delete_error_message'),
|
||||
tKey('secrets.toast.delete_error_title')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitModal() {
|
||||
var kind = modalKind.value;
|
||||
var value = buildSecretValue();
|
||||
modalSubmit.disabled = true;
|
||||
modalSubmit.textContent = state.modalMode === 'rotate'
|
||||
? tKey('secrets.modal.rotating')
|
||||
: tKey('secrets.modal.creating');
|
||||
|
||||
try {
|
||||
if (state.modalMode === 'rotate') {
|
||||
await window.CrankApi.rotateSecret(state.workspaceId, state.modalSecretId, { value: value });
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tKey('secrets.toast.rotate_message'),
|
||||
tKey('secrets.toast.rotate_title')
|
||||
);
|
||||
}
|
||||
} else {
|
||||
var name = modalName.value.trim();
|
||||
if (!name) {
|
||||
throw new Error(tKey('secrets.validation.name_required'));
|
||||
}
|
||||
await window.CrankApi.createSecret(state.workspaceId, {
|
||||
name: name,
|
||||
kind: kind,
|
||||
value: value,
|
||||
});
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tKey('secrets.toast.create_message'),
|
||||
tKey('secrets.toast.create_title')
|
||||
);
|
||||
}
|
||||
}
|
||||
closeModal();
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || tKey(state.modalMode === 'rotate' ? 'secrets.toast.rotate_error_message' : 'secrets.toast.create_error_message'),
|
||||
tKey(state.modalMode === 'rotate' ? 'secrets.toast.rotate_error_title' : 'secrets.toast.create_error_title')
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
modalSubmit.disabled = false;
|
||||
modalSubmit.textContent = state.modalMode === 'rotate'
|
||||
? tKey('secrets.modal.rotate_action')
|
||||
: tKey('secrets.modal.create_action');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btn-create-secret').addEventListener('click', function () {
|
||||
openModal('create');
|
||||
});
|
||||
document.getElementById('secret-modal-close-btn').addEventListener('click', closeModal);
|
||||
document.getElementById('secret-modal-cancel-btn').addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', function (event) {
|
||||
if (event.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
modalKind.addEventListener('change', updateKindFields);
|
||||
modalSubmit.addEventListener('click', function () {
|
||||
void submitModal();
|
||||
});
|
||||
searchInput.addEventListener('input', function (event) {
|
||||
state.search = event.target.value || '';
|
||||
renderSecrets();
|
||||
});
|
||||
document.addEventListener('workspace:changed', function () {
|
||||
void load();
|
||||
});
|
||||
|
||||
updateKindFields();
|
||||
void load();
|
||||
}
|
||||
|
||||
if (window.CrankDiagnostics && typeof window.CrankDiagnostics.bootstrap === 'function') {
|
||||
window.CrankDiagnostics.bootstrap('secrets', initSecretsPage);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', initSecretsPage);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
var settingsSession = null;
|
||||
var settingsCapabilities = null;
|
||||
function tKey(key) {
|
||||
return typeof t === 'function' ? t(key) : key;
|
||||
}
|
||||
function tfKey(key, vars) {
|
||||
return typeof tf === 'function' ? tf(key, vars) : key;
|
||||
}
|
||||
|
||||
function settingsWorkspaceId() {
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
return workspace ? workspace.id : null;
|
||||
}
|
||||
|
||||
function initials(displayName, email) {
|
||||
var source = displayName || email || 'Crank';
|
||||
return source
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map(function(part) { return part.charAt(0).toUpperCase(); })
|
||||
.join('') || 'CR';
|
||||
}
|
||||
|
||||
function setStatus(id, text, isError) {
|
||||
var node = document.getElementById(id);
|
||||
if (!node) return;
|
||||
node.textContent = text || '';
|
||||
node.style.color = text
|
||||
? (isError ? 'var(--red)' : 'var(--green)')
|
||||
: 'var(--text-muted)';
|
||||
}
|
||||
|
||||
function titleCaseRole(role) {
|
||||
var normalized = String(role || 'viewer').toLowerCase();
|
||||
if (normalized === 'operator') return tKey('workspace_setup.role.operator');
|
||||
if (normalized === 'owner') return tKey('workspace_setup.role.owner');
|
||||
if (normalized === 'admin') return tKey('workspace_setup.role.admin');
|
||||
if (normalized === 'viewer') return tKey('workspace_setup.role.viewer');
|
||||
return normalized.replace(/^\w/, function(character) {
|
||||
return character.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
function populateCurrentSession(session) {
|
||||
var summary = document.getElementById('settings-session-summary');
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(session && session.user)) {
|
||||
summary.textContent = tKey('settings.session.unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
var membership = null;
|
||||
if (Array.isArray(session.memberships)) {
|
||||
membership = session.memberships.find(function(item) {
|
||||
return item.workspace && item.workspace.id === session.current_workspace_id;
|
||||
}) || session.memberships[0] || null;
|
||||
}
|
||||
|
||||
var workspaceLabel = membership && membership.workspace
|
||||
? (membership.workspace.display_name || membership.workspace.slug || membership.workspace.id)
|
||||
: tKey('settings.session.current_workspace');
|
||||
var roleLabel = membership ? titleCaseRole(membership.role) : tKey('workspace_setup.role.viewer');
|
||||
summary.textContent = tfKey('settings.session.summary', {
|
||||
email: session.user.email,
|
||||
role: roleLabel,
|
||||
workspace: workspaceLabel,
|
||||
});
|
||||
}
|
||||
|
||||
function capabilityLabel(key) {
|
||||
return tKey('settings.capability.' + key);
|
||||
}
|
||||
|
||||
function populateNotificationsCapabilityHint(capabilities) {
|
||||
var title = document.getElementById('settings-notifications-capability-title');
|
||||
var body = document.getElementById('settings-notifications-capability-body');
|
||||
if (!title || !body || !capabilities) {
|
||||
return;
|
||||
}
|
||||
|
||||
title.textContent = tKey('settings.notifications.capability_title_' + capabilities.edition);
|
||||
body.textContent = tfKey('settings.notifications.capability_body_' + capabilities.edition, {
|
||||
edition: capabilities.edition,
|
||||
});
|
||||
}
|
||||
|
||||
function populateCapabilities(capabilities) {
|
||||
var title = document.getElementById('settings-security-capability-title');
|
||||
var body = document.getElementById('settings-security-capability-body');
|
||||
if (!title || !body || !capabilities) {
|
||||
return;
|
||||
}
|
||||
|
||||
var protocols = (capabilities.supported_protocols || []).map(capabilityLabel).join(', ');
|
||||
var accessModes = (capabilities.machine_access_modes || []).map(capabilityLabel).join(', ');
|
||||
var securityLevels = (capabilities.supported_security_levels || []).map(capabilityLabel).join(', ');
|
||||
var limits = capabilities.limits || {};
|
||||
|
||||
title.textContent = tKey('settings.security.capability_title_' + capabilities.edition);
|
||||
body.textContent = tfKey('settings.security.capability_summary', {
|
||||
protocols: protocols || capabilityLabel('none'),
|
||||
access_modes: accessModes || capabilityLabel('none'),
|
||||
security_levels: securityLevels || capabilityLabel('none'),
|
||||
max_workspaces: limits.max_workspaces == null ? capabilityLabel('unlimited') : String(limits.max_workspaces),
|
||||
max_users: limits.max_users_per_workspace == null ? capabilityLabel('unlimited') : String(limits.max_users_per_workspace),
|
||||
max_agents: limits.max_agents_per_workspace == null ? capabilityLabel('unlimited') : String(limits.max_agents_per_workspace),
|
||||
});
|
||||
populateNotificationsCapabilityHint(capabilities);
|
||||
}
|
||||
|
||||
function populateProfile(session) {
|
||||
if (!session || !session.user) {
|
||||
return;
|
||||
}
|
||||
|
||||
var user = session.user;
|
||||
document.getElementById('profile-avatar').textContent = initials(user.display_name, user.email);
|
||||
document.getElementById('profile-display-name').textContent = user.display_name || 'Crank';
|
||||
document.getElementById('profile-email').textContent = user.email || '';
|
||||
|
||||
var firstName = document.getElementById('field-firstname');
|
||||
var email = document.getElementById('field-email');
|
||||
if (firstName) {
|
||||
firstName.value = user.display_name || '';
|
||||
}
|
||||
if (email) {
|
||||
email.value = user.email || '';
|
||||
}
|
||||
|
||||
populateCurrentSession(session);
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
if (!window.CrankAuth || !window.CrankApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
settingsSession = await window.CrankAuth.fetchSession(true);
|
||||
populateProfile(settingsSession);
|
||||
}
|
||||
|
||||
async function loadCapabilities() {
|
||||
if (!window.CrankApi || typeof window.CrankApi.getCapabilities !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
settingsCapabilities = await window.CrankApi.getCapabilities();
|
||||
populateCapabilities(settingsCapabilities);
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderOverlaySlots() {
|
||||
if (!window.CrankOverlay || typeof window.CrankOverlay.render !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
|
||||
await window.CrankOverlay.render(document, {
|
||||
workspace: window.getCurrentWorkspace ? window.getCurrentWorkspace() : null,
|
||||
capabilities: settingsCapabilities,
|
||||
locale: localStorage.getItem('crank_lang') || 'en',
|
||||
});
|
||||
}
|
||||
|
||||
function bindProfileSave() {
|
||||
var button = document.getElementById('settings-profile-save-btn');
|
||||
if (!button || button.dataset.bound === 'true') {
|
||||
return;
|
||||
}
|
||||
button.dataset.bound = 'true';
|
||||
|
||||
button.addEventListener('click', async function() {
|
||||
var original = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = tKey('settings.profile.saving');
|
||||
setStatus('settings-profile-status', '', false);
|
||||
|
||||
try {
|
||||
var session = await window.CrankApi.updateProfile({
|
||||
display_name: document.getElementById('field-firstname').value.trim(),
|
||||
email: document.getElementById('field-email').value.trim(),
|
||||
});
|
||||
settingsSession = session;
|
||||
if (window.CrankAuth && typeof window.CrankAuth.replaceSession === 'function') {
|
||||
window.CrankAuth.replaceSession(session);
|
||||
}
|
||||
populateProfile(session);
|
||||
setStatus('settings-profile-status', tKey('settings.profile.saved_status'), false);
|
||||
button.textContent = tKey('settings.profile.saved');
|
||||
} catch (error) {
|
||||
setStatus('settings-profile-status', error.message || tKey('settings.profile.save_error'), true);
|
||||
button.textContent = original;
|
||||
} finally {
|
||||
setTimeout(function() {
|
||||
button.disabled = false;
|
||||
button.textContent = original;
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindPasswordSave() {
|
||||
var button = document.getElementById('settings-password-save-btn');
|
||||
if (!button || button.dataset.bound === 'true') {
|
||||
return;
|
||||
}
|
||||
button.dataset.bound = 'true';
|
||||
|
||||
button.addEventListener('click', async function() {
|
||||
var currentPassword = document.getElementById('security-current-password').value;
|
||||
var newPassword = document.getElementById('security-new-password').value;
|
||||
var confirmPassword = document.getElementById('security-confirm-password').value;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setStatus('settings-password-status', tKey('settings.security.mismatch'), true);
|
||||
return;
|
||||
}
|
||||
|
||||
var original = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = tKey('settings.profile.saving');
|
||||
setStatus('settings-password-status', '', false);
|
||||
|
||||
try {
|
||||
await window.CrankApi.changePassword({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
document.getElementById('security-current-password').value = '';
|
||||
document.getElementById('security-new-password').value = '';
|
||||
document.getElementById('security-confirm-password').value = '';
|
||||
setStatus('settings-password-status', tKey('settings.security.saved'), false);
|
||||
button.textContent = tKey('settings.profile.saved');
|
||||
} catch (error) {
|
||||
setStatus('settings-password-status', error.message || tKey('settings.security.save_error'), true);
|
||||
button.textContent = original;
|
||||
} finally {
|
||||
setTimeout(function() {
|
||||
button.disabled = false;
|
||||
button.textContent = original;
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function injectLanguageSwitcher() {
|
||||
var profileSection = document.getElementById('section-profile');
|
||||
if (!profileSection) {
|
||||
return;
|
||||
}
|
||||
|
||||
var cardBody = profileSection.querySelector('.section-card-body');
|
||||
if (!cardBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('html/fragments/lang-switcher.html')
|
||||
.then(function(response) { return response.text(); })
|
||||
.then(function(html) {
|
||||
var template = document.createElement('template');
|
||||
template.innerHTML = html;
|
||||
var fragment = template.content.cloneNode(true);
|
||||
var saveRow = cardBody.querySelector('div[style*="justify-content:flex-end"]');
|
||||
if (saveRow) {
|
||||
saveRow.parentNode.insertBefore(fragment, saveRow);
|
||||
} else {
|
||||
cardBody.appendChild(fragment);
|
||||
}
|
||||
if (typeof applyLang === 'function') {
|
||||
applyLang();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindSectionNavigation() {
|
||||
var navItems = document.querySelectorAll('.settings-nav-item[data-section]');
|
||||
navItems.forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
navItems.forEach(function (item) { item.classList.remove('active'); });
|
||||
this.classList.add('active');
|
||||
var target = document.getElementById('section-' + this.dataset.section);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var initialSection = window.location.hash ? window.location.hash.slice(1) : 'profile';
|
||||
var initialButton = document.querySelector('.settings-nav-item[data-section="' + initialSection + '"]')
|
||||
|| document.querySelector('.settings-nav-item[data-section="profile"]');
|
||||
if (initialButton) {
|
||||
setTimeout(function () { initialButton.click(); }, 50);
|
||||
}
|
||||
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
var section = entry.target.id.replace('section-', '');
|
||||
document.querySelectorAll('.settings-nav-item[data-section]').forEach(function (button) {
|
||||
button.classList.toggle('active', button.dataset.section === section);
|
||||
});
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
|
||||
document.querySelectorAll('[id^="section-"]').forEach(function (section) {
|
||||
if (!section.hidden) {
|
||||
observer.observe(section);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWorkspaceSettings() {
|
||||
if (!window.CrankApi || !window.whenWorkspacesReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
await window.whenWorkspacesReady();
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var record = await window.CrankApi.getWorkspace(workspace.id);
|
||||
var item = record.workspace;
|
||||
var settings = item.settings || {};
|
||||
|
||||
document.getElementById('settings-ws-slug').value = item.slug || '';
|
||||
document.getElementById('settings-ws-display-name').value = item.display_name || '';
|
||||
document.getElementById('settings-ws-description').value = settings.description || '';
|
||||
|
||||
var saveButton = document.getElementById('settings-ws-save-btn');
|
||||
if (saveButton.dataset.bound === 'true') {
|
||||
return;
|
||||
}
|
||||
saveButton.dataset.bound = 'true';
|
||||
|
||||
saveButton.addEventListener('click', async function () {
|
||||
var saveButton = this;
|
||||
var original = saveButton.textContent;
|
||||
saveButton.disabled = true;
|
||||
saveButton.textContent = tKey('settings.workspace.saving');
|
||||
|
||||
try {
|
||||
await window.CrankApi.updateWorkspace(item.id, {
|
||||
slug: document.getElementById('settings-ws-slug').value.trim(),
|
||||
display_name: document.getElementById('settings-ws-display-name').value.trim(),
|
||||
settings: {
|
||||
description: document.getElementById('settings-ws-description').value.trim(),
|
||||
color: (workspace.settings && workspace.settings.color) || '#0d9488',
|
||||
},
|
||||
});
|
||||
|
||||
if (window.refreshWorkspaces) {
|
||||
var workspaces = await window.refreshWorkspaces();
|
||||
var updated = workspaces.find(function (entry) { return entry.id === item.id; });
|
||||
if (updated && window.setCurrentWorkspace) {
|
||||
await window.setCurrentWorkspace(updated);
|
||||
}
|
||||
}
|
||||
|
||||
saveButton.textContent = tKey('settings.workspace.saved');
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message || tKey('settings.workspace.save_error'), tKey('settings.workspace.save_error_title'));
|
||||
}
|
||||
saveButton.textContent = original;
|
||||
} finally {
|
||||
setTimeout(function () {
|
||||
saveButton.disabled = false;
|
||||
saveButton.textContent = original;
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
async function initSettingsPage() {
|
||||
injectLanguageSwitcher();
|
||||
bindSectionNavigation();
|
||||
await loadProfile();
|
||||
await loadCapabilities();
|
||||
bindProfileSave();
|
||||
bindPasswordSave();
|
||||
await loadWorkspaceSettings();
|
||||
await renderOverlaySlots();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
void initSettingsPage();
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
(function() {
|
||||
var allowedSlots = {
|
||||
'settings.sso_panel': true,
|
||||
'settings.totp_panel': true,
|
||||
'settings.audit_panel': true,
|
||||
'settings.billing_panel': true,
|
||||
'wizard.protocol_cards.graphql': true,
|
||||
'wizard.protocol_cards.grpc': true,
|
||||
'wizard.protocol_cards.soap': true,
|
||||
'wizard.protocol_cards.websocket': true,
|
||||
};
|
||||
|
||||
var handlers = Object.create(null);
|
||||
|
||||
function noop() {}
|
||||
|
||||
function isAllowed(slotId) {
|
||||
return !!allowedSlots[slotId];
|
||||
}
|
||||
|
||||
function register(slotId, render) {
|
||||
if (!isAllowed(slotId) || typeof render !== 'function') {
|
||||
return false;
|
||||
}
|
||||
handlers[slotId] = render;
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderInto(target, slotId, context) {
|
||||
if (!(target && slotId && isAllowed(slotId))) {
|
||||
return;
|
||||
}
|
||||
var handler = handlers[slotId] || noop;
|
||||
handler(target, context || {});
|
||||
}
|
||||
|
||||
function renderAll(root, context) {
|
||||
var scope = root || document;
|
||||
scope.querySelectorAll('[data-slot]').forEach(function(target) {
|
||||
renderInto(target, target.getAttribute('data-slot'), context);
|
||||
});
|
||||
}
|
||||
|
||||
function knownSlots() {
|
||||
return Object.keys(allowedSlots);
|
||||
}
|
||||
|
||||
window.CrankUiSlots = {
|
||||
isAllowed: isAllowed,
|
||||
knownSlots: knownSlots,
|
||||
register: register,
|
||||
renderAll: renderAll,
|
||||
renderInto: renderInto,
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,105 @@
|
||||
(function() {
|
||||
var CONTAINER_ID = 'crank-toast-container';
|
||||
var nextToastId = 0;
|
||||
|
||||
function ensureContainer() {
|
||||
var container = document.getElementById(CONTAINER_ID);
|
||||
if (container) return container;
|
||||
|
||||
container = document.createElement('div');
|
||||
container.id = CONTAINER_ID;
|
||||
container.className = 'toast-stack';
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
function dismiss(toast) {
|
||||
if (!toast || !toast.parentNode) return;
|
||||
toast.classList.add('closing');
|
||||
window.setTimeout(function() {
|
||||
if (toast.parentNode) toast.parentNode.removeChild(toast);
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function notify(options) {
|
||||
var config = options || {};
|
||||
var duration = config.duration === 0 ? 0 : (config.duration || 3600);
|
||||
var toast = document.createElement('div');
|
||||
var copy = document.createElement('div');
|
||||
var close = document.createElement('button');
|
||||
var closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
var lineA = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
var lineB = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
|
||||
toast.id = 'toast_' + (++nextToastId);
|
||||
toast.className = 'toast-card toast-' + (config.type || 'info');
|
||||
copy.className = 'toast-copy';
|
||||
if (config.title) {
|
||||
var title = document.createElement('div');
|
||||
title.className = 'toast-title';
|
||||
title.textContent = config.title;
|
||||
copy.appendChild(title);
|
||||
}
|
||||
if (config.message) {
|
||||
var message = document.createElement('div');
|
||||
message.className = 'toast-message';
|
||||
message.textContent = config.message;
|
||||
copy.appendChild(message);
|
||||
}
|
||||
|
||||
close.className = 'toast-close';
|
||||
close.type = 'button';
|
||||
close.setAttribute('aria-label', 'Dismiss');
|
||||
|
||||
closeIcon.setAttribute('width', '12');
|
||||
closeIcon.setAttribute('height', '12');
|
||||
closeIcon.setAttribute('viewBox', '0 0 12 12');
|
||||
closeIcon.setAttribute('fill', 'none');
|
||||
closeIcon.setAttribute('stroke', 'currentColor');
|
||||
closeIcon.setAttribute('stroke-width', '1.8');
|
||||
closeIcon.setAttribute('stroke-linecap', 'round');
|
||||
|
||||
lineA.setAttribute('x1', '1');
|
||||
lineA.setAttribute('y1', '1');
|
||||
lineA.setAttribute('x2', '11');
|
||||
lineA.setAttribute('y2', '11');
|
||||
lineB.setAttribute('x1', '11');
|
||||
lineB.setAttribute('y1', '1');
|
||||
lineB.setAttribute('x2', '1');
|
||||
lineB.setAttribute('y2', '11');
|
||||
|
||||
closeIcon.appendChild(lineA);
|
||||
closeIcon.appendChild(lineB);
|
||||
close.appendChild(closeIcon);
|
||||
|
||||
toast.appendChild(copy);
|
||||
toast.appendChild(close);
|
||||
|
||||
close.addEventListener('click', function() {
|
||||
dismiss(toast);
|
||||
});
|
||||
|
||||
ensureContainer().appendChild(toast);
|
||||
|
||||
if (duration > 0) {
|
||||
window.setTimeout(function() {
|
||||
dismiss(toast);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return toast.id;
|
||||
}
|
||||
|
||||
window.CrankUi = {
|
||||
notify: notify,
|
||||
success: function(message, title) {
|
||||
return notify({ type: 'success', title: title || 'Done', message: message });
|
||||
},
|
||||
error: function(message, title) {
|
||||
return notify({ type: 'error', title: title || 'Request failed', message: message });
|
||||
},
|
||||
info: function(message, title) {
|
||||
return notify({ type: 'info', title: title || 'Info', message: message });
|
||||
}
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,549 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var state = {
|
||||
period: '7d',
|
||||
workspaceId: null,
|
||||
usage: null,
|
||||
derived: {
|
||||
localizedOperations: [],
|
||||
normalizedTimeline: [],
|
||||
timelineMaxValue: 0,
|
||||
totalCalls: 0,
|
||||
},
|
||||
loading: false,
|
||||
loadError: '',
|
||||
};
|
||||
|
||||
var periodSelect = document.getElementById('period');
|
||||
var exportBtn = document.querySelector('.page-header-actions .btn-secondary');
|
||||
var chartWrap = document.getElementById('chart-bars');
|
||||
var tableBody = document.getElementById('usage-tbody');
|
||||
var cardList = document.getElementById('usage-card-list');
|
||||
var chartTemplate = document.getElementById('tmpl-chart-bar');
|
||||
var rowTemplate = document.getElementById('tmpl-usage-row');
|
||||
var subtitle = document.querySelector('.section-card-subtitle');
|
||||
var statCards = document.querySelectorAll('.stats-grid .stat-card');
|
||||
|
||||
function tKey(key) {
|
||||
return window.t ? t(key) : key;
|
||||
}
|
||||
|
||||
function tfKey(key, vars) {
|
||||
return window.tf ? tf(key, vars) : tKey(key);
|
||||
}
|
||||
|
||||
function currentLocale() {
|
||||
return localStorage.getItem('crank_lang') === 'ru' ? 'ru-RU' : 'en-US';
|
||||
}
|
||||
|
||||
function currentWorkspaceId() {
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
return workspace ? workspace.id : null;
|
||||
}
|
||||
|
||||
function element(tag, className, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined && text !== null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function formatCount(value) {
|
||||
return Number(value || 0).toLocaleString(currentLocale());
|
||||
}
|
||||
|
||||
function formatMs(value) {
|
||||
if (!value) {
|
||||
return '0ms';
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(1) + 's';
|
||||
}
|
||||
return value + 'ms';
|
||||
}
|
||||
|
||||
function periodLabel(period) {
|
||||
return tKey('usage.period.label.' + period);
|
||||
}
|
||||
|
||||
function protocolColor(protocol) {
|
||||
return 'var(--blue)';
|
||||
}
|
||||
|
||||
function protocolLabel(protocol) {
|
||||
return 'REST';
|
||||
}
|
||||
|
||||
function localizedUsageOperation(operation) {
|
||||
if (!window.localizeDemoOperation) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
return window.localizeDemoOperation({
|
||||
name: operation.operation_name,
|
||||
operation_display_name: operation.operation_display_name,
|
||||
description: operation.operation_description || null,
|
||||
});
|
||||
}
|
||||
|
||||
function chartBucketLabel(timestamp, period, index) {
|
||||
var date = new Date(timestamp);
|
||||
if (period === '7d') {
|
||||
return date.toLocaleDateString(currentLocale(), { weekday: 'short' });
|
||||
}
|
||||
if (period === '90d') {
|
||||
return date.toLocaleDateString(currentLocale(), { month: 'short' });
|
||||
}
|
||||
return tfKey('usage.chart.week', { index: index + 1 });
|
||||
}
|
||||
|
||||
function toUtcBucketStart(date, period) {
|
||||
var bucket = new Date(date.getTime());
|
||||
bucket.setUTCMilliseconds(0);
|
||||
bucket.setUTCSeconds(0);
|
||||
bucket.setUTCMinutes(0);
|
||||
bucket.setUTCHours(0);
|
||||
|
||||
if (period === '30d' || period === 'this_month') {
|
||||
var day = bucket.getUTCDay();
|
||||
var offset = day === 0 ? 6 : (day - 1);
|
||||
bucket.setUTCDate(bucket.getUTCDate() - offset);
|
||||
return bucket;
|
||||
}
|
||||
|
||||
if (period === '90d') {
|
||||
bucket.setUTCDate(1);
|
||||
return bucket;
|
||||
}
|
||||
|
||||
return bucket;
|
||||
}
|
||||
|
||||
function shiftUtcBucket(date, period, amount) {
|
||||
var shifted = new Date(date.getTime());
|
||||
if (period === '30d' || period === 'this_month') {
|
||||
shifted.setUTCDate(shifted.getUTCDate() + (amount * 7));
|
||||
return shifted;
|
||||
}
|
||||
if (period === '90d') {
|
||||
shifted.setUTCMonth(shifted.getUTCMonth() + amount);
|
||||
return shifted;
|
||||
}
|
||||
shifted.setUTCDate(shifted.getUTCDate() + amount);
|
||||
return shifted;
|
||||
}
|
||||
|
||||
function bucketKey(date) {
|
||||
return date.toISOString().slice(0, 19) + 'Z';
|
||||
}
|
||||
|
||||
function expectedBucketCount(period, endBucket) {
|
||||
if (period === '7d') {
|
||||
return 7;
|
||||
}
|
||||
if (period === '30d') {
|
||||
return 5;
|
||||
}
|
||||
if (period === '90d') {
|
||||
return 4;
|
||||
}
|
||||
if (period === 'this_month') {
|
||||
var firstDay = new Date(Date.UTC(endBucket.getUTCFullYear(), endBucket.getUTCMonth(), 1));
|
||||
var firstWeek = toUtcBucketStart(firstDay, period);
|
||||
var diffMs = endBucket.getTime() - firstWeek.getTime();
|
||||
return Math.max(1, Math.floor(diffMs / (7 * 24 * 60 * 60 * 1000)) + 1);
|
||||
}
|
||||
return 7;
|
||||
}
|
||||
|
||||
function normalizeTimeline(timeline, period) {
|
||||
if (!timeline.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
var byBucket = {};
|
||||
timeline.forEach(function(point) {
|
||||
byBucket[point.bucket_start] = point;
|
||||
});
|
||||
|
||||
var latestBucket = timeline.reduce(function(current, point) {
|
||||
return point.bucket_start > current.bucket_start ? point : current;
|
||||
}, timeline[0]);
|
||||
var endBucket = toUtcBucketStart(new Date(latestBucket.bucket_start), period);
|
||||
var bucketCount = expectedBucketCount(period, endBucket);
|
||||
var startBucket = shiftUtcBucket(endBucket, period, -(bucketCount - 1));
|
||||
var normalized = [];
|
||||
|
||||
for (var index = 0; index < bucketCount; index += 1) {
|
||||
var bucketDate = shiftUtcBucket(startBucket, period, index);
|
||||
var key = bucketKey(bucketDate);
|
||||
normalized.push(byBucket[key] || {
|
||||
bucket_start: key,
|
||||
calls_ok: 0,
|
||||
calls_error: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function recomputeDerivedState() {
|
||||
var operations = state.usage ? (state.usage.operations || []) : [];
|
||||
var timeline = state.usage ? normalizeTimeline(state.usage.timeline || [], state.period) : [];
|
||||
var totalCalls = operations.reduce(function (sum, operation) {
|
||||
return sum + operation.calls_total;
|
||||
}, 0);
|
||||
var timelineMaxValue = timeline.reduce(function (maxValue, point) {
|
||||
return Math.max(maxValue, point.calls_ok + point.calls_error);
|
||||
}, 0);
|
||||
|
||||
state.derived = {
|
||||
localizedOperations: operations.map(function (operation) {
|
||||
return {
|
||||
operation: operation,
|
||||
localized: localizedUsageOperation(operation),
|
||||
};
|
||||
}),
|
||||
normalizedTimeline: timeline,
|
||||
timelineMaxValue: timelineMaxValue,
|
||||
totalCalls: totalCalls,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmptyState(title, message, compact) {
|
||||
var empty = element('div', 'empty-state');
|
||||
if (compact) {
|
||||
empty.style.padding = '0';
|
||||
}
|
||||
empty.appendChild(element('div', 'empty-state-title', title));
|
||||
empty.appendChild(element('div', 'empty-state-text', message));
|
||||
return empty;
|
||||
}
|
||||
|
||||
function renderEmpty(title, message) {
|
||||
chartWrap.innerHTML = '';
|
||||
var chartEmpty = buildEmptyState(title, message, false);
|
||||
chartEmpty.style.width = '100%';
|
||||
chartWrap.appendChild(chartEmpty);
|
||||
|
||||
tableBody.innerHTML = '';
|
||||
var row = document.createElement('tr');
|
||||
var cell = document.createElement('td');
|
||||
cell.colSpan = 7;
|
||||
cell.style.padding = '24px';
|
||||
cell.appendChild(buildEmptyState(title, message, true));
|
||||
row.appendChild(cell);
|
||||
tableBody.appendChild(row);
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
var summary = state.usage ? state.usage.summary : null;
|
||||
var cards = [
|
||||
{
|
||||
value: formatCount(summary ? summary.rollup.calls_total : 0),
|
||||
text: tfKey('usage.stats.across', { period: periodLabel(state.period) }),
|
||||
},
|
||||
{
|
||||
value: ((summary ? summary.success_rate : 0) || 0).toFixed(1) + '%',
|
||||
text: tfKey('usage.stats.success_calls', { count: formatCount(summary ? summary.rollup.calls_ok : 0) }),
|
||||
},
|
||||
{
|
||||
value: formatMs(summary ? summary.rollup.p50_ms : 0),
|
||||
text: tKey('usage.stats.median'),
|
||||
},
|
||||
{
|
||||
value: formatMs(summary ? summary.rollup.p99_ms : 0),
|
||||
text: tfKey('usage.stats.error_calls', { count: formatCount(summary ? summary.rollup.calls_error : 0) }),
|
||||
}
|
||||
];
|
||||
|
||||
cards.forEach(function (card, index) {
|
||||
var node = statCards[index];
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.querySelector('.stat-value').textContent = card.value;
|
||||
var delta = node.querySelector('.stat-delta');
|
||||
delta.className = 'stat-delta';
|
||||
delta.textContent = card.text;
|
||||
});
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
var timeline = state.derived.normalizedTimeline;
|
||||
chartWrap.innerHTML = '';
|
||||
|
||||
if (!timeline.length) {
|
||||
var chartEmpty = buildEmptyState(
|
||||
tKey('usage.chart.empty.title'),
|
||||
tKey('usage.chart.empty.sub'),
|
||||
false
|
||||
);
|
||||
chartEmpty.style.width = '100%';
|
||||
chartWrap.appendChild(chartEmpty);
|
||||
return;
|
||||
}
|
||||
|
||||
var maxValue = state.derived.timelineMaxValue;
|
||||
|
||||
timeline.forEach(function (point, index) {
|
||||
var node = chartTemplate.content.cloneNode(true);
|
||||
var okHeight = maxValue ? Math.round((point.calls_ok / maxValue) * 160) : 0;
|
||||
var errorHeight = maxValue ? Math.round((point.calls_error / maxValue) * 160) : 0;
|
||||
if (point.calls_ok > 0) {
|
||||
okHeight = Math.max(okHeight, 6);
|
||||
}
|
||||
if (point.calls_error > 0) {
|
||||
errorHeight = Math.max(errorHeight, 6);
|
||||
}
|
||||
node.querySelector('.chart-bar.success').style.height = okHeight + 'px';
|
||||
node.querySelector('.chart-bar.success').dataset.tip = tfKey('usage.chart.ok', { count: formatCount(point.calls_ok) });
|
||||
node.querySelector('.chart-bar.error').style.height = errorHeight + 'px';
|
||||
node.querySelector('.chart-bar.error').dataset.tip = tfKey('usage.chart.errors', { count: formatCount(point.calls_error) });
|
||||
node.querySelector('.chart-col-label').textContent = chartBucketLabel(point.bucket_start, state.period, index);
|
||||
chartWrap.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
var operations = state.derived.localizedOperations;
|
||||
tableBody.innerHTML = '';
|
||||
if (cardList) {
|
||||
cardList.innerHTML = '';
|
||||
}
|
||||
|
||||
if (!operations.length) {
|
||||
var row = document.createElement('tr');
|
||||
var cell = document.createElement('td');
|
||||
cell.colSpan = 7;
|
||||
cell.style.padding = '24px';
|
||||
cell.appendChild(
|
||||
buildEmptyState(
|
||||
tKey('usage.table.empty.title'),
|
||||
tKey('usage.table.empty.sub'),
|
||||
true
|
||||
)
|
||||
);
|
||||
row.appendChild(cell);
|
||||
tableBody.appendChild(row);
|
||||
renderOperationCards([]);
|
||||
return;
|
||||
}
|
||||
|
||||
operations.forEach(function (entry) {
|
||||
var operation = entry.operation;
|
||||
var localizedOperation = entry.localized;
|
||||
var node = rowTemplate.content.cloneNode(true);
|
||||
var errorRate = operation.calls_total === 0
|
||||
? 0
|
||||
: ((operation.calls_error / operation.calls_total) * 100);
|
||||
var share = state.derived.totalCalls === 0
|
||||
? 0
|
||||
: ((operation.calls_total / state.derived.totalCalls) * 100);
|
||||
|
||||
node.querySelector('.col-name').textContent = localizedOperation.operation_display_name || operation.operation_display_name;
|
||||
node.querySelector('.col-op-slug').textContent = operation.operation_name;
|
||||
|
||||
var proto = node.querySelector('.col-proto');
|
||||
proto.textContent = protocolLabel(operation.protocol);
|
||||
proto.style.color = protocolColor(operation.protocol);
|
||||
|
||||
node.querySelector('.col-calls').textContent = formatCount(operation.calls_total);
|
||||
node.querySelector('.col-errors').textContent = formatCount(operation.calls_error);
|
||||
|
||||
var errorRateEl = node.querySelector('.col-err-rate');
|
||||
errorRateEl.textContent = errorRate.toFixed(2) + '%';
|
||||
errorRateEl.style.color = errorRate > 5 ? 'var(--red)' : (errorRate > 1 ? 'var(--amber)' : 'var(--green)');
|
||||
|
||||
var latencyText = node.querySelector('.col-latency-text');
|
||||
latencyText.textContent =
|
||||
formatMs(operation.p50_ms) + ' / ' + formatMs(operation.p95_ms) + ' / ' + formatMs(operation.p99_ms);
|
||||
|
||||
var base = Math.max(operation.p99_ms, 1);
|
||||
node.querySelector('.latency-p50').style.width = Math.max(4, Math.round((operation.p50_ms / base) * 64)) + 'px';
|
||||
node.querySelector('.latency-p95').style.width = Math.max(4, Math.round(((operation.p95_ms - operation.p50_ms) / base) * 64)) + 'px';
|
||||
node.querySelector('.latency-p99').style.width = Math.max(4, Math.round(((operation.p99_ms - operation.p95_ms) / base) * 64)) + 'px';
|
||||
|
||||
node.querySelector('.col-quota-label').textContent = tfKey('usage.table.share', { value: share.toFixed(1) });
|
||||
node.querySelector('.col-quota-fill').style.width = share.toFixed(1) + '%';
|
||||
|
||||
tableBody.appendChild(node);
|
||||
});
|
||||
|
||||
renderOperationCards(operations);
|
||||
}
|
||||
|
||||
function renderOperationCards(operations) {
|
||||
if (!cardList) {
|
||||
return;
|
||||
}
|
||||
|
||||
cardList.innerHTML = '';
|
||||
|
||||
if (!operations.length) {
|
||||
cardList.appendChild(buildEmptyState(
|
||||
tKey('usage.table.empty.title'),
|
||||
tKey('usage.table.empty.sub'),
|
||||
false
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
operations.forEach(function(entry) {
|
||||
var operation = entry.operation;
|
||||
var localizedOperation = entry.localized;
|
||||
var errorRate = operation.calls_total === 0
|
||||
? 0
|
||||
: ((operation.calls_error / operation.calls_total) * 100);
|
||||
var share = state.derived.totalCalls === 0
|
||||
? 0
|
||||
: ((operation.calls_total / state.derived.totalCalls) * 100);
|
||||
|
||||
var card = element('div', 'resource-card');
|
||||
var header = element('div', 'resource-card-header');
|
||||
var headerMain = element('div');
|
||||
headerMain.appendChild(element(
|
||||
'div',
|
||||
'resource-card-title',
|
||||
localizedOperation.operation_display_name || operation.operation_display_name
|
||||
));
|
||||
headerMain.appendChild(element('div', 'resource-card-subtitle', operation.operation_name));
|
||||
|
||||
var actions = element('div', 'resource-card-actions');
|
||||
var protocolBadge = element('span', 'badge');
|
||||
protocolBadge.textContent = protocolLabel(operation.protocol);
|
||||
protocolBadge.style.color = protocolColor(operation.protocol);
|
||||
actions.appendChild(protocolBadge);
|
||||
header.appendChild(headerMain);
|
||||
header.appendChild(actions);
|
||||
card.appendChild(header);
|
||||
|
||||
var metaGrid = element('div', 'resource-meta-grid');
|
||||
metaGrid.appendChild(buildUsageMetaItem('usage.table.th.calls', formatCount(operation.calls_total)));
|
||||
metaGrid.appendChild(buildUsageMetaItem('usage.table.th.errors', formatCount(operation.calls_error)));
|
||||
metaGrid.appendChild(buildUsageMetaItem('usage.table.th.error_rate', errorRate.toFixed(2) + '%'));
|
||||
metaGrid.appendChild(buildUsageMetaItem(
|
||||
'usage.table.th.latency',
|
||||
formatMs(operation.p50_ms) + ' / ' + formatMs(operation.p95_ms) + ' / ' + formatMs(operation.p99_ms)
|
||||
));
|
||||
metaGrid.appendChild(buildUsageMetaItem('usage.table.th.share', tfKey('usage.table.share', { value: share.toFixed(1) })));
|
||||
card.appendChild(metaGrid);
|
||||
|
||||
cardList.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function buildUsageMetaItem(labelKey, valueText) {
|
||||
var item = element('div', 'resource-meta-item');
|
||||
item.appendChild(element('div', 'resource-meta-label', tKey(labelKey)));
|
||||
item.appendChild(element('div', 'resource-meta-value', valueText));
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderUsage() {
|
||||
if (state.loading && !state.usage) {
|
||||
renderEmpty(tKey('usage.loading.title'), tKey('usage.loading.sub'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.loadError) {
|
||||
renderEmpty(tKey('usage.error.title'), state.loadError);
|
||||
return;
|
||||
}
|
||||
|
||||
renderStats();
|
||||
renderChart();
|
||||
renderTable();
|
||||
if (subtitle) {
|
||||
subtitle.textContent = tfKey('usage.table.subtitle', { period: periodLabel(state.period) });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsage() {
|
||||
if (!window.CrankApi) {
|
||||
state.loadError = tKey('usage.error.api');
|
||||
renderUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
state.workspaceId = currentWorkspaceId();
|
||||
if (!state.workspaceId) {
|
||||
state.loadError = tKey('usage.error.workspace');
|
||||
renderUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
state.loading = true;
|
||||
state.loadError = '';
|
||||
renderUsage();
|
||||
|
||||
try {
|
||||
state.usage = await window.CrankApi.getUsageOverview(state.workspaceId, { period: state.period });
|
||||
recomputeDerivedState();
|
||||
} catch (error) {
|
||||
state.loadError = error.message || tKey('usage.error.load');
|
||||
state.usage = null;
|
||||
recomputeDerivedState();
|
||||
} finally {
|
||||
state.loading = false;
|
||||
renderUsage();
|
||||
}
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
if (!state.usage) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.info(tKey('usage.export.empty.body'), tKey('usage.export.empty.title'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = [tKey('usage.csv.header')];
|
||||
state.usage.operations.forEach(function (operation) {
|
||||
var localizedOperation = localizedUsageOperation(operation);
|
||||
var errorRate = operation.calls_total === 0
|
||||
? 0
|
||||
: ((operation.calls_error / operation.calls_total) * 100);
|
||||
rows.push([
|
||||
'"' + (localizedOperation.operation_display_name || operation.operation_display_name) + '"',
|
||||
'"' + protocolLabel(operation.protocol) + '"',
|
||||
operation.calls_total,
|
||||
operation.calls_error,
|
||||
errorRate.toFixed(2),
|
||||
operation.p50_ms,
|
||||
operation.p95_ms,
|
||||
operation.p99_ms,
|
||||
].join(','));
|
||||
});
|
||||
|
||||
var blob = new Blob([rows.join('\r\n')], { type: 'text/csv' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'crank-usage-' + state.period + '.csv';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(tKey('usage.export.done.body'), tKey('usage.export.done.title'));
|
||||
}
|
||||
}
|
||||
|
||||
if (periodSelect) {
|
||||
periodSelect.value = state.period;
|
||||
periodSelect.addEventListener('change', function () {
|
||||
state.period = this.value;
|
||||
loadUsage();
|
||||
});
|
||||
}
|
||||
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', exportCsv);
|
||||
}
|
||||
|
||||
window.addEventListener('crank:workspacechange', loadUsage);
|
||||
|
||||
if (window.whenWorkspacesReady) {
|
||||
window.whenWorkspacesReady().finally(loadUsage);
|
||||
} else {
|
||||
loadUsage();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
function buildToolDescription() {
|
||||
var snapshot = wizardCurrentVersion && wizardCurrentVersion.snapshot
|
||||
? wizardCurrentVersion.snapshot
|
||||
: wizardCurrentVersion;
|
||||
var existing = snapshot && snapshot.tool_description ? snapshot.tool_description : {};
|
||||
|
||||
return {
|
||||
title: textValue('tool-title') || textValue('tool-display-name') || textValue('tool-name'),
|
||||
description: textValue('tool-description'),
|
||||
tags: Array.isArray(existing.tags) ? existing.tags.slice() : [],
|
||||
examples: Array.isArray(existing.examples) ? existing.examples.slice() : [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildWizardState() {
|
||||
return {
|
||||
input_sample: parseStructuredText(textValue('wizard-input-sample')),
|
||||
output_sample: parseStructuredText(textValue('wizard-output-sample')),
|
||||
test_input: parseStructuredText(textValue('wizard-test-input')),
|
||||
};
|
||||
}
|
||||
|
||||
function collectWizardPayload() {
|
||||
var name = textValue('tool-name');
|
||||
if (!name) throw new Error(tKey('wizard.error.tool_name'));
|
||||
|
||||
var inputSchemaValue = parseStructuredText(textValue('tool-input-schema'));
|
||||
var outputSchemaValue = parseStructuredText(textValue('tool-output-schema'));
|
||||
var inputMappingValue = parseStructuredText(textValue('tool-input-mapping'));
|
||||
var outputMappingValue = parseStructuredText(textValue('tool-output-mapping'));
|
||||
|
||||
return {
|
||||
name: name,
|
||||
display_name: textValue('tool-display-name') || name,
|
||||
category: 'general',
|
||||
protocol: wizardProtocol,
|
||||
target: buildTarget(),
|
||||
input_schema: convertJsonSchemaToCrankSchema(inputSchemaValue, []),
|
||||
output_schema: convertJsonSchemaToCrankSchema(outputSchemaValue, []),
|
||||
input_mapping: buildMappingSet(inputMappingValue, 'input'),
|
||||
output_mapping: buildMappingSet(outputMappingValue, 'output'),
|
||||
execution_config: parseExecutionConfig(textValue('tool-exec-config')),
|
||||
tool_description: buildToolDescription(),
|
||||
wizard_state: buildWizardState(),
|
||||
};
|
||||
}
|
||||
|
||||
async function saveOperation(stayOnPage) {
|
||||
try {
|
||||
await persistCurrentDraft(stayOnPage);
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message || tKey('wizard.error.save'), tKey('wizard.error.save_title'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOperationForEdit() {
|
||||
if (!wizardWorkspaceId || !wizardEditId) return;
|
||||
var detail = await window.CrankApi.getOperation(wizardWorkspaceId, wizardEditId);
|
||||
wizardProtocol = detail.protocol || 'rest';
|
||||
await loadWizardPanels([3]);
|
||||
var draftVersion = await window.CrankApi.getOperationVersion(
|
||||
wizardWorkspaceId,
|
||||
wizardEditId,
|
||||
detail.draft_version_ref.version
|
||||
);
|
||||
wizardCurrentOperation = detail;
|
||||
wizardCurrentVersion = draftVersion;
|
||||
prefillWizardFromEdit(detail, draftVersion);
|
||||
}
|
||||
|
||||
function setEditModePresentation() {
|
||||
var progressLabel = document.querySelector('.progress-label');
|
||||
if (progressLabel) progressLabel.textContent = tKey('wizard.progress.edit');
|
||||
renderSidebarBrand('edit');
|
||||
}
|
||||
|
||||
function selectProtocol(protocol) {
|
||||
wizardProtocol = protocol || 'rest';
|
||||
document.querySelectorAll('.protocol-card').forEach(function(card) {
|
||||
var proto = card.dataset.protocol || 'rest';
|
||||
var selected = proto === wizardProtocol;
|
||||
card.classList.toggle('selected', selected);
|
||||
card.setAttribute('aria-checked', selected ? 'true' : 'false');
|
||||
});
|
||||
var s3name = document.getElementById('sidebar-step-3-name');
|
||||
if (s3name) {
|
||||
var step3Labels = window.CrankWizardState.step3Labels();
|
||||
s3name.textContent = step3Labels[wizardProtocol] || tKey('wizard.step3.rest.label');
|
||||
}
|
||||
updateWizardProtocolVisibility();
|
||||
}
|
||||
|
||||
function bindWizardLiveActions() {
|
||||
bindLiveAction('wizard-upload-input-sample', tKey('wizard.busy.input_sample'), uploadInputSampleFromWizard);
|
||||
bindLiveAction('wizard-upload-output-sample', tKey('wizard.busy.output_sample'), uploadOutputSampleFromWizard);
|
||||
bindLiveAction('wizard-generate-draft', tKey('wizard.busy.generate'), generateDraftFromWizard);
|
||||
bindLiveAction('wizard-run-test', tKey('wizard.busy.test'), runWizardTest);
|
||||
bindClick('wizard-copy-test-response', copyTestResponseToOutputSample);
|
||||
bindLiveAction('wizard-export-yaml', tKey('wizard.busy.export_yaml'), exportWizardYaml);
|
||||
bindLiveAction('wizard-import-yaml', tKey('wizard.busy.import_yaml'), importWizardYaml);
|
||||
bindLiveAction('wizard-publish-operation', tKey('wizard.busy.publish'), publishWizardOperation);
|
||||
bindClick('wizard-import-yaml-file-trigger', function() {
|
||||
var input = document.getElementById('wizard-import-yaml-file');
|
||||
if (input) input.click();
|
||||
});
|
||||
|
||||
var yamlInput = document.getElementById('wizard-import-yaml-file');
|
||||
if (yamlInput) yamlInput.addEventListener('change', handleImportYamlFileSelection);
|
||||
}
|
||||
|
||||
function bindClick(id, handler) {
|
||||
var element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
handler();
|
||||
});
|
||||
}
|
||||
|
||||
function bindLiveAction(id, busyLabel, handler) {
|
||||
var element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
runWizardLiveAction(element, busyLabel, handler);
|
||||
});
|
||||
}
|
||||
|
||||
async function runWizardLiveAction(button, busyLabel, handler) {
|
||||
if (!button || button.dataset.busy === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var originalLabel = button.textContent;
|
||||
button.dataset.busy = 'true';
|
||||
button.disabled = true;
|
||||
button.classList.add('is-busy');
|
||||
button.textContent = busyLabel;
|
||||
|
||||
try {
|
||||
await handler();
|
||||
} catch (error) {
|
||||
showWizardLiveStatus(
|
||||
tKey('wizard.live.failed_title'),
|
||||
error && error.message ? error.message : tKey('wizard.live.failed_body'),
|
||||
true
|
||||
);
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error && error.message ? error.message : tKey('wizard.live.failed_body'),
|
||||
tKey('wizard.live.failed_title')
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
button.dataset.busy = 'false';
|
||||
button.disabled = false;
|
||||
button.classList.remove('is-busy');
|
||||
button.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function updateWizardProtocolVisibility() {
|
||||
if (typeof window.renderEditionCapabilityHints === 'function'
|
||||
&& window.CrankWizardState
|
||||
&& typeof window.CrankWizardState.currentEditionCapabilities === 'function') {
|
||||
window.renderEditionCapabilityHints(window.CrankWizardState.currentEditionCapabilities());
|
||||
}
|
||||
}
|
||||
|
||||
function showWizardLiveStatus(title, text, isError) {
|
||||
var root = document.getElementById('wizard-live-status');
|
||||
var titleEl = document.getElementById('wizard-live-status-title');
|
||||
var textEl = document.getElementById('wizard-live-status-text');
|
||||
if (!root || !titleEl || !textEl) return;
|
||||
titleEl.textContent = title;
|
||||
textEl.textContent = text;
|
||||
root.hidden = false;
|
||||
root.classList.toggle('is-error', !!isError);
|
||||
root.classList.toggle('is-success', !isError);
|
||||
}
|
||||
|
||||
function currentDraftVersion() {
|
||||
if (wizardCurrentOperation && wizardCurrentOperation.draft_version_ref) {
|
||||
return wizardCurrentOperation.draft_version_ref.version;
|
||||
}
|
||||
if (wizardCurrentOperation && wizardCurrentOperation.current_draft_version) {
|
||||
return wizardCurrentOperation.current_draft_version;
|
||||
}
|
||||
if (wizardCurrentVersion && wizardCurrentVersion.version) {
|
||||
return wizardCurrentVersion.version;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function updateOperationPayload(payload) {
|
||||
return {
|
||||
display_name: payload.display_name,
|
||||
category: payload.category,
|
||||
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,
|
||||
wizard_state: payload.wizard_state,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshCurrentOperationState() {
|
||||
if (!wizardWorkspaceId || !wizardEditId) return null;
|
||||
var detail = await window.CrankApi.getOperation(wizardWorkspaceId, wizardEditId);
|
||||
var versionNumber = detail.draft_version_ref
|
||||
? detail.draft_version_ref.version
|
||||
: detail.current_draft_version;
|
||||
var versionDocument = await window.CrankApi.getOperationVersion(
|
||||
wizardWorkspaceId,
|
||||
wizardEditId,
|
||||
versionNumber
|
||||
);
|
||||
wizardCurrentOperation = detail;
|
||||
wizardCurrentVersion = versionDocument;
|
||||
updateWizardProtocolVisibility();
|
||||
return {
|
||||
detail: detail,
|
||||
version: versionDocument,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistCurrentDraft(stayOnPage) {
|
||||
if (!wizardWorkspaceId) {
|
||||
throw new Error(tKey('wizard.error.no_workspace'));
|
||||
}
|
||||
|
||||
var payload = collectWizardPayload();
|
||||
var response;
|
||||
var createdNow = false;
|
||||
|
||||
if (wizardMode === 'edit' && wizardEditId) {
|
||||
response = await window.CrankApi.updateOperation(
|
||||
wizardWorkspaceId,
|
||||
wizardEditId,
|
||||
updateOperationPayload(payload)
|
||||
);
|
||||
} else {
|
||||
response = await window.CrankApi.createOperation(wizardWorkspaceId, payload);
|
||||
createdNow = true;
|
||||
wizardEditId = response.operation_id;
|
||||
wizardMode = 'edit';
|
||||
history.replaceState(
|
||||
{},
|
||||
'',
|
||||
window.location.pathname + '?mode=edit&operationId=' + encodeURIComponent(wizardEditId)
|
||||
);
|
||||
setEditModePresentation();
|
||||
_doGoToStep(currentStep);
|
||||
var toolNameInput = document.getElementById('tool-name');
|
||||
if (toolNameInput) toolNameInput.disabled = true;
|
||||
}
|
||||
|
||||
await refreshCurrentOperationState();
|
||||
|
||||
if (stayOnPage) {
|
||||
var saveDraftBtn = document.querySelector('.btn-save-draft');
|
||||
if (saveDraftBtn) {
|
||||
var label = saveDraftBtn.textContent;
|
||||
saveDraftBtn.textContent = tKey('wizard.save.saved');
|
||||
setTimeout(function() {
|
||||
saveDraftBtn.textContent = label;
|
||||
}, 1400);
|
||||
}
|
||||
}
|
||||
|
||||
showWizardLiveStatus(
|
||||
createdNow ? tKey('wizard.save.created') : tKey('wizard.save.updated'),
|
||||
tKey('wizard.save.body')
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
function safeStringify(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function downloadTextFile(fileName, content, mimeType) {
|
||||
var blob = new Blob([content], { type: mimeType || 'text/plain;charset=utf-8' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function setTextareaValue(id, value) {
|
||||
var element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.value = safeStringify(value);
|
||||
}
|
||||
|
||||
async function exportWizardYaml() {
|
||||
await persistCurrentDraft(true);
|
||||
var yaml = await window.CrankApi.exportOperation(wizardWorkspaceId, wizardEditId, {
|
||||
mode: 'portable',
|
||||
version: currentDraftVersion(),
|
||||
});
|
||||
downloadTextFile((textValue('tool-name') || 'operation') + '.yaml', yaml, 'application/yaml');
|
||||
showWizardLiveStatus(tKey('wizard.yaml.exported'), tKey('wizard.yaml.exported_body'));
|
||||
}
|
||||
|
||||
async function importWizardYaml() {
|
||||
if (!wizardWorkspaceId) {
|
||||
throw new Error(tKey('wizard.error.no_workspace'));
|
||||
}
|
||||
var yamlDocument = textValue('wizard-import-yaml-text');
|
||||
if (!yamlDocument) {
|
||||
showWizardLiveStatus(tKey('wizard.yaml.none'), tKey('wizard.yaml.none_body'), true);
|
||||
return;
|
||||
}
|
||||
var imported = await window.CrankApi.importOperation(wizardWorkspaceId, yamlDocument, 'upsert');
|
||||
showWizardLiveStatus(tKey('wizard.yaml.imported'), tKey('wizard.yaml.imported_body'));
|
||||
window.location.href = window.location.pathname + '?mode=edit&operationId=' + encodeURIComponent(imported.operation_id);
|
||||
}
|
||||
|
||||
async function publishWizardOperation() {
|
||||
await persistCurrentDraft(true);
|
||||
var published = await window.CrankApi.publishOperation(
|
||||
wizardWorkspaceId,
|
||||
wizardEditId,
|
||||
currentDraftVersion()
|
||||
);
|
||||
await refreshCurrentOperationState();
|
||||
showWizardLiveStatus(
|
||||
tKey('wizard.publish.done'),
|
||||
tfKey('wizard.publish.done_body', { version: published.published_version })
|
||||
);
|
||||
}
|
||||
|
||||
function handleImportYamlFileSelection(event) {
|
||||
var file = event.target.files && event.target.files[0];
|
||||
if (!file) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(loadEvent) {
|
||||
setValue('wizard-import-yaml-text', loadEvent.target.result || '');
|
||||
showWizardLiveStatus(tKey('wizard.yaml.loaded'), tKey('wizard.yaml.loaded_body'));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function describeWizardTestResult(result) {
|
||||
return {
|
||||
title: result.ok ? tKey('wizard.test.completed') : tKey('wizard.test.failed'),
|
||||
body: result.ok ? tKey('wizard.test.completed_body') : tKey('wizard.test.failed_body'),
|
||||
isError: !result.ok,
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadInputSampleFromWizard() {
|
||||
await persistCurrentDraft(true);
|
||||
await window.CrankApi.uploadInputSample(
|
||||
wizardWorkspaceId,
|
||||
wizardEditId,
|
||||
parseStructuredText(textValue('wizard-input-sample'))
|
||||
);
|
||||
showWizardLiveStatus(tKey('wizard.sample.input_saved'), tKey('wizard.sample.input_saved_body'));
|
||||
}
|
||||
|
||||
async function uploadOutputSampleFromWizard() {
|
||||
await persistCurrentDraft(true);
|
||||
await window.CrankApi.uploadOutputSample(
|
||||
wizardWorkspaceId,
|
||||
wizardEditId,
|
||||
parseStructuredText(textValue('wizard-output-sample'))
|
||||
);
|
||||
showWizardLiveStatus(tKey('wizard.sample.output_saved'), tKey('wizard.sample.output_saved_body'));
|
||||
}
|
||||
|
||||
async function generateDraftFromWizard() {
|
||||
await persistCurrentDraft(true);
|
||||
var generated = await window.CrankApi.generateDraft(wizardWorkspaceId, wizardEditId, {});
|
||||
setValue('tool-input-schema', JSON.stringify(crankSchemaToJsonSchema(generated.input_schema), null, 2));
|
||||
setValue('tool-output-schema', JSON.stringify(crankSchemaToJsonSchema(generated.output_schema), null, 2));
|
||||
setValue('tool-input-mapping', mappingSetToEditorValue(generated.input_mapping, 'input', wizardProtocol));
|
||||
setValue('tool-output-mapping', mappingSetToEditorValue(generated.output_mapping, 'output', wizardProtocol));
|
||||
showWizardLiveStatus(tKey('wizard.sample.generated'), tKey('wizard.sample.generated_body'));
|
||||
}
|
||||
|
||||
async function runWizardTest() {
|
||||
await persistCurrentDraft(true);
|
||||
var result = await window.CrankApi.runOperationTest(wizardWorkspaceId, wizardEditId, {
|
||||
version: currentDraftVersion(),
|
||||
input: parseStructuredText(textValue('wizard-test-input')),
|
||||
});
|
||||
wizardTestResponsePreview = result.response_preview;
|
||||
setTextareaValue('wizard-test-request-preview', result.request_preview);
|
||||
setTextareaValue('wizard-test-response-preview', result.response_preview);
|
||||
setTextareaValue('wizard-test-errors', result.errors && result.errors.length ? result.errors : []);
|
||||
var status = describeWizardTestResult(result);
|
||||
showWizardLiveStatus(
|
||||
status.title,
|
||||
status.body,
|
||||
status.isError
|
||||
);
|
||||
}
|
||||
|
||||
function copyTestResponseToOutputSample() {
|
||||
if (!wizardTestResponsePreview) {
|
||||
showWizardLiveStatus(tKey('wizard.test.no_response'), tKey('wizard.test.no_response_body'), true);
|
||||
return;
|
||||
}
|
||||
setTextareaValue('wizard-output-sample', wizardTestResponsePreview);
|
||||
showWizardLiveStatus(tKey('wizard.test.response_copied'), tKey('wizard.test.response_copied_body'));
|
||||
}
|
||||
|
||||
window.CrankWizardLive = {
|
||||
saveOperation: saveOperation,
|
||||
loadOperationForEdit: loadOperationForEdit,
|
||||
bindWizardLiveActions: bindWizardLiveActions,
|
||||
updateWizardProtocolVisibility: updateWizardProtocolVisibility,
|
||||
};
|
||||
@@ -0,0 +1,406 @@
|
||||
function inferSchemaKind(typeName) {
|
||||
if (typeName === 'object') return 'object';
|
||||
if (typeName === 'array') return 'array';
|
||||
if (typeName === 'string') return 'string';
|
||||
if (typeName === 'integer') return 'integer';
|
||||
if (typeName === 'number') return 'number';
|
||||
if (typeName === 'boolean') return 'boolean';
|
||||
if (typeName === 'null') return 'null';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function convertJsonSchemaToCrankSchema(node, requiredNames) {
|
||||
var schemaNode = node || {};
|
||||
var typeName = Array.isArray(schemaNode.type)
|
||||
? schemaNode.type.find(function(value) { return value !== 'null'; }) || 'string'
|
||||
: schemaNode.type || (schemaNode.properties ? 'object' : 'string');
|
||||
var nullable = Array.isArray(schemaNode.type) && schemaNode.type.indexOf('null') >= 0;
|
||||
var kind = schemaNode.enum ? 'enum' : inferSchemaKind(typeName);
|
||||
var schema = {
|
||||
type: kind,
|
||||
description: schemaNode.description || null,
|
||||
required: false,
|
||||
nullable: nullable,
|
||||
default_value: Object.prototype.hasOwnProperty.call(schemaNode, 'default') ? schemaNode.default : null,
|
||||
fields: {},
|
||||
items: null,
|
||||
enum_values: schemaNode.enum || [],
|
||||
variants: [],
|
||||
};
|
||||
|
||||
if (kind === 'object') {
|
||||
var requiredSet = new Set(schemaNode.required || requiredNames || []);
|
||||
Object.keys(schemaNode.properties || {}).forEach(function(fieldName) {
|
||||
var field = convertJsonSchemaToCrankSchema(schemaNode.properties[fieldName], []);
|
||||
field.required = requiredSet.has(fieldName);
|
||||
schema.fields[fieldName] = field;
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === 'array' && schemaNode.items) {
|
||||
schema.items = convertJsonSchemaToCrankSchema(schemaNode.items, []);
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
function mappingRootForProtocol(protocol) {
|
||||
return '$.request.body';
|
||||
}
|
||||
|
||||
var REQUEST_MAPPING_TARGET_PREFIXES = [
|
||||
'path',
|
||||
'query',
|
||||
'headers',
|
||||
'body',
|
||||
'variables',
|
||||
'grpc',
|
||||
];
|
||||
|
||||
function normalizeInputSource(path) {
|
||||
if (!path) return '$.mcp';
|
||||
if (path === '$') return '$.mcp';
|
||||
if (path.indexOf('$.mcp') === 0) return path;
|
||||
if (path.indexOf('$.input') === 0) return '$.mcp' + path.slice('$.input'.length);
|
||||
return '$.mcp.' + path.replace(/^\$\./, '');
|
||||
}
|
||||
|
||||
function normalizeInputTarget(target) {
|
||||
if (!target) return mappingRootForProtocol(wizardProtocol);
|
||||
if (target.indexOf('$.request.') === 0) return target;
|
||||
if (target.indexOf('request.') === 0) return '$.' + target;
|
||||
|
||||
var normalized = target.replace(/^\$\./, '');
|
||||
var prefix = normalized.split('.')[0];
|
||||
if (REQUEST_MAPPING_TARGET_PREFIXES.indexOf(prefix) >= 0) {
|
||||
return '$.request.' + normalized;
|
||||
}
|
||||
|
||||
return mappingRootForProtocol(wizardProtocol) + '.' + normalized;
|
||||
}
|
||||
|
||||
function normalizeOutputSource(path) {
|
||||
if (!path) return '$.response.body';
|
||||
if (path.indexOf('$.') === 0) return path;
|
||||
return '$.' + path.replace(/^\$\./, '');
|
||||
}
|
||||
|
||||
function normalizeOutputTarget(target) {
|
||||
if (!target) return '$.output';
|
||||
if (target.indexOf('$.output') === 0) return target;
|
||||
if (target.indexOf('output') === 0) return '$.' + target;
|
||||
return '$.output.' + target.replace(/^\$\./, '');
|
||||
}
|
||||
|
||||
function currentOperationSnapshot() {
|
||||
return wizardCurrentVersion && wizardCurrentVersion.snapshot
|
||||
? wizardCurrentVersion.snapshot
|
||||
: wizardCurrentVersion;
|
||||
}
|
||||
|
||||
function existingMappingRuleByTarget(mode, target) {
|
||||
var snapshot = currentOperationSnapshot();
|
||||
var mapping = mode === 'input'
|
||||
? snapshot && snapshot.input_mapping
|
||||
: snapshot && snapshot.output_mapping;
|
||||
var rules = mapping && Array.isArray(mapping.rules) ? mapping.rules : [];
|
||||
return rules.find(function(rule) {
|
||||
return rule.target === target;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function preserveMappingRuleMetadata(rule, existingRule) {
|
||||
if (!existingRule) {
|
||||
rule.required = true;
|
||||
return rule;
|
||||
}
|
||||
|
||||
rule.required = existingRule.required !== undefined ? existingRule.required : true;
|
||||
['default_value', 'transform', 'condition', 'notes'].forEach(function(field) {
|
||||
if (existingRule[field] !== undefined && existingRule[field] !== null) {
|
||||
rule[field] = existingRule[field];
|
||||
}
|
||||
});
|
||||
return rule;
|
||||
}
|
||||
|
||||
function buildMappingSet(rawValue, mode) {
|
||||
var value = rawValue || {};
|
||||
var rules = [];
|
||||
|
||||
Object.keys(value).forEach(function(target) {
|
||||
var source = value[target];
|
||||
if (mode === 'input') {
|
||||
var inputTarget = normalizeInputTarget(target);
|
||||
rules.push(preserveMappingRuleMetadata({
|
||||
source: normalizeInputSource(source),
|
||||
target: inputTarget,
|
||||
}, existingMappingRuleByTarget(mode, inputTarget)));
|
||||
return;
|
||||
}
|
||||
|
||||
var outputTarget = normalizeOutputTarget(target);
|
||||
rules.push(preserveMappingRuleMetadata({
|
||||
source: normalizeOutputSource(source),
|
||||
target: outputTarget,
|
||||
}, existingMappingRuleByTarget(mode, outputTarget)));
|
||||
});
|
||||
|
||||
return { rules: rules };
|
||||
}
|
||||
|
||||
function parseExecutionConfig(text) {
|
||||
var value = parseStructuredText(text);
|
||||
var retry = value.retry || {};
|
||||
var headers = value.headers && typeof value.headers === 'object' ? value.headers : {};
|
||||
var selectedUpstream = currentUpstream();
|
||||
if (!selectedUpstream && wizardMode !== 'edit') {
|
||||
throw new Error(tKey('wizard.error.save_upstream_first'));
|
||||
}
|
||||
var authProfileRef = selectedUpstream && selectedUpstream.authProfileId
|
||||
? selectedUpstream.authProfileId
|
||||
: (value.auth && value.auth.profile ? value.auth.profile : null);
|
||||
var config = {
|
||||
timeout_ms: Number(value.timeout_ms || 10000),
|
||||
retry_policy: retry && retry.max_attempts ? { max_attempts: Number(retry.max_attempts) } : null,
|
||||
auth_profile_ref: authProfileRef,
|
||||
headers: headers,
|
||||
protocol_options: null,
|
||||
streaming: null,
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function currentUpstream() {
|
||||
var selected = upstreams.find(function(item) { return item.id === selectedUpstreamId; });
|
||||
if (selected) return selected;
|
||||
|
||||
var customName = textValue('new-upstream-name');
|
||||
var customUrl = textValue('new-upstream-url');
|
||||
if (!customUrl) return null;
|
||||
|
||||
var authMode = textValue('new-upstream-auth-mode') || 'none';
|
||||
var authProfileId = authMode === 'existing' ? textValue('new-upstream-auth-profile') : null;
|
||||
var authProfile = authProfileById(authProfileId);
|
||||
return {
|
||||
id: 'custom',
|
||||
name: customName || 'custom-upstream',
|
||||
url: customUrl,
|
||||
staticHeaders: textValue('new-upstream-static-headers') || '{\n}',
|
||||
authMode: authMode,
|
||||
authProfileId: authProfileId || null,
|
||||
authProfileName: authProfile ? authProfile.name : '',
|
||||
authKind: authProfile ? authProfile.kind : null,
|
||||
};
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl, path) {
|
||||
if (!baseUrl) return path || '';
|
||||
if (!path) return baseUrl;
|
||||
return baseUrl.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function parseHeaderMap(text) {
|
||||
if (!text || !text.trim()) return {};
|
||||
var value = parseStructuredText(text);
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
function buildTarget() {
|
||||
var upstream = currentUpstream();
|
||||
if (!upstream || !upstream.url) {
|
||||
throw new Error(tKey('wizard.error.select_upstream'));
|
||||
}
|
||||
|
||||
var pathTemplate = textValue('endpoint-path') || '/';
|
||||
var activeMethod = document.querySelector('.method-card.active');
|
||||
return {
|
||||
kind: 'rest',
|
||||
base_url: upstream.url,
|
||||
method: activeMethod ? activeMethod.dataset.method : 'POST',
|
||||
path_template: pathTemplate,
|
||||
static_headers: parseHeaderMap(upstream.staticHeaders),
|
||||
};
|
||||
}
|
||||
|
||||
function crankSchemaToJsonSchema(schema) {
|
||||
var result = {};
|
||||
var typeName = schema.type === 'enum' ? 'string' : schema.type;
|
||||
result.type = schema.nullable ? [typeName, 'null'] : typeName;
|
||||
if (schema.description) result.description = schema.description;
|
||||
if (schema.default_value !== null && schema.default_value !== undefined) result.default = schema.default_value;
|
||||
if (schema.enum_values && schema.enum_values.length) result.enum = schema.enum_values.slice();
|
||||
if (schema.type === 'object') {
|
||||
result.type = schema.nullable ? ['object', 'null'] : 'object';
|
||||
result.properties = {};
|
||||
var required = [];
|
||||
Object.keys(schema.fields || {}).forEach(function(fieldName) {
|
||||
var field = schema.fields[fieldName];
|
||||
result.properties[fieldName] = crankSchemaToJsonSchema(field);
|
||||
if (field.required) required.push(fieldName);
|
||||
});
|
||||
if (required.length) result.required = required;
|
||||
result.additionalProperties = false;
|
||||
}
|
||||
if (schema.type === 'array' && schema.items) {
|
||||
result.items = crankSchemaToJsonSchema(schema.items);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function exampleValueForSchema(schema) {
|
||||
if (!schema) return {};
|
||||
|
||||
if (schema.default_value !== null && schema.default_value !== undefined) {
|
||||
return schema.default_value;
|
||||
}
|
||||
|
||||
if (schema.enum_values && schema.enum_values.length) {
|
||||
return schema.enum_values[0];
|
||||
}
|
||||
|
||||
if (schema.type === 'object') {
|
||||
var object = {};
|
||||
Object.keys(schema.fields || {}).forEach(function(fieldName) {
|
||||
object[fieldName] = exampleValueForSchema(schema.fields[fieldName]);
|
||||
});
|
||||
return object;
|
||||
}
|
||||
|
||||
if (schema.type === 'array') {
|
||||
return [exampleValueForSchema(schema.items)];
|
||||
}
|
||||
|
||||
if (schema.type === 'integer') return 1;
|
||||
if (schema.type === 'number') return 1.23;
|
||||
if (schema.type === 'boolean') return true;
|
||||
if (schema.type === 'null') return null;
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function firstToolExampleValue(toolDescription, fieldName) {
|
||||
var examples = toolDescription && Array.isArray(toolDescription.examples)
|
||||
? toolDescription.examples
|
||||
: [];
|
||||
if (!examples.length) return null;
|
||||
|
||||
var value = examples[0] && examples[0][fieldName];
|
||||
return value === undefined ? null : value;
|
||||
}
|
||||
|
||||
function prefillWizardSamples(snapshot) {
|
||||
var state = snapshot.wizard_state || {};
|
||||
var inputExample = firstToolExampleValue(snapshot.tool_description, 'input');
|
||||
var inputSample = state.input_sample !== undefined && state.input_sample !== null
|
||||
? state.input_sample
|
||||
: inputExample !== null ? inputExample : exampleValueForSchema(snapshot.input_schema);
|
||||
var outputSample = state.output_sample !== undefined && state.output_sample !== null
|
||||
? state.output_sample
|
||||
: exampleValueForSchema(snapshot.output_schema);
|
||||
var testInput = state.test_input !== undefined && state.test_input !== null
|
||||
? state.test_input
|
||||
: inputSample;
|
||||
|
||||
setValue('wizard-input-sample', JSON.stringify(inputSample, null, 2));
|
||||
setValue('wizard-test-input', JSON.stringify(testInput, null, 2));
|
||||
setValue('wizard-output-sample', JSON.stringify(outputSample, null, 2));
|
||||
}
|
||||
|
||||
function mappingSetToEditorValue(mappingSet, mode, protocol) {
|
||||
var rules = mappingSet && mappingSet.rules ? mappingSet.rules : [];
|
||||
var object = {};
|
||||
rules.forEach(function(rule) {
|
||||
if (mode === 'input') {
|
||||
var key = rule.target.indexOf(mappingRootForProtocol(protocol) + '.') === 0
|
||||
? rule.target.replace(mappingRootForProtocol(protocol) + '.', '')
|
||||
: rule.target.replace('$.request.', '');
|
||||
object[key] = rule.source.replace('$.mcp.', '$.input.');
|
||||
return;
|
||||
}
|
||||
var outputKey = rule.target.replace('$.output.', '');
|
||||
object[outputKey] = rule.source;
|
||||
});
|
||||
return window.jsyaml ? window.jsyaml.dump(object, { lineWidth: -1 }) : JSON.stringify(object, null, 2);
|
||||
}
|
||||
|
||||
function executionConfigToEditorValue(config) {
|
||||
var value = {
|
||||
timeout_ms: config.timeout_ms,
|
||||
};
|
||||
if (config.retry_policy) {
|
||||
value.retry = { max_attempts: config.retry_policy.max_attempts };
|
||||
}
|
||||
if (config.auth_profile_ref) {
|
||||
value.auth = { profile: config.auth_profile_ref };
|
||||
}
|
||||
if (config.headers && Object.keys(config.headers).length) {
|
||||
value.headers = config.headers;
|
||||
}
|
||||
return window.jsyaml ? window.jsyaml.dump(value, { lineWidth: -1 }) : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function operationSnapshot(versionDocument) {
|
||||
if (!versionDocument) return {};
|
||||
return versionDocument.snapshot || versionDocument;
|
||||
}
|
||||
|
||||
function prefillUpstream(baseUrl, headers, authProfileRef) {
|
||||
var known = upstreams.find(function(item) {
|
||||
return item.url === baseUrl && (item.authProfileId || null) === (authProfileRef || null);
|
||||
});
|
||||
if (known) {
|
||||
pickUpstream(known.id);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedUpstreamId = null;
|
||||
var trigger = document.getElementById('upstream-new-trigger');
|
||||
var form = document.getElementById('upstream-new-form');
|
||||
if (trigger) trigger.classList.add('active');
|
||||
if (form) form.hidden = false;
|
||||
setValue('new-upstream-name', 'imported-upstream');
|
||||
setValue('new-upstream-url', baseUrl);
|
||||
setValue('new-upstream-static-headers', headers && Object.keys(headers).length ? JSON.stringify(headers, null, 2) : '{\n}');
|
||||
setValue('new-upstream-auth-mode', authProfileRef ? 'existing' : 'none');
|
||||
setValue('new-upstream-auth-profile', authProfileRef || '');
|
||||
updateUpstreamAuthUi();
|
||||
|
||||
var valueEl = document.getElementById('upstream-combobox-value');
|
||||
if (valueEl) {
|
||||
valueEl.innerHTML = '';
|
||||
valueEl.appendChild(buildUpstreamTriggerContent('imported-upstream', baseUrl));
|
||||
}
|
||||
}
|
||||
|
||||
function prefillWizardFromEdit(detail, versionDocument) {
|
||||
if (!detail || !versionDocument) return;
|
||||
|
||||
var snapshot = operationSnapshot(versionDocument);
|
||||
setEditModePresentation();
|
||||
selectProtocol(snapshot.protocol || detail.protocol);
|
||||
|
||||
var target = snapshot.target || {};
|
||||
if (target.kind === 'rest') {
|
||||
prefillUpstream(target.base_url, target.static_headers, snapshot.execution_config ? snapshot.execution_config.auth_profile_ref : null);
|
||||
setValue('endpoint-path', target.path_template);
|
||||
document.querySelectorAll('.method-card').forEach(function(card) {
|
||||
card.classList.toggle('active', card.dataset.method === target.method);
|
||||
});
|
||||
}
|
||||
|
||||
setValue('tool-name', snapshot.name || detail.name);
|
||||
setValue('tool-display-name', snapshot.display_name || detail.display_name);
|
||||
setValue('tool-title', snapshot.tool_description ? snapshot.tool_description.title : (snapshot.display_name || detail.display_name));
|
||||
setValue('tool-description', snapshot.tool_description ? snapshot.tool_description.description : '');
|
||||
setValue('tool-input-schema', JSON.stringify(crankSchemaToJsonSchema(snapshot.input_schema), null, 2));
|
||||
setValue('tool-output-schema', JSON.stringify(crankSchemaToJsonSchema(snapshot.output_schema), null, 2));
|
||||
setValue('tool-input-mapping', mappingSetToEditorValue(snapshot.input_mapping, 'input', snapshot.protocol || detail.protocol));
|
||||
setValue('tool-output-mapping', mappingSetToEditorValue(snapshot.output_mapping, 'output', snapshot.protocol || detail.protocol));
|
||||
setValue('tool-exec-config', executionConfigToEditorValue(snapshot.execution_config || {}));
|
||||
prefillWizardSamples(snapshot);
|
||||
|
||||
var toolNameInput = document.getElementById('tool-name');
|
||||
if (toolNameInput) toolNameInput.disabled = true;
|
||||
updateWizardProtocolVisibility();
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
(function() {
|
||||
var TOTAL_STEPS = 5;
|
||||
|
||||
function tKey(key) {
|
||||
return typeof t === 'function' ? t(key) : key;
|
||||
}
|
||||
|
||||
function tfKey(key, vars) {
|
||||
return typeof tf === 'function' ? tf(key, vars) : key;
|
||||
}
|
||||
|
||||
function step3PanelId() {
|
||||
return 'step-panel-3-rest';
|
||||
}
|
||||
|
||||
function stepFile(step) {
|
||||
return step === 3 ? 'step3-rest.html' : 'step' + step + '.html';
|
||||
}
|
||||
|
||||
function panelIdForStep(step) {
|
||||
return step === 3 ? step3PanelId() : 'step-panel-' + step;
|
||||
}
|
||||
|
||||
function buildIconSvg(href, width, height) {
|
||||
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', String(width));
|
||||
svg.setAttribute('height', String(height));
|
||||
var use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use.setAttribute('href', href);
|
||||
svg.appendChild(use);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function appendHtmlFragment(container, html) {
|
||||
if (!container) return;
|
||||
var template = document.createElement('template');
|
||||
template.innerHTML = html;
|
||||
container.appendChild(template.content.cloneNode(true));
|
||||
}
|
||||
|
||||
function setContinueButtonContent(button, label) {
|
||||
if (!button) return;
|
||||
button.textContent = label + ' ';
|
||||
button.appendChild(
|
||||
buildIconSvg(
|
||||
(window.APP_BASE || '') + 'icons/general/arrow-right.svg#icon',
|
||||
13,
|
||||
13
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderStepCounter(counter, step, total) {
|
||||
if (!counter) return;
|
||||
var language = localStorage.getItem('crank_lang') || 'en';
|
||||
var parts = language === 'ru'
|
||||
? { prefix: 'Шаг ', suffix: ' из ' + total }
|
||||
: { prefix: 'Step ', suffix: ' of ' + total };
|
||||
var strong = document.createElement('strong');
|
||||
counter.textContent = parts.prefix;
|
||||
strong.textContent = String(step);
|
||||
counter.appendChild(strong);
|
||||
counter.appendChild(document.createTextNode(parts.suffix));
|
||||
}
|
||||
|
||||
function loadStep(step, callback) {
|
||||
var panelId = step === 3 ? step3PanelId() : 'step-panel-' + step;
|
||||
if (document.getElementById(panelId)) {
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
fetch(stepFile(step))
|
||||
.then(function(response) { return response.text(); })
|
||||
.then(function(html) {
|
||||
var container = document.getElementById('step-panel-container');
|
||||
appendHtmlFragment(container, html);
|
||||
var pane = document.getElementById(panelId);
|
||||
var activeStep = window.currentStep || 1;
|
||||
if (pane && step !== activeStep) {
|
||||
pane.hidden = true;
|
||||
}
|
||||
if (typeof applyLang === 'function') applyLang();
|
||||
if (callback) callback();
|
||||
})
|
||||
.catch(function() {
|
||||
if (callback) callback();
|
||||
});
|
||||
}
|
||||
|
||||
function doGoToStep(step) {
|
||||
document.querySelectorAll('.step-number[data-step]').forEach(function(element) {
|
||||
var stepNumber = Number(element.getAttribute('data-step')) || 0;
|
||||
element.textContent = tfKey('wizard.step_short', { step: stepNumber });
|
||||
});
|
||||
|
||||
document.querySelectorAll('.step-pane').forEach(function(pane) { pane.hidden = true; });
|
||||
var panelId = panelIdForStep(step);
|
||||
var pane = document.getElementById(panelId);
|
||||
if (pane) pane.hidden = false;
|
||||
|
||||
document.querySelectorAll('.step-item').forEach(function(item, index) {
|
||||
var stepNumber = index + 1;
|
||||
item.classList.remove('active', 'done', 'pending');
|
||||
var indicator = item.querySelector('.step-indicator');
|
||||
var status = item.querySelector('.step-status-text');
|
||||
if (stepNumber < step) {
|
||||
item.classList.add('done');
|
||||
if (indicator) {
|
||||
indicator.replaceChildren(
|
||||
buildIconSvg(
|
||||
(window.APP_BASE || '') + 'icons/general/check.svg#icon',
|
||||
10,
|
||||
10
|
||||
)
|
||||
);
|
||||
}
|
||||
if (status) status.textContent = tKey('wizard.status.completed');
|
||||
} else if (stepNumber === step) {
|
||||
item.classList.add('active');
|
||||
if (indicator) indicator.textContent = stepNumber;
|
||||
if (status) status.textContent = tKey('wizard.status.in_progress');
|
||||
} else {
|
||||
item.classList.add('pending');
|
||||
if (indicator) indicator.textContent = stepNumber;
|
||||
if (status) status.textContent = tKey('wizard.status.not_started');
|
||||
}
|
||||
});
|
||||
|
||||
var percent = Math.round((step / TOTAL_STEPS) * 100);
|
||||
var fill = document.querySelector('.progress-bar-fill');
|
||||
if (fill) fill.style.width = percent + '%';
|
||||
var percentNode = document.querySelector('.progress-pct');
|
||||
if (percentNode) percentNode.textContent = percent + '%';
|
||||
|
||||
var counter = pane ? pane.querySelector('[data-step-counter]') : null;
|
||||
renderStepCounter(counter, step, TOTAL_STEPS);
|
||||
|
||||
var backButton = document.querySelector('.btn-back');
|
||||
if (backButton) {
|
||||
backButton.disabled = step === 1;
|
||||
}
|
||||
|
||||
var continueButton = document.querySelector('.btn-continue');
|
||||
if (continueButton) {
|
||||
if (step === TOTAL_STEPS) {
|
||||
var finalLabel = window.wizardMode === 'edit' ? tKey('wizard.button.save_changes') : tKey('wizard.button.create');
|
||||
setContinueButtonContent(continueButton, finalLabel);
|
||||
continueButton.classList.add('is-final-step');
|
||||
} else {
|
||||
setContinueButtonContent(continueButton, tKey('wizard.button.continue'));
|
||||
continueButton.classList.remove('is-final-step');
|
||||
}
|
||||
}
|
||||
|
||||
window.currentStep = step;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function goToStep(step) {
|
||||
if (step < 1 || step > TOTAL_STEPS) return;
|
||||
loadStep(step, function() { doGoToStep(step); });
|
||||
}
|
||||
|
||||
function loadWizardPanels(steps) {
|
||||
return steps.reduce(function(chain, step) {
|
||||
return chain.then(function() {
|
||||
return new Promise(function(resolve) {
|
||||
loadStep(step, resolve);
|
||||
});
|
||||
});
|
||||
}, Promise.resolve());
|
||||
}
|
||||
|
||||
window.addEventListener('crank:langchange', function() {
|
||||
if (window.currentStep) {
|
||||
doGoToStep(window.currentStep);
|
||||
}
|
||||
});
|
||||
|
||||
function bindProtocolCards() {
|
||||
document.querySelectorAll('.protocol-card').forEach(function(card) {
|
||||
card.addEventListener('click', function() {
|
||||
if (card.hidden || card.getAttribute('aria-disabled') === 'true') {
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.protocol-card').forEach(function(item) {
|
||||
item.classList.remove('selected');
|
||||
item.setAttribute('aria-checked', 'false');
|
||||
});
|
||||
card.classList.add('selected');
|
||||
card.setAttribute('aria-checked', 'true');
|
||||
|
||||
var protocol = card.dataset.protocol
|
||||
|| 'rest';
|
||||
|
||||
window.wizardProtocol = protocol;
|
||||
var step3Name = document.getElementById('sidebar-step-3-name');
|
||||
var labels = window.CrankWizardState.step3Labels();
|
||||
if (step3Name) step3Name.textContent = labels[protocol] || tKey('wizard.step3.rest.label');
|
||||
|
||||
});
|
||||
card.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
card.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function applyEditionProtocolVisibility(capabilities) {
|
||||
var supported = capabilities && Array.isArray(capabilities.supported_protocols)
|
||||
? capabilities.supported_protocols.slice()
|
||||
: ['rest'];
|
||||
var knownProtocols = ['rest', 'graphql', 'grpc', 'websocket', 'soap'];
|
||||
|
||||
document.querySelectorAll('.protocol-card').forEach(function(card) {
|
||||
var protocol = card.dataset.protocol || 'rest';
|
||||
var isSupported = supported.indexOf(protocol) >= 0;
|
||||
card.hidden = !isSupported;
|
||||
card.setAttribute('aria-disabled', isSupported ? 'false' : 'true');
|
||||
if (!isSupported) {
|
||||
card.classList.remove('selected');
|
||||
card.setAttribute('aria-checked', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
var hiddenProtocols = knownProtocols.filter(function(protocol) {
|
||||
return supported.indexOf(protocol) === -1;
|
||||
});
|
||||
|
||||
if (supported.indexOf(window.wizardProtocol) === -1) {
|
||||
window.wizardProtocol = supported[0] || 'rest';
|
||||
}
|
||||
|
||||
var selectedCard = document.querySelector('.protocol-card[data-protocol="' + window.wizardProtocol + '"]');
|
||||
if (selectedCard) {
|
||||
selectedCard.classList.add('selected');
|
||||
selectedCard.setAttribute('aria-checked', 'true');
|
||||
}
|
||||
|
||||
var step3Name = document.getElementById('sidebar-step-3-name');
|
||||
var labels = window.CrankWizardState.step3Labels();
|
||||
if (step3Name) {
|
||||
step3Name.textContent = labels[window.wizardProtocol] || tKey('wizard.step3.rest.label');
|
||||
}
|
||||
|
||||
var note = document.getElementById('wizard-edition-protocol-note');
|
||||
var noteText = document.getElementById('wizard-edition-protocol-note-text');
|
||||
if (note && noteText) {
|
||||
if (hiddenProtocols.length) {
|
||||
note.hidden = false;
|
||||
noteText.textContent = tfKey('wizard.step1.community_protocol_note', {
|
||||
protocols: hiddenProtocols.map(function(protocol) {
|
||||
return tKey('settings.capability.' + protocol);
|
||||
}).join(', ')
|
||||
});
|
||||
} else {
|
||||
note.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.CrankWizardShell = {
|
||||
TOTAL_STEPS: TOTAL_STEPS,
|
||||
step3PanelId: step3PanelId,
|
||||
loadStep: loadStep,
|
||||
doGoToStep: doGoToStep,
|
||||
goToStep: goToStep,
|
||||
loadWizardPanels: loadWizardPanels,
|
||||
bindProtocolCards: bindProtocolCards,
|
||||
applyEditionProtocolVisibility: applyEditionProtocolVisibility,
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,124 @@
|
||||
(function() {
|
||||
function tKey(key) {
|
||||
return typeof t === 'function' ? t(key) : key;
|
||||
}
|
||||
|
||||
var defaults = {
|
||||
currentStep: 1,
|
||||
wizardProtocol: 'rest',
|
||||
wizardMode: 'create',
|
||||
wizardEditId: null,
|
||||
wizardWorkspaceId: null,
|
||||
wizardCurrentOperation: null,
|
||||
wizardCurrentVersion: null,
|
||||
wizardTestResponsePreview: null,
|
||||
wizardProtocolCapabilities: null,
|
||||
wizardEditionCapabilities: null,
|
||||
wizardSecrets: [],
|
||||
wizardAuthProfiles: [],
|
||||
selectedUpstreamId: null,
|
||||
editingUpstreamId: null,
|
||||
protoParsed: null,
|
||||
selectedRpcMethod: null,
|
||||
};
|
||||
|
||||
Object.keys(defaults).forEach(function(key) {
|
||||
if (typeof window[key] !== 'undefined') {
|
||||
return;
|
||||
}
|
||||
var value = defaults[key];
|
||||
if (Array.isArray(value)) {
|
||||
window[key] = value.slice();
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
window[key] = Object.assign({}, value);
|
||||
return;
|
||||
}
|
||||
window[key] = value;
|
||||
});
|
||||
|
||||
function defaultProtocolCapabilities() {
|
||||
return {
|
||||
rest: {
|
||||
supports_execution_modes: ['unary'],
|
||||
supports_transport_behaviors: ['request_response'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultEditionCapabilities() {
|
||||
return {
|
||||
edition: 'community',
|
||||
supported_protocols: ['rest'],
|
||||
supported_security_levels: ['standard'],
|
||||
machine_access_modes: ['static_agent_key'],
|
||||
limits: {
|
||||
max_workspaces: 1,
|
||||
max_users_per_workspace: 1,
|
||||
max_agents_per_workspace: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function currentProtocolCapabilities() {
|
||||
var capabilities = window.wizardProtocolCapabilities || defaultProtocolCapabilities();
|
||||
return capabilities[window.wizardProtocol] || capabilities.rest;
|
||||
}
|
||||
|
||||
function currentEditionCapabilities() {
|
||||
return window.wizardEditionCapabilities || defaultEditionCapabilities();
|
||||
}
|
||||
|
||||
async function loadProtocolCapabilities() {
|
||||
if (!window.wizardWorkspaceId || !window.CrankApi || typeof window.CrankApi.getProtocolCapabilities !== 'function') {
|
||||
window.wizardProtocolCapabilities = defaultProtocolCapabilities();
|
||||
return window.wizardProtocolCapabilities;
|
||||
}
|
||||
|
||||
try {
|
||||
var response = await window.CrankApi.getProtocolCapabilities(window.wizardWorkspaceId);
|
||||
var mapped = {};
|
||||
(response.items || []).forEach(function(item) {
|
||||
mapped[item.protocol] = item;
|
||||
});
|
||||
window.wizardProtocolCapabilities = mapped;
|
||||
} catch (_error) {
|
||||
window.wizardProtocolCapabilities = defaultProtocolCapabilities();
|
||||
}
|
||||
|
||||
return window.wizardProtocolCapabilities;
|
||||
}
|
||||
|
||||
async function loadEditionCapabilities() {
|
||||
if (!window.CrankApi || typeof window.CrankApi.getCapabilities !== 'function') {
|
||||
window.wizardEditionCapabilities = defaultEditionCapabilities();
|
||||
return window.wizardEditionCapabilities;
|
||||
}
|
||||
|
||||
try {
|
||||
window.wizardEditionCapabilities = await window.CrankApi.getCapabilities();
|
||||
} catch (_error) {
|
||||
window.wizardEditionCapabilities = defaultEditionCapabilities();
|
||||
}
|
||||
|
||||
return window.wizardEditionCapabilities;
|
||||
}
|
||||
|
||||
function step3Labels() {
|
||||
return {
|
||||
rest: tKey('wizard.step3.rest.label'),
|
||||
};
|
||||
}
|
||||
|
||||
window.CrankWizardState = {
|
||||
state: window,
|
||||
defaultProtocolCapabilities: defaultProtocolCapabilities,
|
||||
defaultEditionCapabilities: defaultEditionCapabilities,
|
||||
currentProtocolCapabilities: currentProtocolCapabilities,
|
||||
currentEditionCapabilities: currentEditionCapabilities,
|
||||
loadProtocolCapabilities: loadProtocolCapabilities,
|
||||
loadEditionCapabilities: loadEditionCapabilities,
|
||||
step3Labels: step3Labels,
|
||||
};
|
||||
}());
|
||||
@@ -0,0 +1,584 @@
|
||||
/* ── Upstream selector (Step 2) ── */
|
||||
|
||||
var upstreams = [];
|
||||
|
||||
var dropdownOpen = false;
|
||||
|
||||
function createDivWithClass(className, text) {
|
||||
var element = document.createElement('div');
|
||||
element.className = className;
|
||||
element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
|
||||
function authProfileById(authProfileId) {
|
||||
return wizardAuthProfiles.find(function(item) { return item.id === authProfileId; }) || null;
|
||||
}
|
||||
|
||||
function syncUpstreamAuthMetadata(upstream) {
|
||||
if (!upstream) return upstream;
|
||||
if (!upstream.authProfileId) {
|
||||
upstream.authProfileName = '';
|
||||
upstream.authKind = null;
|
||||
return upstream;
|
||||
}
|
||||
|
||||
var profile = authProfileById(upstream.authProfileId);
|
||||
if (profile) {
|
||||
upstream.authProfileName = profile.name;
|
||||
upstream.authKind = profile.kind;
|
||||
}
|
||||
return upstream;
|
||||
}
|
||||
|
||||
function inferUpstreamAuth(upstream) {
|
||||
syncUpstreamAuthMetadata(upstream);
|
||||
if (!upstream || !upstream.authProfileId || !upstream.authKind) {
|
||||
return { kind: 'none', label: tKey('wizard.step2.auth_none') };
|
||||
}
|
||||
if (upstream.authKind === 'bearer') {
|
||||
return { kind: 'bearer', label: tKey('wizard.step2.auth_bearer') };
|
||||
}
|
||||
if (upstream.authKind === 'basic') {
|
||||
return { kind: 'basic', label: tKey('wizard.step2.auth_basic') };
|
||||
}
|
||||
return { kind: 'apikey', label: tKey('wizard.step2.auth_apikey') };
|
||||
}
|
||||
|
||||
function upstreamMeta(upstream) {
|
||||
var auth = inferUpstreamAuth(upstream);
|
||||
return {
|
||||
kind: auth.kind,
|
||||
label: auth.label,
|
||||
};
|
||||
}
|
||||
|
||||
function staticHeadersToEditorValue(value) {
|
||||
if (!value || (typeof value === 'object' && Object.keys(value).length === 0)) {
|
||||
return '{\n}';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function mapWorkspaceUpstream(record) {
|
||||
return syncUpstreamAuthMetadata({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
url: record.base_url,
|
||||
staticHeaders: staticHeadersToEditorValue(record.static_headers),
|
||||
authProfileId: record.auth_profile_id || null,
|
||||
authProfileName: '',
|
||||
authKind: null,
|
||||
persisted: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWizardAuthResources() {
|
||||
if (!wizardWorkspaceId || !window.CrankApi) {
|
||||
wizardSecrets = [];
|
||||
wizardAuthProfiles = [];
|
||||
upstreams = [];
|
||||
renderAuthProfileOptions();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
window.CrankApi.listSecrets(wizardWorkspaceId),
|
||||
window.CrankApi.listAuthProfiles(wizardWorkspaceId),
|
||||
window.CrankApi.listUpstreams(wizardWorkspaceId),
|
||||
]);
|
||||
wizardSecrets = (results[0] && results[0].items) || [];
|
||||
wizardAuthProfiles = (results[1] && results[1].items) || [];
|
||||
upstreams = ((results[2] && results[2].items) || []).map(mapWorkspaceUpstream);
|
||||
} catch (_error) {
|
||||
wizardSecrets = [];
|
||||
wizardAuthProfiles = [];
|
||||
upstreams = [];
|
||||
}
|
||||
|
||||
upstreams.forEach(syncUpstreamAuthMetadata);
|
||||
renderAuthProfileOptions();
|
||||
}
|
||||
|
||||
function renderAuthProfileOptions() {
|
||||
var profileSelect = document.getElementById('new-upstream-auth-profile');
|
||||
var secretSelect = document.getElementById('new-auth-profile-secret-id');
|
||||
var usernameSelect = document.getElementById('new-auth-profile-username-secret');
|
||||
var passwordSelect = document.getElementById('new-auth-profile-password-secret');
|
||||
var profileHint = document.getElementById('new-upstream-auth-profile-hint');
|
||||
|
||||
if (profileSelect) {
|
||||
var previousProfile = profileSelect.value;
|
||||
profileSelect.innerHTML = '';
|
||||
if (!wizardAuthProfiles.length) {
|
||||
var emptyProfile = document.createElement('option');
|
||||
emptyProfile.value = '';
|
||||
emptyProfile.textContent = tKey('wizard.step2.auth_profile_empty');
|
||||
profileSelect.appendChild(emptyProfile);
|
||||
if (profileHint) profileHint.textContent = tKey('wizard.step2.auth_profile_empty');
|
||||
} else {
|
||||
var placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = tKey('wizard.step2.auth_profile');
|
||||
profileSelect.appendChild(placeholder);
|
||||
wizardAuthProfiles.forEach(function(profile) {
|
||||
var option = document.createElement('option');
|
||||
option.value = profile.id;
|
||||
option.textContent = profile.name + ' · ' + profile.kind;
|
||||
profileSelect.appendChild(option);
|
||||
});
|
||||
if (previousProfile) {
|
||||
profileSelect.value = previousProfile;
|
||||
}
|
||||
if (profileHint) profileHint.textContent = tKey('wizard.step2.auth_profile_hint');
|
||||
}
|
||||
}
|
||||
|
||||
[secretSelect, usernameSelect, passwordSelect].forEach(function(select) {
|
||||
if (!select) return;
|
||||
var previous = select.value;
|
||||
select.innerHTML = '';
|
||||
var placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = tKey('wizard.step2.secret_value');
|
||||
select.appendChild(placeholder);
|
||||
wizardSecrets.forEach(function(secret) {
|
||||
var option = document.createElement('option');
|
||||
option.value = secret.id;
|
||||
option.textContent = secret.name + ' · ' + secret.kind;
|
||||
select.appendChild(option);
|
||||
});
|
||||
if (previous) {
|
||||
select.value = previous;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateAuthProfileCreateUi() {
|
||||
var kind = textValue('new-auth-profile-kind') || 'bearer';
|
||||
var headerGroup = document.getElementById('auth-profile-header-name-group');
|
||||
var queryGroup = document.getElementById('auth-profile-query-param-group');
|
||||
var basicRow = document.getElementById('auth-profile-basic-secret-row');
|
||||
var singleSecretGroup = document.getElementById('auth-profile-single-secret-group');
|
||||
|
||||
if (headerGroup) {
|
||||
headerGroup.hidden = !(kind === 'bearer' || kind === 'api_key_header');
|
||||
}
|
||||
if (queryGroup) {
|
||||
queryGroup.hidden = kind !== 'api_key_query';
|
||||
}
|
||||
if (basicRow) {
|
||||
basicRow.hidden = kind !== 'basic';
|
||||
}
|
||||
if (singleSecretGroup) {
|
||||
singleSecretGroup.hidden = kind === 'basic';
|
||||
}
|
||||
}
|
||||
|
||||
function updateUpstreamAuthUi() {
|
||||
var mode = textValue('new-upstream-auth-mode') || 'none';
|
||||
var existingGroup = document.getElementById('upstream-auth-existing-group');
|
||||
var createGroup = document.getElementById('upstream-auth-create-group');
|
||||
if (existingGroup) existingGroup.hidden = mode !== 'existing';
|
||||
if (createGroup) createGroup.hidden = mode !== 'create';
|
||||
updateAuthProfileCreateUi();
|
||||
}
|
||||
|
||||
function buildUpstreamTriggerContent(name, url) {
|
||||
var fragment = document.createDocumentFragment();
|
||||
fragment.appendChild(createDivWithClass('upstream-trigger-name', name));
|
||||
fragment.appendChild(createDivWithClass('upstream-trigger-url', url));
|
||||
return fragment;
|
||||
}
|
||||
|
||||
function setUpstreamComboboxPlaceholder(container) {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
var placeholder = document.createElement('span');
|
||||
placeholder.className = 'upstream-combobox-placeholder';
|
||||
placeholder.textContent = tKey('wizard.step2.upstream_placeholder');
|
||||
container.appendChild(placeholder);
|
||||
}
|
||||
|
||||
function renderDropdownList(filter) {
|
||||
var list = document.getElementById('upstream-dropdown-list');
|
||||
if (!list) return;
|
||||
var q = (filter || '').toLowerCase();
|
||||
var filtered = upstreams.filter(function(u) {
|
||||
return !q || u.name.toLowerCase().includes(q) || u.url.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = '';
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'upstream-dropdown-empty';
|
||||
empty.textContent = tfKey('agents.drawer.ops_no_match', { query: filter || '' });
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
var tmpl = document.getElementById('tmpl-upstream-item');
|
||||
list.innerHTML = '';
|
||||
filtered.forEach(function(u) {
|
||||
var sel = u.id === selectedUpstreamId;
|
||||
var meta = upstreamMeta(u);
|
||||
var node = tmpl.content.cloneNode(true);
|
||||
var item = node.querySelector('.upstream-dropdown-item');
|
||||
if (sel) item.classList.add('selected');
|
||||
item.addEventListener('click', function() { pickUpstream(u.id); });
|
||||
node.querySelector('.upstream-dropdown-item-name').textContent = u.name;
|
||||
node.querySelector('.upstream-dropdown-item-url').textContent = u.url;
|
||||
var badge = node.querySelector('.upstream-auth-badge');
|
||||
badge.textContent = meta.label;
|
||||
badge.className = 'upstream-auth-badge auth-' + meta.kind;
|
||||
list.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
function openUpstreamDropdown() {
|
||||
var combobox = document.getElementById('upstream-combobox');
|
||||
var dropdown = document.getElementById('upstream-dropdown');
|
||||
var search = document.getElementById('upstream-search');
|
||||
if (!combobox || !dropdown) return;
|
||||
combobox.classList.add('open');
|
||||
dropdown.hidden = false;
|
||||
dropdownOpen = true;
|
||||
renderDropdownList('');
|
||||
if (search) { search.value = ''; search.focus(); }
|
||||
|
||||
var form = document.getElementById('upstream-new-form');
|
||||
if (form) form.hidden = true;
|
||||
}
|
||||
|
||||
function closeUpstreamDropdown() {
|
||||
var combobox = document.getElementById('upstream-combobox');
|
||||
var dropdown = document.getElementById('upstream-dropdown');
|
||||
if (!combobox || !dropdown) return;
|
||||
combobox.classList.remove('open');
|
||||
dropdown.hidden = true;
|
||||
dropdownOpen = false;
|
||||
}
|
||||
|
||||
function toggleUpstreamDropdown(e) {
|
||||
e.stopPropagation();
|
||||
if (dropdownOpen) { closeUpstreamDropdown(); } else { openUpstreamDropdown(); }
|
||||
}
|
||||
|
||||
function filterUpstreams(val) {
|
||||
renderDropdownList(val);
|
||||
}
|
||||
|
||||
function pickUpstream(id) {
|
||||
selectedUpstreamId = id;
|
||||
editingUpstreamId = null;
|
||||
closeUpstreamDropdown();
|
||||
|
||||
var u = upstreams.find(function(x) { return x.id === id; });
|
||||
if (!u) return;
|
||||
|
||||
var val = document.getElementById('upstream-combobox-value');
|
||||
if (val) {
|
||||
var trigTmpl = document.getElementById('tmpl-upstream-trigger-value');
|
||||
if (trigTmpl) {
|
||||
val.innerHTML = '';
|
||||
var trigNode = trigTmpl.content.cloneNode(true);
|
||||
trigNode.querySelector('.upstream-trigger-name').textContent = u.name;
|
||||
trigNode.querySelector('.upstream-trigger-url').textContent = u.url;
|
||||
val.appendChild(trigNode);
|
||||
} else {
|
||||
val.innerHTML = '';
|
||||
val.appendChild(buildUpstreamTriggerContent(u.name, u.url));
|
||||
}
|
||||
}
|
||||
|
||||
var preview = document.getElementById('upstream-preview');
|
||||
var pName = document.getElementById('upstream-preview-name');
|
||||
var pUrl = document.getElementById('upstream-preview-url');
|
||||
var pBadge = document.getElementById('upstream-preview-badge');
|
||||
if (preview) {
|
||||
var meta = upstreamMeta(u);
|
||||
pName.textContent = u.name;
|
||||
pUrl.textContent = u.url;
|
||||
pBadge.textContent = meta.label;
|
||||
pBadge.className = 'upstream-auth-badge auth-' + meta.kind;
|
||||
preview.hidden = false;
|
||||
}
|
||||
|
||||
var form = document.getElementById('upstream-new-form');
|
||||
if (form) form.hidden = true;
|
||||
var trigger = document.getElementById('upstream-new-trigger');
|
||||
if (trigger) trigger.classList.remove('active');
|
||||
|
||||
}
|
||||
|
||||
function startNewUpstream() {
|
||||
closeUpstreamDropdown();
|
||||
editingUpstreamId = null;
|
||||
var trigger = document.getElementById('upstream-new-trigger');
|
||||
var form = document.getElementById('upstream-new-form');
|
||||
var isOpen = form && !form.hidden;
|
||||
|
||||
if (isOpen) {
|
||||
if (form) form.hidden = true;
|
||||
if (trigger) trigger.classList.remove('active');
|
||||
return;
|
||||
}
|
||||
|
||||
var val = document.getElementById('upstream-combobox-value');
|
||||
if (val) setUpstreamComboboxPlaceholder(val);
|
||||
var preview = document.getElementById('upstream-preview');
|
||||
if (preview) preview.hidden = true;
|
||||
selectedUpstreamId = null;
|
||||
|
||||
if (trigger) trigger.classList.add('active');
|
||||
if (form) {
|
||||
form.hidden = false;
|
||||
setValue('new-upstream-name', '');
|
||||
setValue('new-upstream-url', '');
|
||||
setValue('new-upstream-static-headers', '{\n}');
|
||||
setValue('new-upstream-auth-mode', 'none');
|
||||
setValue('new-upstream-auth-profile', '');
|
||||
setValue('new-auth-profile-name', '');
|
||||
setValue('new-auth-profile-kind', 'bearer');
|
||||
setValue('new-auth-profile-header-name', 'Authorization');
|
||||
setValue('new-auth-profile-query-param', 'api_key');
|
||||
setValue('new-auth-profile-secret-id', '');
|
||||
setValue('new-auth-profile-username-secret', '');
|
||||
setValue('new-auth-profile-password-secret', '');
|
||||
updateUpstreamAuthUi();
|
||||
var first = form.querySelector('input');
|
||||
if (first) setTimeout(function() { first.focus(); }, 50);
|
||||
}
|
||||
}
|
||||
|
||||
function beginEditSelectedUpstream(e) {
|
||||
if (e) e.stopPropagation();
|
||||
var upstream = upstreams.find(function(item) { return item.id === selectedUpstreamId; });
|
||||
if (!upstream) return;
|
||||
|
||||
closeUpstreamDropdown();
|
||||
editingUpstreamId = upstream.id;
|
||||
|
||||
var trigger = document.getElementById('upstream-new-trigger');
|
||||
var form = document.getElementById('upstream-new-form');
|
||||
var preview = document.getElementById('upstream-preview');
|
||||
if (trigger) trigger.classList.add('active');
|
||||
if (preview) preview.hidden = true;
|
||||
if (form) {
|
||||
form.hidden = false;
|
||||
setValue('new-upstream-name', upstream.name);
|
||||
setValue('new-upstream-url', upstream.url);
|
||||
setValue('new-upstream-static-headers', upstream.staticHeaders || '{\n}');
|
||||
setValue('new-upstream-auth-profile', upstream.authProfileId || '');
|
||||
setValue('new-upstream-auth-mode', upstream.authProfileId ? 'existing' : 'none');
|
||||
updateUpstreamAuthUi();
|
||||
}
|
||||
}
|
||||
|
||||
function buildAuthProfileConfig(kind) {
|
||||
if (kind === 'bearer') {
|
||||
var bearerHeader = textValue('new-auth-profile-header-name') || 'Authorization';
|
||||
var bearerSecretId = textValue('new-auth-profile-secret-id');
|
||||
if (!bearerHeader) throw new Error(tKey('wizard.error.header_name_required'));
|
||||
if (!bearerSecretId) throw new Error(tKey('wizard.error.secret_required'));
|
||||
return {
|
||||
bearer: {
|
||||
header_name: bearerHeader,
|
||||
secret_id: bearerSecretId,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'basic') {
|
||||
var usernameSecretId = textValue('new-auth-profile-username-secret');
|
||||
var passwordSecretId = textValue('new-auth-profile-password-secret');
|
||||
if (!usernameSecretId || !passwordSecretId) throw new Error(tKey('wizard.error.basic_secrets_required'));
|
||||
return {
|
||||
basic: {
|
||||
username_secret_id: usernameSecretId,
|
||||
password_secret_id: passwordSecretId,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'api_key_header') {
|
||||
var headerName = textValue('new-auth-profile-header-name');
|
||||
var headerSecretId = textValue('new-auth-profile-secret-id');
|
||||
if (!headerName) throw new Error(tKey('wizard.error.header_name_required'));
|
||||
if (!headerSecretId) throw new Error(tKey('wizard.error.secret_required'));
|
||||
return {
|
||||
api_key_header: {
|
||||
header_name: headerName,
|
||||
secret_id: headerSecretId,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var queryParam = textValue('new-auth-profile-query-param');
|
||||
var querySecretId = textValue('new-auth-profile-secret-id');
|
||||
if (!queryParam) throw new Error(tKey('wizard.error.query_param_required'));
|
||||
if (!querySecretId) throw new Error(tKey('wizard.error.secret_required'));
|
||||
return {
|
||||
api_key_query: {
|
||||
param_name: queryParam,
|
||||
secret_id: querySecretId,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function createAuthProfileFromForm() {
|
||||
var name = textValue('new-auth-profile-name');
|
||||
var kind = textValue('new-auth-profile-kind') || 'bearer';
|
||||
if (!name) throw new Error(tKey('wizard.error.auth_profile_name'));
|
||||
|
||||
var profile = await window.CrankApi.createAuthProfile(wizardWorkspaceId, {
|
||||
name: name,
|
||||
kind: kind,
|
||||
config: buildAuthProfileConfig(kind),
|
||||
});
|
||||
wizardAuthProfiles.push(profile);
|
||||
renderAuthProfileOptions();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tKey('wizard.toast.auth_profile_created_body'),
|
||||
tKey('wizard.toast.auth_profile_created_title')
|
||||
);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function saveNewUpstream(e) {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
var nameEl = document.getElementById('new-upstream-name');
|
||||
var urlEl = document.getElementById('new-upstream-url');
|
||||
var name = nameEl ? nameEl.value.trim() : '';
|
||||
var url = urlEl ? urlEl.value.trim() : '';
|
||||
if (!name) { if (nameEl) nameEl.focus(); return; }
|
||||
if (!url) { if (urlEl) urlEl.focus(); return; }
|
||||
|
||||
var staticHeadersEl = document.getElementById('new-upstream-static-headers');
|
||||
var staticHeaders = staticHeadersEl ? staticHeadersEl.value : '{\n}';
|
||||
parseHeaderMap(staticHeaders);
|
||||
|
||||
var authMode = textValue('new-upstream-auth-mode') || 'none';
|
||||
var authProfileId = null;
|
||||
var authProfileName = '';
|
||||
var authKind = null;
|
||||
|
||||
if (authMode === 'existing') {
|
||||
authProfileId = textValue('new-upstream-auth-profile');
|
||||
if (!authProfileId) throw new Error(tKey('wizard.error.auth_profile_required'));
|
||||
var existingProfile = authProfileById(authProfileId);
|
||||
authProfileName = existingProfile ? existingProfile.name : '';
|
||||
authKind = existingProfile ? existingProfile.kind : null;
|
||||
} else if (authMode === 'create') {
|
||||
var createdProfile = await createAuthProfileFromForm();
|
||||
authProfileId = createdProfile.id;
|
||||
authProfileName = createdProfile.name;
|
||||
authKind = createdProfile.kind;
|
||||
setValue('new-upstream-auth-profile', createdProfile.id);
|
||||
setValue('new-upstream-auth-mode', 'existing');
|
||||
}
|
||||
var payload = {
|
||||
name: name,
|
||||
base_url: url,
|
||||
static_headers: parseHeaderMap(staticHeaders),
|
||||
auth_profile_id: authProfileId,
|
||||
};
|
||||
var saved = editingUpstreamId
|
||||
? await window.CrankApi.updateUpstream(wizardWorkspaceId, editingUpstreamId, payload)
|
||||
: await window.CrankApi.createUpstream(wizardWorkspaceId, payload);
|
||||
var nextRecord = mapWorkspaceUpstream(saved);
|
||||
nextRecord.authProfileName = authProfileName;
|
||||
nextRecord.authKind = authKind;
|
||||
|
||||
if (editingUpstreamId) {
|
||||
upstreams = upstreams.map(function(item) {
|
||||
return item.id === editingUpstreamId ? nextRecord : item;
|
||||
});
|
||||
} else {
|
||||
upstreams.push(nextRecord);
|
||||
}
|
||||
|
||||
if (nameEl) nameEl.value = '';
|
||||
if (urlEl) urlEl.value = '';
|
||||
if (staticHeadersEl) staticHeadersEl.value = '{\n}';
|
||||
var form = document.getElementById('upstream-new-form');
|
||||
if (form) form.hidden = true;
|
||||
editingUpstreamId = null;
|
||||
pickUpstream(nextRecord.id);
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || tKey('wizard.toast.auth_profile_error_title'),
|
||||
tKey('wizard.toast.auth_profile_error_title')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelNewUpstream(e) {
|
||||
e.stopPropagation();
|
||||
var form = document.getElementById('upstream-new-form');
|
||||
if (form) form.hidden = true;
|
||||
var trigger = document.getElementById('upstream-new-trigger');
|
||||
if (trigger) trigger.classList.remove('active');
|
||||
editingUpstreamId = null;
|
||||
}
|
||||
|
||||
function openQuickSecretModal(e) {
|
||||
if (e) e.preventDefault();
|
||||
var modal = document.getElementById('quick-secret-modal');
|
||||
if (!modal) return;
|
||||
setValue('quick-secret-name', '');
|
||||
setValue('quick-secret-value', '');
|
||||
setValue('quick-secret-kind', 'token');
|
||||
modal.classList.add('open');
|
||||
setTimeout(function() {
|
||||
var input = document.getElementById('quick-secret-name');
|
||||
if (input) input.focus();
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function closeQuickSecretModal() {
|
||||
var modal = document.getElementById('quick-secret-modal');
|
||||
if (modal) modal.classList.remove('open');
|
||||
}
|
||||
|
||||
async function submitQuickSecret() {
|
||||
var name = textValue('quick-secret-name');
|
||||
var value = textValue('quick-secret-value');
|
||||
var kind = textValue('quick-secret-kind') || 'token';
|
||||
if (!name) throw new Error(tKey('wizard.error.secret_name_required'));
|
||||
if (!value) throw new Error(tKey('wizard.error.secret_value_required'));
|
||||
|
||||
var secret = await window.CrankApi.createSecret(wizardWorkspaceId, {
|
||||
name: name,
|
||||
kind: kind,
|
||||
value: value,
|
||||
});
|
||||
wizardSecrets.push(secret);
|
||||
renderAuthProfileOptions();
|
||||
var primarySelect = document.getElementById('new-auth-profile-secret-id');
|
||||
var usernameSelect = document.getElementById('new-auth-profile-username-secret');
|
||||
var passwordSelect = document.getElementById('new-auth-profile-password-secret');
|
||||
if (primarySelect && primarySelect.offsetParent !== null) {
|
||||
primarySelect.value = secret.id;
|
||||
} else if (usernameSelect && !usernameSelect.value) {
|
||||
usernameSelect.value = secret.id;
|
||||
} else if (passwordSelect) {
|
||||
passwordSelect.value = secret.id;
|
||||
}
|
||||
closeQuickSecretModal();
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.success(
|
||||
tKey('wizard.toast.quick_secret_created_body'),
|
||||
tKey('wizard.toast.quick_secret_created_title')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
function tKey(key) {
|
||||
return typeof t === 'function' ? t(key) : key;
|
||||
}
|
||||
function tfKey(key, vars) {
|
||||
return typeof tf === 'function' ? tf(key, vars) : key;
|
||||
}
|
||||
var TOTAL_STEPS = window.CrankWizardShell.TOTAL_STEPS;
|
||||
var loadProtocolCapabilities = window.CrankWizardState.loadProtocolCapabilities;
|
||||
var loadEditionCapabilities = window.CrankWizardState.loadEditionCapabilities;
|
||||
var currentProtocolCapabilities = window.CrankWizardState.currentProtocolCapabilities;
|
||||
var loadStep = window.CrankWizardShell.loadStep;
|
||||
var goToStep = window.CrankWizardShell.goToStep;
|
||||
var _doGoToStep = window.CrankWizardShell.doGoToStep;
|
||||
var loadWizardPanels = window.CrankWizardShell.loadWizardPanels;
|
||||
var bindProtocolCards = window.CrankWizardShell.bindProtocolCards;
|
||||
var applyEditionProtocolVisibility = window.CrankWizardShell.applyEditionProtocolVisibility;
|
||||
var step3PanelId = window.CrankWizardShell.step3PanelId;
|
||||
var saveOperation = window.CrankWizardLive.saveOperation;
|
||||
var loadOperationForEdit = window.CrankWizardLive.loadOperationForEdit;
|
||||
var bindWizardLiveActions = window.CrankWizardLive.bindWizardLiveActions;
|
||||
var updateWizardProtocolVisibility = window.CrankWizardLive.updateWizardProtocolVisibility;
|
||||
var currentStep = window.currentStep;
|
||||
var wizardProtocol = window.wizardProtocol;
|
||||
var wizardMode = window.wizardMode;
|
||||
var wizardEditId = window.wizardEditId;
|
||||
var wizardWorkspaceId = window.wizardWorkspaceId;
|
||||
var wizardCurrentOperation = window.wizardCurrentOperation;
|
||||
var wizardCurrentVersion = window.wizardCurrentVersion;
|
||||
var wizardProtoUpload = window.wizardProtoUpload;
|
||||
var wizardDescriptorSetUpload = window.wizardDescriptorSetUpload;
|
||||
var wizardSoapWsdlUpload = window.wizardSoapWsdlUpload;
|
||||
var wizardSoapXsdUpload = window.wizardSoapXsdUpload;
|
||||
var wizardTestResponsePreview = window.wizardTestResponsePreview;
|
||||
var wizardProtocolCapabilities = window.wizardProtocolCapabilities;
|
||||
var wizardSecrets = window.wizardSecrets;
|
||||
var wizardAuthProfiles = window.wizardAuthProfiles;
|
||||
var selectedUpstreamId = window.selectedUpstreamId;
|
||||
var editingUpstreamId = window.editingUpstreamId;
|
||||
var protoParsed = window.protoParsed;
|
||||
var selectedRpcMethod = window.selectedRpcMethod;
|
||||
|
||||
async function initWizardPage() {
|
||||
renderSidebarBrand('create');
|
||||
document.querySelector('.btn-continue').addEventListener('click', function() {
|
||||
if (currentStep < TOTAL_STEPS) {
|
||||
goToStep(currentStep + 1);
|
||||
} else {
|
||||
saveOperation();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('.btn-back').addEventListener('click', function() {
|
||||
if (!this.disabled && currentStep > 1) goToStep(currentStep - 1);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.step-item').forEach(function(item, i) {
|
||||
item.addEventListener('click', function() { goToStep(i + 1); });
|
||||
});
|
||||
|
||||
var backToCatalog = document.getElementById('back-to-catalog');
|
||||
if (backToCatalog) {
|
||||
backToCatalog.addEventListener('click', function() {
|
||||
window.location.href = (window.CrankRoutes && window.CrankRoutes.home) || '/';
|
||||
});
|
||||
}
|
||||
|
||||
var closeBtn = document.querySelector('.progress-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', function() {
|
||||
window.location.href = (window.CrankRoutes && window.CrankRoutes.home) || '/';
|
||||
});
|
||||
}
|
||||
|
||||
var saveDraftBtn = document.querySelector('.btn-save-draft');
|
||||
if (saveDraftBtn) {
|
||||
saveDraftBtn.addEventListener('click', function() {
|
||||
saveOperation(true);
|
||||
});
|
||||
}
|
||||
|
||||
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
wizardWorkspaceId = workspace ? workspace.id : null;
|
||||
window.wizardWorkspaceId = wizardWorkspaceId;
|
||||
var editionCapabilities = await loadEditionCapabilities();
|
||||
await loadProtocolCapabilities();
|
||||
|
||||
await loadWizardPanels([1, 2, 3, 4, 5]);
|
||||
if (window.CrankOverlay && typeof window.CrankOverlay.render === 'function') {
|
||||
await window.CrankOverlay.render(document, {
|
||||
workspace: workspace,
|
||||
capabilities: editionCapabilities,
|
||||
locale: localStorage.getItem('crank_lang') || 'en',
|
||||
});
|
||||
}
|
||||
await loadWizardAuthResources();
|
||||
bindProtocolCards();
|
||||
applyEditionProtocolVisibility(editionCapabilities);
|
||||
bindWizardLiveActions();
|
||||
updateUpstreamAuthUi();
|
||||
renderEditionCapabilityHints(editionCapabilities);
|
||||
|
||||
var quickSecretModal = document.getElementById('quick-secret-modal');
|
||||
var quickSecretClose = document.getElementById('quick-secret-close-btn');
|
||||
var quickSecretCancel = document.getElementById('quick-secret-cancel-btn');
|
||||
var quickSecretSubmit = document.getElementById('quick-secret-submit-btn');
|
||||
if (quickSecretClose) quickSecretClose.addEventListener('click', closeQuickSecretModal);
|
||||
if (quickSecretCancel) quickSecretCancel.addEventListener('click', closeQuickSecretModal);
|
||||
if (quickSecretModal) {
|
||||
quickSecretModal.addEventListener('click', function(event) {
|
||||
if (event.target === quickSecretModal) closeQuickSecretModal();
|
||||
});
|
||||
}
|
||||
if (quickSecretSubmit) {
|
||||
quickSecretSubmit.addEventListener('click', async function() {
|
||||
var button = quickSecretSubmit;
|
||||
var original = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = tKey('wizard.step2.quick_secret_submit');
|
||||
try {
|
||||
await submitQuickSecret();
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(
|
||||
error.message || tKey('wizard.toast.quick_secret_error_title'),
|
||||
tKey('wizard.toast.quick_secret_error_title')
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = original;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
if (params.get('mode') === 'edit' && params.get('operationId')) {
|
||||
wizardMode = 'edit';
|
||||
wizardEditId = params.get('operationId');
|
||||
window.wizardMode = wizardMode;
|
||||
window.wizardEditId = wizardEditId;
|
||||
document.title = 'Crank — ' + tKey('wizard.progress.edit');
|
||||
await loadOperationForEdit();
|
||||
}
|
||||
|
||||
updateWizardProtocolVisibility();
|
||||
_doGoToStep(1);
|
||||
}
|
||||
|
||||
function renderEditionCapabilityHints(capabilities) {
|
||||
var securityNote = document.getElementById('wizard-security-level-note');
|
||||
var securityText = securityNote ? securityNote.querySelector('.info-callout-text') : null;
|
||||
if (!securityText) return;
|
||||
var levels = capabilities && Array.isArray(capabilities.supported_security_levels)
|
||||
? capabilities.supported_security_levels
|
||||
: ['standard'];
|
||||
securityNote.hidden = !(levels.length === 1 && levels[0] === 'standard');
|
||||
if (!securityNote.hidden) {
|
||||
securityText.textContent = tKey('wizard.step5.community_security_note');
|
||||
}
|
||||
}
|
||||
|
||||
window.renderEditionCapabilityHints = renderEditionCapabilityHints;
|
||||
|
||||
if (window.CrankDiagnostics && typeof window.CrankDiagnostics.bootstrap === 'function') {
|
||||
window.CrankDiagnostics.bootstrap('wizard', initWizardPage);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
void initWizardPage();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/* ── HTTP method picker (Step 4 REST) ── */
|
||||
|
||||
var METHOD_CALLOUTS = {
|
||||
GET: null,
|
||||
DELETE: null,
|
||||
POST: { icon: 'info', text: { en: { title: 'POST selected — body encoding required.', body: 'In step 4 you will define the JSON schema for the body payload. Crank will automatically serialize MCP tool arguments into the format you specify.' }, ru: { title: 'Выбран POST — требуется кодирование тела запроса.', body: 'На шаге 4 вы зададите JSON-схему для body payload. Crank автоматически сериализует аргументы MCP-инструмента в выбранный формат.' } } },
|
||||
PUT: { icon: 'info', text: { en: { title: 'PUT selected — full-resource replacement.', body: 'The request body must contain a complete resource representation. Define the body schema in step 4.' }, ru: { title: 'Выбран PUT — полная замена ресурса.', body: 'Request body должен содержать полное представление ресурса. Задайте body schema на шаге 4.' } } },
|
||||
PATCH: { icon: 'info', text: { en: { title: 'PATCH selected — partial update.', body: 'Only include the fields you want to modify in the body schema defined in step 4.' }, ru: { title: 'Выбран PATCH — частичное обновление.', body: 'В body schema на шаге 4 включайте только те поля, которые хотите изменить.' } } },
|
||||
};
|
||||
|
||||
function renderSidebarBrand(mode) {
|
||||
var sidebarBrand = document.querySelector('.step-sidebar-brand');
|
||||
if (!sidebarBrand) return;
|
||||
var language = localStorage.getItem('crank_lang') || 'en';
|
||||
var parts;
|
||||
if (mode === 'edit') {
|
||||
parts = language === 'ru'
|
||||
? { prefix: 'Редактировать ', suffix: 'операцию' }
|
||||
: { prefix: 'Edit ', suffix: 'operation' };
|
||||
} else {
|
||||
parts = language === 'ru'
|
||||
? { prefix: 'Создать ', suffix: 'операцию' }
|
||||
: { prefix: 'Create ', suffix: 'operation' };
|
||||
}
|
||||
sidebarBrand.dataset.wizardBrand = mode;
|
||||
sidebarBrand.textContent = parts.prefix;
|
||||
var accent = document.createElement('span');
|
||||
accent.textContent = parts.suffix;
|
||||
sidebarBrand.appendChild(accent);
|
||||
}
|
||||
|
||||
window.addEventListener('crank:langchange', function() {
|
||||
renderSidebarBrand(wizardMode === 'edit' ? 'edit' : 'create');
|
||||
});
|
||||
|
||||
function selectMethod(btn) {
|
||||
document.querySelectorAll('.method-card').forEach(function(b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
var method = btn.dataset.method;
|
||||
var callout = document.getElementById('method-callout-rest');
|
||||
if (!callout) return;
|
||||
var info = METHOD_CALLOUTS[method];
|
||||
if (info) {
|
||||
var lang = localStorage.getItem('crank_lang') || 'en';
|
||||
var text = typeof info.text === 'string' ? info.text : (info.text[lang] || info.text.en);
|
||||
callout.innerHTML = '';
|
||||
var icon = buildIconSvg(
|
||||
(window.APP_BASE || '') + 'icons/general/info-circle.svg#icon',
|
||||
15,
|
||||
15
|
||||
);
|
||||
icon.style.flexShrink = '0';
|
||||
icon.style.color = 'var(--accent)';
|
||||
callout.appendChild(icon);
|
||||
|
||||
var content = document.createElement('span');
|
||||
var title = document.createElement('strong');
|
||||
title.textContent = text.title;
|
||||
content.appendChild(title);
|
||||
content.appendChild(document.createTextNode(' ' + text.body));
|
||||
callout.appendChild(content);
|
||||
callout.hidden = false;
|
||||
} else {
|
||||
callout.replaceChildren();
|
||||
callout.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── GraphQL operation type picker (Step 4 GraphQL) ── */
|
||||
|
||||
function selectGqlType(btn) {
|
||||
document.querySelectorAll('.gql-type-card').forEach(function(b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
// Update query editor placeholder if it's still the default
|
||||
var editor = document.getElementById('gql-query-editor');
|
||||
if (!editor) return;
|
||||
var type = btn.dataset.gqlType;
|
||||
var defaults = {
|
||||
query: 'query GetContact($id: ID!) {\n contact(id: $id) {\n id\n firstName\n lastName\n email\n company {\n id\n name\n }\n createdAt\n }\n}',
|
||||
mutation: 'mutation CreateContact(\n $firstName: String!\n $lastName: String!\n $email: String!\n $company: String\n) {\n createContact(input: {\n firstName: $firstName\n lastName: $lastName\n email: $email\n company: $company\n }) {\n id\n firstName\n lastName\n createdAt\n }\n}'
|
||||
};
|
||||
if (defaults[type]) editor.value = defaults[type];
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener('click', function() {
|
||||
if (dropdownOpen) closeUpstreamDropdown();
|
||||
});
|
||||
|
||||
|
||||
function textValue(id) {
|
||||
var element = document.getElementById(id);
|
||||
return element ? element.value.trim() : '';
|
||||
}
|
||||
|
||||
function setValue(id, value) {
|
||||
var element = document.getElementById(id);
|
||||
if (element) element.value = value || '';
|
||||
}
|
||||
|
||||
function parseStructuredText(text) {
|
||||
if (!text.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (_error) {
|
||||
if (!window.jsyaml) throw new Error(tKey('wizard.error.parser_unavailable'));
|
||||
return window.jsyaml.load(text) || {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
var workspaceFormState = {
|
||||
selectedColor: '#0d9488',
|
||||
slugManual: false,
|
||||
isCreateMode: false,
|
||||
formDirty: false,
|
||||
workspaceId: null,
|
||||
workspaceRecord: null,
|
||||
memberships: [],
|
||||
invitations: [],
|
||||
currentUserId: null,
|
||||
};
|
||||
function tKey(key) {
|
||||
return typeof t === 'function' ? t(key) : key;
|
||||
}
|
||||
function tfKey(key, vars) {
|
||||
return typeof tf === 'function' ? tf(key, vars) : key;
|
||||
}
|
||||
|
||||
function normalizeSlug(value) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function uiRoleToApi(value) {
|
||||
if (value === 'developer') {
|
||||
return 'operator';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function roleLabel(value) {
|
||||
if (value === 'operator') return tKey('workspace_setup.role.operator');
|
||||
if (value === 'owner') return tKey('workspace_setup.role.owner');
|
||||
if (value === 'admin') return tKey('workspace_setup.role.admin');
|
||||
if (value === 'viewer') return tKey('workspace_setup.role.viewer');
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function roleClass(value) {
|
||||
if (value === 'operator') {
|
||||
return 'role-developer';
|
||||
}
|
||||
return 'role-' + value;
|
||||
}
|
||||
|
||||
function setFormDirty(value) {
|
||||
workspaceFormState.formDirty = value;
|
||||
}
|
||||
|
||||
function formElements() {
|
||||
return {
|
||||
name: document.getElementById('ws-name'),
|
||||
slug: document.getElementById('ws-slug'),
|
||||
desc: document.getElementById('ws-desc'),
|
||||
submit: document.getElementById('submit-btn'),
|
||||
avatar: document.getElementById('ws-avatar-preview'),
|
||||
};
|
||||
}
|
||||
|
||||
function createElement(tag, className, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (className) {
|
||||
node.className = className;
|
||||
}
|
||||
if (text !== undefined && text !== null) {
|
||||
node.textContent = text;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function buildIconSvg(href, width, height, viewBox, attrs) {
|
||||
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', String(width));
|
||||
svg.setAttribute('height', String(height));
|
||||
if (viewBox) {
|
||||
svg.setAttribute('viewBox', viewBox);
|
||||
}
|
||||
if (attrs) {
|
||||
Object.keys(attrs).forEach(function(key) {
|
||||
svg.setAttribute(key, attrs[key]);
|
||||
});
|
||||
}
|
||||
var use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use.setAttribute('href', href);
|
||||
svg.appendChild(use);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function workspacePayload() {
|
||||
var elements = formElements();
|
||||
return {
|
||||
slug: elements.slug.value.trim(),
|
||||
display_name: elements.name.value.trim(),
|
||||
settings: {
|
||||
description: elements.desc.value.trim(),
|
||||
color: workspaceFormState.selectedColor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyWorkspaceRecord(record) {
|
||||
var workspace = record.workspace;
|
||||
var settings = workspace.settings || {};
|
||||
var elements = formElements();
|
||||
|
||||
workspaceFormState.workspaceId = workspace.id;
|
||||
workspaceFormState.workspaceRecord = record;
|
||||
workspaceFormState.selectedColor = settings.color || '#0d9488';
|
||||
workspaceFormState.slugManual = true;
|
||||
|
||||
elements.name.value = workspace.display_name || '';
|
||||
elements.slug.value = workspace.slug || '';
|
||||
elements.desc.value = settings.description || '';
|
||||
elements.avatar.textContent = (workspace.display_name || workspace.slug || '?').charAt(0).toUpperCase();
|
||||
elements.avatar.style.background = workspaceFormState.selectedColor;
|
||||
|
||||
document.querySelectorAll('.ws-color-swatch').forEach(function (swatch) {
|
||||
swatch.classList.toggle('active', swatch.dataset.color === workspaceFormState.selectedColor);
|
||||
});
|
||||
|
||||
setFormDirty(false);
|
||||
}
|
||||
|
||||
function addMemberRow(container, membership) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'member-row';
|
||||
var isCurrentUser = membership.user.id === workspaceFormState.currentUserId;
|
||||
var canManage = membership.role !== 'owner';
|
||||
var avatar = createElement(
|
||||
'div',
|
||||
'member-avatar',
|
||||
membership.user.display_name.slice(0, 2).toUpperCase()
|
||||
);
|
||||
avatar.style.background =
|
||||
'linear-gradient(135deg,' + workspaceFormState.selectedColor + ',#6366f1)';
|
||||
row.appendChild(avatar);
|
||||
|
||||
var info = createElement('div', 'member-info');
|
||||
var name = createElement('div', 'member-name', membership.user.display_name);
|
||||
if (isCurrentUser) {
|
||||
name.appendChild(document.createTextNode(' '));
|
||||
var currentUserLabel = createElement('span', null, '(you)');
|
||||
currentUserLabel.style.fontSize = '11px';
|
||||
currentUserLabel.style.fontWeight = '400';
|
||||
currentUserLabel.style.color = 'var(--text-muted)';
|
||||
name.appendChild(currentUserLabel);
|
||||
}
|
||||
info.appendChild(name);
|
||||
info.appendChild(createElement('div', 'member-email', membership.user.email));
|
||||
row.appendChild(info);
|
||||
|
||||
row.appendChild(createElement('div', 'member-last-active', '—'));
|
||||
|
||||
if (canManage) {
|
||||
var select = createElement('select', 'member-role-select');
|
||||
select.dataset.previous = membership.role;
|
||||
[
|
||||
['owner', tKey('workspace_setup.role.owner')],
|
||||
['admin', tKey('workspace_setup.role.admin')],
|
||||
['operator', tKey('workspace_setup.role.operator')],
|
||||
['viewer', tKey('workspace_setup.role.viewer')]
|
||||
].forEach(function(optionData) {
|
||||
var option = document.createElement('option');
|
||||
option.value = optionData[0];
|
||||
option.textContent = optionData[1];
|
||||
option.selected = membership.role === optionData[0];
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.addEventListener('change', function() {
|
||||
updateRole(select, membership.user.id);
|
||||
});
|
||||
row.appendChild(select);
|
||||
|
||||
var removeButton = createElement('button', 'member-remove-btn');
|
||||
removeButton.type = 'button';
|
||||
removeButton.title = tKey('workspace_setup.members.remove');
|
||||
removeButton.appendChild(
|
||||
buildIconSvg(
|
||||
'',
|
||||
12,
|
||||
12,
|
||||
'0 0 16 16',
|
||||
{
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '1.8',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round'
|
||||
}
|
||||
)
|
||||
);
|
||||
var removeSvg = removeButton.querySelector('svg');
|
||||
var pathA = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
pathA.setAttribute('d', 'M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4');
|
||||
var pathB = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
pathB.setAttribute('d', 'M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9');
|
||||
removeSvg.appendChild(pathA);
|
||||
removeSvg.appendChild(pathB);
|
||||
removeButton.addEventListener('click', function() {
|
||||
removeMember(membership.user.id, membership.user.display_name);
|
||||
});
|
||||
row.appendChild(removeButton);
|
||||
} else {
|
||||
row.appendChild(
|
||||
createElement(
|
||||
'span',
|
||||
'member-role-badge ' + roleClass(membership.role),
|
||||
roleLabel(membership.role)
|
||||
)
|
||||
);
|
||||
}
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
function renderMembers() {
|
||||
var container = document.getElementById('members-list');
|
||||
var label = document.getElementById('members-count-label');
|
||||
if (!container || !label) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
workspaceFormState.memberships.forEach(function (membership) {
|
||||
addMemberRow(container, membership);
|
||||
});
|
||||
label.textContent = window.tPlural(
|
||||
'workspace_setup.member_count',
|
||||
workspaceFormState.memberships.length,
|
||||
{ count: workspaceFormState.memberships.length }
|
||||
);
|
||||
}
|
||||
|
||||
function renderInvitations() {
|
||||
var pending = document.getElementById('pending-invites');
|
||||
var count = document.getElementById('pending-count');
|
||||
if (!pending || !count) {
|
||||
return;
|
||||
}
|
||||
|
||||
var items = workspaceFormState.invitations.filter(function (record) {
|
||||
return record.invitation.status === 'pending';
|
||||
});
|
||||
|
||||
count.textContent = String(items.length);
|
||||
if (!items.length) {
|
||||
pending.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
pending.hidden = false;
|
||||
pending.innerHTML = '';
|
||||
var heading = createElement('div', null, tKey('workspace_setup.members.pending') + ' ');
|
||||
heading.style.fontSize = '11px';
|
||||
heading.style.fontWeight = '600';
|
||||
heading.style.textTransform = 'uppercase';
|
||||
heading.style.letterSpacing = '0.06em';
|
||||
heading.style.color = 'var(--text-muted)';
|
||||
heading.style.marginBottom = '10px';
|
||||
var countPill = createElement('span', null, String(items.length));
|
||||
countPill.id = 'pending-count';
|
||||
countPill.style.background = 'rgba(139,148,158,0.15)';
|
||||
countPill.style.borderRadius = '10px';
|
||||
countPill.style.padding = '1px 7px';
|
||||
countPill.style.fontSize = '10px';
|
||||
heading.appendChild(countPill);
|
||||
pending.appendChild(heading);
|
||||
|
||||
items.forEach(function (record) {
|
||||
var invitation = record.invitation;
|
||||
var row = document.createElement('div');
|
||||
row.className = 'member-row';
|
||||
var inviteAvatar = createElement('div', 'member-avatar', '?');
|
||||
inviteAvatar.style.background = 'var(--bg-canvas)';
|
||||
inviteAvatar.style.border = '1.5px dashed var(--border)';
|
||||
inviteAvatar.style.color = 'var(--text-muted)';
|
||||
inviteAvatar.style.fontSize = '16px';
|
||||
row.appendChild(inviteAvatar);
|
||||
|
||||
var inviteInfo = createElement('div', 'member-info');
|
||||
inviteInfo.appendChild(createElement('div', 'member-name', invitation.email));
|
||||
inviteInfo.appendChild(
|
||||
createElement(
|
||||
'div',
|
||||
'member-email',
|
||||
tfKey('workspace_setup.members.invited_pending', { date: invitation.created_at.slice(0, 10) })
|
||||
)
|
||||
);
|
||||
row.appendChild(inviteInfo);
|
||||
row.appendChild(createElement('div', 'member-last-active', '—'));
|
||||
|
||||
var roleBadge = createElement('span', 'member-role-badge', roleLabel(invitation.role));
|
||||
roleBadge.style.background = 'rgba(139,148,158,0.12)';
|
||||
roleBadge.style.color = 'var(--text-muted)';
|
||||
roleBadge.style.border = '1px solid var(--border)';
|
||||
row.appendChild(roleBadge);
|
||||
|
||||
var revokeButton = createElement('button', 'member-remove-btn');
|
||||
revokeButton.type = 'button';
|
||||
revokeButton.title = tKey('workspace_setup.members.revoke');
|
||||
var revokeSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
revokeSvg.setAttribute('width', '12');
|
||||
revokeSvg.setAttribute('height', '12');
|
||||
revokeSvg.setAttribute('viewBox', '0 0 16 16');
|
||||
revokeSvg.setAttribute('fill', 'none');
|
||||
revokeSvg.setAttribute('stroke', 'currentColor');
|
||||
revokeSvg.setAttribute('stroke-width', '1.8');
|
||||
revokeSvg.setAttribute('stroke-linecap', 'round');
|
||||
var lineA = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
lineA.setAttribute('x1', '2');
|
||||
lineA.setAttribute('y1', '2');
|
||||
lineA.setAttribute('x2', '14');
|
||||
lineA.setAttribute('y2', '14');
|
||||
var lineB = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
lineB.setAttribute('x1', '14');
|
||||
lineB.setAttribute('y1', '2');
|
||||
lineB.setAttribute('x2', '2');
|
||||
lineB.setAttribute('y2', '14');
|
||||
revokeSvg.appendChild(lineA);
|
||||
revokeSvg.appendChild(lineB);
|
||||
revokeButton.appendChild(revokeSvg);
|
||||
revokeButton.addEventListener('click', function() {
|
||||
revokeInviteById(invitation.id);
|
||||
});
|
||||
row.appendChild(revokeButton);
|
||||
pending.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWorkspaceAccessData() {
|
||||
if (workspaceFormState.isCreateMode || !window.CrankApi || !workspaceFormState.workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.CrankAuth && typeof window.CrankAuth.fetchSession === 'function') {
|
||||
try {
|
||||
var session = await window.CrankAuth.fetchSession(false);
|
||||
workspaceFormState.currentUserId = session && session.user ? session.user.id : null;
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
var membershipsResponse = await window.CrankApi.listMemberships(workspaceFormState.workspaceId);
|
||||
var invitationsResponse = await window.CrankApi.listInvitations(workspaceFormState.workspaceId);
|
||||
workspaceFormState.memberships = membershipsResponse && membershipsResponse.items ? membershipsResponse.items : [];
|
||||
workspaceFormState.invitations = invitationsResponse && invitationsResponse.items ? invitationsResponse.items : [];
|
||||
renderMembers();
|
||||
renderInvitations();
|
||||
}
|
||||
|
||||
function updatePageMode() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
workspaceFormState.isCreateMode = params.get('mode') === 'create';
|
||||
|
||||
var submit = document.getElementById('submit-btn');
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
document.title = 'Crank — ' + tKey('workspace_setup.create.title');
|
||||
document.getElementById('page-title').textContent = tKey('workspace_setup.create.title');
|
||||
document.getElementById('page-subtitle').textContent = tKey('workspace_setup.create.subtitle');
|
||||
document.getElementById('section-invite').hidden = false;
|
||||
document.getElementById('footer-note').hidden = false;
|
||||
submit.textContent = tKey('workspace_setup.actions.create');
|
||||
submit.disabled = true;
|
||||
submit.classList.add('is-disabled');
|
||||
} else {
|
||||
document.getElementById('section-members').hidden = false;
|
||||
document.getElementById('section-danger').hidden = false;
|
||||
submit.textContent = tKey('workspace_setup.actions.save');
|
||||
submit.classList.remove('is-disabled');
|
||||
}
|
||||
}
|
||||
|
||||
function onWsNameInput(value) {
|
||||
var elements = formElements();
|
||||
var normalized = value.trim();
|
||||
elements.avatar.textContent = normalized ? normalized.charAt(0).toUpperCase() : '?';
|
||||
if (!workspaceFormState.slugManual) {
|
||||
elements.slug.value = normalizeSlug(value);
|
||||
}
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
validateCreateForm();
|
||||
}
|
||||
setFormDirty(true);
|
||||
}
|
||||
|
||||
function onWsSlugInput(value) {
|
||||
formElements().slug.value = normalizeSlug(value);
|
||||
workspaceFormState.slugManual = true;
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
validateCreateForm();
|
||||
}
|
||||
setFormDirty(true);
|
||||
}
|
||||
|
||||
function pickColor(element) {
|
||||
workspaceFormState.selectedColor = element.dataset.color;
|
||||
document.querySelectorAll('.ws-color-swatch').forEach(function (swatch) {
|
||||
swatch.classList.remove('active');
|
||||
});
|
||||
element.classList.add('active');
|
||||
formElements().avatar.style.background = workspaceFormState.selectedColor;
|
||||
setFormDirty(true);
|
||||
}
|
||||
|
||||
function validateCreateForm() {
|
||||
var elements = formElements();
|
||||
var valid = Boolean(elements.name.value.trim() && elements.slug.value.trim());
|
||||
elements.submit.disabled = !valid;
|
||||
elements.submit.classList.toggle('is-disabled', !valid);
|
||||
}
|
||||
|
||||
function inviteRows() {
|
||||
return Array.from(document.querySelectorAll('#invite-rows .invite-row')).map(function (row) {
|
||||
return {
|
||||
email: row.querySelector('input').value.trim(),
|
||||
role: uiRoleToApi(row.querySelector('select').value),
|
||||
};
|
||||
}).filter(function (row) {
|
||||
return row.email;
|
||||
});
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
var payload = workspacePayload();
|
||||
if (!payload.display_name || !payload.slug || !window.CrankApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
var submit = document.getElementById('submit-btn');
|
||||
var originalLabel = submit.textContent;
|
||||
submit.disabled = true;
|
||||
submit.textContent = workspaceFormState.isCreateMode ? tKey('workspace_setup.saving_create') : tKey('workspace_setup.saving_update');
|
||||
|
||||
try {
|
||||
var response;
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
response = await window.CrankApi.createWorkspace(payload);
|
||||
} else {
|
||||
response = await window.CrankApi.updateWorkspace(workspaceFormState.workspaceId, payload);
|
||||
}
|
||||
|
||||
var record = response && response.workspace ? response : { workspace: response.workspace || response };
|
||||
var workspace = record.workspace;
|
||||
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
var invites = inviteRows();
|
||||
for (var index = 0; index < invites.length; index += 1) {
|
||||
await window.CrankApi.createInvitation(workspace.id, {
|
||||
email: invites[index].email,
|
||||
role: invites[index].role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var workspaces = await window.refreshWorkspaces();
|
||||
var mapped = workspaces.find(function (item) { return item.id === workspace.id; }) || {
|
||||
id: workspace.id,
|
||||
slug: workspace.slug,
|
||||
name: workspace.display_name,
|
||||
role: tKey('workspace_setup.role.owner'),
|
||||
letter: (workspace.display_name || workspace.slug).charAt(0).toUpperCase(),
|
||||
color: payload.settings.color || '#0d9488',
|
||||
status: workspace.status,
|
||||
settings: payload.settings,
|
||||
};
|
||||
|
||||
await window.setCurrentWorkspace(mapped);
|
||||
workspaceFormState.workspaceId = workspace.id;
|
||||
workspaceFormState.workspaceRecord = { workspace: workspace };
|
||||
setFormDirty(false);
|
||||
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
window.location.href = (window.CrankRoutes && window.CrankRoutes.workspaceSetup) || '/workspace-setup';
|
||||
return;
|
||||
}
|
||||
|
||||
applyWorkspaceRecord({ workspace: workspace });
|
||||
await loadWorkspaceAccessData();
|
||||
submit.textContent = tKey('workspace_setup.saved');
|
||||
setTimeout(function () {
|
||||
submit.textContent = originalLabel;
|
||||
submit.disabled = false;
|
||||
}, 1400);
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message || tKey('workspace_setup.save_error'), tKey('workspace_setup.save_error_title'));
|
||||
}
|
||||
submit.disabled = false;
|
||||
submit.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function addInviteRow() {
|
||||
var rows = document.getElementById('invite-rows');
|
||||
var node = document.createElement('div');
|
||||
node.className = 'invite-row';
|
||||
var emailInput = createElement('input', 'form-input');
|
||||
emailInput.type = 'email';
|
||||
emailInput.placeholder = 'colleague@company.com';
|
||||
emailInput.autocomplete = 'off';
|
||||
emailInput.style.flex = '1';
|
||||
emailInput.style.marginBottom = '0';
|
||||
node.appendChild(emailInput);
|
||||
|
||||
var roleSelect = createElement('select', 'form-input');
|
||||
roleSelect.style.width = '130px';
|
||||
roleSelect.style.marginBottom = '0';
|
||||
[
|
||||
['admin', tKey('workspace_setup.role.admin')],
|
||||
['developer', tKey('workspace_setup.role.operator')],
|
||||
['viewer', tKey('workspace_setup.role.viewer')]
|
||||
].forEach(function(entry) {
|
||||
var option = document.createElement('option');
|
||||
option.value = entry[0];
|
||||
option.textContent = entry[1];
|
||||
option.selected = entry[0] === 'developer';
|
||||
roleSelect.appendChild(option);
|
||||
});
|
||||
node.appendChild(roleSelect);
|
||||
|
||||
var removeButton = createElement('button', 'invite-row-remove');
|
||||
removeButton.type = 'button';
|
||||
removeButton.title = tKey('workspace_setup.members.remove');
|
||||
removeButton.appendChild(
|
||||
buildIconSvg(
|
||||
(window.APP_BASE || '') + 'icons/general/close.svg#icon',
|
||||
12,
|
||||
12
|
||||
)
|
||||
);
|
||||
removeButton.addEventListener('click', function() {
|
||||
removeInviteRow(removeButton);
|
||||
});
|
||||
node.appendChild(removeButton);
|
||||
rows.appendChild(node);
|
||||
}
|
||||
|
||||
function removeInviteRow(button) {
|
||||
button.closest('.invite-row').remove();
|
||||
}
|
||||
|
||||
function toggleInviteForm() {
|
||||
var form = document.getElementById('invite-form');
|
||||
var success = document.getElementById('invite-success');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.hidden) {
|
||||
form.hidden = false;
|
||||
if (success) {
|
||||
success.hidden = true;
|
||||
}
|
||||
document.getElementById('invite-email').focus();
|
||||
} else {
|
||||
form.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendInvite() {
|
||||
if (!window.CrankApi || !workspaceFormState.workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
var email = document.getElementById('invite-email').value.trim();
|
||||
var role = uiRoleToApi(document.getElementById('invite-role').value);
|
||||
if (!email || email.indexOf('@') === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.CrankApi.createInvitation(workspaceFormState.workspaceId, { email: email, role: role });
|
||||
document.getElementById('invite-email').value = '';
|
||||
document.getElementById('invite-success').hidden = false;
|
||||
await loadWorkspaceAccessData();
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message || tKey('workspace_setup.invite_error'), tKey('workspace_setup.invite_error_title'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRole(select, userId) {
|
||||
if (!workspaceFormState.workspaceId || !window.CrankApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
var previous = select.dataset.previous || select.value;
|
||||
select.disabled = true;
|
||||
|
||||
try {
|
||||
var response = await window.CrankApi.updateMembership(workspaceFormState.workspaceId, userId, {
|
||||
role: select.value,
|
||||
});
|
||||
workspaceFormState.memberships = response && response.items ? response.items : workspaceFormState.memberships;
|
||||
renderMembers();
|
||||
} catch (error) {
|
||||
select.value = previous;
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message || tKey('workspace_setup.role_error'), tKey('workspace_setup.role_error_title'));
|
||||
}
|
||||
} finally {
|
||||
select.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(userId, name) {
|
||||
if (!workspaceFormState.workspaceId || !window.CrankApi) {
|
||||
return;
|
||||
}
|
||||
if (!confirm(tfKey('workspace_setup.remove_confirm', { name: name }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.CrankApi.deleteMembership(workspaceFormState.workspaceId, userId);
|
||||
await loadWorkspaceAccessData();
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message || tKey('workspace_setup.remove_error'), tKey('workspace_setup.remove_error_title'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeInviteById(invitationId) {
|
||||
if (!window.CrankApi || !workspaceFormState.workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.CrankApi.deleteInvitation(workspaceFormState.workspaceId, invitationId);
|
||||
await loadWorkspaceAccessData();
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message || tKey('workspace_setup.revoke_error'), tKey('workspace_setup.revoke_error_title'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function revokeInvite(button) {
|
||||
var row = button.closest('.member-row');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
row.remove();
|
||||
}
|
||||
|
||||
function downloadJsonFile(fileName, value) {
|
||||
var blob = new Blob([JSON.stringify(value, null, 2)], { type: 'application/json;charset=utf-8' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function exportWorkspaceSnapshot() {
|
||||
if (!workspaceFormState.workspaceId || !window.CrankApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var snapshot = await window.CrankApi.exportWorkspace(workspaceFormState.workspaceId);
|
||||
var slug = workspaceFormState.workspaceRecord && workspaceFormState.workspaceRecord.workspace
|
||||
? workspaceFormState.workspaceRecord.workspace.slug
|
||||
: tKey('settings.nav.workspace');
|
||||
downloadJsonFile(slug + '-snapshot.json', snapshot);
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message || tKey('workspace_setup.export_error'), tKey('workspace_setup.export_error_title'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorkspaceAction() {
|
||||
if (!workspaceFormState.workspaceId || !window.CrankApi || !window.CrankAuth) {
|
||||
return;
|
||||
}
|
||||
if (!confirm(tKey('workspace_setup.delete_confirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.CrankApi.deleteWorkspace(workspaceFormState.workspaceId);
|
||||
await window.CrankAuth.logout();
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message || tKey('workspace_setup.delete_error'), tKey('workspace_setup.delete_error_title'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function initPage() {
|
||||
updatePageMode();
|
||||
|
||||
document.getElementById('export-workspace-btn').addEventListener('click', exportWorkspaceSnapshot);
|
||||
document.getElementById('delete-workspace-btn').addEventListener('click', deleteWorkspaceAction);
|
||||
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
document.getElementById('ws-name').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
await window.whenWorkspacesReady();
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
if (!workspace || !window.CrankApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var record = await window.CrankApi.getWorkspace(workspace.id);
|
||||
applyWorkspaceRecord(record);
|
||||
await loadWorkspaceAccessData();
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message || 'Failed to load workspace', 'Workspace load failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initPage);
|
||||
|
||||
window.addEventListener('beforeunload', function (event) {
|
||||
if (workspaceFormState.formDirty) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
var WS_LIST = [
|
||||
{ id: 'ws_default', slug: 'default', name: 'Default workspace', role: 'Owner', letter: 'D', color: '#0d9488' }
|
||||
];
|
||||
|
||||
var workspaceLoadPromise = null;
|
||||
var workspaceLoadFailed = false;
|
||||
var lastWorkspaceId = null;
|
||||
function tKey(key) {
|
||||
return typeof t === 'function' ? t(key) : key;
|
||||
}
|
||||
function tfKey(key, vars) {
|
||||
return typeof tf === 'function' ? tf(key, vars) : key;
|
||||
}
|
||||
|
||||
function buildIconSvg(href, width, height) {
|
||||
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', String(width));
|
||||
svg.setAttribute('height', String(height));
|
||||
var use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use.setAttribute('href', href);
|
||||
svg.appendChild(use);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function workspaceColor(index) {
|
||||
return ['#0d9488', '#7c3aed', '#0891b2', '#f59e0b', '#2563eb'][index % 5];
|
||||
}
|
||||
|
||||
function mapWorkspace(record, index) {
|
||||
var workspace = record && record.workspace ? record.workspace : record;
|
||||
var displayName = workspace.display_name || workspace.slug || workspace.id;
|
||||
var settings = workspace.settings || {};
|
||||
var role = record && record.role
|
||||
? String(record.role).replace(/^\w/, function(char) { return char.toUpperCase(); })
|
||||
: tKey('workspace_setup.role.owner');
|
||||
return {
|
||||
id: workspace.id,
|
||||
slug: workspace.slug,
|
||||
name: displayName,
|
||||
role: role,
|
||||
letter: displayName.charAt(0).toUpperCase(),
|
||||
color: settings.color || workspaceColor(index),
|
||||
status: workspace.status,
|
||||
settings: settings,
|
||||
};
|
||||
}
|
||||
|
||||
function cachedWorkspaceId() {
|
||||
try {
|
||||
return localStorage.getItem('crank_workspace_id');
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cachedWorkspaceSlug() {
|
||||
try {
|
||||
return localStorage.getItem('crank_workspace_slug');
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionWorkspaceId() {
|
||||
if (!window.CrankAuth || typeof window.CrankAuth.getCachedSession !== 'function') {
|
||||
return null;
|
||||
}
|
||||
var session = window.CrankAuth.getCachedSession();
|
||||
return session && session.current_workspace_id ? session.current_workspace_id : null;
|
||||
}
|
||||
|
||||
function cacheCurrentWorkspace(workspace) {
|
||||
if (!workspace) return;
|
||||
try {
|
||||
localStorage.setItem('crank_workspace_id', workspace.id);
|
||||
localStorage.setItem('crank_workspace_slug', workspace.slug);
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
function resolveCurrentWorkspace() {
|
||||
var workspaceId = sessionWorkspaceId() || cachedWorkspaceId();
|
||||
var workspaceSlug = cachedWorkspaceSlug();
|
||||
return WS_LIST.find(function(item) { return item.id === workspaceId; })
|
||||
|| WS_LIST.find(function(item) { return item.slug === workspaceSlug; })
|
||||
|| WS_LIST[0]
|
||||
|| null;
|
||||
}
|
||||
|
||||
function emitWorkspaceChange(workspace) {
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
lastWorkspaceId = workspace.id;
|
||||
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace }));
|
||||
}
|
||||
|
||||
function getCurrentWs() {
|
||||
var workspace = resolveCurrentWorkspace();
|
||||
|
||||
if (workspace) {
|
||||
cacheCurrentWorkspace(workspace);
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
function updateWorkspaceHeader(workspace) {
|
||||
var nameEl = document.getElementById('ws-current-name');
|
||||
var dotEl = document.getElementById('ws-dot');
|
||||
if (nameEl) nameEl.textContent = workspace ? workspace.name : 'No workspace';
|
||||
if (dotEl && workspace) {
|
||||
dotEl.textContent = workspace.letter;
|
||||
dotEl.style.background = workspace.color;
|
||||
}
|
||||
}
|
||||
|
||||
function renderWorkspaceList() {
|
||||
var current = getCurrentWs();
|
||||
updateWorkspaceHeader(current);
|
||||
|
||||
var list = document.getElementById('ws-dropdown-list');
|
||||
if (!list) return;
|
||||
window.CrankDom.clear(list);
|
||||
|
||||
WS_LIST.forEach(function(workspace) {
|
||||
var active = current && workspace.id === current.id;
|
||||
var item = document.createElement('div');
|
||||
item.className = 'ws-dropdown-item' + (active ? ' active' : '');
|
||||
item.addEventListener('click', function() {
|
||||
switchWorkspace(workspace.id);
|
||||
});
|
||||
|
||||
var dot = document.createElement('div');
|
||||
dot.className = 'ws-item-dot';
|
||||
dot.style.background = workspace.color;
|
||||
dot.textContent = workspace.letter;
|
||||
item.appendChild(dot);
|
||||
|
||||
var info = document.createElement('div');
|
||||
info.className = 'ws-item-info';
|
||||
var name = document.createElement('div');
|
||||
name.className = 'ws-item-name';
|
||||
name.textContent = workspace.name;
|
||||
var role = document.createElement('div');
|
||||
role.className = 'ws-item-role';
|
||||
role.textContent = workspace.role;
|
||||
info.appendChild(name);
|
||||
info.appendChild(role);
|
||||
item.appendChild(info);
|
||||
|
||||
if (active) {
|
||||
var check = buildIconSvg(
|
||||
(window.APP_BASE || '') + 'icons/general/check.svg#icon',
|
||||
12,
|
||||
12
|
||||
);
|
||||
item.appendChild(check);
|
||||
}
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
var divider = document.createElement('div');
|
||||
divider.className = 'ws-dropdown-divider';
|
||||
list.appendChild(divider);
|
||||
|
||||
var manageLink = document.createElement('a');
|
||||
manageLink.className = 'ws-dropdown-mgmt-link';
|
||||
manageLink.href = (window.CrankRoutes && window.CrankRoutes.workspaceSetup) || '/workspace-setup';
|
||||
manageLink.addEventListener('click', function() {
|
||||
var dd = document.getElementById('ws-dropdown');
|
||||
if (dd) dd.hidden = true;
|
||||
});
|
||||
manageLink.appendChild(
|
||||
buildIconSvg(
|
||||
(window.APP_BASE || '') + 'icons/general/settings.svg#icon',
|
||||
14,
|
||||
14
|
||||
)
|
||||
);
|
||||
manageLink.appendChild(document.createTextNode(tKey('settings.ws.title')));
|
||||
list.appendChild(manageLink);
|
||||
}
|
||||
|
||||
async function loadWorkspaces() {
|
||||
if (workspaceLoadPromise) return workspaceLoadPromise;
|
||||
|
||||
workspaceLoadPromise = (async function() {
|
||||
if (!window.CrankApi) {
|
||||
renderWorkspaceList();
|
||||
return WS_LIST;
|
||||
}
|
||||
|
||||
try {
|
||||
var response = await window.CrankApi.listWorkspaces();
|
||||
var items = (response && response.items ? response.items : []).map(mapWorkspace);
|
||||
if (items.length > 0) {
|
||||
WS_LIST = items;
|
||||
}
|
||||
workspaceLoadFailed = false;
|
||||
} catch (_error) {
|
||||
workspaceLoadFailed = true;
|
||||
}
|
||||
|
||||
renderWorkspaceList();
|
||||
return WS_LIST;
|
||||
}());
|
||||
|
||||
return workspaceLoadPromise;
|
||||
}
|
||||
|
||||
async function refreshWorkspaces() {
|
||||
workspaceLoadPromise = null;
|
||||
return loadWorkspaces();
|
||||
}
|
||||
|
||||
function initWorkspaceSwitcher() {
|
||||
renderWorkspaceList();
|
||||
loadWorkspaces();
|
||||
}
|
||||
|
||||
function toggleWsSwitcher(e) {
|
||||
e.stopPropagation();
|
||||
var dd = document.getElementById('ws-dropdown');
|
||||
if (dd) dd.hidden = !dd.hidden;
|
||||
}
|
||||
|
||||
function switchWorkspace(workspaceId) {
|
||||
var workspace = WS_LIST.find(function(item) { return item.id === workspaceId; });
|
||||
if (!workspace) return;
|
||||
|
||||
var dd = document.getElementById('ws-dropdown');
|
||||
if (dd) dd.hidden = true;
|
||||
|
||||
if (!window.CrankApi || !window.CrankAuth || typeof window.CrankAuth.replaceSession !== 'function') {
|
||||
cacheCurrentWorkspace(workspace);
|
||||
renderWorkspaceList();
|
||||
emitWorkspaceChange(workspace);
|
||||
return;
|
||||
}
|
||||
|
||||
return window.CrankApi
|
||||
.setCurrentWorkspace(workspace.id)
|
||||
.then(function(session) {
|
||||
window.CrankAuth.replaceSession(session);
|
||||
cacheCurrentWorkspace(workspace);
|
||||
renderWorkspaceList();
|
||||
emitWorkspaceChange(workspace);
|
||||
return workspace;
|
||||
})
|
||||
.catch(function(error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message || tKey('workspace.switch_error'), tKey('workspace.switch_error_title'));
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function setCurrentWorkspace(workspace) {
|
||||
if (!workspace) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve(switchWorkspace(workspace.id));
|
||||
}
|
||||
|
||||
window.getCurrentWorkspace = getCurrentWs;
|
||||
window.whenWorkspacesReady = loadWorkspaces;
|
||||
window.refreshWorkspaces = refreshWorkspaces;
|
||||
window.setCurrentWorkspace = setCurrentWorkspace;
|
||||
window.hasWorkspaceApiFailure = function() { return workspaceLoadFailed; };
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initWorkspaceSwitcher);
|
||||
|
||||
window.addEventListener('crank:sessionchange', function() {
|
||||
var workspace = getCurrentWs();
|
||||
renderWorkspaceList();
|
||||
if (workspace && workspace.id !== lastWorkspaceId) {
|
||||
emitWorkspaceChange(workspace);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('#ws-switcher')) {
|
||||
var dd = document.getElementById('ws-dropdown');
|
||||
if (dd) dd.hidden = true;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
absolute_redirect off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
return 302 /;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://admin-api:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /mcp/ {
|
||||
proxy_pass http://mcp-server:3002/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /login {
|
||||
try_files /html/login.html =404;
|
||||
}
|
||||
|
||||
location = /agents {
|
||||
try_files /html/agents.html =404;
|
||||
}
|
||||
|
||||
location = /api-keys {
|
||||
try_files /html/api-keys.html =404;
|
||||
}
|
||||
|
||||
location = /secrets {
|
||||
try_files /html/secrets.html =404;
|
||||
}
|
||||
|
||||
location = /logs {
|
||||
try_files /html/logs.html =404;
|
||||
}
|
||||
|
||||
location = /usage {
|
||||
try_files /html/usage.html =404;
|
||||
}
|
||||
|
||||
location = /settings {
|
||||
try_files /html/settings.html =404;
|
||||
}
|
||||
|
||||
location = /workspace-setup {
|
||||
try_files /html/workspace-setup.html =404;
|
||||
}
|
||||
|
||||
location = /wizard {
|
||||
return 302 /wizard/;
|
||||
}
|
||||
|
||||
location = /wizard/ {
|
||||
try_files /html/wizard/index.html =404;
|
||||
}
|
||||
|
||||
location ~ ^/wizard/(.+)$ {
|
||||
rewrite ^/wizard/(.+)$ /html/wizard/$1 break;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /html/login.html {
|
||||
return 302 /login;
|
||||
}
|
||||
|
||||
location = /html/agents.html {
|
||||
return 302 /agents;
|
||||
}
|
||||
|
||||
location = /html/api-keys.html {
|
||||
return 302 /api-keys;
|
||||
}
|
||||
|
||||
location = /html/secrets.html {
|
||||
return 302 /secrets;
|
||||
}
|
||||
|
||||
location = /html/logs.html {
|
||||
return 302 /logs;
|
||||
}
|
||||
|
||||
location = /html/usage.html {
|
||||
return 302 /usage;
|
||||
}
|
||||
|
||||
location = /html/settings.html {
|
||||
return 302 /settings;
|
||||
}
|
||||
|
||||
location = /html/workspace-setup.html {
|
||||
return 302 /workspace-setup$is_args$args;
|
||||
}
|
||||
|
||||
location = /html/wizard/ {
|
||||
return 302 /wizard/$is_args$args;
|
||||
}
|
||||
|
||||
location = /html/wizard/index.html {
|
||||
return 302 /wizard/;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
{
|
||||
"name": "crank-ui",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "crank-ui",
|
||||
"dependencies": {
|
||||
"alpinejs": "3.15.9",
|
||||
"js-yaml": "4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"esbuild": "^0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.15.9",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.9.tgz",
|
||||
"integrity": "sha512-O30m8Tw/aARbLXmeTnISAFgrNm0K71PT7bZy/1NgRqFD36QGb34VJ4a6WBL1iIO/bofN+LkIkKLikUTkfPL2wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "crank-ui",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node scripts/build.js",
|
||||
"e2e:install": "playwright install chromium",
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"alpinejs": "3.15.9",
|
||||
"js-yaml": "4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"esbuild": "^0.28.0"
|
||||
}
|
||||
}
|
||||