feat: unify workspace settings and runtime resources
This commit is contained in:
@@ -237,6 +237,7 @@ pub enum RuntimeSourceStatus {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RuntimeIdentityAuthority {
|
pub enum RuntimeIdentityAuthority {
|
||||||
RuntimeRegistryProjection,
|
RuntimeRegistryProjection,
|
||||||
|
ServerRuntimeConfiguration,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -263,6 +264,46 @@ pub struct RuntimeSummary {
|
|||||||
pub diagnostics: Vec<Diagnostic>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerWorkspaceSummary {
|
pub struct WorkerWorkspaceSummary {
|
||||||
pub visibility: String,
|
pub visibility: String,
|
||||||
|
|||||||
@@ -58,12 +58,13 @@ use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjec
|
|||||||
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
||||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||||
use workspace_api::{
|
use workspace_api::{
|
||||||
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
|
CreateRemoteRuntimeRequest, CreateRepositorySshCredentialRequest,
|
||||||
DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest,
|
DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest,
|
||||||
ObjectiveLinkTicketRequest, ObjectiveStateRequest, PutRepositorySshHostTrustRequest,
|
ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||||
RepositoryAccessProjection, RepositorySshCredential, RepositorySshHostTrust,
|
ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection,
|
||||||
RotateRepositorySshCredentialRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest,
|
||||||
TICKET_RELATIONS_QUERY_PATH,
|
RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||||
|
TICKET_RELATIONS_QUERY_PATH, WorkspaceRuntimeResource,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
@@ -2428,7 +2429,18 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
|||||||
get(scoped_working_directory_detail).delete(scoped_cleanup_working_directory),
|
get(scoped_working_directory_detail).delete(scoped_cleanup_working_directory),
|
||||||
)
|
)
|
||||||
.route("/api/runtimes", get(list_runtimes))
|
.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(
|
.route(
|
||||||
"/api/workers",
|
"/api/workers",
|
||||||
get(list_workers).post(create_workspace_worker),
|
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",
|
"/api/w/{workspace_id}/workers/launch-options",
|
||||||
get(scoped_get_worker_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(
|
.route(
|
||||||
"/api/runtime/v1/workspaces/{workspace_id}/resources/fetch",
|
"/api/runtime/v1/workspaces/{workspace_id}/resources/fetch",
|
||||||
post(scoped_post_internal_runtime_resource_fetch),
|
post(scoped_post_internal_runtime_resource_fetch),
|
||||||
@@ -2958,66 +2938,6 @@ pub struct WorkerRetentionResponse {
|
|||||||
pub retention_state: String,
|
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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct WorkerLaunchOptionsResponse {
|
pub struct WorkerLaunchOptionsResponse {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
@@ -7961,9 +7881,13 @@ async fn scoped_get_profile_source_archive(
|
|||||||
async fn scoped_list_runtimes(
|
async fn scoped_list_runtimes(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
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)?;
|
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(
|
async fn scoped_workspace_protocol_ws(
|
||||||
@@ -9833,37 +9757,29 @@ fn cleanup_api_error(runtime_id: &str, code: &str, message: &str) -> ApiError {
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_get_runtime_connection_settings(
|
async fn scoped_create_remote_runtime(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
) -> ApiResult<Json<RuntimeConnectionSettingsResponse>> {
|
Json(request): Json<CreateRemoteRuntimeRequest>,
|
||||||
|
) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
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(
|
async fn scoped_delete_remote_runtime(
|
||||||
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(
|
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
|
) -> ApiResult<StatusCode> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
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>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||||
) -> ApiResult<Json<RemoteRuntimeTestResponse>> {
|
) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
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(
|
async fn scoped_get_companion_status(
|
||||||
@@ -11154,27 +11070,17 @@ async fn list_workers(
|
|||||||
workers_response(api).map(Json)
|
workers_response(api).map(Json)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_runtime_connection_settings(
|
async fn create_remote_runtime(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
) -> ApiResult<Json<RuntimeConnectionSettingsResponse>> {
|
Json(request): Json<CreateRemoteRuntimeRequest>,
|
||||||
let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
) -> ApiResult<(StatusCode, Json<WorkspaceRuntimeResource>)> {
|
||||||
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>> {
|
|
||||||
validate_runtime_connection_request(&request)?;
|
validate_runtime_connection_request(&request)?;
|
||||||
let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
||||||
let id = request.runtime_id.trim().to_string();
|
let id = request.runtime_id.trim().to_string();
|
||||||
if id == EMBEDDED_WORKER_RUNTIME_ID {
|
if id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||||
return Err(settings_bad_request(
|
return Err(settings_bad_request(
|
||||||
"embedded_runtime_not_config_managed",
|
"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
|
if request
|
||||||
@@ -11184,7 +11090,7 @@ async fn add_remote_runtime_connection(
|
|||||||
{
|
{
|
||||||
return Err(settings_bad_request(
|
return Err(settings_bad_request(
|
||||||
"remote_runtime_token_ref_unsupported",
|
"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
|
if runtime_config
|
||||||
@@ -11195,11 +11101,11 @@ async fn add_remote_runtime_connection(
|
|||||||
{
|
{
|
||||||
return Err(settings_bad_request(
|
return Err(settings_bad_request(
|
||||||
"remote_runtime_already_exists",
|
"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 {
|
let remote_config = RemoteRuntimeConfigFile {
|
||||||
id,
|
id: id.clone(),
|
||||||
endpoint: request.endpoint.trim().to_string(),
|
endpoint: request.endpoint.trim().to_string(),
|
||||||
display_name: request
|
display_name: request
|
||||||
.display_name
|
.display_name
|
||||||
@@ -11232,31 +11138,19 @@ async fn add_remote_runtime_connection(
|
|||||||
runtime_config.runtimes.remote.push(remote_config);
|
runtime_config.runtimes.remote.push(remote_config);
|
||||||
write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
|
write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
|
||||||
api.runtime.register_or_replace(active_runtime);
|
api.runtime.register_or_replace(active_runtime);
|
||||||
let mut response = runtime_connection_mutation_response(
|
let resource = workspace_runtime_resource_by_id(&api, &runtime_config, &id)
|
||||||
&api,
|
.ok_or_else(|| Error::UnknownRuntime(id.clone()))?;
|
||||||
&runtime_config,
|
Ok((StatusCode::CREATED, Json(resource)))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_remote_runtime_connection(
|
async fn delete_remote_runtime(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(runtime_id): AxumPath<String>,
|
AxumPath(runtime_id): AxumPath<String>,
|
||||||
) -> ApiResult<Json<RuntimeConnectionMutationResponse>> {
|
) -> ApiResult<StatusCode> {
|
||||||
if runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
if runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||||
return Err(settings_bad_request(
|
return Err(settings_bad_request(
|
||||||
"embedded_runtime_not_config_managed",
|
"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)?;
|
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",
|
"remote_runtime_delete_blocked",
|
||||||
DiagnosticSeverity::Error,
|
DiagnosticSeverity::Error,
|
||||||
format!(
|
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(
|
return Err(ApiError::with_diagnostics(
|
||||||
Error::RuntimeOperationFailed {
|
Error::RuntimeOperationFailed {
|
||||||
runtime_id,
|
runtime_id,
|
||||||
code: "remote_runtime_delete_blocked".to_string(),
|
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,
|
diagnostics,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
|
write_backend_runtimes_config_for_settings(&api, &runtime_config)?;
|
||||||
let mut response = runtime_connection_mutation_response(
|
Ok(StatusCode::NO_CONTENT)
|
||||||
&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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn test_remote_runtime_connection(
|
async fn test_runtime_connection(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(runtime_id): AxumPath<String>,
|
AxumPath(runtime_id): AxumPath<String>,
|
||||||
) -> ApiResult<Json<RemoteRuntimeTestResponse>> {
|
) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
|
||||||
let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
let runtime_config = load_backend_runtimes_config_for_settings(&api)?;
|
||||||
let remote = runtime_config
|
let remote = runtime_config
|
||||||
.runtimes
|
.runtimes
|
||||||
@@ -13218,142 +13098,112 @@ fn write_backend_runtimes_config_for_settings(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_connection_settings_response(
|
fn workspace_runtime_resources_response(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
runtime_config: &BackendRuntimesConfigFile,
|
runtime_config: &BackendRuntimesConfigFile,
|
||||||
) -> RuntimeConnectionSettingsResponse {
|
) -> workspace_api::ListResponse<WorkspaceRuntimeResource> {
|
||||||
RuntimeConnectionSettingsResponse {
|
let limit = api.config.max_records.min(200);
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
let runtimes = api.runtime.list_runtimes(limit);
|
||||||
embedded: embedded_runtime_connection_summary(api),
|
let mut items = runtimes
|
||||||
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))
|
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|runtime| runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID);
|
.map(|runtime| {
|
||||||
match active {
|
let remote = runtime_config
|
||||||
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
|
.runtimes
|
||||||
.remote
|
.remote
|
||||||
.iter()
|
.iter()
|
||||||
.map(|remote| {
|
.find(|remote| remote.id == runtime.runtime_id);
|
||||||
let live = live_runtimes
|
let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID;
|
||||||
.iter()
|
WorkspaceRuntimeResource {
|
||||||
.find(|runtime| runtime.runtime_id == remote.id);
|
runtime: runtime.into(),
|
||||||
let (display_name, kind, active, worker_creation_available, status, diagnostics) = match live {
|
management: RuntimeManagementSummary {
|
||||||
Some(runtime) => (
|
built_in,
|
||||||
runtime.label.clone(),
|
config_managed: remote.is_some(),
|
||||||
runtime.kind.clone(),
|
removable: remote.is_some() && !built_in,
|
||||||
runtime.status == "active",
|
endpoint_configured: remote
|
||||||
runtime.worker_creation_available,
|
.is_some_and(|remote| !remote.endpoint.trim().is_empty()),
|
||||||
runtime.status.clone(),
|
token_ref_configured: remote.is_some_and(|remote| {
|
||||||
runtime.diagnostics.clone(),
|
|
||||||
),
|
|
||||||
None => (
|
|
||||||
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
|
.display_name
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| remote.id.clone()),
|
.unwrap_or_else(|| remote.id.clone()),
|
||||||
"remote_http".to_string(),
|
kind: "remote_http".to_string(),
|
||||||
false,
|
status: "unavailable".to_string(),
|
||||||
false,
|
source: workspace_api::RuntimeSourceSummary {
|
||||||
"configured_restart_required".to_string(),
|
kind: workspace_api::RuntimeSourceKind::RemoteHttp,
|
||||||
if restart_required {
|
status: workspace_api::RuntimeSourceStatus::Reserved,
|
||||||
vec![settings_diagnostic(
|
identity_authority:
|
||||||
"runtime_registry_restart_required",
|
workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration,
|
||||||
DiagnosticSeverity::Warning,
|
note: "The configured Runtime is not present in the active Runtime registry."
|
||||||
"This remote Runtime config is persisted but not active until the Workspace backend restarts.",
|
.to_string(),
|
||||||
)]
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
},
|
},
|
||||||
),
|
host_ids: Vec::new(),
|
||||||
};
|
worker_creation_available: false,
|
||||||
RemoteRuntimeConnectionSummary {
|
os: String::new(),
|
||||||
summary: RuntimeConnectionSummary {
|
arch: String::new(),
|
||||||
runtime_id: remote.id.clone(),
|
diagnostics: vec![
|
||||||
display_name,
|
settings_diagnostic(
|
||||||
kind,
|
"configured_runtime_unavailable",
|
||||||
|
DiagnosticSeverity::Warning,
|
||||||
|
"The configured Runtime is not present in the active Runtime registry.",
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
management: RuntimeManagementSummary {
|
||||||
built_in: false,
|
built_in: false,
|
||||||
config_managed: true,
|
config_managed: true,
|
||||||
active,
|
removable: true,
|
||||||
worker_creation_available,
|
|
||||||
restart_required,
|
|
||||||
status,
|
|
||||||
diagnostics,
|
|
||||||
},
|
|
||||||
endpoint_configured: !remote.endpoint.trim().is_empty(),
|
endpoint_configured: !remote.endpoint.trim().is_empty(),
|
||||||
token_ref_configured: remote
|
token_ref_configured: remote
|
||||||
.token_ref
|
.token_ref
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|value| !value.trim().is_empty()),
|
.is_some_and(|value| !value.trim().is_empty()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_runtime_connection_request(
|
fn workspace_runtime_resource_by_id(
|
||||||
request: &AddRemoteRuntimeConnectionRequest,
|
api: &WorkspaceApi,
|
||||||
) -> ApiResult<()> {
|
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())?;
|
validate_public_runtime_id(request.runtime_id.trim())?;
|
||||||
let endpoint = request.endpoint.trim();
|
let endpoint = request.endpoint.trim();
|
||||||
if endpoint.is_empty() || !(endpoint.starts_with("http://") || endpoint.starts_with("https://"))
|
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(
|
async fn test_remote_runtime_config(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
remote: &RemoteRuntimeConfigFile,
|
remote: &RemoteRuntimeConfigFile,
|
||||||
) -> RemoteRuntimeTestResponse {
|
) -> RuntimeConnectionTestResponse {
|
||||||
let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
|
let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
|
||||||
if remote
|
if remote
|
||||||
.token_ref
|
.token_ref
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|value| !value.trim().is_empty())
|
.is_some_and(|value| !value.trim().is_empty())
|
||||||
{
|
{
|
||||||
return RemoteRuntimeTestResponse {
|
return RuntimeConnectionTestResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
runtime_id: remote.id.clone(),
|
runtime_id: remote.id.clone(),
|
||||||
checked_at,
|
checked_at,
|
||||||
@@ -13431,7 +13281,8 @@ async fn test_remote_runtime_config(
|
|||||||
"remote_runtime_token_ref_unsupported",
|
"remote_runtime_token_ref_unsupported",
|
||||||
DiagnosticSeverity::Error,
|
DiagnosticSeverity::Error,
|
||||||
"Remote Runtime test cannot use token_ref in v0; no token or secret value was exposed to the Browser.",
|
"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.",
|
"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(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
runtime_id: remote.id.clone(),
|
runtime_id: remote.id.clone(),
|
||||||
checked_at,
|
checked_at,
|
||||||
@@ -13705,7 +13556,11 @@ async fn test_remote_runtime_config(
|
|||||||
observation.incompatible_count,
|
observation.incompatible_count,
|
||||||
observation.unknown_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,
|
checked_at: String,
|
||||||
code: impl Into<String>,
|
code: impl Into<String>,
|
||||||
message: impl Into<String>,
|
message: impl Into<String>,
|
||||||
) -> RemoteRuntimeTestResponse {
|
) -> RuntimeConnectionTestResponse {
|
||||||
RemoteRuntimeTestResponse {
|
RuntimeConnectionTestResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
runtime_id: remote.id.clone(),
|
runtime_id: remote.id.clone(),
|
||||||
checked_at,
|
checked_at,
|
||||||
@@ -13725,11 +13580,7 @@ fn remote_runtime_test_failed(
|
|||||||
compatibility_basis: "worker-runtime lightweight HTTP compatibility probes".to_string(),
|
compatibility_basis: "worker-runtime lightweight HTTP compatibility probes".to_string(),
|
||||||
capabilities: Vec::new(),
|
capabilities: Vec::new(),
|
||||||
health_result: "failed".to_string(),
|
health_result: "failed".to_string(),
|
||||||
diagnostics: vec![settings_diagnostic(
|
diagnostics: vec![settings_diagnostic(code, DiagnosticSeverity::Error, message).into()],
|
||||||
code,
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
message,
|
|
||||||
)],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17046,7 +16897,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_connection_request_validation_bounds_browser_input() {
|
fn runtime_connection_request_validation_bounds_browser_input() {
|
||||||
let ok = AddRemoteRuntimeConnectionRequest {
|
let ok = CreateRemoteRuntimeRequest {
|
||||||
runtime_id: "team-runtime_1".to_string(),
|
runtime_id: "team-runtime_1".to_string(),
|
||||||
display_name: Some("Team Runtime".to_string()),
|
display_name: Some("Team Runtime".to_string()),
|
||||||
endpoint: "https://runtime.example".to_string(),
|
endpoint: "https://runtime.example".to_string(),
|
||||||
@@ -17054,7 +16905,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(validate_runtime_connection_request(&ok).is_ok());
|
assert!(validate_runtime_connection_request(&ok).is_ok());
|
||||||
|
|
||||||
let bad_endpoint = AddRemoteRuntimeConnectionRequest {
|
let bad_endpoint = CreateRemoteRuntimeRequest {
|
||||||
endpoint: "/tmp/socket".to_string(),
|
endpoint: "/tmp/socket".to_string(),
|
||||||
..ok
|
..ok
|
||||||
};
|
};
|
||||||
@@ -22698,34 +22549,52 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 dir = tempfile::tempdir().unwrap();
|
||||||
let app = test_app(dir.path()).await;
|
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;
|
let initial = get_json(app.clone(), &runtimes_uri).await;
|
||||||
assert_eq!(settings["embedded"]["built_in"], true);
|
let embedded = initial["items"]
|
||||||
assert_eq!(settings["embedded"]["config_managed"], false);
|
|
||||||
|
|
||||||
let added = post_json(
|
|
||||||
app.clone(),
|
|
||||||
"/api/settings/runtime-connections/remotes",
|
|
||||||
serde_json::json!({
|
|
||||||
"runtime_id": "team-runtime",
|
|
||||||
"display_name": "Team Runtime",
|
|
||||||
"endpoint": "https://runtime.example.invalid"
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.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()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|diagnostic| diagnostic["code"] == "runtime_registry_applied")
|
.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(),
|
||||||
|
"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["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();
|
let projected = serde_json::to_string(&added).unwrap();
|
||||||
assert!(!projected.contains("runtime.example.invalid"));
|
assert!(!projected.contains("runtime.example.invalid"));
|
||||||
|
|
||||||
@@ -22756,13 +22625,12 @@ mod tests {
|
|||||||
let deleted = request_json(
|
let deleted = request_json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
"DELETE",
|
"DELETE",
|
||||||
"/api/settings/runtime-connections/remotes/team-runtime",
|
&format!("{runtimes_uri}/team-runtime"),
|
||||||
None,
|
None,
|
||||||
StatusCode::OK,
|
StatusCode::NO_CONTENT,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(deleted["restart_required"], false);
|
assert_eq!(deleted["message"], "");
|
||||||
assert_eq!(deleted["remotes"].as_array().unwrap().len(), 0);
|
|
||||||
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
|
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
|
||||||
assert!(
|
assert!(
|
||||||
!launch_options["runtimes"]
|
!launch_options["runtimes"]
|
||||||
@@ -22794,17 +22662,19 @@ mod tests {
|
|||||||
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let app = test_app(dir.path()).await;
|
let app = test_app(dir.path()).await;
|
||||||
let added = post_json(
|
let added = request_json(
|
||||||
app.clone(),
|
app.clone(),
|
||||||
"/api/settings/runtime-connections/remotes",
|
"POST",
|
||||||
serde_json::json!({
|
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes"),
|
||||||
|
Some(serde_json::json!({
|
||||||
"runtime_id": "busy-runtime",
|
"runtime_id": "busy-runtime",
|
||||||
"display_name": "Busy Runtime",
|
"display_name": "Busy Runtime",
|
||||||
"endpoint": format!("http://{runtime_addr}")
|
"endpoint": format!("http://{runtime_addr}")
|
||||||
}),
|
})),
|
||||||
|
StatusCode::CREATED,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(added["restart_required"], false);
|
assert_eq!(added["runtime_id"], "busy-runtime");
|
||||||
let workers = get_json(app.clone(), "/api/workers").await;
|
let workers = get_json(app.clone(), "/api/workers").await;
|
||||||
assert!(
|
assert!(
|
||||||
workers["items"]
|
workers["items"]
|
||||||
@@ -22818,7 +22688,7 @@ mod tests {
|
|||||||
let response = request_json(
|
let response = request_json(
|
||||||
app,
|
app,
|
||||||
"DELETE",
|
"DELETE",
|
||||||
"/api/settings/runtime-connections/remotes/busy-runtime",
|
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/busy-runtime"),
|
||||||
None,
|
None,
|
||||||
StatusCode::CONFLICT,
|
StatusCode::CONFLICT,
|
||||||
)
|
)
|
||||||
@@ -22876,7 +22746,7 @@ mod tests {
|
|||||||
|
|
||||||
let response = post_json(
|
let response = post_json(
|
||||||
app,
|
app,
|
||||||
"/api/settings/runtime-connections/remotes/probe-runtime/test",
|
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/probe-runtime/connection-tests"),
|
||||||
serde_json::json!({}),
|
serde_json::json!({}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -22940,7 +22810,7 @@ mod tests {
|
|||||||
|
|
||||||
let response = post_json(
|
let response = post_json(
|
||||||
app,
|
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!({}),
|
serde_json::json!({}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"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",
|
"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",
|
"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",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"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')",
|
"workspaceRoute(workspaceId, '/settings/runtimes')",
|
||||||
) &&
|
) &&
|
||||||
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
|
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(
|
assert(
|
||||||
!workspacePage.includes("workerConsoleHref") &&
|
!workspacePage.includes("workerConsoleHref") &&
|
||||||
@@ -599,21 +599,24 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
|
|||||||
|
|
||||||
assert(
|
assert(
|
||||||
!sidebar.includes("RuntimesNavSection") &&
|
!sidebar.includes("RuntimesNavSection") &&
|
||||||
settingsModel.includes('id: "runtime-inventory"') &&
|
settingsModel.includes('id: "runtimes"') &&
|
||||||
settingsModel.includes("return `${SETTINGS_ROUTE}/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(
|
assert(
|
||||||
runtimesPage.includes("Runtime Inventory") &&
|
runtimesPage.includes("Add remote Runtime") &&
|
||||||
runtimesPage.includes("Open workdirs") &&
|
runtimesPage.includes("Open workdirs") &&
|
||||||
runtimesPage.includes("runtimes-table") &&
|
runtimesPage.includes("settings-runtime-table") &&
|
||||||
|
runtimesPage.includes(
|
||||||
|
"/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests",
|
||||||
|
) &&
|
||||||
runtimesPage.includes(
|
runtimesPage.includes(
|
||||||
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs",
|
"/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(
|
assert(
|
||||||
workdirsPage.includes("Runtime Inventory") &&
|
workdirsPage.includes(">Runtimes</a>") &&
|
||||||
workdirsPage.includes("workdirs-table") &&
|
workdirsPage.includes("workdirs-table") &&
|
||||||
workdirsLoad.includes("/working-directories"),
|
workdirsLoad.includes("/working-directories"),
|
||||||
"Runtime workdirs should remain backed by Runtime APIs without legacy Runtime route redirects",
|
"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",
|
"Sidebar styles should define their layer order before component rules so base link styles do not win by import order",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
sidebarOverride.includes("controller.setSidebar(sidebar)") &&
|
sidebarOverride.includes("controller.registerSidebar(sidebar)") &&
|
||||||
sidebarOverride.includes("controller.clearSidebar(sidebar)"),
|
rootLayout.includes("createOverrideStack<SidebarSnippet>") &&
|
||||||
"SidebarOverride should register and clean up the child-provided sidebar snippet",
|
rootLayout.includes("registerSidebar: sidebarOverrides.register"),
|
||||||
|
"SidebarOverride should register a nested sidebar whose cleanup restores the parent override",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
rootLayoutLoad.includes("export const load") &&
|
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) =>
|
const runtimeSection = SETTINGS_SECTIONS.find((section) =>
|
||||||
section.id === "runtime-connections"
|
section.id === "runtimes"
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
runtimeSection?.status === "editable",
|
runtimeSection?.status === "editable",
|
||||||
"Runtime Connections should be editable",
|
"Runtimes should be editable",
|
||||||
);
|
);
|
||||||
|
|
||||||
const allText = [
|
const allText = [
|
||||||
@@ -91,13 +91,13 @@ Deno.test("runtime connections are editable without advertising raw authority le
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
allText.includes("restart_required=true") ||
|
allText.includes("canonical") && allText.includes("REST resource"),
|
||||||
allText.includes("Restart-required"),
|
"Runtime settings should describe the canonical REST resource",
|
||||||
"restart-required pattern should be visible",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
allText.includes("not echoed back") || allText.includes("not echoed"),
|
!allText.includes("restart_required") &&
|
||||||
"endpoint submission should not imply endpoint echoing",
|
!allText.includes("Restart-required"),
|
||||||
|
"Runtime settings should not retain obsolete restart-required semantics",
|
||||||
);
|
);
|
||||||
|
|
||||||
for (
|
for (
|
||||||
@@ -120,12 +120,12 @@ Deno.test("runtime connections are editable without advertising raw authority le
|
|||||||
Deno.test("diagnostic labels preserve severity and code", () => {
|
Deno.test("diagnostic labels preserve severity and code", () => {
|
||||||
const diagnostic = {
|
const diagnostic = {
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
code: "runtime_registry_restart_required",
|
code: "configured_runtime_unavailable",
|
||||||
message: "Restart required.",
|
message: "Configured Runtime unavailable.",
|
||||||
} as const;
|
} as const;
|
||||||
assert(
|
assert(
|
||||||
diagnosticLabel(diagnostic) ===
|
diagnosticLabel(diagnostic) ===
|
||||||
"warning: runtime_registry_restart_required",
|
"warning: configured_runtime_unavailable",
|
||||||
"diagnostic label should be bounded and stable",
|
"diagnostic label should be bounded and stable",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,18 +5,16 @@ export type Diagnostic = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SettingsSectionId =
|
export type SettingsSectionId =
|
||||||
| "runtime-connections"
|
| "runtimes"
|
||||||
| "runtime-inventory"
|
|
||||||
| "configuration-sources"
|
| "configuration-sources"
|
||||||
| "repository-access"
|
| "repository-access"
|
||||||
| "profile-sources"
|
| "profile-sources"
|
||||||
| "backend-config"
|
|
||||||
| "workspace-identity";
|
| "workspace-identity";
|
||||||
|
|
||||||
export type SettingsSection = {
|
export type SettingsSection = {
|
||||||
readonly id: SettingsSectionId;
|
readonly id: SettingsSectionId;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly status: "editable" | "placeholder" | "read-only";
|
readonly status: "editable" | "read-only";
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
readonly bullets: readonly string[];
|
readonly bullets: readonly string[];
|
||||||
};
|
};
|
||||||
@@ -26,50 +24,6 @@ export type SettingsPattern = {
|
|||||||
readonly body: string;
|
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_ROUTE = "/settings";
|
||||||
|
|
||||||
export const SETTINGS_PERMISSION_NOTICE =
|
export const SETTINGS_PERMISSION_NOTICE =
|
||||||
@@ -77,27 +31,15 @@ export const SETTINGS_PERMISSION_NOTICE =
|
|||||||
|
|
||||||
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||||
{
|
{
|
||||||
id: "runtime-connections",
|
id: "runtimes",
|
||||||
label: "Runtime Connections",
|
label: "Runtimes",
|
||||||
status: "editable",
|
status: "editable",
|
||||||
summary:
|
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: [
|
bullets: [
|
||||||
"Remote connection changes are persisted through typed read-modify-write config updates and require a Backend restart before the live registry changes.",
|
"Embedded and remote Runtimes share one canonical Workspace resource representation.",
|
||||||
"The browser may submit a new endpoint, but Runtime endpoints, tokens, sockets, store roots, and config paths are not echoed back in API responses.",
|
"Remote Runtime creation, connection tests, and guarded deletion use the same REST collection.",
|
||||||
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
|
"Runtime status, worker creation availability, diagnostics, and Workdir inventory remain visible without exposing endpoints or credentials.",
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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.",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -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.",
|
"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",
|
id: "workspace-identity",
|
||||||
label: "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.",
|
"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:
|
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",
|
title: "Typed Runtime surface only",
|
||||||
body:
|
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 {
|
export function settingsSectionHref(id: SettingsSectionId): string {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "runtime-connections":
|
case "runtimes":
|
||||||
return `${SETTINGS_ROUTE}/runtime-connections`;
|
|
||||||
case "runtime-inventory":
|
|
||||||
return `${SETTINGS_ROUTE}/runtimes`;
|
return `${SETTINGS_ROUTE}/runtimes`;
|
||||||
case "configuration-sources":
|
case "configuration-sources":
|
||||||
return `${SETTINGS_ROUTE}/configuration`;
|
return `${SETTINGS_ROUTE}/configuration`;
|
||||||
@@ -194,8 +122,6 @@ export function settingsSectionHref(id: SettingsSectionId): string {
|
|||||||
return `${SETTINGS_ROUTE}/profiles`;
|
return `${SETTINGS_ROUTE}/profiles`;
|
||||||
case "workspace-identity":
|
case "workspace-identity":
|
||||||
return `${SETTINGS_ROUTE}/workspace`;
|
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 { sidebar }: Props = $props();
|
||||||
const controller = getSidebarController();
|
const controller = getSidebarController();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => controller.registerSidebar(sidebar));
|
||||||
controller.setSidebar(sidebar);
|
|
||||||
return () => controller.clearSidebar(sidebar);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
import './sidebar.css';
|
import './sidebar.css';
|
||||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||||
import MemoryNavSection from './MemoryNavSection.svelte';
|
import MemoryNavSection from './MemoryNavSection.svelte';
|
||||||
@@ -12,9 +13,15 @@
|
|||||||
workspace: WorkspaceResponse | null;
|
workspace: WorkspaceResponse | null;
|
||||||
workspaceError?: string | null;
|
workspaceError?: string | null;
|
||||||
currentPath?: string;
|
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 ?? '');
|
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
||||||
</script>
|
</script>
|
||||||
@@ -38,6 +45,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{#if content}
|
||||||
|
{@render content()}
|
||||||
|
{:else}
|
||||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||||
<TicketsNavSection {currentPath} {workspaceId} />
|
<TicketsNavSection {currentPath} {workspaceId} />
|
||||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||||
@@ -45,4 +55,5 @@
|
|||||||
<MergeRequestsNavSection {currentPath} {workspaceId} />
|
<MergeRequestsNavSection {currentPath} {workspaceId} />
|
||||||
<WorkersNavSection {currentPath} {workspaceId} />
|
<WorkersNavSection {currentPath} {workspaceId} />
|
||||||
</nav>
|
</nav>
|
||||||
|
{/if}
|
||||||
</div>
|
</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 SidebarSnippet = Snippet<[]>;
|
||||||
|
|
||||||
export type SidebarController = {
|
export type SidebarController = {
|
||||||
setSidebar(snippet: SidebarSnippet): void;
|
registerSidebar(sidebar: SidebarSnippet): () => void;
|
||||||
clearSidebar(snippet: SidebarSnippet): void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SIDEBAR_CONTEXT = Symbol("yoi-sidebar-context");
|
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;
|
os: string;
|
||||||
arch: string;
|
arch: string;
|
||||||
diagnostics: Diagnostic[];
|
diagnostics: Diagnostic[];
|
||||||
|
management?: {
|
||||||
|
built_in: boolean;
|
||||||
|
config_managed: boolean;
|
||||||
|
removable: boolean;
|
||||||
|
endpoint_configured: boolean;
|
||||||
|
token_ref_configured: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Host = {
|
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);
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-hero,
|
|
||||||
.settings-notice,
|
.settings-notice,
|
||||||
.settings-section-header,
|
.settings-section-header,
|
||||||
.settings-patterns {
|
.settings-patterns {
|
||||||
@@ -183,7 +182,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.settings-hero,
|
|
||||||
.settings-notice,
|
.settings-notice,
|
||||||
.settings-section-header,
|
.settings-section-header,
|
||||||
.settings-patterns {
|
.settings-patterns {
|
||||||
@@ -345,18 +343,11 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
.settings-nav {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.settings-nav a,
|
|
||||||
.settings-section-card,
|
.settings-section-card,
|
||||||
.button-link {
|
.button-link {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.settings-nav a,
|
|
||||||
.settings-section-card {
|
.settings-section-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
@@ -366,14 +357,10 @@
|
|||||||
border-radius: var(--radius-panel);
|
border-radius: var(--radius-panel);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
}
|
}
|
||||||
.settings-nav a:hover,
|
|
||||||
.settings-nav a:focus-visible,
|
|
||||||
.settings-nav a.active,
|
|
||||||
.settings-section-card:hover,
|
.settings-section-card:hover,
|
||||||
.settings-section-card:focus-visible {
|
.settings-section-card:focus-visible {
|
||||||
background: var(--interactive-hover);
|
background: var(--interactive-hover);
|
||||||
}
|
}
|
||||||
.settings-nav span,
|
|
||||||
.settings-section-card h3 {
|
.settings-section-card h3 {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
|||||||
@@ -5,22 +5,21 @@
|
|||||||
import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context';
|
import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context';
|
||||||
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
|
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
|
||||||
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.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 '../app.css';
|
||||||
import type { LayoutProps } from './$types';
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
let { children }: LayoutProps = $props();
|
let { children }: LayoutProps = $props();
|
||||||
let sidebar = $state<SidebarSnippet | null>(null);
|
let sidebar = $state<SidebarSnippet | null>(null);
|
||||||
|
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
|
||||||
|
sidebar = activeSidebar;
|
||||||
|
});
|
||||||
const headerController = $state<HeaderController>({ content: null });
|
const headerController = $state<HeaderController>({ content: null });
|
||||||
|
|
||||||
provideHeaderController(headerController);
|
provideHeaderController(headerController);
|
||||||
setContext(SIDEBAR_CONTEXT, {
|
setContext<SidebarController>(SIDEBAR_CONTEXT, {
|
||||||
setSidebar(snippet: SidebarSnippet) {
|
registerSidebar: sidebarOverrides.register,
|
||||||
sidebar = snippet;
|
|
||||||
},
|
|
||||||
clearSidebar(snippet: SidebarSnippet) {
|
|
||||||
if (sidebar === snippet) sidebar = null;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { setContext, type Snippet } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
|
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
|
||||||
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
|
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
|
||||||
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.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 { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
||||||
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
||||||
import '$lib/workspace/styles/workspace-pages.css';
|
import '$lib/workspace/styles/workspace-pages.css';
|
||||||
@@ -11,6 +17,15 @@
|
|||||||
import type { LayoutProps } from './$types';
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
let { data, children }: LayoutProps = $props();
|
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(() => {
|
$effect(() => {
|
||||||
const workspaceId = data.workspace?.workspace_id;
|
const workspaceId = data.workspace?.workspace_id;
|
||||||
if (!workspaceId) return;
|
if (!workspaceId) return;
|
||||||
@@ -27,6 +42,7 @@
|
|||||||
workspace={data.workspace ?? null}
|
workspace={data.workspace ?? null}
|
||||||
workspaceError={data.workspaceError ?? null}
|
workspaceError={data.workspaceError ?? null}
|
||||||
currentPath={page.url.pathname}
|
currentPath={page.url.pathname}
|
||||||
|
content={sidebarContent}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,8 @@
|
|||||||
<small>Read typed Ticket records</small>
|
<small>Read typed Ticket records</small>
|
||||||
</a>
|
</a>
|
||||||
<a class="workspace-action-card" href={runtimeSettingsHref}>
|
<a class="workspace-action-card" href={runtimeSettingsHref}>
|
||||||
<span>Runtime Inventory</span>
|
<span>Runtimes</span>
|
||||||
<strong>Open admin runtime inventory</strong>
|
<strong>Open admin Runtimes</strong>
|
||||||
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
|
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
|
||||||
</a>
|
</a>
|
||||||
<a class="workspace-action-card" href={workersHref}>
|
<a class="workspace-action-card" href={workersHref}>
|
||||||
|
|||||||
@@ -1,47 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
import SettingsSidebarContent from '$lib/workspace/sidebar/SettingsSidebarContent.svelte';
|
||||||
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model';
|
import WorkspaceSidebarContentOverride from '$lib/workspace/sidebar/WorkspaceSidebarContentOverride.svelte';
|
||||||
import '$lib/workspace/styles/settings.css';
|
import '$lib/workspace/styles/settings.css';
|
||||||
import type { LayoutProps } from './$types';
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
let { data, children }: LayoutProps = $props();
|
let { 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}/`);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet settingsSidebarContent()}
|
||||||
|
<SettingsSidebarContent
|
||||||
|
workspaceId={page.params.workspaceId ?? ''}
|
||||||
|
currentPath={page.url.pathname}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<WorkspaceSidebarContentOverride content={settingsSidebarContent} />
|
||||||
|
|
||||||
<section class="settings-page">
|
<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()}
|
{@render children()}
|
||||||
</section>
|
</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">
|
<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';
|
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 { 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 {
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Runtime Inventory · Settings · Yoi Workspace</title>
|
<title>Runtimes · Settings · Yoi Workspace</title>
|
||||||
<meta name="description" content="Workspace Runtime inventory" />
|
<meta name="description" content="Workspace Runtime resources" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="runtimes-page" aria-labelledby="runtimes-heading">
|
<section class="runtimes-page" aria-labelledby="runtimes-heading">
|
||||||
<header class="page-header-row">
|
<header class="page-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1 id="runtimes-heading">Runtime Inventory</h1>
|
<h1 id="runtimes-heading">Runtimes</h1>
|
||||||
<p>Admin view of execution backends available to this workspace.</p>
|
<p>Register and inspect the execution backends available to this Workspace.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
|
||||||
|
{showAddRuntime ? 'Close' : 'Add Runtime'}
|
||||||
|
</button>
|
||||||
</header>
|
</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}
|
{#if data.runtimesError}
|
||||||
<p class="section-state error">{data.runtimesError}</p>
|
<p class="section-state error">{data.runtimesError}</p>
|
||||||
{:else if !data.runtimes}
|
{:else if !data.runtimes}
|
||||||
@@ -29,20 +163,22 @@
|
|||||||
{:else if data.runtimes.items.length === 0}
|
{:else if data.runtimes.items.length === 0}
|
||||||
<p class="section-state">No Runtimes are visible.</p>
|
<p class="section-state">No Runtimes are visible.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="table-wrap">
|
<div class="settings-runtime-table-wrap">
|
||||||
<table class="runtimes-table">
|
<table class="settings-runtime-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Runtime</th>
|
<th>Runtime</th>
|
||||||
<th>Kind</th>
|
<th>Kind</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Platform</th>
|
<th>Platform</th>
|
||||||
|
<th>Management</th>
|
||||||
<th>Workdirs</th>
|
<th>Workdirs</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.runtimes.items as runtime}
|
{#each data.runtimes.items as runtime}
|
||||||
<tr>
|
<tr class:inactive={runtime.status !== 'active'}>
|
||||||
<td>
|
<td>
|
||||||
<strong>{runtime.label}</strong>
|
<strong>{runtime.label}</strong>
|
||||||
<small><code>{runtime.runtime_id}</code></small>
|
<small><code>{runtime.runtime_id}</code></small>
|
||||||
@@ -50,12 +186,58 @@
|
|||||||
<td>{runtime.kind}</td>
|
<td>{runtime.kind}</td>
|
||||||
<td>{runtime.status}</td>
|
<td>{runtime.status}</td>
|
||||||
<td>{runtimePlatform(runtime)}</td>
|
<td>{runtimePlatform(runtime)}</td>
|
||||||
|
<td>{managementLabel(runtime)}</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
|
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
|
||||||
Open workdirs
|
Open workdirs
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</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>
|
</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}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+1
-1
@@ -107,7 +107,7 @@
|
|||||||
<section class="workdirs-page" aria-labelledby="workdirs-heading">
|
<section class="workdirs-page" aria-labelledby="workdirs-heading">
|
||||||
<header class="page-header-row">
|
<header class="page-header-row">
|
||||||
<div>
|
<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>
|
<h1 id="workdirs-heading">Workdirs</h1>
|
||||||
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
|
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
|
||||||
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
|
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user