feat: migrate alpine ui into app shell

This commit is contained in:
a.tolmachev
2026-03-30 00:15:02 +03:00
parent f33aa29c88
commit 7070231c3e
84 changed files with 12530 additions and 92 deletions
+208
View File
@@ -0,0 +1,208 @@
document.addEventListener('alpine:init', () => {
Alpine.data('agents', () => ({
agents: [],
operations: [],
loading: true,
agentSearch: '',
openDropdown: null,
// drawer state
drawerOpen: false,
drawerMode: 'create', // 'create' | 'edit'
editingId: null,
// form fields
form: {
display_name: '',
slug: '',
description: '',
status: 'active',
selectedOps: [],
},
// operations picker
opSearch: '',
slugManuallyEdited: false,
async init() {
const [agentsRes, opsRes] = await Promise.all([
fetch((window.DATA_URL||'data/')+'agents.json'),
fetch((window.DATA_URL||'data/')+'operations.json'),
]);
this.agents = await agentsRes.json();
this.operations = await opsRes.json();
this.loading = false;
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.drawerOpen) this.closeDrawer();
});
document.addEventListener('click', () => { this.openDropdown = null; });
},
// ── computed ──
get filteredAgents() {
const q = this.agentSearch.toLowerCase().trim();
if (!q) return this.agents;
return this.agents.filter(a =>
a.display_name.toLowerCase().includes(q) ||
a.slug.toLowerCase().includes(q) ||
(a.description || '').toLowerCase().includes(q)
);
},
get totalCalls() {
return this.agents.reduce((s, a) => s + (a.calls_today || 0), 0);
},
get activeCount() {
return this.agents.filter(a => a.status === 'active').length;
},
get totalOpsExposed() {
return this.agents.reduce((s, a) => s + (a.operation_count || 0), 0);
},
get filteredOps() {
const q = this.opSearch.toLowerCase();
if (!q) return this.operations;
return this.operations.filter(op =>
op.name.toLowerCase().includes(q) ||
op.display_name.toLowerCase().includes(q)
);
},
// ── drawer ──
openCreate() {
this.drawerMode = 'create';
this.editingId = null;
this.form = { display_name: '', slug: '', description: '', status: 'active', selectedOps: [] };
this.opSearch = '';
this.slugManuallyEdited = false;
this.drawerOpen = true;
},
openEdit(agent) {
this.drawerMode = 'edit';
this.editingId = agent.id;
this.form = {
display_name: agent.display_name,
slug: agent.slug,
description: agent.description,
status: agent.status,
selectedOps: [...(agent.operation_ids || [])],
};
this.opSearch = '';
this.slugManuallyEdited = true;
this.drawerOpen = true;
},
closeDrawer() {
this.drawerOpen = false;
},
// ── slug auto-derive ──
onNameInput(val) {
this.form.display_name = val;
if (!this.slugManuallyEdited) {
this.form.slug = val.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
}
},
onSlugInput(val) {
this.form.slug = val.toLowerCase().replace(/[^a-z0-9-]/g, '');
this.slugManuallyEdited = true;
},
// ── operations picker ──
toggleOp(opId) {
const idx = this.form.selectedOps.indexOf(opId);
if (idx === -1) this.form.selectedOps.push(opId);
else this.form.selectedOps.splice(idx, 1);
},
isOpSelected(opId) {
return this.form.selectedOps.includes(opId);
},
// ── save ──
saveAgent() {
if (!this.form.display_name.trim() || !this.form.slug.trim()) return;
const ws = (localStorage.getItem('crank_workspace') || 'acme-workspace');
const endpoint = '/mcp/v1/' + ws + '/' + this.form.slug;
if (this.drawerMode === 'create') {
this.agents.unshift({
id: 'ag-' + Date.now(),
slug: this.form.slug,
display_name: this.form.display_name,
description: this.form.description,
status: this.form.status,
operation_count: this.form.selectedOps.length,
operation_ids: [...this.form.selectedOps],
key_count: 0,
created_at: new Date().toISOString(),
created_by: 'AT',
last_called_at: null,
calls_today: 0,
mcp_endpoint: endpoint,
});
} else {
const idx = this.agents.findIndex(a => a.id === this.editingId);
if (idx !== -1) {
this.agents[idx] = Object.assign({}, this.agents[idx], {
display_name: this.form.display_name,
slug: this.form.slug,
description: this.form.description,
status: this.form.status,
operation_count: this.form.selectedOps.length,
operation_ids: [...this.form.selectedOps],
mcp_endpoint: endpoint,
});
}
}
this.closeDrawer();
},
// ── delete ──
deleteAgent(id) {
this.agents = this.agents.filter(a => a.id !== id);
},
// ── helpers ──
mcpEndpoint(agent) {
if (agent.mcp_endpoint) return agent.mcp_endpoint;
const ws = localStorage.getItem('crank_workspace') || 'acme-workspace';
return '/mcp/v1/' + ws + '/' + agent.slug;
},
copyEndpoint(agent) {
navigator.clipboard.writeText(this.mcpEndpoint(agent)).catch(() => {});
},
statusClass(status) {
return 'agent-status-badge agent-status-' + status;
},
protocolBadge(op) {
if (op.protocol === 'rest') return 'badge badge-rest';
if (op.protocol === 'graphql') return 'badge badge-graphql';
return 'badge badge-grpc';
},
protocolLabel(op) {
if (op.protocol === 'rest') return 'REST';
if (op.protocol === 'graphql') return 'GQL';
return 'gRPC';
},
formatDate(dateStr) {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
},
formatCalls(n) {
if (!n) return '0';
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
},
}));
});
+241
View File
@@ -0,0 +1,241 @@
/* ═══════════════════════════════════════════════════
Crank — API Keys page
Data: GET /api/workspaces/{slug}/keys
═══════════════════════════════════════════════════ */
var KEYS = [];
var currentWorkspaceSlug = '';
var SCOPES = ['read', 'write', 'deploy'];
var SCOPE_DESC = {
read: 'Read operations, logs, and usage',
write: 'Create and update operations',
deploy: 'Publish and rollback deployments',
};
var selectedScopes = new Set(['read']);
var search = '';
// ── API helpers ──────────────────────────────────────────────────────────────
function apiBase() {
return '/api/workspaces/' + currentWorkspaceSlug + '/keys';
}
// In production: replace fetch(mockUrl()) with fetch(apiBase())
function mockUrl() {
return (window.DATA_URL || 'data/') + 'keys.json';
}
// ── Load keys for current workspace ─────────────────────────────────────────
function loadKeys() {
currentWorkspaceSlug = getCurrentWs().slug;
setTableLoading(true);
// Production: fetch(apiBase())
fetch(mockUrl())
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function(data) {
// Mock JSON is keyed by workspace slug; real API returns array directly
KEYS = Array.isArray(data) ? data : (data[currentWorkspaceSlug] || []);
setTableLoading(false);
renderTable();
})
.catch(function() {
KEYS = [];
setTableLoading(false);
renderTable();
});
}
function setTableLoading(on) {
var tbody = document.getElementById('keys-tbody');
if (!on) return;
tbody.innerHTML = '';
var tr = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = 7;
td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
td.textContent = 'Loading…';
tr.appendChild(td);
tbody.appendChild(tr);
}
// ── Mutations (in production: real fetch calls) ──────────────────────────────
function revokeKey(id) {
if (!confirm('Revoke this key? All requests using it will fail immediately.')) return;
// Production: fetch(apiBase() + '/' + id, { method: 'PATCH', body: JSON.stringify({ status: 'revoked' }) })
var k = KEYS.find(function(k) { return k.id === id; });
if (k) { k.status = 'revoked'; renderTable(); }
}
function deleteKey(id) {
// Production: fetch(apiBase() + '/' + id, { method: 'DELETE' })
KEYS.splice(KEYS.findIndex(function(k) { return k.id === id; }), 1);
renderTable();
}
function createKey(name, scopes) {
// Production: fetch(apiBase(), { method: 'POST', body: JSON.stringify({ name, scopes }) })
// → response: { key: 'mcp_...', prefix: 'mcp_...', id: '...' }
var rand = Array.from({length: 32}, function() { return Math.random().toString(36)[2]; }).join('');
var rawKey = 'mcp_' + name.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4) + '_' + rand;
var prefix = rawKey.slice(0, 12) + '…';
var newKey = {
id: 'key_' + Date.now(),
name: name,
prefix: prefix,
scopes: scopes,
created: new Date().toISOString().slice(0, 10),
lastUsed: 'Never',
status: 'active',
};
KEYS.unshift(newKey);
return rawKey;
}
// ── Render ───────────────────────────────────────────────────────────────────
function renderScopes() {
var el = document.getElementById('scope-checkboxes');
var tmpl = document.getElementById('tmpl-scope-checkbox');
el.innerHTML = '';
SCOPES.forEach(function(s) {
var node = tmpl.content.cloneNode(true);
var input = node.querySelector('input');
input.dataset.scope = s;
if (selectedScopes.has(s)) input.checked = true;
node.querySelector('.scope-checkbox-name').textContent = s;
node.querySelector('.scope-checkbox-desc').textContent = SCOPE_DESC[s];
input.addEventListener('change', function() {
if (this.checked) selectedScopes.add(this.dataset.scope);
else selectedScopes.delete(this.dataset.scope);
});
el.appendChild(node);
});
}
function renderTable() {
var q = search.toLowerCase();
var tbody = document.getElementById('keys-tbody');
var rows = KEYS.filter(function(k) {
return !q || k.name.toLowerCase().includes(q) || k.prefix.includes(q);
});
tbody.innerHTML = '';
if (!rows.length) {
var empty = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = 7;
td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
td.textContent = KEYS.length ? 'No keys match your search' : 'No API keys yet';
empty.appendChild(td);
tbody.appendChild(empty);
return;
}
var tmpl = document.getElementById('tmpl-key-row');
rows.forEach(function(k) {
var node = tmpl.content.cloneNode(true);
node.querySelector('.col-name').textContent = k.name;
node.querySelector('.col-mono').textContent = k.prefix;
node.querySelector('.col-created').textContent = k.created;
node.querySelector('.col-last-used').textContent = k.lastUsed;
var scopesEl = node.querySelector('.col-scopes');
k.scopes.forEach(function(s) {
var span = document.createElement('span');
span.className = 'badge badge-scope';
span.textContent = s;
scopesEl.appendChild(span);
});
var badge = document.createElement('span');
badge.className = k.status === 'active' ? 'badge badge-active' : 'badge badge-revoked';
badge.textContent = k.status === 'active' ? 'Active' : 'Revoked';
node.querySelector('.col-status').appendChild(badge);
var actionsActive = node.querySelector('.actions-active');
var actionsRevoked = node.querySelector('.actions-revoked');
if (k.status === 'active') {
actionsActive.querySelector('[title="Copy key prefix"]')
.addEventListener('click', function() { copyPrefix(k.prefix); });
actionsActive.querySelector('[title="Revoke key"]')
.addEventListener('click', function() { revokeKey(k.id); });
} else {
actionsActive.style.display = 'none';
actionsRevoked.style.display = '';
actionsRevoked.querySelector('[title="Delete"]')
.addEventListener('click', function() { deleteKey(k.id); });
}
tbody.appendChild(node);
});
}
// ── Modal ────────────────────────────────────────────────────────────────────
var modal = document.getElementById('modal-create');
function openModal() {
document.getElementById('modal-form-body').style.display = '';
document.getElementById('modal-reveal-body').style.display = 'none';
document.getElementById('modal-footer-create').style.display = '';
document.getElementById('modal-footer-done').style.display = 'none';
document.getElementById('new-key-name').value = '';
selectedScopes = new Set(['read']);
renderScopes();
modal.classList.add('open');
setTimeout(function() { document.getElementById('new-key-name').focus(); }, 50);
}
function closeModal() { modal.classList.remove('open'); }
document.getElementById('btn-create-key').addEventListener('click', openModal);
document.getElementById('modal-close-btn').addEventListener('click', closeModal);
document.getElementById('modal-cancel-btn').addEventListener('click', closeModal);
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
document.getElementById('modal-confirm-btn').addEventListener('click', function() {
var name = document.getElementById('new-key-name').value.trim();
if (!name) { document.getElementById('new-key-name').focus(); return; }
if (!selectedScopes.size) { alert('Select at least one scope.'); return; }
var rawKey = createKey(name, Array.from(selectedScopes));
document.getElementById('reveal-key-value').textContent = rawKey;
document.getElementById('modal-form-body').style.display = 'none';
document.getElementById('modal-reveal-body').style.display = '';
document.getElementById('modal-footer-create').style.display = 'none';
document.getElementById('modal-footer-done').style.display = '';
renderTable();
});
document.getElementById('modal-done-btn').addEventListener('click', closeModal);
document.getElementById('copy-key-btn').addEventListener('click', function() {
var val = document.getElementById('reveal-key-value').textContent;
navigator.clipboard && navigator.clipboard.writeText(val);
this.innerHTML = '<svg width="13" height="13"><use href="' + (window.APP_BASE||'') + 'icons/general/check.svg#icon"/></svg>';
});
document.getElementById('key-search').addEventListener('input', function() {
search = this.value;
renderTable();
});
// ── Init ─────────────────────────────────────────────────────────────────────
function copyPrefix(prefix) {
navigator.clipboard && navigator.clipboard.writeText(prefix);
}
document.addEventListener('DOMContentLoaded', loadKeys);
+333
View File
@@ -0,0 +1,333 @@
const PAGE_SIZE_DESKTOP = 6;
const PAGE_SIZE_MOBILE = 4;
const MOBILE_BREAKPOINT = 960;
function getSortOptions() {
return [
{ value: 'created_desc', label: (window.t && t('sort.created_desc')) || 'Sort: Last created' },
{ value: 'created_asc', label: (window.t && t('sort.created_asc')) || 'Sort: Oldest first' },
{ value: 'name_asc', label: (window.t && t('sort.name_asc')) || 'Sort: Name AZ' },
{ value: 'name_desc', label: (window.t && t('sort.name_desc')) || 'Sort: Name ZA' },
];
}
function getTabDefs() {
return [
{ id: 'all', label: (window.t && t('tab.all')) || 'All' },
{ id: 'active', label: (window.t && t('tab.active')) || 'Active' },
{ id: 'draft', label: (window.t && t('tab.draft')) || 'Draft' },
{ id: 'error', label: (window.t && t('tab.error')) || 'Error' },
{ id: 'inactive', label: (window.t && t('tab.inactive')) || 'Inactive' },
];
}
document.addEventListener('alpine:init', () => {
Alpine.data('catalog', () => ({
// ── raw data ──
operations: [],
agents: [],
loading: true,
// ── page size (responsive) ──
pageSize: PAGE_SIZE_DESKTOP,
// ── filter state ──
search: '',
tab: 'all',
filterProtocol: null,
filterCategory: null,
filterAgent: null,
sort: 'created_desc',
page: 1,
// ── dropdown open state ──
openDropdown: null,
// ── mobile nav ──
navOpen: false,
// ── derived option lists (built after data loads) ──
categoryOptions: [],
async init() {
const [opsRes, agentsRes] = await Promise.all([
fetch((window.DATA_URL||'data/')+'operations.json'),
fetch((window.DATA_URL||'data/')+'agents.json'),
]);
let ops = await opsRes.json();
this.agents = await agentsRes.json();
// Apply localStorage overrides (from wizard create/edit)
try {
const overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]');
if (overrides.length) {
const existingIds = new Set(ops.map(o => o.id));
ops = ops.map(op => {
const ov = overrides.find(o => o.id === op.id);
return ov ? Object.assign({}, op, ov) : op;
});
overrides
.filter(o => !existingIds.has(o.id))
.forEach(o => ops.push(o));
}
} catch (e) {}
this.operations = ops;
this.categoryOptions = [...new Set(this.operations.map(op => op.category))].filter(Boolean).sort();
this.loading = false;
// Responsive page size
const updatePageSize = () => {
this.pageSize = window.innerWidth <= MOBILE_BREAKPOINT ? PAGE_SIZE_MOBILE : PAGE_SIZE_DESKTOP;
this.page = 1;
};
updatePageSize();
window.addEventListener('resize', updatePageSize);
// Re-render labels on language change
window.addEventListener('crank:langchange', () => {
this.sort = this.sort;
applyLang();
});
// Close dropdowns and mobile nav on outside click
document.addEventListener('click', (e) => {
if (!e.target.closest('.filter-dropdown, .sort-dropdown, .user-menu')) {
this.openDropdown = null;
}
if (!e.target.closest('.navbar')) {
this.navOpen = false;
}
});
},
// ── helpers for filtering (shared between filtered getter and tabCount) ──
_applyNonStatusFilters(ops) {
if (this.search.trim()) {
const q = this.search.toLowerCase();
ops = ops.filter(op =>
op.name.toLowerCase().includes(q) ||
op.display_name.toLowerCase().includes(q) ||
(op.target_url || '').toLowerCase().includes(q)
);
}
if (this.filterProtocol) {
ops = ops.filter(op => op.protocol === this.filterProtocol);
}
if (this.filterCategory) {
ops = ops.filter(op => op.category === this.filterCategory);
}
if (this.filterAgent) {
const agent = this.agents.find(a => a.id === this.filterAgent);
const ids = agent ? agent.operation_ids : [];
ops = ops.filter(op => ids.includes(op.id));
}
return ops;
},
// ── computed: filtered + sorted + paginated ──
get filtered() {
let ops = this._applyNonStatusFilters(this.operations);
if (this.tab !== 'all') {
ops = ops.filter(op => op.status === this.tab);
}
ops = [...ops].sort((a, b) => {
switch (this.sort) {
case 'created_desc': return new Date(b.created_at) - new Date(a.created_at);
case 'created_asc': return new Date(a.created_at) - new Date(b.created_at);
case 'name_asc': return a.name.localeCompare(b.name);
case 'name_desc': return b.name.localeCompare(a.name);
default: return 0;
}
});
return ops;
},
get totalFiltered() { return this.filtered.length; },
get totalPages() { return Math.max(1, Math.ceil(this.totalFiltered / this.pageSize)); },
get paginated() {
const start = (this.page - 1) * this.pageSize;
return this.filtered.slice(start, start + this.pageSize);
},
get pageStart() { return this.totalFiltered === 0 ? 0 : (this.page - 1) * this.pageSize + 1; },
get pageEnd() { return Math.min(this.page * this.pageSize, this.totalFiltered); },
// ── tab counts (respect non-status filters so numbers are consistent) ──
tabCount(tab) {
const ops = this._applyNonStatusFilters(this.operations);
if (tab === 'all') return ops.length;
return ops.filter(op => op.status === tab).length;
},
// ── active filter chips (for dismissible chip row) ──
get activeChips() {
const chips = [];
if (this.filterProtocol) {
chips.push({ key: 'protocol', label: 'Protocol: ' + this.filterProtocol.toUpperCase() });
}
if (this.filterCategory) {
chips.push({ key: 'category', label: 'Category: ' + this.filterCategory });
}
if (this.filterAgent) {
const a = this.agents.find(ag => ag.id === this.filterAgent);
chips.push({ key: 'agent', label: 'Agent: ' + (a ? a.display_name : '') });
}
return chips;
},
get hasActiveFilters() {
return !!(this.filterProtocol || this.filterCategory || this.filterAgent || this.search.trim());
},
clearChip(key) {
if (key === 'protocol') this.filterProtocol = null;
if (key === 'category') this.filterCategory = null;
if (key === 'agent') this.filterAgent = null;
this.page = 1;
},
clearAllFilters() {
this.filterProtocol = null;
this.filterCategory = null;
this.filterAgent = null;
this.search = '';
this.tab = 'all';
this.page = 1;
},
// ── agent lookup per operation (for badges in rows) ──
get agentsByOpId() {
const map = {};
this.agents.forEach(a => {
a.operation_ids.forEach(id => {
if (!map[id]) map[id] = [];
map[id].push(a);
});
});
return map;
},
// ── active agents only (for filter dropdown) ──
get activeAgents() {
return this.agents.filter(a => a.status === 'active');
},
// ── sort label ──
get sortLabel() {
return getSortOptions().find(o => o.value === this.sort)?.label ?? 'Sort';
},
// ── actions ──
setTab(tab) {
this.tab = tab;
this.page = 1;
},
setSearch(val) {
this.search = val;
this.page = 1;
},
setProtocol(val) {
this.filterProtocol = this.filterProtocol === val ? null : val;
this.openDropdown = null;
this.page = 1;
},
setCategory(val) {
this.filterCategory = this.filterCategory === val ? null : val;
this.openDropdown = null;
this.page = 1;
},
setSort(val) {
this.sort = val;
this.openDropdown = null;
},
setAgent(val) {
this.filterAgent = this.filterAgent === val ? null : val;
this.openDropdown = null;
this.page = 1;
},
clearProtocol() { this.filterProtocol = null; this.openDropdown = null; this.page = 1; },
clearCategory() { this.filterCategory = null; this.openDropdown = null; this.page = 1; },
clearAgent() { this.filterAgent = null; this.openDropdown = null; this.page = 1; },
toggleDropdown(name) {
this.openDropdown = this.openDropdown === name ? null : name;
},
goPage(p) {
if (p >= 1 && p <= this.totalPages) this.page = p;
},
// ── operation CRUD ──
editOperation(op) {
try { sessionStorage.setItem('wizard_edit', JSON.stringify(op)); } catch (e) {}
window.location.href = (window.APP_BASE||'')+'html/wizard/?mode=edit';
},
deleteOperation(id) {
if (!confirm('Delete this operation? This cannot be undone.')) return;
this.operations = this.operations.filter(op => op.id !== id);
// Persist deletion in overrides as a tombstone
try {
const overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]');
const filtered = overrides.filter(o => o.id !== id);
filtered.push({ id, _deleted: true });
localStorage.setItem('crank_ops_overrides', JSON.stringify(filtered));
} catch (e) {}
},
// ── helpers для шаблона ──
protocolLabel(op) {
if (op.protocol === 'rest') return `REST · ${op.method}`;
if (op.protocol === 'graphql') return 'GraphQL';
return 'gRPC';
},
protocolClass(op) {
return `badge badge-${op.protocol}`;
},
statusClass(op) {
return `status-badge status-${op.status}`;
},
statusLabel(op) {
return op.status.charAt(0).toUpperCase() + op.status.slice(1);
},
formatDate(dateStr) {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
},
truncateUrl(url) {
if (!url) return '';
return url.length > 42 ? url.slice(0, 42) + '…' : url;
},
get sortOptions() { return getSortOptions(); },
get tabDefs() { return getTabDefs(); },
handleNewOperation() {
sessionStorage.removeItem('wizard_edit');
window.location.href = (window.APP_BASE||'')+'html/wizard/';
},
handleLogout() {
localStorage.removeItem('crank_user');
window.location.href = (window.APP_BASE||'')+'html/login.html';
},
}));
});
+20
View File
@@ -0,0 +1,20 @@
/**
* config.js — auto-detects base paths based on current page location.
* Must be loaded before workspace.js and other navigation-using scripts.
*/
(function() {
var p = window.location.pathname;
// html/wizard/ is two levels deep from root
if (/\/html\/wizard\//.test(p)) {
window.APP_BASE = '../../';
window.DATA_URL = '../../data/';
// html/*.html is one level deep from root
} else if (/\/html\//.test(p)) {
window.APP_BASE = '../';
window.DATA_URL = '../data/';
// root level (index.html)
} else {
window.APP_BASE = '';
window.DATA_URL = 'data/';
}
}());
+289
View File
@@ -0,0 +1,289 @@
/* ═══════════════════════════════════════════════
Crank — i18n
Reads `crank_lang` from localStorage ('en'|'ru')
═══════════════════════════════════════════════ */
var TRANSLATIONS = {
en: {
// Nav
'nav.operations': 'Operations',
'nav.agents': 'Agents',
'nav.apikeys': 'API Keys',
'nav.logs': 'Logs',
'nav.usage': 'Usage',
'nav.settings': 'Settings',
'nav.logout': 'Log out',
// Operations page
'ops.title': 'Operations',
'ops.subtitle': 'Tool contracts that expose your upstream APIs as MCP tools',
'ops.new': 'New operation',
'ops.loading': 'Loading operations…',
'ops.empty.title': 'No operations found',
'ops.empty.sub': 'Try adjusting your search or filters.',
// Stats
'stats.total': 'Total operations',
'stats.active': 'Active',
'stats.requests': 'Requests today',
'stats.latency': 'Avg. latency',
// Tabs
'tab.all': 'All',
'tab.active': 'Active',
'tab.draft': 'Draft',
'tab.error': 'Error',
'tab.inactive': 'Inactive',
// Filters & sort
'filter.search': 'Search operations...',
'filter.protocol': 'Protocol',
'filter.status': 'Status',
'filter.category': 'Category',
'filter.showing': 'Showing',
'filter.of': 'of',
'filter.clear': 'Clear filter',
'sort.created_desc':'Sort: Last created',
'sort.created_asc': 'Sort: Oldest first',
'sort.name_asc': 'Sort: Name AZ',
'sort.name_desc': 'Sort: Name ZA',
// Table headers
'th.name': 'NAME',
'th.protocol': 'PROTOCOL',
'th.status': 'STATUS',
'th.created': 'CREATED',
'th.url': 'TARGET URL',
// Pagination
'page.showing': 'Showing',
'page.of': 'of',
'page.ops': 'operations',
// API Keys page
'apikeys.title': 'API Keys',
'apikeys.subtitle': 'Manage access tokens for external integrations',
'apikeys.new': 'Create key',
'apikeys.th.name': 'NAME',
'apikeys.th.scope': 'SCOPE',
'apikeys.th.created':'CREATED',
'apikeys.th.last': 'LAST USED',
// Logs page
'logs.title': 'Logs',
'logs.subtitle': 'Real-time request and event log',
'logs.search': 'Search logs...',
'logs.level.all': 'All',
'logs.level.info': 'Info',
'logs.level.warn': 'Warn',
'logs.level.error': 'Error',
'logs.level.debug': 'Debug',
// Usage page
'usage.title': 'Usage',
'usage.subtitle': 'Request volume and performance metrics',
// Settings sidebar
'settings.title': 'Settings',
'settings.nav.workspace': 'Workspace',
'settings.nav.profile': 'Profile',
'settings.nav.members': 'Members',
'settings.nav.security': 'Security',
'settings.nav.notif': 'Notifications',
'settings.nav.danger': 'Danger zone',
// Settings — workspace section
'settings.ws.title': 'Workspace settings',
'settings.ws.subtitle': 'General information about your workspace',
'settings.ws.name': 'Workspace name',
'settings.ws.display': 'Display name',
'settings.ws.desc': 'Description',
'settings.ws.tz': 'Timezone',
'settings.ws.protocol': 'Default protocol',
// Settings — language
'settings.lang.title': 'Language',
'settings.lang.subtitle': 'Interface display language',
'settings.lang.en': 'English',
'settings.lang.ru': 'Русский',
// Settings — profile section
'settings.profile.title': 'Profile',
'settings.profile.name': 'Full name',
'settings.profile.email': 'Email',
'settings.profile.role': 'Role',
// Common buttons
'btn.save': 'Save changes',
'btn.cancel': 'Cancel',
'btn.create': 'Create',
'btn.delete': 'Delete',
'btn.revoke': 'Revoke',
'btn.copy': 'Copy',
// Login
'login.title': 'Sign in',
'login.subtitle': 'Welcome back to your workspace',
'login.email': 'Email',
'login.password': 'Password',
'login.submit': 'Sign in',
'login.sso': 'Continue with Google',
},
ru: {
// Nav
'nav.operations': 'Операции',
'nav.agents': 'Агенты',
'nav.apikeys': 'API Ключи',
'nav.logs': 'Логи',
'nav.usage': 'Использование',
'nav.settings': 'Настройки',
'nav.logout': 'Выйти',
// Operations page
'ops.title': 'Операции',
'ops.subtitle': 'Контракты инструментов, открывающие ваши API как MCP-инструменты',
'ops.new': 'Новая операция',
'ops.loading': 'Загрузка операций…',
'ops.empty.title': 'Операции не найдены',
'ops.empty.sub': 'Попробуйте изменить поисковый запрос или фильтры.',
// Stats
'stats.total': 'Всего операций',
'stats.active': 'Активные',
'stats.requests': 'Запросов сегодня',
'stats.latency': 'Ср. задержка',
// Tabs
'tab.all': 'Все',
'tab.active': 'Активные',
'tab.draft': 'Черновик',
'tab.error': 'Ошибка',
'tab.inactive': 'Неактивные',
// Filters & sort
'filter.search': 'Поиск операций...',
'filter.protocol': 'Протокол',
'filter.status': 'Статус',
'filter.category': 'Категория',
'filter.showing': 'Показано',
'filter.of': 'из',
'filter.clear': 'Сбросить',
'sort.created_desc':'Сначала новые',
'sort.created_asc': 'Сначала старые',
'sort.name_asc': 'Название А–Я',
'sort.name_desc': 'Название Я–А',
// Table headers
'th.name': 'НАЗВАНИЕ',
'th.protocol': 'ПРОТОКОЛ',
'th.status': 'СТАТУС',
'th.created': 'СОЗДАН',
'th.url': 'ЦЕЛЕВОЙ URL',
// Pagination
'page.showing': 'Показано',
'page.of': 'из',
'page.ops': 'операций',
// API Keys page
'apikeys.title': 'API Ключи',
'apikeys.subtitle': 'Управление токенами доступа для внешних интеграций',
'apikeys.new': 'Создать ключ',
'apikeys.th.name': 'НАЗВАНИЕ',
'apikeys.th.scope': 'ОБЛАСТЬ',
'apikeys.th.created':'СОЗДАН',
'apikeys.th.last': 'ПОСЛЕДНЕЕ ИСПОЛЬЗОВАНИЕ',
// Logs page
'logs.title': 'Логи',
'logs.subtitle': 'Журнал запросов и событий в реальном времени',
'logs.search': 'Поиск в логах...',
'logs.level.all': 'Все',
'logs.level.info': 'Info',
'logs.level.warn': 'Warn',
'logs.level.error': 'Error',
'logs.level.debug': 'Debug',
// Usage page
'usage.title': 'Использование',
'usage.subtitle': 'Объём запросов и метрики производительности',
// Settings sidebar
'settings.title': 'Настройки',
'settings.nav.workspace': 'Воркспейс',
'settings.nav.profile': 'Профиль',
'settings.nav.members': 'Участники',
'settings.nav.security': 'Безопасность',
'settings.nav.notif': 'Уведомления',
'settings.nav.danger': 'Опасная зона',
// Settings — workspace section
'settings.ws.title': 'Настройки воркспейса',
'settings.ws.subtitle': 'Общая информация о вашем воркспейсе',
'settings.ws.name': 'Имя воркспейса',
'settings.ws.display': 'Отображаемое имя',
'settings.ws.desc': 'Описание',
'settings.ws.tz': 'Часовой пояс',
'settings.ws.protocol': 'Протокол по умолчанию',
// Settings — language
'settings.lang.title': 'Язык',
'settings.lang.subtitle': 'Язык отображения интерфейса',
'settings.lang.en': 'English',
'settings.lang.ru': 'Русский',
// Settings — profile section
'settings.profile.title': 'Профиль',
'settings.profile.name': 'Полное имя',
'settings.profile.email': 'Email',
'settings.profile.role': 'Роль',
// Common buttons
'btn.save': 'Сохранить',
'btn.cancel': 'Отмена',
'btn.create': 'Создать',
'btn.delete': 'Удалить',
'btn.revoke': 'Отозвать',
'btn.copy': 'Копировать',
// Login
'login.title': 'Войти',
'login.subtitle': 'Добро пожаловать обратно',
'login.email': 'Email',
'login.password': 'Пароль',
'login.submit': 'Войти',
'login.sso': 'Войти через Google',
}
};
function t(key) {
var lang = localStorage.getItem('crank_lang') || 'en';
var tr = TRANSLATIONS[lang] || TRANSLATIONS.en;
return tr[key] !== undefined ? tr[key] : (TRANSLATIONS.en[key] !== undefined ? TRANSLATIONS.en[key] : key);
}
function applyLang() {
// text content
document.querySelectorAll('[data-i18n]').forEach(function(el) {
el.textContent = t(el.getAttribute('data-i18n'));
});
// placeholder
document.querySelectorAll('[data-i18n-ph]').forEach(function(el) {
el.placeholder = t(el.getAttribute('data-i18n-ph'));
});
// highlight active language button in settings
var lang = localStorage.getItem('crank_lang') || 'en';
document.querySelectorAll('.lang-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.lang === lang);
});
}
function setLang(lang) {
localStorage.setItem('crank_lang', lang);
applyLang();
// Notify Alpine components to re-render reactive getters
window.dispatchEvent(new CustomEvent('crank:langchange', { detail: { lang: lang } }));
}
document.addEventListener('DOMContentLoaded', applyLang);
+34
View File
@@ -0,0 +1,34 @@
// If already logged in, redirect to home
try {
if (localStorage.getItem('crank_user')) {
window.location.replace((window.APP_BASE||'')+'index.html');
}
} catch (e) {}
document.getElementById('login-form').addEventListener('submit', function (e) {
e.preventDefault();
var email = document.getElementById('email').value.trim();
var password = document.getElementById('password').value;
var errEl = document.getElementById('login-error');
if (!email || !password) {
errEl.style.display = 'block';
errEl.textContent = 'Please enter your email and password.';
return;
}
// Demo: any non-empty credentials succeed
errEl.style.display = 'none';
var initials = email.split('@')[0].slice(0, 2).toUpperCase();
var displayName = email.split('@')[0].replace(/[._]/g, ' ')
.split(' ').map(function(w) { return w.charAt(0).toUpperCase() + w.slice(1); }).join(' ');
var user = {
name: displayName,
email: email,
role: 'Admin',
workspace: 'acme-workspace',
initials: initials,
};
try { localStorage.setItem('crank_user', JSON.stringify(user)); } catch (ex) {}
window.location.href = (window.APP_BASE||'')+'index.html';
});
+487
View File
@@ -0,0 +1,487 @@
// logs.js — Crank log viewer
document.addEventListener('DOMContentLoaded', function () {
// ── Helpers ────────────────────────────────────────────────────────────────
function minsAgo(mins) {
return new Date(Date.now() - mins * 60 * 1000);
}
function fmtTs(date) {
var d = (date instanceof Date) ? date : new Date(date);
var pad = function (n, w) { return String(n).padStart(w || 2, '0'); };
return (
d.getFullYear() + '-' +
pad(d.getMonth() + 1) + '-' +
pad(d.getDate()) + ' ' +
pad(d.getHours()) + ':' +
pad(d.getMinutes()) + ':' +
pad(d.getSeconds()) + '.' +
pad(d.getMilliseconds(), 3)
);
}
// ── Seed log data ──────────────────────────────────────────────────────────
// Timestamps are computed relative to Date.now() so time-range filtering works.
var LOGS = [
{
id: 1, ts: minsAgo(2), level: 'info', op: 'create_crm_lead',
msg: 'Tool invoked — POST /v1/leads', duration: '142ms', status: 201,
detail: {
req: '{"first_name":"Alice","last_name":"Smith","email":"alice@acme.com"}',
res: '{"lead_id":"ld_9f3a","status":"new","created_at":"2026-03-27T14:32:11Z"}'
}
},
{
id: 2, ts: minsAgo(5), level: 'info', op: 'get_user_profile',
msg: 'Tool invoked — GET /v1/users/me', duration: '38ms', status: 200,
detail: {
req: '{"user_id":"usr_0042"}',
res: '{"id":"usr_0042","name":"Alice Smith","email":"alice@acme.com","role":"admin"}'
}
},
{
id: 3, ts: minsAgo(12), level: 'warn', op: 'send_slack_message',
msg: 'Rate limited by Slack API — retrying after back-off', duration: '1842ms', status: 429,
detail: {
req: '{"channel":"#ops","text":"Deploy finished"}',
res: '{"error":"ratelimited","retry_after":30}'
}
},
{
id: 4, ts: minsAgo(18), level: 'info', op: 'fetch_invoice',
msg: 'Tool invoked — GET /invoices/inv_003', duration: '95ms', status: 200,
detail: {
req: '{"invoice_id":"inv_003"}',
res: '{"id":"inv_003","amount":2400,"currency":"USD","status":"paid"}'
}
},
{
id: 5, ts: minsAgo(26), level: 'error', op: 'stream_telemetry',
msg: 'Upstream unavailable', duration: '0ms', status: 503,
detail: {
req: '{"stream_id":"st_881","since":"2026-03-27T14:00:00Z"}',
res: '503 Service Unavailable\nRetry-After: 60'
}
},
{
id: 6, ts: minsAgo(38), level: 'info', op: 'create_crm_lead',
msg: 'Tool invoked — POST /v1/leads', duration: '167ms', status: 201,
detail: {
req: '{"first_name":"Bob","last_name":"Jones","email":"bob@example.com"}',
res: '{"lead_id":"ld_9f3b","status":"new"}'
}
},
{
id: 7, ts: minsAgo(52), level: 'debug', op: 'get_user_profile',
msg: 'Schema validation passed — 4 fields mapped', duration: '41ms', status: 200,
detail: {
req: '{"user_id":"usr_0041"}',
res: '{"id":"usr_0041","name":"Bob Jones","email":"bob@example.com","role":"viewer"}'
}
},
{
id: 8, ts: minsAgo(75), level: 'info', op: 'list_products',
msg: 'Tool invoked — GET /api/products', duration: '210ms', status: 200,
detail: {
req: '{"limit":20,"page":1}',
res: '{"total":142,"items":[{"id":"p01","name":"Mechanical Keyboard","price":149.99},...]}'
}
},
{
id: 9, ts: minsAgo(110), level: 'info', op: 'create_support_ticket',
msg: 'Tool invoked — POST /v1/tickets', duration: '178ms', status: 201,
detail: {
req: '{"subject":"Login issue","priority":"high","user_id":"usr_0042"}',
res: '{"ticket_id":"tkt_5521","status":"open","created_at":"2026-03-27T12:42:11Z"}'
}
},
{
id: 10, ts: minsAgo(190), level: 'error', op: 'send_slack_message',
msg: 'Upstream returned 503 Service Unavailable — retried 3×', duration: '0ms', status: 503,
detail: {
req: '{"channel":"#alerts","text":"DB failover triggered"}',
res: '503 Service Unavailable\nRetry-After: 30'
}
},
{
id: 11, ts: minsAgo(280), level: 'info', op: 'fetch_invoice',
msg: 'Tool invoked — GET /invoices/inv_002', duration: '88ms', status: 200,
detail: {
req: '{"invoice_id":"inv_002"}',
res: '{"id":"inv_002","amount":1250,"currency":"USD","status":"open"}'
}
},
{
id: 12, ts: minsAgo(420), level: 'warn', op: 'get_order_status',
msg: 'Validation error: missing field order_id', duration: '55ms', status: 422,
detail: {
req: '{"customer_id":"cus_009"}',
res: '{"error":"validation_failed","fields":["order_id"]}'
}
},
{
id: 13, ts: minsAgo(720), level: 'info', op: 'create_crm_lead',
msg: 'Tool invoked — POST /v1/leads', duration: '155ms', status: 200,
detail: {
req: '{"first_name":"Carol","last_name":"Davis","email":"carol@co.io"}',
res: '{"lead_id":"ld_9f3c","status":"new"}'
}
},
{
id: 14, ts: minsAgo(2200), level: 'info', op: 'sync_contacts',
msg: 'Tool invoked — POST /v1/contacts/sync', duration: '340ms', status: 200,
detail: {
req: '{"source":"hubspot","since":"2026-03-25T00:00:00Z"}',
res: '{"synced":48,"skipped":2,"errors":0}'
}
},
{
id: 15, ts: minsAgo(7200), level: 'debug', op: 'list_products',
msg: 'Cache miss — fetching from origin', duration: '198ms', status: 200,
detail: {
req: '{"limit":10,"page":1}',
res: '{"total":142,"items":[...]}'
}
}
];
// ── State ──────────────────────────────────────────────────────────────────
var levelFilter = 'all';
var searchText = '';
var timeRange = '7d';
var openId = null;
var liveMode = true;
var liveTimer = null;
var nextId = 100;
// ── DOM refs ───────────────────────────────────────────────────────────────
var logList = document.getElementById('log-list');
var logSearch = document.getElementById('log-search');
var refreshBtn = document.getElementById('refresh-btn');
var timeRangeSel = document.getElementById('time-range');
var liveDot = document.querySelector('.live-dot');
var liveLabel = document.querySelector('.live-label');
// ── Debug chip injection ───────────────────────────────────────────────────
// The HTML has All/Info/Warn/Error chips. We add Debug after Error.
var chipError = document.getElementById('chip-error');
if (chipError && !document.getElementById('chip-debug')) {
var debugChip = document.createElement('button');
debugChip.className = 'filter-chip';
debugChip.setAttribute('data-level', 'debug');
debugChip.id = 'chip-debug';
var debugSpan = document.createElement('span');
debugSpan.className = 'log-level debug';
debugSpan.style.cssText = 'padding:0 4px;font-size:10px;';
debugSpan.textContent = 'DEBUG';
debugChip.appendChild(debugSpan);
chipError.parentNode.insertBefore(debugChip, chipError.nextSibling);
}
// ── Time-range helpers ─────────────────────────────────────────────────────
function rangeMs(range) {
var map = { '30m': 30, '1h': 60, '6h': 360, '24h': 1440, '7d': 10080 };
var mins = map[range] || 10080;
return mins * 60 * 1000;
}
// ── Render ─────────────────────────────────────────────────────────────────
function statusClass(s) {
if (!s) return '';
if (s < 300) return 'ok';
if (s < 500) return 'warn';
return 'err';
}
function escText(str) {
// Safely return string; all user-facing dynamic strings go through textContent,
// but for pre blocks in detail we use textContent assignment too (see below).
return str;
}
function renderLogs() {
var q = searchText.toLowerCase();
var cutoff = Date.now() - rangeMs(timeRange);
var rows = LOGS.filter(function (l) {
var ts = (l.ts instanceof Date) ? l.ts.getTime() : new Date(l.ts).getTime();
var matchTime = ts >= cutoff;
var matchLevel = levelFilter === 'all' || l.level === levelFilter;
var matchSearch = !q ||
l.msg.toLowerCase().indexOf(q) !== -1 ||
l.op.indexOf(q) !== -1;
return matchTime && matchLevel && matchSearch;
});
// Build DOM nodes instead of innerHTML for XSS safety where data is dynamic.
// For static/controlled detail payloads we still use textContent on pre elements.
var frag = document.createDocumentFragment();
if (!rows.length) {
var empty = document.createElement('div');
empty.style.cssText = 'text-align:center;padding:40px;color:var(--text-muted);font-size:13.5px;';
empty.textContent = 'No log entries match the current filter';
frag.appendChild(empty);
}
rows.forEach(function (l) {
var tsDate = (l.ts instanceof Date) ? l.ts : new Date(l.ts);
var timeStr = fmtTs(tsDate).slice(11, 23); // HH:MM:SS.mmm
// ── Row ──
var row = document.createElement('div');
row.className = 'log-entry';
row.setAttribute('data-id', l.id);
var timeEl = document.createElement('span');
timeEl.className = 'log-time';
timeEl.textContent = timeStr;
row.appendChild(timeEl);
var levelEl = document.createElement('span');
levelEl.className = 'log-level ' + l.level;
levelEl.textContent = l.level.toUpperCase();
row.appendChild(levelEl);
var msgDiv = document.createElement('div');
msgDiv.className = 'log-msg';
var opSpan = document.createElement('span');
opSpan.className = 'log-op-name';
opSpan.textContent = l.op;
msgDiv.appendChild(opSpan);
msgDiv.appendChild(document.createTextNode('\u00a0\u00a0')); // two nbsps
if (l.status) {
var statusEl = document.createElement('span');
statusEl.className = 'log-status ' + statusClass(l.status);
statusEl.textContent = l.status;
msgDiv.appendChild(statusEl);
msgDiv.appendChild(document.createTextNode(' \u00b7 ')); // · separator
}
var msgText = document.createTextNode(l.msg);
msgDiv.appendChild(msgText);
row.appendChild(msgDiv);
var durEl = document.createElement('span');
var durSlow = (l.duration.indexOf('s') !== -1 && parseFloat(l.duration) > 1);
var durClass = durSlow ? 'slow' : (l.level === 'error' ? 'error' : '');
durEl.className = 'log-duration' + (durClass ? ' ' + durClass : '');
durEl.textContent = l.duration;
row.appendChild(durEl);
frag.appendChild(row);
// ── Expanded detail ──
if (l.detail) {
var expDiv = document.createElement('div');
expDiv.className = 'log-entry-expanded' + (l.id === openId ? ' open' : '');
expDiv.setAttribute('data-exp', l.id);
if (l.detail.req) {
var reqLabel = document.createElement('div');
reqLabel.className = 'log-detail-label';
reqLabel.textContent = 'Request body';
expDiv.appendChild(reqLabel);
var reqPre = document.createElement('pre');
reqPre.className = 'log-detail-block';
reqPre.textContent = l.detail.req;
expDiv.appendChild(reqPre);
}
if (l.detail.res) {
var resLabel = document.createElement('div');
resLabel.className = 'log-detail-label';
resLabel.textContent = 'Response';
expDiv.appendChild(resLabel);
var resPre = document.createElement('pre');
resPre.className = 'log-detail-block';
resPre.textContent = l.detail.res;
expDiv.appendChild(resPre);
}
frag.appendChild(expDiv);
}
});
logList.innerHTML = '';
logList.appendChild(frag);
// Attach click handlers for expand/collapse
logList.querySelectorAll('.log-entry').forEach(function (el) {
el.addEventListener('click', function () {
var id = parseInt(this.getAttribute('data-id'), 10);
openId = (openId === id) ? null : id;
logList.querySelectorAll('.log-entry-expanded').forEach(function (exp) {
var expId = parseInt(exp.getAttribute('data-exp'), 10);
exp.classList.toggle('open', expId === openId);
});
});
});
}
// ── Level chip wiring ──────────────────────────────────────────────────────
function wireChips() {
document.querySelectorAll('.filter-chip[data-level]').forEach(function (btn) {
btn.addEventListener('click', function () {
levelFilter = this.getAttribute('data-level');
document.querySelectorAll('.filter-chip[data-level]').forEach(function (b) {
b.classList.remove('active');
});
this.classList.add('active');
renderLogs();
});
});
}
wireChips();
// ── Search ─────────────────────────────────────────────────────────────────
logSearch.addEventListener('input', function () {
searchText = this.value;
renderLogs();
});
// ── Refresh ────────────────────────────────────────────────────────────────
refreshBtn.addEventListener('click', renderLogs);
// ── Time-range select ──────────────────────────────────────────────────────
// The HTML uses a <select id="time-range"> with option values 30m/1h/6h/24h/7d.
// Default the select to "7d" on page load.
if (timeRangeSel) {
// Set initial value to 7d
timeRangeSel.value = '7d';
timeRange = '7d';
timeRangeSel.addEventListener('change', function () {
timeRange = this.value;
renderLogs();
});
}
// ── Live mode ──────────────────────────────────────────────────────────────
var LIVE_OPS = ['create_crm_lead', 'get_user_profile', 'fetch_invoice', 'list_products'];
var LIVE_MSGS = {
'create_crm_lead': 'Tool invoked — POST /v1/leads',
'get_user_profile': 'Tool invoked — GET /v1/users/me',
'fetch_invoice': 'Tool invoked — GET /invoices/inv_live',
'list_products': 'Tool invoked — GET /api/products'
};
var LIVE_STATUSES = { 'info': 200, 'warn': 429, 'error': 503, 'debug': 200 };
function pickLiveLevel() {
var r = Math.random();
if (r < 0.70) return 'info';
if (r < 0.85) return 'warn';
if (r < 0.95) return 'error';
return 'debug';
}
function makeLiveEntry() {
var op = LIVE_OPS[Math.floor(Math.random() * LIVE_OPS.length)];
var level = pickLiveLevel();
var status = LIVE_STATUSES[level];
var duration = (level === 'error') ? '0ms' : (Math.floor(Math.random() * 400) + 20) + 'ms';
nextId += 1;
return {
id: nextId,
ts: new Date(),
level: level,
op: op,
msg: LIVE_MSGS[op],
duration: duration,
status: status,
detail: {
req: '{"live":true}',
res: level === 'error' ? status + ' Service Unavailable' : '{"ok":true}'
}
};
}
function setLivePulsing(on) {
if (!liveDot) return;
if (on) {
liveDot.style.animationPlayState = 'running';
liveDot.style.opacity = '';
} else {
liveDot.style.animationPlayState = 'paused';
liveDot.style.opacity = '0.35';
}
}
function updateLiveLabel() {
if (!liveLabel) return;
liveLabel.textContent = liveMode ? 'Live' : 'Paused';
liveLabel.style.color = liveMode ? '' : 'var(--text-muted)';
}
function startLive() {
if (liveTimer) return;
liveTimer = setInterval(function () {
LOGS.unshift(makeLiveEntry());
if (LOGS.length > 100) {
LOGS.length = 100;
}
renderLogs();
}, 4000);
}
function stopLive() {
if (liveTimer) {
clearInterval(liveTimer);
liveTimer = null;
}
}
function toggleLive() {
liveMode = !liveMode;
setLivePulsing(liveMode);
updateLiveLabel();
if (liveMode) {
startLive();
} else {
stopLive();
}
}
// Wire live toggle to clicking the dot or the label
if (liveDot) {
liveDot.style.cursor = 'pointer';
liveDot.addEventListener('click', toggleLive);
}
if (liveLabel) {
liveLabel.style.cursor = 'pointer';
liveLabel.addEventListener('click', toggleLive);
}
// Also support a dedicated live-toggle button if one exists in the DOM
var liveToggleBtn = document.getElementById('live-toggle') || document.querySelector('.live-btn');
if (liveToggleBtn) {
liveToggleBtn.addEventListener('click', toggleLive);
}
// ── Initialise ─────────────────────────────────────────────────────────────
setLivePulsing(true);
updateLiveLabel();
startLive();
renderLogs();
});
+102
View File
@@ -0,0 +1,102 @@
/**
* nav.js — shared navbar logic for non-catalog pages
* Handles: auth guard, user dropdown, hamburger, logout
*/
(function () {
/* ── Auth guard ── */
var STORAGE_KEY = 'crank_user';
var user = null;
try { user = JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch (e) {}
if (!user) {
var path = window.location.pathname;
var isLogin = path.endsWith('login.html') || path.endsWith('/login');
if (!isLogin) { window.location.replace((window.APP_BASE||'')+'html/login.html'); return; }
}
/* ── Fill user info once DOM is available ── */
function fillUserInfo() {
if (!user) return;
document.querySelectorAll('.nav-avatar').forEach(function (el) {
el.textContent = user.initials || 'AT';
});
var nameEl = document.querySelector('.user-dropdown-name');
var roleEl = document.querySelector('.user-dropdown-role');
if (nameEl) nameEl.textContent = user.name || 'Operator';
if (roleEl) roleEl.textContent = (user.role || 'Admin') + ' · ' + (user.workspace || 'workspace');
}
/* ── Wire up nav interactions ── */
function initNav() {
fillUserInfo();
var dropdown = document.querySelector('.user-dropdown');
var avatar = document.querySelector('.nav-avatar');
var hamburger = document.querySelector('.nav-hamburger');
var mobileNav = document.querySelector('.mobile-nav');
// Initially hide elements controlled by JS
if (dropdown) dropdown.style.display = 'none';
if (mobileNav) mobileNav.style.display = 'none';
// User dropdown toggle
if (avatar && dropdown) {
avatar.addEventListener('click', function (e) {
e.stopPropagation();
var showing = dropdown.style.display !== 'none';
dropdown.style.display = showing ? 'none' : 'block';
});
}
// Hamburger toggle
if (hamburger && mobileNav) {
hamburger.addEventListener('click', function (e) {
e.stopPropagation();
var showing = mobileNav.style.display !== 'none';
mobileNav.style.display = showing ? 'none' : 'block';
hamburger.classList.toggle('open', !showing);
});
}
// Close dropdown / mobile-nav on outside click
document.addEventListener('click', function (e) {
if (dropdown && !e.target.closest('.user-menu')) {
dropdown.style.display = 'none';
}
if (mobileNav && !e.target.closest('.navbar') && !e.target.closest('.mobile-nav')) {
mobileNav.style.display = 'none';
if (hamburger) hamburger.classList.remove('open');
}
});
// Action buttons inside dropdown
document.querySelectorAll('[data-action="logout"]').forEach(function (btn) {
btn.addEventListener('click', function () {
localStorage.removeItem(STORAGE_KEY);
window.location.href = (window.APP_BASE||'')+'html/login.html';
});
});
document.querySelectorAll('[data-action="profile"]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (dropdown) dropdown.style.display = 'none';
window.location.href = (window.APP_BASE||'')+'html/settings.html#profile';
});
});
document.querySelectorAll('[data-action="settings-link"]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (dropdown) dropdown.style.display = 'none';
window.location.href = (window.APP_BASE||'')+'html/settings.html';
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initNav);
} else {
initNav();
}
})();
+73
View File
@@ -0,0 +1,73 @@
// Populate profile from stored user
try {
var u = JSON.parse(localStorage.getItem('crank_user'));
if (u) {
document.getElementById('profile-avatar').textContent = u.initials || 'AT';
document.getElementById('profile-display-name').textContent = u.name || 'Operator';
document.getElementById('profile-email').textContent = u.email || '';
var fi = document.getElementById('field-firstname');
var fe = document.getElementById('field-email');
if (fi && u.name) fi.value = u.name;
if (fe && u.email) fe.value = u.email;
}
} catch (e) {}
// Inject language switcher into section-profile card body, before Save button
(function() {
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(r) { return r.text(); })
.then(function(html) {
var saveRow = cardBody.querySelector('div[style*="justify-content:flex-end"]');
if (saveRow) {
saveRow.insertAdjacentHTML('beforebegin', html);
} else {
cardBody.insertAdjacentHTML('beforeend', html);
}
if (typeof applyLang === 'function') applyLang();
});
})();
// Settings sidebar navigation
var navItems = document.querySelectorAll('.settings-nav-item[data-section]');
navItems.forEach(function(btn) {
btn.addEventListener('click', function() {
navItems.forEach(function(b) { b.classList.remove('active'); });
this.classList.add('active');
var target = document.getElementById('section-' + this.dataset.section);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
// Hash navigation (e.g. settings.html#security)
if (window.location.hash) {
var hash = window.location.hash.slice(1);
var btn = document.querySelector('.settings-nav-item[data-section="' + hash + '"]');
if (btn) setTimeout(function() { btn.click(); }, 100);
}
// Default: scroll to profile
else {
var profileBtn = document.querySelector('.settings-nav-item[data-section="profile"]');
if (profileBtn) profileBtn.classList.add('active');
}
// IntersectionObserver scroll-spy: highlight active sidebar item as user scrolls
var observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
var sectionId = entry.target.id; // e.g. "section-profile"
var name = sectionId.replace('section-', ''); // e.g. "profile"
document.querySelectorAll('.settings-nav-item[data-section]').forEach(function(b) {
b.classList.toggle('active', b.dataset.section === name);
});
}
});
}, { threshold: 0.3 });
// Observe all visible sections (not hidden ones)
document.querySelectorAll('[id^="section-"]').forEach(function(el) {
if (el.style.display !== 'none') observer.observe(el);
});
+270
View File
@@ -0,0 +1,270 @@
document.addEventListener('DOMContentLoaded', function () {
// ── Period datasets ───────────────────────────────────────────────────────
var CHART_DATA = {
'7d': [
{d:'Mon', s:3838, e:18}, {d:'Tue', s:4242, e:32}, {d:'Wed', s:3988, e:28},
{d:'Thu', s:4147, e:45}, {d:'Fri', s:3810, e:22}, {d:'Sat', s:2915, e:15},
{d:'Sun', s:1569, e:8}
],
'30d': [
{d:'Wk 1', s:24500, e:120}, {d:'Wk 2', s:26800, e:145},
{d:'Wk 3', s:25100, e:98}, {d:'Wk 4', s:27400, e:178}
],
'90d': [
{d:'Jan', s:92000, e:420}, {d:'Feb', s:108000, e:510}, {d:'Mar', s:98400, e:458}
],
'this_month': [
{d:'Wk 1', s:24500, e:120}, {d:'Wk 2', s:26800, e:145},
{d:'Wk 3', s:25100, e:98}, {d:'Wk 4', s:27400, e:178}
]
};
// Base 7-day operation data; other periods scale from this.
var OPS_7D = [
{ name: 'create_crm_lead', display: 'Create CRM Lead', proto: 'REST', calls: 9840, errors: 42, p50: 138, p95: 390, p99: 820 },
{ name: 'search_products', display: 'Search Products', proto: 'REST', calls: 7123, errors: 8, p50: 91, p95: 210, p99: 440 },
{ name: 'fetch_invoice', display: 'Fetch Invoice', proto: 'REST', calls: 5210, errors: 214, p50: 104, p95: 540, p99: 8200 },
{ name: 'update_contact', display: 'Update Contact', proto: 'REST', calls: 3882, errors: 28, p50: 192, p95: 460, p99: 910 },
{ name: 'send_email', display: 'Send Email', proto: 'REST', calls: 1440, errors: 96, p50: 310, p95: 2100, p99: 30100 },
{ name: 'list_orders', display: 'List Orders (GraphQL)', proto: 'GraphQL', calls: 670, errors: 2, p50: 66, p95: 180, p99: 320 },
{ name: 'delete_record', display: 'Delete Record', proto: 'REST', calls: 176, errors: 6, p50: 285, p95: 690, p99: 1400 }
];
function scaleOps(factor) {
return OPS_7D.map(function (op) {
return {
name: op.name,
display: op.display,
proto: op.proto,
calls: Math.round(op.calls * factor),
errors: Math.round(op.errors * factor),
p50: op.p50,
p95: op.p95,
p99: op.p99
};
});
}
var OPS_DATA = {
'7d': OPS_7D,
'30d': scaleOps(4.2),
'90d': scaleOps(12),
'this_month': scaleOps(4.2)
};
// ── Stat card data ────────────────────────────────────────────────────────
var STATS = {
'7d': {
invocations: '28,341',
invDelta: { dir: 'up', text: '+12% vs prior period' },
success: '98.6%',
sucDelta: { dir: 'up', text: '+0.3pp vs prior period' },
p50: '124ms',
p50Delta: { dir: 'up', text: '-18ms vs prior period' },
p99: '2.1s',
p99Delta: { dir: 'down', text: '+340ms vs prior period' }
},
'30d': {
invocations: '118,623',
invDelta: { dir: 'up', text: '+8% vs prior period' },
success: '98.4%',
sucDelta: { dir: 'down', text: '-0.2pp vs prior period' },
p50: '128ms',
p50Delta: { dir: 'down', text: '+4ms vs prior period' },
p99: '2.3s',
p99Delta: { dir: 'down', text: '+200ms vs prior period' }
},
'90d': {
invocations: '342,804',
invDelta: { dir: 'up', text: '+15% vs prior period' },
success: '98.7%',
sucDelta: { dir: 'up', text: '+0.1pp vs prior period' },
p50: '121ms',
p50Delta: { dir: 'up', text: '-7ms vs prior period' },
p99: '2.0s',
p99Delta: { dir: 'up', text: '-100ms vs prior period' }
},
'this_month': {
invocations: '118,623',
invDelta: { dir: 'up', text: '+8% vs prior period' },
success: '98.4%',
sucDelta: { dir: 'down', text: '-0.2pp vs prior period' },
p50: '128ms',
p50Delta: { dir: 'down', text: '+4ms vs prior period' },
p99: '2.3s',
p99Delta: { dir: 'down', text: '+200ms vs prior period' }
}
};
// ── Helpers ───────────────────────────────────────────────────────────────
var COLORS = { REST: 'var(--blue)', GraphQL: 'var(--purple)', gRPC: 'var(--accent)' };
var QUOTA = 50000;
function fmtMs(ms) {
return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms';
}
function errRateClass(rate) {
if (rate > 5) return 'color:var(--red)';
if (rate > 1) return 'color:var(--amber)';
return 'color:var(--green)';
}
var ARROW_UP = '<svg width="11" height="11"><use href="' + (window.APP_BASE||'') + 'icons/general/arrow-up.svg#icon"/></svg>';
var ARROW_DOWN = '<svg width="11" height="11"><use href="' + (window.APP_BASE||'') + 'icons/general/arrow-down.svg#icon"/></svg>';
// ── Render functions ──────────────────────────────────────────────────────
function renderChart(period) {
var data = CHART_DATA[period];
var maxVal = Math.max.apply(null, data.map(function (d) { return d.s + d.e; }));
var wrap = document.getElementById('chart-bars');
var tmpl = document.getElementById('tmpl-chart-bar');
wrap.innerHTML = '';
data.forEach(function (d) {
var hS = Math.round((d.s / maxVal) * 160);
var hE = Math.round((d.e / maxVal) * 160);
var node = tmpl.content.cloneNode(true);
node.querySelector('.chart-bar.error').style.height = hE + 'px';
node.querySelector('.chart-bar.error').dataset.tip = d.e + ' errors';
node.querySelector('.chart-bar.success').style.height = hS + 'px';
node.querySelector('.chart-bar.success').dataset.tip = d.s.toLocaleString() + ' ok';
node.querySelector('.chart-col-label').textContent = d.d;
wrap.appendChild(node);
});
}
function renderTable(period) {
var ops = OPS_DATA[period];
var tbody = document.getElementById('usage-tbody');
var tmpl = document.getElementById('tmpl-usage-row');
tbody.innerHTML = '';
ops.forEach(function (op) {
var errRate = (op.errors / op.calls * 100).toFixed(2);
var quotaW = Math.min(op.calls / QUOTA * 100, 100).toFixed(1);
var node = tmpl.content.cloneNode(true);
node.querySelector('.col-name').textContent = op.display;
node.querySelector('.col-op-slug').textContent = op.name;
node.querySelector('.col-proto').textContent = op.proto;
node.querySelector('.col-calls').textContent = op.calls.toLocaleString();
node.querySelector('.col-errors').textContent = op.errors.toLocaleString();
var errEl = node.querySelector('.col-err-rate');
errEl.innerHTML = '<span style="' + errRateClass(parseFloat(errRate)) + '">' + errRate + '%</span>';
var p50w = (op.p50 / op.p99 * 64).toFixed(1);
var p95w = ((op.p95 - op.p50) / op.p99 * 64).toFixed(1);
var p99w = ((op.p99 - op.p95) / op.p99 * 64).toFixed(1);
node.querySelector('.latency-p50').style.width = p50w + 'px';
node.querySelector('.latency-p50').title = 'p50: ' + fmtMs(op.p50);
node.querySelector('.latency-p95').style.width = p95w + 'px';
node.querySelector('.latency-p95').title = 'p95: ' + fmtMs(op.p95);
node.querySelector('.latency-p99').style.width = p99w + 'px';
node.querySelector('.latency-p99').title = 'p99: ' + fmtMs(op.p99);
node.querySelector('.col-latency-text').textContent =
fmtMs(op.p50) + ' / ' + fmtMs(op.p95) + ' / ' + fmtMs(op.p99);
node.querySelector('.col-quota-label').textContent =
op.calls.toLocaleString() + ' / ' + QUOTA.toLocaleString();
node.querySelector('.col-quota-fill').style.width = quotaW + '%';
tbody.appendChild(node);
});
}
function renderStats(period) {
var s = STATS[period];
var cards = document.querySelectorAll('.stats-grid .stat-card');
var data = [
{ value: s.invocations, delta: s.invDelta },
{ value: s.success, delta: s.sucDelta },
{ value: s.p50, delta: s.p50Delta },
{ value: s.p99, delta: s.p99Delta }
];
for (var i = 0; i < cards.length; i++) {
var card = cards[i];
var d = data[i];
card.querySelector('.stat-value').textContent = d.value;
var deltaEl = card.querySelector('.stat-delta');
deltaEl.className = 'stat-delta ' + d.delta.dir;
var arrow = d.delta.dir === 'up' ? ARROW_UP : ARROW_DOWN;
deltaEl.innerHTML = arrow + ' ' + d.delta.text;
}
}
function updateSubtitle(period) {
var labels = {
'7d': 'last 7 days',
'30d': 'last 30 days',
'90d': 'last 90 days',
'this_month': 'this month'
};
var el = document.querySelector('.section-card-subtitle');
if (el) el.textContent = 'Breakdown for ' + (labels[period] || period);
}
// ── CSV export ────────────────────────────────────────────────────────────
function exportCSV(period) {
var ops = OPS_DATA[period];
var rows = ['Operation,Protocol,Calls,Errors,Error Rate (%),p50 (ms),p95 (ms),p99 (ms)'];
ops.forEach(function (op) {
var errRate = (op.errors / op.calls * 100).toFixed(2);
rows.push([
'"' + op.display + '"',
op.proto,
op.calls,
op.errors,
errRate,
op.p50,
op.p95,
op.p99
].join(','));
});
var csv = rows.join('\r\n');
var blob = new Blob([csv], {type: 'text/csv'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'crank-usage-' + period + '.csv';
a.click();
URL.revokeObjectURL(url);
}
// ── Period selector ───────────────────────────────────────────────────────
var currentPeriod = '7d';
var periodSelect = document.getElementById('period');
if (periodSelect) {
periodSelect.value = currentPeriod;
periodSelect.addEventListener('change', function () {
currentPeriod = this.value;
renderChart(currentPeriod);
renderTable(currentPeriod);
renderStats(currentPeriod);
updateSubtitle(currentPeriod);
});
}
// Export CSV button — first .btn-secondary inside .page-header-actions
var exportBtn = document.querySelector('.page-header-actions .btn-secondary');
if (exportBtn) {
exportBtn.addEventListener('click', function () {
exportCSV(currentPeriod);
});
}
// ── Initial render ────────────────────────────────────────────────────────
renderChart(currentPeriod);
renderTable(currentPeriod);
renderStats(currentPeriod);
updateSubtitle(currentPeriod);
});
+1041
View File
File diff suppressed because it is too large Load Diff
+193
View File
@@ -0,0 +1,193 @@
var selectedColor = '#0d9488';
var slugManual = false;
var isCreateMode = false;
var formDirty = false;
function initPage() {
var params = new URLSearchParams(window.location.search);
isCreateMode = params.get('mode') === 'create';
if (isCreateMode) {
document.title = 'Crank — Create workspace';
document.getElementById('page-title').textContent = 'Create a new workspace';
document.getElementById('page-subtitle').textContent = 'A workspace is a shared environment for your team\'s MCP operations and agents. Each workspace gets its own MCP endpoint namespace.';
document.getElementById('section-invite').style.display = '';
document.getElementById('footer-note').style.display = '';
var btn = document.getElementById('submit-btn');
btn.textContent = 'Create workspace';
btn.disabled = true;
btn.style.opacity = '0.5';
btn.style.cursor = 'not-allowed';
} else {
// Edit mode — prefill from current workspace
document.getElementById('section-members').style.display = '';
document.getElementById('section-danger').style.display = '';
var ws = getCurrentWs();
document.getElementById('ws-name').value = ws.name;
document.getElementById('ws-slug').value = ws.slug;
document.getElementById('ws-avatar-preview').textContent = ws.letter;
document.getElementById('ws-avatar-preview').style.background = ws.color;
selectedColor = ws.color;
slugManual = true;
// Activate matching color swatch
document.querySelectorAll('.ws-color-swatch').forEach(function(s) {
s.classList.toggle('active', s.dataset.color === ws.color);
});
// Scroll to #section-members if hash present
if (window.location.hash === '#section-members') {
setTimeout(function() {
document.getElementById('section-members').scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}
// Apply language state to lang buttons
var lang = localStorage.getItem('crank_lang') || 'en';
document.querySelectorAll('.lang-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.lang === lang);
});
}
function onWsNameInput(val) {
var letter = val.trim() ? val.trim()[0].toUpperCase() : '?';
document.getElementById('ws-avatar-preview').textContent = letter;
if (!slugManual) {
var slug = val.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
document.getElementById('ws-slug').value = slug;
}
if (isCreateMode) validateCreateForm();
formDirty = true;
}
function onWsSlugInput(val) {
var clean = val.toLowerCase().replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-');
document.getElementById('ws-slug').value = clean;
slugManual = true;
if (isCreateMode) validateCreateForm();
formDirty = true;
}
function pickColor(el) {
selectedColor = el.dataset.color;
document.querySelectorAll('.ws-color-swatch').forEach(function(s) { s.classList.remove('active'); });
el.classList.add('active');
document.getElementById('ws-avatar-preview').style.background = selectedColor;
formDirty = true;
}
function validateCreateForm() {
var name = document.getElementById('ws-name').value.trim();
var slug = document.getElementById('ws-slug').value.trim();
var btn = document.getElementById('submit-btn');
var valid = name.length > 0 && slug.length > 0;
btn.disabled = !valid;
btn.style.opacity = valid ? '1' : '0.5';
btn.style.cursor = valid ? 'pointer' : 'not-allowed';
}
function submitForm() {
var name = document.getElementById('ws-name').value.trim();
var slug = document.getElementById('ws-slug').value.trim();
if (!name || !slug) return;
formDirty = false;
if (isCreateMode) {
if (window.WS_LIST) {
WS_LIST.push({ slug: slug, name: slug, role: 'Owner', letter: name[0].toUpperCase(), color: selectedColor });
}
localStorage.setItem('crank_workspace', slug);
window.location.href = (window.APP_BASE||'')+'index.html';
} else {
// Update current workspace in WS_LIST
if (window.WS_LIST) {
var current = localStorage.getItem('crank_workspace') || 'acme-workspace';
var idx = WS_LIST.findIndex(function(w) { return w.slug === current; });
if (idx !== -1) {
WS_LIST[idx].name = slug;
WS_LIST[idx].slug = slug;
WS_LIST[idx].letter = name[0].toUpperCase();
WS_LIST[idx].color = selectedColor;
}
}
localStorage.setItem('crank_workspace', slug);
// Show saved feedback
var btn = document.getElementById('submit-btn');
var orig = btn.textContent;
btn.textContent = 'Saved ✓';
btn.disabled = true;
setTimeout(function() { btn.textContent = orig; btn.disabled = false; }, 1800);
}
}
// Create mode invite rows
function addInviteRow() {
var rows = document.getElementById('invite-rows');
var div = document.createElement('div');
div.className = 'invite-row';
div.innerHTML =
'<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">Admin</option>' +
'<option value="developer" selected>Developer</option>' +
'<option value="viewer">Viewer</option>' +
'</select>' +
'<button class="invite-row-remove" onclick="removeInviteRow(this)" title="Remove">' +
'<svg width="12" height="12"><use href="' + (window.APP_BASE||'') + 'icons/general/close.svg#icon"/></svg>' +
'</button>';
rows.appendChild(div);
}
function removeInviteRow(btn) {
btn.closest('.invite-row').remove();
}
// Members management (edit mode)
function toggleInviteForm() {
var f = document.getElementById('invite-form');
var s = document.getElementById('invite-success');
if (f.style.display === 'none') {
f.style.display = '';
s.style.display = 'none';
document.getElementById('invite-email').focus();
} else {
f.style.display = 'none';
}
}
function sendInvite() {
var email = document.getElementById('invite-email').value.trim();
if (!email || !email.includes('@')) return;
document.getElementById('invite-success').style.display = '';
document.getElementById('invite-email').value = '';
}
function updateRole(select, email) {
console.log('Role updated:', email, '->', select.value);
}
function removeMember(btn, name) {
if (!confirm('Remove ' + name + ' from this workspace?')) return;
btn.closest('.member-row').remove();
var rows = document.querySelectorAll('#members-list .member-row');
var label = document.getElementById('members-count-label');
if (label) label.textContent = rows.length + ' member' + (rows.length !== 1 ? 's' : '');
}
function revokeInvite(btn) {
btn.closest('.member-row').remove();
var pending = document.getElementById('pending-invites');
if (pending && !pending.querySelector('.member-row')) pending.style.display = 'none';
}
document.addEventListener('DOMContentLoaded', function() {
initPage();
if (isCreateMode) document.getElementById('ws-name').focus();
});
window.addEventListener('beforeunload', function(e) {
if (formDirty) {
e.preventDefault();
e.returnValue = '';
}
});
+64
View File
@@ -0,0 +1,64 @@
/* ═══════════════════════════════════════════════════
Crank — workspace switcher
═══════════════════════════════════════════════════ */
var WS_LIST = [
{ slug: 'acme-workspace', name: 'acme-workspace', role: 'Owner', letter: 'A', color: '#0d9488' },
{ slug: 'startup-demo', name: 'startup-demo', role: 'Developer', letter: 'S', color: '#7c3aed' },
{ slug: 'personal', name: 'personal', role: 'Owner', letter: 'P', color: '#0891b2' },
];
function getCurrentWs() {
var slug = localStorage.getItem('crank_workspace') || 'acme-workspace';
return WS_LIST.find(function(w) { return w.slug === slug; }) || WS_LIST[0];
}
function initWorkspaceSwitcher() {
var ws = getCurrentWs();
var nameEl = document.getElementById('ws-current-name');
var dotEl = document.getElementById('ws-dot');
if (nameEl) nameEl.textContent = ws.name;
if (dotEl) { dotEl.textContent = ws.letter; dotEl.style.background = ws.color; }
var list = document.getElementById('ws-dropdown-list');
if (!list) return;
list.innerHTML = WS_LIST.map(function(w) {
var active = w.slug === ws.slug;
return '<div class="ws-dropdown-item' + (active ? ' active' : '') + '" onclick="switchWorkspace(\'' + w.slug + '\')">' +
'<div class="ws-item-dot" style="background:' + w.color + '">' + w.letter + '</div>' +
'<div class="ws-item-info">' +
'<div class="ws-item-name">' + w.name + '</div>' +
'<div class="ws-item-role">' + w.role + '</div>' +
'</div>' +
(active ? '<svg width="12" height="12"><use href="' + (window.APP_BASE||'') + 'icons/general/check.svg#icon"/></svg>' : '') +
'</div>';
}).join('') +
'<div class="ws-dropdown-divider"></div>' +
'<a class="ws-dropdown-mgmt-link" href="' + (window.APP_BASE||'') + 'html/workspace-setup.html" onclick="var dd=document.getElementById(\'ws-dropdown\');if(dd)dd.style.display=\'none\'">' +
'<svg width="14" height="14"><use href="' + (window.APP_BASE||'') + 'icons/general/settings.svg#icon"/></svg>' +
'Workspace settings' +
'</a>';
}
function toggleWsSwitcher(e) {
e.stopPropagation();
var dd = document.getElementById('ws-dropdown');
if (dd) dd.style.display = dd.style.display === 'none' ? '' : 'none';
}
function switchWorkspace(slug) {
localStorage.setItem('crank_workspace', slug);
var dd = document.getElementById('ws-dropdown');
if (dd) dd.style.display = 'none';
initWorkspaceSwitcher();
}
document.addEventListener('DOMContentLoaded', initWorkspaceSwitcher);
document.addEventListener('click', function(e) {
if (!e.target.closest('#ws-switcher')) {
var dd = document.getElementById('ws-dropdown');
if (dd) dd.style.display = 'none';
}
});