Merge branch 'feat/alpine-ui'

This commit is contained in:
a.tolmachev
2026-03-30 01:16:19 +03:00
100 changed files with 14704 additions and 166 deletions
+1
View File
@@ -32,6 +32,7 @@ Crank - платформа для публикации внешних API в в
- `docs/as-is-to-be.md` - переход `as is -> to be`, page-by-page gap analysis и архитектурные конфликты.
- `docs/backend-gap-plan.md` - конкретный backend-план: сущности, API, БД и порядок реализации.
- `docs/operations-workspace-contracts.md` - точные `workspace-scoped` контракты для экранов `Operations` и `Wizard`.
- `docs/alpine-ui-integration-plan.md` - постраничный план подключения нового Alpine UI к реальному backend.
- `docs/module-decomposition.md` - декомпозиция crates и модулей.
- `docs/data-model.md` - целевая модель данных.
- `docs/database-schema.md` - целевая схема БД.
+12 -7
View File
@@ -2,19 +2,24 @@
## Current
### `feat/observability-api`
### `feat/alpine-ui`
Status: completed
Status: in_progress
DoD:
- `Logs` page и `Usage` page имеют backend-контракт на реальных данных
- `test-runs` и `MCP tools/call` пишут продуктовые invocation logs
- usage endpoints отдают summary, timeline, breakdown по operation и agent
- observability не смешана с application logs
- `apps/ui` заменен на новый Alpine.js UI из `test-ui`
- `test-ui` остается нетронутым как fallback
- UI продолжает раздаваться как статический контейнер через текущий deployment path
- legacy React/Vite артефакты удалены из `apps/ui`
- backend для `Operations/Wizard` поддерживает server-side `PATCH`, `DELETE`, `archive`, `category`, `usage_summary`, `agent_refs`
- backend summary для `Operations` отдает `target_url` и `target_action`
- первые экраны Alpine UI можно сажать на реальные catalog/detail/mutation endpoints без расширения backend-контракта
- `Operations` page читает live catalog из `admin-api` и удаляет операции через backend
- `Wizard` использует live create/get/update flow вместо `localStorage` и `sessionStorage`
## Next
- `feat/alpine-ui`
- `feat/demo-assets`
## Backlog
+174 -11
View File
@@ -1,6 +1,6 @@
use axum::{
Router,
routing::{get, post},
routing::{delete, get, post},
};
use crate::{
@@ -16,10 +16,10 @@ use crate::{
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
operations::{
create_operation, create_version, export_operation, generate_draft, get_operation,
get_operation_version, list_grpc_services, list_operations, publish_operation,
run_test, upload_descriptor_set, upload_input_json, upload_output_json,
upload_proto_descriptor,
archive_operation, create_operation, create_version, delete_operation,
export_operation, generate_draft, get_operation, get_operation_version,
list_grpc_services, list_operations, publish_operation, run_test, update_operation,
upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_descriptor,
},
workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace},
},
@@ -30,7 +30,12 @@ pub fn build_app(state: AppState) -> Router {
let workspace_router = Router::new()
.route("/operations", get(list_operations).post(create_operation))
.route("/operations/import", post(import_operation))
.route("/operations/{operation_id}", get(get_operation))
.route(
"/operations/{operation_id}",
get(get_operation)
.patch(update_operation)
.delete(delete_operation),
)
.route("/operations/{operation_id}/versions", post(create_version))
.route(
"/operations/{operation_id}/versions/{version}",
@@ -40,6 +45,10 @@ pub fn build_app(state: AppState) -> Router {
"/operations/{operation_id}/publish",
post(publish_operation),
)
.route(
"/operations/{operation_id}/archive",
post(archive_operation),
)
.route("/operations/{operation_id}/test-runs", post(run_test))
.route(
"/operations/{operation_id}/samples/input-json",
@@ -84,10 +93,7 @@ pub fn build_app(state: AppState) -> Router {
"/invitations",
get(list_invitations).post(create_invitation),
)
.route(
"/invitations/{invitation_id}",
axum::routing::delete(delete_invitation),
)
.route("/invitations/{invitation_id}", delete(delete_invitation))
.route(
"/platform-api-keys",
get(list_platform_api_keys).post(create_platform_api_key),
@@ -98,7 +104,7 @@ pub fn build_app(state: AppState) -> Router {
)
.route(
"/platform-api-keys/{key_id}",
axum::routing::delete(delete_platform_api_key),
delete(delete_platform_api_key),
)
.route("/logs", get(list_logs))
.route("/logs/{log_id}", get(get_log))
@@ -204,6 +210,11 @@ mod tests {
.unwrap();
assert_eq!(listed["items"][0]["name"], "crm_create_lead");
assert_eq!(
listed["items"][0]["target_url"],
format!("{upstream_base_url}/crm/leads")
);
assert_eq!(listed["items"][0]["target_action"], "POST");
assert_eq!(published["published_version"], 1);
assert_eq!(test_run["ok"], true);
assert_eq!(
@@ -213,6 +224,155 @@ mod tests {
assert_eq!(test_run["response_preview"]["id"], "lead_123");
}
#[tokio::test]
async fn updates_archives_and_deletes_operation() {
let registry = test_registry().await;
let storage_root = test_storage_root("operation_mutations");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let created = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_mutable_operation",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
let listed = client
.get(format!("{base_url}/operations"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(listed["total"], 1);
assert_eq!(listed["items"][0]["category"], "sales");
assert_eq!(
listed["items"][0]["target_url"],
format!("{upstream_base_url}/crm/leads")
);
assert_eq!(listed["items"][0]["target_action"], "POST");
let updated = client
.patch(format!("{base_url}/operations/{operation_id}"))
.json(&json!({
"display_name": "Create Lead Updated",
"category": "marketing",
"target": {
"kind": "rest",
"base_url": upstream_base_url,
"method": "POST",
"path_template": "/crm/leads",
"static_headers": {}
},
"input_schema": {
"type": "object",
"required": true,
"nullable": false,
"fields": {
"email": {
"type": "string",
"required": true,
"nullable": false
}
}
},
"output_schema": {
"type": "object",
"required": true,
"nullable": false,
"fields": {
"id": {
"type": "string",
"required": true,
"nullable": false
}
}
},
"input_mapping": {
"rules": [
{
"source": "$.mcp.email",
"target": "$.request.body.email",
"required": true
}
]
},
"output_mapping": {
"rules": [
{
"source": "$.response.body.id",
"target": "$.output.id",
"required": true
}
]
},
"execution_config": {
"timeout_ms": 1000,
"headers": {}
},
"tool_description": {
"title": "Create Lead Updated",
"description": "Creates a CRM lead",
"tags": ["crm"]
}
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(updated["status"], "draft");
let detail = client
.get(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(detail["display_name"], "Create Lead Updated");
assert_eq!(detail["category"], "marketing");
let archived = client
.post(format!("{base_url}/operations/{operation_id}/archive"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(archived["status"], "archived");
let deleted = client
.delete(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(deleted["operation_id"], operation_id);
let missing = client
.get(format!("{base_url}/operations/{operation_id}"))
.send()
.await
.unwrap();
assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn creates_binds_and_publishes_agent() {
let registry = test_registry().await;
@@ -984,6 +1144,7 @@ mod tests {
OperationPayload {
name: name.to_owned(),
display_name: "Create Lead".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Rest,
target: Target::Rest(RestTarget {
base_url: base_url.to_owned(),
@@ -1035,6 +1196,7 @@ mod tests {
OperationPayload {
name: name.to_owned(),
display_name: "Create Lead GraphQL".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Graphql,
target: Target::Graphql(GraphqlTarget {
endpoint: endpoint.to_owned(),
@@ -1089,6 +1251,7 @@ mod tests {
OperationPayload {
name: name.to_owned(),
display_name: "Unary Echo gRPC".to_owned(),
category: "support".to_owned(),
protocol: Protocol::Grpc,
target: Target::Grpc(GrpcTarget {
server_addr: server_addr.to_owned(),
+5
View File
@@ -126,6 +126,11 @@ impl From<RegistryError> for ApiError {
} => Self::not_found(format!(
"operation version {version} for {operation_id} was not found"
)),
RegistryError::OperationHasPublishedAgentBindings { operation_id } => {
Self::conflict(format!(
"operation {operation_id} cannot be deleted while it is bound to a published agent"
))
}
RegistryError::AuthProfileNotFound { auth_profile_id } => {
Self::not_found(format!("auth profile {auth_profile_id} was not found"))
}
+52 -2
View File
@@ -12,7 +12,7 @@ use crate::{
error::ApiError,
service::{
ExportQuery, GenerateDraftPayload, ImportQuery, NewVersionPayload, OperationPayload,
PublishPayload, TestRunPayload,
PublishPayload, TestRunPayload, UpdateOperationPayload,
},
state::AppState,
};
@@ -43,7 +43,13 @@ pub async fn list_operations(
.service
.list_operations(&path.workspace_id.as_str().into())
.await?;
Ok(Json(json!({ "items": items })))
let total = items.len();
Ok(Json(json!({
"items": items,
"page": 1,
"page_size": total,
"total": total
})))
}
pub async fn create_operation(
@@ -72,6 +78,36 @@ pub async fn get_operation(
Ok(Json(json!(operation)))
}
pub async fn update_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
Json(payload): Json<UpdateOperationPayload>,
) -> Result<Json<Value>, ApiError> {
let result = state
.service
.update_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(result)))
}
pub async fn delete_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let result = state
.service
.delete_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
)
.await?;
Ok(Json(json!(result)))
}
pub async fn get_operation_version(
Path(path): Path<WorkspaceOperationVersionPath>,
State(state): State<AppState>,
@@ -119,6 +155,20 @@ pub async fn publish_operation(
Ok(Json(json!(published)))
}
pub async fn archive_operation(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let archived = state
.service
.archive_operation(
&path.workspace_id.as_str().into(),
&path.operation_id.as_str().into(),
)
.await?;
Ok(Json(json!(archived)))
}
pub async fn run_test(
Path(path): Path<WorkspaceOperationPath>,
State(state): State<AppState>,
+340 -10
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -15,11 +16,12 @@ use crank_registry::{
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
MembershipRecord, OperationSampleMetadata, OperationSummary, OperationVersionRecord,
PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation,
SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, WorkspaceRecord,
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PostgresRegistry,
PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
UsageSummary, UsageTimelinePoint, WorkspaceRecord,
};
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
use crank_schema::Schema;
@@ -43,6 +45,8 @@ pub struct AdminService {
pub struct OperationPayload {
pub name: String,
pub display_name: String,
#[serde(default = "default_operation_category")]
pub category: String,
pub protocol: Protocol,
pub target: Target,
pub input_schema: Schema,
@@ -240,6 +244,7 @@ pub struct CreatedOperationResponse {
pub workspace_id: String,
pub version: u32,
pub status: OperationStatus,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize)]
@@ -250,6 +255,88 @@ pub struct PublishResponse {
pub published_at: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UpdateOperationPayload {
pub display_name: String,
#[serde(default = "default_operation_category")]
pub category: String,
pub target: Target,
pub input_schema: Schema,
pub output_schema: Schema,
pub input_mapping: MappingSet,
pub output_mapping: MappingSet,
pub execution_config: crank_core::ExecutionConfig,
pub tool_description: crank_core::ToolDescription,
}
#[derive(Clone, Debug, Serialize)]
pub struct OperationUsageSummaryView {
pub calls_today: u64,
pub error_rate_pct: f64,
pub avg_latency_ms: u64,
}
#[derive(Clone, Debug, Serialize)]
pub struct OperationAgentRefView {
pub agent_id: String,
pub agent_slug: String,
pub display_name: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct OperationSummaryView {
pub id: String,
pub workspace_id: String,
pub name: String,
pub display_name: String,
pub category: String,
pub protocol: Protocol,
pub target_url: String,
pub target_action: String,
pub status: OperationStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub created_at: String,
pub updated_at: String,
pub published_at: Option<String>,
pub usage_summary: OperationUsageSummaryView,
pub agent_refs: Vec<OperationAgentRefView>,
}
#[derive(Clone, Debug, Serialize)]
pub struct OperationDetailView {
pub id: String,
pub workspace_id: String,
pub name: String,
pub display_name: String,
pub category: String,
pub protocol: Protocol,
pub status: OperationStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
pub created_at: String,
pub updated_at: String,
pub published_at: Option<String>,
pub draft_version_ref: VersionRef,
pub published_version_ref: Option<VersionRef>,
pub agent_refs: Vec<OperationAgentRefView>,
}
#[derive(Clone, Debug, Serialize)]
pub struct VersionRef {
pub version: u32,
pub status: OperationStatus,
}
#[derive(Clone, Debug, Serialize)]
pub struct OperationMutationResult {
pub operation_id: String,
pub workspace_id: String,
pub version: u32,
pub status: OperationStatus,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct ImportResponse {
pub operation_id: String,
@@ -280,6 +367,10 @@ pub struct GrpcMethodSummary {
pub output_schema: Schema,
}
fn default_operation_category() -> String {
"general".to_owned()
}
impl AdminService {
pub fn new(registry: PostgresRegistry, storage_root: PathBuf) -> Self {
Self {
@@ -630,9 +721,37 @@ impl AdminService {
pub async fn list_operations(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<OperationSummary>, ApiError> {
) -> Result<Vec<OperationSummaryView>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
Ok(self.registry.list_operations(workspace_id).await?)
let summaries = self.registry.list_operations(workspace_id).await?;
let usage = self
.registry
.list_operation_usage_summaries(workspace_id, &today_start_utc()?)
.await?;
let agent_refs = self
.registry
.list_operation_agent_refs(workspace_id)
.await?;
let usage_by_operation = usage_map(usage);
let refs_by_operation = agent_ref_map(agent_refs);
Ok(summaries
.into_iter()
.map(|summary| {
let operation_id = summary.id.as_str().to_owned();
enrich_operation_summary(
summary,
usage_by_operation
.get(&operation_id)
.cloned()
.unwrap_or_else(default_usage_summary),
refs_by_operation
.get(&operation_id)
.cloned()
.unwrap_or_default(),
)
})
.collect())
}
#[instrument(skip(self))]
@@ -640,13 +759,45 @@ impl AdminService {
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
) -> Result<OperationSummary, ApiError> {
self.registry
) -> Result<OperationDetailView, ApiError> {
let summary = self
.registry
.get_operation_summary(workspace_id, operation_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!("operation {} was not found", operation_id.as_str()))
})
})?;
let agent_refs = self
.registry
.list_operation_agent_refs(workspace_id)
.await?;
let refs = agent_ref_map(agent_refs)
.remove(operation_id.as_str())
.unwrap_or_default();
Ok(OperationDetailView {
id: summary.id.as_str().to_owned(),
workspace_id: summary.workspace_id.as_str().to_owned(),
name: summary.name,
display_name: summary.display_name,
category: summary.category,
protocol: summary.protocol,
status: summary.status,
current_draft_version: summary.current_draft_version,
latest_published_version: summary.latest_published_version,
created_at: summary.created_at,
updated_at: summary.updated_at,
published_at: summary.published_at,
draft_version_ref: VersionRef {
version: summary.current_draft_version,
status: summary.status,
},
published_version_ref: summary.latest_published_version.map(|version| VersionRef {
version,
status: OperationStatus::Published,
}),
agent_refs: refs,
})
}
#[instrument(skip(self))]
@@ -693,6 +844,7 @@ impl AdminService {
id: operation_id.clone(),
name: payload.name,
display_name: payload.display_name,
category: payload.category,
protocol: payload.protocol,
status: OperationStatus::Draft,
version: 1,
@@ -724,6 +876,7 @@ impl AdminService {
workspace_id: workspace_id.as_str().to_owned(),
version: 1,
status: OperationStatus::Draft,
updated_at: snapshot.updated_at,
})
}
@@ -743,6 +896,7 @@ impl AdminService {
id: operation_id.clone(),
name: payload.operation.name,
display_name: payload.operation.display_name,
category: payload.operation.category,
protocol: payload.operation.protocol,
status: OperationStatus::Draft,
version,
@@ -779,6 +933,62 @@ impl AdminService {
workspace_id: workspace_id.as_str().to_owned(),
version,
status: OperationStatus::Draft,
updated_at: snapshot.updated_at,
})
}
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str()))]
pub async fn update_operation(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
payload: UpdateOperationPayload,
) -> Result<OperationMutationResult, ApiError> {
let existing = self
.get_operation_version(
workspace_id,
operation_id,
self.get_operation(workspace_id, operation_id)
.await?
.current_draft_version,
)
.await?;
let updated_at = now_string()?;
let snapshot = RegistryOperation {
id: operation_id.clone(),
name: existing.snapshot.name,
display_name: payload.display_name,
category: payload.category,
protocol: existing.snapshot.protocol,
status: OperationStatus::Draft,
version: existing.version,
target: payload.target,
input_schema: payload.input_schema,
output_schema: payload.output_schema,
input_mapping: payload.input_mapping,
output_mapping: payload.output_mapping,
execution_config: payload.execution_config,
tool_description: payload.tool_description,
samples: existing.snapshot.samples,
generated_draft: existing.snapshot.generated_draft,
config_export: existing.snapshot.config_export,
created_at: existing.snapshot.created_at,
updated_at: updated_at.clone(),
published_at: existing.snapshot.published_at,
};
self.validate_registry_operation(&snapshot)?;
self.registry
.update_operation_draft(workspace_id, &snapshot)
.await?;
Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: snapshot.version,
status: snapshot.status,
updated_at,
})
}
@@ -809,6 +1019,48 @@ impl AdminService {
})
}
#[instrument(skip(self), fields(operation_id = %operation_id.as_str()))]
pub async fn archive_operation(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
) -> Result<OperationMutationResult, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?;
let updated_at = now_string()?;
self.registry
.archive_operation(workspace_id, operation_id, &updated_at)
.await?;
Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: summary.current_draft_version,
status: OperationStatus::Archived,
updated_at,
})
}
#[instrument(skip(self), fields(operation_id = %operation_id.as_str()))]
pub async fn delete_operation(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
) -> Result<OperationMutationResult, ApiError> {
let summary = self.get_operation(workspace_id, operation_id).await?;
let updated_at = now_string()?;
self.registry
.delete_operation(workspace_id, operation_id)
.await?;
Ok(OperationMutationResult {
operation_id: operation_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: summary.current_draft_version,
status: summary.status,
updated_at,
})
}
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), version = payload.version))]
pub async fn run_test(
&self,
@@ -1328,6 +1580,7 @@ impl AdminService {
let payload = OperationPayload {
name: document.operation.name.clone(),
display_name: document.operation.display_name.clone(),
category: document.operation.category.clone(),
protocol: document.operation.protocol,
target: document.operation.target.clone(),
input_schema: document.operation.input_schema.clone(),
@@ -1515,6 +1768,13 @@ impl AdminService {
Ok(())
}
fn validate_registry_operation(&self, operation: &RegistryOperation) -> Result<(), ApiError> {
validate_protocol_target(operation.protocol, &operation.target)?;
operation.input_mapping.validate_paths()?;
operation.output_mapping.validate_paths()?;
Ok(())
}
async fn find_operation_by_name(
&self,
workspace_id: &WorkspaceId,
@@ -1755,6 +2015,76 @@ fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket
))
}
fn today_start_utc() -> Result<String, ApiError> {
OffsetDateTime::now_utc()
.replace_time(time::Time::MIDNIGHT)
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))
}
fn default_usage_summary() -> OperationUsageSummaryView {
OperationUsageSummaryView {
calls_today: 0,
error_rate_pct: 0.0,
avg_latency_ms: 0,
}
}
fn usage_map(items: Vec<OperationUsageSummary>) -> BTreeMap<String, OperationUsageSummaryView> {
items
.into_iter()
.map(|item| {
(
item.operation_id.as_str().to_owned(),
OperationUsageSummaryView {
calls_today: item.calls_today,
error_rate_pct: item.error_rate_pct,
avg_latency_ms: item.avg_latency_ms,
},
)
})
.collect()
}
fn agent_ref_map(items: Vec<OperationAgentRef>) -> BTreeMap<String, Vec<OperationAgentRefView>> {
let mut map = BTreeMap::<String, Vec<OperationAgentRefView>>::new();
for item in items {
map.entry(item.operation_id.as_str().to_owned())
.or_default()
.push(OperationAgentRefView {
agent_id: item.agent_id.as_str().to_owned(),
agent_slug: item.agent_slug,
display_name: item.display_name,
});
}
map
}
fn enrich_operation_summary(
summary: OperationSummary,
usage_summary: OperationUsageSummaryView,
agent_refs: Vec<OperationAgentRefView>,
) -> OperationSummaryView {
OperationSummaryView {
id: summary.id.as_str().to_owned(),
workspace_id: summary.workspace_id.as_str().to_owned(),
name: summary.name,
display_name: summary.display_name,
category: summary.category,
protocol: summary.protocol,
target_url: summary.target_url,
target_action: summary.target_action,
status: summary.status,
current_draft_version: summary.current_draft_version,
latest_published_version: summary.latest_published_version,
created_at: summary.created_at,
updated_at: summary.updated_at,
published_at: summary.published_at,
usage_summary,
agent_refs,
}
}
fn proto_service_summary(service: ProtoService) -> Result<GrpcServiceSummary, ApiError> {
let methods = service
.unary_methods()
+3
View File
@@ -706,6 +706,7 @@ mod tests {
id: OperationId::new(format!("op_{name}")),
name: name.to_owned(),
display_name: "Create Lead".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Rest,
status: OperationStatus::Published,
version: 1,
@@ -766,6 +767,7 @@ mod tests {
id: OperationId::new(format!("op_{name}")),
name: name.to_owned(),
display_name: "Create Lead GraphQL".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Graphql,
status: OperationStatus::Published,
version: 1,
@@ -829,6 +831,7 @@ mod tests {
id: OperationId::new(format!("op_{name}")),
name: name.to_owned(),
display_name: "Unary Echo gRPC".to_owned(),
category: "support".to_owned(),
protocol: Protocol::Grpc,
status: OperationStatus::Published,
version: 1,
+5
View File
@@ -2,6 +2,11 @@ FROM nginx:1.27-alpine
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
COPY apps/ui/index.html /usr/share/nginx/html/index.html
COPY apps/ui/css /usr/share/nginx/html/css
COPY apps/ui/data /usr/share/nginx/html/data
COPY apps/ui/html /usr/share/nginx/html/html
COPY apps/ui/icons /usr/share/nginx/html/icons
COPY apps/ui/js /usr/share/nginx/html/js
COPY Crank.png /usr/share/nginx/html/Crank.png
EXPOSE 3000
+579
View File
@@ -0,0 +1,579 @@
/* ── Page header ── */
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 32px;
}
.page-heading {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
color: var(--text-primary);
margin-bottom: 4px;
}
.page-subheading { font-size: 14px; color: var(--text-muted); }
.btn-new {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 18px;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
font-size: 13.5px;
font-weight: 600;
border: 1px solid var(--accent-dim);
box-shadow: 0 1px 4px rgba(13,148,136,0.4);
transition: all 0.15s;
white-space: nowrap;
text-decoration: none;
}
.btn-new:hover {
background: var(--accent-dim);
box-shadow: 0 2px 10px rgba(13,148,136,0.5);
transform: translateY(-1px);
}
/* ── Stats ── */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
margin-bottom: 28px;
}
.stat-card { background: var(--bg-surface); padding: 18px 22px; }
.stat-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 6px;
}
.stat-value {
font-size: 26px;
font-weight: 700;
letter-spacing: -1px;
color: var(--text-primary);
}
.stat-delta { font-size: 12px; color: var(--text-muted); margin-top: 3px; }
.stat-delta.up { color: var(--green); }
.stat-delta.down { color: var(--red); }
/* ── Tabs ── */
.tab-row {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}
.tab-item {
display: flex;
align-items: center;
gap: 7px;
padding: 10px 18px;
font-size: 14px;
font-weight: 450;
color: var(--text-muted);
cursor: pointer;
border: none;
background: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.1s;
}
.tab-item:hover { color: var(--text-secondary); }
.tab-item.active {
color: var(--text-primary);
font-weight: 600;
border-bottom-color: var(--accent-bright);
}
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg-overlay);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0 6px;
height: 18px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
min-width: 20px;
}
.tab-item.active .tab-count {
background: var(--accent);
color: #fff;
border-color: var(--accent-dim);
}
/* ── Filter bar ── */
.filter-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.search-box {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 12px;
width: 220px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.search-box:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-ring);
}
.search-box input {
border: none;
background: none;
outline: none;
font-size: 13.5px;
color: var(--text-primary);
width: 100%;
font-family: inherit;
}
.search-box input::placeholder { color: var(--text-muted); }
.search-box svg { color: var(--text-muted); flex-shrink: 0; }
/* ── Dropdown filter chip ── */
.filter-dropdown { position: relative; }
.filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-surface);
font-size: 13px;
color: var(--text-secondary);
transition: all 0.1s;
white-space: nowrap;
}
.filter-chip:hover { border-color: var(--bg-muted); background: var(--bg-overlay); color: var(--text-primary); }
.filter-chip.has-value {
border-color: var(--accent);
color: var(--accent-bright);
background: var(--accent-glow);
}
.filter-chip .chevron {
opacity: 0.6;
transition: transform 0.15s;
}
.filter-chip.open .chevron { transform: rotate(180deg); }
.dropdown-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 180px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
z-index: 150;
overflow: hidden;
padding: 4px;
}
.dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: var(--radius-sm);
font-size: 13.5px;
color: var(--text-secondary);
border: none;
background: none;
width: 100%;
text-align: left;
cursor: pointer;
transition: all 0.1s;
}
.dropdown-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.dropdown-item.selected { color: var(--accent-bright); }
.dropdown-item .check {
opacity: 0;
color: var(--accent);
}
.dropdown-item.selected .check { opacity: 1; }
.dropdown-divider {
height: 1px;
background: var(--border-subtle);
margin: 4px 0;
}
.dropdown-clear {
display: flex;
align-items: center;
padding: 7px 10px;
font-size: 12px;
color: var(--text-muted);
border: none;
background: none;
width: 100%;
text-align: left;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.1s;
}
.dropdown-clear:hover { color: var(--red); background: var(--red-bg); }
/* ── Filter right side ── */
.filter-right {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.results-count { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
.sort-dropdown { position: relative; }
.sort-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-surface);
font-size: 13px;
color: var(--text-secondary);
transition: all 0.1s;
white-space: nowrap;
}
.sort-chip:hover { border-color: var(--bg-muted); background: var(--bg-overlay); color: var(--text-primary); }
.sort-chip .chevron { opacity: 0.6; transition: transform 0.15s; }
.sort-chip.open .chevron { transform: rotate(180deg); }
/* ── Table ── */
.table-wrap {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.table-wrap table {
width: 100%;
border-collapse: collapse;
}
.table-wrap thead tr {
background: var(--bg-canvas);
border-bottom: 1px solid var(--border);
}
.table-wrap th {
padding: 10px 16px;
text-align: left;
font-size: 11.5px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
user-select: none;
}
.th-sortable { cursor: pointer; }
.th-sortable:hover { color: var(--text-secondary); }
.th-sortable.sort-active { color: var(--text-secondary); }
.sort-arrow { margin-left: 4px; opacity: 0.5; font-size: 11px; }
.sort-arrow.active { opacity: 1; color: var(--accent-bright); }
.table-wrap tbody tr {
border-bottom: 1px solid var(--border-subtle);
transition: background 0.1s;
}
.table-wrap tbody tr:last-child { border-bottom: none; }
.table-wrap tbody tr:hover { background: var(--bg-overlay); }
.table-wrap td { padding: 14px 16px; vertical-align: middle; }
.op-name { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 13px; font-weight: 500; color: var(--text-primary); }
.op-display { font-size: 12px; color: var(--text-muted); margin-top: 3px; }
/* ── Badges ── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: var(--radius-sm);
font-size: 11.5px;
font-weight: 700;
white-space: nowrap;
border: 1px solid;
letter-spacing: 0.02em;
}
.badge-rest { color: var(--blue); background: var(--blue-bg); border-color: var(--blue-border); }
.badge-graphql { color: var(--purple); background: var(--purple-bg); border-color: var(--purple-border); }
.badge-grpc { color: #14b8a6; background: rgba(13,148,136,0.1); border-color: rgba(13,148,136,0.25); }
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border-radius: var(--radius-sm);
font-size: 12.5px;
font-weight: 500;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.status-active { color: var(--green); background: var(--green-bg); }
.status-active .status-dot { background: var(--green); box-shadow: 0 0 0 2px rgba(63,185,80,0.2); }
.status-draft { color: var(--amber); background: var(--amber-bg); }
.status-draft .status-dot { background: var(--amber); }
.status-error { color: var(--red); background: var(--red-bg); }
.status-error .status-dot { background: var(--red); box-shadow: 0 0 0 2px rgba(248,81,73,0.2); }
.status-inactive { color: var(--text-muted); background: var(--bg-overlay); }
.status-inactive .status-dot { background: var(--text-muted); }
.target-url-cell {
display: flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--text-secondary);
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.target-url-cell a { color: inherit; text-decoration: none; }
.target-url-cell svg { flex-shrink: 0; opacity: 0.5; }
.date-cell { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
.row-actions { display: flex; align-items: center; gap: 4px; justify-content: flex-end; }
.row-btn {
padding: 5px 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-surface);
font-size: 12.5px;
color: var(--text-secondary);
transition: all 0.1s;
}
.row-btn:hover { border-color: var(--bg-muted); color: var(--text-primary); background: var(--bg-overlay); }
.row-btn.danger:hover { border-color: var(--red-border); color: var(--red); background: var(--red-bg); }
.row-btn-edit {
width: 34px;
height: 34px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
}
/* ── Empty state ── */
.empty-state {
padding: 60px 20px;
text-align: center;
color: var(--text-muted);
}
.empty-state h3 { font-size: 16px; color: var(--text-secondary); margin-bottom: 8px; }
.empty-state p { font-size: 13.5px; }
/* ── Table footer / pagination ── */
.table-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-top: 1px solid var(--border);
background: var(--bg-canvas);
}
.table-footer-info { font-size: 13px; color: var(--text-muted); }
.pagination { display: flex; align-items: center; gap: 4px; }
.page-btn {
width: 32px;
height: 32px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-surface);
font-size: 13px;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s;
}
.page-btn:hover:not(:disabled) { border-color: var(--bg-muted); color: var(--text-primary); }
.page-btn.active { background: var(--accent); color: #fff; border-color: var(--accent-dim); font-weight: 600; }
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
/* ── Banner ── */
.banner {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 16px;
border-radius: var(--radius);
background: rgba(13,148,136,0.07);
border: 1px solid rgba(13,148,136,0.2);
color: var(--accent-bright);
font-size: 13.5px;
margin-bottom: 24px;
}
.banner-close {
margin-left: auto;
background: none;
border: none;
color: var(--accent-bright);
font-size: 16px;
opacity: 0.6;
line-height: 1;
}
.banner-close:hover { opacity: 1; }
/* ═══════════════ RESPONSIVE ═══════════════ */
/* Tablet: ≤ 960px */
@media (max-width: 960px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
.col-url { display: none; }
.results-count { display: none; }
}
/* Narrow / mobile: ≤ 720px */
@media (max-width: 720px) {
/* --- Скрываем колонку CREATED --- */
.col-created { display: none; }
/* --- Filter bar → grid --- */
.filter-bar {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
/* Search во всю ширину */
.search-box {
grid-column: 1 / -1;
width: 100%;
}
/* Каждый dropdown занимает ячейку */
.filter-dropdown {
width: 100%;
}
.filter-chip {
width: 100%;
justify-content: space-between;
}
/* Sort справа, во всю строку */
.filter-right {
grid-column: 1 / -1;
margin-left: 0;
justify-content: flex-end;
}
.sort-chip { white-space: nowrap; }
/* action column compact */
.row-actions { justify-content: center; }
/* --- Таблица --- */
.table-wrap td { padding: 12px 10px; }
.table-wrap th { padding: 8px 10px; }
}
/* Mobile: ≤ 480px */
@media (max-width: 480px) {
.tab-item { padding: 10px 8px; font-size: 13px; gap: 5px; }
.page-header { flex-direction: column; gap: 12px; align-items: flex-start; }
.btn-new { width: 100%; justify-content: center; }
}
/* ── Active filter chips ── */
.active-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 8px 0 4px;
}
.active-filter-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px 3px 10px;
background: rgba(13,148,136,0.12);
border: 1px solid rgba(13,148,136,0.3);
border-radius: 20px;
font-size: 12px;
color: var(--accent);
}
.active-filter-chip-remove {
display: flex;
align-items: center;
background: none;
border: none;
padding: 1px;
cursor: pointer;
color: var(--accent);
opacity: 0.7;
border-radius: 50%;
transition: opacity 0.1s;
}
.active-filter-chip-remove:hover { opacity: 1; }
.active-filter-clear-all {
background: none;
border: none;
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
padding: 3px 6px;
border-radius: 4px;
transition: color 0.1s;
}
.active-filter-clear-all:hover { color: var(--text-secondary); }
/* ── Operation agent badges ── */
.op-agent-badges {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.op-agent-badge {
display: inline-block;
padding: 1px 6px;
font-size: 10px;
font-weight: 500;
background: rgba(99,102,241,0.12);
border: 1px solid rgba(99,102,241,0.25);
border-radius: 3px;
color: #818cf8;
white-space: nowrap;
}
/* ── Row delete button ── */
.row-btn-delete {
color: var(--text-muted);
transition: color 0.1s, background 0.1s;
}
.row-btn-delete:hover {
color: var(--red, #f87171);
background: rgba(248,113,113,0.08);
}
+231
View File
@@ -0,0 +1,231 @@
/* ── Navbar ── */
.navbar {
height: 60px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 40px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 1px 0 var(--border-subtle), 0 4px 16px rgba(0,0,0,0.4);
}
.nav-logo {
display: flex;
align-items: center;
gap: 9px;
margin-right: 36px;
text-decoration: none;
}
.nav-logo-mark {
width: 28px;
height: 28px;
background: linear-gradient(135deg, #0d9488, #0891b2);
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 13px;
font-weight: 800;
flex-shrink: 0;
}
.nav-logo-text {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.4px;
color: var(--text-primary);
}
.nav-links {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
}
.nav-link {
padding: 6px 14px;
border-radius: var(--radius);
font-size: 14px;
font-weight: 450;
color: var(--text-muted);
text-decoration: none;
border: none;
background: none;
transition: all 0.1s;
}
.nav-link:hover { background: var(--bg-overlay); color: var(--text-secondary); }
.nav-link.active { color: var(--text-primary); font-weight: 550; background: var(--bg-overlay); }
.nav-right {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.nav-kbd {
display: flex;
align-items: center;
gap: 4px;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px 9px;
font-size: 12px;
color: var(--text-muted);
background: var(--bg-overlay);
cursor: pointer;
}
.nav-icon-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
border: none;
background: none;
color: var(--text-muted);
transition: all 0.1s;
position: relative;
}
.nav-icon-btn:hover { background: var(--bg-overlay); color: var(--text-secondary); }
.nav-divider { width: 1px; height: 20px; background: var(--border); }
.nav-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #0d9488, #6366f1);
color: #fff;
font-size: 11.5px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 2px solid transparent;
transition: border-color 0.15s;
}
.nav-avatar:hover { border-color: var(--accent); }
/* ── User dropdown ── */
.user-menu {
position: relative;
}
.user-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 180px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
z-index: 200;
}
.user-dropdown-header {
padding: 12px 14px;
border-bottom: 1px solid var(--border-subtle);
}
.user-dropdown-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.user-dropdown-role {
font-size: 11.5px;
color: var(--text-muted);
margin-top: 2px;
}
.user-dropdown-item {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 14px;
font-size: 13.5px;
color: var(--text-secondary);
border: none;
background: none;
width: 100%;
text-align: left;
transition: all 0.1s;
}
.user-dropdown-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.user-dropdown-item.danger:hover { background: var(--red-bg); color: var(--red); }
.user-dropdown-item svg { flex-shrink: 0; opacity: 0.7; }
/* ── Page container ── */
.page {
max-width: 1160px;
margin: 0 auto;
padding: 36px 40px 60px;
}
/* ── Hamburger button (hidden on desktop) ── */
.nav-hamburger {
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
width: 32px;
height: 32px;
border: none;
background: none;
cursor: pointer;
padding: 4px;
border-radius: var(--radius);
transition: background 0.1s;
order: 10; /* after other right items */
}
.nav-hamburger:hover { background: var(--bg-overlay); }
.nav-hamburger span {
display: block;
width: 18px;
height: 1.5px;
background: var(--text-muted);
border-radius: 1px;
transition: all 0.2s;
transform-origin: center;
}
.nav-hamburger.open span:nth-child(1) { transform: translateY(6.5px) rotate(45deg); }
.nav-hamburger.open span:nth-child(2) { opacity: 0; }
.nav-hamburger.open span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); }
/* ── Mobile nav dropdown ── */
.mobile-nav {
display: none;
position: sticky;
top: 60px;
z-index: 99;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
padding: 8px 16px 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.mobile-nav-link {
display: block;
padding: 10px 12px;
border-radius: var(--radius);
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
text-decoration: none;
transition: all 0.1s;
}
.mobile-nav-link:hover { background: var(--bg-overlay); color: var(--text-secondary); }
.mobile-nav-link.active { color: var(--text-primary); background: var(--bg-overlay); }
/* ── Responsive breakpoints ── */
@media (max-width: 720px) {
.navbar { padding: 0 16px; }
.nav-links { display: none; }
.nav-kbd { display: none; }
.nav-hamburger { display: flex; }
.mobile-nav { display: block; }
.page { padding: 20px 16px 40px; }
}
+192
View File
@@ -0,0 +1,192 @@
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(ellipse 70% 50% at 50% -20%, rgba(13,148,136,0.12) 0%, transparent 70%),
var(--bg-canvas);
}
.login-card {
width: 100%;
max-width: 380px;
}
.login-logo {
display: flex;
align-items: center;
gap: 10px;
justify-content: center;
margin-bottom: 36px;
}
.login-logo-mark {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #0d9488, #0891b2);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 17px;
font-weight: 800;
}
.login-logo-text {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.5px;
color: var(--text-primary);
}
.login-box {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 32px;
box-shadow: 0 8px 40px rgba(0,0,0,0.4);
}
.login-heading {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.4px;
color: var(--text-primary);
margin-bottom: 4px;
}
.login-sub {
font-size: 13.5px;
color: var(--text-muted);
margin-bottom: 28px;
}
.field {
margin-bottom: 16px;
}
.field-label {
display: block;
font-size: 12.5px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.field-input {
width: 100%;
padding: 9px 12px;
background: var(--bg-canvas);
border: 1px solid var(--border);
border-radius: 7px;
font-family: 'Inter', sans-serif;
font-size: 13.5px;
color: var(--text-primary);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.field-input::placeholder { color: var(--text-muted); }
.field-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(13,148,136,0.25);
}
.field-footer {
display: flex;
justify-content: flex-end;
margin-top: 5px;
}
.field-link {
font-size: 12px;
color: var(--accent);
text-decoration: none;
}
.field-link:hover { text-decoration: underline; }
.btn-signin {
width: 100%;
padding: 10px;
margin-top: 8px;
background: var(--accent);
color: #fff;
border: 1px solid var(--accent-dim);
border-radius: 7px;
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
box-shadow: 0 1px 4px rgba(13,148,136,0.4);
}
.btn-signin:hover {
background: var(--accent-dim);
box-shadow: 0 2px 10px rgba(13,148,136,0.5), 0 0 0 3px rgba(13,148,136,0.25);
}
.btn-signin:active { transform: translateY(1px); }
.login-error {
display: none;
background: var(--red-bg);
border: 1px solid var(--red-border);
border-radius: 7px;
padding: 10px 14px;
font-size: 13px;
color: var(--red);
margin-bottom: 16px;
}
.login-footer {
text-align: center;
margin-top: 20px;
font-size: 12.5px;
color: var(--text-muted);
}
.login-footer a { color: var(--accent); text-decoration: none; }
.login-footer a:hover { text-decoration: underline; }
.login-divider {
display: flex;
align-items: center;
gap: 12px;
margin: 22px 0;
}
.login-divider-line { flex: 1; height: 1px; background: var(--border); }
.login-divider-text { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; }
.btn-sso {
width: 100%;
padding: 9px;
background: var(--bg-overlay);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 7px;
font-family: 'Inter', sans-serif;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
}
.btn-sso:hover { background: var(--bg-muted); color: var(--text-primary); }
.version-badge {
text-align: center;
margin-top: 28px;
font-size: 11.5px;
color: var(--text-muted);
}
+125
View File
@@ -0,0 +1,125 @@
.log-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-bottom: 1px solid var(--border-subtle);
flex-wrap: wrap;
}
.live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--green);
animation: pulse 1.8s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(63,185,80,0.6); }
50% { opacity: 0.7; box-shadow: 0 0 0 5px rgba(63,185,80,0); }
}
.live-label {
font-size: 11.5px;
font-weight: 600;
color: var(--green);
letter-spacing: 0.2px;
}
.toolbar-sep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; }
.time-range-select {
background: var(--bg-canvas);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 5px 28px 5px 10px;
font-family: 'Inter', sans-serif;
font-size: 12.5px;
color: var(--text-secondary);
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
cursor: pointer;
outline: none;
}
.time-range-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-ring);
}
.log-op-name {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
color: var(--text-muted);
background: var(--bg-overlay);
border: 1px solid var(--border-subtle);
padding: 1px 7px;
border-radius: 4px;
white-space: nowrap;
}
.log-status {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
font-weight: 600;
}
.log-status.ok { color: var(--green); }
.log-status.err { color: var(--red); }
.log-status.warn { color: var(--amber); }
.log-entry-expanded {
padding: 10px 20px 16px calc(20px + 152px + 12px + 58px + 12px);
background: var(--bg-canvas);
border-bottom: 1px solid var(--border-subtle);
display: none;
}
.log-entry-expanded.open { display: block; }
.log-detail-block {
background: #161b22;
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 14px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #c9d1d9;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-all;
}
.log-detail-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
margin-top: 12px;
}
.log-detail-label:first-child { margin-top: 0; }
.refresh-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
background: var(--bg-overlay);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 12.5px;
color: var(--text-muted);
cursor: pointer;
font-family: 'Inter', sans-serif;
transition: all 0.12s;
}
.refresh-btn:hover { color: var(--text-secondary); background: var(--bg-muted); }
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
.avatar-upload {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--bg-canvas);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 4px;
}
.avatar-large {
width: 52px;
height: 52px;
border-radius: 50%;
background: linear-gradient(135deg, #0d9488, #6366f1);
color: #fff;
font-size: 18px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 2px solid var(--border);
}
.avatar-upload-actions { flex: 1; }
.avatar-name { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 3px; }
.avatar-sub { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; }
.save-bar {
position: sticky;
bottom: 0;
background: rgba(13,17,23,0.9);
backdrop-filter: blur(12px);
border-top: 1px solid var(--border);
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: auto;
display: none;
}
.save-bar.visible { display: flex; }
.section-anchor { scroll-margin-top: 80px; }
.danger-zone-action {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
border-bottom: 1px solid var(--border-subtle);
}
.danger-zone-action:last-child { border-bottom: none; }
.danger-zone-text { flex: 1; }
.danger-zone-title { font-size: 13.5px; font-weight: 500; color: var(--text-primary); margin-bottom: 3px; }
.danger-zone-desc { font-size: 12.5px; color: var(--text-muted); line-height: 1.5; }
.toggle-setting-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 0;
border-bottom: 1px solid var(--border-subtle);
}
.toggle-setting-row:last-child { border-bottom: none; }
.toggle-sm {
width: 36px;
height: 20px;
border-radius: 10px;
background: var(--bg-muted);
border: 1px solid var(--border);
position: relative;
flex-shrink: 0;
cursor: pointer;
transition: background 0.2s;
}
.toggle-sm.on { background: var(--accent); border-color: var(--accent-dim); }
.toggle-sm::after {
content: '';
position: absolute;
left: 2px; top: 1px;
width: 16px; height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
transition: transform 0.2s;
}
.toggle-sm.on::after { transform: translateX(16px); }
.toggle-setting-text { flex: 1; }
.toggle-setting-label { font-size: 13.5px; font-weight: 500; color: var(--text-primary); }
.toggle-setting-desc { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.member-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border-subtle);
}
.member-row:last-child { border-bottom: none; }
.member-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--bg-muted);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
flex-shrink: 0;
}
.member-info { flex: 1; }
.member-name { font-size: 13.5px; color: var(--text-primary); font-weight: 500; }
.member-email { font-size: 12px; color: var(--text-muted); }
.member-role-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid;
}
.role-admin { background: rgba(13,148,136,0.1); border-color: rgba(13,148,136,0.25); color: var(--accent); }
.role-member { background: var(--bg-overlay); border-color: var(--border); color: var(--text-muted); }
.role-viewer { background: rgba(88,166,255,0.07); border-color: var(--blue-border); color: var(--blue); }
+122
View File
@@ -0,0 +1,122 @@
.period-select {
background: var(--bg-canvas);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 28px 7px 11px;
font-family: 'Inter', sans-serif;
font-size: 13px;
color: var(--text-secondary);
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath fill='%236e7681' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
cursor: pointer;
outline: none;
transition: border-color 0.15s;
}
.period-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-ring);
}
.chart-placeholder {
background: var(--bg-canvas);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
height: 200px;
display: flex;
align-items: flex-end;
padding: 12px 16px;
gap: 4px;
overflow: hidden;
margin-bottom: 24px;
position: relative;
}
.chart-bar {
flex: 1;
border-radius: 3px 3px 0 0;
min-width: 0;
transition: opacity 0.15s;
position: relative;
cursor: pointer;
}
.chart-bar:hover { opacity: 0.8; }
.chart-bar:hover::after {
content: attr(data-tip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 5px;
padding: 4px 8px;
font-size: 11px;
color: var(--text-primary);
white-space: nowrap;
pointer-events: none;
margin-bottom: 4px;
}
.chart-bar.success { background: linear-gradient(180deg, var(--accent) 0%, rgba(13,148,136,0.4) 100%); }
.chart-bar.error { background: linear-gradient(180deg, var(--red) 0%, rgba(248,81,73,0.3) 100%); }
.chart-label {
position: absolute;
bottom: -20px;
left: 0;
right: 0;
text-align: center;
font-size: 10px;
color: var(--text-muted);
}
.chart-wrap {
position: relative;
padding-bottom: 24px;
}
.chart-y-grid {
position: absolute;
left: 0; right: 0; top: 0; bottom: 24px;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.chart-y-line {
width: 100%;
height: 1px;
background: var(--border-subtle);
}
.quota-bar-wrap {
height: 8px;
background: var(--bg-muted);
border-radius: 4px;
overflow: hidden;
margin: 8px 0 4px;
}
.quota-bar-fill {
height: 100%;
border-radius: 4px;
background: linear-gradient(90deg, var(--accent), #7c3aed);
}
.latency-bar {
display: flex;
height: 12px;
border-radius: 3px;
overflow: hidden;
gap: 2px;
}
.latency-seg { border-radius: 2px; }
.latency-p50 { background: var(--accent); }
.latency-p95 { background: var(--amber); }
.latency-p99 { background: var(--red); }
+64
View File
@@ -0,0 +1,64 @@
:root {
--bg-canvas: #0d1117;
--bg-surface: #161b22;
--bg-overlay: #21262d;
--bg-muted: #30363d;
--border: rgba(48, 54, 61, 0.9);
--border-subtle: rgba(33, 38, 45, 0.9);
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent: #0d9488;
--accent-dim: #0f766e;
--accent-bright: #14b8a6;
--accent-glow: rgba(13, 148, 136, 0.15);
--accent-ring: rgba(13, 148, 136, 0.25);
--red: #f85149;
--red-bg: rgba(248, 81, 73, 0.10);
--red-border: rgba(248, 81, 73, 0.25);
--amber: #d29922;
--amber-bg: rgba(210, 153, 34, 0.10);
--amber-border: rgba(210, 153, 34, 0.25);
--green: #3fb950;
--green-bg: rgba(63, 185, 80, 0.10);
--green-border: rgba(63, 185, 80, 0.25);
--blue: #58a6ff;
--blue-bg: rgba(88, 166, 255, 0.08);
--blue-border: rgba(88, 166, 255, 0.20);
--purple: #bc8cff;
--purple-bg: rgba(188, 140, 255, 0.08);
--purple-border: rgba(188, 140, 255, 0.20);
--radius-sm: 5px;
--radius: 7px;
--radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
min-width: 360px;
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg-canvas);
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a { color: inherit; text-decoration: none; }
button { cursor: pointer; font-family: inherit; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-muted); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #484f58; }
::selection { background: var(--accent-glow); color: var(--text-primary); }
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
.ws-setup-page {
min-height: 100vh;
background: var(--bg-canvas);
display: flex;
flex-direction: column;
}
.ws-setup-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px;
border-bottom: 1px solid var(--border-subtle);
}
.ws-setup-logo {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: var(--text-primary);
font-size: 15px;
font-weight: 600;
}
.ws-setup-logo-mark {
width: 30px;
height: 30px;
background: #0d9488;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.ws-setup-back {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-muted);
text-decoration: none;
padding: 6px 10px;
border-radius: 6px;
transition: color 0.15s, background 0.15s;
}
.ws-setup-back:hover { color: var(--text-secondary); background: rgba(255,255,255,0.04); }
.ws-setup-body {
flex: 1;
display: flex;
justify-content: center;
padding: 48px 24px 80px;
}
.ws-setup-container {
width: 100%;
max-width: 600px;
}
.ws-setup-header { margin-bottom: 32px; }
.ws-setup-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
}
.ws-setup-subtitle {
font-size: 14px;
color: var(--text-muted);
line-height: 1.5;
}
.ws-form-section {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
}
.ws-form-section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 18px;
}
/* Avatar */
.ws-avatar-row {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.ws-avatar-preview {
width: 52px;
height: 52px;
border-radius: 12px;
background: #0d9488;
color: #fff;
font-size: 22px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background 0.2s;
}
.ws-avatar-hint { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
.ws-color-swatches { display: flex; gap: 6px; margin-top: 6px; }
.ws-color-swatch {
width: 20px;
height: 20px;
border-radius: 5px;
cursor: pointer;
border: 2px solid transparent;
transition: transform 0.1s, border-color 0.1s;
}
.ws-color-swatch:hover { transform: scale(1.15); }
.ws-color-swatch.active { border-color: var(--text-primary); }
/* Invite rows */
.invite-rows { display: flex; flex-direction: column; gap: 8px; }
.invite-row { display: flex; align-items: center; gap: 8px; }
.invite-row .form-input { margin-bottom: 0; }
.invite-row-remove {
background: none;
border: none;
padding: 6px;
cursor: pointer;
color: var(--text-muted);
border-radius: 5px;
display: flex;
align-items: center;
transition: color 0.1s, background 0.1s;
flex-shrink: 0;
}
.invite-row-remove:hover { color: var(--error, #f87171); background: rgba(248,113,113,0.08); }
/* Members */
.member-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border-subtle);
}
.member-row:last-child { border-bottom: none; }
.member-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--bg-muted);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
flex-shrink: 0;
}
.member-info { flex: 1; }
.member-name { font-size: 13.5px; color: var(--text-primary); font-weight: 500; }
.member-email { font-size: 12px; color: var(--text-muted); }
.member-last-active { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
/* Danger zone */
.danger-zone-action {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px 24px;
border-bottom: 1px solid var(--border-subtle);
}
.danger-zone-action:last-child { border-bottom: none; }
.danger-zone-text { flex: 1; }
.danger-zone-title { font-size: 13.5px; font-weight: 500; color: var(--text-primary); margin-bottom: 3px; }
.danger-zone-desc { font-size: 12.5px; color: var(--text-muted); line-height: 1.5; }
/* Lang switcher */
.lang-switcher { display: flex; gap: 6px; margin-top: 6px; }
.lang-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-canvas);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.lang-btn.active {
background: rgba(13,148,136,0.1);
border-color: rgba(13,148,136,0.35);
color: var(--accent);
}
.lang-btn:hover:not(.active) { background: rgba(255,255,255,0.04); color: var(--text-primary); }
.lang-flag { font-size: 14px; }
/* Actions row */
.ws-setup-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-top: 24px;
}
.ws-setup-footer-note {
font-size: 12px;
color: var(--text-muted);
text-align: center;
margin-top: 20px;
line-height: 1.5;
}
/* Roles reference */
.role-def-row {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 11px 0;
border-bottom: 1px solid var(--border-subtle);
}
.role-def-row:last-child { border-bottom: none; }
.role-def-text { font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
+58
View File
@@ -0,0 +1,58 @@
[
{
"id": "ag-001",
"slug": "customer-support",
"display_name": "Customer Support",
"description": "Handles ticket creation, status lookups, and customer data retrieval. Powers the support team's Claude integration.",
"status": "active",
"operation_count": 5,
"operation_ids": ["op_01", "op_02", "op_07", "op_09", "op_10"],
"key_count": 2,
"created_at": "2024-11-15T10:22:00Z",
"created_by": "AT",
"last_called_at": "2025-03-28T18:44:00Z",
"calls_today": 312
},
{
"id": "ag-002",
"slug": "sales-pipeline",
"display_name": "Sales Pipeline",
"description": "CRM operations for the sales team: lead creation, deal updates, contact lookup, and company enrichment.",
"status": "active",
"operation_count": 5,
"operation_ids": ["op_01", "op_06", "op_08", "op_11", "op_12"],
"key_count": 1,
"created_at": "2024-11-28T14:10:00Z",
"created_by": "AT",
"last_called_at": "2025-03-29T09:01:00Z",
"calls_today": 541
},
{
"id": "ag-003",
"slug": "internal-devops",
"display_name": "Internal DevOps",
"description": "Infrastructure tooling: deployment triggers, service health checks, and incident management workflows.",
"status": "draft",
"operation_count": 3,
"operation_ids": ["op_04", "op_05", "op_11"],
"key_count": 0,
"created_at": "2025-01-08T09:45:00Z",
"created_by": "AT",
"last_called_at": null,
"calls_today": 0
},
{
"id": "ag-004",
"slug": "product-analytics",
"display_name": "Product Analytics",
"description": "Queries usage metrics, feature adoption data, and user behaviour reports from the analytics stack.",
"status": "active",
"operation_count": 4,
"operation_ids": ["op_02", "op_03", "op_08", "op_12"],
"key_count": 3,
"created_at": "2025-02-01T11:30:00Z",
"created_by": "AT",
"last_called_at": "2025-03-29T11:22:00Z",
"calls_today": 87
}
]
+15
View File
@@ -0,0 +1,15 @@
{
"acme-workspace": [
{ "id": "key_01", "name": "Production", "prefix": "mcp_prod_4f8a…", "scopes": ["read","write","deploy"], "created": "2025-11-15", "lastUsed": "Today", "status": "active" },
{ "id": "key_02", "name": "CI / CD pipeline", "prefix": "mcp_ci_b21c…", "scopes": ["read","deploy"], "created": "2025-12-01", "lastUsed": "2 days ago", "status": "active" },
{ "id": "key_03", "name": "Dev local", "prefix": "mcp_dev_7e3f…", "scopes": ["read","write"], "created": "2026-01-08", "lastUsed": "Never", "status": "active" },
{ "id": "key_04", "name": "Legacy integration", "prefix": "mcp_leg_c90d…", "scopes": ["read"], "created": "2025-09-22", "lastUsed": "45 days ago", "status": "revoked" }
],
"startup-demo": [
{ "id": "key_11", "name": "Backend service", "prefix": "mcp_back_9a1b…", "scopes": ["read","write"], "created": "2026-01-20", "lastUsed": "Today", "status": "active" },
{ "id": "key_12", "name": "Analytics pipeline", "prefix": "mcp_anal_3c7d…", "scopes": ["read"], "created": "2026-02-05", "lastUsed": "3 days ago", "status": "active" }
],
"personal": [
{ "id": "key_21", "name": "Local experiments", "prefix": "mcp_loc_f2e8…", "scopes": ["read","write","deploy"], "created": "2026-03-01", "lastUsed": "Yesterday", "status": "active" }
]
}
+134
View File
@@ -0,0 +1,134 @@
[
{
"id": "op_01",
"name": "create_crm_lead",
"display_name": "Create CRM Lead",
"protocol": "rest",
"method": "POST",
"status": "active",
"category": "CRM",
"target_url": "https://api.acme.com/v2/crm/leads",
"created_at": "2026-03-21"
},
{
"id": "op_02",
"name": "get_user_profile",
"display_name": "Get User Profile",
"protocol": "rest",
"method": "GET",
"status": "active",
"category": "Users",
"target_url": "https://api.acme.com/v1/users/{id}",
"created_at": "2026-03-18"
},
{
"id": "op_03",
"name": "search_knowledge_base",
"display_name": "Search Knowledge Base",
"protocol": "graphql",
"method": null,
"status": "active",
"category": "Search",
"target_url": "https://graph.notion-int.acme.com/graphql",
"created_at": "2026-03-14"
},
{
"id": "op_04",
"name": "send_slack_message",
"display_name": "Send Slack Message",
"protocol": "rest",
"method": "POST",
"status": "error",
"category": "Notifications",
"target_url": "https://slack.com/api/chat.postMessage",
"created_at": "2026-03-10"
},
{
"id": "op_05",
"name": "stream_telemetry",
"display_name": "Stream Telemetry Events",
"protocol": "grpc",
"method": null,
"status": "draft",
"category": "Observability",
"target_url": "grpc://telemetry.internal.acme.com:9090",
"created_at": "2026-03-05"
},
{
"id": "op_06",
"name": "update_deal_stage",
"display_name": "Update Deal Stage",
"protocol": "rest",
"method": "PATCH",
"status": "inactive",
"category": "CRM",
"target_url": "https://api.acme.com/v2/crm/deals/{id}",
"created_at": "2026-02-28"
},
{
"id": "op_07",
"name": "fetch_invoice",
"display_name": "Fetch Invoice",
"protocol": "rest",
"method": "GET",
"status": "active",
"category": "Billing",
"target_url": "https://billing.acme.com/v1/invoices/{id}",
"created_at": "2026-02-20"
},
{
"id": "op_08",
"name": "list_products",
"display_name": "List Products",
"protocol": "graphql",
"method": null,
"status": "active",
"category": "Catalog",
"target_url": "https://shop.acme.com/graphql",
"created_at": "2026-02-15"
},
{
"id": "op_09",
"name": "create_support_ticket",
"display_name": "Create Support Ticket",
"protocol": "rest",
"method": "POST",
"status": "active",
"category": "Support",
"target_url": "https://support.acme.com/api/tickets",
"created_at": "2026-02-10"
},
{
"id": "op_10",
"name": "get_order_status",
"display_name": "Get Order Status",
"protocol": "rest",
"method": "GET",
"status": "draft",
"category": "Orders",
"target_url": "https://orders.acme.com/v2/status/{id}",
"created_at": "2026-02-05"
},
{
"id": "op_11",
"name": "sync_contacts",
"display_name": "Sync Contacts",
"protocol": "grpc",
"method": null,
"status": "active",
"category": "CRM",
"target_url": "grpc://contacts.internal.acme.com:9091",
"created_at": "2026-01-28"
},
{
"id": "op_12",
"name": "send_email_campaign",
"display_name": "Send Email Campaign",
"protocol": "rest",
"method": "POST",
"status": "active",
"category": "Marketing",
"target_url": "https://mail.acme.com/api/v3/campaigns/send",
"created_at": "2026-01-20"
}
]
+380
View File
@@ -0,0 +1,380 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="../">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Agents</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/catalog.css">
<link rel="stylesheet" href="css/wizard.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/workspace.js"></script>
</head>
<body x-data="agents">
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
<nav class="navbar">
<a href="index.html" class="nav-logo">
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
<span class="nav-logo-text">Crank</span>
</a>
<!-- Workspace switcher -->
<div class="ws-switcher" id="ws-switcher">
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
<div class="ws-dot" id="ws-dot">A</div>
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button>
<div class="ws-dropdown" id="ws-dropdown" style="display:none">
<div class="ws-dropdown-label">Your workspaces</div>
<div id="ws-dropdown-list"></div>
<div class="ws-dropdown-footer">
<button class="ws-dropdown-create" onclick="window.location.href='workspace-setup.html?mode=create'">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
Create workspace
</button>
</div>
</div>
</div>
<div class="nav-links">
<a href="index.html" class="nav-link" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="nav-link active" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="nav-right">
<button class="nav-hamburger" aria-label="Menu"><span></span><span></span><span></span></button>
<div class="nav-kbd">
<svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg>
<span>⌘K</span>
</div>
<button class="nav-icon-btn" title="Notifications">
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
</button>
<div class="nav-divider"></div>
<div class="user-menu" @click.stop>
<div class="nav-avatar" @click="openDropdown = openDropdown === 'user' ? null : 'user'" title="Account">AT</div>
<div class="user-dropdown" x-show="openDropdown === 'user'" x-transition style="display:none">
<div class="user-dropdown-header">
<div class="user-dropdown-name">Operator</div>
<div class="user-dropdown-role" id="user-ws-role">Admin · acme-workspace</div>
</div>
<button class="user-dropdown-item" onclick="window.location.href='settings.html'">
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
<span data-i18n="nav.settings">Settings</span>
</button>
<div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" onclick="localStorage.removeItem('crank_user'); window.location.href='login.html'">
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
<span data-i18n="nav.logout">Log out</span>
</button>
</div>
</div>
</div>
</nav>
<div class="mobile-nav">
<a href="index.html" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="mobile-nav-link active" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
</div>
<!-- ═══════════════════ PAGE ═══════════════════ -->
<div class="page">
<!-- Page header -->
<div class="page-header">
<div>
<h1 class="page-heading">Agents</h1>
<p class="page-subheading">Named MCP endpoints that expose a curated subset of operations to an LLM</p>
</div>
<button class="btn-new" @click="openCreate()">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
New agent
</button>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Total agents</div>
<div class="stat-value" x-text="agents.length"></div>
<div class="stat-delta">across this workspace</div>
</div>
<div class="stat-card">
<div class="stat-label">Active</div>
<div class="stat-value" x-text="activeCount"></div>
<div class="stat-delta" x-text="agents.length ? Math.round(activeCount/agents.length*100)+'% of total' : ''"></div>
</div>
<div class="stat-card">
<div class="stat-label">Operations exposed</div>
<div class="stat-value" x-text="totalOpsExposed"></div>
<div class="stat-delta" x-text="'across ' + agents.length + ' agents'"></div>
</div>
<div class="stat-card">
<div class="stat-label">Calls today</div>
<div class="stat-value" x-text="formatCalls(totalCalls)"></div>
<div class="stat-delta up">all agents combined</div>
</div>
</div>
<!-- Search + filter bar -->
<div class="filter-bar" style="margin-bottom:0;">
<div class="search-box">
<svg width="14" height="14"><use href="icons/general/search.svg#icon"/></svg>
<input
type="text"
placeholder="Search agents..."
:value="agentSearch"
@input="agentSearch = $event.target.value"
>
</div>
<div class="filter-right">
<span class="results-count" x-show="agentSearch">
<span x-text="filteredAgents.length"></span> of <span x-text="agents.length"></span> agents
</span>
</div>
</div>
<!-- Info callout -->
<div class="agents-info-callout">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="7"/><path d="M8 11V8M8 5v-.5"/></svg>
<span>Each agent gets its own MCP endpoint. Connect your LLM client to a specific agent so it only sees the tools it needs — not all <strong x-text="operations.length">?</strong> operations in this workspace.</span>
</div>
<!-- Loading -->
<div class="loading-spinner" x-show="loading">
<div class="spinner"></div>
<span>Loading agents…</span>
</div>
<!-- Empty state: no agents at all -->
<div class="empty-state" x-show="!loading && agents.length === 0">
<div class="empty-state-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.35"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 17.5h7M17.5 14v7"/></svg>
</div>
<div class="empty-state-title">No agents yet</div>
<div class="empty-state-sub">Create your first agent to get a dedicated MCP endpoint with a curated set of tools.</div>
<button class="btn-new" @click="openCreate()" style="margin-top: 16px;">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
New agent
</button>
</div>
<!-- Empty state: search returned nothing -->
<div class="empty-state" x-show="!loading && agents.length > 0 && filteredAgents.length === 0">
<div class="empty-state-title">No agents match "<span x-text="agentSearch"></span>"</div>
<div class="empty-state-sub">Try a different search term.</div>
</div>
<!-- Agents grid -->
<div class="agents-grid" x-show="!loading && filteredAgents.length > 0">
<template x-for="agent in filteredAgents" :key="agent.id">
<div class="agent-card">
<!-- Card header -->
<div class="agent-card-header">
<div class="agent-card-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 17.5h7M17.5 14v7"/></svg>
</div>
<div class="agent-card-title-wrap">
<div class="agent-card-name" x-text="agent.display_name"></div>
<div class="agent-card-slug" x-text="agent.slug"></div>
</div>
<span :class="statusClass(agent.status)" x-text="agent.status.charAt(0).toUpperCase() + agent.status.slice(1)"></span>
</div>
<!-- Description -->
<div class="agent-card-desc" x-text="agent.description"></div>
<!-- Stats row -->
<div class="agent-card-stats">
<div class="agent-stat">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 5h12M2 8h8M2 11h5"/></svg>
<span class="agent-stat-value" x-text="agent.operation_count"></span>
<span>tools</span>
</div>
<div class="agent-stat">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 2H4a1 1 0 00-1 1v3M7 2l7 7M7 2v5h5"/><path d="M3 9v4a1 1 0 001 1h8a1 1 0 001-1V9"/></svg>
<span class="agent-stat-value" x-text="agent.key_count"></span>
<span>API keys</span>
</div>
<div class="agent-stat" x-show="agent.calls_today > 0">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 8s3-5 7-5 7 5 7 5-3 5-7 5-7-5-7-5z"/><circle cx="8" cy="8" r="2"/></svg>
<span class="agent-stat-value" x-text="formatCalls(agent.calls_today)"></span>
<span>calls today</span>
</div>
</div>
<!-- MCP endpoint -->
<div class="agent-endpoint-row">
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8h12M10 4l4 4-4 4"/></svg>
<code class="agent-endpoint-url" x-text="mcpEndpoint(agent)"></code>
<button class="agent-endpoint-copy" @click.stop="copyEndpoint(agent)" title="Copy endpoint">
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="9" height="9" rx="1"/><path d="M5 5V3a1 1 0 011-1h7a1 1 0 011 1v7a1 1 0 01-1 1h-2"/></svg>
</button>
</div>
<!-- Footer -->
<div class="agent-card-footer">
<span class="agent-card-date" x-text="'Created ' + formatDate(agent.created_at)"></span>
<div class="agent-card-actions">
<button class="agent-action-btn" @click.stop="openEdit(agent)">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 2l3 3-8 8H3v-3l8-8z"/></svg>
Edit
</button>
<button class="agent-action-btn danger" @click.stop="deleteAgent(agent.id)">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
Delete
</button>
</div>
</div>
</div>
</template>
</div>
</div><!-- /page -->
<!-- ═══════════════════ DRAWER ═══════════════════ -->
<!-- Overlay -->
<div class="drawer-overlay" x-show="drawerOpen" x-transition:enter="transition-opacity" @click="closeDrawer()" style="display:none"></div>
<!-- Drawer panel -->
<div class="drawer" :class="{ open: drawerOpen }" style="display:none" x-show="drawerOpen">
<!-- Drawer header -->
<div class="drawer-header">
<div>
<div class="drawer-title" x-text="drawerMode === 'create' ? 'New agent' : 'Edit agent'"></div>
<div class="drawer-subtitle" x-text="drawerMode === 'create' ? 'Configure a named MCP endpoint for your LLM' : 'Update agent settings and tool selection'"></div>
</div>
<button class="drawer-close" @click="closeDrawer()">
<svg width="14" height="14" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
</button>
</div>
<!-- Drawer body -->
<div class="drawer-body">
<!-- Identity section -->
<div class="drawer-section">
<div class="drawer-section-title">Identity</div>
<div class="form-group" style="margin-bottom: 14px;">
<label class="form-label">Display name <span class="form-label-required">required</span></label>
<input class="form-input" type="text" placeholder="Customer Support" autocomplete="off"
:value="form.display_name" @input="onNameInput($event.target.value)">
</div>
<div class="form-group" style="margin-bottom: 14px;">
<label class="form-label">Slug <span class="form-label-required">required</span></label>
<input class="form-input input-mono" type="text" placeholder="customer-support" autocomplete="off"
:value="form.slug" @input="onSlugInput($event.target.value)">
<div class="form-hint" x-show="form.slug">
MCP endpoint: <code style="font-family:monospace;font-size:11px;color:var(--accent)" x-text="'/mcp/v1/' + (localStorage.getItem('crank_workspace') || 'acme-workspace') + '/' + form.slug"></code>
</div>
</div>
<div class="form-group" style="margin-bottom: 14px;">
<label class="form-label">Description <span class="form-label-optional">(optional)</span></label>
<textarea class="form-textarea" rows="3" placeholder="What does this agent do? What LLM or use-case is it for?"
x-model="form.description"></textarea>
</div>
<div class="form-group">
<label class="form-label">Status</label>
<div class="agent-status-toggle">
<button class="agent-status-opt" :class="{ active: form.status === 'active' }" @click="form.status = 'active'">
<span class="status-dot" style="background: var(--success, #3fb950)"></span> Active
</button>
<button class="agent-status-opt" :class="{ active: form.status === 'draft' }" @click="form.status = 'draft'">
<span class="status-dot" style="background: var(--text-muted)"></span> Draft
</button>
</div>
</div>
</div>
<!-- Operations picker section -->
<div class="drawer-section">
<div class="drawer-section-header">
<div class="drawer-section-title">Operations</div>
<span class="drawer-section-count" x-text="form.selectedOps.length + ' selected'"></span>
</div>
<div class="drawer-section-sub">Select which operations this agent exposes. LLMs connecting to this agent will only see these tools.</div>
<div class="ops-picker">
<!-- Search -->
<div class="ops-picker-search">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round"><circle cx="7" cy="7" r="5"/><path d="M12 12l2.5 2.5"/></svg>
<input class="ops-picker-search-input" type="text" placeholder="Filter operations…" x-model="opSearch" autocomplete="off" spellcheck="false">
<button class="ops-picker-clear" x-show="opSearch" @click="opSearch = ''" style="display:none">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
</button>
</div>
<!-- List -->
<div class="ops-picker-list">
<template x-for="op in filteredOps" :key="op.id || op.name">
<div class="ops-picker-item" :class="{ selected: isOpSelected(op.id || op.name) }" @click="toggleOp(op.id || op.name)">
<div class="ops-picker-check">
<svg x-show="isOpSelected(op.id || op.name)" width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
</div>
<div class="ops-picker-info">
<div class="ops-picker-name" x-text="op.display_name || op.name"></div>
<div class="ops-picker-slug" x-text="op.name"></div>
</div>
<span :class="protocolBadge(op)" x-text="protocolLabel(op)" style="font-size:10px; padding: 2px 6px;"></span>
<div class="ops-picker-status-dot" :class="'ops-status-' + op.status"></div>
</div>
</template>
<div class="ops-picker-empty" x-show="filteredOps.length === 0">
No operations match "<span x-text="opSearch"></span>"
</div>
</div>
<!-- Footer count -->
<div class="ops-picker-footer" x-show="form.selectedOps.length > 0">
<span x-text="form.selectedOps.length"></span> operation<span x-show="form.selectedOps.length !== 1">s</span> selected
<button @click="form.selectedOps = []" style="background:none;border:none;color:var(--text-muted);font-size:12px;cursor:pointer;margin-left:8px;">Clear all</button>
</div>
</div>
<!-- Recommendation callout -->
<div class="agents-rec-callout" x-show="form.selectedOps.length > 15">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polygon points="8,1 15,14 1,14" fill="none"/><path d="M8 6v4M8 11.5v.5"/></svg>
<span>You've selected <strong x-text="form.selectedOps.length"></strong> tools. LLMs perform best with &lt;15 tools per agent. Consider splitting into separate agents by use-case.</span>
</div>
</div>
</div><!-- /drawer-body -->
<!-- Drawer footer -->
<div class="drawer-footer">
<button class="btn-ghost-sm" style="padding: 8px 16px; font-size: 13px;" @click="closeDrawer()">Cancel</button>
<button class="btn-primary-sm" style="padding: 8px 20px; font-size: 13px;"
:disabled="!form.display_name.trim() || !form.slug.trim()"
@click="saveAgent()"
x-text="drawerMode === 'create' ? 'Create agent' : 'Save changes'">
Create agent
</button>
</div>
</div><!-- /drawer -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="js/agents.js"></script>
</body>
</html>
+281
View File
@@ -0,0 +1,281 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="../">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — API Keys</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css">
<style>
.scope-checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 9px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-canvas);
}
.scope-checkbox-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.scope-checkbox-desc { font-size: 11.5px; color: var(--text-muted); }
</style>
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/workspace.js"></script>
</head>
<body>
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
<nav class="navbar">
<a href="index.html" class="nav-logo">
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
<span class="nav-logo-text">Crank</span>
</a>
<!-- Workspace switcher -->
<div class="ws-switcher" id="ws-switcher">
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
<div class="ws-dot" id="ws-dot">A</div>
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button>
<div class="ws-dropdown" id="ws-dropdown" style="display:none">
<div class="ws-dropdown-label">Your workspaces</div>
<div id="ws-dropdown-list"></div>
<div class="ws-dropdown-footer">
<button class="ws-dropdown-create" onclick="window.location.href='workspace-setup.html?mode=create'">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
Create workspace
</button>
</div>
</div>
</div>
<div class="nav-links">
<a href="index.html" class="nav-link" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="nav-link" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="nav-link active" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="nav-right">
<button class="nav-hamburger" aria-label="Menu"><span></span><span></span><span></span></button>
<div class="nav-kbd">
<svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg>
<span>⌘K</span>
</div>
<button class="nav-icon-btn" title="Notifications">
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
</button>
<div class="nav-divider"></div>
<div class="user-menu">
<div class="nav-avatar" title="Account">AT</div>
<div class="user-dropdown">
<div class="user-dropdown-header">
<div class="user-dropdown-name">Operator</div>
<div class="user-dropdown-role">Admin · acme-workspace</div>
</div>
<button class="user-dropdown-item" data-action="settings-link">
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
<span data-i18n="nav.settings">Settings</span>
</button>
<div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" data-action="logout">
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
<span data-i18n="nav.logout">Log out</span>
</button>
</div>
</div>
</div>
</nav>
<div class="mobile-nav">
<a href="index.html" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="mobile-nav-link active" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
</div>
<!-- ═══════════════════ PAGE ═══════════════════ -->
<div class="page">
<div class="page-header">
<div class="page-header-text">
<h1 class="page-title" data-i18n="apikeys.title">API Keys</h1>
<p class="page-subtitle">Keys authenticate external clients and CI pipelines to the Crank API.</p>
</div>
<div class="page-header-actions">
<button class="btn-primary" id="btn-create-key" type="button">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
New key
</button>
</div>
</div>
<div class="callout info">
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--blue)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="6.5"/>
<path d="M8 11V8M8 5.5V5"/>
</svg>
<div>
<strong>Keys are only shown once.</strong> Copy and store them securely immediately after creation — Crank stores only a hash. Revoked keys cannot be restored.
</div>
</div>
<div class="section-card">
<div class="section-card-header">
<div>
<div class="section-card-title">Active keys</div>
<div class="section-card-subtitle">3 active · 1 revoked</div>
</div>
<div class="filter-bar-search" style="max-width: 220px; margin: 0;">
<svg width="13" height="13" style="position:absolute; left:9px; top:50%; transform:translateY(-50%); color:var(--text-muted);" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215zM11.5 7a4.499 4.499 0 11-8.997 0A4.499 4.499 0 0111.5 7z"/></svg>
<input type="text" id="key-search" placeholder="Filter keys…" autocomplete="off">
</div>
</div>
<div id="keys-table-wrap">
<table class="data-table" id="keys-table">
<thead>
<tr>
<th>Name</th>
<th>Key prefix</th>
<th>Scopes</th>
<th>Created</th>
<th>Last used</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="keys-tbody">
</tbody>
</table>
</div>
</div>
<div class="section-card">
<div class="section-card-header">
<div class="section-card-title">Scope reference</div>
</div>
<div class="section-card-body" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
<div>
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;display:flex;align-items:center;gap:6px;">
<span class="badge badge-scope">read</span>
</div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;">List operations, read configs, query logs and usage metrics.</div>
</div>
<div>
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
<span class="badge badge-scope">write</span>
</div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;">Create, update, and delete operations and their configurations.</div>
</div>
<div>
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
<span class="badge badge-scope">deploy</span>
</div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;">Publish draft operations, rollback deployments, and manage traffic splits.</div>
</div>
</div>
</div>
</div><!-- /page -->
<!-- ══ Create Key Modal ══ -->
<div class="modal-overlay" id="modal-create">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Create API key</span>
<button class="modal-close" id="modal-close-btn" type="button">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
</svg>
</button>
</div>
<div class="modal-body" id="modal-form-body">
<div class="field-group">
<label class="field-label">Key name</label>
<input class="field-input" id="new-key-name" type="text" placeholder="e.g. Production, CI pipeline" autocomplete="off">
<div class="field-hint">A descriptive label to identify the key. Only visible to admins.</div>
</div>
<div class="field-group" style="margin-bottom:0;">
<label class="field-label">Scopes</label>
<div style="display:flex;flex-direction:column;gap:8px;margin-top:2px;" id="scope-checkboxes">
</div>
</div>
</div>
<div class="modal-body" id="modal-reveal-body" style="display:none;">
<div class="callout warning" style="margin-bottom:16px;">
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--amber)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
</svg>
<div><strong>Copy this key now.</strong> It won't be shown again.</div>
</div>
<div class="key-reveal">
<span class="key-mask" id="reveal-key-value">mcp_prod_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</span>
<button class="key-copy" id="copy-key-btn" type="button" title="Copy to clipboard">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="5" width="9" height="9" rx="1.5"/>
<path d="M11 5V3.5A1.5 1.5 0 009.5 2H3.5A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5"/>
</svg>
</button>
</div>
</div>
<div class="modal-footer" id="modal-footer-create">
<button class="btn-secondary" id="modal-cancel-btn" type="button">Cancel</button>
<button class="btn-primary" id="modal-confirm-btn" type="button">Create key</button>
</div>
<div class="modal-footer" id="modal-footer-done" style="display:none;">
<button class="btn-primary" id="modal-done-btn" type="button">Done — I've copied the key</button>
</div>
</div>
</div>
<!-- Template: scope checkbox row -->
<template id="tmpl-scope-checkbox">
<label class="scope-checkbox-label">
<input type="checkbox" data-scope="" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;flex-shrink:0;">
<div>
<div class="scope-checkbox-name"></div>
<div class="scope-checkbox-desc"></div>
</div>
</label>
</template>
<!-- Template: API key table row -->
<template id="tmpl-key-row">
<tr>
<td class="col-name"></td>
<td class="col-mono"></td>
<td><div class="col-scopes" style="display:flex;gap:4px;flex-wrap:wrap;"></div></td>
<td class="col-created"></td>
<td class="col-last-used"></td>
<td class="col-status"></td>
<td class="col-actions">
<span class="actions-active">
<button class="btn-icon" title="Copy key prefix"><svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M11 5V3.5A1.5 1.5 0 009.5 2H3.5A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5"/></svg></button>
<button class="btn-icon danger" title="Revoke key"><svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.5"/><path d="M5.5 10.5l5-5M10.5 10.5l-5-5"/></svg></button>
</span>
<span class="actions-revoked" style="display:none;">
<button class="btn-icon" title="Delete"><svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2h4M2.5 4h11M5 4l.5 9h5l.5-9"/></svg></button>
</span>
</td>
</tr>
</template>
<script src="js/nav.js"></script>
<script src="js/api-keys.js"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<div class="field-group" style="margin-top:8px;">
<label class="field-label">Interface language</label>
<div class="lang-switcher" style="display:flex;gap:6px;margin-top:6px;">
<button class="lang-btn" data-lang="en" onclick="setLang('en')">
<span class="lang-flag">🇬🇧</span>
<span data-i18n="settings.lang.en">English</span>
</button>
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">
<span class="lang-flag">🇷🇺</span>
<span data-i18n="settings.lang.ru">Русский</span>
</button>
</div>
</div>
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="../">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Sign in</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/login.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
</head>
<body>
<div class="login-page">
<div class="login-card">
<div class="login-logo">
<div class="login-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="22" height="22" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
<span class="login-logo-text">Crank</span>
</div>
<div class="login-box">
<div class="login-heading">Sign in</div>
<div class="login-sub">Welcome back to your workspace</div>
<div class="login-error" id="login-error">
Invalid email or password. Please try again.
</div>
<form id="login-form" novalidate>
<div class="field">
<label class="field-label" for="email">Email address</label>
<input class="field-input" type="email" id="email" placeholder="you@acme.com" autocomplete="email" autofocus>
</div>
<div class="field">
<label class="field-label" for="password">Password</label>
<input class="field-input" type="password" id="password" placeholder="••••••••" autocomplete="current-password">
<div class="field-footer">
<a href="#" class="field-link">Forgot password?</a>
</div>
</div>
<button class="btn-signin" type="submit">Sign in</button>
</form>
<div class="login-divider">
<div class="login-divider-line"></div>
<span class="login-divider-text">or continue with</span>
<div class="login-divider-line"></div>
</div>
<button class="btn-sso" type="button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
Google SSO
</button>
</div>
<div class="login-footer">
Don't have an account? <a href="#">Request access</a>
</div>
<div class="version-badge">Crank v0.9.0-beta</div>
</div>
</div>
<script src="js/login.js"></script>
</body>
</html>
+148
View File
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="../">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Logs</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/logs.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/workspace.js"></script>
</head>
<body>
<nav class="navbar">
<a href="index.html" class="nav-logo">
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
<span class="nav-logo-text">Crank</span>
</a>
<!-- Workspace switcher -->
<div class="ws-switcher" id="ws-switcher">
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
<div class="ws-dot" id="ws-dot">A</div>
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button>
<div class="ws-dropdown" id="ws-dropdown" style="display:none">
<div class="ws-dropdown-label">Your workspaces</div>
<div id="ws-dropdown-list"></div>
<div class="ws-dropdown-footer">
<button class="ws-dropdown-create" onclick="window.location.href='workspace-setup.html?mode=create'">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
Create workspace
</button>
</div>
</div>
</div>
<div class="nav-links">
<a href="index.html" class="nav-link" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="nav-link" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="nav-link active" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="nav-right">
<button class="nav-hamburger" aria-label="Menu"><span></span><span></span><span></span></button>
<div class="nav-kbd">
<svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg>
<span>⌘K</span>
</div>
<button class="nav-icon-btn" title="Notifications">
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
</button>
<div class="nav-divider"></div>
<div class="user-menu">
<div class="nav-avatar" title="Account">AT</div>
<div class="user-dropdown">
<div class="user-dropdown-header">
<div class="user-dropdown-name">Operator</div>
<div class="user-dropdown-role">Admin · acme-workspace</div>
</div>
<button class="user-dropdown-item" data-action="settings-link">
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
<span data-i18n="nav.settings">Settings</span>
</button>
<div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" data-action="logout">
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
<span data-i18n="nav.logout">Log out</span>
</button>
</div>
</div>
</div>
</nav>
<div class="mobile-nav">
<a href="index.html" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="mobile-nav-link active" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="page">
<div class="page-header">
<div class="page-header-text">
<h1 class="page-title" data-i18n="logs.title">Logs</h1>
<p class="page-subtitle">Real-time invocation log for all operations in this workspace.</p>
</div>
</div>
<div class="section-card">
<div class="log-toolbar">
<div class="live-dot"></div>
<span class="live-label">Live</span>
<div class="toolbar-sep"></div>
<select class="time-range-select" id="time-range">
<option value="30m">Last 30 min</option>
<option value="1h" selected>Last 1 hour</option>
<option value="6h">Last 6 hours</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
</select>
<div class="toolbar-sep"></div>
<button class="filter-chip active" data-level="all" id="chip-all">All</button>
<button class="filter-chip" data-level="info" id="chip-info">
<span class="log-level info" style="padding:0 4px;font-size:10px;">INFO</span>
</button>
<button class="filter-chip" data-level="warn" id="chip-warn">
<span class="log-level warn" style="padding:0 4px;font-size:10px;">WARN</span>
</button>
<button class="filter-chip" data-level="error" id="chip-error">
<span class="log-level error" style="padding:0 4px;font-size:10px;">ERROR</span>
</button>
<div class="filter-bar-search" style="max-width:240px;margin:0;">
<svg width="13" height="13" style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-muted);" viewBox="0 0 16 16" fill="currentColor"><path d="M10.68 11.74a6 6 0 01-7.922-8.982 6 6 0 018.982 7.922l3.04 3.04a.749.749 0 01-.326 1.275.749.749 0 01-.734-.215zM11.5 7a4.499 4.499 0 11-8.997 0A4.499 4.499 0 0111.5 7z"/></svg>
<input type="text" id="log-search" placeholder="Search messages…" autocomplete="off">
</div>
<button class="refresh-btn" id="refresh-btn" type="button">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 01.834.656 5.5 5.5 0 009.592 2.97l-1.204-1.204a.25.25 0 01.177-.427h3.646a.25.25 0 01.25.25v3.646a.25.25 0 01-.427.177l-1.38-1.38A7.001 7.001 0 011.05 8.84a.75.75 0 01.656-.834zM8 2.5a5.487 5.487 0 00-4.131 1.869l1.204 1.204A.25.25 0 014.896 6H1.25A.25.25 0 011 5.75V2.104a.25.25 0 01.427-.177l1.38 1.38A7.001 7.001 0 0114.95 7.16a.75.75 0 01-1.49.178A5.501 5.501 0 008 2.5z"/></svg>
Refresh
</button>
</div>
<div class="log-list" id="log-list">
</div>
</div>
</div>
<script src="js/nav.js"></script>
<script src="js/logs.js"></script>
</body>
</html>
+597
View File
@@ -0,0 +1,597 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="../">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Settings</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/settings.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/workspace.js"></script>
</head>
<body>
<nav class="navbar">
<a href="index.html" class="nav-logo">
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
<span class="nav-logo-text">Crank</span>
</a>
<!-- Workspace switcher -->
<div class="ws-switcher" id="ws-switcher">
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
<div class="ws-dot" id="ws-dot">A</div>
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button>
<div class="ws-dropdown" id="ws-dropdown" style="display:none">
<div class="ws-dropdown-label">Your workspaces</div>
<div id="ws-dropdown-list"></div>
<div class="ws-dropdown-footer">
<button class="ws-dropdown-create" onclick="window.location.href='workspace-setup.html?mode=create'">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
Create workspace
</button>
</div>
</div>
</div>
<div class="nav-links">
<a href="index.html" class="nav-link" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="nav-link" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="nav-right">
<button class="nav-hamburger" aria-label="Menu"><span></span><span></span><span></span></button>
<div class="nav-kbd">
<svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg>
<span>⌘K</span>
</div>
<button class="nav-icon-btn" title="Notifications">
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
</button>
<div class="nav-divider"></div>
<div class="user-menu">
<div class="nav-avatar" title="Account">AT</div>
<div class="user-dropdown">
<div class="user-dropdown-header">
<div class="user-dropdown-name">Operator</div>
<div class="user-dropdown-role">Admin · acme-workspace</div>
</div>
<button class="user-dropdown-item" data-action="settings-link">
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
<span data-i18n="nav.settings">Settings</span>
</button>
<div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" data-action="logout">
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
<span data-i18n="nav.logout">Log out</span>
</button>
</div>
</div>
</div>
</nav>
<div class="mobile-nav">
<a href="index.html" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="page">
<div class="page-header" style="margin-bottom: 24px;">
<div class="page-header-text">
<h1 class="page-title">Account settings</h1>
<p class="page-subtitle">Manage your personal profile, security, and notification preferences.</p>
</div>
</div>
<div class="settings-layout">
<!-- ── Sidebar nav ── -->
<nav class="settings-nav" id="settings-nav">
<button class="settings-nav-item active" data-section="profile">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="5" r="3"/>
<path d="M1.5 14.5c0-3.314 2.91-6 6.5-6s6.5 2.686 6.5 6"/>
</svg>
<span data-i18n="settings.nav.profile">Profile</span>
</button>
<button class="settings-nav-item" data-section="security">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 1L2 3.5v4.5C2 11.3 4.7 14.4 8 15c3.3-.6 6-3.7 6-7V3.5L8 1z"/>
</svg>
<span data-i18n="settings.nav.security">Security</span>
</button>
<button class="settings-nav-item" data-section="notifications">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 1.5a5 5 0 015 5v2.5l1 2H2l1-2V6.5a5 5 0 015-5z"/>
<path d="M6.5 13a1.5 1.5 0 003 0"/>
</svg>
<span data-i18n="settings.nav.notif">Notifications</span>
</button>
</nav>
<!-- ── Content ── -->
<div id="settings-content">
<div id="section-workspace" style="display:none;">
<div class="section-card" style="margin-bottom: 20px;">
<div class="section-card-header">
<div>
<div class="section-card-title" data-i18n="settings.ws.title">Workspace settings</div>
<div class="section-card-subtitle" data-i18n="settings.ws.subtitle">General information about your workspace</div>
</div>
</div>
<div class="section-card-body" style="padding-bottom:8px;">
<div class="field-group">
<label class="field-label" data-i18n="settings.ws.name">Workspace name</label>
<input class="field-input" 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>
<div class="field-group">
<label class="field-label" data-i18n="settings.ws.display">Display name</label>
<input class="field-input" type="text" value="Acme Corp" autocomplete="off">
</div>
<div class="field-group">
<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>
</div>
<div class="field-row">
<div class="field-group">
<label class="field-label" data-i18n="settings.ws.tz">Timezone</label>
<select class="field-select">
<option selected>UTC</option>
<option>America/New_York</option>
<option>America/Los_Angeles</option>
<option>Europe/London</option>
<option>Europe/Paris</option>
<option>Asia/Tokyo</option>
</select>
</div>
<div class="field-group">
<label class="field-label" data-i18n="settings.ws.protocol">Default protocol</label>
<select class="field-select">
<option selected>REST / HTTP</option>
<option>GraphQL</option>
<option>gRPC</option>
</select>
</div>
</div>
<!-- Language -->
<div class="field-group" style="margin-top:4px;">
<label class="field-label">
<span data-i18n="settings.lang.title">Language</span>
</label>
<div class="lang-switcher">
<button class="lang-btn active" data-lang="en" onclick="setLang('en')">
<span class="lang-flag">🇬🇧</span>
<span data-i18n="settings.lang.en">English</span>
</button>
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">
<span class="lang-flag">🇷🇺</span>
<span data-i18n="settings.lang.ru">Русский</span>
</button>
</div>
<div class="field-hint" data-i18n="settings.lang.subtitle">Interface display language</div>
</div>
<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>
</div>
</div>
</div>
</div>
<!-- ■ PROFILE ■ -->
<div id="section-profile" class="section-anchor">
<div class="section-card" style="margin-bottom: 20px;">
<div class="section-card-header">
<div class="section-card-title" data-i18n="settings.profile.title">Profile</div>
</div>
<div class="section-card-body">
<div class="avatar-upload">
<div class="avatar-large" id="profile-avatar">AT</div>
<div class="avatar-upload-actions">
<div class="avatar-name" id="profile-display-name">Operator</div>
<div class="avatar-sub" id="profile-email">operator@acme-workspace</div>
<button class="btn-secondary" type="button" style="padding:5px 12px;font-size:12.5px;">Change photo</button>
</div>
</div>
<div class="field-hint" style="margin-bottom:16px;">Avatar will appear in the console and team views.</div>
<div class="field-row">
<div class="field-group">
<label class="field-label">First name</label>
<input class="field-input" id="field-firstname" type="text" value="Operator" autocomplete="given-name">
</div>
<div class="field-group">
<label class="field-label">Last name</label>
<input class="field-input" id="field-lastname" type="text" value="" autocomplete="family-name" placeholder="Optional">
</div>
</div>
<div class="field-group">
<label class="field-label">Email address</label>
<input class="field-input" id="field-email" type="email" value="operator@acme.com" autocomplete="email">
<div class="field-hint">Changing email requires re-verification. You'll receive a confirmation link.</div>
</div>
<div style="display:flex;justify-content:flex-end;">
<button class="btn-primary" type="button">Save profile</button>
</div>
</div>
</div>
</div>
<!-- ■ MEMBERS — moved to workspace-setup.html ■ -->
<div id="section-members" style="display:none;">
<!-- Members card -->
<div class="section-card" style="margin-bottom: 16px;">
<div class="section-card-header">
<div>
<div class="section-card-title">Team members</div>
<div class="section-card-subtitle" id="members-count-label">4 members</div>
</div>
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;" onclick="toggleInviteForm()">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" style="margin-right:4px;"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
Invite member
</button>
</div>
<!-- Invite form -->
<div id="invite-form" style="display:none; padding: 0 20px 16px; border-bottom: 1px solid var(--border-subtle);">
<div style="display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap;">
<div class="field-group" style="flex:1; min-width:200px; margin-bottom:0;">
<label class="field-label">Email address</label>
<input class="field-input" id="invite-email" type="email" placeholder="colleague@company.com" autocomplete="off">
</div>
<div class="field-group" style="width:150px; margin-bottom:0;">
<label class="field-label">Role</label>
<select class="field-input" id="invite-role">
<option value="admin">Admin</option>
<option value="developer" selected>Developer</option>
<option value="viewer">Viewer</option>
</select>
</div>
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;white-space:nowrap;" onclick="sendInvite()">Send invite</button>
<button class="btn-secondary" type="button" style="padding:7px 12px;font-size:13px;" onclick="toggleInviteForm()">Cancel</button>
</div>
<div id="invite-success" style="display:none; margin-top:10px; font-size:13px; color: #3fb950;">
✓ Invite sent successfully.
</div>
</div>
<!-- Members list -->
<div class="section-card-body" style="padding: 4px 20px 8px;" id="members-list">
<!-- Owner row (you) -->
<div class="member-row">
<div class="member-avatar" style="background:linear-gradient(135deg,#0d9488,#6366f1);">AT</div>
<div class="member-info">
<div class="member-name">Operator <span style="font-size:11px;font-weight:400;color:var(--text-muted);">(you)</span></div>
<div class="member-email">operator@acme.com</div>
</div>
<div class="member-last-active">Today</div>
<span class="member-role-badge role-owner">Owner</span>
</div>
<!-- Admin -->
<div class="member-row">
<div class="member-avatar" style="background:linear-gradient(135deg,#7c3aed,#4f46e5);">BS</div>
<div class="member-info">
<div class="member-name">Blake Smith</div>
<div class="member-email">blake@acme.com</div>
</div>
<div class="member-last-active">2 days ago</div>
<select class="member-role-select" onchange="updateRole(this, 'blake@acme.com')">
<option value="admin" selected>Admin</option>
<option value="developer">Developer</option>
<option value="viewer">Viewer</option>
</select>
<button class="member-remove-btn" onclick="removeMember(this, 'Blake Smith')" title="Remove member">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
</div>
<!-- Developer -->
<div class="member-row">
<div class="member-avatar" style="background:linear-gradient(135deg,#d29922,#f59e0b);">CL</div>
<div class="member-info">
<div class="member-name">Carol Lane</div>
<div class="member-email">carol@acme.com</div>
</div>
<div class="member-last-active">1 week ago</div>
<select class="member-role-select" onchange="updateRole(this, 'carol@acme.com')">
<option value="admin">Admin</option>
<option value="developer" selected>Developer</option>
<option value="viewer">Viewer</option>
</select>
<button class="member-remove-btn" onclick="removeMember(this, 'Carol Lane')" title="Remove member">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
</div>
<!-- Viewer -->
<div class="member-row">
<div class="member-avatar" style="background:linear-gradient(135deg,#0891b2,#06b6d4);">DM</div>
<div class="member-info">
<div class="member-name">Dan Morgan</div>
<div class="member-email">dan@acme.com</div>
</div>
<div class="member-last-active">Never</div>
<select class="member-role-select" onchange="updateRole(this, 'dan@acme.com')">
<option value="admin">Admin</option>
<option value="developer">Developer</option>
<option value="viewer" selected>Viewer</option>
</select>
<button class="member-remove-btn" onclick="removeMember(this, 'Dan Morgan')" title="Remove member">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
</div>
</div>
<!-- Pending invites -->
<div id="pending-invites" style="border-top: 1px solid var(--border-subtle); padding: 12px 20px 16px;">
<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;">1</span></div>
<div class="member-row" id="invite-john">
<div class="member-avatar" style="background:var(--bg-canvas);border:1.5px dashed var(--border);color:var(--text-muted);font-size:16px;">?</div>
<div class="member-info">
<div class="member-name">john@startup.com</div>
<div class="member-email">Invited 3 days ago · not yet accepted</div>
</div>
<div class="member-last-active"></div>
<span class="member-role-badge" style="background:rgba(139,148,158,0.12);color:var(--text-muted);border:1px solid var(--border);">Developer</span>
<button class="member-remove-btn" onclick="revokeInvite(this)" title="Revoke invite">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="2" x2="14" y2="14"/><line x1="14" y1="2" x2="2" y2="14"/></svg>
</button>
</div>
</div>
</div>
<!-- Roles & permissions reference -->
<div class="section-card" style="margin-bottom: 20px;">
<div class="section-card-header">
<div class="section-card-title">Roles &amp; permissions</div>
</div>
<div class="section-card-body" style="padding: 4px 0 8px; gap: 0;">
<div class="role-def-row">
<span class="member-role-badge role-owner" style="min-width:80px;text-align:center;">Owner</span>
<div class="role-def-text">Full workspace control, billing access, can transfer or delete the workspace. Only one owner per workspace.</div>
</div>
<div class="role-def-row">
<span class="member-role-badge role-admin" style="min-width:80px;text-align:center;">Admin</span>
<div class="role-def-text">Manage members and roles, create and edit all operations and agents, manage API keys.</div>
</div>
<div class="role-def-row">
<span class="member-role-badge role-developer" style="min-width:80px;text-align:center;">Developer</span>
<div class="role-def-text">Create and edit operations and agents. Cannot manage members or workspace settings.</div>
</div>
<div class="role-def-row">
<span class="member-role-badge role-viewer" style="min-width:80px;text-align:center;">Viewer</span>
<div class="role-def-text">Read-only access to operations, agents, and logs. Cannot create or modify anything.</div>
</div>
</div>
</div>
</div>
<script>
function toggleInviteForm() {
var f = document.getElementById('invite-form');
var s = document.getElementById('invite-success');
if (f.style.display === 'none') {
f.style.display = '';
s.style.display = 'none';
document.getElementById('invite-email').focus();
} else {
f.style.display = 'none';
}
}
function sendInvite() {
var email = document.getElementById('invite-email').value.trim();
if (!email || !email.includes('@')) return;
document.getElementById('invite-success').style.display = '';
document.getElementById('invite-email').value = '';
}
function updateRole(select, email) {
// In production: API call to update role
console.log('Role updated:', email, '->', select.value);
}
function removeMember(btn, name) {
if (!confirm('Remove ' + name + ' from this workspace?')) return;
btn.closest('.member-row').remove();
updateMembersCount();
}
function revokeInvite(btn) {
btn.closest('.member-row').remove();
var pending = document.getElementById('pending-invites');
if (pending && !pending.querySelector('.member-row')) {
pending.style.display = 'none';
}
}
function updateMembersCount() {
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' : '');
}
</script>
<!-- ■ SECURITY ■ -->
<div id="section-security" class="section-anchor">
<div class="section-card" style="margin-bottom: 20px;">
<div class="section-card-header">
<div class="section-card-title">Security</div>
</div>
<div class="section-card-body" style="padding-bottom:8px;">
<div class="field-group">
<label class="field-label">Current password</label>
<input class="field-input" type="password" placeholder="••••••••" autocomplete="current-password">
</div>
<div class="field-row">
<div class="field-group">
<label class="field-label">New password</label>
<input class="field-input" type="password" placeholder="min. 12 characters" autocomplete="new-password">
</div>
<div class="field-group">
<label class="field-label">Confirm new password</label>
<input class="field-input" type="password" placeholder="••••••••" autocomplete="new-password">
</div>
</div>
<div style="display:flex;justify-content:flex-end;padding-bottom:8px;">
<button class="btn-primary" type="button">Change password</button>
</div>
</div>
<div style="padding: 4px 20px 16px;">
<div class="settings-section-divider" style="margin-top:0;"></div>
<div style="font-size:13.5px;font-weight:600;color:var(--text-primary);margin-bottom:12px;">Two-factor authentication</div>
<div class="toggle-setting-row">
<div class="toggle-setting-text">
<div class="toggle-setting-label">TOTP authenticator app</div>
<div class="toggle-setting-desc">Use Google Authenticator, 1Password, or any TOTP-compatible app.</div>
</div>
<span class="badge badge-active" style="margin-right:8px;">Enabled</span>
<button class="btn-secondary" type="button" style="padding:5px 12px;font-size:12.5px;">Configure</button>
</div>
<div class="toggle-setting-row">
<div class="toggle-setting-text">
<div class="toggle-setting-label">Hardware security key</div>
<div class="toggle-setting-desc">FIDO2 / WebAuthn passkey or YubiKey.</div>
</div>
<button class="btn-secondary" type="button" style="padding:5px 12px;font-size:12.5px;">Add key</button>
</div>
</div>
</div>
<div class="section-card" style="margin-bottom: 20px;">
<div class="section-card-header">
<div class="section-card-title">Active sessions</div>
</div>
<div style="padding: 8px 20px 16px;">
<div class="member-row">
<div class="member-info">
<div class="member-name">Chrome · macOS — <span style="color:var(--green);font-size:12px;">current</span></div>
<div class="member-email">Last active: now · 93.184.216.34 (US)</div>
</div>
</div>
<div class="member-row">
<div class="member-info">
<div class="member-name">Safari · iPhone</div>
<div class="member-email">Last active: 2 days ago · 93.184.216.34 (US)</div>
</div>
<button class="btn-icon danger" title="Revoke session">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
</button>
</div>
<div class="member-row">
<div class="member-info">
<div class="member-name">Firefox · Linux</div>
<div class="member-email">Last active: 8 days ago · 172.16.0.4 (Internal)</div>
</div>
<button class="btn-icon danger" title="Revoke session">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
</button>
</div>
</div>
</div>
</div>
<!-- ■ NOTIFICATIONS ■ -->
<div id="section-notifications" class="section-anchor">
<div class="section-card" style="margin-bottom: 20px;">
<div class="section-card-header">
<div class="section-card-title">Notification preferences</div>
</div>
<div style="padding: 8px 20px 16px;">
<div class="toggle-setting-row">
<div class="toggle-setting-text">
<div class="toggle-setting-label">Operation error spike</div>
<div class="toggle-setting-desc">Alert when error rate exceeds 5% over a 5-minute window.</div>
</div>
<div class="toggle-sm on" onclick="this.classList.toggle('on')"></div>
</div>
<div class="toggle-setting-row">
<div class="toggle-setting-text">
<div class="toggle-setting-label">Upstream latency degradation</div>
<div class="toggle-setting-desc">Alert when p99 latency exceeds the baseline by 3×.</div>
</div>
<div class="toggle-sm on" onclick="this.classList.toggle('on')"></div>
</div>
<div class="toggle-setting-row">
<div class="toggle-setting-text">
<div class="toggle-setting-label">Quota approaching limit</div>
<div class="toggle-setting-desc">Email when any operation reaches 80% of its monthly quota.</div>
</div>
<div class="toggle-sm on" onclick="this.classList.toggle('on')"></div>
</div>
<div class="toggle-setting-row">
<div class="toggle-setting-text">
<div class="toggle-setting-label">New team member joined</div>
<div class="toggle-setting-desc">Notify workspace admins when someone accepts an invite.</div>
</div>
<div class="toggle-sm" onclick="this.classList.toggle('on')"></div>
</div>
<div class="toggle-setting-row">
<div class="toggle-setting-text">
<div class="toggle-setting-label">Weekly usage digest</div>
<div class="toggle-setting-desc">Email summary of invocations, errors and latency each Monday.</div>
</div>
<div class="toggle-sm on" onclick="this.classList.toggle('on')"></div>
</div>
</div>
</div>
</div>
<!-- ■ DANGER ZONE — moved to workspace-setup.html ■ -->
<div id="section-danger" style="display:none;">
<div class="section-card" style="border-color: rgba(248,81,73,0.25);">
<div class="section-card-header" style="border-bottom-color: rgba(248,81,73,0.15);">
<div class="section-card-title" style="color: var(--red);">Danger zone</div>
</div>
<div>
<div class="danger-zone-action">
<div class="danger-zone-text">
<div class="danger-zone-title">Rename workspace</div>
<div class="danger-zone-desc">Changes the workspace slug used in all API endpoints. Existing API keys will continue to work. All existing tool call references will break.</div>
</div>
<button class="btn-danger" type="button">Rename</button>
</div>
<div class="danger-zone-action">
<div class="danger-zone-text">
<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. May take a few minutes.</div>
</div>
<button class="btn-danger" type="button">Export</button>
</div>
<div class="danger-zone-action">
<div class="danger-zone-text">
<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>
<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>
</div>
</div>
</div>
</div>
</div><!-- /settings-content -->
</div><!-- /settings-layout -->
</div><!-- /page -->
<script src="js/nav.js"></script>
<script src="js/settings.js"></script>
</body>
</html>
+228
View File
@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="../">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Usage</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/usage.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/workspace.js"></script>
</head>
<body>
<nav class="navbar">
<a href="index.html" class="nav-logo">
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
<span class="nav-logo-text">Crank</span>
</a>
<!-- Workspace switcher -->
<div class="ws-switcher" id="ws-switcher">
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
<div class="ws-dot" id="ws-dot">A</div>
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button>
<div class="ws-dropdown" id="ws-dropdown" style="display:none">
<div class="ws-dropdown-label">Your workspaces</div>
<div id="ws-dropdown-list"></div>
<div class="ws-dropdown-footer">
<button class="ws-dropdown-create" onclick="window.location.href='workspace-setup.html?mode=create'">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
Create workspace
</button>
</div>
</div>
</div>
<div class="nav-links">
<a href="index.html" class="nav-link" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="nav-link" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="nav-link active" data-i18n="nav.usage">Usage</a>
</div>
<div class="nav-right">
<button class="nav-hamburger" aria-label="Menu"><span></span><span></span><span></span></button>
<div class="nav-kbd">
<svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg>
<span>⌘K</span>
</div>
<button class="nav-icon-btn" title="Notifications">
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
</button>
<div class="nav-divider"></div>
<div class="user-menu">
<div class="nav-avatar" title="Account">AT</div>
<div class="user-dropdown">
<div class="user-dropdown-header">
<div class="user-dropdown-name">Operator</div>
<div class="user-dropdown-role">Admin · acme-workspace</div>
</div>
<button class="user-dropdown-item" data-action="settings-link">
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
<span data-i18n="nav.settings">Settings</span>
</button>
<div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" data-action="logout">
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
<span data-i18n="nav.logout">Log out</span>
</button>
</div>
</div>
</div>
</nav>
<div class="mobile-nav">
<a href="index.html" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="mobile-nav-link active" data-i18n="nav.usage">Usage</a>
</div>
<div class="page">
<div class="page-header">
<div class="page-header-text">
<h1 class="page-title" data-i18n="usage.title">Usage</h1>
<p class="page-subtitle">Invocation metrics for all operations in your workspace.</p>
</div>
<div class="page-header-actions">
<select class="period-select" id="period">
<option value="7d" selected>Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
<option value="this_month">This month</option>
</select>
<button class="btn-secondary" type="button">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.47 1.72a.75.75 0 011.06 0l4.5 4.5a.75.75 0 01-1.06 1.06L8.75 4.06v8.94a.75.75 0 01-1.5 0V4.06L4.53 7.28a.75.75 0 01-1.06-1.06l4.5-4.5zM1.5 13.75a.75.75 0 01.75-.75h11.5a.75.75 0 010 1.5H2.25a.75.75 0 01-.75-.75z"/></svg>
Export CSV
</button>
</div>
</div>
<!-- Stat cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Total invocations</div>
<div class="stat-value">28,341</div>
<div class="stat-delta up">
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg>
+12% vs prior period
</div>
</div>
<div class="stat-card">
<div class="stat-label">Success rate</div>
<div class="stat-value">98.6%</div>
<div class="stat-delta up">
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg>
+0.3pp vs prior period
</div>
</div>
<div class="stat-card">
<div class="stat-label">Median latency (p50)</div>
<div class="stat-value">124ms</div>
<div class="stat-delta up">
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg>
-18ms vs prior period
</div>
</div>
<div class="stat-card">
<div class="stat-label">p99 latency</div>
<div class="stat-value">2.1s</div>
<div class="stat-delta down">
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 10L2 6h3V2h2v4h3l-4 4z"/></svg>
+340ms vs prior period
</div>
</div>
</div>
<!-- Invocations chart -->
<div class="section-card" style="margin-bottom:20px;">
<div class="section-card-header">
<div class="section-card-title">Invocations over time</div>
<div style="display:flex;align-items:center;gap:12px;font-size:12px;color:var(--text-muted);">
<span style="display:flex;align-items:center;gap:5px;"><span style="width:10px;height:10px;border-radius:2px;background:var(--accent);display:inline-block;"></span>Success</span>
<span style="display:flex;align-items:center;gap:5px;"><span style="width:10px;height:10px;border-radius:2px;background:var(--red);display:inline-block;"></span>Error</span>
</div>
</div>
<div class="section-card-body" style="padding-bottom:28px;">
<div class="chart-placeholder" id="chart-bars"></div>
</div>
</div>
<!-- Per-operation table -->
<div class="section-card">
<div class="section-card-header">
<div>
<div class="section-card-title">By operation</div>
<div class="section-card-subtitle">Breakdown for last 7 days</div>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th>Operation</th>
<th>Protocol</th>
<th style="text-align:right;">Calls</th>
<th style="text-align:right;">Errors</th>
<th style="text-align:right;">Error rate</th>
<th>Latency (p50 / p95 / p99)</th>
<th style="text-align:right;">Quota</th>
</tr>
</thead>
<tbody id="usage-tbody">
</tbody>
</table>
</div>
</div>
<!-- Template: chart bar column -->
<template id="tmpl-chart-bar">
<div class="chart-col" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;justify-content:flex-end;position:relative;">
<div class="chart-bar error" style="width:100%;"></div>
<div class="chart-bar success" style="width:100%;"></div>
<div class="chart-col-label" style="position:absolute;bottom:-22px;font-size:10.5px;color:var(--text-muted);text-align:center;width:100%;"></div>
</div>
</template>
<!-- Template: usage stats table row -->
<template id="tmpl-usage-row">
<tr>
<td>
<div class="col-name"></div>
<div class="col-op-slug" style="font-family:monospace;font-size:11px;color:var(--text-muted);"></div>
</td>
<td><span class="badge col-proto" style="border-color:rgba(88,166,255,0.25);background:rgba(88,166,255,0.07);color:var(--blue);"></span></td>
<td class="col-calls" style="text-align:right;font-variant-numeric:tabular-nums;"></td>
<td class="col-errors" style="text-align:right;font-variant-numeric:tabular-nums;"></td>
<td class="col-err-rate" style="text-align:right;font-variant-numeric:tabular-nums;"></td>
<td class="col-latency">
<div class="latency-bar">
<div class="latency-seg latency-p50"></div>
<div class="latency-seg latency-p95"></div>
<div class="latency-seg latency-p99"></div>
</div>
<div class="col-latency-text" style="font-size:11px;color:var(--text-muted);margin-top:3px;font-family:monospace;"></div>
</td>
<td style="text-align:right;min-width:120px;">
<div class="col-quota-label" style="font-size:11.5px;color:var(--text-muted);margin-bottom:3px;"></div>
<div class="quota-bar-wrap"><div class="quota-bar-fill col-quota-fill"></div></div>
</td>
</tr>
</template>
<script src="js/nav.js"></script>
<script src="js/usage.js"></script>
</body>
</html>
+240
View File
@@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — New Operation</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../../css/variables.css">
<link rel="stylesheet" href="../../css/layout.css">
<link rel="stylesheet" href="../../css/wizard.css">
<link rel="stylesheet" href="../../css/pages.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('../login.html');}catch(e){}</script>
<script src="../../js/config.js"></script>
<script src="../../js/i18n.js"></script>
<script src="../../js/workspace.js"></script>
<script src="../../js/api.js"></script>
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
</head>
<body>
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
<nav class="navbar">
<a href="../../index.html" class="nav-logo">
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
<span class="nav-logo-text">Crank</span>
</a>
<!-- Workspace switcher -->
<div class="ws-switcher" id="ws-switcher">
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
<div class="ws-dot" id="ws-dot">A</div>
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button>
<div class="ws-dropdown" id="ws-dropdown" style="display:none">
<div class="ws-dropdown-label">Your workspaces</div>
<div id="ws-dropdown-list"></div>
<div class="ws-dropdown-footer">
<button class="ws-dropdown-create" onclick="window.location.href='../workspace-setup.html?mode=create'">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
Create workspace
</button>
</div>
</div>
</div>
<div class="nav-links">
<a href="../../index.html" class="nav-link active" data-i18n="nav.operations">Operations</a>
<a href="../agents.html" class="nav-link" data-i18n="nav.agents">Agents</a>
<a href="../api-keys.html" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="../logs.html" class="nav-link" data-i18n="nav.logs">Logs</a>
<a href="../usage.html" class="nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="nav-right">
<button class="nav-hamburger" aria-label="Menu"><span></span><span></span><span></span></button>
<div class="nav-kbd">
<svg width="12" height="12"><use href="../../icons/general/grid.svg#icon"/></svg>
<span>⌘K</span>
</div>
<button class="nav-icon-btn" title="Notifications">
<svg width="15" height="15"><use href="../../icons/general/bell.svg#icon"/></svg>
</button>
<div class="nav-divider"></div>
<div class="user-menu">
<div class="nav-avatar" title="Account">AT</div>
<div class="user-dropdown">
<div class="user-dropdown-header">
<div class="user-dropdown-name">Operator</div>
<div class="user-dropdown-role">Admin · acme-workspace</div>
</div>
<button class="user-dropdown-item" data-action="settings-link">
<svg width="13" height="13"><use href="../../icons/general/settings.svg#icon"/></svg>
<span data-i18n="nav.settings">Settings</span>
</button>
<div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" data-action="logout">
<svg width="13" height="13"><use href="../../icons/general/logout.svg#icon"/></svg>
<span data-i18n="nav.logout">Log out</span>
</button>
</div>
</div>
</div>
</nav>
<div class="mobile-nav">
<a href="../../index.html" class="mobile-nav-link active" data-i18n="nav.operations">Operations</a>
<a href="../agents.html" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
<a href="../api-keys.html" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="../logs.html" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
<a href="../usage.html" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
</div>
<!-- ═══════════════════ WIZARD SHELL ═══════════════════ -->
<div class="wizard-shell">
<!-- ══ TOP PROGRESS STRIP ══ -->
<div class="progress-strip">
<button class="back-to-catalog-btn" id="back-to-catalog" title="Back to catalog">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M9.78 12.78a.75.75 0 01-1.06 0L4.47 8.53a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L6.06 8l3.72 3.72a.75.75 0 010 1.06z"/></svg>
Operations
</button>
<div class="progress-divider"></div>
<span class="progress-label">Create operation</span>
<div class="progress-bar-wrap">
<div class="progress-bar-fill"></div>
</div>
<span class="progress-pct">20%</span>
<button class="progress-close" title="Exit wizard" aria-label="Exit wizard">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
</svg>
</button>
</div>
<!-- ══ WIZARD BODY ══ -->
<div class="wizard-body">
<!-- ── Step sidebar ── -->
<div class="step-sidebar">
<div class="step-sidebar-card">
<div class="step-sidebar-header">
<div class="step-sidebar-logo-mark">M</div>
<div class="step-sidebar-brand">
Create <span>operation</span>
</div>
</div>
<div class="step-sidebar-body">
<div class="step-sidebar-section-label">Progress</div>
<div class="steps-list">
<div class="step-item active">
<div class="step-indicator">1</div>
<div class="step-content">
<div class="step-number">Step 1</div>
<div class="step-name">Protocol</div>
<div class="step-status-text">In progress</div>
</div>
</div>
<div class="step-item pending">
<div class="step-indicator">2</div>
<div class="step-content">
<div class="step-number">Step 2</div>
<div class="step-name">Upstream target</div>
<div class="step-status-text">Not started</div>
</div>
</div>
<div class="step-item pending" id="sidebar-step-3">
<div class="step-indicator">3</div>
<div class="step-content">
<div class="step-number">Step 3</div>
<div class="step-name" id="sidebar-step-3-name">Request config</div>
<div class="step-status-text">Not started</div>
</div>
</div>
<div class="step-item pending">
<div class="step-indicator">4</div>
<div class="step-content">
<div class="step-number">Step 4</div>
<div class="step-name">Tool config</div>
<div class="step-status-text">Not started</div>
</div>
</div>
<div class="step-item pending">
<div class="step-indicator">5</div>
<div class="step-content">
<div class="step-number">Step 5</div>
<div class="step-name">Mapping</div>
<div class="step-status-text">Not started</div>
</div>
</div>
</div><!-- /steps-list -->
<div class="sidebar-help" style="margin-top: 20px;">
<div class="sidebar-help-title">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="7"/>
<path d="M8 12v-4M8 5v-.5"/>
</svg>
Need help?
</div>
<div class="sidebar-help-text">
Unsure which protocol to choose? Read the
<a href="#" class="sidebar-help-link">protocol comparison guide →</a>
</div>
</div>
</div><!-- /step-sidebar-body -->
</div><!-- /step-sidebar-card -->
</div><!-- /step-sidebar -->
<!-- ── Main step panel ── -->
<div class="wizard-main"><div id="step-panel-container"><!-- steps loaded dynamically --></div></div>
</div><!-- /wizard-body -->
<!-- ══ BOTTOM ACTION BAR ══ -->
<div class="action-bar" role="toolbar" aria-label="Wizard navigation">
<div class="action-bar-inner">
<button class="btn btn-back" type="button" disabled style="opacity: 0.35; cursor: not-allowed;">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
<path d="M9.78 12.78a.75.75 0 01-1.06 0L4.47 8.53a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L6.06 8l3.72 3.72a.75.75 0 010 1.06z"/>
</svg>
Back
</button>
<button class="btn-save-draft" type="button">Save draft</button>
<div class="action-bar-spacer"></div>
<span class="step-counter">Step <strong>1</strong> of 5</span>
<button id="btn-continue" class="btn btn-continue" type="button">
Continue
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
<path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"/>
</svg>
</button>
</div>
</div>
</div><!-- /wizard-shell -->
<script src="../../js/wizard.js"></script>
<script src="../../js/nav.js"></script>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
<!-- ════════════ STEP 1: Protocol ════════════ -->
<div id="step-panel-1" class="step-pane">
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 1 of 5
</div>
<h1 class="step-panel-title">Choose a protocol</h1>
<p class="step-panel-subtitle">Select the transport protocol your upstream service uses. This determines how Crank will communicate with your API and which configuration options are available in subsequent steps.</p>
</div>
<!-- Protocol selection cards -->
<div class="protocol-grid">
<div class="protocol-card rest selected" role="radio" aria-checked="true" tabindex="0">
<div class="protocol-card-check">
<svg viewBox="0 0 12 12"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
</div>
<div class="protocol-icon-wrap">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#58a6ff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path d="M3.6 9h16.8M3.6 15h16.8"/>
<path d="M12 3a15.3 15.3 0 010 18M12 3a15.3 15.3 0 000 18"/>
</svg>
</div>
<div class="protocol-card-name">REST / HTTP</div>
<div class="protocol-card-tagline">Standard HTTP verbs over a RESTful endpoint. The most broadly supported option.</div>
<div class="protocol-card-tags">
<span class="protocol-tag">GET</span>
<span class="protocol-tag">POST</span>
<span class="protocol-tag">PUT</span>
<span class="protocol-tag">PATCH</span>
<span class="protocol-tag">DELETE</span>
</div>
</div>
<div class="protocol-card graphql" role="radio" aria-checked="false" tabindex="0">
<div class="protocol-card-check">
<svg viewBox="0 0 12 12"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
</div>
<div class="protocol-icon-wrap">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#bc8cff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2l9.5 5.5v11L12 22l-9.5-5.5v-11L12 2z"/>
<circle cx="12" cy="2" r="1.5" fill="#bc8cff"/>
<circle cx="21.5" cy="7.5" r="1.5" fill="#bc8cff"/>
<circle cx="21.5" cy="16.5" r="1.5" fill="#bc8cff"/>
<circle cx="12" cy="22" r="1.5" fill="#bc8cff"/>
<circle cx="2.5" cy="16.5" r="1.5" fill="#bc8cff"/>
<circle cx="2.5" cy="7.5" r="1.5" fill="#bc8cff"/>
</svg>
</div>
<div class="protocol-card-name">GraphQL</div>
<div class="protocol-card-tagline">Query and mutate a GraphQL schema. Supports queries, mutations, and subscriptions.</div>
<div class="protocol-card-tags">
<span class="protocol-tag">Query</span>
<span class="protocol-tag">Mutation</span>
</div>
</div>
<div class="protocol-card grpc" role="radio" aria-checked="false" tabindex="0">
<div class="protocol-card-check">
<svg viewBox="0 0 12 12"><polyline points="1.5,6 4.5,9 10.5,3"/></svg>
</div>
<div class="protocol-icon-wrap">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#0d9488" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="8" height="18" rx="2"/>
<rect x="14" y="3" width="8" height="18" rx="2"/>
<path d="M10 8h4M10 12h4M10 16h4"/>
</svg>
</div>
<div class="protocol-card-name">gRPC</div>
<div class="protocol-card-tagline">High-performance binary RPC over HTTP/2. Ideal for low-latency internal services.</div>
<div class="protocol-card-tags">
<span class="protocol-tag">Unary</span>
<span class="protocol-tag">Protobuf</span>
</div>
</div>
</div><!-- /protocol-grid -->
<div class="info-callout" style="margin-bottom: 24px;">
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<polygon points="8,1 15,14 1,14" fill="none"/>
<path d="M8 6v4M8 11.5v.5"/>
</svg>
<div class="info-callout-body">
<div class="info-callout-title">You can change this later</div>
<div class="info-callout-text">
Switching protocol after configuring subsequent steps will clear schema and mapping data. Save your operation as a draft before experimenting. See <a href="#">protocol migration guide</a> for details.
</div>
</div>
</div>
</div><!-- /step-pane-1 -->
+157
View File
@@ -0,0 +1,157 @@
<!-- ════════════ STEP 2: Upstream target ════════════ -->
<div id="step-panel-2" class="step-pane">
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 2 of 5
</div>
<h1 class="step-panel-title">Select upstream &amp; endpoint</h1>
<p class="step-panel-subtitle">Choose an existing upstream — a shared host with its auth config — or register a new one. Then define the specific endpoint path this operation will call.</p>
</div>
<!-- ── Upstream selector ── -->
<div class="config-card" style="margin-bottom: 20px; overflow: visible; position: relative; z-index: 10;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="1" y="3" width="14" height="4" rx="1"/>
<rect x="1" y="9" width="14" height="4" rx="1"/>
<circle cx="4" cy="5" r="0.8" fill="var(--text-secondary)" stroke="none"/>
<circle cx="4" cy="11" r="0.8" fill="var(--text-secondary)" stroke="none"/>
</svg>
</div>
<div>
<div class="config-card-title">Upstream</div>
<div class="config-card-subtitle">Shared host, base URL, and authentication</div>
</div>
<span class="config-card-optional" style="color: var(--error, #f87171);">Required</span>
</div>
<div class="config-card-body" style="gap: 12px; padding: 16px;">
<!-- Searchable combobox -->
<div class="upstream-combobox" id="upstream-combobox">
<div class="upstream-combobox-trigger" id="upstream-combobox-trigger" onclick="toggleUpstreamDropdown(event)">
<div class="upstream-combobox-value" id="upstream-combobox-value">
<span class="upstream-combobox-placeholder">Select an upstream…</span>
</div>
<svg class="upstream-combobox-chevron" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6l4 4 4-4"/>
</svg>
</div>
<!-- Dropdown panel -->
<div class="upstream-dropdown" id="upstream-dropdown" style="display:none;">
<div class="upstream-search-wrap">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.8" stroke-linecap="round">
<circle cx="7" cy="7" r="5"/><path d="M12 12l2.5 2.5"/>
</svg>
<input class="upstream-search-input" id="upstream-search" type="text" placeholder="Search by name or URL…" oninput="filterUpstreams(this.value)" autocomplete="off" spellcheck="false">
</div>
<div class="upstream-dropdown-list" id="upstream-dropdown-list">
<!-- populated by JS -->
</div>
</div>
</div>
<!-- Selected upstream preview -->
<div class="upstream-preview" id="upstream-preview" style="display:none;">
<div class="upstream-preview-name" id="upstream-preview-name"></div>
<div class="upstream-preview-url" id="upstream-preview-url"></div>
<span class="upstream-auth-badge" id="upstream-preview-badge"></span>
<button class="upstream-preview-change" onclick="openUpstreamDropdown()">Change</button>
</div>
<!-- Register new upstream trigger row -->
<div class="upstream-new-trigger" id="upstream-new-trigger" onclick="startNewUpstream()">
<div class="upstream-new-trigger-radio" id="upstream-new-trigger-radio">
<div class="upstream-new-trigger-dot"></div>
</div>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="2.2" stroke-linecap="round">
<path d="M8 3v10M3 8h10"/>
</svg>
<span>Register new upstream</span>
</div>
<!-- Register new upstream form (kept as-is per design) -->
<div class="upstream-new-form" id="upstream-new-form" style="display:none;">
<div class="upstream-new-form-inner">
<div class="form-row" style="grid-template-columns: 1fr 2fr;">
<div class="form-group">
<label class="form-label">Name <span class="form-label-required">required</span></label>
<input class="form-input" id="new-upstream-name" type="text" placeholder="e.g. acme-api" autocomplete="off" spellcheck="false">
<div class="form-hint">Unique identifier for this upstream.</div>
</div>
<div class="form-group">
<label class="form-label">Base URL <span class="form-label-required">required</span></label>
<input class="form-input input-mono" id="new-upstream-url" type="text" placeholder="https://api.example.com" autocomplete="off" spellcheck="false">
<div class="form-hint">Root URL — no trailing slash. Supports <code style="font-family:monospace;font-size:11px;color:var(--accent);">${secrets.*}</code>.</div>
</div>
</div>
<div class="form-group">
<label class="form-label">Auth headers</label>
<div class="code-block" style="border-radius: 6px;">
<div class="code-toolbar">
<div class="code-dots"><span></span><span></span><span></span></div>
<span class="code-toolbar-label">json / auth-headers</span>
</div>
<textarea class="form-textarea code-textarea" id="new-upstream-auth-headers" rows="7">{
"Authorization": "${secrets.API_KEY}"
}</textarea>
</div>
<div class="form-hint">These headers are sent on every request to this upstream. Use secrets for credentials.</div>
</div>
<div style="display:flex; gap:8px; margin-top:4px;">
<button class="btn-primary-sm" onclick="saveNewUpstream(event)">Save upstream</button>
<button class="btn-ghost-sm" onclick="cancelNewUpstream(event)">Cancel</button>
</div>
</div>
</div>
</div>
</div>
<!-- ── Endpoint ── -->
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 8h12M10 4l4 4-4 4"/>
</svg>
</div>
<div>
<div class="config-card-title">Endpoint</div>
<div class="config-card-subtitle">Path and HTTP method for this specific operation</div>
</div>
<span class="config-card-optional" style="color: var(--error, #f87171);">Required</span>
</div>
<div class="config-card-body" style="gap: 16px;">
<div class="form-group">
<label class="form-label">
Path template
<span class="form-label-required">required</span>
</label>
<input class="form-input input-mono" id="endpoint-path" type="text" value="/v1/leads" autocomplete="off" spellcheck="false">
<div class="form-hint">Appended to the upstream base URL. Use <code style="font-family:monospace;font-size:11px;color:var(--accent);">{param}</code> for path variables.</div>
</div>
</div>
</div>
</div><!-- /step-pane-2 -->
<!-- Template: upstream dropdown item -->
<template id="tmpl-upstream-item">
<div class="upstream-dropdown-item">
<div class="upstream-dropdown-item-info">
<div class="upstream-dropdown-item-name"></div>
<div class="upstream-dropdown-item-url"></div>
</div>
<span class="upstream-auth-badge"></span>
<svg class="upstream-dropdown-item-check" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8l4 4 6-7"/></svg>
</div>
</template>
<!-- Template: upstream combobox trigger value -->
<template id="tmpl-upstream-trigger-value">
<div class="upstream-trigger-name"></div>
<div class="upstream-trigger-url"></div>
</template>
+92
View File
@@ -0,0 +1,92 @@
<div id="step-panel-3-graphql" class="step-pane">
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 3 of 5
</div>
<h1 class="step-panel-title">GraphQL operation</h1>
<p class="step-panel-subtitle">Define the GraphQL operation this tool will execute. All requests are sent as HTTP POST to the endpoint configured in the previous step. Variables are populated from MCP tool arguments at runtime.</p>
</div>
<!-- Operation type -->
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="6"/>
<path d="M5 8l2 2 4-4"/>
</svg>
</div>
<div>
<div class="config-card-title">Operation type</div>
<div class="config-card-subtitle">GraphQL operation kind — subscriptions are not supported</div>
</div>
<span class="config-card-optional" style="color:var(--error,#f87171);">Required</span>
</div>
<div class="config-card-body">
<div class="gql-type-grid">
<button class="gql-type-card active" data-gql-type="query" onclick="selectGqlType(this)">
<div class="gql-type-keyword">query</div>
<div class="gql-type-desc">Fetch data without side-effects</div>
</button>
<button class="gql-type-card" data-gql-type="mutation" onclick="selectGqlType(this)">
<div class="gql-type-keyword">mutation</div>
<div class="gql-type-desc">Create, update or delete data</div>
</button>
</div>
</div>
</div>
<!-- Query editor -->
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 2h8a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V4a2 2 0 012-2z"/>
<path d="M6 6l-2 2 2 2M10 6l2 2-2 2"/>
</svg>
</div>
<div>
<div class="config-card-title">Query document</div>
<div class="config-card-subtitle">GraphQL document with typed variable declarations</div>
</div>
<span class="config-card-optional" style="color:var(--error,#f87171);">Required</span>
</div>
<div class="config-card-body" style="gap: 0; padding: 0;">
<div class="code-block" style="border-radius: 0; border: none;">
<div class="code-toolbar">
<div class="code-dots"><span></span><span></span><span></span></div>
<span class="code-toolbar-label">graphql / query</span>
</div>
<textarea class="form-textarea code-textarea" rows="16" id="gql-query-editor" spellcheck="false">query GetContact($id: ID!) {
contact(id: $id) {
id
firstName
lastName
email
company {
id
name
}
createdAt
}
}</textarea>
</div>
</div>
</div>
<!-- Variables hint -->
<div class="info-callout" style="margin-bottom: 24px;">
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="7"/>
<path d="M8 11V8M8 5v-.5"/>
</svg>
<div class="info-callout-body">
<div class="info-callout-title">Variables are mapped in step 5</div>
<div class="info-callout-text">
Declare all variables in the query document using the <code style="font-family:monospace;font-size:11px;color:var(--accent);">$variable: Type</code> syntax. In step 5 you will map MCP input fields to these variables. Crank serialises the final <code style="font-family:monospace;font-size:11px;color:var(--accent);">{"query","variables"}</code> payload automatically.
</div>
</div>
</div>
</div><!-- /step-pane-3-graphql -->
+274
View File
@@ -0,0 +1,274 @@
<div id="step-panel-3-grpc" class="step-pane">
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 3 of 5
</div>
<h1 class="step-panel-title">RPC method</h1>
<p class="step-panel-subtitle">Choose how to describe the gRPC service contract. Upload a <code style="font-family:monospace;font-size:13px;color:var(--accent);">.proto</code> file, discover methods from a live server via reflection, or enter the route manually. Only unary RPCs are supported — streaming connections cannot be held open during MCP tool invocations.</p>
</div>
<!-- ── Source selector ── -->
<div class="grpc-source-tabs">
<button class="grpc-source-btn active" data-source="proto" onclick="selectGrpcSource('proto')">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 2H4a1 1 0 00-1 1v10a1 1 0 001 1h8a1 1 0 001-1V6z"/><path d="M9 2v4h4"/></svg>
Proto file
</button>
<button class="grpc-source-btn" data-source="reflection" onclick="selectGrpcSource('reflection')">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.1 3.1l1.4 1.4M11.5 11.5l1.4 1.4M3.1 12.9l1.4-1.4M11.5 4.5l1.4-1.4"/></svg>
Server reflection
</button>
<button class="grpc-source-btn" data-source="manual" onclick="selectGrpcSource('manual')">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 2l3 3-8 8H3v-3l8-8z"/></svg>
Manual
</button>
</div>
<!-- ══ Proto source ══ -->
<div id="grpc-src-proto">
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 2H4a1 1 0 00-1 1v10a1 1 0 001 1h8a1 1 0 001-1V6z"/><path d="M9 2v4h4"/></svg>
</div>
<div>
<div class="config-card-title">Proto definition</div>
<div class="config-card-subtitle">Upload or paste your .proto file</div>
</div>
<span class="config-card-optional" style="color:var(--error,#f87171);">Required</span>
</div>
<div class="config-card-body" style="gap: 12px; padding: 16px;">
<div class="proto-dropzone" id="proto-dropzone"
onclick="document.getElementById('proto-file-input').click()"
ondragover="event.preventDefault(); this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="handleProtoDrop(event)">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<div class="proto-dropzone-title">Drop <code style="font-family:monospace;font-size:12px;">.proto</code> file here</div>
<div class="proto-dropzone-sub">or click to browse</div>
<input type="file" id="proto-file-input" accept=".proto" style="display:none" onchange="handleProtoFile(event)">
</div>
<div class="proto-file-info" id="proto-file-info" style="display:none">
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--accent)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 2H4a1 1 0 00-1 1v10a1 1 0 001 1h8a1 1 0 001-1V6z"/><path d="M9 2v4h4"/></svg>
<span class="proto-file-name" id="proto-file-name"></span>
<span class="proto-file-size" id="proto-file-size"></span>
<button class="proto-file-change" onclick="resetProto()">Change file</button>
</div>
<div style="display:flex; align-items:center; gap:10px;">
<div style="flex:1; height:1px; background:var(--border-subtle);"></div>
<button class="btn-ghost-sm" onclick="toggleProtoPaste()" id="proto-paste-btn">Paste content</button>
<div style="flex:1; height:1px; background:var(--border-subtle);"></div>
</div>
<div id="proto-paste-area" style="display:none">
<div class="code-block" style="border-radius: 6px;">
<div class="code-toolbar">
<div class="code-dots"><span></span><span></span><span></span></div>
<span class="code-toolbar-label">protobuf / definition</span>
</div>
<textarea class="form-textarea code-textarea" id="proto-paste-input" rows="14"
placeholder='syntax = "proto3";&#10;&#10;package acme;&#10;&#10;service ContactService {&#10; rpc GetContact (GetContactRequest) returns (Contact) {}&#10;}' spellcheck="false"></textarea>
</div>
<div style="display:flex; gap: 8px; margin-top: 8px;">
<button class="btn-primary-sm" onclick="parseProtoPasted()">Parse proto</button>
<button class="btn-ghost-sm" onclick="cancelProtoPaste()">Cancel</button>
</div>
</div>
</div>
</div>
<div id="proto-parsed-view" style="display:none">
<div class="info-callout" id="proto-streaming-notice" style="margin-bottom:20px;display:none">
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polygon points="8,1 15,14 1,14" fill="none"/><path d="M8 6v4M8 11.5v.5"/></svg>
<div class="info-callout-body">
<div class="info-callout-title">Streaming methods excluded</div>
<div class="info-callout-text"><span id="proto-streaming-count">0</span> streaming method(s) found and hidden — not compatible with MCP tool calls.</div>
</div>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div><div class="config-card-title">Select method</div><div class="config-card-subtitle" id="proto-services-summary"></div></div>
<span class="config-card-optional" style="color:var(--error,#f87171);">Required</span>
</div>
<div class="config-card-body" style="padding: 0 16px 16px; gap: 0;">
<div id="proto-services-list"></div>
</div>
</div>
</div>
</div><!-- /grpc-src-proto -->
<!-- ══ Reflection source ══ -->
<div id="grpc-src-reflection" style="display:none">
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.1 3.1l1.4 1.4M11.5 11.5l1.4 1.4M3.1 12.9l1.4-1.4M11.5 4.5l1.4-1.4"/></svg>
</div>
<div>
<div class="config-card-title">Server reflection</div>
<div class="config-card-subtitle">Discover services from the upstream gRPC server at runtime</div>
</div>
</div>
<div class="config-card-body" style="gap: 14px; padding: 16px;">
<!-- Upstream info strip -->
<div class="grpc-reflect-upstream">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="6" height="10" rx="1"/><rect x="9" y="3" width="6" height="10" rx="1"/></svg>
<div class="grpc-reflect-upstream-info">
<span class="grpc-reflect-upstream-name" id="grpc-reflect-upstream-name">No upstream selected</span>
<span class="grpc-reflect-upstream-url" id="grpc-reflect-upstream-url">Select an upstream in step 2 first</span>
</div>
</div>
<!-- Idle -->
<div id="grpc-reflect-idle">
<button class="btn-primary-sm" onclick="startReflection()">
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2"/></svg>
Discover methods
</button>
</div>
<!-- Loading -->
<div id="grpc-reflect-loading" style="display:none">
<div class="grpc-reflect-status">
<div class="grpc-reflect-spinner"></div>
<span id="grpc-reflect-status-text">Connecting…</span>
</div>
</div>
<!-- Error -->
<div id="grpc-reflect-error" style="display:none">
<div class="grpc-reflect-error-row">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="var(--error,#f87171)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="7"/><path d="M8 5v3M8 10.5v.5"/></svg>
<span id="grpc-reflect-error-text">Reflection not available on this server.</span>
<button class="proto-file-change" onclick="startReflection()">Retry</button>
</div>
</div>
</div>
</div>
<div id="reflect-parsed-view" style="display:none">
<div class="info-callout" id="reflect-streaming-notice" style="margin-bottom:20px;display:none">
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polygon points="8,1 15,14 1,14" fill="none"/><path d="M8 6v4M8 11.5v.5"/></svg>
<div class="info-callout-body">
<div class="info-callout-title">Streaming methods excluded</div>
<div class="info-callout-text"><span id="reflect-streaming-count">0</span> streaming method(s) discovered and hidden.</div>
</div>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div><div class="config-card-title">Select method</div><div class="config-card-subtitle" id="reflect-services-summary"></div></div>
<span class="config-card-optional" style="color:var(--error,#f87171);">Required</span>
</div>
<div class="config-card-body" style="padding: 0 16px 16px; gap: 0;">
<div id="reflect-services-list"></div>
</div>
</div>
</div>
</div><!-- /grpc-src-reflection -->
<!-- ══ Manual source ══ -->
<div id="grpc-src-manual" style="display:none">
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8h12M10 4l4 4-4 4"/></svg>
</div>
<div>
<div class="config-card-title">Method route</div>
<div class="config-card-subtitle">Full gRPC path to the unary method</div>
</div>
<span class="config-card-optional" style="color:var(--error,#f87171);">Required</span>
</div>
<div class="config-card-body" style="gap: 16px;">
<div class="form-group">
<label class="form-label">gRPC route <span class="form-label-required">required</span></label>
<input class="form-input input-mono" id="grpc-manual-route" type="text" placeholder="/package.ServiceName/MethodName" autocomplete="off" spellcheck="false" oninput="grpcManualRouteInput(this.value)">
<div class="form-hint">Format: <code style="font-family:monospace;font-size:11px;color:var(--accent);">/[package.]ServiceName/MethodName</code></div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Request type <span class="form-label-optional">(optional)</span></label>
<input class="form-input input-mono" id="grpc-manual-req-type" type="text" placeholder="GetContactRequest" autocomplete="off" spellcheck="false" oninput="grpcManualTypeInput()">
<div class="form-hint">Protobuf message name, for documentation.</div>
</div>
<div class="form-group">
<label class="form-label">Response type <span class="form-label-optional">(optional)</span></label>
<input class="form-input input-mono" id="grpc-manual-res-type" type="text" placeholder="Contact" autocomplete="off" spellcheck="false" oninput="grpcManualTypeInput()">
<div class="form-hint">Protobuf message name, for documentation.</div>
</div>
</div>
</div>
</div>
<div class="info-callout" style="margin-bottom: 20px;">
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="7"/><path d="M8 11V8M8 5v-.5"/></svg>
<div class="info-callout-body">
<div class="info-callout-title">Input and output schemas are defined in step 4</div>
<div class="info-callout-text">In manual mode you only need the route. The MCP tool input/output schemas — which the LLM uses to call this tool — are configured in the next step regardless of Protobuf definitions.</div>
</div>
</div>
</div><!-- /grpc-src-manual -->
<!-- ══ Shared method detail (proto + reflection) ══ -->
<div id="grpc-method-detail" class="config-card" style="margin-bottom: 20px; display:none">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8h12M10 4l4 4-4 4"/></svg>
</div>
<div>
<div class="config-card-title">Method signature</div>
<div class="config-card-subtitle" id="grpc-method-subtitle"></div>
</div>
</div>
<div class="config-card-body" style="gap: 16px; padding: 16px;">
<div class="proto-path-row">
<span class="proto-path-label">gRPC route</span>
<code class="proto-path-value" id="grpc-path-value"></code>
</div>
<div class="proto-sig-grid" id="grpc-detail-sig">
<div class="proto-sig-col">
<div class="proto-sig-col-header">
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 8h12M8 3l5 5-5 5"/></svg>
Request <code class="proto-type-badge" id="grpc-req-type"></code>
</div>
<div class="proto-field-list" id="grpc-req-fields"></div>
</div>
<div class="proto-sig-col">
<div class="proto-sig-col-header">
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M14 8H2M8 3l-5 5 5 5"/></svg>
Response <code class="proto-type-badge" id="grpc-res-type"></code>
</div>
<div class="proto-field-list" id="grpc-res-fields"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Template: proto service group -->
<template id="tmpl-proto-service-group">
<div class="proto-service-group">
<div class="proto-service-label">
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="3" width="6" height="10" rx="1"/><rect x="9" y="3" width="6" height="10" rx="1"/></svg>
<span class="proto-service-name"></span>
</div>
<div class="proto-method-grid"></div>
</div>
</template>
<!-- Template: proto method card -->
<template id="tmpl-proto-method-card">
<button class="proto-method-card" onclick="selectRpcMethod(this)">
<span class="proto-method-name"></span>
<span class="proto-method-types">
<span class="proto-req-type"></span>
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor"><path d="M3 8h10M9 4l4 4-4 4"/></svg>
<span class="proto-res-type"></span>
</span>
</button>
</template>
<!-- Template: proto field row -->
<template id="tmpl-proto-field-row">
<div class="proto-field-row">
<span class="proto-field-name"></span>
<span class="proto-field-type"></span>
</div>
</template>
+93
View File
@@ -0,0 +1,93 @@
<!-- REST variant -->
<div id="step-panel-3-rest" class="step-pane">
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 3 of 5
</div>
<h1 class="step-panel-title">HTTP method &amp; format</h1>
<p class="step-panel-subtitle">Choose the HTTP verb this operation sends and configure content negotiation headers. Crank will serialize MCP tool arguments into the appropriate request body format.</p>
</div>
<!-- HTTP method picker -->
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 8h12M10 4l4 4-4 4"/>
</svg>
</div>
<div>
<div class="config-card-title">HTTP method</div>
<div class="config-card-subtitle">Verb sent to the upstream on every tool invocation</div>
</div>
<span class="config-card-optional" style="color:var(--error,#f87171);">Required</span>
</div>
<div class="config-card-body">
<div class="method-grid">
<button class="method-card" data-method="GET" onclick="selectMethod(this)">
<span class="method-name">GET</span>
<span class="method-desc">Read</span>
</button>
<button class="method-card active" data-method="POST" onclick="selectMethod(this)">
<span class="method-name">POST</span>
<span class="method-desc">Create</span>
</button>
<button class="method-card" data-method="PUT" onclick="selectMethod(this)">
<span class="method-name">PUT</span>
<span class="method-desc">Replace</span>
</button>
<button class="method-card" data-method="PATCH" onclick="selectMethod(this)">
<span class="method-name">PATCH</span>
<span class="method-desc">Update</span>
</button>
<button class="method-card" data-method="DELETE" onclick="selectMethod(this)">
<span class="method-name">DELETE</span>
<span class="method-desc">Remove</span>
</button>
</div>
<div class="method-callout" id="method-callout-rest" style="display:none;"></div>
</div>
</div>
<!-- Request format -->
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
<path d="M5 7h6M5 10h4"/>
</svg>
</div>
<div>
<div class="config-card-title">Request format</div>
<div class="config-card-subtitle">Content negotiation and serialisation</div>
</div>
<span class="config-card-optional">Optional</span>
</div>
<div class="config-card-body" style="gap: 16px;">
<div class="form-row">
<div class="form-group">
<label class="form-label">Content-Type</label>
<select class="form-select">
<option selected>application/json</option>
<option>application/x-www-form-urlencoded</option>
<option>multipart/form-data</option>
<option>text/plain</option>
</select>
<div class="form-hint">How the request body is encoded when sent to the upstream.</div>
</div>
<div class="form-group">
<label class="form-label">Accept</label>
<select class="form-select">
<option selected>application/json</option>
<option>text/plain</option>
<option>*/*</option>
</select>
<div class="form-hint">Accepted response content types from the upstream server.</div>
</div>
</div>
</div>
</div>
</div><!-- /step-pane-3-rest -->
+162
View File
@@ -0,0 +1,162 @@
<div id="step-panel-4" class="step-pane">
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 4 of 5
</div>
<h1 class="step-panel-title">Tool config</h1>
<p class="step-panel-subtitle">Name your tool, write the LLM-facing description, and define the input/output schemas. The description is the primary signal the model uses when deciding whether to call this tool.</p>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 5h12M2 8h8M2 11h5"/>
</svg>
</div>
<div>
<div class="config-card-title">Tool identity</div>
<div class="config-card-subtitle">Machine-readable name and LLM-facing description</div>
</div>
</div>
<div class="config-card-body" style="gap: 20px;">
<div class="form-group">
<label class="form-label">
Tool name
<span class="form-label-required">required</span>
</label>
<input id="tool-name" class="form-input input-mono" type="text" value="create_crm_lead" autocomplete="off" spellcheck="false">
<div class="form-hint">Machine-readable identifier used in MCP tool calls. Only lowercase letters, numbers and underscores. Cannot be changed after publishing.</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">
Display name
<span class="form-label-required">required</span>
</label>
<input id="tool-display-name" class="form-input" type="text" value="Create CRM Lead" autocomplete="off">
<div class="form-hint">Human-readable name shown in the console and audit log.</div>
</div>
<div class="form-group">
<label class="form-label">
Tool title
<span class="form-label-optional">(optional)</span>
</label>
<input id="tool-title" class="form-input" type="text" value="Create a new CRM lead record" autocomplete="off">
<div class="form-hint">Short imperative sentence shown in the MCP tool manifest.</div>
</div>
</div>
<div class="form-group">
<label class="form-label">
Description
<span class="form-label-required">required</span>
</label>
<textarea id="tool-description" class="form-textarea" rows="5">Creates a new lead record in the CRM from the provided contact details. Use this tool when the user wants to add a new prospect or contact. Returns the new lead ID and creation timestamp on success.</textarea>
<div class="form-hint">LLM-facing description. Be precise — this is the primary signal the model uses to decide whether to invoke this tool.</div>
</div>
</div>
</div>
<div class="info-callout" style="margin-bottom: 24px;">
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="7"/>
<path d="M8 11V8M8 5v-.5"/>
</svg>
<div class="info-callout-body">
<div class="info-callout-title">Writing effective descriptions</div>
<div class="info-callout-text">
Start with a verb ("Creates", "Fetches", "Updates"). Mention key input requirements. Describe what a successful response looks like. Avoid vague phrasing like "handles" or "manages". See <a href="#">description best practices →</a>
</div>
</div>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 2h8a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V4a2 2 0 012-2z"/>
<path d="M6 6l-2 2 2 2M10 6l2 2-2 2"/>
</svg>
</div>
<div>
<div class="config-card-title">Input schema</div>
<div class="config-card-subtitle">Parameters the LLM passes when calling this tool</div>
</div>
<span class="config-card-optional">JSON Schema draft-07</span>
</div>
<div class="config-card-body" style="gap: 0; padding: 0;">
<div class="code-block" style="border-radius: 0; border: none;">
<div class="code-toolbar">
<div class="code-dots"><span></span><span></span><span></span></div>
<span class="code-toolbar-label">json / input-schema</span>
</div>
<textarea id="tool-input-schema" class="form-textarea code-textarea" rows="16">{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["first_name", "last_name", "email"],
"properties": {
"first_name": { "type": "string", "description": "Contact first name" },
"last_name": { "type": "string", "description": "Contact last name" },
"email": { "type": "string", "format": "email", "description": "Primary contact email address" },
"company": { "type": "string", "description": "Company or organisation name" },
"phone": { "type": "string", "description": "Phone number in E.164 format" },
"source": { "type": "string", "enum": ["web","api","import"], "default": "api" }
},
"additionalProperties": false
}</textarea>
</div>
</div>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 2h8a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V4a2 2 0 012-2z"/>
<path d="M5 8h6M5 5h4M5 11h3"/>
</svg>
</div>
<div>
<div class="config-card-title">Output schema</div>
<div class="config-card-subtitle">Shape of the data returned to the LLM after a successful call</div>
</div>
<span class="config-card-optional">JSON Schema draft-07</span>
</div>
<div class="config-card-body" style="gap: 0; padding: 0;">
<div class="code-block" style="border-radius: 0; border: none;">
<div class="code-toolbar">
<div class="code-dots"><span></span><span></span><span></span></div>
<span class="code-toolbar-label">json / output-schema</span>
</div>
<textarea id="tool-output-schema" class="form-textarea code-textarea" rows="14">{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"lead_id": { "type": "string", "description": "Unique identifier of the created lead" },
"status": { "type": "string", "description": "Initial lead status assigned by the CRM" },
"created_at": { "type": "string", "format": "date-time" },
"owner_id": { "type": "string", "description": "CRM user ID of the assigned owner" }
}
}</textarea>
</div>
</div>
</div>
<div class="info-callout" style="margin-bottom: 24px;">
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="7"/>
<path d="M8 11V8M8 5v-.5"/>
</svg>
<div class="info-callout-body">
<div class="info-callout-title">Schemas are protocol-agnostic</div>
<div class="info-callout-text">
These schemas describe the MCP contract, not the upstream wire format. Field mapping in Step 5 translates between the two. You can upload a sample JSON response to auto-generate the output schema.
</div>
</div>
</div>
</div><!-- /step-pane-4 -->
+102
View File
@@ -0,0 +1,102 @@
<div id="step-panel-5" class="step-pane">
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 5 of 5
</div>
<h1 class="step-panel-title">Mapping and execution</h1>
<p class="step-panel-subtitle">Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 3.</p>
</div>
<div class="section-divider" style="margin-bottom: 16px;">
<span class="section-divider-label">Input → Request</span>
<div class="section-divider-line"></div>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-body" style="gap: 0; padding: 0;">
<div class="code-block" style="border-radius: 0; border: none;">
<div class="code-toolbar">
<div class="code-dots"><span></span><span></span><span></span></div>
<span class="code-toolbar-label">yaml / input-mapping</span>
</div>
<textarea id="tool-input-mapping" class="form-textarea code-textarea" rows="12">first_name: "$.input.first_name"
last_name: "$.input.last_name"
email: "$.input.email"
company: "$.input.company"
phone: "$.input.phone"
source: "$.input.source"</textarea>
</div>
</div>
</div>
<div class="section-divider" style="margin-bottom: 16px;">
<span class="section-divider-label">Response → Output</span>
<div class="section-divider-line"></div>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-body" style="gap: 0; padding: 0;">
<div class="code-block" style="border-radius: 0; border: none;">
<div class="code-toolbar">
<div class="code-dots"><span></span><span></span><span></span></div>
<span class="code-toolbar-label">yaml / output-mapping</span>
</div>
<textarea id="tool-output-mapping" class="form-textarea code-textarea" rows="10">lead_id: "$.response.data.id"
status: "$.response.data.status"
created_at: "$.response.data.created_at"
owner_id: "$.response.data.owner.id"</textarea>
</div>
</div>
</div>
<div class="section-divider" style="margin-bottom: 16px;">
<span class="section-divider-label">Execution</span>
<div class="section-divider-line"></div>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="6"/>
<path d="M8 5v3l2 2"/>
</svg>
</div>
<div>
<div class="config-card-title">Execution config</div>
<div class="config-card-subtitle">Timeouts, retry policy and auth profile reference</div>
</div>
<span class="config-card-optional">Optional</span>
</div>
<div class="config-card-body" style="gap: 0; padding: 0;">
<div class="code-block" style="border-radius: 0; border: none;">
<div class="code-toolbar">
<div class="code-dots"><span></span><span></span><span></span></div>
<span class="code-toolbar-label">yaml / exec-config</span>
</div>
<textarea id="tool-exec-config" class="form-textarea code-textarea" rows="10">timeout_ms: 10000
retry:
max_attempts: 3
backoff_ms: 500
auth:
profile: acme_bearer
tls:
verify: true</textarea>
</div>
</div>
</div>
<div class="info-callout" style="margin-bottom: 24px;">
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="7"/>
<path d="M8 11V8M8 5v-.5"/>
</svg>
<div class="info-callout-body">
<div class="info-callout-title">Operation will be saved as a draft</div>
<div class="info-callout-text">
Clicking "Create operation" saves the contract in draft status. You can run tests, inspect metadata and publish from the operation workspace. Drafts are never exposed to MCP clients.
</div>
</div>
</div>
</div><!-- /step-pane-5 -->
+327
View File
@@ -0,0 +1,327 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="../">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Workspace</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css">
<link rel="stylesheet" href="css/wizard.css">
<link rel="stylesheet" href="css/workspace-setup.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
</head>
<body>
<div class="ws-setup-page">
<!-- Top bar -->
<div class="ws-setup-topbar">
<a href="index.html" class="ws-setup-logo">
<div class="ws-setup-logo-mark">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="18" height="18" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/></svg>
</div>
Crank
</a>
<a href="javascript:history.back()" class="ws-setup-back">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg>
Back
</a>
</div>
<!-- Body -->
<div class="ws-setup-body">
<div class="ws-setup-container">
<div class="ws-setup-header">
<div class="ws-setup-title" id="page-title">Workspace settings</div>
<div class="ws-setup-subtitle" id="page-subtitle">Configure your workspace identity, default settings, and team members.</div>
</div>
<!-- ── Identity ── -->
<div class="ws-form-section">
<div class="ws-form-section-title">Workspace identity</div>
<div class="ws-avatar-row">
<div class="ws-avatar-preview" id="ws-avatar-preview">A</div>
<div>
<div class="ws-avatar-hint">Avatar is derived from your workspace name.</div>
<div class="ws-color-swatches">
<div class="ws-color-swatch active" style="background:#0d9488;" data-color="#0d9488" onclick="pickColor(this)" title="Teal"></div>
<div class="ws-color-swatch" style="background:#7c3aed;" data-color="#7c3aed" onclick="pickColor(this)" title="Purple"></div>
<div class="ws-color-swatch" style="background:#0891b2;" data-color="#0891b2" onclick="pickColor(this)" title="Cyan"></div>
<div class="ws-color-swatch" style="background:#d29922;" data-color="#d29922" onclick="pickColor(this)" title="Amber"></div>
<div class="ws-color-swatch" style="background:#1a7f37;" data-color="#1a7f37" onclick="pickColor(this)" title="Green"></div>
<div class="ws-color-swatch" style="background:#cf222e;" data-color="#cf222e" onclick="pickColor(this)" title="Red"></div>
<div class="ws-color-swatch" style="background:#5e6ad2;" data-color="#5e6ad2" onclick="pickColor(this)" title="Indigo"></div>
</div>
</div>
</div>
<div class="form-group" style="margin-bottom:14px;">
<label class="form-label">Workspace name <span class="form-label-required">required</span></label>
<input class="form-input" id="ws-name" type="text" placeholder="Acme Inc" autocomplete="off" oninput="onWsNameInput(this.value)">
</div>
<div class="form-group" style="margin-bottom:14px;">
<label class="form-label">Slug <span class="form-label-required">required</span></label>
<div style="position:relative;">
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:13px;color:var(--text-muted);font-family:monospace;pointer-events:none;">mcp.crank.io/</span>
<input class="form-input input-mono" id="ws-slug" type="text" placeholder="acme-inc" autocomplete="off" style="padding-left: 112px;" oninput="onWsSlugInput(this.value)">
</div>
<div class="form-hint">Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.</div>
</div>
<div class="form-group">
<label class="form-label">Description <span class="form-label-optional">(optional)</span></label>
<textarea class="form-textarea" id="ws-desc" rows="2" placeholder="What does this workspace do?"></textarea>
</div>
</div>
<!-- ── Default settings ── -->
<div class="ws-form-section">
<div class="ws-form-section-title">Default settings</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Default protocol</label>
<select class="form-input" id="ws-protocol">
<option value="rest" selected>REST</option>
<option value="graphql">GraphQL</option>
<option value="grpc">gRPC</option>
</select>
<div class="form-hint">Pre-selected in the operation wizard.</div>
</div>
<div class="form-group">
<label class="form-label">Timezone</label>
<select class="form-input" id="ws-timezone">
<option value="UTC" selected>UTC</option>
<option value="America/New_York">Eastern Time</option>
<option value="America/Chicago">Central Time</option>
<option value="America/Los_Angeles">Pacific Time</option>
<option value="Europe/London">London</option>
<option value="Europe/Berlin">Berlin / Paris</option>
<option value="Asia/Tokyo">Tokyo</option>
<option value="Asia/Shanghai">Shanghai</option>
</select>
</div>
</div>
<div class="form-group" style="margin-top:4px;margin-bottom:0;">
<label class="form-label">Interface language</label>
<div class="lang-switcher">
<button class="lang-btn active" data-lang="en" onclick="setLang('en')">
<span class="lang-flag">🇬🇧</span>
<span data-i18n="settings.lang.en">English</span>
</button>
<button class="lang-btn" data-lang="ru" onclick="setLang('ru')">
<span class="lang-flag">🇷🇺</span>
<span data-i18n="settings.lang.ru">Русский</span>
</button>
</div>
</div>
</div>
<!-- ── Members (edit mode only) ── -->
<div id="section-members" class="ws-form-section" style="display:none; padding:0; overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:20px 24px 16px;">
<div>
<div class="ws-form-section-title" style="margin-bottom:2px;">Team members</div>
<div style="font-size:12px;color:var(--text-muted);" id="members-count-label">4 members</div>
</div>
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;" onclick="toggleInviteForm()">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" style="margin-right:4px;"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
Invite member
</button>
</div>
<!-- Invite form -->
<div id="invite-form" style="display:none; padding: 0 24px 16px; border-bottom: 1px solid var(--border-subtle);">
<div style="display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap;">
<div class="form-group" style="flex:1; min-width:200px; margin-bottom:0;">
<label class="form-label">Email address</label>
<input class="form-input" id="invite-email" type="email" placeholder="colleague@company.com" autocomplete="off">
</div>
<div class="form-group" style="width:140px; margin-bottom:0;">
<label class="form-label">Role</label>
<select class="form-input" id="invite-role">
<option value="admin">Admin</option>
<option value="developer" selected>Developer</option>
<option value="viewer">Viewer</option>
</select>
</div>
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;" onclick="sendInvite()">Send invite</button>
<button class="btn-ghost-sm" type="button" style="padding:7px 12px;" onclick="toggleInviteForm()">Cancel</button>
</div>
<div id="invite-success" style="display:none; margin-top:10px; font-size:13px; color: #3fb950;">✓ Invite sent.</div>
</div>
<!-- Members list -->
<div style="padding: 4px 24px 8px;" id="members-list">
<div class="member-row">
<div class="member-avatar" style="background:linear-gradient(135deg,#0d9488,#6366f1);">AT</div>
<div class="member-info">
<div class="member-name">Operator <span style="font-size:11px;font-weight:400;color:var(--text-muted);">(you)</span></div>
<div class="member-email">operator@acme.com</div>
</div>
<div class="member-last-active">Today</div>
<span class="member-role-badge role-owner">Owner</span>
</div>
<div class="member-row">
<div class="member-avatar" style="background:linear-gradient(135deg,#7c3aed,#4f46e5);">BS</div>
<div class="member-info">
<div class="member-name">Blake Smith</div>
<div class="member-email">blake@acme.com</div>
</div>
<div class="member-last-active">2 days ago</div>
<select class="member-role-select" onchange="updateRole(this, 'blake@acme.com')">
<option value="admin" selected>Admin</option>
<option value="developer">Developer</option>
<option value="viewer">Viewer</option>
</select>
<button class="member-remove-btn" onclick="removeMember(this, 'Blake Smith')" title="Remove member">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
</div>
<div class="member-row">
<div class="member-avatar" style="background:linear-gradient(135deg,#d29922,#f59e0b);">CL</div>
<div class="member-info">
<div class="member-name">Carol Lane</div>
<div class="member-email">carol@acme.com</div>
</div>
<div class="member-last-active">1 week ago</div>
<select class="member-role-select" onchange="updateRole(this, 'carol@acme.com')">
<option value="admin">Admin</option>
<option value="developer" selected>Developer</option>
<option value="viewer">Viewer</option>
</select>
<button class="member-remove-btn" onclick="removeMember(this, 'Carol Lane')" title="Remove member">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
</div>
<div class="member-row">
<div class="member-avatar" style="background:linear-gradient(135deg,#0891b2,#06b6d4);">DM</div>
<div class="member-info">
<div class="member-name">Dan Morgan</div>
<div class="member-email">dan@acme.com</div>
</div>
<div class="member-last-active">Never</div>
<select class="member-role-select" onchange="updateRole(this, 'dan@acme.com')">
<option value="admin">Admin</option>
<option value="developer">Developer</option>
<option value="viewer" selected>Viewer</option>
</select>
<button class="member-remove-btn" onclick="removeMember(this, 'Dan Morgan')" title="Remove member">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
</div>
</div>
<!-- Pending invites -->
<div id="pending-invites" style="border-top: 1px solid var(--border-subtle); padding: 12px 24px 16px;">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);margin-bottom:10px;">
Pending invites <span id="pending-count" style="background:rgba(139,148,158,0.15);border-radius:10px;padding:1px 7px;font-size:10px;">1</span>
</div>
<div class="member-row" id="invite-john">
<div class="member-avatar" style="background:var(--bg-canvas);border:1.5px dashed var(--border);color:var(--text-muted);font-size:16px;">?</div>
<div class="member-info">
<div class="member-name">john@startup.com</div>
<div class="member-email">Invited 3 days ago · not yet accepted</div>
</div>
<div class="member-last-active"></div>
<span class="member-role-badge" style="background:rgba(139,148,158,0.12);color:var(--text-muted);border:1px solid var(--border);">Developer</span>
<button class="member-remove-btn" onclick="revokeInvite(this)" title="Revoke invite">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="2" x2="14" y2="14"/><line x1="14" y1="2" x2="2" y2="14"/></svg>
</button>
</div>
</div>
<!-- Roles reference -->
<div style="border-top:1px solid var(--border-subtle);padding:16px 24px 20px;">
<div style="font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:14px;">Roles &amp; permissions</div>
<div class="role-def-row">
<span class="member-role-badge role-owner" style="min-width:80px;text-align:center;">Owner</span>
<div class="role-def-text">Full workspace control, billing access, can transfer or delete the workspace.</div>
</div>
<div class="role-def-row">
<span class="member-role-badge role-admin" style="min-width:80px;text-align:center;">Admin</span>
<div class="role-def-text">Manage members and roles, create and edit all operations and agents, manage API keys.</div>
</div>
<div class="role-def-row">
<span class="member-role-badge role-developer" style="min-width:80px;text-align:center;">Developer</span>
<div class="role-def-text">Create and edit operations and agents. Cannot manage members or workspace settings.</div>
</div>
<div class="role-def-row">
<span class="member-role-badge role-viewer" style="min-width:80px;text-align:center;">Viewer</span>
<div class="role-def-text">Read-only access to operations, agents, and logs.</div>
</div>
</div>
</div>
<!-- ── Invite team members (create mode only) ── -->
<div id="section-invite" class="ws-form-section" style="display:none;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
<div class="ws-form-section-title" style="margin-bottom:0;">Invite team members <span style="font-size:12px;font-weight:400;color:var(--text-muted);">(optional)</span></div>
<button class="btn-ghost-sm" type="button" onclick="addInviteRow()" style="font-size:12px;padding:4px 10px;">+ Add person</button>
</div>
<div class="form-hint" style="margin-bottom:14px;">You can invite more people later from Workspace settings.</div>
<div class="invite-rows" id="invite-rows">
<div class="invite-row">
<input class="form-input" type="email" placeholder="colleague@company.com" autocomplete="off" style="flex:1;margin-bottom:0;">
<select class="form-input" style="width:130px;margin-bottom:0;">
<option value="admin">Admin</option>
<option value="developer" selected>Developer</option>
<option value="viewer">Viewer</option>
</select>
<button class="invite-row-remove" onclick="removeInviteRow(this)" title="Remove">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
</button>
</div>
</div>
</div>
<!-- ── Actions ── -->
<div class="ws-setup-actions">
<a href="javascript:history.back()" class="btn-ghost-sm" style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);">Cancel</a>
<button class="btn-primary" id="submit-btn" type="button" onclick="submitForm()" style="padding:9px 22px;font-size:13px;">Save changes</button>
</div>
<div class="ws-setup-footer-note" id="footer-note" style="display:none;">
You'll be the Owner of this workspace. You can invite additional members after creation.
</div>
<!-- ── Danger zone (edit mode only) ── -->
<div id="section-danger" style="display:none; margin-top:32px;">
<div class="ws-form-section" style="border-color: rgba(248,81,73,0.25); padding:0; overflow:hidden;">
<div style="padding:16px 24px; border-bottom: 1px solid rgba(248,81,73,0.15);">
<div style="font-size:14px;font-weight:600;color:var(--red);">Danger zone</div>
</div>
<div class="danger-zone-action">
<div class="danger-zone-text">
<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>
<button class="btn-danger" type="button">Export</button>
</div>
<div class="danger-zone-action">
<div class="danger-zone-text">
<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>
<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>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="js/workspace.js"></script>
<script src="js/workspace-setup.js"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs><symbol id="icon" viewBox="0 0 24 24">
<rect x="2" y="3" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.6"/>
<rect x="13" y="3" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.6"/>
<rect x="2" y="14" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.6"/>
<rect x="13" y="14" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.6"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z"/>
<circle cx="8" cy="8" r="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 417 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M7 10a4 4 0 100-8 4 4 0 000 8zm5-1l3 3-2 2-1-1-1 1-1-1 1-1-1-1"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 351 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<defs><symbol id="icon" viewBox="0 0 12 12">
<path fill="currentColor" d="M6 10L1 5h3V2h4v3h3z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14">
<defs><symbol id="icon" viewBox="0 0 14 14">
<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M9 11L5 7l4-4"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 302 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14">
<defs><symbol id="icon" viewBox="0 0 14 14">
<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M5 3l4 4-4 4"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 301 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<defs><symbol id="icon" viewBox="0 0 12 12">
<path fill="currentColor" d="M6 2L1 7h3v3h4V7h3z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="currentColor" d="M8 16a2 2 0 002-2H6a2 2 0 002 2zm.995-14.901a1 1 0 10-1.99 0A5.002 5.002 0 003 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7a5.002 5.002 0 00-4.005-4.901z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 358 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<defs><symbol id="icon" viewBox="0 0 12 12">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M2 6l3 3 5-5"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<defs><symbol id="icon" viewBox="0 0 12 12">
<polyline fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" points="1.5,6 4.5,9 10.5,3"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 316 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
<defs><symbol id="icon" viewBox="0 0 10 10">
<path fill="currentColor" d="M5 7L1 3h8z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 220 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<defs><symbol id="icon" viewBox="0 0 12 12">
<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" d="M2 10L10 2M2 2l8 8"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 283 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M5 3H3a1 1 0 00-1 1v9a1 1 0 001 1h7a1 1 0 001-1v-2M6 1h7a1 1 0 011 1v9a1 1 0 01-1 1H6a1 1 0 01-1-1V2a1 1 0 011-1z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28">
<defs><symbol id="icon" viewBox="0 0 28 28">
<path fill="white" fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/>
<path fill="white" fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/>
<path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/>
</symbol></defs>
<use href="#icon"/>
</svg>

After

Width:  |  Height:  |  Size: 910 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M8 2v9M4 8l4 4 4-4M2 13h12"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 315 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="currentColor" d="M12.146.146a.5.5 0 01.708 0l3 3a.5.5 0 010 .708l-10 10a.5.5 0 01-.168.11l-5 2a.5.5 0 01-.65-.65l2-5a.5.5 0 01.11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 01.5.5v.5h.5a.5.5 0 01.5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 015 12.5V12h-.5a.5.5 0 01-.5-.5V11h-.5a.5.5 0 01-.468-.325z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 596 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="currentColor" d="M10.5 1.5a.5.5 0 01.5-.5h4a.5.5 0 01.5.5v4a.5.5 0 01-1 0V2.707L7.354 9.354a.5.5 0 01-.708-.708L13.293 2H11a.5.5 0 01-.5-.5zM2 3.5A1.5 1.5 0 013.5 2h7a.5.5 0 010 1h-7a.5.5 0 00-.5.5v9a.5.5 0 00.5.5h9a.5.5 0 00.5-.5v-7a.5.5 0 011 0v7A1.5 1.5 0 0112.5 14h-9A1.5 1.5 0 012 12.5v-9z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="currentColor" d="M3.5 2A1.5 1.5 0 002 3.5v1A1.5 1.5 0 003.5 6h1A1.5 1.5 0 006 4.5v-1A1.5 1.5 0 004.5 2h-1zm0 6A1.5 1.5 0 002 9.5v1A1.5 1.5 0 003.5 12h1A1.5 1.5 0 006 10.5v-1A1.5 1.5 0 004.5 8h-1zm5-6A1.5 1.5 0 007 3.5v1A1.5 1.5 0 008.5 6h1A1.5 1.5 0 0011 4.5v-1A1.5 1.5 0 009.5 2h-1zm0 6A1.5 1.5 0 007 9.5v1A1.5 1.5 0 008.5 12h1A1.5 1.5 0 0011 10.5v-1A1.5 1.5 0 009.5 8h-1z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 565 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" d="M8 5v4M8 11v1"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 365 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="currentColor" d="M8 15A7 7 0 108 1a7 7 0 000 14zm.75-10.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5zm-.75 5.5a.75.75 0 110 1.5.75.75 0 010-1.5z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="currentColor" fill-rule="evenodd" d="M10 12.5a.5.5 0 01-.5.5h-8a.5.5 0 01-.5-.5v-9a.5.5 0 01.5-.5h8a.5.5 0 01.5.5v2a.5.5 0 001 0v-2A1.5 1.5 0 009.5 2h-8A1.5 1.5 0 000 3.5v9A1.5 1.5 0 001.5 14h8a1.5 1.5 0 001.5-1.5v-2a.5.5 0 00-1 0v2z"/>
<path fill="currentColor" fill-rule="evenodd" d="M15.854 8.354a.5.5 0 000-.708l-3-3a.5.5 0 10-.708.708L14.293 7.5H5.5a.5.5 0 000 1h8.793l-2.147 2.146a.5.5 0 00.708.708l3-3z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 605 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<defs><symbol id="icon" viewBox="0 0 12 12">
<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" d="M6 1v10M1 6h10"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 279 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="currentColor" d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 337 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M13.5 8A5.5 5.5 0 112.5 5M2.5 2v3h3"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="currentColor" d="M11.742 10.344a6.5 6.5 0 10-1.397 1.398h-.001l3.85 3.85a1 1 0 001.415-1.414l-3.85-3.85a1.007 1.007 0 00-.115-.099zm-5.242 1.656a5.5 5.5 0 110-11 5.5 5.5 0 010 11z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 371 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="currentColor" d="M8 4.754a3.246 3.246 0 100 6.492 3.246 3.246 0 000-6.492zM5.754 8a2.246 2.246 0 114.492 0 2.246 2.246 0 01-4.492 0z"/>
<path fill="currentColor" d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 01-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 01-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 01.52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 011.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 011.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 01.52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 01-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 01-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 002.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 001.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 00-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 00-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 00-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 003.06 4.377l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 004.175.52l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 002.692-1.115l.094-.319z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M6 7v5M10 7v5M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 381 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="currentColor" d="M8 8a3 3 0 100-6 3 3 0 000 6zm2-3a2 2 0 11-4 0 2 2 0 014 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.029 10 8 10c-2.029 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<polygon fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" points="8,1 15,14 1,14"/>
<path fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" d="M8 6v4M8 11.5v.5"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 420 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs><symbol id="icon" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</symbol></defs>
<use href="#icon"/>
</svg>

After

Width:  |  Height:  |  Size: 792 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.7"/>
<path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" d="M8 5v3l2 2"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 362 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" d="M4 2h8a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V4a2 2 0 012-2zM6 6l-2 2 2 2M10 6l2 2-2 2"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 378 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" d="M9 2H4a1 1 0 00-1 1v10a1 1 0 001 1h8a1 1 0 001-1V6zM9 2v4h4"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs><symbol id="icon" viewBox="0 0 24 24">
<path fill="none" stroke="#bc8cff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" d="M12 2l9.5 5.5v11L12 22l-9.5-5.5v-11L12 2z"/>
<circle cx="12" cy="2" r="1.5" fill="#bc8cff"/>
<circle cx="21.5" cy="7.5" r="1.5" fill="#bc8cff"/>
<circle cx="21.5" cy="16.5" r="1.5" fill="#bc8cff"/>
<circle cx="12" cy="22" r="1.5" fill="#bc8cff"/>
<circle cx="2.5" cy="16.5" r="1.5" fill="#bc8cff"/>
<circle cx="2.5" cy="7.5" r="1.5" fill="#bc8cff"/>
</symbol></defs>
<use href="#icon"/>
</svg>

After

Width:  |  Height:  |  Size: 638 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs><symbol id="icon" viewBox="0 0 24 24">
<rect x="2" y="3" width="8" height="18" rx="2" fill="none" stroke="#0d9488" stroke-width="1.8"/>
<rect x="14" y="3" width="8" height="18" rx="2" fill="none" stroke="#0d9488" stroke-width="1.8"/>
<path fill="none" stroke="#0d9488" stroke-width="1.8" stroke-linecap="round" d="M10 8h4M10 12h4M10 16h4"/>
</symbol></defs>
<use href="#icon"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="2.5" fill="none" stroke="currentColor" stroke-width="1.8"/>
<path fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" d="M8 1v2M8 13v2M1 8h2M13 8h2M3.1 3.1l1.4 1.4M11.5 11.5l1.4 1.4M3.1 12.9l1.4-1.4M11.5 4.5l1.4-1.4"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs><symbol id="icon" viewBox="0 0 24 24">
<path fill="none" stroke="#58a6ff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0zM3.6 9h16.8M3.6 15h16.8M12 3a15.3 15.3 0 010 18M12 3a15.3 15.3 0 000 18"/>
</symbol></defs>
<use href="#icon"/>
</svg>

After

Width:  |  Height:  |  Size: 373 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<defs><symbol id="icon" viewBox="0 0 16 16">
<rect x="1" y="3" width="14" height="4" rx="1" fill="none" stroke="currentColor" stroke-width="1.7"/>
<rect x="1" y="9" width="14" height="4" rx="1" fill="none" stroke="currentColor" stroke-width="1.7"/>
<circle cx="4" cy="5" r="0.8" fill="currentColor"/>
<circle cx="4" cy="11" r="0.8" fill="currentColor"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 497 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<defs><symbol id="icon" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</symbol></defs>
<use href="#icon" color="#6b7280"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

+415 -85
View File
@@ -1,94 +1,424 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Crank Console</title>
<style>
:root {
color-scheme: light;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
background: #eef3f7;
color: #162433;
}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Operations</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/catalog.css">
<link rel="stylesheet" href="css/pages.css">
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('html/login.html');}catch(e){}</script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/workspace.js"></script>
<script src="js/api.js"></script>
</head>
<body x-data="catalog()">
* {
box-sizing: border-box;
}
<!-- ═══════════════════ NAVBAR ═══════════════════ -->
<nav class="navbar">
<a href="index.html" class="nav-logo">
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
<span class="nav-logo-text">Crank</span>
</a>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background:
radial-gradient(circle at top, rgba(0, 148, 134, 0.12), transparent 28rem),
linear-gradient(180deg, #f5f8fb 0%, #ebf1f6 100%);
}
<!-- Workspace switcher -->
<div class="ws-switcher" id="ws-switcher">
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
<div class="ws-dot" id="ws-dot">A</div>
<span class="ws-current-name" id="ws-current-name">acme-workspace</span>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button>
<div class="ws-dropdown" id="ws-dropdown" style="display:none">
<div class="ws-dropdown-label">Your workspaces</div>
<div id="ws-dropdown-list"></div>
<div class="ws-dropdown-footer">
<button class="ws-dropdown-create" onclick="window.location.href=(window.APP_BASE||'')+'html/workspace-setup.html?mode=create'">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
Create workspace
</button>
</div>
</div>
</div>
main {
width: min(42rem, calc(100vw - 3rem));
padding: 3rem;
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(22, 36, 51, 0.08);
box-shadow: 0 24px 60px rgba(22, 36, 51, 0.12);
}
<div class="nav-links">
<a href="index.html" class="nav-link active" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="nav-link" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="nav-link" data-i18n="nav.usage">Usage</a>
</div>
img {
width: 5rem;
height: 5rem;
display: block;
margin-bottom: 1.5rem;
}
<div class="nav-right">
<!-- hamburger (mobile only) -->
<button class="nav-hamburger" @click.stop="navOpen = !navOpen" :class="{ open: navOpen }" aria-label="Menu">
<span></span><span></span><span></span>
</button>
h1 {
margin: 0 0 0.75rem;
font-size: clamp(2rem, 5vw, 3rem);
line-height: 1;
}
<div class="nav-kbd">
<svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg>
<span>⌘K</span>
</div>
p {
margin: 0 0 1rem;
font-size: 1rem;
line-height: 1.6;
color: #516070;
}
<button class="nav-icon-btn" title="Notifications">
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
</button>
.badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.45rem 0.8rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #0f766e;
background: rgba(15, 118, 110, 0.12);
}
<div class="nav-divider"></div>
.muted {
font-size: 0.95rem;
color: #6a7b8d;
}
</style>
</head>
<body>
<main>
<img src="/Crank.png" alt="Crank logo" />
<div class="badge">UI replacement in progress</div>
<h1>Crank Console</h1>
<p>
The legacy React/Vite UI has been removed. A new operator console is being integrated on top of the same
deployment path.
</p>
<p class="muted">
Backend services, MCP endpoints and deployment contracts remain intact. Replace this static entrypoint with
the new Alpine.js UI when it is ready.
</p>
</main>
</body>
</html>
<!-- User menu -->
<div class="user-menu" @click.stop>
<div class="nav-avatar" @click="toggleDropdown('user')" title="Account">AT</div>
<div class="user-dropdown" x-show="openDropdown === 'user'" x-transition>
<div class="user-dropdown-header">
<div class="user-dropdown-name">Operator</div>
<div class="user-dropdown-role">Admin · acme-workspace</div>
</div>
<button class="user-dropdown-item" @click="openDropdown = null; window.location.href='html/settings.html'">
<svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg>
<span data-i18n="nav.settings">Settings</span>
</button>
<div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" @click="handleLogout()">
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
<span data-i18n="nav.logout">Log out</span>
</button>
</div>
</div>
</div>
</nav>
<!-- Mobile nav dropdown -->
<div class="mobile-nav" x-show="navOpen" x-transition @click.stop>
<a href="index.html" class="mobile-nav-link active" data-i18n="nav.operations">Operations</a>
<a href="html/agents.html" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
<a href="html/api-keys.html" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="html/logs.html" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
<a href="html/usage.html" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
</div>
<!-- ═══════════════════ PAGE ═══════════════════ -->
<div class="page">
<!-- Page header -->
<div class="page-header">
<div>
<h1 class="page-heading" data-i18n="ops.title">Operations</h1>
<p class="page-subheading" data-i18n="ops.subtitle">Tool contracts that expose your upstream APIs as MCP tools</p>
</div>
<button class="btn-new" @click="handleNewOperation()">
<svg width="13" height="13"><use href="icons/general/plus.svg#icon"/></svg>
New operation
</button>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-label" data-i18n="stats.total">Total operations</div>
<div class="stat-value" x-text="formatMetricNumber(stats.total)"></div>
<div class="stat-delta" x-text="stats.total ? 'Catalog synced with backend' : 'No operations yet'"></div>
</div>
<div class="stat-card">
<div class="stat-label" data-i18n="stats.active">Active</div>
<div class="stat-value" x-text="formatMetricNumber(stats.active)"></div>
<div class="stat-delta" x-text="stats.total ? Math.round((stats.active / stats.total) * 100) + '% of total' : 'No published tools yet'"></div>
</div>
<div class="stat-card">
<div class="stat-label" data-i18n="stats.requests">Requests today</div>
<div class="stat-value" x-text="formatMetricNumber(stats.requestsToday)"></div>
<div class="stat-delta" x-text="stats.requestsToday ? 'Summed from operation usage' : 'No traffic recorded today'"></div>
</div>
<div class="stat-card">
<div class="stat-label" data-i18n="stats.latency">Avg. latency</div>
<div class="stat-value" x-text="stats.averageLatencyMs ? stats.averageLatencyMs + 'ms' : '—'"></div>
<div class="stat-delta" x-text="stats.averageLatencyMs ? 'Weighted by today\\'s calls' : 'Waiting for runtime samples'"></div>
</div>
</div>
<!-- Tabs -->
<div class="tab-row">
<template x-for="t in tabDefs" :key="t.id">
<button
class="tab-item"
:class="{ active: tab === t.id }"
@click="setTab(t.id)"
>
<span x-text="t.label"></span>
<span class="tab-count" x-text="tabCount(t.id)"></span>
</button>
</template>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<!-- Search -->
<div class="search-box">
<svg width="14" height="14"><use href="icons/general/search.svg#icon"/></svg>
<input
type="text"
placeholder="Search operations..."
:value="search"
@input="setSearch($event.target.value)"
>
</div>
<!-- Protocol dropdown -->
<div class="filter-dropdown" @click.stop>
<button
class="filter-chip"
:class="{ 'has-value': filterProtocol, open: openDropdown === 'protocol' }"
@click="toggleDropdown('protocol')"
>
<span x-text="filterProtocol ? filterProtocol.toUpperCase() : 'Protocol'"></span>
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
</button>
<div class="dropdown-menu" x-show="openDropdown === 'protocol'" x-transition>
<template x-for="p in ['rest','graphql','grpc']" :key="p">
<button
class="dropdown-item"
:class="{ selected: filterProtocol === p }"
@click="setProtocol(p)"
>
<span x-text="p === 'rest' ? 'REST' : p === 'graphql' ? 'GraphQL' : 'gRPC'"></span>
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
</button>
</template>
<template x-if="filterProtocol">
<div>
<div class="dropdown-divider"></div>
<button class="dropdown-clear" @click="clearProtocol()">Clear filter</button>
</div>
</template>
</div>
</div>
<!-- Category dropdown -->
<div class="filter-dropdown" @click.stop>
<button
class="filter-chip"
:class="{ 'has-value': filterCategory, open: openDropdown === 'category' }"
@click="toggleDropdown('category')"
>
<span x-text="filterCategory ?? 'Category'"></span>
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
</button>
<div class="dropdown-menu" x-show="openDropdown === 'category'" x-transition>
<template x-for="c in categoryOptions" :key="c">
<button
class="dropdown-item"
:class="{ selected: filterCategory === c }"
@click="setCategory(c)"
>
<span x-text="c"></span>
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
</button>
</template>
<template x-if="filterCategory">
<div>
<div class="dropdown-divider"></div>
<button class="dropdown-clear" @click="clearCategory()">Clear filter</button>
</div>
</template>
</div>
</div>
<!-- Agent dropdown -->
<div class="filter-dropdown" @click.stop>
<button
class="filter-chip"
:class="{ 'has-value': filterAgent, open: openDropdown === 'agent' }"
@click="toggleDropdown('agent')"
>
<span x-text="filterAgent ? activeAgents.find(a => a.agent_id === filterAgent)?.display_name : 'Agent'"></span>
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
</button>
<div class="dropdown-menu" x-show="openDropdown === 'agent'" x-transition>
<template x-for="a in activeAgents" :key="a.agent_id">
<button
class="dropdown-item"
:class="{ selected: filterAgent === a.agent_id }"
@click="setAgent(a.agent_id)"
>
<span x-text="a.display_name"></span>
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
</button>
</template>
<template x-if="!activeAgents.length">
<div class="dropdown-item" style="color:var(--text-muted);cursor:default;">No active agents</div>
</template>
<template x-if="filterAgent">
<div>
<div class="dropdown-divider"></div>
<button class="dropdown-clear" @click="clearAgent()">Clear filter</button>
</div>
</template>
</div>
</div>
<!-- Right side: count + sort -->
<div class="filter-right">
<span class="results-count">
Showing <span x-text="totalFiltered"></span> of <span x-text="operations.length"></span>
</span>
<!-- Sort dropdown -->
<div class="sort-dropdown" @click.stop>
<button
class="sort-chip"
:class="{ open: openDropdown === 'sort' }"
@click="toggleDropdown('sort')"
>
<span x-text="sortLabel"></span>
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
</button>
<div class="dropdown-menu" style="right:0;left:auto;min-width:200px" x-show="openDropdown === 'sort'" x-transition>
<template x-for="opt in sortOptions" :key="opt.value">
<button
class="dropdown-item"
:class="{ selected: sort === opt.value }"
@click="setSort(opt.value)"
>
<span x-text="opt.label"></span>
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
</button>
</template>
</div>
</div>
</div>
</div><!-- /filter-bar -->
<!-- Active filter chips -->
<div class="active-filters" x-show="hasActiveFilters" x-transition>
<template x-for="chip in activeChips" :key="chip.key">
<div class="active-filter-chip">
<span x-text="chip.label"></span>
<button class="active-filter-chip-remove" @click="clearChip(chip.key)" title="Remove filter">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="7" y2="7"/><line x1="7" y1="1" x2="1" y2="7"/></svg>
</button>
</div>
</template>
<button class="active-filter-clear-all" @click="clearAllFilters()" x-show="activeChips.length > 1">
Clear all
</button>
</div>
<!-- Table -->
<div class="table-wrap">
<!-- Loading -->
<div class="empty-state" x-show="loading">
<p data-i18n="ops.loading">Loading operations…</p>
</div>
<!-- Empty state -->
<div class="empty-state" x-show="!loading && totalFiltered === 0">
<h3 data-i18n="ops.empty.title">No operations found</h3>
<p data-i18n="ops.empty.sub">Try adjusting your search or filters.</p>
</div>
<div class="empty-state" x-show="!loading && loadError">
<h3>Backend connection failed</h3>
<p x-text="loadError"></p>
</div>
<!-- Table itself -->
<table x-show="!loading && totalFiltered > 0 && !loadError">
<thead>
<tr>
<th class="th-sortable" :class="{ 'sort-active': sort.startsWith('name') }" @click="setSort(sort === 'name_asc' ? 'name_desc' : 'name_asc')">
<span data-i18n="th.name">NAME</span>
<span class="sort-arrow" :class="{ active: sort.startsWith('name') }">
<span x-text="sort === 'name_asc' ? '↑' : sort === 'name_desc' ? '↓' : '↕'"></span>
</span>
</th>
<th data-i18n="th.protocol">PROTOCOL</th>
<th data-i18n="th.status">STATUS</th>
<th class="th-sortable col-created" :class="{ 'sort-active': sort.startsWith('created') }" @click="setSort(sort === 'created_asc' ? 'created_desc' : 'created_asc')">
CREATED
<span class="sort-arrow" :class="{ active: sort.startsWith('created') }">
<span x-text="sort === 'created_asc' ? '↑' : sort === 'created_desc' ? '↓' : '↕'"></span>
</span>
</th>
<th class="col-url" data-i18n="th.url">TARGET URL</th>
<th></th>
</tr>
</thead>
<tbody>
<template x-for="op in paginated" :key="op.id">
<tr>
<td>
<div class="op-name" x-text="op.name"></div>
<div class="op-display" x-text="op.display_name"></div>
<div class="op-agent-badges" x-show="(agentsByOpId[op.id] || []).length > 0">
<template x-for="a in (agentsByOpId[op.id] || [])" :key="a.agent_id">
<span class="op-agent-badge" x-text="a.display_name"></span>
</template>
</div>
</td>
<td>
<span :class="protocolClass(op)" x-text="protocolLabel(op)"></span>
</td>
<td>
<span :class="statusClass(op)">
<span class="status-dot"></span>
<span x-text="statusLabel(op)"></span>
</span>
</td>
<td class="col-created"><span class="date-cell" x-text="formatDate(op.created_at)"></span></td>
<td class="col-url">
<div class="target-url-cell">
<span x-text="truncateUrl(op.target_url)" :title="op.target_url"></span>
<a :href="op.target_url && op.target_url.startsWith('http') ? op.target_url : '#'" target="_blank" rel="noopener" :style="!op.target_url ? 'opacity:.35;pointer-events:none;' : ''">
<svg width="11" height="11"><use href="icons/general/external.svg#icon"/></svg>
</a>
</div>
</td>
<td>
<div class="row-actions">
<button class="row-btn row-btn-edit" title="Edit operation" @click="editOperation(op)">
<svg width="14" height="14"><use href="icons/general/edit.svg#icon"/></svg>
</button>
<button class="row-btn row-btn-delete" title="Delete operation" @click="deleteOperation(op.id)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
<!-- Footer / pagination -->
<div class="table-footer" x-show="!loading && totalFiltered > 0 && !loadError">
<span class="table-footer-info">
Showing <span x-text="pageStart"></span><span x-text="pageEnd"></span> of <span x-text="totalFiltered"></span> operations
</span>
<div class="pagination">
<button class="page-btn" :disabled="page === 1" @click="goPage(page - 1)"></button>
<template x-for="p in totalPages" :key="p">
<button
class="page-btn"
:class="{ active: page === p }"
@click="goPage(p)"
x-text="p"
></button>
</template>
<button class="page-btn" :disabled="page === totalPages" @click="goPage(page + 1)"></button>
</div>
</div>
</div><!-- /table-wrap -->
</div><!-- /page -->
<script src="js/catalog.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
</body>
</html>
+208
View File
@@ -0,0 +1,208 @@
document.addEventListener('alpine:init', () => {
Alpine.data('agents', () => ({
agents: [],
operations: [],
loading: true,
agentSearch: '',
openDropdown: null,
// drawer state
drawerOpen: false,
drawerMode: 'create', // 'create' | 'edit'
editingId: null,
// form fields
form: {
display_name: '',
slug: '',
description: '',
status: 'active',
selectedOps: [],
},
// operations picker
opSearch: '',
slugManuallyEdited: false,
async init() {
const [agentsRes, opsRes] = await Promise.all([
fetch((window.DATA_URL||'data/')+'agents.json'),
fetch((window.DATA_URL||'data/')+'operations.json'),
]);
this.agents = await agentsRes.json();
this.operations = await opsRes.json();
this.loading = false;
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.drawerOpen) this.closeDrawer();
});
document.addEventListener('click', () => { this.openDropdown = null; });
},
// ── computed ──
get filteredAgents() {
const q = this.agentSearch.toLowerCase().trim();
if (!q) return this.agents;
return this.agents.filter(a =>
a.display_name.toLowerCase().includes(q) ||
a.slug.toLowerCase().includes(q) ||
(a.description || '').toLowerCase().includes(q)
);
},
get totalCalls() {
return this.agents.reduce((s, a) => s + (a.calls_today || 0), 0);
},
get activeCount() {
return this.agents.filter(a => a.status === 'active').length;
},
get totalOpsExposed() {
return this.agents.reduce((s, a) => s + (a.operation_count || 0), 0);
},
get filteredOps() {
const q = this.opSearch.toLowerCase();
if (!q) return this.operations;
return this.operations.filter(op =>
op.name.toLowerCase().includes(q) ||
op.display_name.toLowerCase().includes(q)
);
},
// ── drawer ──
openCreate() {
this.drawerMode = 'create';
this.editingId = null;
this.form = { display_name: '', slug: '', description: '', status: 'active', selectedOps: [] };
this.opSearch = '';
this.slugManuallyEdited = false;
this.drawerOpen = true;
},
openEdit(agent) {
this.drawerMode = 'edit';
this.editingId = agent.id;
this.form = {
display_name: agent.display_name,
slug: agent.slug,
description: agent.description,
status: agent.status,
selectedOps: [...(agent.operation_ids || [])],
};
this.opSearch = '';
this.slugManuallyEdited = true;
this.drawerOpen = true;
},
closeDrawer() {
this.drawerOpen = false;
},
// ── slug auto-derive ──
onNameInput(val) {
this.form.display_name = val;
if (!this.slugManuallyEdited) {
this.form.slug = val.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
}
},
onSlugInput(val) {
this.form.slug = val.toLowerCase().replace(/[^a-z0-9-]/g, '');
this.slugManuallyEdited = true;
},
// ── operations picker ──
toggleOp(opId) {
const idx = this.form.selectedOps.indexOf(opId);
if (idx === -1) this.form.selectedOps.push(opId);
else this.form.selectedOps.splice(idx, 1);
},
isOpSelected(opId) {
return this.form.selectedOps.includes(opId);
},
// ── save ──
saveAgent() {
if (!this.form.display_name.trim() || !this.form.slug.trim()) return;
const ws = (localStorage.getItem('crank_workspace') || 'acme-workspace');
const endpoint = '/mcp/v1/' + ws + '/' + this.form.slug;
if (this.drawerMode === 'create') {
this.agents.unshift({
id: 'ag-' + Date.now(),
slug: this.form.slug,
display_name: this.form.display_name,
description: this.form.description,
status: this.form.status,
operation_count: this.form.selectedOps.length,
operation_ids: [...this.form.selectedOps],
key_count: 0,
created_at: new Date().toISOString(),
created_by: 'AT',
last_called_at: null,
calls_today: 0,
mcp_endpoint: endpoint,
});
} else {
const idx = this.agents.findIndex(a => a.id === this.editingId);
if (idx !== -1) {
this.agents[idx] = Object.assign({}, this.agents[idx], {
display_name: this.form.display_name,
slug: this.form.slug,
description: this.form.description,
status: this.form.status,
operation_count: this.form.selectedOps.length,
operation_ids: [...this.form.selectedOps],
mcp_endpoint: endpoint,
});
}
}
this.closeDrawer();
},
// ── delete ──
deleteAgent(id) {
this.agents = this.agents.filter(a => a.id !== id);
},
// ── helpers ──
mcpEndpoint(agent) {
if (agent.mcp_endpoint) return agent.mcp_endpoint;
const ws = localStorage.getItem('crank_workspace') || 'acme-workspace';
return '/mcp/v1/' + ws + '/' + agent.slug;
},
copyEndpoint(agent) {
navigator.clipboard.writeText(this.mcpEndpoint(agent)).catch(() => {});
},
statusClass(status) {
return 'agent-status-badge agent-status-' + status;
},
protocolBadge(op) {
if (op.protocol === 'rest') return 'badge badge-rest';
if (op.protocol === 'graphql') return 'badge badge-graphql';
return 'badge badge-grpc';
},
protocolLabel(op) {
if (op.protocol === 'rest') return 'REST';
if (op.protocol === 'graphql') return 'GQL';
return 'gRPC';
},
formatDate(dateStr) {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
},
formatCalls(n) {
if (!n) return '0';
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
},
}));
});
+241
View File
@@ -0,0 +1,241 @@
/*
Crank API Keys page
Data: GET /api/workspaces/{slug}/keys
*/
var KEYS = [];
var currentWorkspaceSlug = '';
var SCOPES = ['read', 'write', 'deploy'];
var SCOPE_DESC = {
read: 'Read operations, logs, and usage',
write: 'Create and update operations',
deploy: 'Publish and rollback deployments',
};
var selectedScopes = new Set(['read']);
var search = '';
// ── API helpers ──────────────────────────────────────────────────────────────
function apiBase() {
return '/api/workspaces/' + currentWorkspaceSlug + '/keys';
}
// In production: replace fetch(mockUrl()) with fetch(apiBase())
function mockUrl() {
return (window.DATA_URL || 'data/') + 'keys.json';
}
// ── Load keys for current workspace ─────────────────────────────────────────
function loadKeys() {
currentWorkspaceSlug = getCurrentWs().slug;
setTableLoading(true);
// Production: fetch(apiBase())
fetch(mockUrl())
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function(data) {
// Mock JSON is keyed by workspace slug; real API returns array directly
KEYS = Array.isArray(data) ? data : (data[currentWorkspaceSlug] || []);
setTableLoading(false);
renderTable();
})
.catch(function() {
KEYS = [];
setTableLoading(false);
renderTable();
});
}
function setTableLoading(on) {
var tbody = document.getElementById('keys-tbody');
if (!on) return;
tbody.innerHTML = '';
var tr = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = 7;
td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
td.textContent = 'Loading…';
tr.appendChild(td);
tbody.appendChild(tr);
}
// ── Mutations (in production: real fetch calls) ──────────────────────────────
function revokeKey(id) {
if (!confirm('Revoke this key? All requests using it will fail immediately.')) return;
// Production: fetch(apiBase() + '/' + id, { method: 'PATCH', body: JSON.stringify({ status: 'revoked' }) })
var k = KEYS.find(function(k) { return k.id === id; });
if (k) { k.status = 'revoked'; renderTable(); }
}
function deleteKey(id) {
// Production: fetch(apiBase() + '/' + id, { method: 'DELETE' })
KEYS.splice(KEYS.findIndex(function(k) { return k.id === id; }), 1);
renderTable();
}
function createKey(name, scopes) {
// Production: fetch(apiBase(), { method: 'POST', body: JSON.stringify({ name, scopes }) })
// → response: { key: 'mcp_...', prefix: 'mcp_...', id: '...' }
var rand = Array.from({length: 32}, function() { return Math.random().toString(36)[2]; }).join('');
var rawKey = 'mcp_' + name.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4) + '_' + rand;
var prefix = rawKey.slice(0, 12) + '…';
var newKey = {
id: 'key_' + Date.now(),
name: name,
prefix: prefix,
scopes: scopes,
created: new Date().toISOString().slice(0, 10),
lastUsed: 'Never',
status: 'active',
};
KEYS.unshift(newKey);
return rawKey;
}
// ── Render ───────────────────────────────────────────────────────────────────
function renderScopes() {
var el = document.getElementById('scope-checkboxes');
var tmpl = document.getElementById('tmpl-scope-checkbox');
el.innerHTML = '';
SCOPES.forEach(function(s) {
var node = tmpl.content.cloneNode(true);
var input = node.querySelector('input');
input.dataset.scope = s;
if (selectedScopes.has(s)) input.checked = true;
node.querySelector('.scope-checkbox-name').textContent = s;
node.querySelector('.scope-checkbox-desc').textContent = SCOPE_DESC[s];
input.addEventListener('change', function() {
if (this.checked) selectedScopes.add(this.dataset.scope);
else selectedScopes.delete(this.dataset.scope);
});
el.appendChild(node);
});
}
function renderTable() {
var q = search.toLowerCase();
var tbody = document.getElementById('keys-tbody');
var rows = KEYS.filter(function(k) {
return !q || k.name.toLowerCase().includes(q) || k.prefix.includes(q);
});
tbody.innerHTML = '';
if (!rows.length) {
var empty = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = 7;
td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
td.textContent = KEYS.length ? 'No keys match your search' : 'No API keys yet';
empty.appendChild(td);
tbody.appendChild(empty);
return;
}
var tmpl = document.getElementById('tmpl-key-row');
rows.forEach(function(k) {
var node = tmpl.content.cloneNode(true);
node.querySelector('.col-name').textContent = k.name;
node.querySelector('.col-mono').textContent = k.prefix;
node.querySelector('.col-created').textContent = k.created;
node.querySelector('.col-last-used').textContent = k.lastUsed;
var scopesEl = node.querySelector('.col-scopes');
k.scopes.forEach(function(s) {
var span = document.createElement('span');
span.className = 'badge badge-scope';
span.textContent = s;
scopesEl.appendChild(span);
});
var badge = document.createElement('span');
badge.className = k.status === 'active' ? 'badge badge-active' : 'badge badge-revoked';
badge.textContent = k.status === 'active' ? 'Active' : 'Revoked';
node.querySelector('.col-status').appendChild(badge);
var actionsActive = node.querySelector('.actions-active');
var actionsRevoked = node.querySelector('.actions-revoked');
if (k.status === 'active') {
actionsActive.querySelector('[title="Copy key prefix"]')
.addEventListener('click', function() { copyPrefix(k.prefix); });
actionsActive.querySelector('[title="Revoke key"]')
.addEventListener('click', function() { revokeKey(k.id); });
} else {
actionsActive.style.display = 'none';
actionsRevoked.style.display = '';
actionsRevoked.querySelector('[title="Delete"]')
.addEventListener('click', function() { deleteKey(k.id); });
}
tbody.appendChild(node);
});
}
// ── Modal ────────────────────────────────────────────────────────────────────
var modal = document.getElementById('modal-create');
function openModal() {
document.getElementById('modal-form-body').style.display = '';
document.getElementById('modal-reveal-body').style.display = 'none';
document.getElementById('modal-footer-create').style.display = '';
document.getElementById('modal-footer-done').style.display = 'none';
document.getElementById('new-key-name').value = '';
selectedScopes = new Set(['read']);
renderScopes();
modal.classList.add('open');
setTimeout(function() { document.getElementById('new-key-name').focus(); }, 50);
}
function closeModal() { modal.classList.remove('open'); }
document.getElementById('btn-create-key').addEventListener('click', openModal);
document.getElementById('modal-close-btn').addEventListener('click', closeModal);
document.getElementById('modal-cancel-btn').addEventListener('click', closeModal);
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
document.getElementById('modal-confirm-btn').addEventListener('click', function() {
var name = document.getElementById('new-key-name').value.trim();
if (!name) { document.getElementById('new-key-name').focus(); return; }
if (!selectedScopes.size) { alert('Select at least one scope.'); return; }
var rawKey = createKey(name, Array.from(selectedScopes));
document.getElementById('reveal-key-value').textContent = rawKey;
document.getElementById('modal-form-body').style.display = 'none';
document.getElementById('modal-reveal-body').style.display = '';
document.getElementById('modal-footer-create').style.display = 'none';
document.getElementById('modal-footer-done').style.display = '';
renderTable();
});
document.getElementById('modal-done-btn').addEventListener('click', closeModal);
document.getElementById('copy-key-btn').addEventListener('click', function() {
var val = document.getElementById('reveal-key-value').textContent;
navigator.clipboard && navigator.clipboard.writeText(val);
this.innerHTML = '<svg width="13" height="13"><use href="' + (window.APP_BASE||'') + 'icons/general/check.svg#icon"/></svg>';
});
document.getElementById('key-search').addEventListener('input', function() {
search = this.value;
renderTable();
});
// ── Init ─────────────────────────────────────────────────────────────────────
function copyPrefix(prefix) {
navigator.clipboard && navigator.clipboard.writeText(prefix);
}
document.addEventListener('DOMContentLoaded', loadKeys);
+114
View File
@@ -0,0 +1,114 @@
(function() {
var API_BASE = '/api/admin';
function headers(extra) {
return Object.assign({
'Accept': 'application/json',
}, extra || {});
}
async function request(path, options) {
var response = await fetch(API_BASE + path, Object.assign({
credentials: 'same-origin',
headers: headers(),
}, options || {}));
if (response.status === 204) {
return null;
}
var payload = null;
var text = '';
try {
payload = await response.json();
} catch (_error) {
text = await response.text();
}
if (!response.ok) {
var message = payload && payload.error
? payload.error.message
: payload && payload.message
? payload.message
: text
? text
: ('HTTP ' + response.status);
var error = new Error(message);
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
function get(path) {
return request(path);
}
function post(path, body) {
return request(path, {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
});
}
function patch(path, body) {
return request(path, {
method: 'PATCH',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
});
}
function del(path) {
return request(path, { method: 'DELETE' });
}
function postBytes(path, bytes, fileName) {
return request(path, {
method: 'POST',
headers: headers(fileName ? { 'X-File-Name': fileName } : {}),
body: bytes,
});
}
window.CrankApi = {
listWorkspaces: function() {
return get('/workspaces');
},
listOperations: function(workspaceId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations');
},
getOperation: function(workspaceId, operationId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId));
},
getOperationVersion: function(workspaceId, operationId, version) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/versions/' + encodeURIComponent(version));
},
createOperation: function(workspaceId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations', payload);
},
updateOperation: function(workspaceId, operationId, payload) {
return patch('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId), payload);
},
deleteOperation: function(workspaceId, operationId) {
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId));
},
archiveOperation: function(workspaceId, operationId) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/archive', {});
},
createOperationVersion: function(workspaceId, operationId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/versions', payload);
},
uploadProtoFile: function(workspaceId, operationId, content, fileName) {
return postBytes(
'/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/descriptors/proto',
content,
fileName
);
},
};
}());
+431
View File
@@ -0,0 +1,431 @@
const PAGE_SIZE_DESKTOP = 6;
const PAGE_SIZE_MOBILE = 4;
const MOBILE_BREAKPOINT = 960;
function getSortOptions() {
return [
{ value: 'created_desc', label: (window.t && t('sort.created_desc')) || 'Sort: Last created' },
{ value: 'created_asc', label: (window.t && t('sort.created_asc')) || 'Sort: Oldest first' },
{ value: 'name_asc', label: (window.t && t('sort.name_asc')) || 'Sort: Name AZ' },
{ value: 'name_desc', label: (window.t && t('sort.name_desc')) || 'Sort: Name ZA' },
];
}
function getTabDefs() {
return [
{ id: 'all', label: (window.t && t('tab.all')) || 'All' },
{ id: 'active', label: (window.t && t('tab.active')) || 'Active' },
{ id: 'draft', label: (window.t && t('tab.draft')) || 'Draft' },
{ id: 'error', label: (window.t && t('tab.error')) || 'Error' },
{ id: 'inactive', label: (window.t && t('tab.inactive')) || 'Inactive' },
];
}
function uiStatus(rawStatus) {
if (rawStatus === 'published') return 'active';
if (rawStatus === 'archived') return 'inactive';
if (rawStatus === 'testing') return 'active';
return rawStatus || 'draft';
}
function mapOperation(item) {
return {
id: item.id,
name: item.name,
display_name: item.display_name,
protocol: item.protocol,
status: uiStatus(item.status),
raw_status: item.status,
category: item.category || 'general',
created_at: item.created_at,
updated_at: item.updated_at,
published_at: item.published_at,
target_url: item.target_url || '',
method: item.target_action || '',
usage_summary: item.usage_summary || {
calls_today: 0,
error_rate_pct: 0,
avg_latency_ms: 0,
},
agent_refs: item.agent_refs || [],
};
}
function emptyStats() {
return {
total: 0,
active: 0,
requestsToday: 0,
averageLatencyMs: 0,
};
}
function computeStats(operations) {
if (!operations.length) return emptyStats();
var requestsToday = operations.reduce(function(sum, operation) {
return sum + (operation.usage_summary ? operation.usage_summary.calls_today : 0);
}, 0);
var latencyBase = operations.filter(function(operation) {
return operation.usage_summary && operation.usage_summary.calls_today > 0;
});
var latencyWeight = latencyBase.reduce(function(sum, operation) {
return sum + operation.usage_summary.calls_today;
}, 0);
var weightedLatency = latencyBase.reduce(function(sum, operation) {
return sum + (operation.usage_summary.avg_latency_ms * operation.usage_summary.calls_today);
}, 0);
return {
total: operations.length,
active: operations.filter(function(operation) { return operation.status === 'active'; }).length,
requestsToday: requestsToday,
averageLatencyMs: latencyWeight > 0 ? Math.round(weightedLatency / latencyWeight) : 0,
};
}
document.addEventListener('alpine:init', function() {
Alpine.data('catalog', function() {
return {
operations: [],
loading: true,
loadError: '',
pageSize: PAGE_SIZE_DESKTOP,
search: '',
tab: 'all',
filterProtocol: null,
filterCategory: null,
filterAgent: null,
sort: 'created_desc',
page: 1,
openDropdown: null,
navOpen: false,
categoryOptions: [],
workspaceId: null,
stats: emptyStats(),
async init() {
var self = this;
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
this.workspaceId = workspace ? workspace.id : null;
var updatePageSize = function() {
self.pageSize = window.innerWidth <= MOBILE_BREAKPOINT ? PAGE_SIZE_MOBILE : PAGE_SIZE_DESKTOP;
self.page = 1;
};
updatePageSize();
window.addEventListener('resize', updatePageSize);
window.addEventListener('crank:langchange', function() {
self.sort = self.sort;
applyLang();
});
window.addEventListener('crank:workspacechange', async function(event) {
self.workspaceId = event.detail ? event.detail.id : null;
await self.reload();
});
document.addEventListener('click', function(event) {
if (!event.target.closest('.filter-dropdown, .sort-dropdown, .user-menu')) {
self.openDropdown = null;
}
if (!event.target.closest('.navbar')) {
self.navOpen = false;
}
});
await this.reload();
},
async reload() {
this.loading = true;
this.loadError = '';
try {
var response = await window.CrankApi.listOperations(this.workspaceId);
this.operations = (response && response.items ? response.items : []).map(mapOperation);
this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) {
return operation.category;
}).filter(Boolean))).sort();
this.stats = computeStats(this.operations);
} catch (error) {
this.operations = [];
this.categoryOptions = [];
this.stats = emptyStats();
this.loadError = error.message || 'Failed to load operations';
}
this.loading = false;
this.page = 1;
},
_applyNonStatusFilters(operations) {
var filtered = operations;
if (this.search.trim()) {
var query = this.search.toLowerCase();
filtered = filtered.filter(function(operation) {
return operation.name.toLowerCase().includes(query)
|| operation.display_name.toLowerCase().includes(query)
|| operation.category.toLowerCase().includes(query)
|| (operation.target_url || '').toLowerCase().includes(query);
});
}
if (this.filterProtocol) {
filtered = filtered.filter(function(operation) { return operation.protocol === this.filterProtocol; }, this);
}
if (this.filterCategory) {
filtered = filtered.filter(function(operation) { return operation.category === this.filterCategory; }, this);
}
if (this.filterAgent) {
filtered = filtered.filter(function(operation) {
return (operation.agent_refs || []).some(function(agent) { return agent.agent_id === this.filterAgent; }, this);
}, this);
}
return filtered;
},
get filtered() {
var operations = this._applyNonStatusFilters(this.operations);
if (this.tab !== 'all') {
operations = operations.filter(function(operation) {
return operation.status === this.tab;
}, this);
}
operations = operations.slice().sort(function(left, right) {
if (this.sort === 'created_desc') return new Date(right.created_at) - new Date(left.created_at);
if (this.sort === 'created_asc') return new Date(left.created_at) - new Date(right.created_at);
if (this.sort === 'name_asc') return left.name.localeCompare(right.name);
if (this.sort === 'name_desc') return right.name.localeCompare(left.name);
return 0;
}.bind(this));
return operations;
},
get totalFiltered() {
return this.filtered.length;
},
get totalPages() {
return Math.max(1, Math.ceil(this.totalFiltered / this.pageSize));
},
get paginated() {
var start = (this.page - 1) * this.pageSize;
return this.filtered.slice(start, start + this.pageSize);
},
get pageStart() {
return this.totalFiltered === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
},
get pageEnd() {
return Math.min(this.page * this.pageSize, this.totalFiltered);
},
tabCount(tab) {
var operations = this._applyNonStatusFilters(this.operations);
if (tab === 'all') return operations.length;
return operations.filter(function(operation) { return operation.status === tab; }).length;
},
get activeChips() {
var chips = [];
if (this.filterProtocol) chips.push({ key: 'protocol', label: 'Protocol: ' + this.filterProtocol.toUpperCase() });
if (this.filterCategory) chips.push({ key: 'category', label: 'Category: ' + this.filterCategory });
if (this.filterAgent) {
var agent = this.activeAgents.find(function(item) { return item.agent_id === this.filterAgent; }, this);
chips.push({ key: 'agent', label: 'Agent: ' + (agent ? agent.display_name : '') });
}
return chips;
},
get hasActiveFilters() {
return !!(this.filterProtocol || this.filterCategory || this.filterAgent || this.search.trim());
},
clearChip(key) {
if (key === 'protocol') this.filterProtocol = null;
if (key === 'category') this.filterCategory = null;
if (key === 'agent') this.filterAgent = null;
this.page = 1;
},
clearAllFilters() {
this.filterProtocol = null;
this.filterCategory = null;
this.filterAgent = null;
this.search = '';
this.tab = 'all';
this.page = 1;
},
get agentsByOpId() {
var map = {};
this.operations.forEach(function(operation) {
map[operation.id] = operation.agent_refs || [];
});
return map;
},
get activeAgents() {
var seen = {};
var agents = [];
this.operations.forEach(function(operation) {
(operation.agent_refs || []).forEach(function(agent) {
if (!seen[agent.agent_id]) {
seen[agent.agent_id] = true;
agents.push(agent);
}
});
});
return agents.sort(function(left, right) {
return left.display_name.localeCompare(right.display_name);
});
},
get sortLabel() {
return getSortOptions().find(function(option) { return option.value === this.sort; }, this)?.label || 'Sort';
},
setTab(tab) {
this.tab = tab;
this.page = 1;
},
setSearch(value) {
this.search = value;
this.page = 1;
},
setProtocol(value) {
this.filterProtocol = this.filterProtocol === value ? null : value;
this.openDropdown = null;
this.page = 1;
},
setCategory(value) {
this.filterCategory = this.filterCategory === value ? null : value;
this.openDropdown = null;
this.page = 1;
},
setSort(value) {
this.sort = value;
this.openDropdown = null;
},
setAgent(value) {
this.filterAgent = this.filterAgent === value ? null : value;
this.openDropdown = null;
this.page = 1;
},
clearProtocol() {
this.filterProtocol = null;
this.openDropdown = null;
this.page = 1;
},
clearCategory() {
this.filterCategory = null;
this.openDropdown = null;
this.page = 1;
},
clearAgent() {
this.filterAgent = null;
this.openDropdown = null;
this.page = 1;
},
toggleDropdown(name) {
this.openDropdown = this.openDropdown === name ? null : name;
},
goPage(page) {
if (page >= 1 && page <= this.totalPages) this.page = page;
},
editOperation(operation) {
window.location.href = (window.APP_BASE || '') + 'html/wizard/?mode=edit&operationId=' + encodeURIComponent(operation.id);
},
async deleteOperation(id) {
if (!confirm('Delete this operation? This cannot be undone.')) return;
try {
await window.CrankApi.deleteOperation(this.workspaceId, id);
this.operations = this.operations.filter(function(operation) { return operation.id !== id; });
this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) {
return operation.category;
}).filter(Boolean))).sort();
this.stats = computeStats(this.operations);
} catch (error) {
alert(error.message || 'Failed to delete operation');
}
},
protocolLabel(operation) {
if (operation.protocol === 'rest') {
return operation.method ? ('REST · ' + operation.method) : 'REST';
}
if (operation.protocol === 'graphql') return 'GraphQL';
return 'gRPC';
},
protocolClass(operation) {
return 'badge badge-' + operation.protocol;
},
statusClass(operation) {
return 'status-badge status-' + operation.status;
},
statusLabel(operation) {
if (operation.status === 'active') return 'Active';
if (operation.status === 'inactive') return 'Inactive';
if (operation.status === 'error') return 'Error';
return operation.status.charAt(0).toUpperCase() + operation.status.slice(1);
},
formatDate(dateStr) {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
},
truncateUrl(url) {
if (!url) return '—';
return url.length > 42 ? url.slice(0, 42) + '…' : url;
},
formatMetricNumber(value) {
return new Intl.NumberFormat('en-US').format(value || 0);
},
get sortOptions() {
return getSortOptions();
},
get tabDefs() {
return getTabDefs();
},
handleNewOperation() {
window.location.href = (window.APP_BASE || '') + 'html/wizard/';
},
handleLogout() {
localStorage.removeItem('crank_user');
window.location.href = (window.APP_BASE || '') + 'html/login.html';
},
};
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* config.js auto-detects base paths based on current page location.
* Must be loaded before workspace.js and other navigation-using scripts.
*/
(function() {
var p = window.location.pathname;
// html/wizard/ is two levels deep from root
if (/\/html\/wizard\//.test(p)) {
window.APP_BASE = '../../';
window.DATA_URL = '../../data/';
// html/*.html is one level deep from root
} else if (/\/html\//.test(p)) {
window.APP_BASE = '../';
window.DATA_URL = '../data/';
// root level (index.html)
} else {
window.APP_BASE = '';
window.DATA_URL = 'data/';
}
}());
+289
View File
@@ -0,0 +1,289 @@
/*
Crank i18n
Reads `crank_lang` from localStorage ('en'|'ru')
*/
var TRANSLATIONS = {
en: {
// Nav
'nav.operations': 'Operations',
'nav.agents': 'Agents',
'nav.apikeys': 'API Keys',
'nav.logs': 'Logs',
'nav.usage': 'Usage',
'nav.settings': 'Settings',
'nav.logout': 'Log out',
// Operations page
'ops.title': 'Operations',
'ops.subtitle': 'Tool contracts that expose your upstream APIs as MCP tools',
'ops.new': 'New operation',
'ops.loading': 'Loading operations…',
'ops.empty.title': 'No operations found',
'ops.empty.sub': 'Try adjusting your search or filters.',
// Stats
'stats.total': 'Total operations',
'stats.active': 'Active',
'stats.requests': 'Requests today',
'stats.latency': 'Avg. latency',
// Tabs
'tab.all': 'All',
'tab.active': 'Active',
'tab.draft': 'Draft',
'tab.error': 'Error',
'tab.inactive': 'Inactive',
// Filters & sort
'filter.search': 'Search operations...',
'filter.protocol': 'Protocol',
'filter.status': 'Status',
'filter.category': 'Category',
'filter.showing': 'Showing',
'filter.of': 'of',
'filter.clear': 'Clear filter',
'sort.created_desc':'Sort: Last created',
'sort.created_asc': 'Sort: Oldest first',
'sort.name_asc': 'Sort: Name AZ',
'sort.name_desc': 'Sort: Name ZA',
// Table headers
'th.name': 'NAME',
'th.protocol': 'PROTOCOL',
'th.status': 'STATUS',
'th.created': 'CREATED',
'th.url': 'TARGET URL',
// Pagination
'page.showing': 'Showing',
'page.of': 'of',
'page.ops': 'operations',
// API Keys page
'apikeys.title': 'API Keys',
'apikeys.subtitle': 'Manage access tokens for external integrations',
'apikeys.new': 'Create key',
'apikeys.th.name': 'NAME',
'apikeys.th.scope': 'SCOPE',
'apikeys.th.created':'CREATED',
'apikeys.th.last': 'LAST USED',
// Logs page
'logs.title': 'Logs',
'logs.subtitle': 'Real-time request and event log',
'logs.search': 'Search logs...',
'logs.level.all': 'All',
'logs.level.info': 'Info',
'logs.level.warn': 'Warn',
'logs.level.error': 'Error',
'logs.level.debug': 'Debug',
// Usage page
'usage.title': 'Usage',
'usage.subtitle': 'Request volume and performance metrics',
// Settings sidebar
'settings.title': 'Settings',
'settings.nav.workspace': 'Workspace',
'settings.nav.profile': 'Profile',
'settings.nav.members': 'Members',
'settings.nav.security': 'Security',
'settings.nav.notif': 'Notifications',
'settings.nav.danger': 'Danger zone',
// Settings — workspace section
'settings.ws.title': 'Workspace settings',
'settings.ws.subtitle': 'General information about your workspace',
'settings.ws.name': 'Workspace name',
'settings.ws.display': 'Display name',
'settings.ws.desc': 'Description',
'settings.ws.tz': 'Timezone',
'settings.ws.protocol': 'Default protocol',
// Settings — language
'settings.lang.title': 'Language',
'settings.lang.subtitle': 'Interface display language',
'settings.lang.en': 'English',
'settings.lang.ru': 'Русский',
// Settings — profile section
'settings.profile.title': 'Profile',
'settings.profile.name': 'Full name',
'settings.profile.email': 'Email',
'settings.profile.role': 'Role',
// Common buttons
'btn.save': 'Save changes',
'btn.cancel': 'Cancel',
'btn.create': 'Create',
'btn.delete': 'Delete',
'btn.revoke': 'Revoke',
'btn.copy': 'Copy',
// Login
'login.title': 'Sign in',
'login.subtitle': 'Welcome back to your workspace',
'login.email': 'Email',
'login.password': 'Password',
'login.submit': 'Sign in',
'login.sso': 'Continue with Google',
},
ru: {
// Nav
'nav.operations': 'Операции',
'nav.agents': 'Агенты',
'nav.apikeys': 'API Ключи',
'nav.logs': 'Логи',
'nav.usage': 'Использование',
'nav.settings': 'Настройки',
'nav.logout': 'Выйти',
// Operations page
'ops.title': 'Операции',
'ops.subtitle': 'Контракты инструментов, открывающие ваши API как MCP-инструменты',
'ops.new': 'Новая операция',
'ops.loading': 'Загрузка операций…',
'ops.empty.title': 'Операции не найдены',
'ops.empty.sub': 'Попробуйте изменить поисковый запрос или фильтры.',
// Stats
'stats.total': 'Всего операций',
'stats.active': 'Активные',
'stats.requests': 'Запросов сегодня',
'stats.latency': 'Ср. задержка',
// Tabs
'tab.all': 'Все',
'tab.active': 'Активные',
'tab.draft': 'Черновик',
'tab.error': 'Ошибка',
'tab.inactive': 'Неактивные',
// Filters & sort
'filter.search': 'Поиск операций...',
'filter.protocol': 'Протокол',
'filter.status': 'Статус',
'filter.category': 'Категория',
'filter.showing': 'Показано',
'filter.of': 'из',
'filter.clear': 'Сбросить',
'sort.created_desc':'Сначала новые',
'sort.created_asc': 'Сначала старые',
'sort.name_asc': 'Название А–Я',
'sort.name_desc': 'Название Я–А',
// Table headers
'th.name': 'НАЗВАНИЕ',
'th.protocol': 'ПРОТОКОЛ',
'th.status': 'СТАТУС',
'th.created': 'СОЗДАН',
'th.url': 'ЦЕЛЕВОЙ URL',
// Pagination
'page.showing': 'Показано',
'page.of': 'из',
'page.ops': 'операций',
// API Keys page
'apikeys.title': 'API Ключи',
'apikeys.subtitle': 'Управление токенами доступа для внешних интеграций',
'apikeys.new': 'Создать ключ',
'apikeys.th.name': 'НАЗВАНИЕ',
'apikeys.th.scope': 'ОБЛАСТЬ',
'apikeys.th.created':'СОЗДАН',
'apikeys.th.last': 'ПОСЛЕДНЕЕ ИСПОЛЬЗОВАНИЕ',
// Logs page
'logs.title': 'Логи',
'logs.subtitle': 'Журнал запросов и событий в реальном времени',
'logs.search': 'Поиск в логах...',
'logs.level.all': 'Все',
'logs.level.info': 'Info',
'logs.level.warn': 'Warn',
'logs.level.error': 'Error',
'logs.level.debug': 'Debug',
// Usage page
'usage.title': 'Использование',
'usage.subtitle': 'Объём запросов и метрики производительности',
// Settings sidebar
'settings.title': 'Настройки',
'settings.nav.workspace': 'Воркспейс',
'settings.nav.profile': 'Профиль',
'settings.nav.members': 'Участники',
'settings.nav.security': 'Безопасность',
'settings.nav.notif': 'Уведомления',
'settings.nav.danger': 'Опасная зона',
// Settings — workspace section
'settings.ws.title': 'Настройки воркспейса',
'settings.ws.subtitle': 'Общая информация о вашем воркспейсе',
'settings.ws.name': 'Имя воркспейса',
'settings.ws.display': 'Отображаемое имя',
'settings.ws.desc': 'Описание',
'settings.ws.tz': 'Часовой пояс',
'settings.ws.protocol': 'Протокол по умолчанию',
// Settings — language
'settings.lang.title': 'Язык',
'settings.lang.subtitle': 'Язык отображения интерфейса',
'settings.lang.en': 'English',
'settings.lang.ru': 'Русский',
// Settings — profile section
'settings.profile.title': 'Профиль',
'settings.profile.name': 'Полное имя',
'settings.profile.email': 'Email',
'settings.profile.role': 'Роль',
// Common buttons
'btn.save': 'Сохранить',
'btn.cancel': 'Отмена',
'btn.create': 'Создать',
'btn.delete': 'Удалить',
'btn.revoke': 'Отозвать',
'btn.copy': 'Копировать',
// Login
'login.title': 'Войти',
'login.subtitle': 'Добро пожаловать обратно',
'login.email': 'Email',
'login.password': 'Пароль',
'login.submit': 'Войти',
'login.sso': 'Войти через Google',
}
};
function t(key) {
var lang = localStorage.getItem('crank_lang') || 'en';
var tr = TRANSLATIONS[lang] || TRANSLATIONS.en;
return tr[key] !== undefined ? tr[key] : (TRANSLATIONS.en[key] !== undefined ? TRANSLATIONS.en[key] : key);
}
function applyLang() {
// text content
document.querySelectorAll('[data-i18n]').forEach(function(el) {
el.textContent = t(el.getAttribute('data-i18n'));
});
// placeholder
document.querySelectorAll('[data-i18n-ph]').forEach(function(el) {
el.placeholder = t(el.getAttribute('data-i18n-ph'));
});
// highlight active language button in settings
var lang = localStorage.getItem('crank_lang') || 'en';
document.querySelectorAll('.lang-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.lang === lang);
});
}
function setLang(lang) {
localStorage.setItem('crank_lang', lang);
applyLang();
// Notify Alpine components to re-render reactive getters
window.dispatchEvent(new CustomEvent('crank:langchange', { detail: { lang: lang } }));
}
document.addEventListener('DOMContentLoaded', applyLang);
+34
View File
@@ -0,0 +1,34 @@
// If already logged in, redirect to home
try {
if (localStorage.getItem('crank_user')) {
window.location.replace((window.APP_BASE||'')+'index.html');
}
} catch (e) {}
document.getElementById('login-form').addEventListener('submit', function (e) {
e.preventDefault();
var email = document.getElementById('email').value.trim();
var password = document.getElementById('password').value;
var errEl = document.getElementById('login-error');
if (!email || !password) {
errEl.style.display = 'block';
errEl.textContent = 'Please enter your email and password.';
return;
}
// Demo: any non-empty credentials succeed
errEl.style.display = 'none';
var initials = email.split('@')[0].slice(0, 2).toUpperCase();
var displayName = email.split('@')[0].replace(/[._]/g, ' ')
.split(' ').map(function(w) { return w.charAt(0).toUpperCase() + w.slice(1); }).join(' ');
var user = {
name: displayName,
email: email,
role: 'Admin',
workspace: 'acme-workspace',
initials: initials,
};
try { localStorage.setItem('crank_user', JSON.stringify(user)); } catch (ex) {}
window.location.href = (window.APP_BASE||'')+'index.html';
});
+487
View File
@@ -0,0 +1,487 @@
// logs.js — Crank log viewer
document.addEventListener('DOMContentLoaded', function () {
// ── Helpers ────────────────────────────────────────────────────────────────
function minsAgo(mins) {
return new Date(Date.now() - mins * 60 * 1000);
}
function fmtTs(date) {
var d = (date instanceof Date) ? date : new Date(date);
var pad = function (n, w) { return String(n).padStart(w || 2, '0'); };
return (
d.getFullYear() + '-' +
pad(d.getMonth() + 1) + '-' +
pad(d.getDate()) + ' ' +
pad(d.getHours()) + ':' +
pad(d.getMinutes()) + ':' +
pad(d.getSeconds()) + '.' +
pad(d.getMilliseconds(), 3)
);
}
// ── Seed log data ──────────────────────────────────────────────────────────
// Timestamps are computed relative to Date.now() so time-range filtering works.
var LOGS = [
{
id: 1, ts: minsAgo(2), level: 'info', op: 'create_crm_lead',
msg: 'Tool invoked — POST /v1/leads', duration: '142ms', status: 201,
detail: {
req: '{"first_name":"Alice","last_name":"Smith","email":"alice@acme.com"}',
res: '{"lead_id":"ld_9f3a","status":"new","created_at":"2026-03-27T14:32:11Z"}'
}
},
{
id: 2, ts: minsAgo(5), level: 'info', op: 'get_user_profile',
msg: 'Tool invoked — GET /v1/users/me', duration: '38ms', status: 200,
detail: {
req: '{"user_id":"usr_0042"}',
res: '{"id":"usr_0042","name":"Alice Smith","email":"alice@acme.com","role":"admin"}'
}
},
{
id: 3, ts: minsAgo(12), level: 'warn', op: 'send_slack_message',
msg: 'Rate limited by Slack API — retrying after back-off', duration: '1842ms', status: 429,
detail: {
req: '{"channel":"#ops","text":"Deploy finished"}',
res: '{"error":"ratelimited","retry_after":30}'
}
},
{
id: 4, ts: minsAgo(18), level: 'info', op: 'fetch_invoice',
msg: 'Tool invoked — GET /invoices/inv_003', duration: '95ms', status: 200,
detail: {
req: '{"invoice_id":"inv_003"}',
res: '{"id":"inv_003","amount":2400,"currency":"USD","status":"paid"}'
}
},
{
id: 5, ts: minsAgo(26), level: 'error', op: 'stream_telemetry',
msg: 'Upstream unavailable', duration: '0ms', status: 503,
detail: {
req: '{"stream_id":"st_881","since":"2026-03-27T14:00:00Z"}',
res: '503 Service Unavailable\nRetry-After: 60'
}
},
{
id: 6, ts: minsAgo(38), level: 'info', op: 'create_crm_lead',
msg: 'Tool invoked — POST /v1/leads', duration: '167ms', status: 201,
detail: {
req: '{"first_name":"Bob","last_name":"Jones","email":"bob@example.com"}',
res: '{"lead_id":"ld_9f3b","status":"new"}'
}
},
{
id: 7, ts: minsAgo(52), level: 'debug', op: 'get_user_profile',
msg: 'Schema validation passed — 4 fields mapped', duration: '41ms', status: 200,
detail: {
req: '{"user_id":"usr_0041"}',
res: '{"id":"usr_0041","name":"Bob Jones","email":"bob@example.com","role":"viewer"}'
}
},
{
id: 8, ts: minsAgo(75), level: 'info', op: 'list_products',
msg: 'Tool invoked — GET /api/products', duration: '210ms', status: 200,
detail: {
req: '{"limit":20,"page":1}',
res: '{"total":142,"items":[{"id":"p01","name":"Mechanical Keyboard","price":149.99},...]}'
}
},
{
id: 9, ts: minsAgo(110), level: 'info', op: 'create_support_ticket',
msg: 'Tool invoked — POST /v1/tickets', duration: '178ms', status: 201,
detail: {
req: '{"subject":"Login issue","priority":"high","user_id":"usr_0042"}',
res: '{"ticket_id":"tkt_5521","status":"open","created_at":"2026-03-27T12:42:11Z"}'
}
},
{
id: 10, ts: minsAgo(190), level: 'error', op: 'send_slack_message',
msg: 'Upstream returned 503 Service Unavailable — retried 3×', duration: '0ms', status: 503,
detail: {
req: '{"channel":"#alerts","text":"DB failover triggered"}',
res: '503 Service Unavailable\nRetry-After: 30'
}
},
{
id: 11, ts: minsAgo(280), level: 'info', op: 'fetch_invoice',
msg: 'Tool invoked — GET /invoices/inv_002', duration: '88ms', status: 200,
detail: {
req: '{"invoice_id":"inv_002"}',
res: '{"id":"inv_002","amount":1250,"currency":"USD","status":"open"}'
}
},
{
id: 12, ts: minsAgo(420), level: 'warn', op: 'get_order_status',
msg: 'Validation error: missing field order_id', duration: '55ms', status: 422,
detail: {
req: '{"customer_id":"cus_009"}',
res: '{"error":"validation_failed","fields":["order_id"]}'
}
},
{
id: 13, ts: minsAgo(720), level: 'info', op: 'create_crm_lead',
msg: 'Tool invoked — POST /v1/leads', duration: '155ms', status: 200,
detail: {
req: '{"first_name":"Carol","last_name":"Davis","email":"carol@co.io"}',
res: '{"lead_id":"ld_9f3c","status":"new"}'
}
},
{
id: 14, ts: minsAgo(2200), level: 'info', op: 'sync_contacts',
msg: 'Tool invoked — POST /v1/contacts/sync', duration: '340ms', status: 200,
detail: {
req: '{"source":"hubspot","since":"2026-03-25T00:00:00Z"}',
res: '{"synced":48,"skipped":2,"errors":0}'
}
},
{
id: 15, ts: minsAgo(7200), level: 'debug', op: 'list_products',
msg: 'Cache miss — fetching from origin', duration: '198ms', status: 200,
detail: {
req: '{"limit":10,"page":1}',
res: '{"total":142,"items":[...]}'
}
}
];
// ── State ──────────────────────────────────────────────────────────────────
var levelFilter = 'all';
var searchText = '';
var timeRange = '7d';
var openId = null;
var liveMode = true;
var liveTimer = null;
var nextId = 100;
// ── DOM refs ───────────────────────────────────────────────────────────────
var logList = document.getElementById('log-list');
var logSearch = document.getElementById('log-search');
var refreshBtn = document.getElementById('refresh-btn');
var timeRangeSel = document.getElementById('time-range');
var liveDot = document.querySelector('.live-dot');
var liveLabel = document.querySelector('.live-label');
// ── Debug chip injection ───────────────────────────────────────────────────
// The HTML has All/Info/Warn/Error chips. We add Debug after Error.
var chipError = document.getElementById('chip-error');
if (chipError && !document.getElementById('chip-debug')) {
var debugChip = document.createElement('button');
debugChip.className = 'filter-chip';
debugChip.setAttribute('data-level', 'debug');
debugChip.id = 'chip-debug';
var debugSpan = document.createElement('span');
debugSpan.className = 'log-level debug';
debugSpan.style.cssText = 'padding:0 4px;font-size:10px;';
debugSpan.textContent = 'DEBUG';
debugChip.appendChild(debugSpan);
chipError.parentNode.insertBefore(debugChip, chipError.nextSibling);
}
// ── Time-range helpers ─────────────────────────────────────────────────────
function rangeMs(range) {
var map = { '30m': 30, '1h': 60, '6h': 360, '24h': 1440, '7d': 10080 };
var mins = map[range] || 10080;
return mins * 60 * 1000;
}
// ── Render ─────────────────────────────────────────────────────────────────
function statusClass(s) {
if (!s) return '';
if (s < 300) return 'ok';
if (s < 500) return 'warn';
return 'err';
}
function escText(str) {
// Safely return string; all user-facing dynamic strings go through textContent,
// but for pre blocks in detail we use textContent assignment too (see below).
return str;
}
function renderLogs() {
var q = searchText.toLowerCase();
var cutoff = Date.now() - rangeMs(timeRange);
var rows = LOGS.filter(function (l) {
var ts = (l.ts instanceof Date) ? l.ts.getTime() : new Date(l.ts).getTime();
var matchTime = ts >= cutoff;
var matchLevel = levelFilter === 'all' || l.level === levelFilter;
var matchSearch = !q ||
l.msg.toLowerCase().indexOf(q) !== -1 ||
l.op.indexOf(q) !== -1;
return matchTime && matchLevel && matchSearch;
});
// Build DOM nodes instead of innerHTML for XSS safety where data is dynamic.
// For static/controlled detail payloads we still use textContent on pre elements.
var frag = document.createDocumentFragment();
if (!rows.length) {
var empty = document.createElement('div');
empty.style.cssText = 'text-align:center;padding:40px;color:var(--text-muted);font-size:13.5px;';
empty.textContent = 'No log entries match the current filter';
frag.appendChild(empty);
}
rows.forEach(function (l) {
var tsDate = (l.ts instanceof Date) ? l.ts : new Date(l.ts);
var timeStr = fmtTs(tsDate).slice(11, 23); // HH:MM:SS.mmm
// ── Row ──
var row = document.createElement('div');
row.className = 'log-entry';
row.setAttribute('data-id', l.id);
var timeEl = document.createElement('span');
timeEl.className = 'log-time';
timeEl.textContent = timeStr;
row.appendChild(timeEl);
var levelEl = document.createElement('span');
levelEl.className = 'log-level ' + l.level;
levelEl.textContent = l.level.toUpperCase();
row.appendChild(levelEl);
var msgDiv = document.createElement('div');
msgDiv.className = 'log-msg';
var opSpan = document.createElement('span');
opSpan.className = 'log-op-name';
opSpan.textContent = l.op;
msgDiv.appendChild(opSpan);
msgDiv.appendChild(document.createTextNode('\u00a0\u00a0')); // two nbsps
if (l.status) {
var statusEl = document.createElement('span');
statusEl.className = 'log-status ' + statusClass(l.status);
statusEl.textContent = l.status;
msgDiv.appendChild(statusEl);
msgDiv.appendChild(document.createTextNode(' \u00b7 ')); // · separator
}
var msgText = document.createTextNode(l.msg);
msgDiv.appendChild(msgText);
row.appendChild(msgDiv);
var durEl = document.createElement('span');
var durSlow = (l.duration.indexOf('s') !== -1 && parseFloat(l.duration) > 1);
var durClass = durSlow ? 'slow' : (l.level === 'error' ? 'error' : '');
durEl.className = 'log-duration' + (durClass ? ' ' + durClass : '');
durEl.textContent = l.duration;
row.appendChild(durEl);
frag.appendChild(row);
// ── Expanded detail ──
if (l.detail) {
var expDiv = document.createElement('div');
expDiv.className = 'log-entry-expanded' + (l.id === openId ? ' open' : '');
expDiv.setAttribute('data-exp', l.id);
if (l.detail.req) {
var reqLabel = document.createElement('div');
reqLabel.className = 'log-detail-label';
reqLabel.textContent = 'Request body';
expDiv.appendChild(reqLabel);
var reqPre = document.createElement('pre');
reqPre.className = 'log-detail-block';
reqPre.textContent = l.detail.req;
expDiv.appendChild(reqPre);
}
if (l.detail.res) {
var resLabel = document.createElement('div');
resLabel.className = 'log-detail-label';
resLabel.textContent = 'Response';
expDiv.appendChild(resLabel);
var resPre = document.createElement('pre');
resPre.className = 'log-detail-block';
resPre.textContent = l.detail.res;
expDiv.appendChild(resPre);
}
frag.appendChild(expDiv);
}
});
logList.innerHTML = '';
logList.appendChild(frag);
// Attach click handlers for expand/collapse
logList.querySelectorAll('.log-entry').forEach(function (el) {
el.addEventListener('click', function () {
var id = parseInt(this.getAttribute('data-id'), 10);
openId = (openId === id) ? null : id;
logList.querySelectorAll('.log-entry-expanded').forEach(function (exp) {
var expId = parseInt(exp.getAttribute('data-exp'), 10);
exp.classList.toggle('open', expId === openId);
});
});
});
}
// ── Level chip wiring ──────────────────────────────────────────────────────
function wireChips() {
document.querySelectorAll('.filter-chip[data-level]').forEach(function (btn) {
btn.addEventListener('click', function () {
levelFilter = this.getAttribute('data-level');
document.querySelectorAll('.filter-chip[data-level]').forEach(function (b) {
b.classList.remove('active');
});
this.classList.add('active');
renderLogs();
});
});
}
wireChips();
// ── Search ─────────────────────────────────────────────────────────────────
logSearch.addEventListener('input', function () {
searchText = this.value;
renderLogs();
});
// ── Refresh ────────────────────────────────────────────────────────────────
refreshBtn.addEventListener('click', renderLogs);
// ── Time-range select ──────────────────────────────────────────────────────
// The HTML uses a <select id="time-range"> with option values 30m/1h/6h/24h/7d.
// Default the select to "7d" on page load.
if (timeRangeSel) {
// Set initial value to 7d
timeRangeSel.value = '7d';
timeRange = '7d';
timeRangeSel.addEventListener('change', function () {
timeRange = this.value;
renderLogs();
});
}
// ── Live mode ──────────────────────────────────────────────────────────────
var LIVE_OPS = ['create_crm_lead', 'get_user_profile', 'fetch_invoice', 'list_products'];
var LIVE_MSGS = {
'create_crm_lead': 'Tool invoked — POST /v1/leads',
'get_user_profile': 'Tool invoked — GET /v1/users/me',
'fetch_invoice': 'Tool invoked — GET /invoices/inv_live',
'list_products': 'Tool invoked — GET /api/products'
};
var LIVE_STATUSES = { 'info': 200, 'warn': 429, 'error': 503, 'debug': 200 };
function pickLiveLevel() {
var r = Math.random();
if (r < 0.70) return 'info';
if (r < 0.85) return 'warn';
if (r < 0.95) return 'error';
return 'debug';
}
function makeLiveEntry() {
var op = LIVE_OPS[Math.floor(Math.random() * LIVE_OPS.length)];
var level = pickLiveLevel();
var status = LIVE_STATUSES[level];
var duration = (level === 'error') ? '0ms' : (Math.floor(Math.random() * 400) + 20) + 'ms';
nextId += 1;
return {
id: nextId,
ts: new Date(),
level: level,
op: op,
msg: LIVE_MSGS[op],
duration: duration,
status: status,
detail: {
req: '{"live":true}',
res: level === 'error' ? status + ' Service Unavailable' : '{"ok":true}'
}
};
}
function setLivePulsing(on) {
if (!liveDot) return;
if (on) {
liveDot.style.animationPlayState = 'running';
liveDot.style.opacity = '';
} else {
liveDot.style.animationPlayState = 'paused';
liveDot.style.opacity = '0.35';
}
}
function updateLiveLabel() {
if (!liveLabel) return;
liveLabel.textContent = liveMode ? 'Live' : 'Paused';
liveLabel.style.color = liveMode ? '' : 'var(--text-muted)';
}
function startLive() {
if (liveTimer) return;
liveTimer = setInterval(function () {
LOGS.unshift(makeLiveEntry());
if (LOGS.length > 100) {
LOGS.length = 100;
}
renderLogs();
}, 4000);
}
function stopLive() {
if (liveTimer) {
clearInterval(liveTimer);
liveTimer = null;
}
}
function toggleLive() {
liveMode = !liveMode;
setLivePulsing(liveMode);
updateLiveLabel();
if (liveMode) {
startLive();
} else {
stopLive();
}
}
// Wire live toggle to clicking the dot or the label
if (liveDot) {
liveDot.style.cursor = 'pointer';
liveDot.addEventListener('click', toggleLive);
}
if (liveLabel) {
liveLabel.style.cursor = 'pointer';
liveLabel.addEventListener('click', toggleLive);
}
// Also support a dedicated live-toggle button if one exists in the DOM
var liveToggleBtn = document.getElementById('live-toggle') || document.querySelector('.live-btn');
if (liveToggleBtn) {
liveToggleBtn.addEventListener('click', toggleLive);
}
// ── Initialise ─────────────────────────────────────────────────────────────
setLivePulsing(true);
updateLiveLabel();
startLive();
renderLogs();
});
+102
View File
@@ -0,0 +1,102 @@
/**
* nav.js shared navbar logic for non-catalog pages
* Handles: auth guard, user dropdown, hamburger, logout
*/
(function () {
/* ── Auth guard ── */
var STORAGE_KEY = 'crank_user';
var user = null;
try { user = JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch (e) {}
if (!user) {
var path = window.location.pathname;
var isLogin = path.endsWith('login.html') || path.endsWith('/login');
if (!isLogin) { window.location.replace((window.APP_BASE||'')+'html/login.html'); return; }
}
/* ── Fill user info once DOM is available ── */
function fillUserInfo() {
if (!user) return;
document.querySelectorAll('.nav-avatar').forEach(function (el) {
el.textContent = user.initials || 'AT';
});
var nameEl = document.querySelector('.user-dropdown-name');
var roleEl = document.querySelector('.user-dropdown-role');
if (nameEl) nameEl.textContent = user.name || 'Operator';
if (roleEl) roleEl.textContent = (user.role || 'Admin') + ' · ' + (user.workspace || 'workspace');
}
/* ── Wire up nav interactions ── */
function initNav() {
fillUserInfo();
var dropdown = document.querySelector('.user-dropdown');
var avatar = document.querySelector('.nav-avatar');
var hamburger = document.querySelector('.nav-hamburger');
var mobileNav = document.querySelector('.mobile-nav');
// Initially hide elements controlled by JS
if (dropdown) dropdown.style.display = 'none';
if (mobileNav) mobileNav.style.display = 'none';
// User dropdown toggle
if (avatar && dropdown) {
avatar.addEventListener('click', function (e) {
e.stopPropagation();
var showing = dropdown.style.display !== 'none';
dropdown.style.display = showing ? 'none' : 'block';
});
}
// Hamburger toggle
if (hamburger && mobileNav) {
hamburger.addEventListener('click', function (e) {
e.stopPropagation();
var showing = mobileNav.style.display !== 'none';
mobileNav.style.display = showing ? 'none' : 'block';
hamburger.classList.toggle('open', !showing);
});
}
// Close dropdown / mobile-nav on outside click
document.addEventListener('click', function (e) {
if (dropdown && !e.target.closest('.user-menu')) {
dropdown.style.display = 'none';
}
if (mobileNav && !e.target.closest('.navbar') && !e.target.closest('.mobile-nav')) {
mobileNav.style.display = 'none';
if (hamburger) hamburger.classList.remove('open');
}
});
// Action buttons inside dropdown
document.querySelectorAll('[data-action="logout"]').forEach(function (btn) {
btn.addEventListener('click', function () {
localStorage.removeItem(STORAGE_KEY);
window.location.href = (window.APP_BASE||'')+'html/login.html';
});
});
document.querySelectorAll('[data-action="profile"]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (dropdown) dropdown.style.display = 'none';
window.location.href = (window.APP_BASE||'')+'html/settings.html#profile';
});
});
document.querySelectorAll('[data-action="settings-link"]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (dropdown) dropdown.style.display = 'none';
window.location.href = (window.APP_BASE||'')+'html/settings.html';
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initNav);
} else {
initNav();
}
})();
+73
View File
@@ -0,0 +1,73 @@
// Populate profile from stored user
try {
var u = JSON.parse(localStorage.getItem('crank_user'));
if (u) {
document.getElementById('profile-avatar').textContent = u.initials || 'AT';
document.getElementById('profile-display-name').textContent = u.name || 'Operator';
document.getElementById('profile-email').textContent = u.email || '';
var fi = document.getElementById('field-firstname');
var fe = document.getElementById('field-email');
if (fi && u.name) fi.value = u.name;
if (fe && u.email) fe.value = u.email;
}
} catch (e) {}
// Inject language switcher into section-profile card body, before Save button
(function() {
var profileSection = document.getElementById('section-profile');
if (!profileSection) return;
var cardBody = profileSection.querySelector('.section-card-body');
if (!cardBody) return;
fetch('html/fragments/lang-switcher.html')
.then(function(r) { return r.text(); })
.then(function(html) {
var saveRow = cardBody.querySelector('div[style*="justify-content:flex-end"]');
if (saveRow) {
saveRow.insertAdjacentHTML('beforebegin', html);
} else {
cardBody.insertAdjacentHTML('beforeend', html);
}
if (typeof applyLang === 'function') applyLang();
});
})();
// Settings sidebar navigation
var navItems = document.querySelectorAll('.settings-nav-item[data-section]');
navItems.forEach(function(btn) {
btn.addEventListener('click', function() {
navItems.forEach(function(b) { b.classList.remove('active'); });
this.classList.add('active');
var target = document.getElementById('section-' + this.dataset.section);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
// Hash navigation (e.g. settings.html#security)
if (window.location.hash) {
var hash = window.location.hash.slice(1);
var btn = document.querySelector('.settings-nav-item[data-section="' + hash + '"]');
if (btn) setTimeout(function() { btn.click(); }, 100);
}
// Default: scroll to profile
else {
var profileBtn = document.querySelector('.settings-nav-item[data-section="profile"]');
if (profileBtn) profileBtn.classList.add('active');
}
// IntersectionObserver scroll-spy: highlight active sidebar item as user scrolls
var observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
var sectionId = entry.target.id; // e.g. "section-profile"
var name = sectionId.replace('section-', ''); // e.g. "profile"
document.querySelectorAll('.settings-nav-item[data-section]').forEach(function(b) {
b.classList.toggle('active', b.dataset.section === name);
});
}
});
}, { threshold: 0.3 });
// Observe all visible sections (not hidden ones)
document.querySelectorAll('[id^="section-"]').forEach(function(el) {
if (el.style.display !== 'none') observer.observe(el);
});
+270
View File
@@ -0,0 +1,270 @@
document.addEventListener('DOMContentLoaded', function () {
// ── Period datasets ───────────────────────────────────────────────────────
var CHART_DATA = {
'7d': [
{d:'Mon', s:3838, e:18}, {d:'Tue', s:4242, e:32}, {d:'Wed', s:3988, e:28},
{d:'Thu', s:4147, e:45}, {d:'Fri', s:3810, e:22}, {d:'Sat', s:2915, e:15},
{d:'Sun', s:1569, e:8}
],
'30d': [
{d:'Wk 1', s:24500, e:120}, {d:'Wk 2', s:26800, e:145},
{d:'Wk 3', s:25100, e:98}, {d:'Wk 4', s:27400, e:178}
],
'90d': [
{d:'Jan', s:92000, e:420}, {d:'Feb', s:108000, e:510}, {d:'Mar', s:98400, e:458}
],
'this_month': [
{d:'Wk 1', s:24500, e:120}, {d:'Wk 2', s:26800, e:145},
{d:'Wk 3', s:25100, e:98}, {d:'Wk 4', s:27400, e:178}
]
};
// Base 7-day operation data; other periods scale from this.
var OPS_7D = [
{ name: 'create_crm_lead', display: 'Create CRM Lead', proto: 'REST', calls: 9840, errors: 42, p50: 138, p95: 390, p99: 820 },
{ name: 'search_products', display: 'Search Products', proto: 'REST', calls: 7123, errors: 8, p50: 91, p95: 210, p99: 440 },
{ name: 'fetch_invoice', display: 'Fetch Invoice', proto: 'REST', calls: 5210, errors: 214, p50: 104, p95: 540, p99: 8200 },
{ name: 'update_contact', display: 'Update Contact', proto: 'REST', calls: 3882, errors: 28, p50: 192, p95: 460, p99: 910 },
{ name: 'send_email', display: 'Send Email', proto: 'REST', calls: 1440, errors: 96, p50: 310, p95: 2100, p99: 30100 },
{ name: 'list_orders', display: 'List Orders (GraphQL)', proto: 'GraphQL', calls: 670, errors: 2, p50: 66, p95: 180, p99: 320 },
{ name: 'delete_record', display: 'Delete Record', proto: 'REST', calls: 176, errors: 6, p50: 285, p95: 690, p99: 1400 }
];
function scaleOps(factor) {
return OPS_7D.map(function (op) {
return {
name: op.name,
display: op.display,
proto: op.proto,
calls: Math.round(op.calls * factor),
errors: Math.round(op.errors * factor),
p50: op.p50,
p95: op.p95,
p99: op.p99
};
});
}
var OPS_DATA = {
'7d': OPS_7D,
'30d': scaleOps(4.2),
'90d': scaleOps(12),
'this_month': scaleOps(4.2)
};
// ── Stat card data ────────────────────────────────────────────────────────
var STATS = {
'7d': {
invocations: '28,341',
invDelta: { dir: 'up', text: '+12% vs prior period' },
success: '98.6%',
sucDelta: { dir: 'up', text: '+0.3pp vs prior period' },
p50: '124ms',
p50Delta: { dir: 'up', text: '-18ms vs prior period' },
p99: '2.1s',
p99Delta: { dir: 'down', text: '+340ms vs prior period' }
},
'30d': {
invocations: '118,623',
invDelta: { dir: 'up', text: '+8% vs prior period' },
success: '98.4%',
sucDelta: { dir: 'down', text: '-0.2pp vs prior period' },
p50: '128ms',
p50Delta: { dir: 'down', text: '+4ms vs prior period' },
p99: '2.3s',
p99Delta: { dir: 'down', text: '+200ms vs prior period' }
},
'90d': {
invocations: '342,804',
invDelta: { dir: 'up', text: '+15% vs prior period' },
success: '98.7%',
sucDelta: { dir: 'up', text: '+0.1pp vs prior period' },
p50: '121ms',
p50Delta: { dir: 'up', text: '-7ms vs prior period' },
p99: '2.0s',
p99Delta: { dir: 'up', text: '-100ms vs prior period' }
},
'this_month': {
invocations: '118,623',
invDelta: { dir: 'up', text: '+8% vs prior period' },
success: '98.4%',
sucDelta: { dir: 'down', text: '-0.2pp vs prior period' },
p50: '128ms',
p50Delta: { dir: 'down', text: '+4ms vs prior period' },
p99: '2.3s',
p99Delta: { dir: 'down', text: '+200ms vs prior period' }
}
};
// ── Helpers ───────────────────────────────────────────────────────────────
var COLORS = { REST: 'var(--blue)', GraphQL: 'var(--purple)', gRPC: 'var(--accent)' };
var QUOTA = 50000;
function fmtMs(ms) {
return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms';
}
function errRateClass(rate) {
if (rate > 5) return 'color:var(--red)';
if (rate > 1) return 'color:var(--amber)';
return 'color:var(--green)';
}
var ARROW_UP = '<svg width="11" height="11"><use href="' + (window.APP_BASE||'') + 'icons/general/arrow-up.svg#icon"/></svg>';
var ARROW_DOWN = '<svg width="11" height="11"><use href="' + (window.APP_BASE||'') + 'icons/general/arrow-down.svg#icon"/></svg>';
// ── Render functions ──────────────────────────────────────────────────────
function renderChart(period) {
var data = CHART_DATA[period];
var maxVal = Math.max.apply(null, data.map(function (d) { return d.s + d.e; }));
var wrap = document.getElementById('chart-bars');
var tmpl = document.getElementById('tmpl-chart-bar');
wrap.innerHTML = '';
data.forEach(function (d) {
var hS = Math.round((d.s / maxVal) * 160);
var hE = Math.round((d.e / maxVal) * 160);
var node = tmpl.content.cloneNode(true);
node.querySelector('.chart-bar.error').style.height = hE + 'px';
node.querySelector('.chart-bar.error').dataset.tip = d.e + ' errors';
node.querySelector('.chart-bar.success').style.height = hS + 'px';
node.querySelector('.chart-bar.success').dataset.tip = d.s.toLocaleString() + ' ok';
node.querySelector('.chart-col-label').textContent = d.d;
wrap.appendChild(node);
});
}
function renderTable(period) {
var ops = OPS_DATA[period];
var tbody = document.getElementById('usage-tbody');
var tmpl = document.getElementById('tmpl-usage-row');
tbody.innerHTML = '';
ops.forEach(function (op) {
var errRate = (op.errors / op.calls * 100).toFixed(2);
var quotaW = Math.min(op.calls / QUOTA * 100, 100).toFixed(1);
var node = tmpl.content.cloneNode(true);
node.querySelector('.col-name').textContent = op.display;
node.querySelector('.col-op-slug').textContent = op.name;
node.querySelector('.col-proto').textContent = op.proto;
node.querySelector('.col-calls').textContent = op.calls.toLocaleString();
node.querySelector('.col-errors').textContent = op.errors.toLocaleString();
var errEl = node.querySelector('.col-err-rate');
errEl.innerHTML = '<span style="' + errRateClass(parseFloat(errRate)) + '">' + errRate + '%</span>';
var p50w = (op.p50 / op.p99 * 64).toFixed(1);
var p95w = ((op.p95 - op.p50) / op.p99 * 64).toFixed(1);
var p99w = ((op.p99 - op.p95) / op.p99 * 64).toFixed(1);
node.querySelector('.latency-p50').style.width = p50w + 'px';
node.querySelector('.latency-p50').title = 'p50: ' + fmtMs(op.p50);
node.querySelector('.latency-p95').style.width = p95w + 'px';
node.querySelector('.latency-p95').title = 'p95: ' + fmtMs(op.p95);
node.querySelector('.latency-p99').style.width = p99w + 'px';
node.querySelector('.latency-p99').title = 'p99: ' + fmtMs(op.p99);
node.querySelector('.col-latency-text').textContent =
fmtMs(op.p50) + ' / ' + fmtMs(op.p95) + ' / ' + fmtMs(op.p99);
node.querySelector('.col-quota-label').textContent =
op.calls.toLocaleString() + ' / ' + QUOTA.toLocaleString();
node.querySelector('.col-quota-fill').style.width = quotaW + '%';
tbody.appendChild(node);
});
}
function renderStats(period) {
var s = STATS[period];
var cards = document.querySelectorAll('.stats-grid .stat-card');
var data = [
{ value: s.invocations, delta: s.invDelta },
{ value: s.success, delta: s.sucDelta },
{ value: s.p50, delta: s.p50Delta },
{ value: s.p99, delta: s.p99Delta }
];
for (var i = 0; i < cards.length; i++) {
var card = cards[i];
var d = data[i];
card.querySelector('.stat-value').textContent = d.value;
var deltaEl = card.querySelector('.stat-delta');
deltaEl.className = 'stat-delta ' + d.delta.dir;
var arrow = d.delta.dir === 'up' ? ARROW_UP : ARROW_DOWN;
deltaEl.innerHTML = arrow + ' ' + d.delta.text;
}
}
function updateSubtitle(period) {
var labels = {
'7d': 'last 7 days',
'30d': 'last 30 days',
'90d': 'last 90 days',
'this_month': 'this month'
};
var el = document.querySelector('.section-card-subtitle');
if (el) el.textContent = 'Breakdown for ' + (labels[period] || period);
}
// ── CSV export ────────────────────────────────────────────────────────────
function exportCSV(period) {
var ops = OPS_DATA[period];
var rows = ['Operation,Protocol,Calls,Errors,Error Rate (%),p50 (ms),p95 (ms),p99 (ms)'];
ops.forEach(function (op) {
var errRate = (op.errors / op.calls * 100).toFixed(2);
rows.push([
'"' + op.display + '"',
op.proto,
op.calls,
op.errors,
errRate,
op.p50,
op.p95,
op.p99
].join(','));
});
var csv = rows.join('\r\n');
var blob = new Blob([csv], {type: 'text/csv'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'crank-usage-' + period + '.csv';
a.click();
URL.revokeObjectURL(url);
}
// ── Period selector ───────────────────────────────────────────────────────
var currentPeriod = '7d';
var periodSelect = document.getElementById('period');
if (periodSelect) {
periodSelect.value = currentPeriod;
periodSelect.addEventListener('change', function () {
currentPeriod = this.value;
renderChart(currentPeriod);
renderTable(currentPeriod);
renderStats(currentPeriod);
updateSubtitle(currentPeriod);
});
}
// Export CSV button — first .btn-secondary inside .page-header-actions
var exportBtn = document.querySelector('.page-header-actions .btn-secondary');
if (exportBtn) {
exportBtn.addEventListener('click', function () {
exportCSV(currentPeriod);
});
}
// ── Initial render ────────────────────────────────────────────────────────
renderChart(currentPeriod);
renderTable(currentPeriod);
renderStats(currentPeriod);
updateSubtitle(currentPeriod);
});
+1426
View File
File diff suppressed because it is too large Load Diff
+193
View File
@@ -0,0 +1,193 @@
var selectedColor = '#0d9488';
var slugManual = false;
var isCreateMode = false;
var formDirty = false;
function initPage() {
var params = new URLSearchParams(window.location.search);
isCreateMode = params.get('mode') === 'create';
if (isCreateMode) {
document.title = 'Crank — Create workspace';
document.getElementById('page-title').textContent = 'Create a new workspace';
document.getElementById('page-subtitle').textContent = 'A workspace is a shared environment for your team\'s MCP operations and agents. Each workspace gets its own MCP endpoint namespace.';
document.getElementById('section-invite').style.display = '';
document.getElementById('footer-note').style.display = '';
var btn = document.getElementById('submit-btn');
btn.textContent = 'Create workspace';
btn.disabled = true;
btn.style.opacity = '0.5';
btn.style.cursor = 'not-allowed';
} else {
// Edit mode — prefill from current workspace
document.getElementById('section-members').style.display = '';
document.getElementById('section-danger').style.display = '';
var ws = getCurrentWs();
document.getElementById('ws-name').value = ws.name;
document.getElementById('ws-slug').value = ws.slug;
document.getElementById('ws-avatar-preview').textContent = ws.letter;
document.getElementById('ws-avatar-preview').style.background = ws.color;
selectedColor = ws.color;
slugManual = true;
// Activate matching color swatch
document.querySelectorAll('.ws-color-swatch').forEach(function(s) {
s.classList.toggle('active', s.dataset.color === ws.color);
});
// Scroll to #section-members if hash present
if (window.location.hash === '#section-members') {
setTimeout(function() {
document.getElementById('section-members').scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}
// Apply language state to lang buttons
var lang = localStorage.getItem('crank_lang') || 'en';
document.querySelectorAll('.lang-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.lang === lang);
});
}
function onWsNameInput(val) {
var letter = val.trim() ? val.trim()[0].toUpperCase() : '?';
document.getElementById('ws-avatar-preview').textContent = letter;
if (!slugManual) {
var slug = val.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
document.getElementById('ws-slug').value = slug;
}
if (isCreateMode) validateCreateForm();
formDirty = true;
}
function onWsSlugInput(val) {
var clean = val.toLowerCase().replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-');
document.getElementById('ws-slug').value = clean;
slugManual = true;
if (isCreateMode) validateCreateForm();
formDirty = true;
}
function pickColor(el) {
selectedColor = el.dataset.color;
document.querySelectorAll('.ws-color-swatch').forEach(function(s) { s.classList.remove('active'); });
el.classList.add('active');
document.getElementById('ws-avatar-preview').style.background = selectedColor;
formDirty = true;
}
function validateCreateForm() {
var name = document.getElementById('ws-name').value.trim();
var slug = document.getElementById('ws-slug').value.trim();
var btn = document.getElementById('submit-btn');
var valid = name.length > 0 && slug.length > 0;
btn.disabled = !valid;
btn.style.opacity = valid ? '1' : '0.5';
btn.style.cursor = valid ? 'pointer' : 'not-allowed';
}
function submitForm() {
var name = document.getElementById('ws-name').value.trim();
var slug = document.getElementById('ws-slug').value.trim();
if (!name || !slug) return;
formDirty = false;
if (isCreateMode) {
if (window.WS_LIST) {
WS_LIST.push({ slug: slug, name: slug, role: 'Owner', letter: name[0].toUpperCase(), color: selectedColor });
}
localStorage.setItem('crank_workspace', slug);
window.location.href = (window.APP_BASE||'')+'index.html';
} else {
// Update current workspace in WS_LIST
if (window.WS_LIST) {
var current = localStorage.getItem('crank_workspace') || 'acme-workspace';
var idx = WS_LIST.findIndex(function(w) { return w.slug === current; });
if (idx !== -1) {
WS_LIST[idx].name = slug;
WS_LIST[idx].slug = slug;
WS_LIST[idx].letter = name[0].toUpperCase();
WS_LIST[idx].color = selectedColor;
}
}
localStorage.setItem('crank_workspace', slug);
// Show saved feedback
var btn = document.getElementById('submit-btn');
var orig = btn.textContent;
btn.textContent = 'Saved ✓';
btn.disabled = true;
setTimeout(function() { btn.textContent = orig; btn.disabled = false; }, 1800);
}
}
// Create mode invite rows
function addInviteRow() {
var rows = document.getElementById('invite-rows');
var div = document.createElement('div');
div.className = 'invite-row';
div.innerHTML =
'<input class="form-input" type="email" placeholder="colleague@company.com" autocomplete="off" style="flex:1;margin-bottom:0;">' +
'<select class="form-input" style="width:130px;margin-bottom:0;">' +
'<option value="admin">Admin</option>' +
'<option value="developer" selected>Developer</option>' +
'<option value="viewer">Viewer</option>' +
'</select>' +
'<button class="invite-row-remove" onclick="removeInviteRow(this)" title="Remove">' +
'<svg width="12" height="12"><use href="' + (window.APP_BASE||'') + 'icons/general/close.svg#icon"/></svg>' +
'</button>';
rows.appendChild(div);
}
function removeInviteRow(btn) {
btn.closest('.invite-row').remove();
}
// Members management (edit mode)
function toggleInviteForm() {
var f = document.getElementById('invite-form');
var s = document.getElementById('invite-success');
if (f.style.display === 'none') {
f.style.display = '';
s.style.display = 'none';
document.getElementById('invite-email').focus();
} else {
f.style.display = 'none';
}
}
function sendInvite() {
var email = document.getElementById('invite-email').value.trim();
if (!email || !email.includes('@')) return;
document.getElementById('invite-success').style.display = '';
document.getElementById('invite-email').value = '';
}
function updateRole(select, email) {
console.log('Role updated:', email, '->', select.value);
}
function removeMember(btn, name) {
if (!confirm('Remove ' + name + ' from this workspace?')) return;
btn.closest('.member-row').remove();
var rows = document.querySelectorAll('#members-list .member-row');
var label = document.getElementById('members-count-label');
if (label) label.textContent = rows.length + ' member' + (rows.length !== 1 ? 's' : '');
}
function revokeInvite(btn) {
btn.closest('.member-row').remove();
var pending = document.getElementById('pending-invites');
if (pending && !pending.querySelector('.member-row')) pending.style.display = 'none';
}
document.addEventListener('DOMContentLoaded', function() {
initPage();
if (isCreateMode) document.getElementById('ws-name').focus();
});
window.addEventListener('beforeunload', function(e) {
if (formDirty) {
e.preventDefault();
e.returnValue = '';
}
});
+148
View File
@@ -0,0 +1,148 @@
var WS_LIST = [
{ id: 'ws_default', slug: 'default', name: 'Default workspace', role: 'Owner', letter: 'D', color: '#0d9488' }
];
var workspaceLoadPromise = null;
var workspaceLoadFailed = false;
function workspaceColor(index) {
return ['#0d9488', '#7c3aed', '#0891b2', '#f59e0b', '#2563eb'][index % 5];
}
function mapWorkspace(record, index) {
var workspace = record && record.workspace ? record.workspace : record;
var displayName = workspace.display_name || workspace.slug || workspace.id;
return {
id: workspace.id,
slug: workspace.slug,
name: displayName,
role: 'Owner',
letter: displayName.charAt(0).toUpperCase(),
color: workspaceColor(index),
status: workspace.status,
};
}
function selectedWorkspaceId() {
return localStorage.getItem('crank_workspace_id');
}
function selectedWorkspaceSlug() {
return localStorage.getItem('crank_workspace_slug');
}
function persistCurrentWorkspace(workspace) {
if (!workspace) return;
localStorage.setItem('crank_workspace_id', workspace.id);
localStorage.setItem('crank_workspace_slug', workspace.slug);
}
function getCurrentWs() {
var workspaceId = selectedWorkspaceId();
var workspaceSlug = selectedWorkspaceSlug();
var workspace = WS_LIST.find(function(item) { return item.id === workspaceId; })
|| WS_LIST.find(function(item) { return item.slug === workspaceSlug; })
|| WS_LIST[0];
if (workspace) {
persistCurrentWorkspace(workspace);
}
return workspace;
}
function updateWorkspaceHeader(workspace) {
var nameEl = document.getElementById('ws-current-name');
var dotEl = document.getElementById('ws-dot');
if (nameEl) nameEl.textContent = workspace ? workspace.name : 'No workspace';
if (dotEl && workspace) {
dotEl.textContent = workspace.letter;
dotEl.style.background = workspace.color;
}
}
function renderWorkspaceList() {
var current = getCurrentWs();
updateWorkspaceHeader(current);
var list = document.getElementById('ws-dropdown-list');
if (!list) return;
list.innerHTML = WS_LIST.map(function(workspace) {
var active = current && workspace.id === current.id;
return '<div class="ws-dropdown-item' + (active ? ' active' : '') + '" onclick="switchWorkspace(\'' + workspace.id + '\')">' +
'<div class="ws-item-dot" style="background:' + workspace.color + '">' + workspace.letter + '</div>' +
'<div class="ws-item-info">' +
'<div class="ws-item-name">' + workspace.name + '</div>' +
'<div class="ws-item-role">' + workspace.role + '</div>' +
'</div>' +
(active ? '<svg width="12" height="12"><use href="' + (window.APP_BASE || '') + 'icons/general/check.svg#icon"/></svg>' : '') +
'</div>';
}).join('') +
'<div class="ws-dropdown-divider"></div>' +
'<a class="ws-dropdown-mgmt-link" href="' + (window.APP_BASE || '') + 'html/workspace-setup.html" onclick="var dd=document.getElementById(\'ws-dropdown\');if(dd)dd.style.display=\'none\'">' +
'<svg width="14" height="14"><use href="' + (window.APP_BASE || '') + 'icons/general/settings.svg#icon"/></svg>' +
'Workspace settings' +
'</a>';
}
async function loadWorkspaces() {
if (workspaceLoadPromise) return workspaceLoadPromise;
workspaceLoadPromise = (async function() {
if (!window.CrankApi) {
renderWorkspaceList();
return WS_LIST;
}
try {
var response = await window.CrankApi.listWorkspaces();
var items = (response && response.items ? response.items : []).map(mapWorkspace);
if (items.length > 0) {
WS_LIST = items;
}
workspaceLoadFailed = false;
} catch (_error) {
workspaceLoadFailed = true;
}
renderWorkspaceList();
return WS_LIST;
}());
return workspaceLoadPromise;
}
function initWorkspaceSwitcher() {
renderWorkspaceList();
loadWorkspaces();
}
function toggleWsSwitcher(e) {
e.stopPropagation();
var dd = document.getElementById('ws-dropdown');
if (dd) dd.style.display = dd.style.display === 'none' ? '' : 'none';
}
function switchWorkspace(workspaceId) {
var workspace = WS_LIST.find(function(item) { return item.id === workspaceId; });
if (!workspace) return;
persistCurrentWorkspace(workspace);
var dd = document.getElementById('ws-dropdown');
if (dd) dd.style.display = 'none';
renderWorkspaceList();
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace }));
}
window.getCurrentWorkspace = getCurrentWs;
window.whenWorkspacesReady = loadWorkspaces;
window.hasWorkspaceApiFailure = function() { return workspaceLoadFailed; };
document.addEventListener('DOMContentLoaded', initWorkspaceSwitcher);
document.addEventListener('click', function(e) {
if (!e.target.closest('#ws-switcher')) {
var dd = document.getElementById('ws-dropdown');
if (dd) dd.style.display = 'none';
}
});
+8
View File
@@ -8,6 +8,10 @@ use crate::{
protocol::{ExportMode, GraphqlOperationType, HttpMethod, Protocol},
};
fn default_operation_category() -> String {
"general".to_owned()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OperationStatus {
@@ -144,6 +148,8 @@ pub struct Operation<TSchema, TMapping> {
pub id: OperationId,
pub name: String,
pub display_name: String,
#[serde(default = "default_operation_category")]
pub category: String,
pub protocol: Protocol,
pub status: OperationStatus,
pub version: u32,
@@ -241,6 +247,7 @@ mod tests {
id: OperationId::new("op_01"),
name: "crm_create_lead".to_owned(),
display_name: "Create Lead".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Rest,
status: OperationStatus::Draft,
version: 1,
@@ -315,6 +322,7 @@ mod tests {
id: OperationId::new("op_01"),
name: "crm_create_lead".to_owned(),
display_name: "Create Lead".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Rest,
status: OperationStatus::Published,
version: 3,
+2
View File
@@ -29,6 +29,8 @@ pub enum RegistryError {
OperationNotFound { operation_id: String },
#[error("operation version {version} for {operation_id} was not found")]
OperationVersionNotFound { operation_id: String, version: u32 },
#[error("operation {operation_id} cannot be deleted while it is bound to a published agent")]
OperationHasPublishedAgentBindings { operation_id: String },
#[error("operation {operation_id} must start with version 1, got {version}")]
InvalidInitialVersion { operation_id: String, version: u32 },
#[error("operation {operation_id} expected next version {expected}, got {actual}")]
+7 -6
View File
@@ -9,11 +9,12 @@ pub use model::{
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata,
InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord,
OperationSampleMetadata, OperationSummary, OperationVersionRecord, PlatformApiKeyRecord,
PublishAgentRequest, PublishRequest, PublishedAgentTool, RegistryOperation, SampleKind,
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
WorkspaceRecord, YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary,
OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest,
PublishedAgentTool, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceRecord, YamlImportJob,
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
};
pub use postgres::PostgresRegistry;
+6
View File
@@ -165,6 +165,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
workspace_id text null references workspaces(id) on delete cascade,
name text not null,
display_name text not null,
category text not null default 'general',
protocol text not null,
status text not null,
current_draft_version integer not null default 1,
@@ -180,6 +181,11 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
query("alter table operations add column if not exists workspace_id text null references workspaces(id) on delete cascade")
.execute(pool)
.await?;
query(
"alter table operations add column if not exists category text not null default 'general'",
)
.execute(pool)
.await?;
query("update operations set workspace_id = 'ws_default' where workspace_id is null")
.execute(pool)
.await?;
+19
View File
@@ -155,7 +155,10 @@ pub struct OperationSummary {
pub workspace_id: WorkspaceId,
pub name: String,
pub display_name: String,
pub category: String,
pub protocol: Protocol,
pub target_url: String,
pub target_action: String,
pub status: OperationStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
@@ -164,6 +167,22 @@ pub struct OperationSummary {
pub published_at: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OperationUsageSummary {
pub operation_id: OperationId,
pub calls_today: u64,
pub error_rate_pct: f64,
pub avg_latency_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationAgentRef {
pub operation_id: OperationId,
pub agent_id: AgentId,
pub agent_slug: String,
pub display_name: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OperationVersionRecord {
pub operation_id: OperationId,
+377 -45
View File
@@ -1,7 +1,8 @@
use crank_core::{
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, InvitationId,
InvitationToken, InvocationLog, InvocationLogId, OperationId, OperationStatus, PlatformApiKey,
PlatformApiKeyId, PlatformApiKeyStatus, UsageRollup, User, UserId, Workspace, WorkspaceId,
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, GraphqlOperationType,
HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, OperationId,
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Target, UsageRollup,
User, UserId, Workspace, WorkspaceId,
};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
@@ -18,13 +19,14 @@ use crate::{
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord,
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationSampleMetadata,
OperationSummary, OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest,
PublishRequest, PublishedAgentTool, RegistryOperation, SaveAgentBindingsRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery,
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceRecord, YamlImportJob,
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
RegistryOperation, SaveAgentBindingsRequest, SaveAuthProfileRequest,
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest,
UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary,
UsageTimelinePoint, WorkspaceRecord, YamlImportJob, YamlImportJobCompletion,
YamlImportJobId, YamlImportJobStatus,
},
};
@@ -1084,6 +1086,7 @@ impl PostgresRegistry {
o.id,
o.name,
o.display_name,
o.category,
o.protocol,
to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at,
to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at,
@@ -1192,6 +1195,7 @@ impl PostgresRegistry {
workspace_id,
name,
display_name,
category,
protocol,
status,
current_draft_version,
@@ -1200,16 +1204,17 @@ impl PostgresRegistry {
updated_at,
published_at
) values (
$1, $2, $3, $4, $5, $6, $7, $8,
$9::timestamptz,
$1, $2, $3, $4, $5, $6, $7, $8, $9,
$10::timestamptz,
$11::timestamptz
$11::timestamptz,
$12::timestamptz
)",
)
.bind(snapshot.id.as_str())
.bind(workspace_id.as_str())
.bind(&snapshot.name)
.bind(&snapshot.display_name)
.bind(&snapshot.category)
.bind(serialize_enum_text(&snapshot.protocol, "protocol")?)
.bind(serialize_enum_text(&snapshot.status, "status")?)
.bind(to_db_version(snapshot.version))
@@ -1283,26 +1288,228 @@ impl PostgresRegistry {
Ok(())
}
pub async fn update_operation_draft(
&self,
workspace_id: &WorkspaceId,
snapshot: &RegistryOperation,
) -> Result<(), RegistryError> {
let Some(summary) = self
.get_operation_summary(workspace_id, &snapshot.id)
.await?
else {
return Err(RegistryError::OperationNotFound {
operation_id: snapshot.id.as_str().to_owned(),
});
};
if snapshot.version != summary.current_draft_version {
return Err(RegistryError::InvalidVersionSequence {
operation_id: snapshot.id.as_str().to_owned(),
expected: summary.current_draft_version,
actual: snapshot.version,
});
}
assert_immutable_fields(&summary, snapshot)?;
let mut tx = self.pool.begin().await?;
sqlx::query(
"update operations
set display_name = $1,
category = $2,
status = $3,
updated_at = $4::timestamptz
where workspace_id = $5 and id = $6",
)
.bind(&snapshot.display_name)
.bind(&snapshot.category)
.bind(serialize_enum_text(&snapshot.status, "status")?)
.bind(&snapshot.updated_at)
.bind(workspace_id.as_str())
.bind(snapshot.id.as_str())
.execute(&mut *tx)
.await?;
sqlx::query(
"update operation_versions
set status = $1,
target_json = $2,
input_schema_json = $3,
output_schema_json = $4,
input_mapping_json = $5,
output_mapping_json = $6,
execution_config_json = $7,
tool_description_json = $8,
samples_json = $9,
generated_draft_json = $10,
config_export_json = $11
where operation_id = $12 and version = $13",
)
.bind(serialize_enum_text(&snapshot.status, "status")?)
.bind(Json(serialize_json_value(&snapshot.target)?))
.bind(Json(serialize_json_value(&snapshot.input_schema)?))
.bind(Json(serialize_json_value(&snapshot.output_schema)?))
.bind(Json(serialize_json_value(&snapshot.input_mapping)?))
.bind(Json(serialize_json_value(&snapshot.output_mapping)?))
.bind(Json(serialize_json_value(&snapshot.execution_config)?))
.bind(Json(serialize_json_value(&snapshot.tool_description)?))
.bind(Json(
snapshot
.samples
.clone()
.map(|value| serialize_json_value(&value))
.transpose()?,
))
.bind(Json(
snapshot
.generated_draft
.clone()
.map(|value| serialize_json_value(&value))
.transpose()?,
))
.bind(Json(
snapshot
.config_export
.clone()
.map(|value| serialize_json_value(&value))
.transpose()?,
))
.bind(snapshot.id.as_str())
.bind(to_db_version(snapshot.version))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn archive_operation(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
archived_at: &str,
) -> Result<(), RegistryError> {
let Some(summary) = self
.get_operation_summary(workspace_id, operation_id)
.await?
else {
return Err(RegistryError::OperationNotFound {
operation_id: operation_id.as_str().to_owned(),
});
};
let mut tx = self.pool.begin().await?;
sqlx::query(
"update operations
set status = $1,
updated_at = $2::timestamptz
where workspace_id = $3 and id = $4",
)
.bind(serialize_enum_text(&OperationStatus::Archived, "status")?)
.bind(archived_at)
.bind(workspace_id.as_str())
.bind(operation_id.as_str())
.execute(&mut *tx)
.await?;
sqlx::query(
"update operation_versions
set status = $1
where operation_id = $2 and version = $3",
)
.bind(serialize_enum_text(&OperationStatus::Archived, "status")?)
.bind(operation_id.as_str())
.bind(to_db_version(summary.current_draft_version))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn delete_operation(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
) -> Result<(), RegistryError> {
if self
.has_published_agent_bindings_for_operation(workspace_id, operation_id)
.await?
{
return Err(RegistryError::OperationHasPublishedAgentBindings {
operation_id: operation_id.as_str().to_owned(),
});
}
let deleted = sqlx::query(
"delete from operations
where workspace_id = $1 and id = $2",
)
.bind(workspace_id.as_str())
.bind(operation_id.as_str())
.execute(&self.pool)
.await?
.rows_affected();
if deleted == 0 {
return Err(RegistryError::OperationNotFound {
operation_id: operation_id.as_str().to_owned(),
});
}
Ok(())
}
pub async fn has_published_agent_bindings_for_operation(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
) -> Result<bool, RegistryError> {
let row = sqlx::query(
"select 1
from agents a
join published_agents pa on pa.agent_id = a.id
join agent_operation_bindings b
on b.agent_id = a.id and b.agent_version = pa.version
where a.workspace_id = $1
and b.operation_id = $2
limit 1",
)
.bind(workspace_id.as_str())
.bind(operation_id.as_str())
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
pub async fn list_operations(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<OperationSummary>, RegistryError> {
let rows = sqlx::query(
"select
id,
workspace_id,
name,
display_name,
protocol,
status,
current_draft_version,
latest_published_version,
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
to_char(published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at
operations.id,
operations.workspace_id,
operations.name,
operations.display_name,
operations.category,
operations.protocol,
ov.target_json,
operations.status,
operations.current_draft_version,
operations.latest_published_version,
to_char(operations.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
to_char(operations.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
to_char(operations.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at
from operations
where workspace_id = $1
order by name asc",
join operation_versions ov
on ov.operation_id = operations.id
and ov.version = operations.current_draft_version
where operations.workspace_id = $1
order by operations.name asc",
)
.bind(workspace_id.as_str())
.fetch_all(&self.pool)
@@ -1311,6 +1518,57 @@ impl PostgresRegistry {
rows.iter().map(map_operation_summary).collect()
}
pub async fn list_operation_usage_summaries(
&self,
workspace_id: &WorkspaceId,
created_after: &str,
) -> Result<Vec<OperationUsageSummary>, RegistryError> {
let rows = sqlx::query(
"select
operation_id,
count(*)::bigint as calls_today,
coalesce(
round((sum(case when status = 'error' then 1 else 0 end)::numeric / nullif(count(*), 0)) * 100, 2),
0
)::float8 as error_rate_pct,
coalesce(round(avg(duration_ms))::bigint, 0) as avg_latency_ms
from invocation_logs
where workspace_id = $1
and created_at >= $2::timestamptz
group by operation_id",
)
.bind(workspace_id.as_str())
.bind(created_after)
.fetch_all(&self.pool)
.await?;
rows.iter().map(map_operation_usage_summary).collect()
}
pub async fn list_operation_agent_refs(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<OperationAgentRef>, RegistryError> {
let rows = sqlx::query(
"select
b.operation_id,
a.id as agent_id,
a.slug as agent_slug,
a.display_name
from agents a
join published_agents pa on pa.agent_id = a.id
join agent_operation_bindings b
on b.agent_id = a.id and b.agent_version = pa.version
where a.workspace_id = $1
order by a.display_name asc, b.tool_name asc",
)
.bind(workspace_id.as_str())
.fetch_all(&self.pool)
.await?;
rows.iter().map(map_operation_agent_ref).collect()
}
pub async fn get_operation_summary(
&self,
workspace_id: &WorkspaceId,
@@ -1318,19 +1576,24 @@ impl PostgresRegistry {
) -> Result<Option<OperationSummary>, RegistryError> {
let row = sqlx::query(
"select
id,
workspace_id,
name,
display_name,
protocol,
status,
current_draft_version,
latest_published_version,
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
to_char(published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at
operations.id,
operations.workspace_id,
operations.name,
operations.display_name,
operations.category,
operations.protocol,
ov.target_json,
operations.status,
operations.current_draft_version,
operations.latest_published_version,
to_char(operations.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
to_char(operations.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
to_char(operations.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at
from operations
where workspace_id = $1 and id = $2",
join operation_versions ov
on ov.operation_id = operations.id
and ov.version = operations.current_draft_version
where operations.workspace_id = $1 and operations.id = $2",
)
.bind(workspace_id.as_str())
.bind(operation_id.as_str())
@@ -1352,6 +1615,7 @@ impl PostgresRegistry {
o.workspace_id,
o.name,
o.display_name,
o.category,
o.protocol,
to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at,
to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at,
@@ -1395,6 +1659,7 @@ impl PostgresRegistry {
o.workspace_id,
o.name,
o.display_name,
o.category,
o.protocol,
to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at,
to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at,
@@ -1504,6 +1769,7 @@ impl PostgresRegistry {
o.workspace_id,
o.name,
o.display_name,
o.category,
o.protocol,
to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at,
to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at,
@@ -1545,6 +1811,7 @@ impl PostgresRegistry {
o.workspace_id,
o.name,
o.display_name,
o.category,
o.protocol,
to_char(o.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_created_at,
to_char(o.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as operation_updated_at,
@@ -2058,13 +2325,6 @@ fn assert_immutable_fields(
});
}
if summary.display_name != snapshot.display_name {
return Err(RegistryError::ImmutableOperationFieldChanged {
operation_id: snapshot.id.as_str().to_owned(),
field: "display_name",
});
}
if summary.protocol != snapshot.protocol {
return Err(RegistryError::ImmutableOperationFieldChanged {
operation_id: snapshot.id.as_str().to_owned(),
@@ -2196,12 +2456,18 @@ fn map_agent_summary(row: &PgRow) -> Result<AgentSummary, RegistryError> {
}
fn map_operation_summary(row: &PgRow) -> Result<OperationSummary, RegistryError> {
let target: Target = deserialize_json_value(row.try_get::<Json<Value>, _>("target_json")?.0)?;
let (target_url, target_action) = target_summary(&target);
Ok(OperationSummary {
id: OperationId::new(row.try_get::<String, _>("id")?),
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
name: row.try_get("name")?,
display_name: row.try_get("display_name")?,
category: row.try_get("category")?,
protocol: deserialize_enum_text(&row.try_get::<String, _>("protocol")?, "protocol")?,
target_url,
target_action,
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
current_draft_version: from_db_version(
row.try_get("current_draft_version")?,
@@ -2217,6 +2483,70 @@ fn map_operation_summary(row: &PgRow) -> Result<OperationSummary, RegistryError>
})
}
fn map_operation_usage_summary(row: &PgRow) -> Result<OperationUsageSummary, RegistryError> {
Ok(OperationUsageSummary {
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
calls_today: to_u64(row.try_get::<i64, _>("calls_today")?, "calls_today")?,
error_rate_pct: row.try_get("error_rate_pct")?,
avg_latency_ms: to_u64(row.try_get::<i64, _>("avg_latency_ms")?, "avg_latency_ms")?,
})
}
fn target_summary(target: &Target) -> (String, String) {
match target {
Target::Rest(rest) => (
join_target_url(&rest.base_url, &rest.path_template),
match rest.method {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Patch => "PATCH",
HttpMethod::Delete => "DELETE",
}
.to_owned(),
),
Target::Graphql(graphql) => (
graphql.endpoint.clone(),
match graphql.operation_type {
GraphqlOperationType::Query => "QUERY",
GraphqlOperationType::Mutation => "MUTATION",
}
.to_owned(),
),
Target::Grpc(grpc) => (grpc.server_addr.clone(), grpc.method.clone()),
}
}
fn join_target_url(base: &str, path: &str) -> String {
if path.is_empty() {
return base.to_owned();
}
if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("grpc://") {
return path.to_owned();
}
let trimmed_base = base.trim_end_matches('/');
let trimmed_path = path.trim_start_matches('/');
if trimmed_base.is_empty() {
format!("/{trimmed_path}")
} else if trimmed_path.is_empty() {
trimmed_base.to_owned()
} else {
format!("{trimmed_base}/{trimmed_path}")
}
}
fn map_operation_agent_ref(row: &PgRow) -> Result<OperationAgentRef, RegistryError> {
Ok(OperationAgentRef {
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
agent_id: AgentId::new(row.try_get::<String, _>("agent_id")?),
agent_slug: row.try_get("agent_slug")?,
display_name: row.try_get("display_name")?,
})
}
fn map_operation_version_record(row: &PgRow) -> Result<OperationVersionRecord, RegistryError> {
let operation_id = OperationId::new(row.try_get::<String, _>("id")?);
let version = from_db_version(row.try_get("version")?, "version")?;
@@ -2234,6 +2564,7 @@ fn map_operation_version_record(row: &PgRow) -> Result<OperationVersionRecord, R
id: operation_id,
name: row.try_get("name")?,
display_name: row.try_get("display_name")?,
category: row.try_get("category")?,
protocol: deserialize_enum_text(&row.try_get::<String, _>("protocol")?, "protocol")?,
status,
version,
@@ -2809,6 +3140,7 @@ mod tests {
id: OperationId::new(id),
name: format!("{id}_tool"),
display_name: format!("Display {id}"),
category: "general".to_owned(),
protocol: Protocol::Rest,
status,
version,
+3
View File
@@ -444,6 +444,7 @@ mod tests {
id: OperationId::new("op_rest_runtime"),
name: "crm_create_lead".to_owned(),
display_name: "Create Lead".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Rest,
status: OperationStatus::Published,
version: 1,
@@ -505,6 +506,7 @@ mod tests {
id: OperationId::new("op_graphql_runtime"),
name: "crm_create_lead_graphql".to_owned(),
display_name: "Create Lead GraphQL".to_owned(),
category: "sales".to_owned(),
protocol: Protocol::Graphql,
status: OperationStatus::Published,
version: 1,
@@ -579,6 +581,7 @@ mod tests {
id: OperationId::new("op_grpc_runtime"),
name: "echo_unary_grpc".to_owned(),
display_name: "Unary Echo gRPC".to_owned(),
category: "support".to_owned(),
protocol: Protocol::Grpc,
status: OperationStatus::Published,
version: 1,
+481
View File
@@ -0,0 +1,481 @@
# Alpine UI Integration Plan
## 1. Назначение документа
Этот документ фиксирует, как переносимый Alpine UI из `apps/ui` должен подключаться к реальному backend.
Его задача:
- разложить UI по страницам;
- показать, что уже поддержано текущим backend;
- зафиксировать отсутствующие endpoint-ы и контрактные разрывы;
- определить порядок интеграции без хаотичных правок.
## 2. Текущее состояние
Сейчас `apps/ui` уже заменен на Alpine.js UI, перенесенный из `test-ui`.
Важно:
- `apps/ui` теперь является основной UI-кодовой базой;
- `test-ui` остается fallback-источником и не должен использоваться как рабочая папка интеграции;
- новый UI пока опирается на `localStorage`, mock JSON и локальные сценарии;
- backend уже поддерживает `workspace`, `operations`, `agents`, `platform access`, `logs`, `usage`, но не все UI-flow закрыты полностью.
## 3. Принципы интеграции
- сначала подключаем страницы, уже совпадающие с текущим backend-контрактом;
- затем добавляем недостающие endpoint-ы под уже существующие UI-flow;
- только после этого убираем mock JSON и `localStorage`-костыли;
- если UI противоречит текущему backend, приоритет у целевой продуктовой модели, но конфликт должен быть разобран явно.
## 4. Page-by-page integration matrix
### 4.1. Operations catalog
UI-файлы:
- `apps/ui/index.html`
- `apps/ui/js/catalog.js`
Что UI хочет:
- список операций;
- фильтры по protocol/category/agent/status;
- карточки верхних метрик;
- переход в wizard create/edit;
- удаление/архивирование;
- отображение агентных привязок.
Что уже можно подключить:
- `GET /api/admin/workspaces/{workspace_id}/operations`
- `GET /api/admin/workspaces/{workspace_id}/agents`
- `GET /api/admin/workspaces/{workspace_id}/usage`
- `PATCH /api/admin/workspaces/{workspace_id}/operations/{operation_id}`
- `DELETE /api/admin/workspaces/{workspace_id}/operations/{operation_id}`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive`
Что еще не хватает:
- frontend data adapter, который заменит mock merge на реальные `items/page/page_size/total`;
- wiring server-side filters `protocol/category/agent/status/search`;
- переключение карточек верхних метрик с seeded summary на реальный usage payload.
Что убрать из UI после интеграции:
- `crank_ops_overrides` в `localStorage`
- merge поверх `data/operations.json`
- локальный tombstone/delete flow
Простой итог:
- эту страницу можно интегрировать первой;
- backend уже отдает server-side category, usage summary и agent refs;
- каталог уже может работать через реальные `list/delete/edit` вызовы;
- следующий шаг здесь только в server-side paging и остальных catalog actions; `target_url` и `target_action` теперь должны приходить из backend summary.
### 4.2. Wizard
UI-файлы:
- `apps/ui/html/wizard/index.html`
- `apps/ui/html/wizard/step*.html`
- `apps/ui/js/wizard.js`
Что UI хочет:
- create operation;
- edit operation;
- draft/save flow;
- test run;
- publish;
- загрузку samples;
- генерацию draft mapping;
- gRPC descriptor upload;
- import/export.
Что уже можно подключить:
- `POST /api/admin/workspaces/{workspace_id}/operations`
- `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}`
- `PATCH /api/admin/workspaces/{workspace_id}/operations/{operation_id}`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions`
- `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions/{version}`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/test-runs`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/input-json`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/output-json`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/drafts/generate`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/descriptors/proto`
- `POST /api/admin/workspaces/{workspace_id}/operations/{operation_id}/descriptors/descriptor-set`
- `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/grpc/services`
- `GET /api/admin/workspaces/{workspace_id}/operations/{operation_id}/export`
- `POST /api/admin/workspaces/{workspace_id}/operations/import`
Что еще не хватает:
- frontend adapter поверх `OperationDetail` и `OperationVersionDocument`, чтобы wizard перестал хранить edit-state в браузере;
- финальное выравнивание step payload c canonical DTO, чтобы create/edit/export/import использовали одну модель на фронте.
Что убрать из UI после интеграции:
- `sessionStorage`-передачу `wizard_edit`
- draft-overrides через `localStorage`
- локальное создание operation без backend
Простой итог:
- wizard почти готов для реального backend;
- это основной экран второй очереди после catalog;
- базовый create/edit draft flow уже можно посадить на live `create/get version/update` endpoints;
- следующий разрыв здесь - test/publish/import/export wiring и полноценный gRPC descriptor-set lifecycle.
### 4.3. Agents
UI-файлы:
- `apps/ui/html/agents.html`
- `apps/ui/js/agents.js`
Что UI хочет:
- список агентов;
- create/edit agent;
- выбор операций для агента;
- publish agent;
- MCP endpoint на агента;
- calls today / operation count / key count.
Что уже можно подключить:
- `GET /api/admin/workspaces/{workspace_id}/agents`
- `POST /api/admin/workspaces/{workspace_id}/agents`
- `GET /api/admin/workspaces/{workspace_id}/agents/{agent_id}`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/publish`
- `GET /api/admin/workspaces/{workspace_id}/usage`
Что еще не хватает:
- `PATCH /api/admin/workspaces/{workspace_id}/agents/{agent_id}`
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}`
- `POST /api/admin/workspaces/{workspace_id}/agents/{agent_id}/versions`
- `DELETE /api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings/{operation_id}`
- более богатый `GET /agents` response:
- `calls_today`
- `key_count`
- `operation_count`
- `mcp_endpoint`
Отдельный конфликт:
- UI считает, что агент можно редактировать прямо как рабочую сущность;
- backend пока ближе к publish-модели с version snapshot;
- нужно решить, edit агента мутирует draft напрямую или всегда создает новую version.
Простой итог:
- база для agents уже есть;
- но UI требует более полного lifecycle, чем backend поддерживает сейчас.
### 4.4. API Keys
UI-файлы:
- `apps/ui/html/api-keys.html`
- `apps/ui/js/api-keys.js`
Что UI хочет:
- список platform API keys;
- create;
- one-time reveal;
- revoke;
- delete;
- scopes.
Что уже можно подключить:
- `GET /api/admin/workspaces/{workspace_id}/platform-api-keys`
- `POST /api/admin/workspaces/{workspace_id}/platform-api-keys`
- `POST /api/admin/workspaces/{workspace_id}/platform-api-keys/{key_id}/revoke`
- `DELETE /api/admin/workspaces/{workspace_id}/platform-api-keys/{key_id}`
Что еще не хватает:
- финальная договоренность по scope model;
- согласование response shape с UI:
- `secret`
- `prefix`
- `status`
- `last_used_at`
Отдельный конфликт:
- UI сейчас обещает scope `deploy` и формулировки про rollback/deployments;
- backend этого продуктово не поддерживает;
- scope-тексты надо выровнять до интеграции.
Простой итог:
- эта страница почти закрывается текущим backend;
- здесь нужен не новый слой, а выравнивание payload и текстов.
### 4.5. Logs
UI-файлы:
- `apps/ui/html/logs.html`
- `apps/ui/js/logs.js`
Что UI хочет:
- список invocation logs;
- фильтры;
- detail row expansion;
- live refresh;
- status/error/duration.
Что уже можно подключить:
- `GET /api/admin/workspaces/{workspace_id}/logs`
- `GET /api/admin/workspaces/{workspace_id}/logs/{log_id}`
Что еще не хватает:
- frontend adapter с трансформацией backend response в текущую log-row форму;
- polling strategy вместо локального seeded массива;
- явная поддержка `debug/info/warn/error` в UI без локального seed;
- если нужен live mode, то либо polling, либо отдельный future streaming endpoint
Простой итог:
- backend foundation для logs уже есть;
- страница интегрируется быстро, если заменить seeded `LOGS` на API polling.
### 4.6. Usage
UI-файлы:
- `apps/ui/html/usage.html`
- `apps/ui/js/usage.js`
Что UI хочет:
- верхние summary cards;
- timeline;
- breakdown по operations;
- p50/p95/p99;
- breakdown по agents;
- CSV export.
Что уже можно подключить:
- `GET /api/admin/workspaces/{workspace_id}/usage`
- `GET /api/admin/workspaces/{workspace_id}/usage/operations/{operation_id}`
- `GET /api/admin/workspaces/{workspace_id}/usage/agents/{agent_id}`
Что еще не хватает:
- frontend mapping из backend `summary/timeline/operations/agents` в текущие widgets;
- `CSV export` endpoint или серверная договоренность, если экспорт не хотим делать на клиенте;
- согласование полей latency percentiles в одном формате
Простой итог:
- usage page можно подключать сразу после logs;
- главная работа здесь на frontend adapter, а не на новом backend-ядре.
### 4.7. Workspace setup
UI-файлы:
- `apps/ui/html/workspace-setup.html`
- `apps/ui/js/workspace-setup.js`
- `apps/ui/js/workspace.js`
Что UI хочет:
- create workspace;
- edit workspace;
- список участников;
- приглашения;
- смену текущего workspace.
Что уже можно подключить:
- `GET /api/admin/workspaces`
- `POST /api/admin/workspaces`
- `GET /api/admin/workspaces/{workspace_id}`
- `PATCH /api/admin/workspaces/{workspace_id}`
- `GET /api/admin/workspaces/{workspace_id}/members`
- `GET /api/admin/workspaces/{workspace_id}/invitations`
- `POST /api/admin/workspaces/{workspace_id}/invitations`
- `DELETE /api/admin/workspaces/{workspace_id}/invitations/{invitation_id}`
Что еще не хватает:
- `switch current workspace` как backend-aware UI state, а не `localStorage`;
- нормальная серверная модель current user + memberships;
- endpoint на удаление участника или изменение роли, если UI хочет это поддерживать
Отдельный конфликт:
- UI создает workspace локально и сразу считает его текущим;
- backend уже умеет создавать workspace, но у нас нет полноценной user-session модели;
- значит выбор текущего workspace пока придется держать на клиенте, но список и metadata брать с сервера.
Простой итог:
- create/edit workspace уже можно подключать;
- members/invites тоже частично готовы;
- но полноценный switch и role management еще не закрыты.
### 4.8. Settings
UI-файлы:
- `apps/ui/html/settings.html`
- `apps/ui/js/settings.js`
Что UI хочет:
- профиль пользователя;
- security/preferences;
- workspace settings;
- language switcher.
Что уже можно подключить:
- частично `GET /api/admin/workspaces/{workspace_id}`
- `PATCH /api/admin/workspaces/{workspace_id}`
Что еще не хватает:
- current user endpoint;
- user profile update endpoint;
- preferences endpoint;
- security/auth settings model
Отдельный конфликт:
- UI считает, что у нас уже есть app-level user profile;
- backend пока не имеет session/user profile API;
- страницу стоит разбить:
- workspace settings подключать раньше;
- profile/security оставить временно mock или read-only.
Простой итог:
- эту страницу нельзя подключать целиком сразу;
- сначала надо отделить workspace settings от пользовательского профиля.
### 4.9. Login
UI-файлы:
- `apps/ui/html/login.html`
- `apps/ui/js/login.js`
Что UI хочет:
- app-level sign in;
- mock SSO button;
- user session.
Что уже есть реально:
- внешний `Basic Auth` на `nginx`
Что отсутствует:
- session auth backend;
- login endpoint;
- logout endpoint;
- current user endpoint
Отдельный конфликт:
- это не баг реализации, а отсутствие целого auth слоя;
- login page пока не должна позиционироваться как production-ready flow.
Простой итог:
- пока не интегрировать как реальный backend flow;
- оставить как временный mock screen до отдельного решения по auth.
## 5. Отдельные UI-vs-backend конфликты
### 5.1. Login vs Basic Auth
UI предполагает встроенный login, backend сейчас защищен внешним `Basic Auth`.
Решение:
- не делать вид, что login уже рабочий;
- отложить интеграцию login до отдельного auth этапа.
### 5.2. Agent draft/edit lifecycle
UI редактирует агента напрямую, backend идет к version/publish модели.
Решение:
- зафиксировать draft agent как редактируемую сущность;
- publish должен фиксировать snapshot;
- UI не должен скрывать разницу между draft и published.
### 5.3. API key scopes
UI вводит scope `deploy`, backend пока не отражает полноценную deploy-подсистему.
Решение:
- либо урезать scope-тексты;
- либо позже расширять platform access под реальный deploy scope.
### 5.4. Workspace switching
UI хранит current workspace в `localStorage`.
Решение:
- на ближайшем этапе оставить client-side current workspace;
- данные workspace брать с backend;
- позже заменить на session-aware current workspace model.
## 6. Порядок интеграции
### Wave 1
- `Operations catalog`
- `Wizard`
### Wave 2
- `Agents`
- `API Keys`
### Wave 3
- `Logs`
- `Usage`
### Wave 4
- `Workspace setup`
- частичный `Settings`
### Wave 5
- `Login`
- полноценный session/auth layer
## 7. Ближайший практический шаг
Следующий рабочий этап:
- реализовать frontend adapters для `Operations catalog` и `Wizard`;
- добить backend `PATCH/DELETE/archive` по operations;
- убрать `localStorage` overrides и mock `operations.json` для первых двух экранов.
+2
View File
@@ -183,6 +183,8 @@ Query params:
"protocol": "rest",
"status": "draft",
"category": "sales",
"target_url": "https://api.example.com/v1/leads",
"target_action": "POST",
"current_draft_version": 3,
"latest_published_version": 2,
"updated_at": "2026-03-29T12:00:00Z",