Merge branch 'feat/alpine-settings-workspace'
This commit is contained in:
@@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/alpine-api-keys`
|
### `feat/alpine-settings-workspace`
|
||||||
|
|
||||||
Status: completed
|
Status: completed
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- `API Keys` page читает live keys catalog из `admin-api`
|
- `Workspace setup` page использует live create/edit workspace flow через `admin-api`
|
||||||
- `API Keys` page использует live `create/revoke/delete` flow вместо `keys.json`
|
- список workspace обновляется после create/edit без локальных моков
|
||||||
- scope model и тексты в UI выровнены с реальным backend
|
- members и invitations читаются из live backend
|
||||||
|
- create workspace умеет отправлять initial invites
|
||||||
- `test-ui` остается нетронутым как fallback
|
- `test-ui` остается нетронутым как fallback
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/alpine-logs-usage`
|
- `feat/alpine-login-auth-gap`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -317,6 +317,21 @@ pub struct OperationAgentRefView {
|
|||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct InvocationRecordRequest<'a> {
|
||||||
|
workspace_id: &'a WorkspaceId,
|
||||||
|
agent_id: Option<&'a AgentId>,
|
||||||
|
operation: &'a RegistryOperation,
|
||||||
|
source: InvocationSource,
|
||||||
|
level: InvocationLevel,
|
||||||
|
status: InvocationStatus,
|
||||||
|
message: String,
|
||||||
|
status_code: Option<u16>,
|
||||||
|
error_kind: Option<String>,
|
||||||
|
duration_ms: u64,
|
||||||
|
request_preview: Value,
|
||||||
|
response_preview: Value,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct OperationSummaryView {
|
pub struct OperationSummaryView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -1110,20 +1125,20 @@ impl AdminService {
|
|||||||
match build_request_preview(&record.snapshot.input_mapping, &payload.input) {
|
match build_request_preview(&record.snapshot.input_mapping, &payload.input) {
|
||||||
Ok(preview) => preview,
|
Ok(preview) => preview,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.record_invocation(
|
self.record_invocation(InvocationRecordRequest {
|
||||||
workspace_id,
|
workspace_id,
|
||||||
None,
|
agent_id: None,
|
||||||
&record.snapshot,
|
operation: &record.snapshot,
|
||||||
InvocationSource::AdminTestRun,
|
source: InvocationSource::AdminTestRun,
|
||||||
InvocationLevel::Error,
|
level: InvocationLevel::Error,
|
||||||
InvocationStatus::Error,
|
status: InvocationStatus::Error,
|
||||||
"mapping preview failed".to_owned(),
|
message: "mapping preview failed".to_owned(),
|
||||||
None,
|
status_code: None,
|
||||||
Some("mapping".to_owned()),
|
error_kind: Some("mapping".to_owned()),
|
||||||
0,
|
duration_ms: 0,
|
||||||
Value::Null,
|
request_preview: Value::Null,
|
||||||
Value::Null,
|
response_preview: Value::Null,
|
||||||
)
|
})
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(TestRunResult {
|
return Ok(TestRunResult {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -1141,20 +1156,20 @@ impl AdminService {
|
|||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
let duration_ms =
|
let duration_ms =
|
||||||
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||||
self.record_invocation(
|
self.record_invocation(InvocationRecordRequest {
|
||||||
workspace_id,
|
workspace_id,
|
||||||
None,
|
agent_id: None,
|
||||||
&record.snapshot,
|
operation: &record.snapshot,
|
||||||
InvocationSource::AdminTestRun,
|
source: InvocationSource::AdminTestRun,
|
||||||
InvocationLevel::Info,
|
level: InvocationLevel::Info,
|
||||||
InvocationStatus::Ok,
|
status: InvocationStatus::Ok,
|
||||||
"admin test run completed".to_owned(),
|
message: "admin test run completed".to_owned(),
|
||||||
None,
|
status_code: None,
|
||||||
None,
|
error_kind: None,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
request_preview.clone(),
|
request_preview: request_preview.clone(),
|
||||||
output.clone(),
|
response_preview: output.clone(),
|
||||||
)
|
})
|
||||||
.await?;
|
.await?;
|
||||||
Ok(TestRunResult {
|
Ok(TestRunResult {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -1166,20 +1181,20 @@ impl AdminService {
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
let duration_ms =
|
let duration_ms =
|
||||||
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||||
self.record_invocation(
|
self.record_invocation(InvocationRecordRequest {
|
||||||
workspace_id,
|
workspace_id,
|
||||||
None,
|
agent_id: None,
|
||||||
&record.snapshot,
|
operation: &record.snapshot,
|
||||||
InvocationSource::AdminTestRun,
|
source: InvocationSource::AdminTestRun,
|
||||||
InvocationLevel::Error,
|
level: InvocationLevel::Error,
|
||||||
InvocationStatus::Error,
|
status: InvocationStatus::Error,
|
||||||
error.to_string(),
|
message: error.to_string(),
|
||||||
None,
|
status_code: None,
|
||||||
Some(runtime_error_code(&error).to_owned()),
|
error_kind: Some(runtime_error_code(&error).to_owned()),
|
||||||
duration_ms,
|
duration_ms,
|
||||||
request_preview.clone(),
|
request_preview: request_preview.clone(),
|
||||||
Value::Null,
|
response_preview: Value::Null,
|
||||||
)
|
})
|
||||||
.await?;
|
.await?;
|
||||||
Ok(TestRunResult {
|
Ok(TestRunResult {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -1996,35 +2011,24 @@ impl AdminService {
|
|||||||
|
|
||||||
async fn record_invocation(
|
async fn record_invocation(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
request: InvocationRecordRequest<'_>,
|
||||||
agent_id: Option<&AgentId>,
|
|
||||||
operation: &RegistryOperation,
|
|
||||||
source: InvocationSource,
|
|
||||||
level: InvocationLevel,
|
|
||||||
status: InvocationStatus,
|
|
||||||
message: String,
|
|
||||||
status_code: Option<u16>,
|
|
||||||
error_kind: Option<String>,
|
|
||||||
duration_ms: u64,
|
|
||||||
request_preview: Value,
|
|
||||||
response_preview: Value,
|
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
let log = InvocationLog {
|
let log = InvocationLog {
|
||||||
id: InvocationLogId::new(new_prefixed_id("log")),
|
id: InvocationLogId::new(new_prefixed_id("log")),
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: request.workspace_id.clone(),
|
||||||
agent_id: agent_id.cloned(),
|
agent_id: request.agent_id.cloned(),
|
||||||
operation_id: operation.id.clone(),
|
operation_id: request.operation.id.clone(),
|
||||||
source,
|
source: request.source,
|
||||||
level,
|
level: request.level,
|
||||||
status,
|
status: request.status,
|
||||||
tool_name: operation.name.clone(),
|
tool_name: request.operation.name.clone(),
|
||||||
message,
|
message: request.message,
|
||||||
request_id: None,
|
request_id: None,
|
||||||
status_code,
|
status_code: request.status_code,
|
||||||
duration_ms,
|
duration_ms: request.duration_ms,
|
||||||
error_kind,
|
error_kind: request.error_kind,
|
||||||
request_preview,
|
request_preview: request.request_preview,
|
||||||
response_preview,
|
response_preview: request.response_preview,
|
||||||
created_at: now_string()?,
|
created_at: now_string()?,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+35
-27
@@ -244,14 +244,16 @@ async fn mcp_post(
|
|||||||
let _ = persist_invocation(
|
let _ = persist_invocation(
|
||||||
&state,
|
&state,
|
||||||
&tool,
|
&tool,
|
||||||
InvocationStatus::Ok,
|
InvocationRecord {
|
||||||
InvocationLevel::Info,
|
status: InvocationStatus::Ok,
|
||||||
"agent tool call completed",
|
level: InvocationLevel::Info,
|
||||||
None,
|
message: "agent tool call completed",
|
||||||
None,
|
status_code: None,
|
||||||
started_at.elapsed(),
|
error_kind: None,
|
||||||
|
duration: started_at.elapsed(),
|
||||||
request_preview,
|
request_preview,
|
||||||
output.clone(),
|
response_preview: output.clone(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
jsonrpc_result(
|
jsonrpc_result(
|
||||||
@@ -275,14 +277,16 @@ async fn mcp_post(
|
|||||||
let _ = persist_invocation(
|
let _ = persist_invocation(
|
||||||
&state,
|
&state,
|
||||||
&tool,
|
&tool,
|
||||||
InvocationStatus::Error,
|
InvocationRecord {
|
||||||
InvocationLevel::Error,
|
status: InvocationStatus::Error,
|
||||||
&error.to_string(),
|
level: InvocationLevel::Error,
|
||||||
None,
|
message: &error.to_string(),
|
||||||
Some(runtime_error_code(&error)),
|
status_code: None,
|
||||||
started_at.elapsed(),
|
error_kind: Some(runtime_error_code(&error)),
|
||||||
|
duration: started_at.elapsed(),
|
||||||
request_preview,
|
request_preview,
|
||||||
Value::Null,
|
response_preview: Value::Null,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
json_response(
|
json_response(
|
||||||
@@ -550,38 +554,42 @@ fn build_request_preview(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_invocation(
|
struct InvocationRecord<'a> {
|
||||||
state: &Arc<AppState>,
|
|
||||||
tool: &PublishedAgentTool,
|
|
||||||
status: InvocationStatus,
|
status: InvocationStatus,
|
||||||
level: InvocationLevel,
|
level: InvocationLevel,
|
||||||
message: &str,
|
message: &'a str,
|
||||||
status_code: Option<u16>,
|
status_code: Option<u16>,
|
||||||
error_kind: Option<&str>,
|
error_kind: Option<&'a str>,
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
request_preview: Value,
|
request_preview: Value,
|
||||||
response_preview: Value,
|
response_preview: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_invocation(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
tool: &PublishedAgentTool,
|
||||||
|
record: InvocationRecord<'_>,
|
||||||
) -> Result<(), crank_registry::RegistryError> {
|
) -> Result<(), crank_registry::RegistryError> {
|
||||||
let created_at = OffsetDateTime::now_utc()
|
let created_at = OffsetDateTime::now_utc()
|
||||||
.format(&Rfc3339)
|
.format(&Rfc3339)
|
||||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned());
|
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned());
|
||||||
let duration_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
|
let duration_ms = u64::try_from(record.duration.as_millis()).unwrap_or(u64::MAX);
|
||||||
let log = InvocationLog {
|
let log = InvocationLog {
|
||||||
id: InvocationLogId::new(format!("log_{}", uuid::Uuid::now_v7().simple())),
|
id: InvocationLogId::new(format!("log_{}", uuid::Uuid::now_v7().simple())),
|
||||||
workspace_id: tool.workspace_id.clone(),
|
workspace_id: tool.workspace_id.clone(),
|
||||||
agent_id: Some(tool.agent_id.clone()),
|
agent_id: Some(tool.agent_id.clone()),
|
||||||
operation_id: tool.operation.id.clone(),
|
operation_id: tool.operation.id.clone(),
|
||||||
source: InvocationSource::AgentToolCall,
|
source: InvocationSource::AgentToolCall,
|
||||||
level,
|
level: record.level,
|
||||||
status,
|
status: record.status,
|
||||||
tool_name: tool.tool_name.clone(),
|
tool_name: tool.tool_name.clone(),
|
||||||
message: message.to_owned(),
|
message: record.message.to_owned(),
|
||||||
request_id: None,
|
request_id: None,
|
||||||
status_code,
|
status_code: record.status_code,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
error_kind: error_kind.map(ToOwned::to_owned),
|
error_kind: record.error_kind.map(ToOwned::to_owned),
|
||||||
request_preview,
|
request_preview: record.request_preview,
|
||||||
response_preview,
|
response_preview: record.response_preview,
|
||||||
created_at,
|
created_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
||||||
<script src="js/config.js"></script>
|
<script src="js/config.js"></script>
|
||||||
<script src="js/i18n.js"></script>
|
<script src="js/i18n.js"></script>
|
||||||
|
<script src="js/api.js"></script>
|
||||||
<script src="js/workspace.js"></script>
|
<script src="js/workspace.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+17
-10
@@ -13,6 +13,7 @@
|
|||||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
||||||
<script src="js/config.js"></script>
|
<script src="js/config.js"></script>
|
||||||
<script src="js/i18n.js"></script>
|
<script src="js/i18n.js"></script>
|
||||||
|
<script src="js/api.js"></script>
|
||||||
<script src="js/workspace.js"></script>
|
<script src="js/workspace.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -101,6 +102,12 @@
|
|||||||
|
|
||||||
<!-- ── Sidebar nav ── -->
|
<!-- ── Sidebar nav ── -->
|
||||||
<nav class="settings-nav" id="settings-nav">
|
<nav class="settings-nav" id="settings-nav">
|
||||||
|
<button class="settings-nav-item" data-section="workspace">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 5.5h12M4 2.5h8a1.5 1.5 0 011.5 1.5v8A1.5 1.5 0 0112 13.5H4A1.5 1.5 0 012.5 12V4A1.5 1.5 0 014 2.5z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Workspace</span>
|
||||||
|
</button>
|
||||||
<button class="settings-nav-item active" data-section="profile">
|
<button class="settings-nav-item active" data-section="profile">
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="8" cy="5" r="3"/>
|
<circle cx="8" cy="5" r="3"/>
|
||||||
@@ -126,7 +133,7 @@
|
|||||||
<!-- ── Content ── -->
|
<!-- ── Content ── -->
|
||||||
<div id="settings-content">
|
<div id="settings-content">
|
||||||
|
|
||||||
<div id="section-workspace" style="display:none;">
|
<div id="section-workspace" class="section-anchor">
|
||||||
<div class="section-card" style="margin-bottom: 20px;">
|
<div class="section-card" style="margin-bottom: 20px;">
|
||||||
<div class="section-card-header">
|
<div class="section-card-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -137,21 +144,21 @@
|
|||||||
<div class="section-card-body" style="padding-bottom:8px;">
|
<div class="section-card-body" style="padding-bottom:8px;">
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label class="field-label" data-i18n="settings.ws.name">Workspace name</label>
|
<label class="field-label" data-i18n="settings.ws.name">Workspace name</label>
|
||||||
<input class="field-input" type="text" value="acme-workspace" autocomplete="off">
|
<input class="field-input" id="settings-ws-slug" type="text" value="acme-workspace" autocomplete="off">
|
||||||
<div class="field-hint">Used in API endpoints and across the console. Only lowercase letters, numbers and hyphens.</div>
|
<div class="field-hint">Used in API endpoints and across the console. Only lowercase letters, numbers and hyphens.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label class="field-label" data-i18n="settings.ws.display">Display name</label>
|
<label class="field-label" data-i18n="settings.ws.display">Display name</label>
|
||||||
<input class="field-input" type="text" value="Acme Corp" autocomplete="off">
|
<input class="field-input" id="settings-ws-display-name" type="text" value="Acme Corp" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label class="field-label">Description <span style="font-size:11px;color:var(--text-muted);">(optional)</span></label>
|
<label class="field-label">Description <span style="font-size:11px;color:var(--text-muted);">(optional)</span></label>
|
||||||
<textarea class="field-textarea" rows="2" placeholder="What does this workspace do?">Primary production workspace for CRM and e-commerce API operations.</textarea>
|
<textarea class="field-textarea" id="settings-ws-description" rows="2" placeholder="What does this workspace do?">Primary production workspace for CRM and e-commerce API operations.</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label class="field-label" data-i18n="settings.ws.tz">Timezone</label>
|
<label class="field-label" data-i18n="settings.ws.tz">Timezone</label>
|
||||||
<select class="field-select">
|
<select class="field-select" id="settings-ws-timezone">
|
||||||
<option selected>UTC</option>
|
<option selected>UTC</option>
|
||||||
<option>America/New_York</option>
|
<option>America/New_York</option>
|
||||||
<option>America/Los_Angeles</option>
|
<option>America/Los_Angeles</option>
|
||||||
@@ -162,10 +169,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label class="field-label" data-i18n="settings.ws.protocol">Default protocol</label>
|
<label class="field-label" data-i18n="settings.ws.protocol">Default protocol</label>
|
||||||
<select class="field-select">
|
<select class="field-select" id="settings-ws-protocol">
|
||||||
<option selected>REST / HTTP</option>
|
<option value="rest" selected>REST / HTTP</option>
|
||||||
<option>GraphQL</option>
|
<option value="graphql">GraphQL</option>
|
||||||
<option>gRPC</option>
|
<option value="grpc">gRPC</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +195,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;justify-content:flex-end;padding-top:4px;">
|
<div style="display:flex;justify-content:flex-end;padding-top:4px;">
|
||||||
<button class="btn-primary" type="button"><span data-i18n="btn.save">Save changes</span></button>
|
<button class="btn-primary" id="settings-ws-save-btn" type="button"><span data-i18n="btn.save">Save changes</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
||||||
<script src="js/config.js"></script>
|
<script src="js/config.js"></script>
|
||||||
<script src="js/i18n.js"></script>
|
<script src="js/i18n.js"></script>
|
||||||
|
<script src="js/api.js"></script>
|
||||||
<script src="js/workspace.js"></script>
|
<script src="js/workspace.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -176,7 +177,7 @@
|
|||||||
<th style="text-align:right;">Errors</th>
|
<th style="text-align:right;">Errors</th>
|
||||||
<th style="text-align:right;">Error rate</th>
|
<th style="text-align:right;">Error rate</th>
|
||||||
<th>Latency (p50 / p95 / p99)</th>
|
<th>Latency (p50 / p95 / p99)</th>
|
||||||
<th style="text-align:right;">Quota</th>
|
<th style="text-align:right;">Traffic share</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="usage-tbody">
|
<tbody id="usage-tbody">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
||||||
<script src="js/config.js"></script>
|
<script src="js/config.js"></script>
|
||||||
<script src="js/i18n.js"></script>
|
<script src="js/i18n.js"></script>
|
||||||
|
<script src="js/api.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="ws-setup-page">
|
<div class="ws-setup-page">
|
||||||
@@ -304,14 +305,14 @@
|
|||||||
<div class="danger-zone-title">Export all data</div>
|
<div class="danger-zone-title">Export all data</div>
|
||||||
<div class="danger-zone-desc">Download a ZIP archive of all operation configs, schemas, mappings and invocation history as JSON.</div>
|
<div class="danger-zone-desc">Download a ZIP archive of all operation configs, schemas, mappings and invocation history as JSON.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-danger" type="button">Export</button>
|
<button class="btn-danger" id="export-workspace-btn" type="button">Export</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="danger-zone-action">
|
<div class="danger-zone-action">
|
||||||
<div class="danger-zone-text">
|
<div class="danger-zone-text">
|
||||||
<div class="danger-zone-title">Delete workspace</div>
|
<div class="danger-zone-title">Delete workspace</div>
|
||||||
<div class="danger-zone-desc">Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.</div>
|
<div class="danger-zone-desc">Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-danger" type="button" onclick="if(confirm('Delete workspace? This cannot be undone.')) { localStorage.clear(); window.location.href = 'login.html'; }">Delete workspace</button>
|
<button class="btn-danger" id="delete-workspace-btn" type="button">Delete workspace</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,6 +67,19 @@
|
|||||||
return request(path, { method: 'DELETE' });
|
return request(path, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function query(params) {
|
||||||
|
var search = new URLSearchParams();
|
||||||
|
Object.keys(params || {}).forEach(function(key) {
|
||||||
|
var value = params[key];
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
search.set(key, value);
|
||||||
|
});
|
||||||
|
var encoded = search.toString();
|
||||||
|
return encoded ? ('?' + encoded) : '';
|
||||||
|
}
|
||||||
|
|
||||||
function postBytes(path, bytes, fileName) {
|
function postBytes(path, bytes, fileName) {
|
||||||
return request(path, {
|
return request(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -79,6 +92,27 @@
|
|||||||
listWorkspaces: function() {
|
listWorkspaces: function() {
|
||||||
return get('/workspaces');
|
return get('/workspaces');
|
||||||
},
|
},
|
||||||
|
createWorkspace: function(payload) {
|
||||||
|
return post('/workspaces', payload);
|
||||||
|
},
|
||||||
|
getWorkspace: function(workspaceId) {
|
||||||
|
return get('/workspaces/' + encodeURIComponent(workspaceId));
|
||||||
|
},
|
||||||
|
updateWorkspace: function(workspaceId, payload) {
|
||||||
|
return patch('/workspaces/' + encodeURIComponent(workspaceId), payload);
|
||||||
|
},
|
||||||
|
listMemberships: function(workspaceId) {
|
||||||
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/members');
|
||||||
|
},
|
||||||
|
listInvitations: function(workspaceId) {
|
||||||
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations');
|
||||||
|
},
|
||||||
|
createInvitation: function(workspaceId, payload) {
|
||||||
|
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations', payload);
|
||||||
|
},
|
||||||
|
deleteInvitation: function(workspaceId, invitationId) {
|
||||||
|
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations/' + encodeURIComponent(invitationId));
|
||||||
|
},
|
||||||
listOperations: function(workspaceId) {
|
listOperations: function(workspaceId) {
|
||||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations');
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations');
|
||||||
},
|
},
|
||||||
@@ -143,5 +177,24 @@
|
|||||||
deletePlatformApiKey: function(workspaceId, keyId) {
|
deletePlatformApiKey: function(workspaceId, keyId) {
|
||||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId));
|
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId));
|
||||||
},
|
},
|
||||||
|
listLogs: function(workspaceId, params) {
|
||||||
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs' + query(params));
|
||||||
|
},
|
||||||
|
getLog: function(workspaceId, logId) {
|
||||||
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs/' + encodeURIComponent(logId));
|
||||||
|
},
|
||||||
|
getUsageOverview: function(workspaceId, params) {
|
||||||
|
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params));
|
||||||
|
},
|
||||||
|
getOperationUsage: function(workspaceId, operationId, params) {
|
||||||
|
return get(
|
||||||
|
'/workspaces/' + encodeURIComponent(workspaceId) + '/usage/operations/' + encodeURIComponent(operationId) + query(params)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getAgentUsage: function(workspaceId, agentId, params) {
|
||||||
|
return get(
|
||||||
|
'/workspaces/' + encodeURIComponent(workspaceId) + '/usage/agents/' + encodeURIComponent(agentId) + query(params)
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}());
|
}());
|
||||||
|
|||||||
+296
-403
@@ -1,164 +1,17 @@
|
|||||||
// logs.js — Crank log viewer
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var state = {
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
logs: [],
|
||||||
|
details: {},
|
||||||
function minsAgo(mins) {
|
level: 'all',
|
||||||
return new Date(Date.now() - mins * 60 * 1000);
|
search: '',
|
||||||
}
|
period: '7d',
|
||||||
|
openId: null,
|
||||||
function fmtTs(date) {
|
liveMode: true,
|
||||||
var d = (date instanceof Date) ? date : new Date(date);
|
timer: null,
|
||||||
var pad = function (n, w) { return String(n).padStart(w || 2, '0'); };
|
workspaceId: null,
|
||||||
return (
|
loading: false,
|
||||||
d.getFullYear() + '-' +
|
loadError: '',
|
||||||
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 logList = document.getElementById('log-list');
|
||||||
var logSearch = document.getElementById('log-search');
|
var logSearch = document.getElementById('log-search');
|
||||||
@@ -167,88 +20,111 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
var liveDot = document.querySelector('.live-dot');
|
var liveDot = document.querySelector('.live-dot');
|
||||||
var liveLabel = document.querySelector('.live-label');
|
var liveLabel = document.querySelector('.live-label');
|
||||||
|
|
||||||
// ── Debug chip injection ───────────────────────────────────────────────────
|
function currentWorkspaceId() {
|
||||||
// The HTML has All/Info/Warn/Error chips. We add Debug after Error.
|
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||||
|
return workspace ? workspace.id : null;
|
||||||
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 durationLabel(durationMs) {
|
||||||
|
if (!durationMs) {
|
||||||
function rangeMs(range) {
|
return '0ms';
|
||||||
var map = { '30m': 30, '1h': 60, '6h': 360, '24h': 1440, '7d': 10080 };
|
}
|
||||||
var mins = map[range] || 10080;
|
if (durationMs >= 1000) {
|
||||||
return mins * 60 * 1000;
|
return (durationMs / 1000).toFixed(1) + 's';
|
||||||
|
}
|
||||||
|
return durationMs + 'ms';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render ─────────────────────────────────────────────────────────────────
|
function formatJson(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
function statusClass(s) {
|
function formatTime(timestamp) {
|
||||||
if (!s) return '';
|
var date = new Date(timestamp);
|
||||||
if (s < 300) return 'ok';
|
return date.toISOString().slice(11, 23);
|
||||||
if (s < 500) return 'warn';
|
}
|
||||||
|
|
||||||
|
function statusClass(statusCode) {
|
||||||
|
if (!statusCode) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (statusCode < 300) {
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
if (statusCode < 500) {
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
return 'err';
|
return 'err';
|
||||||
}
|
}
|
||||||
|
|
||||||
function escText(str) {
|
function normalizeLog(record) {
|
||||||
// Safely return string; all user-facing dynamic strings go through textContent,
|
var log = record.log;
|
||||||
// but for pre blocks in detail we use textContent assignment too (see below).
|
return {
|
||||||
return str;
|
id: log.id,
|
||||||
|
createdAt: log.created_at,
|
||||||
|
level: log.level,
|
||||||
|
source: log.source,
|
||||||
|
status: log.status,
|
||||||
|
statusCode: log.status_code,
|
||||||
|
durationMs: log.duration_ms,
|
||||||
|
toolName: log.tool_name,
|
||||||
|
message: log.message,
|
||||||
|
operationName: record.operation_name,
|
||||||
|
operationDisplayName: record.operation_display_name,
|
||||||
|
agentSlug: record.agent_slug,
|
||||||
|
agentDisplayName: record.agent_display_name,
|
||||||
|
requestPreview: log.request_preview,
|
||||||
|
responsePreview: log.response_preview,
|
||||||
|
errorKind: log.error_kind,
|
||||||
|
requestId: log.request_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmpty(message) {
|
||||||
|
logList.innerHTML = '';
|
||||||
|
var empty = document.createElement('div');
|
||||||
|
empty.style.cssText = 'text-align:center;padding:40px;color:var(--text-muted);font-size:13.5px;';
|
||||||
|
empty.textContent = message;
|
||||||
|
logList.appendChild(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLogs() {
|
function renderLogs() {
|
||||||
var q = searchText.toLowerCase();
|
if (state.loading && state.logs.length === 0) {
|
||||||
var cutoff = Date.now() - rangeMs(timeRange);
|
renderEmpty('Loading logs...');
|
||||||
|
return;
|
||||||
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) {
|
if (state.loadError) {
|
||||||
var tsDate = (l.ts instanceof Date) ? l.ts : new Date(l.ts);
|
renderEmpty(state.loadError);
|
||||||
var timeStr = fmtTs(tsDate).slice(11, 23); // HH:MM:SS.mmm
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Row ──
|
if (!state.logs.length) {
|
||||||
|
renderEmpty('No log entries match the current filter');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
state.logs.forEach(function (item) {
|
||||||
var row = document.createElement('div');
|
var row = document.createElement('div');
|
||||||
row.className = 'log-entry';
|
row.className = 'log-entry';
|
||||||
row.setAttribute('data-id', l.id);
|
row.setAttribute('data-id', item.id);
|
||||||
|
|
||||||
var timeEl = document.createElement('span');
|
var timeEl = document.createElement('span');
|
||||||
timeEl.className = 'log-time';
|
timeEl.className = 'log-time';
|
||||||
timeEl.textContent = timeStr;
|
timeEl.textContent = formatTime(item.createdAt);
|
||||||
row.appendChild(timeEl);
|
row.appendChild(timeEl);
|
||||||
|
|
||||||
var levelEl = document.createElement('span');
|
var levelEl = document.createElement('span');
|
||||||
levelEl.className = 'log-level ' + l.level;
|
levelEl.className = 'log-level ' + item.level;
|
||||||
levelEl.textContent = l.level.toUpperCase();
|
levelEl.textContent = item.level.toUpperCase();
|
||||||
row.appendChild(levelEl);
|
row.appendChild(levelEl);
|
||||||
|
|
||||||
var msgDiv = document.createElement('div');
|
var msgDiv = document.createElement('div');
|
||||||
@@ -256,232 +132,249 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
|
|
||||||
var opSpan = document.createElement('span');
|
var opSpan = document.createElement('span');
|
||||||
opSpan.className = 'log-op-name';
|
opSpan.className = 'log-op-name';
|
||||||
opSpan.textContent = l.op;
|
opSpan.textContent = item.operationName;
|
||||||
msgDiv.appendChild(opSpan);
|
msgDiv.appendChild(opSpan);
|
||||||
|
msgDiv.appendChild(document.createTextNode('\u00a0\u00a0'));
|
||||||
|
|
||||||
msgDiv.appendChild(document.createTextNode('\u00a0\u00a0')); // two nbsps
|
if (item.statusCode) {
|
||||||
|
|
||||||
if (l.status) {
|
|
||||||
var statusEl = document.createElement('span');
|
var statusEl = document.createElement('span');
|
||||||
statusEl.className = 'log-status ' + statusClass(l.status);
|
statusEl.className = 'log-status ' + statusClass(item.statusCode);
|
||||||
statusEl.textContent = l.status;
|
statusEl.textContent = item.statusCode;
|
||||||
msgDiv.appendChild(statusEl);
|
msgDiv.appendChild(statusEl);
|
||||||
msgDiv.appendChild(document.createTextNode(' \u00b7 ')); // · separator
|
msgDiv.appendChild(document.createTextNode(' \u00b7 '));
|
||||||
}
|
}
|
||||||
|
|
||||||
var msgText = document.createTextNode(l.msg);
|
msgDiv.appendChild(document.createTextNode(item.message));
|
||||||
msgDiv.appendChild(msgText);
|
|
||||||
row.appendChild(msgDiv);
|
row.appendChild(msgDiv);
|
||||||
|
|
||||||
var durEl = document.createElement('span');
|
var durationEl = document.createElement('span');
|
||||||
var durSlow = (l.duration.indexOf('s') !== -1 && parseFloat(l.duration) > 1);
|
var durationClass = item.level === 'error' ? ' error' : (item.durationMs >= 1000 ? ' slow' : '');
|
||||||
var durClass = durSlow ? 'slow' : (l.level === 'error' ? 'error' : '');
|
durationEl.className = 'log-duration' + durationClass;
|
||||||
durEl.className = 'log-duration' + (durClass ? ' ' + durClass : '');
|
durationEl.textContent = durationLabel(item.durationMs);
|
||||||
durEl.textContent = l.duration;
|
row.appendChild(durationEl);
|
||||||
row.appendChild(durEl);
|
|
||||||
|
|
||||||
frag.appendChild(row);
|
fragment.appendChild(row);
|
||||||
|
|
||||||
// ── Expanded detail ──
|
var expanded = document.createElement('div');
|
||||||
if (l.detail) {
|
expanded.className = 'log-entry-expanded' + (item.id === state.openId ? ' open' : '');
|
||||||
var expDiv = document.createElement('div');
|
expanded.setAttribute('data-exp', item.id);
|
||||||
expDiv.className = 'log-entry-expanded' + (l.id === openId ? ' open' : '');
|
|
||||||
expDiv.setAttribute('data-exp', l.id);
|
|
||||||
|
|
||||||
if (l.detail.req) {
|
if (item.id === state.openId) {
|
||||||
var reqLabel = document.createElement('div');
|
var detail = state.details[item.id] || item;
|
||||||
reqLabel.className = 'log-detail-label';
|
|
||||||
reqLabel.textContent = 'Request body';
|
|
||||||
expDiv.appendChild(reqLabel);
|
|
||||||
|
|
||||||
var reqPre = document.createElement('pre');
|
if (detail.agentDisplayName || detail.agentSlug) {
|
||||||
reqPre.className = 'log-detail-block';
|
var agentLabel = document.createElement('div');
|
||||||
reqPre.textContent = l.detail.req;
|
agentLabel.className = 'log-detail-label';
|
||||||
expDiv.appendChild(reqPre);
|
agentLabel.textContent = 'Agent';
|
||||||
|
expanded.appendChild(agentLabel);
|
||||||
|
|
||||||
|
var agentPre = document.createElement('pre');
|
||||||
|
agentPre.className = 'log-detail-block';
|
||||||
|
agentPre.textContent = detail.agentDisplayName || detail.agentSlug;
|
||||||
|
expanded.appendChild(agentPre);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (l.detail.res) {
|
var requestLabel = document.createElement('div');
|
||||||
var resLabel = document.createElement('div');
|
requestLabel.className = 'log-detail-label';
|
||||||
resLabel.className = 'log-detail-label';
|
requestLabel.textContent = 'Request preview';
|
||||||
resLabel.textContent = 'Response';
|
expanded.appendChild(requestLabel);
|
||||||
expDiv.appendChild(resLabel);
|
|
||||||
|
|
||||||
var resPre = document.createElement('pre');
|
var requestPre = document.createElement('pre');
|
||||||
resPre.className = 'log-detail-block';
|
requestPre.className = 'log-detail-block';
|
||||||
resPre.textContent = l.detail.res;
|
requestPre.textContent = formatJson(detail.requestPreview);
|
||||||
expDiv.appendChild(resPre);
|
expanded.appendChild(requestPre);
|
||||||
|
|
||||||
|
var responseLabel = document.createElement('div');
|
||||||
|
responseLabel.className = 'log-detail-label';
|
||||||
|
responseLabel.textContent = 'Response preview';
|
||||||
|
expanded.appendChild(responseLabel);
|
||||||
|
|
||||||
|
var responsePre = document.createElement('pre');
|
||||||
|
responsePre.className = 'log-detail-block';
|
||||||
|
responsePre.textContent = formatJson(detail.responsePreview);
|
||||||
|
expanded.appendChild(responsePre);
|
||||||
|
|
||||||
|
if (detail.errorKind || detail.requestId) {
|
||||||
|
var metaLabel = document.createElement('div');
|
||||||
|
metaLabel.className = 'log-detail-label';
|
||||||
|
metaLabel.textContent = 'Metadata';
|
||||||
|
expanded.appendChild(metaLabel);
|
||||||
|
|
||||||
|
var metaPre = document.createElement('pre');
|
||||||
|
metaPre.className = 'log-detail-block';
|
||||||
|
metaPre.textContent = formatJson({
|
||||||
|
request_id: detail.requestId || null,
|
||||||
|
error_kind: detail.errorKind || null,
|
||||||
|
source: detail.source,
|
||||||
|
status: detail.status,
|
||||||
|
});
|
||||||
|
expanded.appendChild(metaPre);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
frag.appendChild(expDiv);
|
fragment.appendChild(expanded);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logList.innerHTML = '';
|
logList.innerHTML = '';
|
||||||
logList.appendChild(frag);
|
logList.appendChild(fragment);
|
||||||
|
|
||||||
// Attach click handlers for expand/collapse
|
logList.querySelectorAll('.log-entry').forEach(function (row) {
|
||||||
logList.querySelectorAll('.log-entry').forEach(function (el) {
|
row.addEventListener('click', async function () {
|
||||||
el.addEventListener('click', function () {
|
var id = this.getAttribute('data-id');
|
||||||
var id = parseInt(this.getAttribute('data-id'), 10);
|
state.openId = state.openId === id ? null : id;
|
||||||
openId = (openId === id) ? null : id;
|
renderLogs();
|
||||||
logList.querySelectorAll('.log-entry-expanded').forEach(function (exp) {
|
if (state.openId) {
|
||||||
var expId = parseInt(exp.getAttribute('data-exp'), 10);
|
await loadLogDetail(id);
|
||||||
exp.classList.toggle('open', expId === openId);
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Level chip wiring ──────────────────────────────────────────────────────
|
function queryParams() {
|
||||||
|
var params = {
|
||||||
function wireChips() {
|
period: state.period,
|
||||||
document.querySelectorAll('.filter-chip[data-level]').forEach(function (btn) {
|
limit: 100,
|
||||||
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 };
|
if (state.level !== 'all') {
|
||||||
|
params.level = state.level;
|
||||||
function pickLiveLevel() {
|
}
|
||||||
var r = Math.random();
|
if (state.search) {
|
||||||
if (r < 0.70) return 'info';
|
params.search = state.search;
|
||||||
if (r < 0.85) return 'warn';
|
}
|
||||||
if (r < 0.95) return 'error';
|
return params;
|
||||||
return 'debug';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeLiveEntry() {
|
async function loadLogs() {
|
||||||
var op = LIVE_OPS[Math.floor(Math.random() * LIVE_OPS.length)];
|
if (!window.CrankApi) {
|
||||||
var level = pickLiveLevel();
|
state.loadError = 'Admin API is not available';
|
||||||
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();
|
renderLogs();
|
||||||
}, 4000);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopLive() {
|
state.workspaceId = currentWorkspaceId();
|
||||||
if (liveTimer) {
|
if (!state.workspaceId) {
|
||||||
clearInterval(liveTimer);
|
state.loadError = 'Workspace is not selected';
|
||||||
liveTimer = null;
|
renderLogs();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.loading = true;
|
||||||
|
state.loadError = '';
|
||||||
|
renderLogs();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var response = await window.CrankApi.listLogs(state.workspaceId, queryParams());
|
||||||
|
state.logs = (response && response.items ? response.items : []).map(normalizeLog);
|
||||||
|
if (state.openId && !state.logs.some(function (item) { return item.id === state.openId; })) {
|
||||||
|
state.openId = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
state.loadError = error.message || 'Failed to load logs';
|
||||||
|
} finally {
|
||||||
|
state.loading = false;
|
||||||
|
renderLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogDetail(logId) {
|
||||||
|
if (!window.CrankApi || !state.workspaceId || state.details[logId]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var record = await window.CrankApi.getLog(state.workspaceId, logId);
|
||||||
|
state.details[logId] = normalizeLog(record);
|
||||||
|
if (state.openId === logId) {
|
||||||
|
renderLogs();
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLiveState() {
|
||||||
|
if (liveDot) {
|
||||||
|
liveDot.style.animationPlayState = state.liveMode ? 'running' : 'paused';
|
||||||
|
liveDot.style.opacity = state.liveMode ? '' : '0.35';
|
||||||
|
liveDot.style.cursor = 'pointer';
|
||||||
|
}
|
||||||
|
if (liveLabel) {
|
||||||
|
liveLabel.textContent = state.liveMode ? 'Live' : 'Paused';
|
||||||
|
liveLabel.style.color = state.liveMode ? '' : 'var(--text-muted)';
|
||||||
|
liveLabel.style.cursor = 'pointer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (state.timer) {
|
||||||
|
clearInterval(state.timer);
|
||||||
|
state.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling();
|
||||||
|
if (!state.liveMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.timer = setInterval(loadLogs, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLive() {
|
function toggleLive() {
|
||||||
liveMode = !liveMode;
|
state.liveMode = !state.liveMode;
|
||||||
setLivePulsing(liveMode);
|
setLiveState();
|
||||||
updateLiveLabel();
|
startPolling();
|
||||||
if (liveMode) {
|
}
|
||||||
startLive();
|
|
||||||
} else {
|
document.querySelectorAll('.filter-chip[data-level]').forEach(function (button) {
|
||||||
stopLive();
|
button.addEventListener('click', function () {
|
||||||
}
|
state.level = this.getAttribute('data-level');
|
||||||
|
document.querySelectorAll('.filter-chip[data-level]').forEach(function (item) {
|
||||||
|
item.classList.remove('active');
|
||||||
|
});
|
||||||
|
this.classList.add('active');
|
||||||
|
loadLogs();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (logSearch) {
|
||||||
|
logSearch.addEventListener('input', function () {
|
||||||
|
state.search = this.value.trim();
|
||||||
|
loadLogs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', loadLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeRangeSel) {
|
||||||
|
timeRangeSel.value = state.period;
|
||||||
|
timeRangeSel.addEventListener('change', function () {
|
||||||
|
state.period = this.value;
|
||||||
|
loadLogs();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire live toggle to clicking the dot or the label
|
|
||||||
if (liveDot) {
|
if (liveDot) {
|
||||||
liveDot.style.cursor = 'pointer';
|
|
||||||
liveDot.addEventListener('click', toggleLive);
|
liveDot.addEventListener('click', toggleLive);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (liveLabel) {
|
if (liveLabel) {
|
||||||
liveLabel.style.cursor = 'pointer';
|
|
||||||
liveLabel.addEventListener('click', toggleLive);
|
liveLabel.addEventListener('click', toggleLive);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also support a dedicated live-toggle button if one exists in the DOM
|
window.addEventListener('crank:workspacechange', function () {
|
||||||
var liveToggleBtn = document.getElementById('live-toggle') || document.querySelector('.live-btn');
|
state.details = {};
|
||||||
if (liveToggleBtn) {
|
state.openId = null;
|
||||||
liveToggleBtn.addEventListener('click', toggleLive);
|
loadLogs();
|
||||||
|
});
|
||||||
|
|
||||||
|
setLiveState();
|
||||||
|
startPolling();
|
||||||
|
|
||||||
|
if (window.whenWorkspacesReady) {
|
||||||
|
window.whenWorkspacesReady().finally(loadLogs);
|
||||||
|
} else {
|
||||||
|
loadLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Initialise ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
setLivePulsing(true);
|
|
||||||
updateLiveLabel();
|
|
||||||
startLive();
|
|
||||||
renderLogs();
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
+152
-57
@@ -1,25 +1,39 @@
|
|||||||
// Populate profile from stored user
|
function populateProfile() {
|
||||||
try {
|
try {
|
||||||
var u = JSON.parse(localStorage.getItem('crank_user'));
|
var user = JSON.parse(localStorage.getItem('crank_user'));
|
||||||
if (u) {
|
if (!user) {
|
||||||
document.getElementById('profile-avatar').textContent = u.initials || 'AT';
|
return;
|
||||||
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
|
document.getElementById('profile-avatar').textContent = user.initials || 'AT';
|
||||||
(function() {
|
document.getElementById('profile-display-name').textContent = user.name || 'Operator';
|
||||||
|
document.getElementById('profile-email').textContent = user.email || '';
|
||||||
|
|
||||||
|
var firstName = document.getElementById('field-firstname');
|
||||||
|
var email = document.getElementById('field-email');
|
||||||
|
if (firstName && user.name) {
|
||||||
|
firstName.value = user.name;
|
||||||
|
}
|
||||||
|
if (email && user.email) {
|
||||||
|
email.value = user.email;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectLanguageSwitcher() {
|
||||||
var profileSection = document.getElementById('section-profile');
|
var profileSection = document.getElementById('section-profile');
|
||||||
if (!profileSection) return;
|
if (!profileSection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var cardBody = profileSection.querySelector('.section-card-body');
|
var cardBody = profileSection.querySelector('.section-card-body');
|
||||||
if (!cardBody) return;
|
if (!cardBody) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fetch('html/fragments/lang-switcher.html')
|
fetch('html/fragments/lang-switcher.html')
|
||||||
.then(function(r) { return r.text(); })
|
.then(function(response) { return response.text(); })
|
||||||
.then(function(html) {
|
.then(function(html) {
|
||||||
var saveRow = cardBody.querySelector('div[style*="justify-content:flex-end"]');
|
var saveRow = cardBody.querySelector('div[style*="justify-content:flex-end"]');
|
||||||
if (saveRow) {
|
if (saveRow) {
|
||||||
@@ -27,47 +41,128 @@ try {
|
|||||||
} else {
|
} else {
|
||||||
cardBody.insertAdjacentHTML('beforeend', html);
|
cardBody.insertAdjacentHTML('beforeend', html);
|
||||||
}
|
}
|
||||||
if (typeof applyLang === 'function') applyLang();
|
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)
|
function bindSectionNavigation() {
|
||||||
document.querySelectorAll('[id^="section-"]').forEach(function(el) {
|
var navItems = document.querySelectorAll('.settings-nav-item[data-section]');
|
||||||
if (el.style.display !== 'none') observer.observe(el);
|
navItems.forEach(function (button) {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
navItems.forEach(function (item) { item.classList.remove('active'); });
|
||||||
|
this.classList.add('active');
|
||||||
|
var target = document.getElementById('section-' + this.dataset.section);
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var initialSection = window.location.hash ? window.location.hash.slice(1) : 'profile';
|
||||||
|
var initialButton = document.querySelector('.settings-nav-item[data-section="' + initialSection + '"]')
|
||||||
|
|| document.querySelector('.settings-nav-item[data-section="profile"]');
|
||||||
|
if (initialButton) {
|
||||||
|
setTimeout(function () { initialButton.click(); }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
var observer = new IntersectionObserver(function (entries) {
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
if (!entry.isIntersecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var section = entry.target.id.replace('section-', '');
|
||||||
|
document.querySelectorAll('.settings-nav-item[data-section]').forEach(function (button) {
|
||||||
|
button.classList.toggle('active', button.dataset.section === section);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, { threshold: 0.3 });
|
||||||
|
|
||||||
|
document.querySelectorAll('[id^="section-"]').forEach(function (section) {
|
||||||
|
if (section.style.display !== 'none') {
|
||||||
|
observer.observe(section);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkspaceSettings() {
|
||||||
|
if (!window.CrankApi || !window.whenWorkspacesReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await window.whenWorkspacesReady();
|
||||||
|
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||||
|
if (!workspace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var record = await window.CrankApi.getWorkspace(workspace.id);
|
||||||
|
var item = record.workspace;
|
||||||
|
var settings = item.settings || {};
|
||||||
|
|
||||||
|
document.getElementById('settings-ws-slug').value = item.slug || '';
|
||||||
|
document.getElementById('settings-ws-display-name').value = item.display_name || '';
|
||||||
|
document.getElementById('settings-ws-description').value = settings.description || '';
|
||||||
|
document.getElementById('settings-ws-timezone').value = settings.timezone || 'UTC';
|
||||||
|
document.getElementById('settings-ws-protocol').value = settings.default_protocol || 'rest';
|
||||||
|
|
||||||
|
document.querySelectorAll('#section-workspace .lang-btn').forEach(function (button) {
|
||||||
|
button.classList.toggle('active', button.dataset.lang === (settings.language || (localStorage.getItem('crank_lang') || 'en')));
|
||||||
|
});
|
||||||
|
|
||||||
|
var saveButton = document.getElementById('settings-ws-save-btn');
|
||||||
|
if (saveButton.dataset.bound === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveButton.dataset.bound = 'true';
|
||||||
|
|
||||||
|
saveButton.addEventListener('click', async function () {
|
||||||
|
var saveButton = this;
|
||||||
|
var original = saveButton.textContent;
|
||||||
|
saveButton.disabled = true;
|
||||||
|
saveButton.textContent = 'Saving…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.CrankApi.updateWorkspace(item.id, {
|
||||||
|
slug: document.getElementById('settings-ws-slug').value.trim(),
|
||||||
|
display_name: document.getElementById('settings-ws-display-name').value.trim(),
|
||||||
|
settings: {
|
||||||
|
description: document.getElementById('settings-ws-description').value.trim(),
|
||||||
|
timezone: document.getElementById('settings-ws-timezone').value,
|
||||||
|
default_protocol: document.getElementById('settings-ws-protocol').value,
|
||||||
|
language: localStorage.getItem('crank_lang') || 'en',
|
||||||
|
color: (workspace.settings && workspace.settings.color) || '#0d9488',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window.refreshWorkspaces) {
|
||||||
|
var workspaces = await window.refreshWorkspaces();
|
||||||
|
var updated = workspaces.find(function (entry) { return entry.id === item.id; });
|
||||||
|
if (updated && window.setCurrentWorkspace) {
|
||||||
|
window.setCurrentWorkspace(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveButton.textContent = 'Saved ✓';
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Failed to save workspace settings');
|
||||||
|
saveButton.textContent = original;
|
||||||
|
} finally {
|
||||||
|
setTimeout(function () {
|
||||||
|
saveButton.disabled = false;
|
||||||
|
saveButton.textContent = original;
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
populateProfile();
|
||||||
|
injectLanguageSwitcher();
|
||||||
|
bindSectionNavigation();
|
||||||
|
loadWorkspaceSettings();
|
||||||
});
|
});
|
||||||
|
|||||||
+256
-234
@@ -1,270 +1,292 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var state = {
|
||||||
// ── Period datasets ───────────────────────────────────────────────────────
|
period: '7d',
|
||||||
|
workspaceId: null,
|
||||||
var CHART_DATA = {
|
usage: null,
|
||||||
'7d': [
|
loading: false,
|
||||||
{d:'Mon', s:3838, e:18}, {d:'Tue', s:4242, e:32}, {d:'Wed', s:3988, e:28},
|
loadError: '',
|
||||||
{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 periodSelect = document.getElementById('period');
|
||||||
var OPS_7D = [
|
var exportBtn = document.querySelector('.page-header-actions .btn-secondary');
|
||||||
{ name: 'create_crm_lead', display: 'Create CRM Lead', proto: 'REST', calls: 9840, errors: 42, p50: 138, p95: 390, p99: 820 },
|
var chartWrap = document.getElementById('chart-bars');
|
||||||
{ name: 'search_products', display: 'Search Products', proto: 'REST', calls: 7123, errors: 8, p50: 91, p95: 210, p99: 440 },
|
var tableBody = document.getElementById('usage-tbody');
|
||||||
{ name: 'fetch_invoice', display: 'Fetch Invoice', proto: 'REST', calls: 5210, errors: 214, p50: 104, p95: 540, p99: 8200 },
|
var chartTemplate = document.getElementById('tmpl-chart-bar');
|
||||||
{ name: 'update_contact', display: 'Update Contact', proto: 'REST', calls: 3882, errors: 28, p50: 192, p95: 460, p99: 910 },
|
var rowTemplate = document.getElementById('tmpl-usage-row');
|
||||||
{ name: 'send_email', display: 'Send Email', proto: 'REST', calls: 1440, errors: 96, p50: 310, p95: 2100, p99: 30100 },
|
var subtitle = document.querySelector('.section-card-subtitle');
|
||||||
{ name: 'list_orders', display: 'List Orders (GraphQL)', proto: 'GraphQL', calls: 670, errors: 2, p50: 66, p95: 180, p99: 320 },
|
var statCards = document.querySelectorAll('.stats-grid .stat-card');
|
||||||
{ name: 'delete_record', display: 'Delete Record', proto: 'REST', calls: 176, errors: 6, p50: 285, p95: 690, p99: 1400 }
|
|
||||||
];
|
|
||||||
|
|
||||||
function scaleOps(factor) {
|
function currentWorkspaceId() {
|
||||||
return OPS_7D.map(function (op) {
|
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||||
return {
|
return workspace ? workspace.id : null;
|
||||||
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 = {
|
function formatCount(value) {
|
||||||
'7d': OPS_7D,
|
return Number(value || 0).toLocaleString();
|
||||||
'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) {
|
function formatMs(value) {
|
||||||
if (rate > 5) return 'color:var(--red)';
|
if (!value) {
|
||||||
if (rate > 1) return 'color:var(--amber)';
|
return '0ms';
|
||||||
return 'color:var(--green)';
|
}
|
||||||
|
if (value >= 1000) {
|
||||||
|
return (value / 1000).toFixed(1) + 's';
|
||||||
|
}
|
||||||
|
return value + 'ms';
|
||||||
}
|
}
|
||||||
|
|
||||||
var ARROW_UP = '<svg width="11" height="11"><use href="' + (window.APP_BASE||'') + 'icons/general/arrow-up.svg#icon"/></svg>';
|
function periodLabel(period) {
|
||||||
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 = {
|
var labels = {
|
||||||
'7d': 'last 7 days',
|
'7d': 'last 7 days',
|
||||||
'30d': 'last 30 days',
|
'30d': 'last 30 days',
|
||||||
'90d': 'last 90 days',
|
'90d': 'last 90 days',
|
||||||
'this_month': 'this month'
|
'this_month': 'this month',
|
||||||
};
|
};
|
||||||
var el = document.querySelector('.section-card-subtitle');
|
return labels[period] || period;
|
||||||
if (el) el.textContent = 'Breakdown for ' + (labels[period] || period);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CSV export ────────────────────────────────────────────────────────────
|
function protocolColor(protocol) {
|
||||||
|
if (protocol === 'graphql' || protocol === 'Graphql') {
|
||||||
|
return 'var(--purple)';
|
||||||
|
}
|
||||||
|
if (protocol === 'grpc' || protocol === 'Grpc') {
|
||||||
|
return 'var(--accent)';
|
||||||
|
}
|
||||||
|
return 'var(--blue)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function protocolLabel(protocol) {
|
||||||
|
if (protocol === 'graphql' || protocol === 'Graphql') {
|
||||||
|
return 'GraphQL';
|
||||||
|
}
|
||||||
|
if (protocol === 'grpc' || protocol === 'Grpc') {
|
||||||
|
return 'gRPC';
|
||||||
|
}
|
||||||
|
return 'REST';
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartBucketLabel(timestamp, period, index) {
|
||||||
|
var date = new Date(timestamp);
|
||||||
|
if (period === '7d') {
|
||||||
|
return date.toLocaleDateString('en-US', { weekday: 'short' });
|
||||||
|
}
|
||||||
|
if (period === '90d') {
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short' });
|
||||||
|
}
|
||||||
|
return 'Wk ' + (index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmpty(message) {
|
||||||
|
chartWrap.innerHTML = '<div style="padding:32px;color:var(--text-muted);text-align:center;width:100%;">' + message + '</div>';
|
||||||
|
tableBody.innerHTML = '<tr><td colspan="7" style="padding:24px;text-align:center;color:var(--text-muted);">' + message + '</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats() {
|
||||||
|
var summary = state.usage ? state.usage.summary : null;
|
||||||
|
var cards = [
|
||||||
|
{
|
||||||
|
value: formatCount(summary ? summary.rollup.calls_total : 0),
|
||||||
|
text: 'Across ' + periodLabel(state.period),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: ((summary ? summary.success_rate : 0) || 0).toFixed(1) + '%',
|
||||||
|
text: formatCount(summary ? summary.rollup.calls_ok : 0) + ' successful calls',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: formatMs(summary ? summary.rollup.p50_ms : 0),
|
||||||
|
text: 'Median latency for selected period',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: formatMs(summary ? summary.rollup.p99_ms : 0),
|
||||||
|
text: formatCount(summary ? summary.rollup.calls_error : 0) + ' error calls',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
cards.forEach(function (card, index) {
|
||||||
|
var node = statCards[index];
|
||||||
|
if (!node) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.querySelector('.stat-value').textContent = card.value;
|
||||||
|
var delta = node.querySelector('.stat-delta');
|
||||||
|
delta.className = 'stat-delta';
|
||||||
|
delta.textContent = card.text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart() {
|
||||||
|
var timeline = state.usage ? state.usage.timeline : [];
|
||||||
|
chartWrap.innerHTML = '';
|
||||||
|
|
||||||
|
if (!timeline.length) {
|
||||||
|
chartWrap.innerHTML = '<div style="padding:32px;color:var(--text-muted);text-align:center;width:100%;">No usage data for selected period</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxValue = Math.max.apply(null, timeline.map(function (point) {
|
||||||
|
return point.calls_ok + point.calls_error;
|
||||||
|
}));
|
||||||
|
|
||||||
|
timeline.forEach(function (point, index) {
|
||||||
|
var node = chartTemplate.content.cloneNode(true);
|
||||||
|
var okHeight = maxValue ? Math.round((point.calls_ok / maxValue) * 160) : 0;
|
||||||
|
var errorHeight = maxValue ? Math.round((point.calls_error / maxValue) * 160) : 0;
|
||||||
|
node.querySelector('.chart-bar.success').style.height = okHeight + 'px';
|
||||||
|
node.querySelector('.chart-bar.success').dataset.tip = formatCount(point.calls_ok) + ' ok';
|
||||||
|
node.querySelector('.chart-bar.error').style.height = errorHeight + 'px';
|
||||||
|
node.querySelector('.chart-bar.error').dataset.tip = formatCount(point.calls_error) + ' errors';
|
||||||
|
node.querySelector('.chart-col-label').textContent = chartBucketLabel(point.bucket_start, state.period, index);
|
||||||
|
chartWrap.appendChild(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
var operations = state.usage ? state.usage.operations : [];
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
|
if (!operations.length) {
|
||||||
|
tableBody.innerHTML = '<tr><td colspan="7" style="padding:24px;text-align:center;color:var(--text-muted);">No operation usage data for selected period</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCalls = operations.reduce(function (sum, operation) {
|
||||||
|
return sum + operation.calls_total;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
operations.forEach(function (operation) {
|
||||||
|
var node = rowTemplate.content.cloneNode(true);
|
||||||
|
var errorRate = operation.calls_total === 0
|
||||||
|
? 0
|
||||||
|
: ((operation.calls_error / operation.calls_total) * 100);
|
||||||
|
var share = totalCalls === 0
|
||||||
|
? 0
|
||||||
|
: ((operation.calls_total / totalCalls) * 100);
|
||||||
|
|
||||||
|
node.querySelector('.col-name').textContent = operation.operation_display_name;
|
||||||
|
node.querySelector('.col-op-slug').textContent = operation.operation_name;
|
||||||
|
|
||||||
|
var proto = node.querySelector('.col-proto');
|
||||||
|
proto.textContent = protocolLabel(operation.protocol);
|
||||||
|
proto.style.color = protocolColor(operation.protocol);
|
||||||
|
|
||||||
|
node.querySelector('.col-calls').textContent = formatCount(operation.calls_total);
|
||||||
|
node.querySelector('.col-errors').textContent = formatCount(operation.calls_error);
|
||||||
|
|
||||||
|
var errorRateEl = node.querySelector('.col-err-rate');
|
||||||
|
errorRateEl.textContent = errorRate.toFixed(2) + '%';
|
||||||
|
errorRateEl.style.color = errorRate > 5 ? 'var(--red)' : (errorRate > 1 ? 'var(--amber)' : 'var(--green)');
|
||||||
|
|
||||||
|
var latencyText = node.querySelector('.col-latency-text');
|
||||||
|
latencyText.textContent =
|
||||||
|
formatMs(operation.p50_ms) + ' / ' + formatMs(operation.p95_ms) + ' / ' + formatMs(operation.p99_ms);
|
||||||
|
|
||||||
|
var base = Math.max(operation.p99_ms, 1);
|
||||||
|
node.querySelector('.latency-p50').style.width = Math.max(4, Math.round((operation.p50_ms / base) * 64)) + 'px';
|
||||||
|
node.querySelector('.latency-p95').style.width = Math.max(4, Math.round(((operation.p95_ms - operation.p50_ms) / base) * 64)) + 'px';
|
||||||
|
node.querySelector('.latency-p99').style.width = Math.max(4, Math.round(((operation.p99_ms - operation.p95_ms) / base) * 64)) + 'px';
|
||||||
|
|
||||||
|
node.querySelector('.col-quota-label').textContent = share.toFixed(1) + '% of workspace traffic';
|
||||||
|
node.querySelector('.col-quota-fill').style.width = share.toFixed(1) + '%';
|
||||||
|
|
||||||
|
tableBody.appendChild(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsage() {
|
||||||
|
if (state.loading && !state.usage) {
|
||||||
|
renderEmpty('Loading usage...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.loadError) {
|
||||||
|
renderEmpty(state.loadError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStats();
|
||||||
|
renderChart();
|
||||||
|
renderTable();
|
||||||
|
if (subtitle) {
|
||||||
|
subtitle.textContent = 'Breakdown for ' + periodLabel(state.period);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsage() {
|
||||||
|
if (!window.CrankApi) {
|
||||||
|
state.loadError = 'Admin API is not available';
|
||||||
|
renderUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.workspaceId = currentWorkspaceId();
|
||||||
|
if (!state.workspaceId) {
|
||||||
|
state.loadError = 'Workspace is not selected';
|
||||||
|
renderUsage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loading = true;
|
||||||
|
state.loadError = '';
|
||||||
|
renderUsage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.usage = await window.CrankApi.getUsageOverview(state.workspaceId, { period: state.period });
|
||||||
|
} catch (error) {
|
||||||
|
state.loadError = error.message || 'Failed to load usage';
|
||||||
|
} finally {
|
||||||
|
state.loading = false;
|
||||||
|
renderUsage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
if (!state.usage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
function exportCSV(period) {
|
|
||||||
var ops = OPS_DATA[period];
|
|
||||||
var rows = ['Operation,Protocol,Calls,Errors,Error Rate (%),p50 (ms),p95 (ms),p99 (ms)'];
|
var rows = ['Operation,Protocol,Calls,Errors,Error Rate (%),p50 (ms),p95 (ms),p99 (ms)'];
|
||||||
ops.forEach(function (op) {
|
state.usage.operations.forEach(function (operation) {
|
||||||
var errRate = (op.errors / op.calls * 100).toFixed(2);
|
var errorRate = operation.calls_total === 0
|
||||||
|
? 0
|
||||||
|
: ((operation.calls_error / operation.calls_total) * 100);
|
||||||
rows.push([
|
rows.push([
|
||||||
'"' + op.display + '"',
|
'"' + operation.operation_display_name + '"',
|
||||||
op.proto,
|
'"' + protocolLabel(operation.protocol) + '"',
|
||||||
op.calls,
|
operation.calls_total,
|
||||||
op.errors,
|
operation.calls_error,
|
||||||
errRate,
|
errorRate.toFixed(2),
|
||||||
op.p50,
|
operation.p50_ms,
|
||||||
op.p95,
|
operation.p95_ms,
|
||||||
op.p99
|
operation.p99_ms,
|
||||||
].join(','));
|
].join(','));
|
||||||
});
|
});
|
||||||
var csv = rows.join('\r\n');
|
|
||||||
var blob = new Blob([csv], {type: 'text/csv'});
|
var blob = new Blob([rows.join('\r\n')], { type: 'text/csv' });
|
||||||
var url = URL.createObjectURL(blob);
|
var url = URL.createObjectURL(blob);
|
||||||
var a = document.createElement('a');
|
var link = document.createElement('a');
|
||||||
a.href = url;
|
link.href = url;
|
||||||
a.download = 'crank-usage-' + period + '.csv';
|
link.download = 'crank-usage-' + state.period + '.csv';
|
||||||
a.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Period selector ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
var currentPeriod = '7d';
|
|
||||||
|
|
||||||
var periodSelect = document.getElementById('period');
|
|
||||||
if (periodSelect) {
|
if (periodSelect) {
|
||||||
periodSelect.value = currentPeriod;
|
periodSelect.value = state.period;
|
||||||
periodSelect.addEventListener('change', function () {
|
periodSelect.addEventListener('change', function () {
|
||||||
currentPeriod = this.value;
|
state.period = this.value;
|
||||||
renderChart(currentPeriod);
|
loadUsage();
|
||||||
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) {
|
if (exportBtn) {
|
||||||
exportBtn.addEventListener('click', function () {
|
exportBtn.addEventListener('click', exportCsv);
|
||||||
exportCSV(currentPeriod);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Initial render ────────────────────────────────────────────────────────
|
window.addEventListener('crank:workspacechange', loadUsage);
|
||||||
|
|
||||||
renderChart(currentPeriod);
|
|
||||||
renderTable(currentPeriod);
|
|
||||||
renderStats(currentPeriod);
|
|
||||||
updateSubtitle(currentPeriod);
|
|
||||||
|
|
||||||
|
if (window.whenWorkspacesReady) {
|
||||||
|
window.whenWorkspacesReady().finally(loadUsage);
|
||||||
|
} else {
|
||||||
|
loadUsage();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+402
-136
@@ -1,193 +1,459 @@
|
|||||||
var selectedColor = '#0d9488';
|
var workspaceFormState = {
|
||||||
var slugManual = false;
|
selectedColor: '#0d9488',
|
||||||
var isCreateMode = false;
|
slugManual: false,
|
||||||
var formDirty = false;
|
isCreateMode: false,
|
||||||
|
formDirty: false,
|
||||||
|
workspaceId: null,
|
||||||
|
workspaceRecord: null,
|
||||||
|
memberships: [],
|
||||||
|
invitations: [],
|
||||||
|
};
|
||||||
|
|
||||||
function initPage() {
|
function normalizeSlug(value) {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]/g, '')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function uiRoleToApi(value) {
|
||||||
|
if (value === 'developer') {
|
||||||
|
return 'operator';
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(value) {
|
||||||
|
if (value === 'operator') {
|
||||||
|
return 'Developer';
|
||||||
|
}
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleClass(value) {
|
||||||
|
if (value === 'operator') {
|
||||||
|
return 'role-developer';
|
||||||
|
}
|
||||||
|
return 'role-' + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentLanguage() {
|
||||||
|
return localStorage.getItem('crank_lang') || 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormDirty(value) {
|
||||||
|
workspaceFormState.formDirty = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formElements() {
|
||||||
|
return {
|
||||||
|
name: document.getElementById('ws-name'),
|
||||||
|
slug: document.getElementById('ws-slug'),
|
||||||
|
desc: document.getElementById('ws-desc'),
|
||||||
|
protocol: document.getElementById('ws-protocol'),
|
||||||
|
timezone: document.getElementById('ws-timezone'),
|
||||||
|
submit: document.getElementById('submit-btn'),
|
||||||
|
avatar: document.getElementById('ws-avatar-preview'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspacePayload() {
|
||||||
|
var elements = formElements();
|
||||||
|
return {
|
||||||
|
slug: elements.slug.value.trim(),
|
||||||
|
display_name: elements.name.value.trim(),
|
||||||
|
settings: {
|
||||||
|
description: elements.desc.value.trim(),
|
||||||
|
default_protocol: elements.protocol.value,
|
||||||
|
timezone: elements.timezone.value,
|
||||||
|
language: currentLanguage(),
|
||||||
|
color: workspaceFormState.selectedColor,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWorkspaceRecord(record) {
|
||||||
|
var workspace = record.workspace;
|
||||||
|
var settings = workspace.settings || {};
|
||||||
|
var elements = formElements();
|
||||||
|
|
||||||
|
workspaceFormState.workspaceId = workspace.id;
|
||||||
|
workspaceFormState.workspaceRecord = record;
|
||||||
|
workspaceFormState.selectedColor = settings.color || '#0d9488';
|
||||||
|
workspaceFormState.slugManual = true;
|
||||||
|
|
||||||
|
elements.name.value = workspace.display_name || '';
|
||||||
|
elements.slug.value = workspace.slug || '';
|
||||||
|
elements.desc.value = settings.description || '';
|
||||||
|
elements.protocol.value = settings.default_protocol || 'rest';
|
||||||
|
elements.timezone.value = settings.timezone || 'UTC';
|
||||||
|
elements.avatar.textContent = (workspace.display_name || workspace.slug || '?').charAt(0).toUpperCase();
|
||||||
|
elements.avatar.style.background = workspaceFormState.selectedColor;
|
||||||
|
|
||||||
|
document.querySelectorAll('.ws-color-swatch').forEach(function (swatch) {
|
||||||
|
swatch.classList.toggle('active', swatch.dataset.color === workspaceFormState.selectedColor);
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormDirty(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMemberRow(container, membership) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'member-row';
|
||||||
|
row.innerHTML =
|
||||||
|
'<div class="member-avatar" style="background:linear-gradient(135deg,' + workspaceFormState.selectedColor + ',#6366f1);">' +
|
||||||
|
membership.user.display_name.slice(0, 2).toUpperCase() +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="member-info">' +
|
||||||
|
'<div class="member-name">' + membership.user.display_name + '</div>' +
|
||||||
|
'<div class="member-email">' + membership.user.email + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="member-last-active">—</div>' +
|
||||||
|
'<span class="member-role-badge ' + roleClass(membership.role) + '">' + roleLabel(membership.role) + '</span>';
|
||||||
|
container.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMembers() {
|
||||||
|
var container = document.getElementById('members-list');
|
||||||
|
var label = document.getElementById('members-count-label');
|
||||||
|
if (!container || !label) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
workspaceFormState.memberships.forEach(function (membership) {
|
||||||
|
addMemberRow(container, membership);
|
||||||
|
});
|
||||||
|
label.textContent = workspaceFormState.memberships.length + ' member' + (workspaceFormState.memberships.length === 1 ? '' : 's');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInvitations() {
|
||||||
|
var pending = document.getElementById('pending-invites');
|
||||||
|
var count = document.getElementById('pending-count');
|
||||||
|
if (!pending || !count) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = workspaceFormState.invitations.filter(function (record) {
|
||||||
|
return record.invitation.status === 'pending';
|
||||||
|
});
|
||||||
|
|
||||||
|
count.textContent = String(items.length);
|
||||||
|
if (!items.length) {
|
||||||
|
pending.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.style.display = '';
|
||||||
|
pending.innerHTML =
|
||||||
|
'<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);margin-bottom:10px;">' +
|
||||||
|
'Pending invites <span id="pending-count" style="background:rgba(139,148,158,0.15);border-radius:10px;padding:1px 7px;font-size:10px;">' + items.length + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
items.forEach(function (record) {
|
||||||
|
var invitation = record.invitation;
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'member-row';
|
||||||
|
row.innerHTML =
|
||||||
|
'<div class="member-avatar" style="background:var(--bg-canvas);border:1.5px dashed var(--border);color:var(--text-muted);font-size:16px;">?</div>' +
|
||||||
|
'<div class="member-info">' +
|
||||||
|
'<div class="member-name">' + invitation.email + '</div>' +
|
||||||
|
'<div class="member-email">Invited ' + invitation.created_at.slice(0, 10) + ' · pending</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="member-last-active">—</div>' +
|
||||||
|
'<span class="member-role-badge" style="background:rgba(139,148,158,0.12);color:var(--text-muted);border:1px solid var(--border);">' + roleLabel(invitation.role) + '</span>' +
|
||||||
|
'<button class="member-remove-btn" type="button" onclick="revokeInviteById(\'' + invitation.id + '\')" title="Revoke invite">' +
|
||||||
|
'<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="2" x2="14" y2="14"/><line x1="14" y1="2" x2="2" y2="14"/></svg>' +
|
||||||
|
'</button>';
|
||||||
|
pending.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkspaceAccessData() {
|
||||||
|
if (workspaceFormState.isCreateMode || !window.CrankApi || !workspaceFormState.workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var membershipsResponse = await window.CrankApi.listMemberships(workspaceFormState.workspaceId);
|
||||||
|
var invitationsResponse = await window.CrankApi.listInvitations(workspaceFormState.workspaceId);
|
||||||
|
workspaceFormState.memberships = membershipsResponse && membershipsResponse.items ? membershipsResponse.items : [];
|
||||||
|
workspaceFormState.invitations = invitationsResponse && invitationsResponse.items ? invitationsResponse.items : [];
|
||||||
|
renderMembers();
|
||||||
|
renderInvitations();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePageMode() {
|
||||||
var params = new URLSearchParams(window.location.search);
|
var params = new URLSearchParams(window.location.search);
|
||||||
isCreateMode = params.get('mode') === 'create';
|
workspaceFormState.isCreateMode = params.get('mode') === 'create';
|
||||||
|
|
||||||
if (isCreateMode) {
|
var submit = document.getElementById('submit-btn');
|
||||||
|
if (workspaceFormState.isCreateMode) {
|
||||||
document.title = 'Crank — Create workspace';
|
document.title = 'Crank — Create workspace';
|
||||||
document.getElementById('page-title').textContent = 'Create a new 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('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('section-invite').style.display = '';
|
||||||
document.getElementById('footer-note').style.display = '';
|
document.getElementById('footer-note').style.display = '';
|
||||||
var btn = document.getElementById('submit-btn');
|
submit.textContent = 'Create workspace';
|
||||||
btn.textContent = 'Create workspace';
|
submit.disabled = true;
|
||||||
btn.disabled = true;
|
submit.style.opacity = '0.5';
|
||||||
btn.style.opacity = '0.5';
|
submit.style.cursor = 'not-allowed';
|
||||||
btn.style.cursor = 'not-allowed';
|
|
||||||
} else {
|
} else {
|
||||||
// Edit mode — prefill from current workspace
|
|
||||||
document.getElementById('section-members').style.display = '';
|
document.getElementById('section-members').style.display = '';
|
||||||
document.getElementById('section-danger').style.display = '';
|
document.getElementById('section-danger').style.display = '';
|
||||||
var ws = getCurrentWs();
|
submit.textContent = 'Save changes';
|
||||||
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;
|
function onWsNameInput(value) {
|
||||||
selectedColor = ws.color;
|
var elements = formElements();
|
||||||
slugManual = true;
|
var normalized = value.trim();
|
||||||
// Activate matching color swatch
|
elements.avatar.textContent = normalized ? normalized.charAt(0).toUpperCase() : '?';
|
||||||
document.querySelectorAll('.ws-color-swatch').forEach(function(s) {
|
if (!workspaceFormState.slugManual) {
|
||||||
s.classList.toggle('active', s.dataset.color === ws.color);
|
elements.slug.value = normalizeSlug(value);
|
||||||
|
}
|
||||||
|
if (workspaceFormState.isCreateMode) {
|
||||||
|
validateCreateForm();
|
||||||
|
}
|
||||||
|
setFormDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWsSlugInput(value) {
|
||||||
|
formElements().slug.value = normalizeSlug(value);
|
||||||
|
workspaceFormState.slugManual = true;
|
||||||
|
if (workspaceFormState.isCreateMode) {
|
||||||
|
validateCreateForm();
|
||||||
|
}
|
||||||
|
setFormDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickColor(element) {
|
||||||
|
workspaceFormState.selectedColor = element.dataset.color;
|
||||||
|
document.querySelectorAll('.ws-color-swatch').forEach(function (swatch) {
|
||||||
|
swatch.classList.remove('active');
|
||||||
});
|
});
|
||||||
// Scroll to #section-members if hash present
|
element.classList.add('active');
|
||||||
if (window.location.hash === '#section-members') {
|
formElements().avatar.style.background = workspaceFormState.selectedColor;
|
||||||
setTimeout(function() {
|
setFormDirty(true);
|
||||||
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() {
|
function validateCreateForm() {
|
||||||
var name = document.getElementById('ws-name').value.trim();
|
var elements = formElements();
|
||||||
var slug = document.getElementById('ws-slug').value.trim();
|
var valid = Boolean(elements.name.value.trim() && elements.slug.value.trim());
|
||||||
var btn = document.getElementById('submit-btn');
|
elements.submit.disabled = !valid;
|
||||||
var valid = name.length > 0 && slug.length > 0;
|
elements.submit.style.opacity = valid ? '1' : '0.5';
|
||||||
btn.disabled = !valid;
|
elements.submit.style.cursor = valid ? 'pointer' : 'not-allowed';
|
||||||
btn.style.opacity = valid ? '1' : '0.5';
|
|
||||||
btn.style.cursor = valid ? 'pointer' : 'not-allowed';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitForm() {
|
function inviteRows() {
|
||||||
var name = document.getElementById('ws-name').value.trim();
|
return Array.from(document.querySelectorAll('#invite-rows .invite-row')).map(function (row) {
|
||||||
var slug = document.getElementById('ws-slug').value.trim();
|
return {
|
||||||
if (!name || !slug) return;
|
email: row.querySelector('input').value.trim(),
|
||||||
|
role: uiRoleToApi(row.querySelector('select').value),
|
||||||
|
};
|
||||||
|
}).filter(function (row) {
|
||||||
|
return row.email;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
formDirty = false;
|
async function submitForm() {
|
||||||
|
var payload = workspacePayload();
|
||||||
if (isCreateMode) {
|
if (!payload.display_name || !payload.slug || !window.CrankApi) {
|
||||||
if (window.WS_LIST) {
|
return;
|
||||||
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';
|
var submit = document.getElementById('submit-btn');
|
||||||
|
var originalLabel = submit.textContent;
|
||||||
|
submit.disabled = true;
|
||||||
|
submit.textContent = workspaceFormState.isCreateMode ? 'Creating…' : 'Saving…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var response;
|
||||||
|
if (workspaceFormState.isCreateMode) {
|
||||||
|
response = await window.CrankApi.createWorkspace(payload);
|
||||||
} else {
|
} else {
|
||||||
// Update current workspace in WS_LIST
|
response = await window.CrankApi.updateWorkspace(workspaceFormState.workspaceId, payload);
|
||||||
if (window.WS_LIST) {
|
}
|
||||||
var current = localStorage.getItem('crank_workspace') || 'acme-workspace';
|
|
||||||
var idx = WS_LIST.findIndex(function(w) { return w.slug === current; });
|
var record = response && response.workspace ? response : { workspace: response.workspace || response };
|
||||||
if (idx !== -1) {
|
var workspace = record.workspace;
|
||||||
WS_LIST[idx].name = slug;
|
|
||||||
WS_LIST[idx].slug = slug;
|
if (workspaceFormState.isCreateMode) {
|
||||||
WS_LIST[idx].letter = name[0].toUpperCase();
|
var invites = inviteRows();
|
||||||
WS_LIST[idx].color = selectedColor;
|
for (var index = 0; index < invites.length; index += 1) {
|
||||||
|
await window.CrankApi.createInvitation(workspace.id, {
|
||||||
|
email: invites[index].email,
|
||||||
|
role: invites[index].role,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
localStorage.setItem('crank_workspace', slug);
|
|
||||||
// Show saved feedback
|
var workspaces = await window.refreshWorkspaces();
|
||||||
var btn = document.getElementById('submit-btn');
|
var mapped = workspaces.find(function (item) { return item.id === workspace.id; }) || {
|
||||||
var orig = btn.textContent;
|
id: workspace.id,
|
||||||
btn.textContent = 'Saved ✓';
|
slug: workspace.slug,
|
||||||
btn.disabled = true;
|
name: workspace.display_name,
|
||||||
setTimeout(function() { btn.textContent = orig; btn.disabled = false; }, 1800);
|
role: 'Owner',
|
||||||
|
letter: (workspace.display_name || workspace.slug).charAt(0).toUpperCase(),
|
||||||
|
color: payload.settings.color || '#0d9488',
|
||||||
|
status: workspace.status,
|
||||||
|
settings: payload.settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.setCurrentWorkspace(mapped);
|
||||||
|
workspaceFormState.workspaceId = workspace.id;
|
||||||
|
workspaceFormState.workspaceRecord = { workspace: workspace };
|
||||||
|
setFormDirty(false);
|
||||||
|
|
||||||
|
if (workspaceFormState.isCreateMode) {
|
||||||
|
window.location.href = (window.APP_BASE || '') + 'html/workspace-setup.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyWorkspaceRecord({ workspace: workspace });
|
||||||
|
await loadWorkspaceAccessData();
|
||||||
|
submit.textContent = 'Saved ✓';
|
||||||
|
setTimeout(function () {
|
||||||
|
submit.textContent = originalLabel;
|
||||||
|
submit.disabled = false;
|
||||||
|
}, 1400);
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Failed to save workspace');
|
||||||
|
submit.disabled = false;
|
||||||
|
submit.textContent = originalLabel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create mode invite rows
|
|
||||||
function addInviteRow() {
|
function addInviteRow() {
|
||||||
var rows = document.getElementById('invite-rows');
|
var rows = document.getElementById('invite-rows');
|
||||||
var div = document.createElement('div');
|
var node = document.createElement('div');
|
||||||
div.className = 'invite-row';
|
node.className = 'invite-row';
|
||||||
div.innerHTML =
|
node.innerHTML =
|
||||||
'<input class="form-input" type="email" placeholder="colleague@company.com" autocomplete="off" style="flex:1;margin-bottom:0;">' +
|
'<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;">' +
|
'<select class="form-input" style="width:130px;margin-bottom:0;">' +
|
||||||
'<option value="admin">Admin</option>' +
|
'<option value="admin">Admin</option>' +
|
||||||
'<option value="developer" selected>Developer</option>' +
|
'<option value="developer" selected>Developer</option>' +
|
||||||
'<option value="viewer">Viewer</option>' +
|
'<option value="viewer">Viewer</option>' +
|
||||||
'</select>' +
|
'</select>' +
|
||||||
'<button class="invite-row-remove" onclick="removeInviteRow(this)" title="Remove">' +
|
'<button class="invite-row-remove" type="button" onclick="removeInviteRow(this)" title="Remove">' +
|
||||||
'<svg width="12" height="12"><use href="' + (window.APP_BASE||'') + 'icons/general/close.svg#icon"/></svg>' +
|
'<svg width="12" height="12"><use href="' + (window.APP_BASE || '') + 'icons/general/close.svg#icon"/></svg>' +
|
||||||
'</button>';
|
'</button>';
|
||||||
rows.appendChild(div);
|
rows.appendChild(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeInviteRow(btn) {
|
function removeInviteRow(button) {
|
||||||
btn.closest('.invite-row').remove();
|
button.closest('.invite-row').remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Members management (edit mode)
|
|
||||||
function toggleInviteForm() {
|
function toggleInviteForm() {
|
||||||
var f = document.getElementById('invite-form');
|
var form = document.getElementById('invite-form');
|
||||||
var s = document.getElementById('invite-success');
|
var success = document.getElementById('invite-success');
|
||||||
if (f.style.display === 'none') {
|
if (!form) {
|
||||||
f.style.display = '';
|
return;
|
||||||
s.style.display = 'none';
|
}
|
||||||
|
|
||||||
|
if (form.style.display === 'none') {
|
||||||
|
form.style.display = '';
|
||||||
|
if (success) {
|
||||||
|
success.style.display = 'none';
|
||||||
|
}
|
||||||
document.getElementById('invite-email').focus();
|
document.getElementById('invite-email').focus();
|
||||||
} else {
|
} else {
|
||||||
f.style.display = 'none';
|
form.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendInvite() {
|
async function sendInvite() {
|
||||||
|
if (!window.CrankApi || !workspaceFormState.workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var email = document.getElementById('invite-email').value.trim();
|
var email = document.getElementById('invite-email').value.trim();
|
||||||
if (!email || !email.includes('@')) return;
|
var role = uiRoleToApi(document.getElementById('invite-role').value);
|
||||||
document.getElementById('invite-success').style.display = '';
|
if (!email || email.indexOf('@') === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.CrankApi.createInvitation(workspaceFormState.workspaceId, { email: email, role: role });
|
||||||
document.getElementById('invite-email').value = '';
|
document.getElementById('invite-email').value = '';
|
||||||
|
document.getElementById('invite-success').style.display = '';
|
||||||
|
await loadWorkspaceAccessData();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Failed to send invite');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRole(select, email) {
|
function updateRole() {
|
||||||
console.log('Role updated:', email, '->', select.value);
|
alert('Role management is not available yet in the backend.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMember(btn, name) {
|
function removeMember() {
|
||||||
if (!confirm('Remove ' + name + ' from this workspace?')) return;
|
alert('Member removal is not available yet in the backend.');
|
||||||
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) {
|
async function revokeInviteById(invitationId) {
|
||||||
btn.closest('.member-row').remove();
|
if (!window.CrankApi || !workspaceFormState.workspaceId) {
|
||||||
var pending = document.getElementById('pending-invites');
|
return;
|
||||||
if (pending && !pending.querySelector('.member-row')) pending.style.display = 'none';
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.CrankApi.deleteInvitation(workspaceFormState.workspaceId, invitationId);
|
||||||
|
await loadWorkspaceAccessData();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Failed to revoke invite');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
function revokeInvite(button) {
|
||||||
initPage();
|
var row = button.closest('.member-row');
|
||||||
if (isCreateMode) document.getElementById('ws-name').focus();
|
if (!row) {
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('beforeunload', function(e) {
|
async function initPage() {
|
||||||
if (formDirty) {
|
updatePageMode();
|
||||||
e.preventDefault();
|
|
||||||
e.returnValue = '';
|
var language = currentLanguage();
|
||||||
|
document.querySelectorAll('.lang-btn').forEach(function (button) {
|
||||||
|
button.classList.toggle('active', button.dataset.lang === language);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('export-workspace-btn').addEventListener('click', function () {
|
||||||
|
alert('Workspace export is not available yet in the backend.');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('delete-workspace-btn').addEventListener('click', function () {
|
||||||
|
alert('Workspace deletion is not available yet in the backend.');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (workspaceFormState.isCreateMode) {
|
||||||
|
document.getElementById('ws-name').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await window.whenWorkspacesReady();
|
||||||
|
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||||
|
if (!workspace || !window.CrankApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var record = await window.CrankApi.getWorkspace(workspace.id);
|
||||||
|
applyWorkspaceRecord(record);
|
||||||
|
await loadWorkspaceAccessData();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Failed to load workspace');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initPage);
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function (event) {
|
||||||
|
if (workspaceFormState.formDirty) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-1
@@ -12,14 +12,16 @@ function workspaceColor(index) {
|
|||||||
function mapWorkspace(record, index) {
|
function mapWorkspace(record, index) {
|
||||||
var workspace = record && record.workspace ? record.workspace : record;
|
var workspace = record && record.workspace ? record.workspace : record;
|
||||||
var displayName = workspace.display_name || workspace.slug || workspace.id;
|
var displayName = workspace.display_name || workspace.slug || workspace.id;
|
||||||
|
var settings = workspace.settings || {};
|
||||||
return {
|
return {
|
||||||
id: workspace.id,
|
id: workspace.id,
|
||||||
slug: workspace.slug,
|
slug: workspace.slug,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
role: 'Owner',
|
role: 'Owner',
|
||||||
letter: displayName.charAt(0).toUpperCase(),
|
letter: displayName.charAt(0).toUpperCase(),
|
||||||
color: workspaceColor(index),
|
color: settings.color || workspaceColor(index),
|
||||||
status: workspace.status,
|
status: workspace.status,
|
||||||
|
settings: settings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +115,11 @@ async function loadWorkspaces() {
|
|||||||
return workspaceLoadPromise;
|
return workspaceLoadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshWorkspaces() {
|
||||||
|
workspaceLoadPromise = null;
|
||||||
|
return loadWorkspaces();
|
||||||
|
}
|
||||||
|
|
||||||
function initWorkspaceSwitcher() {
|
function initWorkspaceSwitcher() {
|
||||||
renderWorkspaceList();
|
renderWorkspaceList();
|
||||||
loadWorkspaces();
|
loadWorkspaces();
|
||||||
@@ -134,8 +141,16 @@ function switchWorkspace(workspaceId) {
|
|||||||
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace }));
|
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCurrentWorkspace(workspace) {
|
||||||
|
persistCurrentWorkspace(workspace);
|
||||||
|
renderWorkspaceList();
|
||||||
|
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace }));
|
||||||
|
}
|
||||||
|
|
||||||
window.getCurrentWorkspace = getCurrentWs;
|
window.getCurrentWorkspace = getCurrentWs;
|
||||||
window.whenWorkspacesReady = loadWorkspaces;
|
window.whenWorkspacesReady = loadWorkspaces;
|
||||||
|
window.refreshWorkspaces = refreshWorkspaces;
|
||||||
|
window.setCurrentWorkspace = setCurrentWorkspace;
|
||||||
window.hasWorkspaceApiFailure = function() { return workspaceLoadFailed; };
|
window.hasWorkspaceApiFailure = function() { return workspaceLoadFailed; };
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initWorkspaceSwitcher);
|
document.addEventListener('DOMContentLoaded', initWorkspaceSwitcher);
|
||||||
|
|||||||
@@ -236,15 +236,14 @@ UI-файлы:
|
|||||||
|
|
||||||
Что еще не хватает:
|
Что еще не хватает:
|
||||||
|
|
||||||
- frontend adapter с трансформацией backend response в текущую log-row форму;
|
- optional pagination и server-driven cursor, если логи начнут расти;
|
||||||
- polling strategy вместо локального seeded массива;
|
- отдельный streaming endpoint, если polling перестанет устраивать;
|
||||||
- явная поддержка `debug/info/warn/error` в UI без локального seed;
|
- richer detail metadata, если захотим показывать trace/request ids отдельными виджетами
|
||||||
- если нужен live mode, то либо polling, либо отдельный future streaming endpoint
|
|
||||||
|
|
||||||
Простой итог:
|
Простой итог:
|
||||||
|
|
||||||
- backend foundation для logs уже есть;
|
- страница уже подключена к live `logs` API;
|
||||||
- страница интегрируется быстро, если заменить seeded `LOGS` на API polling.
|
- текущий `live mode` реализован через polling и этого достаточно для текущего этапа.
|
||||||
|
|
||||||
### 4.6. Usage
|
### 4.6. Usage
|
||||||
|
|
||||||
@@ -270,14 +269,14 @@ UI-файлы:
|
|||||||
|
|
||||||
Что еще не хватает:
|
Что еще не хватает:
|
||||||
|
|
||||||
- frontend mapping из backend `summary/timeline/operations/agents` в текущие widgets;
|
- если понадобится agent-specific usage drilldown, для него нужен отдельный экран или таб;
|
||||||
- `CSV export` endpoint или серверная договоренность, если экспорт не хотим делать на клиенте;
|
- если понадобится server-side export, можно добавить отдельный CSV endpoint позже;
|
||||||
- согласование полей latency percentiles в одном формате
|
- quota/limits пока не являются частью backend модели, поэтому страница честно показывает traffic share
|
||||||
|
|
||||||
Простой итог:
|
Простой итог:
|
||||||
|
|
||||||
- usage page можно подключать сразу после logs;
|
- usage page уже подключена к live `usage` API;
|
||||||
- главная работа здесь на frontend adapter, а не на новом backend-ядре.
|
- CSV export сейчас делается на клиенте и этого достаточно для текущего этапа.
|
||||||
|
|
||||||
### 4.7. Workspace setup
|
### 4.7. Workspace setup
|
||||||
|
|
||||||
@@ -308,21 +307,21 @@ UI-файлы:
|
|||||||
|
|
||||||
Что еще не хватает:
|
Что еще не хватает:
|
||||||
|
|
||||||
- `switch current workspace` как backend-aware UI state, а не `localStorage`;
|
- `switch current workspace` все еще живет на клиенте, а не в session/backend;
|
||||||
- нормальная серверная модель current user + memberships;
|
- endpoint на удаление участника или изменение роли, если UI хочет это поддерживать;
|
||||||
- endpoint на удаление участника или изменение роли, если UI хочет это поддерживать
|
- delete/export workspace lifecycle пока не реализован на backend
|
||||||
|
|
||||||
Отдельный конфликт:
|
Отдельный конфликт:
|
||||||
|
|
||||||
- UI создает workspace локально и сразу считает его текущим;
|
- current workspace по-прежнему client-side;
|
||||||
- backend уже умеет создавать workspace, но у нас нет полноценной user-session модели;
|
- memberships и invitations уже live, но role-management пока read-only;
|
||||||
- значит выбор текущего workspace пока придется держать на клиенте, но список и metadata брать с сервера.
|
- `settings` page не должна дублировать этот flow, пока у нее нет своего backend-контракта
|
||||||
|
|
||||||
Простой итог:
|
Простой итог:
|
||||||
|
|
||||||
- create/edit workspace уже можно подключать;
|
- `workspace-setup` уже подключен к live backend;
|
||||||
- members/invites тоже частично готовы;
|
- create/edit workspace, refresh списка workspace и invitations работают;
|
||||||
- но полноценный switch и role management еще не закрыты.
|
- role management и workspace deletion остаются отдельным следующим этапом.
|
||||||
|
|
||||||
### 4.8. Settings
|
### 4.8. Settings
|
||||||
|
|
||||||
@@ -340,7 +339,7 @@ UI-файлы:
|
|||||||
|
|
||||||
Что уже можно подключить:
|
Что уже можно подключить:
|
||||||
|
|
||||||
- частично `GET /api/admin/workspaces/{workspace_id}`
|
- workspace block через `GET /api/admin/workspaces/{workspace_id}`
|
||||||
- `PATCH /api/admin/workspaces/{workspace_id}`
|
- `PATCH /api/admin/workspaces/{workspace_id}`
|
||||||
|
|
||||||
Что еще не хватает:
|
Что еще не хватает:
|
||||||
@@ -360,8 +359,9 @@ UI-файлы:
|
|||||||
|
|
||||||
Простой итог:
|
Простой итог:
|
||||||
|
|
||||||
- эту страницу нельзя подключать целиком сразу;
|
- workspace block уже можно держать live;
|
||||||
- сначала надо отделить workspace settings от пользовательского профиля.
|
- profile/security остаются временно mock/read-only;
|
||||||
|
- страницу целиком считать интегрированной пока нельзя.
|
||||||
|
|
||||||
### 4.9. Login
|
### 4.9. Login
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user