feat: unify workspace settings and runtime resources
This commit is contained in:
@@ -237,6 +237,7 @@ pub enum RuntimeSourceStatus {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeIdentityAuthority {
|
||||
RuntimeRegistryProjection,
|
||||
ServerRuntimeConfiguration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -263,6 +264,46 @@ pub struct RuntimeSummary {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeManagementSummary {
|
||||
pub built_in: bool,
|
||||
pub config_managed: bool,
|
||||
pub removable: bool,
|
||||
pub endpoint_configured: bool,
|
||||
pub token_ref_configured: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkspaceRuntimeResource {
|
||||
#[serde(flatten)]
|
||||
pub runtime: RuntimeSummary,
|
||||
pub management: RuntimeManagementSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateRemoteRuntimeRequest {
|
||||
pub runtime_id: String,
|
||||
pub display_name: Option<String>,
|
||||
pub endpoint: String,
|
||||
pub token_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeConnectionTestResponse {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub checked_at: String,
|
||||
pub state: String,
|
||||
pub protocol_version: Option<String>,
|
||||
pub compatibility_basis: String,
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<String>,
|
||||
pub health_result: String,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerWorkspaceSummary {
|
||||
pub visibility: String,
|
||||
|
||||
@@ -58,12 +58,13 @@ use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjec
|
||||
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||
use workspace_api::{
|
||||
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
|
||||
DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest,
|
||||
ObjectiveLinkTicketRequest, ObjectiveStateRequest, PutRepositorySshHostTrustRequest,
|
||||
RepositoryAccessProjection, RepositorySshCredential, RepositorySshHostTrust,
|
||||
RotateRepositorySshCredentialRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||
TICKET_RELATIONS_QUERY_PATH,
|
||||
CreateRemoteRuntimeRequest, CreateRepositorySshCredentialRequest,
|
||||
DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest,
|
||||
ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection,
|
||||
RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest,
|
||||
RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||
TICKET_RELATIONS_QUERY_PATH, WorkspaceRuntimeResource,
|
||||
};
|
||||
|
||||
use crate::auth::{
|
||||
@@ -2428,7 +2429,18 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
get(scoped_working_directory_detail).delete(scoped_cleanup_working_directory),
|
||||
)
|
||||
.route("/api/runtimes", get(list_runtimes))
|
||||
.route("/api/w/{workspace_id}/runtimes", get(scoped_list_runtimes))
|
||||
.route(
|
||||
"/api/w/{workspace_id}/runtimes",
|
||||
get(scoped_list_runtimes).post(scoped_create_remote_runtime),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}",
|
||||
delete(scoped_delete_remote_runtime),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests",
|
||||
post(scoped_test_runtime_connection),
|
||||
)
|
||||
.route(
|
||||
"/api/workers",
|
||||
get(list_workers).post(create_workspace_worker),
|
||||
@@ -2486,38 +2498,6 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
"/api/w/{workspace_id}/workers/launch-options",
|
||||
get(scoped_get_worker_launch_options),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/runtime-connections",
|
||||
get(get_runtime_connection_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/runtime-connections",
|
||||
get(scoped_get_runtime_connection_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/runtime-connections/remotes",
|
||||
post(add_remote_runtime_connection),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/runtime-connections/remotes",
|
||||
post(scoped_add_remote_runtime_connection),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/runtime-connections/remotes/{runtime_id}",
|
||||
delete(delete_remote_runtime_connection),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/runtime-connections/remotes/{runtime_id}",
|
||||
delete(scoped_delete_remote_runtime_connection),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/runtime-connections/remotes/{runtime_id}/test",
|
||||
post(test_remote_runtime_connection),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/runtime-connections/remotes/{runtime_id}/test",
|
||||
post(scoped_test_remote_runtime_connection),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/v1/workspaces/{workspace_id}/resources/fetch",
|
||||
post(scoped_post_internal_runtime_resource_fetch),
|
||||
@@ -2958,66 +2938,6 @@ pub struct WorkerRetentionResponse {
|
||||
pub retention_state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RuntimeConnectionSettingsResponse {
|
||||
pub workspace_id: String,
|
||||
pub embedded: RuntimeConnectionSummary,
|
||||
pub remotes: Vec<RemoteRuntimeConnectionSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RuntimeConnectionSummary {
|
||||
pub runtime_id: String,
|
||||
pub display_name: String,
|
||||
pub kind: String,
|
||||
pub built_in: bool,
|
||||
pub config_managed: bool,
|
||||
pub active: bool,
|
||||
pub worker_creation_available: bool,
|
||||
pub restart_required: bool,
|
||||
pub status: String,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RemoteRuntimeConnectionSummary {
|
||||
#[serde(flatten)]
|
||||
pub summary: RuntimeConnectionSummary,
|
||||
pub endpoint_configured: bool,
|
||||
pub token_ref_configured: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RuntimeConnectionMutationResponse {
|
||||
pub workspace_id: String,
|
||||
pub restart_required: bool,
|
||||
pub remotes: Vec<RemoteRuntimeConnectionSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AddRemoteRuntimeConnectionRequest {
|
||||
pub runtime_id: String,
|
||||
pub display_name: Option<String>,
|
||||
pub endpoint: String,
|
||||
pub token_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RemoteRuntimeTestResponse {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub checked_at: String,
|
||||
pub state: String,
|
||||
pub protocol_version: Option<String>,
|
||||
pub compatibility_basis: String,
|
||||
pub capabilities: Vec<String>,
|
||||
pub health_result: String,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WorkerLaunchOptionsResponse {
|
||||
pub workspace_id: String,
|
||||
@@ -7961,9 +7881,13 @@ async fn scoped_get_profile_source_archive(
|
||||
async fn scoped_list_runtimes(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::RuntimeSummary>>> {
|
||||
) -> ApiResult<Json<workspace_api::ListResponse<WorkspaceRuntimeResource>>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
list_runtimes(State(api)).await
|
||||
let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
||||
Ok(Json(workspace_runtime_resources_response(
|
||||
&api,
|
||||
&runtime_config,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn scoped_workspace_protocol_ws(
|
||||
@@ -9833,37 +9757,29 @@ fn cleanup_api_error(runtime_id: &str, code: &str, message: &str) -> ApiError {
|
||||
.into()
|
||||
}
|
||||
|
||||
async fn scoped_get_runtime_connection_settings(
|
||||
async fn scoped_create_remote_runtime(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<RuntimeConnectionSettingsResponse>> {
|
||||
Json(request): Json<CreateRemoteRuntimeRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
get_runtime_connection_settings(State(api)).await
|
||||
create_remote_runtime(State(api), Json(request)).await
|
||||
}
|
||||
|
||||
async fn scoped_add_remote_runtime_connection(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<AddRemoteRuntimeConnectionRequest>,
|
||||
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
add_remote_runtime_connection(State(api), Json(request)).await
|
||||
}
|
||||
|
||||
async fn scoped_delete_remote_runtime_connection(
|
||||
async fn scoped_delete_remote_runtime(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
|
||||
) -> ApiResult<StatusCode> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
delete_remote_runtime_connection(State(api), AxumPath(path.runtime_id)).await
|
||||
delete_remote_runtime(State(api), AxumPath(path.runtime_id)).await
|
||||
}
|
||||
|
||||
async fn scoped_test_remote_runtime_connection(
|
||||
async fn scoped_test_runtime_connection(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||
) -> ApiResult<Json<RemoteRuntimeTestResponse>> {
|
||||
) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
test_remote_runtime_connection(State(api), AxumPath(path.runtime_id)).await
|
||||
test_runtime_connection(State(api), AxumPath(path.runtime_id)).await
|
||||
}
|
||||
|
||||
async fn scoped_get_companion_status(
|
||||
@@ -11154,27 +11070,17 @@ async fn list_workers(
|
||||
workers_response(api).map(Json)
|
||||
}
|
||||
|
||||
async fn get_runtime_connection_settings(
|
||||
async fn create_remote_runtime(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<RuntimeConnectionSettingsResponse>> {
|
||||
let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
||||
Ok(Json(runtime_connection_settings_response(
|
||||
&api,
|
||||
&runtime_config,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn add_remote_runtime_connection(
|
||||
State(api): State<WorkspaceApi>,
|
||||
Json(request): Json<AddRemoteRuntimeConnectionRequest>,
|
||||
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
|
||||
Json(request): Json<CreateRemoteRuntimeRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> {
|
||||
validate_runtime_connection_request(&request)?;
|
||||
let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
||||
let id = request.runtime_id.trim().to_string();
|
||||
if id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||
return Err(settings_bad_request(
|
||||
"embedded_runtime_not_config_managed",
|
||||
"the embedded Runtime is built in and cannot be managed from local remote Runtime config",
|
||||
"the embedded Runtime is built in and cannot be managed as a remote Runtime",
|
||||
));
|
||||
}
|
||||
if request
|
||||
@@ -11184,7 +11090,7 @@ async fn add_remote_runtime_connection(
|
||||
{
|
||||
return Err(settings_bad_request(
|
||||
"remote_runtime_token_ref_unsupported",
|
||||
"remote Runtime token_ref persistence is not supported by this v0 browser settings surface",
|
||||
"remote Runtime token_ref persistence is not supported",
|
||||
));
|
||||
}
|
||||
if runtime_config
|
||||
@@ -11195,11 +11101,11 @@ async fn add_remote_runtime_connection(
|
||||
{
|
||||
return Err(settings_bad_request(
|
||||
"remote_runtime_already_exists",
|
||||
"a remote Runtime connection with that id is already configured",
|
||||
"a remote Runtime with that id already exists",
|
||||
));
|
||||
}
|
||||
let remote_config = RemoteRuntimeConfigFile {
|
||||
id,
|
||||
id: id.clone(),
|
||||
endpoint: request.endpoint.trim().to_string(),
|
||||
display_name: request
|
||||
.display_name
|
||||
@@ -11232,31 +11138,19 @@ async fn add_remote_runtime_connection(
|
||||
runtime_config.runtimes.remote.push(remote_config);
|
||||
write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
|
||||
api.runtime.register_or_replace(active_runtime);
|
||||
let mut response = runtime_connection_mutation_response(
|
||||
&api,
|
||||
&runtime_config,
|
||||
vec![settings_diagnostic(
|
||||
"runtime_registry_applied",
|
||||
DiagnosticSeverity::Info,
|
||||
"Remote Runtime config was persisted and applied to the active Runtime registry without restarting the Workspace backend.",
|
||||
)],
|
||||
);
|
||||
response.diagnostics.push(settings_diagnostic(
|
||||
"backend_runtimes_config_rewritten",
|
||||
DiagnosticSeverity::Info,
|
||||
"Backend runtimes config was rewritten from the typed schema; comments and formatting are not preserved in v0.",
|
||||
));
|
||||
Ok(Json(response))
|
||||
let resource = workspace_runtime_resource_by_id(&api, &runtime_config, &id)
|
||||
.ok_or_else(|| Error::UnknownRuntime(id.clone()))?;
|
||||
Ok((StatusCode::CREATED, Json(resource)))
|
||||
}
|
||||
|
||||
async fn delete_remote_runtime_connection(
|
||||
async fn delete_remote_runtime(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(runtime_id): AxumPath<String>,
|
||||
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
|
||||
) -> ApiResult<StatusCode> {
|
||||
if runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||
return Err(settings_bad_request(
|
||||
"embedded_runtime_not_config_managed",
|
||||
"the embedded Runtime is built in and cannot be deleted from remote Runtime config",
|
||||
"the embedded Runtime is built in and cannot be deleted",
|
||||
));
|
||||
}
|
||||
let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
||||
@@ -11283,41 +11177,27 @@ async fn delete_remote_runtime_connection(
|
||||
"remote_runtime_delete_blocked",
|
||||
DiagnosticSeverity::Error,
|
||||
format!(
|
||||
"Remote Runtime '{runtime_id}' has {worker_count} active worker(s); stop or move them before deleting the connection."
|
||||
"Remote Runtime '{runtime_id}' has {worker_count} active worker(s); stop or move them before deleting it."
|
||||
),
|
||||
));
|
||||
return Err(ApiError::with_diagnostics(
|
||||
Error::RuntimeOperationFailed {
|
||||
runtime_id,
|
||||
code: "remote_runtime_delete_blocked".to_string(),
|
||||
message: "Remote Runtime connection has active workers".to_string(),
|
||||
message: "Remote Runtime has active workers".to_string(),
|
||||
},
|
||||
diagnostics,
|
||||
));
|
||||
}
|
||||
}
|
||||
write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
|
||||
let mut response = runtime_connection_mutation_response(
|
||||
&api,
|
||||
&runtime_config,
|
||||
vec![settings_diagnostic(
|
||||
"runtime_registry_applied",
|
||||
DiagnosticSeverity::Info,
|
||||
"Remote Runtime config was removed from persisted config and the active Runtime registry without restarting the Workspace backend.",
|
||||
)],
|
||||
);
|
||||
response.diagnostics.push(settings_diagnostic(
|
||||
"backend_runtimes_config_rewritten",
|
||||
DiagnosticSeverity::Info,
|
||||
"Backend runtimes config was rewritten from the typed schema; comments and formatting are not preserved in v0.",
|
||||
));
|
||||
Ok(Json(response))
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn test_remote_runtime_connection(
|
||||
async fn test_runtime_connection(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(runtime_id): AxumPath<String>,
|
||||
) -> ApiResult<Json<RemoteRuntimeTestResponse>> {
|
||||
) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
|
||||
let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
||||
let remote = runtime_config
|
||||
.runtimes
|
||||
@@ -13218,142 +13098,112 @@ fn write_backend_runtimes_config_for_settings(
|
||||
})
|
||||
}
|
||||
|
||||
fn runtime_connection_settings_response(
|
||||
fn workspace_runtime_resources_response(
|
||||
api: &WorkspaceApi,
|
||||
runtime_config: &BackendRuntimesConfigFile,
|
||||
) -> RuntimeConnectionSettingsResponse {
|
||||
RuntimeConnectionSettingsResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
embedded: embedded_runtime_connection_summary(api),
|
||||
remotes: remote_runtime_connection_summaries(api, runtime_config, false),
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_connection_mutation_response(
|
||||
api: &WorkspaceApi,
|
||||
runtime_config: &BackendRuntimesConfigFile,
|
||||
diagnostics: Vec<RuntimeDiagnostic>,
|
||||
) -> RuntimeConnectionMutationResponse {
|
||||
RuntimeConnectionMutationResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
restart_required: false,
|
||||
remotes: remote_runtime_connection_summaries(api, runtime_config, false),
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionSummary {
|
||||
let active = api
|
||||
.runtime
|
||||
.list_runtimes(api.config.max_records.min(200))
|
||||
) -> workspace_api::ListResponse<WorkspaceRuntimeResource> {
|
||||
let limit = api.config.max_records.min(200);
|
||||
let runtimes = api.runtime.list_runtimes(limit);
|
||||
let mut items = runtimes
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|runtime| runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID);
|
||||
match active {
|
||||
Some(runtime) => RuntimeConnectionSummary {
|
||||
runtime_id: runtime.runtime_id,
|
||||
display_name: runtime.label,
|
||||
kind: runtime.kind,
|
||||
built_in: true,
|
||||
config_managed: false,
|
||||
active: runtime.status == "active",
|
||||
worker_creation_available: runtime.worker_creation_available,
|
||||
restart_required: false,
|
||||
status: runtime.status,
|
||||
diagnostics: runtime.diagnostics,
|
||||
},
|
||||
None => RuntimeConnectionSummary {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
display_name: "Embedded Runtime".to_string(),
|
||||
kind: "embedded_worker_runtime".to_string(),
|
||||
built_in: true,
|
||||
config_managed: false,
|
||||
active: false,
|
||||
worker_creation_available: false,
|
||||
restart_required: false,
|
||||
status: "unavailable".to_string(),
|
||||
diagnostics: vec![settings_diagnostic(
|
||||
"embedded_runtime_unavailable",
|
||||
DiagnosticSeverity::Warning,
|
||||
"The built-in embedded Runtime is not active in the current Runtime registry projection.",
|
||||
)],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_runtime_connection_summaries(
|
||||
api: &WorkspaceApi,
|
||||
runtime_config: &BackendRuntimesConfigFile,
|
||||
restart_required: bool,
|
||||
) -> Vec<RemoteRuntimeConnectionSummary> {
|
||||
let live_runtimes = api
|
||||
.runtime
|
||||
.list_runtimes(api.config.max_records.min(200))
|
||||
.items;
|
||||
runtime_config
|
||||
.runtimes
|
||||
.remote
|
||||
.iter()
|
||||
.map(|remote| {
|
||||
let live = live_runtimes
|
||||
.map(|runtime| {
|
||||
let remote = runtime_config
|
||||
.runtimes
|
||||
.remote
|
||||
.iter()
|
||||
.find(|runtime| runtime.runtime_id == remote.id);
|
||||
let (display_name, kind, active, worker_creation_available, status, diagnostics) = match live {
|
||||
Some(runtime) => (
|
||||
runtime.label.clone(),
|
||||
runtime.kind.clone(),
|
||||
runtime.status == "active",
|
||||
runtime.worker_creation_available,
|
||||
runtime.status.clone(),
|
||||
runtime.diagnostics.clone(),
|
||||
),
|
||||
None => (
|
||||
remote
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| remote.id.clone()),
|
||||
"remote_http".to_string(),
|
||||
false,
|
||||
false,
|
||||
"configured_restart_required".to_string(),
|
||||
if restart_required {
|
||||
vec![settings_diagnostic(
|
||||
"runtime_registry_restart_required",
|
||||
DiagnosticSeverity::Warning,
|
||||
"This remote Runtime config is persisted but not active until the Workspace backend restarts.",
|
||||
)]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
),
|
||||
};
|
||||
RemoteRuntimeConnectionSummary {
|
||||
summary: RuntimeConnectionSummary {
|
||||
runtime_id: remote.id.clone(),
|
||||
display_name,
|
||||
kind,
|
||||
built_in: false,
|
||||
config_managed: true,
|
||||
active,
|
||||
worker_creation_available,
|
||||
restart_required,
|
||||
status,
|
||||
diagnostics,
|
||||
.find(|remote| remote.id == runtime.runtime_id);
|
||||
let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID;
|
||||
WorkspaceRuntimeResource {
|
||||
runtime: runtime.into(),
|
||||
management: RuntimeManagementSummary {
|
||||
built_in,
|
||||
config_managed: remote.is_some(),
|
||||
removable: remote.is_some() && !built_in,
|
||||
endpoint_configured: remote
|
||||
.is_some_and(|remote| !remote.endpoint.trim().is_empty()),
|
||||
token_ref_configured: remote.is_some_and(|remote| {
|
||||
remote
|
||||
.token_ref
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for remote in &runtime_config.runtimes.remote {
|
||||
if items
|
||||
.iter()
|
||||
.any(|resource| resource.runtime.runtime_id == remote.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
items.push(WorkspaceRuntimeResource {
|
||||
runtime: workspace_api::RuntimeSummary {
|
||||
runtime_id: remote.id.clone(),
|
||||
label: remote
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| remote.id.clone()),
|
||||
kind: "remote_http".to_string(),
|
||||
status: "unavailable".to_string(),
|
||||
source: workspace_api::RuntimeSourceSummary {
|
||||
kind: workspace_api::RuntimeSourceKind::RemoteHttp,
|
||||
status: workspace_api::RuntimeSourceStatus::Reserved,
|
||||
identity_authority:
|
||||
workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration,
|
||||
note: "The configured Runtime is not present in the active Runtime registry."
|
||||
.to_string(),
|
||||
},
|
||||
host_ids: Vec::new(),
|
||||
worker_creation_available: false,
|
||||
os: String::new(),
|
||||
arch: String::new(),
|
||||
diagnostics: vec![
|
||||
settings_diagnostic(
|
||||
"configured_runtime_unavailable",
|
||||
DiagnosticSeverity::Warning,
|
||||
"The configured Runtime is not present in the active Runtime registry.",
|
||||
)
|
||||
.into(),
|
||||
],
|
||||
},
|
||||
management: RuntimeManagementSummary {
|
||||
built_in: false,
|
||||
config_managed: true,
|
||||
removable: true,
|
||||
endpoint_configured: !remote.endpoint.trim().is_empty(),
|
||||
token_ref_configured: remote
|
||||
.token_ref
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty()),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
workspace_api::ListResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
limit,
|
||||
items,
|
||||
source: "workspace-runtime-resources".to_string(),
|
||||
diagnostics: runtimes.diagnostics.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_runtime_connection_request(
|
||||
request: &AddRemoteRuntimeConnectionRequest,
|
||||
) -> ApiResult<()> {
|
||||
fn workspace_runtime_resource_by_id(
|
||||
api: &WorkspaceApi,
|
||||
runtime_config: &BackendRuntimesConfigFile,
|
||||
runtime_id: &str,
|
||||
) -> Option<WorkspaceRuntimeResource> {
|
||||
workspace_runtime_resources_response(api, runtime_config)
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|resource| resource.runtime.runtime_id == runtime_id)
|
||||
}
|
||||
|
||||
fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> {
|
||||
validate_public_runtime_id(request.runtime_id.trim())?;
|
||||
let endpoint = request.endpoint.trim();
|
||||
if endpoint.is_empty() || !(endpoint.starts_with("http://") || endpoint.starts_with("https://"))
|
||||
@@ -13411,14 +13261,14 @@ fn remote_runtime_config_from_file(
|
||||
async fn test_remote_runtime_config(
|
||||
api: &WorkspaceApi,
|
||||
remote: &RemoteRuntimeConfigFile,
|
||||
) -> RemoteRuntimeTestResponse {
|
||||
) -> RuntimeConnectionTestResponse {
|
||||
let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
|
||||
if remote
|
||||
.token_ref
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
return RemoteRuntimeTestResponse {
|
||||
return RuntimeConnectionTestResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
runtime_id: remote.id.clone(),
|
||||
checked_at,
|
||||
@@ -13431,7 +13281,8 @@ async fn test_remote_runtime_config(
|
||||
"remote_runtime_token_ref_unsupported",
|
||||
DiagnosticSeverity::Error,
|
||||
"Remote Runtime test cannot use token_ref in v0; no token or secret value was exposed to the Browser.",
|
||||
)],
|
||||
)
|
||||
.into()],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13690,7 +13541,7 @@ async fn test_remote_runtime_config(
|
||||
"No connection problem found. Config-bundle sync was not checked because this lightweight test does not upload bundles as a side effect.",
|
||||
);
|
||||
|
||||
RemoteRuntimeTestResponse {
|
||||
RuntimeConnectionTestResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
runtime_id: remote.id.clone(),
|
||||
checked_at,
|
||||
@@ -13705,7 +13556,11 @@ async fn test_remote_runtime_config(
|
||||
observation.incompatible_count,
|
||||
observation.unknown_count
|
||||
),
|
||||
diagnostics: observation.diagnostics,
|
||||
diagnostics: observation
|
||||
.diagnostics
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13715,8 +13570,8 @@ fn remote_runtime_test_failed(
|
||||
checked_at: String,
|
||||
code: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
) -> RemoteRuntimeTestResponse {
|
||||
RemoteRuntimeTestResponse {
|
||||
) -> RuntimeConnectionTestResponse {
|
||||
RuntimeConnectionTestResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
runtime_id: remote.id.clone(),
|
||||
checked_at,
|
||||
@@ -13725,11 +13580,7 @@ fn remote_runtime_test_failed(
|
||||
compatibility_basis: "worker-runtime lightweight HTTP compatibility probes".to_string(),
|
||||
capabilities: Vec::new(),
|
||||
health_result: "failed".to_string(),
|
||||
diagnostics: vec![settings_diagnostic(
|
||||
code,
|
||||
DiagnosticSeverity::Error,
|
||||
message,
|
||||
)],
|
||||
diagnostics: vec![settings_diagnostic(code, DiagnosticSeverity::Error, message).into()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17046,7 +16897,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn runtime_connection_request_validation_bounds_browser_input() {
|
||||
let ok = AddRemoteRuntimeConnectionRequest {
|
||||
let ok = CreateRemoteRuntimeRequest {
|
||||
runtime_id: "team-runtime_1".to_string(),
|
||||
display_name: Some("Team Runtime".to_string()),
|
||||
endpoint: "https://runtime.example".to_string(),
|
||||
@@ -17054,7 +16905,7 @@ mod tests {
|
||||
};
|
||||
assert!(validate_runtime_connection_request(&ok).is_ok());
|
||||
|
||||
let bad_endpoint = AddRemoteRuntimeConnectionRequest {
|
||||
let bad_endpoint = CreateRemoteRuntimeRequest {
|
||||
endpoint: "/tmp/socket".to_string(),
|
||||
..ok
|
||||
};
|
||||
@@ -22698,34 +22549,52 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_connection_settings_add_delete_apply_live_registry() {
|
||||
async fn runtime_rest_resource_create_list_and_delete_apply_live_registry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes");
|
||||
|
||||
let settings = get_json(app.clone(), "/api/settings/runtime-connections").await;
|
||||
assert_eq!(settings["embedded"]["built_in"], true);
|
||||
assert_eq!(settings["embedded"]["config_managed"], false);
|
||||
|
||||
let added = post_json(
|
||||
let initial = get_json(app.clone(), &runtimes_uri).await;
|
||||
let embedded = initial["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|runtime| runtime["runtime_id"] == EMBEDDED_WORKER_RUNTIME_ID)
|
||||
.unwrap();
|
||||
assert_eq!(embedded["management"]["built_in"], true);
|
||||
assert_eq!(embedded["management"]["config_managed"], false);
|
||||
request_json(
|
||||
app.clone(),
|
||||
"/api/settings/runtime-connections/remotes",
|
||||
serde_json::json!({
|
||||
"DELETE",
|
||||
&format!("{runtimes_uri}/{EMBEDDED_WORKER_RUNTIME_ID}"),
|
||||
None,
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.await;
|
||||
request_json(
|
||||
app.clone(),
|
||||
"GET",
|
||||
"/api/settings/runtime-connections",
|
||||
None,
|
||||
StatusCode::NOT_FOUND,
|
||||
)
|
||||
.await;
|
||||
|
||||
let added = request_json(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&runtimes_uri,
|
||||
Some(serde_json::json!({
|
||||
"runtime_id": "team-runtime",
|
||||
"display_name": "Team Runtime",
|
||||
"endpoint": "https://runtime.example.invalid"
|
||||
}),
|
||||
})),
|
||||
StatusCode::CREATED,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(added["restart_required"], false);
|
||||
assert_eq!(added["remotes"][0]["runtime_id"], "team-runtime");
|
||||
assert_eq!(added["remotes"][0]["endpoint_configured"], true);
|
||||
assert!(
|
||||
added["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic["code"] == "runtime_registry_applied")
|
||||
);
|
||||
assert_eq!(added["runtime_id"], "team-runtime");
|
||||
assert_eq!(added["management"]["config_managed"], true);
|
||||
assert_eq!(added["management"]["endpoint_configured"], true);
|
||||
let projected = serde_json::to_string(&added).unwrap();
|
||||
assert!(!projected.contains("runtime.example.invalid"));
|
||||
|
||||
@@ -22756,13 +22625,12 @@ mod tests {
|
||||
let deleted = request_json(
|
||||
app.clone(),
|
||||
"DELETE",
|
||||
"/api/settings/runtime-connections/remotes/team-runtime",
|
||||
&format!("{runtimes_uri}/team-runtime"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
StatusCode::NO_CONTENT,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(deleted["restart_required"], false);
|
||||
assert_eq!(deleted["remotes"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(deleted["message"], "");
|
||||
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
|
||||
assert!(
|
||||
!launch_options["runtimes"]
|
||||
@@ -22794,17 +22662,19 @@ mod tests {
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = test_app(dir.path()).await;
|
||||
let added = post_json(
|
||||
let added = request_json(
|
||||
app.clone(),
|
||||
"/api/settings/runtime-connections/remotes",
|
||||
serde_json::json!({
|
||||
"POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes"),
|
||||
Some(serde_json::json!({
|
||||
"runtime_id": "busy-runtime",
|
||||
"display_name": "Busy Runtime",
|
||||
"endpoint": format!("http://{runtime_addr}")
|
||||
}),
|
||||
})),
|
||||
StatusCode::CREATED,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(added["restart_required"], false);
|
||||
assert_eq!(added["runtime_id"], "busy-runtime");
|
||||
let workers = get_json(app.clone(), "/api/workers").await;
|
||||
assert!(
|
||||
workers["items"]
|
||||
@@ -22818,7 +22688,7 @@ mod tests {
|
||||
let response = request_json(
|
||||
app,
|
||||
"DELETE",
|
||||
"/api/settings/runtime-connections/remotes/busy-runtime",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/busy-runtime"),
|
||||
None,
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
@@ -22876,7 +22746,7 @@ mod tests {
|
||||
|
||||
let response = post_json(
|
||||
app,
|
||||
"/api/settings/runtime-connections/remotes/probe-runtime/test",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/probe-runtime/connection-tests"),
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
@@ -22940,7 +22810,7 @@ mod tests {
|
||||
|
||||
let response = post_json(
|
||||
app,
|
||||
"/api/settings/runtime-connections/remotes/control-only-runtime/test",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/control-only-runtime/connection-tests"),
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -107,7 +107,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||
"workspaceRoute(workspaceId, '/settings/runtimes')",
|
||||
) &&
|
||||
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
|
||||
"top workspace page should link to Tickets, Runtime Inventory under Settings, and the Workers page",
|
||||
"top workspace page should link to Tickets, Runtimes under Settings, and the Workers page",
|
||||
);
|
||||
assert(
|
||||
!workspacePage.includes("workerConsoleHref") &&
|
||||
@@ -599,21 +599,24 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
|
||||
|
||||
assert(
|
||||
!sidebar.includes("RuntimesNavSection") &&
|
||||
settingsModel.includes('id: "runtime-inventory"') &&
|
||||
settingsModel.includes('id: "runtimes"') &&
|
||||
settingsModel.includes("return `${SETTINGS_ROUTE}/runtimes`;"),
|
||||
"Runtime inventory should be admin Settings navigation, not primary workspace sidebar navigation",
|
||||
"Runtimes should be admin Settings navigation, not primary workspace sidebar navigation",
|
||||
);
|
||||
assert(
|
||||
runtimesPage.includes("Runtime Inventory") &&
|
||||
runtimesPage.includes("Add remote Runtime") &&
|
||||
runtimesPage.includes("Open workdirs") &&
|
||||
runtimesPage.includes("runtimes-table") &&
|
||||
runtimesPage.includes("settings-runtime-table") &&
|
||||
runtimesPage.includes(
|
||||
"/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests",
|
||||
) &&
|
||||
runtimesPage.includes(
|
||||
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs",
|
||||
),
|
||||
"Settings Runtime Inventory page should table Runtimes and link to each Runtime's workdirs",
|
||||
"Settings Runtimes page should expose canonical REST actions and link to each Runtime's workdirs",
|
||||
);
|
||||
assert(
|
||||
workdirsPage.includes("Runtime Inventory") &&
|
||||
workdirsPage.includes(">Runtimes</a>") &&
|
||||
workdirsPage.includes("workdirs-table") &&
|
||||
workdirsLoad.includes("/working-directories"),
|
||||
"Runtime workdirs should remain backed by Runtime APIs without legacy Runtime route redirects",
|
||||
@@ -834,9 +837,10 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
||||
"Sidebar styles should define their layer order before component rules so base link styles do not win by import order",
|
||||
);
|
||||
assert(
|
||||
sidebarOverride.includes("controller.setSidebar(sidebar)") &&
|
||||
sidebarOverride.includes("controller.clearSidebar(sidebar)"),
|
||||
"SidebarOverride should register and clean up the child-provided sidebar snippet",
|
||||
sidebarOverride.includes("controller.registerSidebar(sidebar)") &&
|
||||
rootLayout.includes("createOverrideStack<SidebarSnippet>") &&
|
||||
rootLayout.includes("registerSidebar: sidebarOverrides.register"),
|
||||
"SidebarOverride should register a nested sidebar whose cleanup restores the parent override",
|
||||
);
|
||||
assert(
|
||||
rootLayoutLoad.includes("export const load") &&
|
||||
|
||||
@@ -71,13 +71,13 @@ Deno.test("Repository access settings are editable and canonically routed", () =
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("runtime connections are editable without advertising raw authority leaks", () => {
|
||||
Deno.test("Runtimes are one editable REST resource without authority leaks", () => {
|
||||
const runtimeSection = SETTINGS_SECTIONS.find((section) =>
|
||||
section.id === "runtime-connections"
|
||||
section.id === "runtimes"
|
||||
);
|
||||
assert(
|
||||
runtimeSection?.status === "editable",
|
||||
"Runtime Connections should be editable",
|
||||
"Runtimes should be editable",
|
||||
);
|
||||
|
||||
const allText = [
|
||||
@@ -91,13 +91,13 @@ Deno.test("runtime connections are editable without advertising raw authority le
|
||||
].join("\n");
|
||||
|
||||
assert(
|
||||
allText.includes("restart_required=true") ||
|
||||
allText.includes("Restart-required"),
|
||||
"restart-required pattern should be visible",
|
||||
allText.includes("canonical") && allText.includes("REST resource"),
|
||||
"Runtime settings should describe the canonical REST resource",
|
||||
);
|
||||
assert(
|
||||
allText.includes("not echoed back") || allText.includes("not echoed"),
|
||||
"endpoint submission should not imply endpoint echoing",
|
||||
!allText.includes("restart_required") &&
|
||||
!allText.includes("Restart-required"),
|
||||
"Runtime settings should not retain obsolete restart-required semantics",
|
||||
);
|
||||
|
||||
for (
|
||||
@@ -120,12 +120,12 @@ Deno.test("runtime connections are editable without advertising raw authority le
|
||||
Deno.test("diagnostic labels preserve severity and code", () => {
|
||||
const diagnostic = {
|
||||
severity: "warning",
|
||||
code: "runtime_registry_restart_required",
|
||||
message: "Restart required.",
|
||||
code: "configured_runtime_unavailable",
|
||||
message: "Configured Runtime unavailable.",
|
||||
} as const;
|
||||
assert(
|
||||
diagnosticLabel(diagnostic) ===
|
||||
"warning: runtime_registry_restart_required",
|
||||
"warning: configured_runtime_unavailable",
|
||||
"diagnostic label should be bounded and stable",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,18 +5,16 @@ export type Diagnostic = {
|
||||
};
|
||||
|
||||
export type SettingsSectionId =
|
||||
| "runtime-connections"
|
||||
| "runtime-inventory"
|
||||
| "runtimes"
|
||||
| "configuration-sources"
|
||||
| "repository-access"
|
||||
| "profile-sources"
|
||||
| "backend-config"
|
||||
| "workspace-identity";
|
||||
|
||||
export type SettingsSection = {
|
||||
readonly id: SettingsSectionId;
|
||||
readonly label: string;
|
||||
readonly status: "editable" | "placeholder" | "read-only";
|
||||
readonly status: "editable" | "read-only";
|
||||
readonly summary: string;
|
||||
readonly bullets: readonly string[];
|
||||
};
|
||||
@@ -26,50 +24,6 @@ export type SettingsPattern = {
|
||||
readonly body: string;
|
||||
};
|
||||
|
||||
export type RuntimeConnectionSummary = {
|
||||
runtime_id: string;
|
||||
display_name: string;
|
||||
kind: string;
|
||||
built_in: boolean;
|
||||
config_managed: boolean;
|
||||
active: boolean;
|
||||
worker_creation_available: boolean;
|
||||
restart_required: boolean;
|
||||
status: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type RemoteRuntimeConnectionSummary = RuntimeConnectionSummary & {
|
||||
endpoint_configured: boolean;
|
||||
token_ref_configured: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeConnectionSettingsResponse = {
|
||||
workspace_id: string;
|
||||
embedded: RuntimeConnectionSummary;
|
||||
remotes: RemoteRuntimeConnectionSummary[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type RuntimeConnectionMutationResponse = {
|
||||
workspace_id: string;
|
||||
restart_required: boolean;
|
||||
remotes: RemoteRuntimeConnectionSummary[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type RemoteRuntimeTestResponse = {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
checked_at: string;
|
||||
state: string;
|
||||
protocol_version?: string | null;
|
||||
compatibility_basis: string;
|
||||
capabilities: string[];
|
||||
health_result: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export const SETTINGS_ROUTE = "/settings";
|
||||
|
||||
export const SETTINGS_PERMISSION_NOTICE =
|
||||
@@ -77,27 +31,15 @@ export const SETTINGS_PERMISSION_NOTICE =
|
||||
|
||||
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
{
|
||||
id: "runtime-connections",
|
||||
label: "Runtime Connections",
|
||||
id: "runtimes",
|
||||
label: "Runtimes",
|
||||
status: "editable",
|
||||
summary:
|
||||
"Manage remote Runtime connection records stored in the workspace-local Backend config. The embedded Runtime is built in and shown separately.",
|
||||
"Register and inspect Workspace Runtimes, verify connectivity, and open their Workdir inventory from one resource list.",
|
||||
bullets: [
|
||||
"Remote connection changes are persisted through typed read-modify-write config updates and require a Backend restart before the live registry changes.",
|
||||
"The browser may submit a new endpoint, but Runtime endpoints, tokens, sockets, store roots, and config paths are not echoed back in API responses.",
|
||||
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "runtime-inventory",
|
||||
label: "Runtime Inventory",
|
||||
status: "read-only",
|
||||
summary:
|
||||
"Inspect registered Runtime handles and their materialized workdirs from the admin settings surface instead of the normal workspace navigation.",
|
||||
bullets: [
|
||||
"Runtime state is operational Backend/Runtime context, not a workspace content object like Objectives or Repositories.",
|
||||
"Workdir cleanup remains scoped to typed Runtime APIs and stays outside the primary workspace sidebar.",
|
||||
"Console routes may still target a Runtime handle directly, but Runtime discovery belongs under Settings.",
|
||||
"Embedded and remote Runtimes share one canonical Workspace resource representation.",
|
||||
"Remote Runtime creation, connection tests, and guarded deletion use the same REST collection.",
|
||||
"Runtime status, worker creation availability, diagnostics, and Workdir inventory remain visible without exposing endpoints or credentials.",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -136,18 +78,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
"Launch candidates and Profile archives carry the same active config revision, tree digest, and projection digest.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "backend-config",
|
||||
label: "Backend Config",
|
||||
status: "placeholder",
|
||||
summary:
|
||||
"General Backend config editing remains out of scope; this page only exposes the Runtime Connections v0 typed surface.",
|
||||
bullets: [
|
||||
"Only sanitized summaries belong in the browser; raw config paths, secret refs, tokens, and store roots stay backend-side.",
|
||||
"Missing-provider or invalid-config states should be displayed as typed diagnostics.",
|
||||
"No fake permission model is created to make unrelated config editing appear available.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "workspace-identity",
|
||||
label: "Workspace Identity",
|
||||
@@ -169,22 +99,20 @@ export const SETTINGS_PATTERNS: readonly SettingsPattern[] = [
|
||||
"Settings cards show bounded codes and operator-facing messages, not raw socket paths, credentials, token values, Runtime endpoints, or Runtime store paths.",
|
||||
},
|
||||
{
|
||||
title: "Restart-required changes",
|
||||
title: "Live Runtime resources",
|
||||
body:
|
||||
"Remote Runtime config updates return restart_required=true because v0 does not unregister/register live Runtime handles.",
|
||||
"Remote Runtime create/delete operations update persisted configuration and the live Runtime registry through one canonical REST resource.",
|
||||
},
|
||||
{
|
||||
title: "Typed Runtime surface only",
|
||||
body:
|
||||
"Runtime Connections v0 is intentionally narrow: embedded is built in, remote config is add/delete/test, and broader Backend admin controls stay unavailable.",
|
||||
"The Runtime REST resource exposes embedded and remote inventory, guarded create/delete/test operations, and Workdir links without broader Backend admin controls.",
|
||||
},
|
||||
];
|
||||
|
||||
export function settingsSectionHref(id: SettingsSectionId): string {
|
||||
switch (id) {
|
||||
case "runtime-connections":
|
||||
return `${SETTINGS_ROUTE}/runtime-connections`;
|
||||
case "runtime-inventory":
|
||||
case "runtimes":
|
||||
return `${SETTINGS_ROUTE}/runtimes`;
|
||||
case "configuration-sources":
|
||||
return `${SETTINGS_ROUTE}/configuration`;
|
||||
@@ -194,8 +122,6 @@ export function settingsSectionHref(id: SettingsSectionId): string {
|
||||
return `${SETTINGS_ROUTE}/profiles`;
|
||||
case "workspace-identity":
|
||||
return `${SETTINGS_ROUTE}/workspace`;
|
||||
case "backend-config":
|
||||
return `${SETTINGS_ROUTE}/backend`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import { SETTINGS_SECTIONS, SETTINGS_ROUTE, settingsSectionHref } from '$lib/workspace/settings/model';
|
||||
|
||||
let { workspaceId, currentPath }: { workspaceId: string; currentPath: string } = $props();
|
||||
|
||||
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, SETTINGS_ROUTE) : SETTINGS_ROUTE);
|
||||
|
||||
function sectionHref(path: string): string {
|
||||
return workspaceId ? workspaceRoute(workspaceId, path) : path;
|
||||
}
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="sidebar-sections" aria-label="Settings sections">
|
||||
<div class="nav-section">
|
||||
<div class="section-heading">
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
<div class="sidebar-list">
|
||||
<a
|
||||
class:active={currentPath === settingsHref}
|
||||
class="sidebar-link"
|
||||
href={settingsHref}
|
||||
aria-current={currentPath === settingsHref ? 'page' : undefined}
|
||||
>
|
||||
<span class="sidebar-link-label">Overview</span>
|
||||
</a>
|
||||
{#each SETTINGS_SECTIONS as section}
|
||||
{@const href = sectionHref(settingsSectionHref(section.id))}
|
||||
<a
|
||||
class:active={isActive(href)}
|
||||
class="sidebar-link"
|
||||
href={href}
|
||||
aria-current={isActive(href) ? 'page' : undefined}
|
||||
>
|
||||
<span class="sidebar-link-label">{section.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -8,8 +8,5 @@
|
||||
const { sidebar }: Props = $props();
|
||||
const controller = getSidebarController();
|
||||
|
||||
$effect(() => {
|
||||
controller.setSidebar(sidebar);
|
||||
return () => controller.clearSidebar(sidebar);
|
||||
});
|
||||
$effect(() => controller.registerSidebar(sidebar));
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import './sidebar.css';
|
||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||
import MemoryNavSection from './MemoryNavSection.svelte';
|
||||
@@ -12,9 +13,15 @@
|
||||
workspace: WorkspaceResponse | null;
|
||||
workspaceError?: string | null;
|
||||
currentPath?: string;
|
||||
content?: Snippet | null;
|
||||
};
|
||||
|
||||
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props();
|
||||
let {
|
||||
workspace,
|
||||
workspaceError = null,
|
||||
currentPath = '/',
|
||||
content = null,
|
||||
}: Props = $props();
|
||||
|
||||
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
||||
</script>
|
||||
@@ -38,11 +45,15 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<TicketsNavSection {currentPath} {workspaceId} />
|
||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||
<MemoryNavSection {currentPath} {workspaceId} />
|
||||
<MergeRequestsNavSection {currentPath} {workspaceId} />
|
||||
<WorkersNavSection {currentPath} {workspaceId} />
|
||||
</nav>
|
||||
{#if content}
|
||||
{@render content()}
|
||||
{:else}
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<TicketsNavSection {currentPath} {workspaceId} />
|
||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||
<MemoryNavSection {currentPath} {workspaceId} />
|
||||
<MergeRequestsNavSection {currentPath} {workspaceId} />
|
||||
<WorkersNavSection {currentPath} {workspaceId} />
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { getContext, type Snippet } from 'svelte';
|
||||
import {
|
||||
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
|
||||
type WorkspaceSidebarContentController,
|
||||
} from './workspace-content-context';
|
||||
|
||||
let { content }: { content: Snippet } = $props();
|
||||
|
||||
const controller = getContext<WorkspaceSidebarContentController>(
|
||||
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
|
||||
);
|
||||
|
||||
$effect(() => controller.registerContent(content));
|
||||
</script>
|
||||
@@ -4,8 +4,7 @@ import type { Snippet } from "svelte";
|
||||
export type SidebarSnippet = Snippet<[]>;
|
||||
|
||||
export type SidebarController = {
|
||||
setSidebar(snippet: SidebarSnippet): void;
|
||||
clearSidebar(snippet: SidebarSnippet): void;
|
||||
registerSidebar(sidebar: SidebarSnippet): () => void;
|
||||
};
|
||||
|
||||
export const SIDEBAR_CONTEXT = Symbol("yoi-sidebar-context");
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createOverrideStack } from "./override-stack.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
readTextFile(path: string | URL): Promise<string>;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T, message: string): void {
|
||||
if (!Object.is(actual, expected)) {
|
||||
throw new Error(
|
||||
`${message}: expected ${String(expected)}, received ${String(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("nested sidebar cleanup restores the parent override", () => {
|
||||
let active: string | null = null;
|
||||
const stack = createOverrideStack<string>((value) => {
|
||||
active = value;
|
||||
});
|
||||
|
||||
const clearWorkspace = stack.register("workspace");
|
||||
assertEquals(active, "workspace", "workspace sidebar should become active");
|
||||
|
||||
const clearSettings = stack.register("settings");
|
||||
assertEquals(
|
||||
active,
|
||||
"settings",
|
||||
"child settings sidebar should override its parent",
|
||||
);
|
||||
|
||||
clearSettings();
|
||||
assertEquals(
|
||||
active,
|
||||
"workspace",
|
||||
"removing the child should restore its parent",
|
||||
);
|
||||
|
||||
clearWorkspace();
|
||||
assertEquals(
|
||||
active,
|
||||
null,
|
||||
"removing the final override should restore the global sidebar",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("sidebar disposers remove only their own registration", () => {
|
||||
let active: string | null = null;
|
||||
const stack = createOverrideStack<string>((value) => {
|
||||
active = value;
|
||||
});
|
||||
|
||||
const clearWorkspace = stack.register("workspace");
|
||||
const clearSettings = stack.register("settings");
|
||||
|
||||
clearWorkspace();
|
||||
assertEquals(
|
||||
active,
|
||||
"settings",
|
||||
"removing an inactive parent should preserve the active child",
|
||||
);
|
||||
|
||||
clearWorkspace();
|
||||
assertEquals(active, "settings", "a disposer should be idempotent");
|
||||
|
||||
clearSettings();
|
||||
assertEquals(
|
||||
active,
|
||||
null,
|
||||
"removing the remaining child should empty the stack",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("settings replaces only WorkspaceSidebar content", async () => {
|
||||
const layoutUrl = new URL(
|
||||
"../../../routes/w/[workspaceId]/settings/+layout.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const workspaceLayoutUrl = new URL(
|
||||
"../../../routes/w/[workspaceId]/+layout.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const workspaceSidebarUrl = new URL(
|
||||
"./WorkspaceSidebar.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const settingsContentUrl = new URL(
|
||||
"./SettingsSidebarContent.svelte",
|
||||
import.meta.url,
|
||||
);
|
||||
const [layout, workspaceLayout, workspaceSidebar, settingsContent] =
|
||||
await Promise.all([
|
||||
Deno.readTextFile(layoutUrl),
|
||||
Deno.readTextFile(workspaceLayoutUrl),
|
||||
Deno.readTextFile(workspaceSidebarUrl),
|
||||
Deno.readTextFile(settingsContentUrl),
|
||||
]);
|
||||
|
||||
assert(
|
||||
layout.includes(
|
||||
"<WorkspaceSidebarContentOverride content={settingsSidebarContent} />",
|
||||
),
|
||||
"settings layout should override the WorkspaceSidebar content slot",
|
||||
);
|
||||
assert(
|
||||
!layout.includes("<SidebarOverride") && !layout.includes("settings-nav"),
|
||||
"settings layout should not replace the whole sidebar or retain inline navigation",
|
||||
);
|
||||
assert(
|
||||
workspaceLayout.includes(
|
||||
"registerContent: sidebarContentOverrides.register",
|
||||
) &&
|
||||
workspaceLayout.includes("content={sidebarContent}"),
|
||||
"workspace layout should provide and project the active child content",
|
||||
);
|
||||
assert(
|
||||
workspaceSidebar.includes("<WorkspaceSwitcher") &&
|
||||
workspaceSidebar.indexOf("<WorkspaceSwitcher") <
|
||||
workspaceSidebar.indexOf("{#if content}") &&
|
||||
workspaceSidebar.includes("{@render content()}"),
|
||||
"WorkspaceSidebar should retain its header and render child content below it",
|
||||
);
|
||||
assert(
|
||||
settingsContent.includes("SETTINGS_SECTIONS") &&
|
||||
settingsContent.includes('aria-label="Settings sections"'),
|
||||
"SettingsSidebarContent should render the authoritative settings section catalog",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export type OverrideDisposer = () => void;
|
||||
|
||||
export type OverrideStack<T> = {
|
||||
register(value: T): OverrideDisposer;
|
||||
};
|
||||
|
||||
export function createOverrideStack<T>(
|
||||
setActive: (value: T | null) => void,
|
||||
): OverrideStack<T> {
|
||||
let entries: Array<{ id: symbol; value: T }> = [];
|
||||
|
||||
return {
|
||||
register(value: T): OverrideDisposer {
|
||||
const id = Symbol();
|
||||
let disposed = false;
|
||||
|
||||
entries = [...entries, { id, value }];
|
||||
setActive(value);
|
||||
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
entries = entries.filter((entry) => entry.id !== id);
|
||||
setActive(entries.at(-1)?.value ?? null);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,13 @@ export type Runtime = {
|
||||
os: string;
|
||||
arch: string;
|
||||
diagnostics: Diagnostic[];
|
||||
management?: {
|
||||
built_in: boolean;
|
||||
config_managed: boolean;
|
||||
removable: boolean;
|
||||
endpoint_configured: boolean;
|
||||
token_ref_configured: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type Host = {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
export const WORKSPACE_SIDEBAR_CONTENT_CONTEXT = Symbol(
|
||||
"workspace-sidebar-content",
|
||||
);
|
||||
|
||||
export type WorkspaceSidebarContentController = {
|
||||
registerContent(content: Snippet): () => void;
|
||||
};
|
||||
@@ -16,7 +16,6 @@
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.settings-hero,
|
||||
.settings-notice,
|
||||
.settings-section-header,
|
||||
.settings-patterns {
|
||||
@@ -183,7 +182,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.settings-hero,
|
||||
.settings-notice,
|
||||
.settings-section-header,
|
||||
.settings-patterns {
|
||||
@@ -345,18 +343,11 @@
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.settings-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.settings-nav a,
|
||||
.settings-section-card,
|
||||
.button-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.settings-nav a,
|
||||
.settings-section-card {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
@@ -366,14 +357,10 @@
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
.settings-nav a:hover,
|
||||
.settings-nav a:focus-visible,
|
||||
.settings-nav a.active,
|
||||
.settings-section-card:hover,
|
||||
.settings-section-card:focus-visible {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
.settings-nav span,
|
||||
.settings-section-card h3 {
|
||||
color: var(--text-strong);
|
||||
font-weight: 800;
|
||||
|
||||
@@ -5,22 +5,21 @@
|
||||
import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context';
|
||||
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
|
||||
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
|
||||
import { SIDEBAR_CONTEXT, type SidebarSnippet } from '$lib/workspace/sidebar/context';
|
||||
import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '$lib/workspace/sidebar/context';
|
||||
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
|
||||
import '../app.css';
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { children }: LayoutProps = $props();
|
||||
let sidebar = $state<SidebarSnippet | null>(null);
|
||||
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
|
||||
sidebar = activeSidebar;
|
||||
});
|
||||
const headerController = $state<HeaderController>({ content: null });
|
||||
|
||||
provideHeaderController(headerController);
|
||||
setContext(SIDEBAR_CONTEXT, {
|
||||
setSidebar(snippet: SidebarSnippet) {
|
||||
sidebar = snippet;
|
||||
},
|
||||
clearSidebar(snippet: SidebarSnippet) {
|
||||
if (sidebar === snippet) sidebar = null;
|
||||
},
|
||||
setContext<SidebarController>(SIDEBAR_CONTEXT, {
|
||||
registerSidebar: sidebarOverrides.register,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { setContext, type Snippet } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
|
||||
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
|
||||
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
||||
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
|
||||
import {
|
||||
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
|
||||
type WorkspaceSidebarContentController,
|
||||
} from '$lib/workspace/sidebar/workspace-content-context';
|
||||
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
||||
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
||||
import '$lib/workspace/styles/workspace-pages.css';
|
||||
@@ -11,6 +17,15 @@
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
let sidebarContent = $state<Snippet | null>(null);
|
||||
const sidebarContentOverrides = createOverrideStack<Snippet>((activeContent) => {
|
||||
sidebarContent = activeContent;
|
||||
});
|
||||
|
||||
setContext<WorkspaceSidebarContentController>(WORKSPACE_SIDEBAR_CONTENT_CONTEXT, {
|
||||
registerContent: sidebarContentOverrides.register,
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const workspaceId = data.workspace?.workspace_id;
|
||||
if (!workspaceId) return;
|
||||
@@ -27,6 +42,7 @@
|
||||
workspace={data.workspace ?? null}
|
||||
workspaceError={data.workspaceError ?? null}
|
||||
currentPath={page.url.pathname}
|
||||
content={sidebarContent}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
<small>Read typed Ticket records</small>
|
||||
</a>
|
||||
<a class="workspace-action-card" href={runtimeSettingsHref}>
|
||||
<span>Runtime Inventory</span>
|
||||
<strong>Open admin runtime inventory</strong>
|
||||
<span>Runtimes</span>
|
||||
<strong>Open admin Runtimes</strong>
|
||||
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
|
||||
</a>
|
||||
<a class="workspace-action-card" href={workersHref}>
|
||||
|
||||
@@ -1,47 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model';
|
||||
import SettingsSidebarContent from '$lib/workspace/sidebar/SettingsSidebarContent.svelte';
|
||||
import WorkspaceSidebarContentOverride from '$lib/workspace/sidebar/WorkspaceSidebarContentOverride.svelte';
|
||||
import '$lib/workspace/styles/settings.css';
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
|
||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
|
||||
|
||||
function sectionHref(path: string): string {
|
||||
return workspaceId ? workspaceRoute(workspaceId, path) : path;
|
||||
}
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
const href = sectionHref(path);
|
||||
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
|
||||
}
|
||||
let { children }: LayoutProps = $props();
|
||||
</script>
|
||||
|
||||
{#snippet settingsSidebarContent()}
|
||||
<SettingsSidebarContent
|
||||
workspaceId={page.params.workspaceId ?? ''}
|
||||
currentPath={page.url.pathname}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<WorkspaceSidebarContentOverride content={settingsSidebarContent} />
|
||||
|
||||
<section class="settings-page">
|
||||
<div class="settings-hero">
|
||||
<p class="eyebrow">Settings / Admin</p>
|
||||
<h1>Workspace settings</h1>
|
||||
<p>
|
||||
Configure workspace metadata, runtime connections, and Decodal profile sources through the Backend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
<a class:active={page.url.pathname === settingsHref} href={settingsHref}>
|
||||
<span>Overview</span>
|
||||
<small>Settings map</small>
|
||||
</a>
|
||||
{#each SETTINGS_SECTIONS as section}
|
||||
{@const href = sectionHref(settingsSectionHref(section.id))}
|
||||
<a class:active={isActive(settingsSectionHref(section.id))} href={href}>
|
||||
<span>{section.label}</span>
|
||||
<small>{section.status}</small>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<section class="card settings-section" aria-labelledby="backend-config-title">
|
||||
<header class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">read-only</p>
|
||||
<h2 id="backend-config-title">Backend config</h2>
|
||||
</div>
|
||||
<span class="badge warning">planned</span>
|
||||
</header>
|
||||
<p>
|
||||
Backend-owned config is exposed through typed settings APIs. Additional Backend config surfaces will be added as they become editable product settings.
|
||||
</p>
|
||||
</section>
|
||||
@@ -1,371 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
|
||||
import type {
|
||||
Diagnostic,
|
||||
RemoteRuntimeTestResponse,
|
||||
RuntimeConnectionMutationResponse,
|
||||
RuntimeConnectionSettingsResponse
|
||||
} from '$lib/workspace/settings/model';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
type RemoteAddForm = {
|
||||
runtime_id: string;
|
||||
display_name: string;
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
|
||||
|
||||
let runtimeSettings = $state<RuntimeConnectionSettingsResponse | null>(null);
|
||||
let runtimeLoading = $state(true);
|
||||
let runtimeError = $state<string | null>(null);
|
||||
let mutationMessage = $state<string | null>(null);
|
||||
let mutationDiagnostics = $state<Diagnostic[]>([]);
|
||||
let tests = $state<Record<string, RemoteRuntimeTestResponse>>({});
|
||||
let deleting = $state<string | null>(null);
|
||||
let testing = $state<string | null>(null);
|
||||
let submitting = $state(false);
|
||||
let showAddRuntimeForm = $state(false);
|
||||
let remoteForm = $state<RemoteAddForm>({
|
||||
runtime_id: '',
|
||||
display_name: '',
|
||||
endpoint: ''
|
||||
});
|
||||
|
||||
function settingsApiPath(path: string): string {
|
||||
return workspaceApiPath(workspaceId, path);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!workspaceId) {
|
||||
runtimeLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function loadRuntimeSettings() {
|
||||
runtimeLoading = true;
|
||||
runtimeError = null;
|
||||
try {
|
||||
const response = await fetch(settingsApiPath('/settings/runtime-connections'));
|
||||
if (!response.ok) {
|
||||
throw new Error(`runtime settings request failed (${response.status})`);
|
||||
}
|
||||
const responseData = (await response.json()) as RuntimeConnectionSettingsResponse;
|
||||
if (!cancelled) {
|
||||
runtimeSettings = responseData;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
runtimeError = err instanceof Error ? err.message : 'runtime settings request failed';
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
runtimeLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadRuntimeSettings();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
async function submitRemoteRuntime() {
|
||||
submitting = true;
|
||||
mutationMessage = null;
|
||||
mutationDiagnostics = [];
|
||||
try {
|
||||
const response = await fetch(settingsApiPath('/settings/runtime-connections/remotes'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
runtime_id: remoteForm.runtime_id,
|
||||
display_name: remoteForm.display_name || null,
|
||||
endpoint: remoteForm.endpoint
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseErrorMessage(response, 'add remote Runtime failed'));
|
||||
}
|
||||
const responseData = (await response.json()) as RuntimeConnectionMutationResponse;
|
||||
applyRuntimeMutation(responseData);
|
||||
remoteForm = { runtime_id: '', display_name: '', endpoint: '' };
|
||||
showAddRuntimeForm = false;
|
||||
} catch (err) {
|
||||
mutationMessage = err instanceof Error ? err.message : 'add remote Runtime failed';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRemoteRuntime(runtimeId: string) {
|
||||
deleting = runtimeId;
|
||||
mutationMessage = null;
|
||||
mutationDiagnostics = [];
|
||||
try {
|
||||
const response = await fetch(settingsApiPath(`/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}`), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseErrorMessage(response, 'delete remote Runtime failed'));
|
||||
}
|
||||
const responseData = (await response.json()) as RuntimeConnectionMutationResponse;
|
||||
applyRuntimeMutation(responseData);
|
||||
const nextTests = { ...tests };
|
||||
delete nextTests[runtimeId];
|
||||
tests = nextTests;
|
||||
} catch (err) {
|
||||
mutationMessage = err instanceof Error ? err.message : 'delete remote Runtime failed';
|
||||
} finally {
|
||||
deleting = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function testRemoteRuntime(runtimeId: string) {
|
||||
testing = runtimeId;
|
||||
try {
|
||||
const response = await fetch(settingsApiPath(`/settings/runtime-connections/remotes/${encodeURIComponent(runtimeId)}/test`), {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await responseErrorMessage(response, 'test remote Runtime failed'));
|
||||
}
|
||||
const responseData = (await response.json()) as RemoteRuntimeTestResponse;
|
||||
tests = { ...tests, [runtimeId]: responseData };
|
||||
} catch (err) {
|
||||
tests = {
|
||||
...tests,
|
||||
[runtimeId]: {
|
||||
workspace_id: runtimeSettings?.workspace_id ?? 'unknown',
|
||||
runtime_id: runtimeId,
|
||||
checked_at: new Date().toISOString(),
|
||||
state: 'failed',
|
||||
protocol_version: null,
|
||||
compatibility_basis: 'browser request failed',
|
||||
capabilities: [],
|
||||
health_result: 'failed',
|
||||
diagnostics: [
|
||||
{
|
||||
code: 'browser_runtime_test_failed',
|
||||
severity: 'error',
|
||||
message: err instanceof Error ? err.message : 'test remote Runtime failed'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
testing = null;
|
||||
}
|
||||
}
|
||||
|
||||
function capabilityOperations(test: RemoteRuntimeTestResponse, state: 'available' | 'unknown' | 'incompatible'): string[] {
|
||||
const suffix = `:${state}`;
|
||||
return test.capabilities
|
||||
.filter((capability) => capability.endsWith(suffix))
|
||||
.map((capability) => capability.slice(0, -suffix.length));
|
||||
}
|
||||
|
||||
function applyRuntimeMutation(responseData: RuntimeConnectionMutationResponse) {
|
||||
runtimeSettings = runtimeSettings
|
||||
? { ...runtimeSettings, remotes: responseData.remotes, diagnostics: responseData.diagnostics }
|
||||
: {
|
||||
workspace_id: responseData.workspace_id,
|
||||
embedded: {
|
||||
runtime_id: 'embedded-worker-runtime',
|
||||
display_name: 'Embedded Runtime',
|
||||
kind: 'embedded_worker_runtime',
|
||||
built_in: true,
|
||||
config_managed: false,
|
||||
active: false,
|
||||
worker_creation_available: false,
|
||||
restart_required: false,
|
||||
status: 'unknown',
|
||||
diagnostics: []
|
||||
},
|
||||
remotes: responseData.remotes,
|
||||
diagnostics: responseData.diagnostics
|
||||
};
|
||||
mutationDiagnostics = responseData.diagnostics;
|
||||
mutationMessage = responseData.restart_required
|
||||
? 'Runtime config saved. Restart the Workspace backend to apply live registry changes.'
|
||||
: 'Runtime config saved.';
|
||||
}
|
||||
|
||||
async function responseErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: { message?: string; code?: string } | string; message?: string };
|
||||
if (typeof payload.error === 'object' && payload.error?.message) {
|
||||
return `${payload.error.code ?? 'request_failed'}: ${payload.error.message}`;
|
||||
}
|
||||
if (payload.message) {
|
||||
const code = typeof payload.error === 'string' ? payload.error : 'request_failed';
|
||||
return `${code}: ${payload.message}`;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return `${fallback} (${response.status})`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Runtime Connections · Yoi Workspace</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="card settings-section" aria-labelledby="runtime-connections-title">
|
||||
<header class="settings-section-header">
|
||||
<div>
|
||||
<p class="eyebrow">editable</p>
|
||||
<h2 id="runtime-connections-title">Runtime Connections</h2>
|
||||
</div>
|
||||
<span class="badge success">typed API</span>
|
||||
</header>
|
||||
<p>Manage remote Runtime connection records stored in the workspace-local Backend config. The embedded Runtime is built in and shown separately.</p>
|
||||
|
||||
{#if runtimeLoading}
|
||||
<p class="status-message">Loading Runtime connections…</p>
|
||||
{:else if runtimeError}
|
||||
<p class="status-message error">Runtime connection settings unavailable: {runtimeError}</p>
|
||||
{:else if runtimeSettings}
|
||||
<div class="settings-action-row">
|
||||
<button type="button" onclick={() => (showAddRuntimeForm = !showAddRuntimeForm)}>
|
||||
{showAddRuntimeForm ? 'Cancel adding Runtime' : 'Add remote Runtime'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showAddRuntimeForm}
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void submitRemoteRuntime(); }}>
|
||||
<h3>Add remote Runtime</h3>
|
||||
<p>Endpoint is submitted to the Backend but not echoed back in settings responses.</p>
|
||||
<label>
|
||||
<span>Runtime id</span>
|
||||
<input bind:value={remoteForm.runtime_id} required maxlength="96" pattern="[A-Za-z0-9_.-]+" placeholder="team-runtime" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Display name</span>
|
||||
<input bind:value={remoteForm.display_name} maxlength="80" placeholder="Team Runtime" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Endpoint</span>
|
||||
<input bind:value={remoteForm.endpoint} required inputmode="url" placeholder="https://runtime.example" />
|
||||
</label>
|
||||
<button type="submit" disabled={submitting}>{submitting ? 'Saving…' : 'Add Runtime'}</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if mutationMessage}
|
||||
<p class="status-message" class:error={mutationMessage.includes('failed')}>{mutationMessage}</p>
|
||||
{/if}
|
||||
<DiagnosticsList diagnostics={mutationDiagnostics} />
|
||||
|
||||
<div class="settings-runtime-list" aria-label="Runtime connections">
|
||||
<h3>Runtimes</h3>
|
||||
<div class="settings-runtime-table-wrap">
|
||||
<table class="settings-runtime-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Runtime</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Connection</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class:inactive={!runtimeSettings.embedded.active}>
|
||||
<td>
|
||||
<strong>{runtimeSettings.embedded.display_name}</strong>
|
||||
<code>{runtimeSettings.embedded.runtime_id}</code>
|
||||
</td>
|
||||
<td>
|
||||
<strong>embedded</strong>
|
||||
<span>Workspace backend process</span>
|
||||
</td>
|
||||
<td>Local Backend runtime</td>
|
||||
<td>
|
||||
<span class="badge" class:success={runtimeSettings.embedded.active} class:warning={!runtimeSettings.embedded.active}>{runtimeSettings.embedded.status}</span>
|
||||
{#if runtimeSettings.embedded.restart_required}
|
||||
<span class="badge warning">restart required</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td><span class="settings-muted-action">Managed by backend</span></td>
|
||||
</tr>
|
||||
{#if runtimeSettings.embedded.diagnostics.length > 0}
|
||||
<tr class="settings-runtime-detail-row">
|
||||
<td colspan="5"><DiagnosticsList diagnostics={runtimeSettings.embedded.diagnostics} /></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#each runtimeSettings.remotes as remote (remote.runtime_id)}
|
||||
<tr class:inactive={!remote.active}>
|
||||
<td>
|
||||
<strong>{remote.display_name}</strong>
|
||||
<code>{remote.runtime_id}</code>
|
||||
</td>
|
||||
<td>
|
||||
<strong>remote</strong>
|
||||
<span>Configured Runtime endpoint</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>Endpoint: {remote.endpoint_configured ? 'configured' : 'not configured'}</span>
|
||||
{#if remote.endpoint_configured}<small>hidden</small>{/if}
|
||||
<span>Token: {remote.token_ref_configured ? 'configured' : 'not configured'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" class:success={remote.active} class:warning={!remote.active}>{remote.status}</span>
|
||||
{#if remote.restart_required}
|
||||
<span class="badge warning">restart required</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="settings-action-row">
|
||||
<button type="button" onclick={() => void testRemoteRuntime(remote.runtime_id)} disabled={testing === remote.runtime_id}>
|
||||
{testing === remote.runtime_id ? 'Testing…' : 'Test'}
|
||||
</button>
|
||||
<button type="button" class="danger" onclick={() => void deleteRemoteRuntime(remote.runtime_id)} disabled={deleting === remote.runtime_id}>
|
||||
{deleting === remote.runtime_id ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{#if remote.diagnostics.length > 0}
|
||||
<tr class="settings-runtime-detail-row">
|
||||
<td colspan="5"><DiagnosticsList diagnostics={remote.diagnostics} /></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if tests[remote.runtime_id]}
|
||||
{@const test = tests[remote.runtime_id]}
|
||||
{@const available = capabilityOperations(test, 'available')}
|
||||
{@const unchecked = capabilityOperations(test, 'unknown')}
|
||||
<tr class="settings-runtime-detail-row">
|
||||
<td colspan="5">
|
||||
<div class="settings-test-result">
|
||||
<strong>Test: {test.state}</strong>
|
||||
<span>{test.health_result} · {test.checked_at}</span>
|
||||
<p>{test.compatibility_basis}</p>
|
||||
{#if available.length > 0}
|
||||
<p class="settings-test-verified">Verified areas: {available.join(', ')}</p>
|
||||
{/if}
|
||||
{#if unchecked.length > 0}
|
||||
<p class="settings-test-verified">Unchecked warning areas: {unchecked.join(', ')}</p>
|
||||
{/if}
|
||||
<DiagnosticsList diagnostics={test.diagnostics} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if runtimeSettings.remotes.length === 0}
|
||||
<p class="status-message">No remote Runtime connections configured.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,27 +1,161 @@
|
||||
<script lang="ts">
|
||||
import type { Runtime } from '$lib/workspace/sidebar/types';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||
import type { Diagnostic, Runtime } from '$lib/workspace/sidebar/types';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
type ConnectionTest = {
|
||||
runtime_id: string;
|
||||
checked_at: string;
|
||||
state: string;
|
||||
protocol_version?: string | null;
|
||||
compatibility_basis: string;
|
||||
capabilities: string[];
|
||||
health_result: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let runtimeId = $state('');
|
||||
let displayName = $state('');
|
||||
let endpoint = $state('');
|
||||
let showAddRuntime = $state(false);
|
||||
let busyRuntimeId = $state<string | null>(null);
|
||||
let requestError = $state<string | null>(null);
|
||||
let testResults = $state<Record<string, ConnectionTest>>({});
|
||||
|
||||
function runtimePlatform(runtime: Runtime): string {
|
||||
return `${runtime.os} / ${runtime.arch}`;
|
||||
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
|
||||
}
|
||||
|
||||
function managementLabel(runtime: Runtime): string {
|
||||
if (runtime.management?.built_in) return 'Built-in';
|
||||
if (runtime.management?.config_managed) return 'Managed remote';
|
||||
return 'Observed';
|
||||
}
|
||||
|
||||
async function responseError(response: Response): Promise<string> {
|
||||
const payload = await response.json().catch(() => null) as
|
||||
| { message?: string; error?: string }
|
||||
| null;
|
||||
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`;
|
||||
}
|
||||
|
||||
async function addRuntime(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
requestError = null;
|
||||
busyRuntimeId = 'create';
|
||||
try {
|
||||
const response = await fetch(workspaceApiPath(data.workspaceId, '/runtimes'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
runtime_id: runtimeId,
|
||||
display_name: displayName || null,
|
||||
endpoint,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
runtimeId = '';
|
||||
displayName = '';
|
||||
endpoint = '';
|
||||
showAddRuntime = false;
|
||||
await invalidateAll();
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
busyRuntimeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRuntime(runtime: Runtime): Promise<void> {
|
||||
requestError = null;
|
||||
busyRuntimeId = runtime.runtime_id;
|
||||
try {
|
||||
const response = await fetch(
|
||||
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtime.runtime_id)}`),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
const nextResults = { ...testResults };
|
||||
delete nextResults[runtime.runtime_id];
|
||||
testResults = nextResults;
|
||||
await invalidateAll();
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
busyRuntimeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function testRuntime(runtime: Runtime): Promise<void> {
|
||||
requestError = null;
|
||||
busyRuntimeId = runtime.runtime_id;
|
||||
try {
|
||||
const response = await fetch(
|
||||
workspaceApiPath(
|
||||
data.workspaceId,
|
||||
`/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests`,
|
||||
),
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
const result = await response.json() as ConnectionTest;
|
||||
testResults = { ...testResults, [runtime.runtime_id]: result };
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
busyRuntimeId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Runtime Inventory · Settings · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Runtime inventory" />
|
||||
<title>Runtimes · Settings · Yoi Workspace</title>
|
||||
<meta name="description" content="Workspace Runtime resources" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="runtimes-page" aria-labelledby="runtimes-heading">
|
||||
<header class="page-header-row">
|
||||
<div>
|
||||
<h1 id="runtimes-heading">Runtime Inventory</h1>
|
||||
<p>Admin view of execution backends available to this workspace.</p>
|
||||
<h1 id="runtimes-heading">Runtimes</h1>
|
||||
<p>Register and inspect the execution backends available to this Workspace.</p>
|
||||
</div>
|
||||
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
|
||||
{showAddRuntime ? 'Close' : 'Add Runtime'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showAddRuntime}
|
||||
<form class="settings-runtime-form" onsubmit={addRuntime}>
|
||||
<h2>Add remote Runtime</h2>
|
||||
<div class="settings-form-grid">
|
||||
<label>
|
||||
Runtime ID
|
||||
<input bind:value={runtimeId} required autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Display name
|
||||
<input bind:value={displayName} autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Endpoint
|
||||
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-action-row">
|
||||
<button type="submit" disabled={busyRuntimeId !== null}>Add Runtime</button>
|
||||
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if requestError}
|
||||
<p class="section-state error">{requestError}</p>
|
||||
{/if}
|
||||
|
||||
{#if data.runtimesError}
|
||||
<p class="section-state error">{data.runtimesError}</p>
|
||||
{:else if !data.runtimes}
|
||||
@@ -29,20 +163,22 @@
|
||||
{:else if data.runtimes.items.length === 0}
|
||||
<p class="section-state">No Runtimes are visible.</p>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table class="runtimes-table">
|
||||
<div class="settings-runtime-table-wrap">
|
||||
<table class="settings-runtime-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Runtime</th>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Platform</th>
|
||||
<th>Management</th>
|
||||
<th>Workdirs</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.runtimes.items as runtime}
|
||||
<tr>
|
||||
<tr class:inactive={runtime.status !== 'active'}>
|
||||
<td>
|
||||
<strong>{runtime.label}</strong>
|
||||
<small><code>{runtime.runtime_id}</code></small>
|
||||
@@ -50,12 +186,58 @@
|
||||
<td>{runtime.kind}</td>
|
||||
<td>{runtime.status}</td>
|
||||
<td>{runtimePlatform(runtime)}</td>
|
||||
<td>{managementLabel(runtime)}</td>
|
||||
<td>
|
||||
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
|
||||
Open workdirs
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="settings-action-row">
|
||||
{#if runtime.management?.config_managed}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyRuntimeId !== null}
|
||||
onclick={() => testRuntime(runtime)}
|
||||
>Test</button>
|
||||
{/if}
|
||||
{#if runtime.management?.removable}
|
||||
<button
|
||||
class="danger"
|
||||
type="button"
|
||||
disabled={busyRuntimeId !== null}
|
||||
onclick={() => deleteRuntime(runtime)}
|
||||
>Delete</button>
|
||||
{:else}
|
||||
<span class="settings-muted-action">Not removable</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{#if runtime.diagnostics.length > 0 || testResults[runtime.runtime_id]}
|
||||
<tr class="settings-runtime-detail-row">
|
||||
<td colspan="7">
|
||||
{#if runtime.diagnostics.length > 0}
|
||||
<ul class="settings-diagnostics-list">
|
||||
{#each runtime.diagnostics as diagnostic}
|
||||
<li class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'}>
|
||||
<strong>{diagnostic.code}</strong>
|
||||
<span>{diagnostic.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if testResults[runtime.runtime_id]}
|
||||
{@const result = testResults[runtime.runtime_id]}
|
||||
<div class="settings-test-result">
|
||||
<strong>Connection test: {result.state}</strong>
|
||||
<span>{result.health_result}</span>
|
||||
<small>{result.compatibility_basis} · {result.checked_at}</small>
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@
|
||||
<section class="workdirs-page" aria-labelledby="workdirs-heading">
|
||||
<header class="page-header-row">
|
||||
<div>
|
||||
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtime Inventory</a> / {runtimeLabel}</p>
|
||||
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
|
||||
<h1 id="workdirs-heading">Workdirs</h1>
|
||||
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
|
||||
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
|
||||
|
||||
Reference in New Issue
Block a user