web: show worker status in sidebar
This commit is contained in:
@@ -561,6 +561,8 @@ pub struct SubscriptionWorker {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub profile: Option<String>,
|
pub profile: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub repository_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub working_directory_id: Option<SubscriptionWorkdirId>,
|
pub working_directory_id: Option<SubscriptionWorkdirId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,6 +572,9 @@ impl SubscriptionWorker {
|
|||||||
if let Some(runtime_id) = &self.runtime_id {
|
if let Some(runtime_id) = &self.runtime_id {
|
||||||
validate_identifier("runtime_id", runtime_id, MAX_RESOURCE_ID_BYTES)?;
|
validate_identifier("runtime_id", runtime_id, MAX_RESOURCE_ID_BYTES)?;
|
||||||
}
|
}
|
||||||
|
if let Some(repository_id) = &self.repository_id {
|
||||||
|
validate_identifier("repository_id", repository_id, MAX_RESOURCE_ID_BYTES)?;
|
||||||
|
}
|
||||||
if let Some(working_directory_id) = &self.working_directory_id {
|
if let Some(working_directory_id) = &self.working_directory_id {
|
||||||
working_directory_id.validate()?;
|
working_directory_id.validate()?;
|
||||||
}
|
}
|
||||||
@@ -794,6 +799,7 @@ mod tests {
|
|||||||
workspace_id: Some("workspace-1".to_string()),
|
workspace_id: Some("workspace-1".to_string()),
|
||||||
display_name: Some(format!("Worker {value}")),
|
display_name: Some(format!("Worker {value}")),
|
||||||
profile: Some("builtin:coder".to_string()),
|
profile: Some("builtin:coder".to_string()),
|
||||||
|
repository_id: None,
|
||||||
working_directory_id: None,
|
working_directory_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1952,6 +1952,10 @@ impl RuntimeState {
|
|||||||
) -> Result<SubscriptionWorker, RuntimeError> {
|
) -> Result<SubscriptionWorker, RuntimeError> {
|
||||||
let worker_id = SubscriptionWorkerId::new(worker.worker_id.to_string())
|
let worker_id = SubscriptionWorkerId::new(worker.worker_id.to_string())
|
||||||
.map_err(subscription_validation_error)?;
|
.map_err(subscription_validation_error)?;
|
||||||
|
let repository_id = worker
|
||||||
|
.working_directory
|
||||||
|
.as_ref()
|
||||||
|
.map(|working_directory| working_directory.summary.repository_id.clone());
|
||||||
let working_directory_id = worker
|
let working_directory_id = worker
|
||||||
.working_directory
|
.working_directory
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1975,6 +1979,7 @@ impl RuntimeState {
|
|||||||
workspace_id: worker.workspace_id.clone(),
|
workspace_id: worker.workspace_id.clone(),
|
||||||
display_name: worker.request.display_name.clone(),
|
display_name: worker.request.display_name.clone(),
|
||||||
profile,
|
profile,
|
||||||
|
repository_id,
|
||||||
working_directory_id,
|
working_directory_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ runtime_id?: string | null,
|
|||||||
/**
|
/**
|
||||||
* Producer-owned monotonic revision for this Worker subject.
|
* Producer-owned monotonic revision for this Worker subject.
|
||||||
*/
|
*/
|
||||||
subject_revision: number, state: SubscriptionWorkerState, workspace_id?: string | null, display_name?: string | null, profile?: string | null, working_directory_id?: SubscriptionWorkdirId | null, };
|
subject_revision: number, state: SubscriptionWorkerState, workspace_id?: string | null, display_name?: string | null, profile?: string | null, repository_id?: string | null, working_directory_id?: SubscriptionWorkdirId | null, };
|
||||||
|
|
||||||
export type SubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_id: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, };
|
export type SubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_id: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, };
|
||||||
|
|
||||||
|
|||||||
@@ -125,9 +125,12 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
|||||||
workersNav.includes("href={`/w/${workspaceId}/workers`}") &&
|
workersNav.includes("href={`/w/${workspaceId}/workers`}") &&
|
||||||
workersNav.includes("filter(canShowWorkerInSidebar)") &&
|
workersNav.includes("filter(canShowWorkerInSidebar)") &&
|
||||||
workersNav.includes("worker.display_name || worker.label") &&
|
workersNav.includes("worker.display_name || worker.label") &&
|
||||||
workersNav.includes("worker {worker.worker_id}") &&
|
workersNav.includes("worker-status-dot") &&
|
||||||
|
workersNav.includes("worker-status-spinner") &&
|
||||||
|
workersNav.includes("worker.repository_id ?? '—'") &&
|
||||||
|
workersNav.includes("worker.working_directory_id ?? '—'") &&
|
||||||
!workersNav.includes('aria-disabled="true"'),
|
!workersNav.includes('aria-disabled="true"'),
|
||||||
"Workers sidebar should link to the Worker list page and omit registry-only Workers",
|
"Workers sidebar should link to the Worker list page and show state indicators with repository/workdir metadata",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!sidebar.includes("CompanionNavSection") &&
|
!sidebar.includes("CompanionNavSection") &&
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workerConsoleHref } from '$lib/workspace/console/model';
|
import { workerConsoleHref } from '$lib/workspace/console/model';
|
||||||
import { workspaceWorkersStore } from './worker-subscription';
|
import {
|
||||||
|
workspaceWorkersStore,
|
||||||
|
type SidebarWorker,
|
||||||
|
} from './worker-subscription';
|
||||||
import { canShowWorkerInSidebar } from './workers';
|
import { canShowWorkerInSidebar } from './workers';
|
||||||
import type { Worker } from './types';
|
|
||||||
|
|
||||||
const MAX_VISIBLE_WORKERS = 6;
|
const MAX_VISIBLE_WORKERS = 6;
|
||||||
|
|
||||||
@@ -14,14 +16,16 @@
|
|||||||
let { currentPath = '/', workspaceId }: Props = $props();
|
let { currentPath = '/', workspaceId }: Props = $props();
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let workers = $state<Worker[]>([]);
|
let workers = $state<SidebarWorker[]>([]);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const subscription = workspaceWorkersStore(workspaceId);
|
const subscription = workspaceWorkersStore(workspaceId);
|
||||||
return subscription.subscribe((state) => {
|
return subscription.subscribe((state) => {
|
||||||
loading = state.loading;
|
loading = state.loading;
|
||||||
error = state.error;
|
error = state.error;
|
||||||
workers = state.workers.filter(canShowWorkerInSidebar).slice(0, MAX_VISIBLE_WORKERS);
|
workers = state.workers
|
||||||
|
.filter(canShowWorkerInSidebar)
|
||||||
|
.slice(0, MAX_VISIBLE_WORKERS);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -59,15 +63,23 @@
|
|||||||
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
||||||
{@const href = workerConsoleHref(worker, workspaceId)}
|
{@const href = workerConsoleHref(worker, workspaceId)}
|
||||||
<li>
|
<li>
|
||||||
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
|
<a
|
||||||
<span class="worker-title-row">
|
href={href}
|
||||||
<span class="item-title">{worker.display_name || worker.label}</span>
|
class="worker-nav-link"
|
||||||
<span class="worker-task-title">-</span>
|
class:active={currentPath === href}
|
||||||
</span>
|
aria-current={currentPath === href ? 'page' : undefined}
|
||||||
<span class="item-meta">
|
>
|
||||||
worker {worker.worker_id} · {worker.profile ? `${worker.profile} · ` : ''}{worker.state} · 🖥 {worker.host_id}
|
<span class="worker-status-indicator">
|
||||||
{worker.working_directory?.current_ref ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.current_ref.slice(0, 8)}` : ''}
|
{#if worker.state === 'idle'}
|
||||||
|
<span class="worker-status-dot" aria-label="Idle"></span>
|
||||||
|
{:else if worker.state === 'running'}
|
||||||
|
<span class="worker-status-spinner" aria-label="Running"></span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="worker-nav-label">{worker.display_name || worker.label}</span>
|
||||||
|
<small class="worker-nav-meta">
|
||||||
|
{worker.repository_id ?? '—'}・{worker.working_directory_id ?? '—'}
|
||||||
|
</small>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -211,30 +211,82 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
.worker-nav-item {
|
.worker-nav-link {
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.worker-nav-item.disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.62;
|
|
||||||
}
|
|
||||||
.worker-nav-item.disabled .item-title {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.worker-title-row {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
|
grid-template-columns: 0.75rem minmax(0, 1fr);
|
||||||
align-items: baseline;
|
grid-template-rows: auto auto;
|
||||||
gap: var(--space-2);
|
column-gap: var(--space-2);
|
||||||
min-width: 0;
|
row-gap: 0.1rem;
|
||||||
}
|
margin: 0.0625rem 0;
|
||||||
.worker-task-title {
|
padding: var(--space-2);
|
||||||
overflow: hidden;
|
border-radius: var(--radius-soft);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.82rem;
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.worker-nav-link:hover {
|
||||||
|
background: var(--interactive-hover);
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
.worker-nav-link.active {
|
||||||
|
background: var(--interactive-selected);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.worker-status-indicator {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
display: grid;
|
||||||
|
width: 0.75rem;
|
||||||
|
min-height: 1.1rem;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.worker-status-dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
.worker-status-spinner {
|
||||||
|
width: 0.625rem;
|
||||||
|
height: 0.625rem;
|
||||||
|
border: 0.125rem solid color-mix(in oklch, var(--accent) 25%, transparent);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: worker-status-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
.worker-nav-label {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1rem;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.worker-nav-meta {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
line-height: 1rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
@keyframes worker-status-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.worker-status-spinner {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.sidebar-frame,
|
.sidebar-frame,
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import {
|
|||||||
applyWorkspaceWorkersFrame,
|
applyWorkspaceWorkersFrame,
|
||||||
createWorkspaceWorkersProjection,
|
createWorkspaceWorkersProjection,
|
||||||
} from './worker-subscription-model';
|
} from './worker-subscription-model';
|
||||||
|
import { compareWorkersForSidebar } from './workers';
|
||||||
import type { Worker } from './types';
|
import type { Worker } from './types';
|
||||||
|
|
||||||
|
export type SidebarWorker = Worker & {
|
||||||
|
repository_id: string | null;
|
||||||
|
working_directory_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceWorkersState = {
|
export type WorkspaceWorkersState = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
workers: Worker[];
|
workers: SidebarWorker[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const stores = new Map<string, Readable<WorkspaceWorkersState>>();
|
const stores = new Map<string, Readable<WorkspaceWorkersState>>();
|
||||||
@@ -29,10 +35,7 @@ export function workspaceWorkersStore(workspaceId: string): Readable<WorkspaceWo
|
|||||||
const publish = (loading = false, error: string | null = null) => {
|
const publish = (loading = false, error: string | null = null) => {
|
||||||
const workers = [...projection.workers.values()]
|
const workers = [...projection.workers.values()]
|
||||||
.map(projectWorker)
|
.map(projectWorker)
|
||||||
.sort((left, right) =>
|
.sort(compareWorkersForSidebar);
|
||||||
left.runtime_id.localeCompare(right.runtime_id) ||
|
|
||||||
left.worker_id.localeCompare(right.worker_id)
|
|
||||||
);
|
|
||||||
set({ loading, error, workers });
|
set({ loading, error, workers });
|
||||||
};
|
};
|
||||||
const subscription = workspaceMultiplexer(workspaceId).subscribe(
|
const subscription = workspaceMultiplexer(workspaceId).subscribe(
|
||||||
@@ -68,7 +71,7 @@ export function workspaceWorkersStore(workspaceId: string): Readable<WorkspaceWo
|
|||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectWorker(worker: SubscriptionWorker): Worker {
|
function projectWorker(worker: SubscriptionWorker): SidebarWorker {
|
||||||
if (!worker.runtime_id) throw new Error('Workspace Worker projection is missing runtime_id');
|
if (!worker.runtime_id) throw new Error('Workspace Worker projection is missing runtime_id');
|
||||||
const displayName = worker.display_name ?? `Worker ${worker.worker_id}`;
|
const displayName = worker.display_name ?? `Worker ${worker.worker_id}`;
|
||||||
return {
|
return {
|
||||||
@@ -91,6 +94,8 @@ function projectWorker(worker: SubscriptionWorker): Worker {
|
|||||||
can_stop: worker.state !== 'stopped' && worker.state !== 'cancelled',
|
can_stop: worker.state !== 'stopped' && worker.state !== 'cancelled',
|
||||||
can_spawn_followup: false,
|
can_spawn_followup: false,
|
||||||
},
|
},
|
||||||
|
repository_id: worker.repository_id ?? null,
|
||||||
|
working_directory_id: worker.working_directory_id ?? null,
|
||||||
working_directory: null,
|
working_directory: null,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { canOpenWorkerConsole, canShowWorkerInSidebar } from "./workers.ts";
|
import {
|
||||||
|
canOpenWorkerConsole,
|
||||||
|
canShowWorkerInSidebar,
|
||||||
|
compareWorkersForSidebar,
|
||||||
|
} from "./workers.ts";
|
||||||
import type { Worker } from "./types.ts";
|
import type { Worker } from "./types.ts";
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
@@ -61,3 +65,14 @@ Deno.test("live runtime workers are sidebar targets and console targets", () =>
|
|||||||
assertEquals(canShowWorkerInSidebar(liveWorker), true);
|
assertEquals(canShowWorkerInSidebar(liveWorker), true);
|
||||||
assertEquals(canOpenWorkerConsole(liveWorker), true);
|
assertEquals(canOpenWorkerConsole(liveWorker), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("sidebar workers sort idle then running then stopped", () => {
|
||||||
|
const workers = [
|
||||||
|
worker({ worker_id: "3", display_name: "Stopped", state: "stopped" }),
|
||||||
|
worker({ worker_id: "2", display_name: "Running", state: "running" }),
|
||||||
|
worker({ worker_id: "4", display_name: "Idle B", state: "idle" }),
|
||||||
|
worker({ worker_id: "1", display_name: "Idle A", state: "idle" }),
|
||||||
|
];
|
||||||
|
workers.sort(compareWorkersForSidebar);
|
||||||
|
assertEquals(workers.map((candidate) => candidate.worker_id).join(","), "1,4,2,3");
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,39 @@
|
|||||||
import type { Worker } from "./types";
|
import type { Worker } from './types';
|
||||||
|
|
||||||
export function canShowWorkerInSidebar(worker: Worker): boolean {
|
export function canShowWorkerInSidebar(worker: Worker): boolean {
|
||||||
return worker.implementation.kind !== "backend_worker_registry";
|
return worker.implementation.kind !== 'backend_worker_registry';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canOpenWorkerConsole(worker: Worker): boolean {
|
export function canOpenWorkerConsole(worker: Worker): boolean {
|
||||||
return canShowWorkerInSidebar(worker);
|
return canShowWorkerInSidebar(worker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SortableWorker = Pick<
|
||||||
|
Worker,
|
||||||
|
'state' | 'display_name' | 'runtime_id' | 'worker_id'
|
||||||
|
>;
|
||||||
|
|
||||||
|
function workerStateRank(state: Worker['state']): number {
|
||||||
|
switch (state) {
|
||||||
|
case 'idle':
|
||||||
|
return 0;
|
||||||
|
case 'running':
|
||||||
|
return 1;
|
||||||
|
case 'stopped':
|
||||||
|
return 2;
|
||||||
|
default:
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareWorkersForSidebar(
|
||||||
|
left: SortableWorker,
|
||||||
|
right: SortableWorker,
|
||||||
|
): number {
|
||||||
|
const stateOrder = workerStateRank(left.state) - workerStateRank(right.state);
|
||||||
|
if (stateOrder !== 0) return stateOrder;
|
||||||
|
return (left.display_name ?? left.worker_id).localeCompare(
|
||||||
|
right.display_name ?? right.worker_id,
|
||||||
|
) || left.runtime_id.localeCompare(right.runtime_id) ||
|
||||||
|
left.worker_id.localeCompare(right.worker_id);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user