feat: migrate alpine ui into app shell
This commit is contained in:
@@ -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);
|
||||
},
|
||||
}));
|
||||
});
|
||||
@@ -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);
|
||||
@@ -0,0 +1,333 @@
|
||||
const PAGE_SIZE_DESKTOP = 6;
|
||||
const PAGE_SIZE_MOBILE = 4;
|
||||
const MOBILE_BREAKPOINT = 960;
|
||||
|
||||
function getSortOptions() {
|
||||
return [
|
||||
{ value: 'created_desc', label: (window.t && t('sort.created_desc')) || 'Sort: Last created' },
|
||||
{ value: 'created_asc', label: (window.t && t('sort.created_asc')) || 'Sort: Oldest first' },
|
||||
{ value: 'name_asc', label: (window.t && t('sort.name_asc')) || 'Sort: Name A–Z' },
|
||||
{ value: 'name_desc', label: (window.t && t('sort.name_desc')) || 'Sort: Name Z–A' },
|
||||
];
|
||||
}
|
||||
|
||||
function getTabDefs() {
|
||||
return [
|
||||
{ id: 'all', label: (window.t && t('tab.all')) || 'All' },
|
||||
{ id: 'active', label: (window.t && t('tab.active')) || 'Active' },
|
||||
{ id: 'draft', label: (window.t && t('tab.draft')) || 'Draft' },
|
||||
{ id: 'error', label: (window.t && t('tab.error')) || 'Error' },
|
||||
{ id: 'inactive', label: (window.t && t('tab.inactive')) || 'Inactive' },
|
||||
];
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
|
||||
Alpine.data('catalog', () => ({
|
||||
// ── raw data ──
|
||||
operations: [],
|
||||
agents: [],
|
||||
loading: true,
|
||||
|
||||
// ── page size (responsive) ──
|
||||
pageSize: PAGE_SIZE_DESKTOP,
|
||||
|
||||
// ── filter state ──
|
||||
search: '',
|
||||
tab: 'all',
|
||||
filterProtocol: null,
|
||||
filterCategory: null,
|
||||
filterAgent: null,
|
||||
sort: 'created_desc',
|
||||
page: 1,
|
||||
|
||||
// ── dropdown open state ──
|
||||
openDropdown: null,
|
||||
|
||||
// ── mobile nav ──
|
||||
navOpen: false,
|
||||
|
||||
// ── derived option lists (built after data loads) ──
|
||||
categoryOptions: [],
|
||||
|
||||
async init() {
|
||||
const [opsRes, agentsRes] = await Promise.all([
|
||||
fetch((window.DATA_URL||'data/')+'operations.json'),
|
||||
fetch((window.DATA_URL||'data/')+'agents.json'),
|
||||
]);
|
||||
let ops = await opsRes.json();
|
||||
this.agents = await agentsRes.json();
|
||||
|
||||
// Apply localStorage overrides (from wizard create/edit)
|
||||
try {
|
||||
const overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]');
|
||||
if (overrides.length) {
|
||||
const existingIds = new Set(ops.map(o => o.id));
|
||||
ops = ops.map(op => {
|
||||
const ov = overrides.find(o => o.id === op.id);
|
||||
return ov ? Object.assign({}, op, ov) : op;
|
||||
});
|
||||
overrides
|
||||
.filter(o => !existingIds.has(o.id))
|
||||
.forEach(o => ops.push(o));
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
this.operations = ops;
|
||||
this.categoryOptions = [...new Set(this.operations.map(op => op.category))].filter(Boolean).sort();
|
||||
this.loading = false;
|
||||
|
||||
// Responsive page size
|
||||
const updatePageSize = () => {
|
||||
this.pageSize = window.innerWidth <= MOBILE_BREAKPOINT ? PAGE_SIZE_MOBILE : PAGE_SIZE_DESKTOP;
|
||||
this.page = 1;
|
||||
};
|
||||
updatePageSize();
|
||||
window.addEventListener('resize', updatePageSize);
|
||||
|
||||
// Re-render labels on language change
|
||||
window.addEventListener('crank:langchange', () => {
|
||||
this.sort = this.sort;
|
||||
applyLang();
|
||||
});
|
||||
|
||||
// Close dropdowns and mobile nav on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.filter-dropdown, .sort-dropdown, .user-menu')) {
|
||||
this.openDropdown = null;
|
||||
}
|
||||
if (!e.target.closest('.navbar')) {
|
||||
this.navOpen = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ── helpers for filtering (shared between filtered getter and tabCount) ──
|
||||
_applyNonStatusFilters(ops) {
|
||||
if (this.search.trim()) {
|
||||
const q = this.search.toLowerCase();
|
||||
ops = ops.filter(op =>
|
||||
op.name.toLowerCase().includes(q) ||
|
||||
op.display_name.toLowerCase().includes(q) ||
|
||||
(op.target_url || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (this.filterProtocol) {
|
||||
ops = ops.filter(op => op.protocol === this.filterProtocol);
|
||||
}
|
||||
if (this.filterCategory) {
|
||||
ops = ops.filter(op => op.category === this.filterCategory);
|
||||
}
|
||||
if (this.filterAgent) {
|
||||
const agent = this.agents.find(a => a.id === this.filterAgent);
|
||||
const ids = agent ? agent.operation_ids : [];
|
||||
ops = ops.filter(op => ids.includes(op.id));
|
||||
}
|
||||
return ops;
|
||||
},
|
||||
|
||||
// ── computed: filtered + sorted + paginated ──
|
||||
get filtered() {
|
||||
let ops = this._applyNonStatusFilters(this.operations);
|
||||
|
||||
if (this.tab !== 'all') {
|
||||
ops = ops.filter(op => op.status === this.tab);
|
||||
}
|
||||
|
||||
ops = [...ops].sort((a, b) => {
|
||||
switch (this.sort) {
|
||||
case 'created_desc': return new Date(b.created_at) - new Date(a.created_at);
|
||||
case 'created_asc': return new Date(a.created_at) - new Date(b.created_at);
|
||||
case 'name_asc': return a.name.localeCompare(b.name);
|
||||
case 'name_desc': return b.name.localeCompare(a.name);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return ops;
|
||||
},
|
||||
|
||||
get totalFiltered() { return this.filtered.length; },
|
||||
get totalPages() { return Math.max(1, Math.ceil(this.totalFiltered / this.pageSize)); },
|
||||
|
||||
get paginated() {
|
||||
const start = (this.page - 1) * this.pageSize;
|
||||
return this.filtered.slice(start, start + this.pageSize);
|
||||
},
|
||||
|
||||
get pageStart() { return this.totalFiltered === 0 ? 0 : (this.page - 1) * this.pageSize + 1; },
|
||||
get pageEnd() { return Math.min(this.page * this.pageSize, this.totalFiltered); },
|
||||
|
||||
// ── tab counts (respect non-status filters so numbers are consistent) ──
|
||||
tabCount(tab) {
|
||||
const ops = this._applyNonStatusFilters(this.operations);
|
||||
if (tab === 'all') return ops.length;
|
||||
return ops.filter(op => op.status === tab).length;
|
||||
},
|
||||
|
||||
// ── active filter chips (for dismissible chip row) ──
|
||||
get activeChips() {
|
||||
const chips = [];
|
||||
if (this.filterProtocol) {
|
||||
chips.push({ key: 'protocol', label: 'Protocol: ' + this.filterProtocol.toUpperCase() });
|
||||
}
|
||||
if (this.filterCategory) {
|
||||
chips.push({ key: 'category', label: 'Category: ' + this.filterCategory });
|
||||
}
|
||||
if (this.filterAgent) {
|
||||
const a = this.agents.find(ag => ag.id === this.filterAgent);
|
||||
chips.push({ key: 'agent', label: 'Agent: ' + (a ? a.display_name : '') });
|
||||
}
|
||||
return chips;
|
||||
},
|
||||
|
||||
get hasActiveFilters() {
|
||||
return !!(this.filterProtocol || this.filterCategory || this.filterAgent || this.search.trim());
|
||||
},
|
||||
|
||||
clearChip(key) {
|
||||
if (key === 'protocol') this.filterProtocol = null;
|
||||
if (key === 'category') this.filterCategory = null;
|
||||
if (key === 'agent') this.filterAgent = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
this.filterProtocol = null;
|
||||
this.filterCategory = null;
|
||||
this.filterAgent = null;
|
||||
this.search = '';
|
||||
this.tab = 'all';
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
// ── agent lookup per operation (for badges in rows) ──
|
||||
get agentsByOpId() {
|
||||
const map = {};
|
||||
this.agents.forEach(a => {
|
||||
a.operation_ids.forEach(id => {
|
||||
if (!map[id]) map[id] = [];
|
||||
map[id].push(a);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
},
|
||||
|
||||
// ── active agents only (for filter dropdown) ──
|
||||
get activeAgents() {
|
||||
return this.agents.filter(a => a.status === 'active');
|
||||
},
|
||||
|
||||
// ── sort label ──
|
||||
get sortLabel() {
|
||||
return getSortOptions().find(o => o.value === this.sort)?.label ?? 'Sort';
|
||||
},
|
||||
|
||||
// ── actions ──
|
||||
setTab(tab) {
|
||||
this.tab = tab;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setSearch(val) {
|
||||
this.search = val;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setProtocol(val) {
|
||||
this.filterProtocol = this.filterProtocol === val ? null : val;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setCategory(val) {
|
||||
this.filterCategory = this.filterCategory === val ? null : val;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setSort(val) {
|
||||
this.sort = val;
|
||||
this.openDropdown = null;
|
||||
},
|
||||
|
||||
setAgent(val) {
|
||||
this.filterAgent = this.filterAgent === val ? null : val;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
clearProtocol() { this.filterProtocol = null; this.openDropdown = null; this.page = 1; },
|
||||
clearCategory() { this.filterCategory = null; this.openDropdown = null; this.page = 1; },
|
||||
clearAgent() { this.filterAgent = null; this.openDropdown = null; this.page = 1; },
|
||||
|
||||
toggleDropdown(name) {
|
||||
this.openDropdown = this.openDropdown === name ? null : name;
|
||||
},
|
||||
|
||||
goPage(p) {
|
||||
if (p >= 1 && p <= this.totalPages) this.page = p;
|
||||
},
|
||||
|
||||
// ── operation CRUD ──
|
||||
editOperation(op) {
|
||||
try { sessionStorage.setItem('wizard_edit', JSON.stringify(op)); } catch (e) {}
|
||||
window.location.href = (window.APP_BASE||'')+'html/wizard/?mode=edit';
|
||||
},
|
||||
|
||||
deleteOperation(id) {
|
||||
if (!confirm('Delete this operation? This cannot be undone.')) return;
|
||||
this.operations = this.operations.filter(op => op.id !== id);
|
||||
// Persist deletion in overrides as a tombstone
|
||||
try {
|
||||
const overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]');
|
||||
const filtered = overrides.filter(o => o.id !== id);
|
||||
filtered.push({ id, _deleted: true });
|
||||
localStorage.setItem('crank_ops_overrides', JSON.stringify(filtered));
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
// ── helpers для шаблона ──
|
||||
protocolLabel(op) {
|
||||
if (op.protocol === 'rest') return `REST · ${op.method}`;
|
||||
if (op.protocol === 'graphql') return 'GraphQL';
|
||||
return 'gRPC';
|
||||
},
|
||||
|
||||
protocolClass(op) {
|
||||
return `badge badge-${op.protocol}`;
|
||||
},
|
||||
|
||||
statusClass(op) {
|
||||
return `status-badge status-${op.status}`;
|
||||
},
|
||||
|
||||
statusLabel(op) {
|
||||
return op.status.charAt(0).toUpperCase() + op.status.slice(1);
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
},
|
||||
|
||||
truncateUrl(url) {
|
||||
if (!url) return '';
|
||||
return url.length > 42 ? url.slice(0, 42) + '…' : url;
|
||||
},
|
||||
|
||||
get sortOptions() { return getSortOptions(); },
|
||||
get tabDefs() { return getTabDefs(); },
|
||||
|
||||
handleNewOperation() {
|
||||
sessionStorage.removeItem('wizard_edit');
|
||||
window.location.href = (window.APP_BASE||'')+'html/wizard/';
|
||||
},
|
||||
|
||||
handleLogout() {
|
||||
localStorage.removeItem('crank_user');
|
||||
window.location.href = (window.APP_BASE||'')+'html/login.html';
|
||||
},
|
||||
}));
|
||||
|
||||
});
|
||||
@@ -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/';
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,289 @@
|
||||
/* ═══════════════════════════════════════════════
|
||||
Crank — i18n
|
||||
Reads `crank_lang` from localStorage ('en'|'ru')
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
var TRANSLATIONS = {
|
||||
en: {
|
||||
// Nav
|
||||
'nav.operations': 'Operations',
|
||||
'nav.agents': 'Agents',
|
||||
'nav.apikeys': 'API Keys',
|
||||
'nav.logs': 'Logs',
|
||||
'nav.usage': 'Usage',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.logout': 'Log out',
|
||||
|
||||
// Operations page
|
||||
'ops.title': 'Operations',
|
||||
'ops.subtitle': 'Tool contracts that expose your upstream APIs as MCP tools',
|
||||
'ops.new': 'New operation',
|
||||
'ops.loading': 'Loading operations…',
|
||||
'ops.empty.title': 'No operations found',
|
||||
'ops.empty.sub': 'Try adjusting your search or filters.',
|
||||
|
||||
// Stats
|
||||
'stats.total': 'Total operations',
|
||||
'stats.active': 'Active',
|
||||
'stats.requests': 'Requests today',
|
||||
'stats.latency': 'Avg. latency',
|
||||
|
||||
// Tabs
|
||||
'tab.all': 'All',
|
||||
'tab.active': 'Active',
|
||||
'tab.draft': 'Draft',
|
||||
'tab.error': 'Error',
|
||||
'tab.inactive': 'Inactive',
|
||||
|
||||
// Filters & sort
|
||||
'filter.search': 'Search operations...',
|
||||
'filter.protocol': 'Protocol',
|
||||
'filter.status': 'Status',
|
||||
'filter.category': 'Category',
|
||||
'filter.showing': 'Showing',
|
||||
'filter.of': 'of',
|
||||
'filter.clear': 'Clear filter',
|
||||
'sort.created_desc':'Sort: Last created',
|
||||
'sort.created_asc': 'Sort: Oldest first',
|
||||
'sort.name_asc': 'Sort: Name A–Z',
|
||||
'sort.name_desc': 'Sort: Name Z–A',
|
||||
|
||||
// Table headers
|
||||
'th.name': 'NAME',
|
||||
'th.protocol': 'PROTOCOL',
|
||||
'th.status': 'STATUS',
|
||||
'th.created': 'CREATED',
|
||||
'th.url': 'TARGET URL',
|
||||
|
||||
// Pagination
|
||||
'page.showing': 'Showing',
|
||||
'page.of': 'of',
|
||||
'page.ops': 'operations',
|
||||
|
||||
// API Keys page
|
||||
'apikeys.title': 'API Keys',
|
||||
'apikeys.subtitle': 'Manage access tokens for external integrations',
|
||||
'apikeys.new': 'Create key',
|
||||
'apikeys.th.name': 'NAME',
|
||||
'apikeys.th.scope': 'SCOPE',
|
||||
'apikeys.th.created':'CREATED',
|
||||
'apikeys.th.last': 'LAST USED',
|
||||
|
||||
// Logs page
|
||||
'logs.title': 'Logs',
|
||||
'logs.subtitle': 'Real-time request and event log',
|
||||
'logs.search': 'Search logs...',
|
||||
'logs.level.all': 'All',
|
||||
'logs.level.info': 'Info',
|
||||
'logs.level.warn': 'Warn',
|
||||
'logs.level.error': 'Error',
|
||||
'logs.level.debug': 'Debug',
|
||||
|
||||
// Usage page
|
||||
'usage.title': 'Usage',
|
||||
'usage.subtitle': 'Request volume and performance metrics',
|
||||
|
||||
// Settings sidebar
|
||||
'settings.title': 'Settings',
|
||||
'settings.nav.workspace': 'Workspace',
|
||||
'settings.nav.profile': 'Profile',
|
||||
'settings.nav.members': 'Members',
|
||||
'settings.nav.security': 'Security',
|
||||
'settings.nav.notif': 'Notifications',
|
||||
'settings.nav.danger': 'Danger zone',
|
||||
|
||||
// Settings — workspace section
|
||||
'settings.ws.title': 'Workspace settings',
|
||||
'settings.ws.subtitle': 'General information about your workspace',
|
||||
'settings.ws.name': 'Workspace name',
|
||||
'settings.ws.display': 'Display name',
|
||||
'settings.ws.desc': 'Description',
|
||||
'settings.ws.tz': 'Timezone',
|
||||
'settings.ws.protocol': 'Default protocol',
|
||||
|
||||
// Settings — language
|
||||
'settings.lang.title': 'Language',
|
||||
'settings.lang.subtitle': 'Interface display language',
|
||||
'settings.lang.en': 'English',
|
||||
'settings.lang.ru': 'Русский',
|
||||
|
||||
// Settings — profile section
|
||||
'settings.profile.title': 'Profile',
|
||||
'settings.profile.name': 'Full name',
|
||||
'settings.profile.email': 'Email',
|
||||
'settings.profile.role': 'Role',
|
||||
|
||||
// Common buttons
|
||||
'btn.save': 'Save changes',
|
||||
'btn.cancel': 'Cancel',
|
||||
'btn.create': 'Create',
|
||||
'btn.delete': 'Delete',
|
||||
'btn.revoke': 'Revoke',
|
||||
'btn.copy': 'Copy',
|
||||
|
||||
// Login
|
||||
'login.title': 'Sign in',
|
||||
'login.subtitle': 'Welcome back to your workspace',
|
||||
'login.email': 'Email',
|
||||
'login.password': 'Password',
|
||||
'login.submit': 'Sign in',
|
||||
'login.sso': 'Continue with Google',
|
||||
},
|
||||
|
||||
ru: {
|
||||
// Nav
|
||||
'nav.operations': 'Операции',
|
||||
'nav.agents': 'Агенты',
|
||||
'nav.apikeys': 'API Ключи',
|
||||
'nav.logs': 'Логи',
|
||||
'nav.usage': 'Использование',
|
||||
'nav.settings': 'Настройки',
|
||||
'nav.logout': 'Выйти',
|
||||
|
||||
// Operations page
|
||||
'ops.title': 'Операции',
|
||||
'ops.subtitle': 'Контракты инструментов, открывающие ваши API как MCP-инструменты',
|
||||
'ops.new': 'Новая операция',
|
||||
'ops.loading': 'Загрузка операций…',
|
||||
'ops.empty.title': 'Операции не найдены',
|
||||
'ops.empty.sub': 'Попробуйте изменить поисковый запрос или фильтры.',
|
||||
|
||||
// Stats
|
||||
'stats.total': 'Всего операций',
|
||||
'stats.active': 'Активные',
|
||||
'stats.requests': 'Запросов сегодня',
|
||||
'stats.latency': 'Ср. задержка',
|
||||
|
||||
// Tabs
|
||||
'tab.all': 'Все',
|
||||
'tab.active': 'Активные',
|
||||
'tab.draft': 'Черновик',
|
||||
'tab.error': 'Ошибка',
|
||||
'tab.inactive': 'Неактивные',
|
||||
|
||||
// Filters & sort
|
||||
'filter.search': 'Поиск операций...',
|
||||
'filter.protocol': 'Протокол',
|
||||
'filter.status': 'Статус',
|
||||
'filter.category': 'Категория',
|
||||
'filter.showing': 'Показано',
|
||||
'filter.of': 'из',
|
||||
'filter.clear': 'Сбросить',
|
||||
'sort.created_desc':'Сначала новые',
|
||||
'sort.created_asc': 'Сначала старые',
|
||||
'sort.name_asc': 'Название А–Я',
|
||||
'sort.name_desc': 'Название Я–А',
|
||||
|
||||
// Table headers
|
||||
'th.name': 'НАЗВАНИЕ',
|
||||
'th.protocol': 'ПРОТОКОЛ',
|
||||
'th.status': 'СТАТУС',
|
||||
'th.created': 'СОЗДАН',
|
||||
'th.url': 'ЦЕЛЕВОЙ URL',
|
||||
|
||||
// Pagination
|
||||
'page.showing': 'Показано',
|
||||
'page.of': 'из',
|
||||
'page.ops': 'операций',
|
||||
|
||||
// API Keys page
|
||||
'apikeys.title': 'API Ключи',
|
||||
'apikeys.subtitle': 'Управление токенами доступа для внешних интеграций',
|
||||
'apikeys.new': 'Создать ключ',
|
||||
'apikeys.th.name': 'НАЗВАНИЕ',
|
||||
'apikeys.th.scope': 'ОБЛАСТЬ',
|
||||
'apikeys.th.created':'СОЗДАН',
|
||||
'apikeys.th.last': 'ПОСЛЕДНЕЕ ИСПОЛЬЗОВАНИЕ',
|
||||
|
||||
// Logs page
|
||||
'logs.title': 'Логи',
|
||||
'logs.subtitle': 'Журнал запросов и событий в реальном времени',
|
||||
'logs.search': 'Поиск в логах...',
|
||||
'logs.level.all': 'Все',
|
||||
'logs.level.info': 'Info',
|
||||
'logs.level.warn': 'Warn',
|
||||
'logs.level.error': 'Error',
|
||||
'logs.level.debug': 'Debug',
|
||||
|
||||
// Usage page
|
||||
'usage.title': 'Использование',
|
||||
'usage.subtitle': 'Объём запросов и метрики производительности',
|
||||
|
||||
// Settings sidebar
|
||||
'settings.title': 'Настройки',
|
||||
'settings.nav.workspace': 'Воркспейс',
|
||||
'settings.nav.profile': 'Профиль',
|
||||
'settings.nav.members': 'Участники',
|
||||
'settings.nav.security': 'Безопасность',
|
||||
'settings.nav.notif': 'Уведомления',
|
||||
'settings.nav.danger': 'Опасная зона',
|
||||
|
||||
// Settings — workspace section
|
||||
'settings.ws.title': 'Настройки воркспейса',
|
||||
'settings.ws.subtitle': 'Общая информация о вашем воркспейсе',
|
||||
'settings.ws.name': 'Имя воркспейса',
|
||||
'settings.ws.display': 'Отображаемое имя',
|
||||
'settings.ws.desc': 'Описание',
|
||||
'settings.ws.tz': 'Часовой пояс',
|
||||
'settings.ws.protocol': 'Протокол по умолчанию',
|
||||
|
||||
// Settings — language
|
||||
'settings.lang.title': 'Язык',
|
||||
'settings.lang.subtitle': 'Язык отображения интерфейса',
|
||||
'settings.lang.en': 'English',
|
||||
'settings.lang.ru': 'Русский',
|
||||
|
||||
// Settings — profile section
|
||||
'settings.profile.title': 'Профиль',
|
||||
'settings.profile.name': 'Полное имя',
|
||||
'settings.profile.email': 'Email',
|
||||
'settings.profile.role': 'Роль',
|
||||
|
||||
// Common buttons
|
||||
'btn.save': 'Сохранить',
|
||||
'btn.cancel': 'Отмена',
|
||||
'btn.create': 'Создать',
|
||||
'btn.delete': 'Удалить',
|
||||
'btn.revoke': 'Отозвать',
|
||||
'btn.copy': 'Копировать',
|
||||
|
||||
// Login
|
||||
'login.title': 'Войти',
|
||||
'login.subtitle': 'Добро пожаловать обратно',
|
||||
'login.email': 'Email',
|
||||
'login.password': 'Пароль',
|
||||
'login.submit': 'Войти',
|
||||
'login.sso': 'Войти через Google',
|
||||
}
|
||||
};
|
||||
|
||||
function t(key) {
|
||||
var lang = localStorage.getItem('crank_lang') || 'en';
|
||||
var tr = TRANSLATIONS[lang] || TRANSLATIONS.en;
|
||||
return tr[key] !== undefined ? tr[key] : (TRANSLATIONS.en[key] !== undefined ? TRANSLATIONS.en[key] : key);
|
||||
}
|
||||
|
||||
function applyLang() {
|
||||
// text content
|
||||
document.querySelectorAll('[data-i18n]').forEach(function(el) {
|
||||
el.textContent = t(el.getAttribute('data-i18n'));
|
||||
});
|
||||
// placeholder
|
||||
document.querySelectorAll('[data-i18n-ph]').forEach(function(el) {
|
||||
el.placeholder = t(el.getAttribute('data-i18n-ph'));
|
||||
});
|
||||
// highlight active language button in settings
|
||||
var lang = localStorage.getItem('crank_lang') || 'en';
|
||||
document.querySelectorAll('.lang-btn').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.dataset.lang === lang);
|
||||
});
|
||||
}
|
||||
|
||||
function setLang(lang) {
|
||||
localStorage.setItem('crank_lang', lang);
|
||||
applyLang();
|
||||
// Notify Alpine components to re-render reactive getters
|
||||
window.dispatchEvent(new CustomEvent('crank:langchange', { detail: { lang: lang } }));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', applyLang);
|
||||
@@ -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';
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 = '';
|
||||
}
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user