merge: workspace sidebar navigation
This commit is contained in:
commit
613f412659
|
|
@ -0,0 +1,157 @@
|
|||
<script lang="ts">
|
||||
import type { ObjectiveListResponse, ObjectiveSummary } from './types';
|
||||
|
||||
const MAX_VISIBLE_OBJECTIVES = 6;
|
||||
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let objectives = $state<ObjectiveSummary[]>([]);
|
||||
let invalidRecordCount = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadObjectives(controller.signal);
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
async function loadObjectives(signal: AbortSignal) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const response = await fetch('/api/objectives', { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`objectives request failed (${response.status})`);
|
||||
}
|
||||
const payload = (await response.json()) as ObjectiveListResponse;
|
||||
objectives = Array.isArray(payload.items) ? payload.items.slice(0, MAX_VISIBLE_OBJECTIVES) : [];
|
||||
invalidRecordCount = Array.isArray(payload.invalid_records) ? payload.invalid_records.length : 0;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
error = err instanceof Error ? err.message : 'objectives request failed';
|
||||
objectives = [];
|
||||
invalidRecordCount = 0;
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="nav-section" aria-labelledby="objectives-heading">
|
||||
<div class="section-heading-row">
|
||||
<h2 id="objectives-heading">objectives</h2>
|
||||
{#if !loading && !error}
|
||||
<span class="section-count">{objectives.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="section-state">Loading objectives…</p>
|
||||
{:else if error}
|
||||
<p class="section-state error">{error}</p>
|
||||
{:else if objectives.length === 0}
|
||||
<p class="section-state">No objectives found.</p>
|
||||
{:else}
|
||||
<ul class="nav-list" aria-label="Objectives">
|
||||
{#each objectives as objective (objective.id)}
|
||||
<li class="nav-item">
|
||||
<span class="item-title">{objective.title}</span>
|
||||
<span class="item-meta">{objective.state}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if invalidRecordCount > 0}
|
||||
<p class="section-note">{invalidRecordCount} invalid objective record(s) hidden.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.nav-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-heading-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 999px;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 14px;
|
||||
background: rgba(15, 23, 42, 0.64);
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-title,
|
||||
.item-meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
color: #e2e8f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.item-meta,
|
||||
.section-state,
|
||||
.section-note {
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.section-state,
|
||||
.section-note {
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.section-state {
|
||||
border: 1px dashed rgba(148, 163, 184, 0.2);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.section-state.error {
|
||||
border-color: rgba(248, 113, 113, 0.36);
|
||||
color: #fecaca;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<script lang="ts">
|
||||
import type { WorkspaceResponse } from './types';
|
||||
|
||||
type Props = {
|
||||
workspace: WorkspaceResponse | null;
|
||||
};
|
||||
|
||||
let { workspace }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="nav-section" aria-labelledby="repositories-heading">
|
||||
<div class="section-heading-row">
|
||||
<h2 id="repositories-heading">repositories</h2>
|
||||
<span class="section-count">1</span>
|
||||
</div>
|
||||
|
||||
<ul class="nav-list" aria-label="Repositories">
|
||||
<li class="nav-item active">
|
||||
<span class="item-title">{workspace?.display_name ?? 'local workspace'}</span>
|
||||
<span class="item-meta">local project records</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="section-note">
|
||||
Repository API is not wired yet; this placeholder keeps the navigation seam
|
||||
ready without adding repository authority.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.nav-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-heading-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 999px;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 14px;
|
||||
background: rgba(15, 23, 42, 0.64);
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
border-color: rgba(56, 189, 248, 0.36);
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
.item-title,
|
||||
.item-meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
color: #e2e8f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.item-meta,
|
||||
.section-note {
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.section-note {
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
157
web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte
Normal file
157
web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<script lang="ts">
|
||||
import type { ListResponse, Worker } from './types';
|
||||
|
||||
const MAX_VISIBLE_WORKERS = 6;
|
||||
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let workers = $state<Worker[]>([]);
|
||||
let placeholder = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadWorkers(controller.signal);
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
async function loadWorkers(signal: AbortSignal) {
|
||||
loading = true;
|
||||
error = null;
|
||||
placeholder = null;
|
||||
try {
|
||||
const response = await fetch('/api/workers', { signal });
|
||||
if (response.status === 404) {
|
||||
workers = [];
|
||||
placeholder = 'Worker API is not integrated in this build yet.';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`workers request failed (${response.status})`);
|
||||
}
|
||||
const payload = (await response.json()) as ListResponse<Worker>;
|
||||
workers = Array.isArray(payload.items) ? payload.items.slice(0, MAX_VISIBLE_WORKERS) : [];
|
||||
if (workers.length === 0) {
|
||||
placeholder = 'No workers reported by the current API.';
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
error = err instanceof Error ? err.message : 'workers request failed';
|
||||
workers = [];
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="nav-section" aria-labelledby="workers-heading">
|
||||
<div class="section-heading-row">
|
||||
<h2 id="workers-heading">workers</h2>
|
||||
{#if !loading && !error && workers.length > 0}
|
||||
<span class="section-count">{workers.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="section-state">Checking workers…</p>
|
||||
{:else if error}
|
||||
<p class="section-state error">{error}</p>
|
||||
{:else if workers.length === 0}
|
||||
<p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p>
|
||||
{:else}
|
||||
<ul class="nav-list" aria-label="Workers">
|
||||
{#each workers as worker (worker.worker_id)}
|
||||
<li class="nav-item">
|
||||
<span class="item-title">{worker.label}</span>
|
||||
<span class="item-meta">
|
||||
{worker.state} · {worker.status}{worker.role ? ` · ${worker.role}` : ''}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.nav-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-heading-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 999px;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 14px;
|
||||
background: rgba(15, 23, 42, 0.64);
|
||||
padding: 10px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-title,
|
||||
.item-meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
color: #e2e8f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.item-meta,
|
||||
.section-state {
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.section-state {
|
||||
margin: 0;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.2);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.section-state.error {
|
||||
border-color: rgba(248, 113, 113, 0.36);
|
||||
color: #fecaca;
|
||||
}
|
||||
</style>
|
||||
124
web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte
Normal file
124
web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<script lang="ts">
|
||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||
import type { WorkspaceResponse } from './types';
|
||||
|
||||
type Props = {
|
||||
workspace: WorkspaceResponse | null;
|
||||
workspaceError?: string | null;
|
||||
};
|
||||
|
||||
let { workspace, workspaceError = null }: Props = $props();
|
||||
</script>
|
||||
|
||||
<aside class="workspace-sidebar" aria-label="Workspace navigation">
|
||||
<header class="sidebar-header">
|
||||
<div class="workspace-label">
|
||||
<span class="eyebrow">workspace</span>
|
||||
<h1>{workspace?.display_name ?? 'Yoi workspace'}</h1>
|
||||
{#if workspaceError}
|
||||
<p class="workspace-status error">Workspace summary unavailable.</p>
|
||||
{:else if workspace}
|
||||
<p class="workspace-status">{workspace.workspace_id}</p>
|
||||
{:else}
|
||||
<p class="workspace-status">Loading workspace…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="settings-button"
|
||||
type="button"
|
||||
aria-label="Workspace settings"
|
||||
title="Workspace settings placeholder"
|
||||
disabled
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||
<RepositoriesNavSection {workspace} />
|
||||
<ObjectivesNavSection />
|
||||
<WorkersNavSection />
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.workspace-sidebar {
|
||||
align-self: stretch;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 26px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(15, 23, 42, 0.82)),
|
||||
rgba(15, 23, 42, 0.88);
|
||||
box-shadow: 0 24px 80px rgba(2, 6, 23, 0.28);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #38bdf8;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
.workspace-status {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.workspace-status {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.workspace-status.error {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
color: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sidebar-sections {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
82
web/workspace/src/lib/workspace-sidebar/types.ts
Normal file
82
web/workspace/src/lib/workspace-sidebar/types.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
export type ExtensionPoint = {
|
||||
status: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type WorkspaceResponse = {
|
||||
workspace_id: string;
|
||||
display_name: string;
|
||||
record_authority: string;
|
||||
extension_points: {
|
||||
event_stream: ExtensionPoint;
|
||||
host_worker_bridge: ExtensionPoint;
|
||||
};
|
||||
};
|
||||
|
||||
export type Diagnostic = {
|
||||
code: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type Host = {
|
||||
host_id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
observed_at: string;
|
||||
last_seen_at: string;
|
||||
capabilities: {
|
||||
local_pod_inspection: string;
|
||||
workspace_root: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
max_workers: number;
|
||||
};
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type Worker = {
|
||||
worker_id: string;
|
||||
host_id: string;
|
||||
label: string;
|
||||
pod_name: string;
|
||||
role?: string;
|
||||
profile?: string;
|
||||
workspace_root?: string;
|
||||
state: string;
|
||||
status: string;
|
||||
last_seen_at?: string;
|
||||
implementation: { kind: string; pod_name: string };
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type ListResponse<T> = {
|
||||
workspace_id: string;
|
||||
limit: number;
|
||||
items: T[];
|
||||
source: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
export type ObjectiveSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
state: string;
|
||||
updated_at?: string | null;
|
||||
linked_tickets?: string[];
|
||||
record_source?: string;
|
||||
};
|
||||
|
||||
export type InvalidProjectRecord = {
|
||||
label: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ObjectiveListResponse = {
|
||||
workspace_id: string;
|
||||
limit: number;
|
||||
items: ObjectiveSummary[];
|
||||
invalid_records: InvalidProjectRecord[];
|
||||
record_authority: string;
|
||||
};
|
||||
|
|
@ -1,59 +1,6 @@
|
|||
<script lang="ts">
|
||||
type WorkspaceResponse = {
|
||||
workspace_id: string;
|
||||
display_name: string;
|
||||
record_authority: string;
|
||||
extension_points: {
|
||||
event_stream: { status: string; note: string };
|
||||
host_worker_bridge: { status: string; note: string };
|
||||
};
|
||||
};
|
||||
|
||||
type Diagnostic = {
|
||||
code: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type Host = {
|
||||
host_id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
observed_at: string;
|
||||
last_seen_at: string;
|
||||
capabilities: {
|
||||
local_pod_inspection: string;
|
||||
workspace_root: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
max_workers: number;
|
||||
};
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
type Worker = {
|
||||
worker_id: string;
|
||||
host_id: string;
|
||||
label: string;
|
||||
pod_name: string;
|
||||
role?: string;
|
||||
profile?: string;
|
||||
workspace_root?: string;
|
||||
state: string;
|
||||
status: string;
|
||||
last_seen_at?: string;
|
||||
implementation: { kind: string; pod_name: string };
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
type ListResponse<T> = {
|
||||
workspace_id: string;
|
||||
limit: number;
|
||||
items: T[];
|
||||
source: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
||||
import type { Diagnostic, Host, ListResponse, Worker, WorkspaceResponse } from '$lib/workspace-sidebar/types';
|
||||
|
||||
const endpoints = [
|
||||
{ label: 'Workspace', path: '/api/workspace' },
|
||||
|
|
@ -67,7 +14,9 @@
|
|||
let workspace = $state<WorkspaceResponse | null>(null);
|
||||
let hosts = $state<ListResponse<Host> | null>(null);
|
||||
let workers = $state<ListResponse<Worker> | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
let workspaceError = $state<string | null>(null);
|
||||
let hostsError = $state<string | null>(null);
|
||||
let workersError = $state<string | null>(null);
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(path);
|
||||
|
|
@ -78,18 +27,32 @@
|
|||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
workspaceError = null;
|
||||
try {
|
||||
const [workspaceResponse, hostResponse, workerResponse] = await Promise.all([
|
||||
getJson<WorkspaceResponse>('/api/workspace'),
|
||||
getJson<ListResponse<Host>>('/api/hosts'),
|
||||
getJson<ListResponse<Worker>>('/api/workers')
|
||||
]);
|
||||
workspace = workspaceResponse;
|
||||
hosts = hostResponse;
|
||||
workers = workerResponse;
|
||||
loadError = null;
|
||||
workspace = await getJson<WorkspaceResponse>('/api/workspace');
|
||||
} catch (error) {
|
||||
loadError = error instanceof Error ? error.message : String(error);
|
||||
workspaceError = error instanceof Error ? error.message : String(error);
|
||||
workspace = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHosts() {
|
||||
hostsError = null;
|
||||
try {
|
||||
hosts = await getJson<ListResponse<Host>>('/api/hosts');
|
||||
} catch (error) {
|
||||
hostsError = error instanceof Error ? error.message : String(error);
|
||||
hosts = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkers() {
|
||||
workersError = null;
|
||||
try {
|
||||
workers = await getJson<ListResponse<Worker>>('/api/workers');
|
||||
} catch (error) {
|
||||
workersError = error instanceof Error ? error.message : String(error);
|
||||
workers = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,6 +62,8 @@
|
|||
|
||||
$effect(() => {
|
||||
void loadWorkspace();
|
||||
void loadHosts();
|
||||
void loadWorkers();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -110,174 +75,182 @@
|
|||
/>
|
||||
</svelte:head>
|
||||
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Local / single-workspace bootstrap</p>
|
||||
<h1>Yoi Workspace Control Plane</h1>
|
||||
<p>
|
||||
Static SPA shell for reading canonical <code>.yoi</code> project records
|
||||
and the local Host / Worker execution view through bounded backend APIs.
|
||||
Ticket and Objective lifecycle authority stays in the existing local record
|
||||
workflow.
|
||||
</p>
|
||||
</section>
|
||||
<div class="workspace-layout">
|
||||
<WorkspaceSidebar {workspace} {workspaceError} />
|
||||
|
||||
<section class="card">
|
||||
<h2>Workspace</h2>
|
||||
{#if workspace}
|
||||
<dl>
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd>{workspace.workspace_id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
<dd>{workspace.display_name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Record authority</dt>
|
||||
<dd>{workspace.record_authority}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Host / Worker bridge</dt>
|
||||
<dd>{workspace.extension_points.host_worker_bridge.status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{:else if loadError}
|
||||
<p class="error">{loadError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/workspace</code>…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="card">
|
||||
<h2>Read API surface</h2>
|
||||
<ul>
|
||||
{#each endpoints as endpoint}
|
||||
<li><code>{endpoint.path}</code> — {endpoint.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Reserved seams</h2>
|
||||
<main class="shell">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Local / single-workspace bootstrap</p>
|
||||
<h1>Yoi Workspace Control Plane</h1>
|
||||
<p>
|
||||
Event streams remain represented as extension-point state in the backend
|
||||
response. Hosts and Workers are read-only local observations; no
|
||||
scheduler, lifecycle control, or hosted multi-tenant behavior is
|
||||
implemented in this slice.
|
||||
Static SPA shell for reading canonical <code>.yoi</code> project records
|
||||
and the local Host / Worker execution view through bounded backend APIs.
|
||||
Ticket and Objective lifecycle authority stays in the existing local record
|
||||
workflow.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="grid runtime">
|
||||
<div class="card">
|
||||
<h2>Hosts</h2>
|
||||
{#if hosts}
|
||||
{#if hosts.items.length === 0}
|
||||
<p>No local Hosts are visible.</p>
|
||||
{:else}
|
||||
<div class="stack">
|
||||
{#each hosts.items as host}
|
||||
<article class="runtime-card">
|
||||
<div class="runtime-heading">
|
||||
<strong>{host.label}</strong>
|
||||
<span class:warn={host.status !== 'available'}>{host.status}</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd><code>{host.host_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Kind</dt>
|
||||
<dd>{host.kind}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Local Pod inspection</dt>
|
||||
<dd>{host.capabilities.local_pod_inspection}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Platform</dt>
|
||||
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
{/each}
|
||||
<section class="card">
|
||||
<h2>Workspace</h2>
|
||||
{#if workspace}
|
||||
<dl>
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd>{workspace.workspace_id}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if loadError}
|
||||
<p class="error">{loadError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/hosts</code>…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Workers</h2>
|
||||
{#if workers}
|
||||
{#if workers.items.length === 0}
|
||||
<p>No local Workers are visible.</p>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th>Workspace</th>
|
||||
<th>Implementation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each workers.items as worker}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{worker.label}</strong>
|
||||
{#if worker.role || worker.profile}
|
||||
<small>{worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'}</small>
|
||||
{/if}
|
||||
</td>
|
||||
<td><code>{worker.host_id}</code></td>
|
||||
<td>{worker.state} · {worker.status}</td>
|
||||
<td>{worker.workspace_root ?? 'unknown'}</td>
|
||||
<td>{worker.implementation.kind}: {worker.implementation.pod_name}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
<dd>{workspace.display_name}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if loadError}
|
||||
<p class="error">{loadError}</p>
|
||||
<div>
|
||||
<dt>Record authority</dt>
|
||||
<dd>{workspace.record_authority}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Host / Worker bridge</dt>
|
||||
<dd>{workspace.extension_points.host_worker_bridge.status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{:else if workspaceError}
|
||||
<p class="error">{workspaceError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/workers</code>…</p>
|
||||
<p>Waiting for <code>/api/workspace</code>…</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{#if hosts || workers}
|
||||
{@const diagnostics = diagnosticsFor(hosts?.diagnostics, workers?.diagnostics)}
|
||||
{#if diagnostics.length > 0}
|
||||
<section class="card diagnostics">
|
||||
<h2>Diagnostics</h2>
|
||||
<section class="grid">
|
||||
<div class="card">
|
||||
<h2>Read API surface</h2>
|
||||
<ul>
|
||||
{#each diagnostics as diagnostic}
|
||||
<li>
|
||||
<strong>{diagnostic.severity}</strong>
|
||||
<code>{diagnostic.code}</code>
|
||||
<span>{diagnostic.message}</span>
|
||||
</li>
|
||||
{#each endpoints as endpoint}
|
||||
<li><code>{endpoint.path}</code> — {endpoint.label}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Reserved seams</h2>
|
||||
<p>
|
||||
Event streams remain represented as extension-point state in the backend
|
||||
response. Hosts and Workers are read-only local observations; no
|
||||
scheduler, lifecycle control, or hosted multi-tenant behavior is
|
||||
implemented in this slice.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid runtime">
|
||||
<div class="card">
|
||||
<h2>Hosts</h2>
|
||||
{#if hosts}
|
||||
{#if hosts.items.length === 0}
|
||||
<p>No local Hosts are visible.</p>
|
||||
{:else}
|
||||
<div class="stack">
|
||||
{#each hosts.items as host}
|
||||
<article class="runtime-card">
|
||||
<div class="runtime-heading">
|
||||
<strong>{host.label}</strong>
|
||||
<span class:warn={host.status !== 'available'}>{host.status}</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd><code>{host.host_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Kind</dt>
|
||||
<dd>{host.kind}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Local inspection</dt>
|
||||
<dd>{host.capabilities.local_pod_inspection}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Platform</dt>
|
||||
<dd>{host.capabilities.os} / {host.capabilities.arch}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if hostsError}
|
||||
<p class="error">{hostsError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/hosts</code>…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Workers</h2>
|
||||
{#if workers}
|
||||
{#if workers.items.length === 0}
|
||||
<p>No local Workers are visible.</p>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th>Workspace</th>
|
||||
<th>Implementation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each workers.items as worker}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{worker.label}</strong>
|
||||
{#if worker.role || worker.profile}
|
||||
<small>{worker.role ?? 'role unknown'} / {worker.profile ?? 'profile unknown'}</small>
|
||||
{/if}
|
||||
</td>
|
||||
<td><code>{worker.host_id}</code></td>
|
||||
<td>{worker.state} · {worker.status}</td>
|
||||
<td>{worker.workspace_root ?? 'unknown'}</td>
|
||||
<td>{worker.implementation.kind}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if workersError}
|
||||
<p class="error">{workersError}</p>
|
||||
{:else}
|
||||
<p>Waiting for <code>/api/workers</code>…</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if hosts || workers}
|
||||
{@const diagnostics = diagnosticsFor(hosts?.diagnostics, workers?.diagnostics)}
|
||||
{#if diagnostics.length > 0}
|
||||
<section class="card diagnostics">
|
||||
<h2>Diagnostics</h2>
|
||||
<ul>
|
||||
{#each diagnostics as diagnostic}
|
||||
<li>
|
||||
<strong>{diagnostic.severity}</strong>
|
||||
<code>{diagnostic.code}</code>
|
||||
<span>{diagnostic.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
background: #0f172a;
|
||||
|
|
@ -286,14 +259,28 @@
|
|||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1120px, calc(100vw - 32px));
|
||||
.workspace-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
width: min(1240px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 48px 0;
|
||||
padding: 32px 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-bottom: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
|
|
@ -307,12 +294,19 @@
|
|||
margin: 0 0 16px;
|
||||
font-size: clamp(2.5rem, 8vw, 5rem);
|
||||
line-height: 0.95;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
p,
|
||||
li,
|
||||
dd {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
code {
|
||||
color: #bae6fd;
|
||||
}
|
||||
|
|
@ -320,12 +314,12 @@
|
|||
.grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
margin-top: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runtime {
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
|
|
@ -334,6 +328,7 @@
|
|||
background: rgba(15, 23, 42, 0.75);
|
||||
padding: 24px;
|
||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stack {
|
||||
|
|
@ -421,4 +416,17 @@
|
|||
.error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.workspace-layout {
|
||||
grid-template-columns: 1fr;
|
||||
width: min(100vw - 24px, 620px);
|
||||
gap: 18px;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user