ui: refine workspace web layout
This commit is contained in:
parent
5f06af81cb
commit
1d626cdea6
78
docs/design/workspace-web-design-system.md
Normal file
78
docs/design/workspace-web-design-system.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Workspace web design system
|
||||||
|
|
||||||
|
This document defines the visual rules for `web/workspace`. The current authority for tokens and reusable page/sidebar styling is `web/workspace/src/app.css`.
|
||||||
|
|
||||||
|
## Design position
|
||||||
|
|
||||||
|
Workspace web should read as a control surface, not a set of detached widgets. Group information primarily through spacing, typography, and text contrast. Use borders, rounded rectangles, shadows, and filled panels only when they clarify hierarchy that spacing cannot express.
|
||||||
|
|
||||||
|
## Palette
|
||||||
|
|
||||||
|
Colors are defined as CSS custom properties in OKLCH. The palette supports light and dark modes through `prefers-color-scheme`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Background and layout surfaces use zero chroma: `oklch(... 0 0)`.
|
||||||
|
- Primary text and code text are near-neutral warm colors. Because CSS OKLCH exposes chroma (`C`) rather than saturation directly, encode the “about 5% saturation” intent as very low warm chroma, around `C = 0.01` to `0.012`.
|
||||||
|
- Muted text reduces lightness/chroma before introducing new hues.
|
||||||
|
- Accent/status colors are semantic exceptions. They should mark state, focus, or navigation, not decorate containers.
|
||||||
|
- Do not introduce raw hex/rgb colors in Workspace web components. Add or reuse a token in `app.css`.
|
||||||
|
|
||||||
|
Core tokens:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--bg
|
||||||
|
--bg-raised
|
||||||
|
--bg-subtle
|
||||||
|
--line
|
||||||
|
--line-strong
|
||||||
|
--text
|
||||||
|
--text-strong
|
||||||
|
--text-muted
|
||||||
|
--text-faint
|
||||||
|
--code
|
||||||
|
--accent
|
||||||
|
--success
|
||||||
|
--warning
|
||||||
|
--danger
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout and grouping
|
||||||
|
|
||||||
|
Prefer vertical rhythm and text hierarchy over card chrome.
|
||||||
|
|
||||||
|
- Page sections are separated by whitespace and a light top rule.
|
||||||
|
- Navigation selection uses a left rule rather than filled pills.
|
||||||
|
- Nested records use indentation or top rules, not repeated rounded containers.
|
||||||
|
- Shadows are avoided in the base system.
|
||||||
|
- Rounded corners are reserved for small controls where hit area shape matters.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- Headings and primary labels use `--text-strong`.
|
||||||
|
- Body text uses `--text`.
|
||||||
|
- Metadata, helper text, timestamps, and table headings use `--text-muted` or `--text-faint`.
|
||||||
|
- Uppercase labels are acceptable for small metadata labels only; avoid large all-caps UI blocks.
|
||||||
|
|
||||||
|
## Component styling boundary
|
||||||
|
|
||||||
|
`app.css` owns the shared visual language:
|
||||||
|
|
||||||
|
- reset/base body styles
|
||||||
|
- OKLCH tokens
|
||||||
|
- layout primitives
|
||||||
|
- page cards/sections
|
||||||
|
- sidebar/navigation sections
|
||||||
|
- tables, kanban lists, diagnostics, record bodies
|
||||||
|
|
||||||
|
Svelte components should keep local styles only when a behavior is truly component-specific. If a style affects color, spacing, borders, text hierarchy, or repeated record layout, it belongs in `app.css`.
|
||||||
|
|
||||||
|
## Adding new UI
|
||||||
|
|
||||||
|
When adding Workspace web UI:
|
||||||
|
|
||||||
|
1. Start with semantic HTML and existing classes from `app.css`.
|
||||||
|
2. Use spacing and text contrast first.
|
||||||
|
3. Use a border only when the boundary carries meaning.
|
||||||
|
4. Use background fills only for page-level surfaces or read-only code/record bodies.
|
||||||
|
5. If a new color is needed, define it as an OKLCH token and document why the existing semantic tokens are insufficient.
|
||||||
473
web/workspace/src/app.css
Normal file
473
web/workspace/src/app.css
Normal file
|
|
@ -0,0 +1,473 @@
|
||||||
|
@layer reset, tokens, base, layout, components;
|
||||||
|
|
||||||
|
@layer reset {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer tokens {
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
/* Palette rule: layout/background neutrals use zero chroma. */
|
||||||
|
--bg: oklch(98.5% 0 0);
|
||||||
|
--bg-raised: oklch(96% 0 0);
|
||||||
|
--bg-subtle: oklch(93% 0 0);
|
||||||
|
--line: oklch(82% 0 0);
|
||||||
|
--line-strong: oklch(70% 0 0);
|
||||||
|
|
||||||
|
/* Text is near-neutral warm: OKLCH C is kept around 0.01, roughly a 5% saturation intent. */
|
||||||
|
--text: oklch(30% 0.012 75);
|
||||||
|
--text-strong: oklch(22% 0.012 75);
|
||||||
|
--text-muted: oklch(50% 0.01 75);
|
||||||
|
--text-faint: oklch(62% 0.008 75);
|
||||||
|
--code: oklch(34% 0.012 75);
|
||||||
|
|
||||||
|
--accent: oklch(54% 0.13 230);
|
||||||
|
--accent-muted: oklch(54% 0.08 230);
|
||||||
|
--success: oklch(48% 0.11 145);
|
||||||
|
--warning: oklch(62% 0.12 85);
|
||||||
|
--danger: oklch(54% 0.14 25);
|
||||||
|
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 24px;
|
||||||
|
--space-6: 32px;
|
||||||
|
|
||||||
|
--radius-soft: 8px;
|
||||||
|
--radius-panel: 12px;
|
||||||
|
--font-sans:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
|
"Segoe UI", sans-serif;
|
||||||
|
--font-mono:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||||
|
"Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: oklch(16% 0 0);
|
||||||
|
--bg-raised: oklch(21% 0 0);
|
||||||
|
--bg-subtle: oklch(26% 0 0);
|
||||||
|
--line: oklch(36% 0 0);
|
||||||
|
--line-strong: oklch(48% 0 0);
|
||||||
|
|
||||||
|
--text: oklch(87% 0.012 75);
|
||||||
|
--text-strong: oklch(95% 0.01 75);
|
||||||
|
--text-muted: oklch(70% 0.01 75);
|
||||||
|
--text-faint: oklch(58% 0.008 75);
|
||||||
|
--code: oklch(84% 0.012 75);
|
||||||
|
|
||||||
|
--accent: oklch(76% 0.12 230);
|
||||||
|
--accent-muted: oklch(71% 0.08 230);
|
||||||
|
--success: oklch(77% 0.12 145);
|
||||||
|
--warning: oklch(82% 0.13 85);
|
||||||
|
--danger: oklch(76% 0.14 25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p,
|
||||||
|
li,
|
||||||
|
dd {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
color: var(--text-strong);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
pre {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: var(--code);
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer layout {
|
||||||
|
.workspace-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-6);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.workspace-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
width: 100vw;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100dvh;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
overflow: visible;
|
||||||
|
padding: var(--space-5) var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.workspace-sidebar {
|
||||||
|
align-self: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-6) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-label {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-sidebar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-status {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-status.error,
|
||||||
|
.section-state.error,
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-soft);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sections {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading-row,
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading-row h2,
|
||||||
|
.section-header,
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.11em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading-row h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--accent-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-count {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item,
|
||||||
|
.objective-link {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-2) 0 var(--space-2) var(--space-3);
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active,
|
||||||
|
.objective-link.active,
|
||||||
|
.nav-item:hover,
|
||||||
|
.objective-link:hover {
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title,
|
||||||
|
.item-meta {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta,
|
||||||
|
.section-note,
|
||||||
|
.section-state,
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-note,
|
||||||
|
.section-state {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-5) 0 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-card,
|
||||||
|
.kanban-column {
|
||||||
|
padding: var(--space-4) 0 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-card.selected {
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-heading,
|
||||||
|
.detail-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-heading span {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-heading span.warn {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-heading span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 10px 8px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr));
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column h3 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column ul {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column li {
|
||||||
|
padding-left: var(--space-3);
|
||||||
|
border-left: 2px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics li {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-body {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: var(--space-4) 0 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
645
web/workspace/src/lib/workspace-pages/WorkspacePage.svelte
Normal file
645
web/workspace/src/lib/workspace-pages/WorkspacePage.svelte
Normal file
|
|
@ -0,0 +1,645 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
||||||
|
import type {
|
||||||
|
Diagnostic,
|
||||||
|
Host,
|
||||||
|
ListResponse,
|
||||||
|
ObjectiveDetail,
|
||||||
|
ObjectiveListResponse,
|
||||||
|
RepositoryDetailResponse,
|
||||||
|
RepositoryLogResponse,
|
||||||
|
RepositorySummary,
|
||||||
|
RepositoryTicketsResponse,
|
||||||
|
Worker,
|
||||||
|
WorkspaceResponse
|
||||||
|
} from '$lib/workspace-sidebar/types';
|
||||||
|
|
||||||
|
type WorkspaceView = 'overview' | 'repository' | 'objectives' | 'objective';
|
||||||
|
|
||||||
|
type RouteState =
|
||||||
|
| { page: 'overview'; objectiveId?: undefined }
|
||||||
|
| { page: 'repository'; objectiveId?: undefined }
|
||||||
|
| { page: 'objectives'; objectiveId?: undefined }
|
||||||
|
| { page: 'objective'; objectiveId: string };
|
||||||
|
|
||||||
|
let {
|
||||||
|
view = 'overview',
|
||||||
|
objectiveId = null
|
||||||
|
}: { view?: WorkspaceView; repositoryId?: string; objectiveId?: string | null } = $props();
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
{ label: 'Workspace', path: '/api/workspace' },
|
||||||
|
{ label: 'Tickets', path: '/api/tickets' },
|
||||||
|
{ label: 'Objectives', path: '/api/objectives' },
|
||||||
|
{ label: 'Repositories', path: '/api/repositories' },
|
||||||
|
{ label: 'Repository log', path: '/api/repositories/local/log' },
|
||||||
|
{ label: 'Repository tickets', path: '/api/repositories/local/tickets' },
|
||||||
|
{ label: 'Runs', path: '/api/runs' },
|
||||||
|
{ label: 'Hosts', path: '/api/hosts' },
|
||||||
|
{ label: 'Workers', path: '/api/workers' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let workspace = $state<WorkspaceResponse | null>(null);
|
||||||
|
let hosts = $state<ListResponse<Host> | null>(null);
|
||||||
|
let workers = $state<ListResponse<Worker> | null>(null);
|
||||||
|
let repository = $state<RepositorySummary | null>(null);
|
||||||
|
let repositoryLog = $state<RepositoryLogResponse | null>(null);
|
||||||
|
let repositoryTickets = $state<RepositoryTicketsResponse | null>(null);
|
||||||
|
let objectives = $state<ObjectiveListResponse | null>(null);
|
||||||
|
let objectiveDetail = $state<ObjectiveDetail | null>(null);
|
||||||
|
|
||||||
|
let workspaceError = $state<string | null>(null);
|
||||||
|
let hostsError = $state<string | null>(null);
|
||||||
|
let workersError = $state<string | null>(null);
|
||||||
|
let repositoryError = $state<string | null>(null);
|
||||||
|
let repositoryLogError = $state<string | null>(null);
|
||||||
|
let repositoryTicketsError = $state<string | null>(null);
|
||||||
|
let objectivesError = $state<string | null>(null);
|
||||||
|
let objectiveDetailError = $state<string | null>(null);
|
||||||
|
let objectiveDetailLoading = $state(false);
|
||||||
|
let objectiveDetailRequest = 0;
|
||||||
|
let route = $derived(routeFromView(view, objectiveId));
|
||||||
|
let currentPath = $derived(pathFromRoute(route));
|
||||||
|
|
||||||
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
|
const response = await fetch(path);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GET ${path} failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkspace() {
|
||||||
|
workspaceError = null;
|
||||||
|
try {
|
||||||
|
workspace = await getJson<WorkspaceResponse>('/api/workspace');
|
||||||
|
} catch (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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRepository() {
|
||||||
|
repositoryError = null;
|
||||||
|
try {
|
||||||
|
const detail = await getJson<RepositoryDetailResponse>('/api/repositories/local');
|
||||||
|
repository = detail.item;
|
||||||
|
} catch (error) {
|
||||||
|
repositoryError = error instanceof Error ? error.message : String(error);
|
||||||
|
repository = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRepositoryLog() {
|
||||||
|
repositoryLogError = null;
|
||||||
|
try {
|
||||||
|
repositoryLog = await getJson<RepositoryLogResponse>('/api/repositories/local/log?limit=10');
|
||||||
|
} catch (error) {
|
||||||
|
repositoryLogError = error instanceof Error ? error.message : String(error);
|
||||||
|
repositoryLog = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRepositoryTickets() {
|
||||||
|
repositoryTicketsError = null;
|
||||||
|
try {
|
||||||
|
repositoryTickets = await getJson<RepositoryTicketsResponse>('/api/repositories/local/tickets');
|
||||||
|
} catch (error) {
|
||||||
|
repositoryTicketsError = error instanceof Error ? error.message : String(error);
|
||||||
|
repositoryTickets = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadObjectives() {
|
||||||
|
objectivesError = null;
|
||||||
|
try {
|
||||||
|
objectives = await getJson<ObjectiveListResponse>('/api/objectives');
|
||||||
|
} catch (error) {
|
||||||
|
objectivesError = error instanceof Error ? error.message : String(error);
|
||||||
|
objectives = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadObjectiveDetail(id: string) {
|
||||||
|
const request = ++objectiveDetailRequest;
|
||||||
|
objectiveDetailLoading = true;
|
||||||
|
objectiveDetailError = null;
|
||||||
|
objectiveDetail = null;
|
||||||
|
try {
|
||||||
|
const detail = await getJson<ObjectiveDetail>(`/api/objectives/${encodeURIComponent(id)}`);
|
||||||
|
if (request === objectiveDetailRequest) {
|
||||||
|
objectiveDetail = detail;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (request === objectiveDetailRequest) {
|
||||||
|
objectiveDetailError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (request === objectiveDetailRequest) {
|
||||||
|
objectiveDetailLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnosticsFor(...groups: Array<Diagnostic[] | undefined>): Diagnostic[] {
|
||||||
|
return groups.flatMap((group) => group ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeFromView(view: WorkspaceView, objectiveId: string | null): RouteState {
|
||||||
|
if (view === 'repository') {
|
||||||
|
return { page: 'repository' };
|
||||||
|
}
|
||||||
|
if (view === 'objective' && objectiveId) {
|
||||||
|
return { page: 'objective', objectiveId };
|
||||||
|
}
|
||||||
|
if (view === 'objectives') {
|
||||||
|
return { page: 'objectives' };
|
||||||
|
}
|
||||||
|
return { page: 'overview' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathFromRoute(route: RouteState): string {
|
||||||
|
if (route.page === 'repository') {
|
||||||
|
return '/repositories/local';
|
||||||
|
}
|
||||||
|
if (route.page === 'objective') {
|
||||||
|
return `/objectives/${route.objectiveId}`;
|
||||||
|
}
|
||||||
|
if (route.page === 'objectives') {
|
||||||
|
return '/objectives';
|
||||||
|
}
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined): string {
|
||||||
|
return value ?? 'not recorded';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortHash(hash: string | null | undefined): string {
|
||||||
|
return hash ? hash.slice(0, 12) : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void loadWorkspace();
|
||||||
|
void loadHosts();
|
||||||
|
void loadWorkers();
|
||||||
|
void loadRepository();
|
||||||
|
void loadRepositoryLog();
|
||||||
|
void loadRepositoryTickets();
|
||||||
|
void loadObjectives();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const selectedObjectiveId = route.page === 'objective' ? route.objectiveId : null;
|
||||||
|
if (selectedObjectiveId) {
|
||||||
|
void loadObjectiveDetail(selectedObjectiveId);
|
||||||
|
} else {
|
||||||
|
objectiveDetailRequest += 1;
|
||||||
|
objectiveDetail = null;
|
||||||
|
objectiveDetailError = null;
|
||||||
|
objectiveDetailLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Yoi Workspace Control Plane</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Local single-workspace Yoi control plane bootstrap"
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="workspace-layout">
|
||||||
|
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} />
|
||||||
|
|
||||||
|
<main class="shell">
|
||||||
|
{#if route.page === 'repository'}
|
||||||
|
<section class="grid runtime">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Repository summary</h2>
|
||||||
|
{#if repository}
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>ID</dt>
|
||||||
|
<dd><code>{repository.id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Kind</dt>
|
||||||
|
<dd>{repository.kind}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Workspace root</dt>
|
||||||
|
<dd><code>{repository.workspace_root}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Record authority</dt>
|
||||||
|
<dd>{repository.record_authority}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Git</dt>
|
||||||
|
<dd>{repository.git.status}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{:else if repositoryError}
|
||||||
|
<p class="error">{repositoryError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/repositories/local</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Git summary</h2>
|
||||||
|
{#if repository}
|
||||||
|
{#if repository.git.status === 'available'}
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Root</dt>
|
||||||
|
<dd><code>{repository.git.root ?? 'unknown'}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Branch</dt>
|
||||||
|
<dd>{repository.git.branch ?? 'unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>HEAD</dt>
|
||||||
|
<dd><code>{shortHash(repository.git.head)}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Dirty</dt>
|
||||||
|
<dd>{repository.git.dirty === null || repository.git.dirty === undefined ? 'unknown' : repository.git.dirty ? 'yes' : 'no'} <small>{repository.git.dirty_scope}</small></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Remote</dt>
|
||||||
|
<dd>
|
||||||
|
{#if repository.git.remote}
|
||||||
|
<code>{repository.git.remote.name}</code> · {repository.git.remote.url}
|
||||||
|
{#if repository.git.remote.redacted}<small>credentials redacted</small>{/if}
|
||||||
|
{:else}
|
||||||
|
not configured
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{:else}
|
||||||
|
<p>Git metadata is unavailable for this local Repository.</p>
|
||||||
|
{/if}
|
||||||
|
{:else if repositoryError}
|
||||||
|
<p class="error">{repositoryError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for Git summary…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Recent Git log</h2>
|
||||||
|
{#if repositoryLog}
|
||||||
|
{#if repositoryLog.items.length === 0}
|
||||||
|
<p>No recent commits are available from the bounded Git log API.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Commit</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Author</th>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each repositoryLog.items as commit (commit.hash)}
|
||||||
|
<tr>
|
||||||
|
<td><code>{shortHash(commit.hash)}</code></td>
|
||||||
|
<td>{commit.subject}</td>
|
||||||
|
<td>{commit.author_name} <small>{commit.author_email}</small></td>
|
||||||
|
<td>{commit.timestamp}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if repositoryLogError}
|
||||||
|
<p class="error">{repositoryLogError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/repositories/local/log</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Repository Ticket Kanban</h2>
|
||||||
|
<p class="section-note">
|
||||||
|
Read-only grouping of canonical Ticket records. No drag/drop or lifecycle mutation is exposed.
|
||||||
|
</p>
|
||||||
|
{#if repositoryTickets}
|
||||||
|
<div class="kanban">
|
||||||
|
{#each repositoryTickets.columns as column (column.state)}
|
||||||
|
<article class="kanban-column">
|
||||||
|
<h3>{column.state} <span>{column.items.length}</span></h3>
|
||||||
|
{#if column.items.length === 0}
|
||||||
|
<p class="muted">No tickets.</p>
|
||||||
|
{:else}
|
||||||
|
<ul>
|
||||||
|
{#each column.items as ticket (ticket.id)}
|
||||||
|
<li>
|
||||||
|
<strong>{ticket.title}</strong>
|
||||||
|
<small><code>{ticket.id}</code> · updated {formatDate(ticket.updated_at)}</small>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if repositoryTicketsError}
|
||||||
|
<p class="error">{repositoryTicketsError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/repositories/local/tickets</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{@const repositoryDiagnostics = diagnosticsFor(repository?.git.diagnostics, repositoryLog?.diagnostics, repositoryTickets?.diagnostics)}
|
||||||
|
{#if repositoryDiagnostics.length > 0}
|
||||||
|
<section class="card diagnostics">
|
||||||
|
<h2>Repository diagnostics</h2>
|
||||||
|
<ul>
|
||||||
|
{#each repositoryDiagnostics as diagnostic}
|
||||||
|
<li>
|
||||||
|
<strong>{diagnostic.severity}</strong>
|
||||||
|
<code>{diagnostic.code}</code>
|
||||||
|
<span>{diagnostic.message}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{:else if route.page === 'objectives' || route.page === 'objective'}
|
||||||
|
<section class="card">
|
||||||
|
<h2>Objectives</h2>
|
||||||
|
<p class="section-note">
|
||||||
|
Objectives are read from canonical filesystem records through <code>/api/objectives</code>.
|
||||||
|
</p>
|
||||||
|
{#if objectives}
|
||||||
|
{#if objectives.items.length === 0}
|
||||||
|
<p>No Objective records are present.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="stack">
|
||||||
|
{#each objectives.items as objective (objective.id)}
|
||||||
|
<article class="runtime-card selected-card" class:selected={route.page === 'objective' && route.objectiveId === objective.id}>
|
||||||
|
<div class="runtime-heading">
|
||||||
|
<strong>{objective.title}</strong>
|
||||||
|
<span>{objective.state}</span>
|
||||||
|
</div>
|
||||||
|
<p>{objective.summary || 'No summary text is available.'}</p>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>ID</dt>
|
||||||
|
<dd><code>{objective.id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Updated</dt>
|
||||||
|
<dd>{formatDate(objective.updated_at)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Linked tickets</dt>
|
||||||
|
<dd>{objective.linked_tickets?.length ? objective.linked_tickets.join(', ') : 'none'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p><a href={`/objectives/${objective.id}`}>View detail</a></p>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if objectives.invalid_records.length > 0}
|
||||||
|
<p class="error">{objectives.invalid_records.length} invalid objective record(s) hidden.</p>
|
||||||
|
{/if}
|
||||||
|
{:else if objectivesError}
|
||||||
|
<p class="error">{objectivesError}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/objectives</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if route.page === 'objective'}
|
||||||
|
<section class="card">
|
||||||
|
<h2>Objective detail</h2>
|
||||||
|
{#if objectiveDetail}
|
||||||
|
<div class="detail-heading">
|
||||||
|
<h3>{objectiveDetail.title}</h3>
|
||||||
|
<span>{objectiveDetail.state}</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>ID</dt>
|
||||||
|
<dd><code>{objectiveDetail.id}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Updated</dt>
|
||||||
|
<dd>{formatDate(objectiveDetail.updated_at)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Created</dt>
|
||||||
|
<dd>{formatDate(objectiveDetail.created_at)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Linked tickets</dt>
|
||||||
|
<dd>{objectiveDetail.linked_tickets.length ? objectiveDetail.linked_tickets.join(', ') : 'none'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Source</dt>
|
||||||
|
<dd>{objectiveDetail.record_source}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{#if objectiveDetail.body_truncated}
|
||||||
|
<p class="section-note">Objective body was truncated by the backend response limit.</p>
|
||||||
|
{/if}
|
||||||
|
<pre class="record-body">{objectiveDetail.body || 'No Objective body text is available.'}</pre>
|
||||||
|
{:else if objectiveDetailError}
|
||||||
|
<p class="error">{objectiveDetailError}</p>
|
||||||
|
{:else if objectiveDetailLoading}
|
||||||
|
<p>Loading Objective <code>{route.objectiveId}</code>…</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for Objective detail…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<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 workspaceError}
|
||||||
|
<p class="error">{workspaceError}</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
@ -1,167 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ObjectiveListResponse, ObjectiveSummary } from './types';
|
type Props = {
|
||||||
|
currentPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_VISIBLE_OBJECTIVES = 6;
|
let { currentPath = '/' }: Props = $props();
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<section class="nav-section" aria-labelledby="objectives-heading">
|
<section class="nav-section">
|
||||||
<div class="section-heading-row">
|
<header class="section-header">
|
||||||
<h2 id="objectives-heading">objectives</h2>
|
<span>Objectives</span>
|
||||||
{#if !loading && !error}
|
</header>
|
||||||
<span class="section-count">{objectives.length}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
<a class="objective-link" class:active={currentPath.startsWith('/objectives')} href="/objectives">
|
||||||
<p class="section-state">Loading objectives…</p>
|
<span class="item-title">Open Objectives</span>
|
||||||
{:else if error}
|
<span class="item-meta">workspace objectives</span>
|
||||||
<p class="section-state error">{error}</p>
|
</a>
|
||||||
{:else if objectives.length === 0}
|
|
||||||
<p class="section-state">No objectives found.</p>
|
|
||||||
{:else}
|
|
||||||
<ul class="nav-list" aria-label="Objectives">
|
|
||||||
<li>
|
|
||||||
<a class="nav-item active" href="#/objectives">
|
|
||||||
<span class="item-title">All objectives</span>
|
|
||||||
<span class="item-meta">read-only list</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{#each objectives as objective (objective.id)}
|
|
||||||
<li>
|
|
||||||
<a class="nav-item" href={`#/objectives/${objective.id}`}>
|
|
||||||
<span class="item-title">{objective.title}</span>
|
|
||||||
<span class="item-meta">{objective.state}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if invalidRecordCount > 0}
|
|
||||||
<p class="section-note">{invalidRecordCount} invalid objective record(s) hidden.</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</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;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspace: WorkspaceResponse | null;
|
workspace: WorkspaceResponse | null;
|
||||||
|
currentPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { workspace }: Props = $props();
|
let { workspace, currentPath = '/' }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="nav-section" aria-labelledby="repositories-heading">
|
<section class="nav-section" aria-labelledby="repositories-heading">
|
||||||
|
|
@ -16,7 +17,7 @@
|
||||||
|
|
||||||
<ul class="nav-list" aria-label="Repositories">
|
<ul class="nav-list" aria-label="Repositories">
|
||||||
<li>
|
<li>
|
||||||
<a class="nav-item active" href="#/repositories/local">
|
<a class="nav-item" class:active={currentPath.startsWith('/repositories')} href="/repositories/local">
|
||||||
<span class="item-title">{workspace?.display_name ?? 'local workspace'}</span>
|
<span class="item-title">{workspace?.display_name ?? 'local workspace'}</span>
|
||||||
<span class="item-meta">local repository · read-only</span>
|
<span class="item-meta">local repository · read-only</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -27,83 +28,3 @@
|
||||||
Repository authority remains the current workspace root and canonical project records.
|
Repository authority remains the current workspace root and canonical project records.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</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;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
|
|
|
||||||
|
|
@ -74,84 +74,3 @@
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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>
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@
|
||||||
type Props = {
|
type Props = {
|
||||||
workspace: WorkspaceResponse | null;
|
workspace: WorkspaceResponse | null;
|
||||||
workspaceError?: string | null;
|
workspaceError?: string | null;
|
||||||
|
currentPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { workspace, workspaceError = null }: Props = $props();
|
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="workspace-sidebar" aria-label="Workspace navigation">
|
<aside class="workspace-sidebar" aria-label="Workspace navigation">
|
||||||
|
|
@ -38,87 +39,8 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||||
<RepositoriesNavSection {workspace} />
|
<RepositoriesNavSection {workspace} {currentPath} />
|
||||||
<ObjectivesNavSection />
|
<ObjectivesNavSection {currentPath} />
|
||||||
<WorkersNavSection />
|
<WorkersNavSection />
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</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>
|
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,18 @@ export type ObjectiveSummary = {
|
||||||
record_source?: string;
|
record_source?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ObjectiveDetail = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
linked_tickets: string[];
|
||||||
|
body: string;
|
||||||
|
body_truncated: boolean;
|
||||||
|
record_source: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type InvalidProjectRecord = {
|
export type InvalidProjectRecord = {
|
||||||
label: string;
|
label: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
|
|
||||||
7
web/workspace/src/routes/+layout.svelte
Normal file
7
web/workspace/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
export const prerender = true;
|
export const prerender = false;
|
||||||
|
|
|
||||||
|
|
@ -1,827 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
|
||||||
import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
|
|
||||||
import type {
|
|
||||||
Diagnostic,
|
|
||||||
Host,
|
|
||||||
ListResponse,
|
|
||||||
ObjectiveListResponse,
|
|
||||||
ObjectiveSummary,
|
|
||||||
RepositoryDetailResponse,
|
|
||||||
RepositoryLogResponse,
|
|
||||||
RepositorySummary,
|
|
||||||
RepositoryTicketsResponse,
|
|
||||||
Worker,
|
|
||||||
WorkspaceResponse
|
|
||||||
} from '$lib/workspace-sidebar/types';
|
|
||||||
|
|
||||||
type RouteState =
|
|
||||||
| { page: 'overview'; objectiveId?: undefined }
|
|
||||||
| { page: 'repository'; objectiveId?: undefined }
|
|
||||||
| { page: 'objectives'; objectiveId?: string };
|
|
||||||
|
|
||||||
const endpoints = [
|
|
||||||
{ label: 'Workspace', path: '/api/workspace' },
|
|
||||||
{ label: 'Tickets', path: '/api/tickets' },
|
|
||||||
{ label: 'Objectives', path: '/api/objectives' },
|
|
||||||
{ label: 'Repositories', path: '/api/repositories' },
|
|
||||||
{ label: 'Repository log', path: '/api/repositories/local/log' },
|
|
||||||
{ label: 'Repository tickets', path: '/api/repositories/local/tickets' },
|
|
||||||
{ label: 'Runs', path: '/api/runs' },
|
|
||||||
{ label: 'Hosts', path: '/api/hosts' },
|
|
||||||
{ label: 'Workers', path: '/api/workers' }
|
|
||||||
];
|
|
||||||
|
|
||||||
let workspace = $state<WorkspaceResponse | null>(null);
|
|
||||||
let hosts = $state<ListResponse<Host> | null>(null);
|
|
||||||
let workers = $state<ListResponse<Worker> | null>(null);
|
|
||||||
let repository = $state<RepositorySummary | null>(null);
|
|
||||||
let repositoryLog = $state<RepositoryLogResponse | null>(null);
|
|
||||||
let repositoryTickets = $state<RepositoryTicketsResponse | null>(null);
|
|
||||||
let objectives = $state<ObjectiveListResponse | null>(null);
|
|
||||||
|
|
||||||
let workspaceError = $state<string | null>(null);
|
|
||||||
let hostsError = $state<string | null>(null);
|
|
||||||
let workersError = $state<string | null>(null);
|
|
||||||
let repositoryError = $state<string | null>(null);
|
|
||||||
let repositoryLogError = $state<string | null>(null);
|
|
||||||
let repositoryTicketsError = $state<string | null>(null);
|
|
||||||
let objectivesError = $state<string | null>(null);
|
|
||||||
let currentPath = $state('/');
|
|
||||||
let route = $derived(routeFromPath(currentPath));
|
|
||||||
|
|
||||||
async function getJson<T>(path: string): Promise<T> {
|
|
||||||
const response = await fetch(path);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`GET ${path} failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWorkspace() {
|
|
||||||
workspaceError = null;
|
|
||||||
try {
|
|
||||||
workspace = await getJson<WorkspaceResponse>('/api/workspace');
|
|
||||||
} catch (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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRepository() {
|
|
||||||
repositoryError = null;
|
|
||||||
try {
|
|
||||||
const detail = await getJson<RepositoryDetailResponse>('/api/repositories/local');
|
|
||||||
repository = detail.item;
|
|
||||||
} catch (error) {
|
|
||||||
repositoryError = error instanceof Error ? error.message : String(error);
|
|
||||||
repository = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRepositoryLog() {
|
|
||||||
repositoryLogError = null;
|
|
||||||
try {
|
|
||||||
repositoryLog = await getJson<RepositoryLogResponse>('/api/repositories/local/log?limit=10');
|
|
||||||
} catch (error) {
|
|
||||||
repositoryLogError = error instanceof Error ? error.message : String(error);
|
|
||||||
repositoryLog = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRepositoryTickets() {
|
|
||||||
repositoryTicketsError = null;
|
|
||||||
try {
|
|
||||||
repositoryTickets = await getJson<RepositoryTicketsResponse>('/api/repositories/local/tickets');
|
|
||||||
} catch (error) {
|
|
||||||
repositoryTicketsError = error instanceof Error ? error.message : String(error);
|
|
||||||
repositoryTickets = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadObjectives() {
|
|
||||||
objectivesError = null;
|
|
||||||
try {
|
|
||||||
objectives = await getJson<ObjectiveListResponse>('/api/objectives');
|
|
||||||
} catch (error) {
|
|
||||||
objectivesError = error instanceof Error ? error.message : String(error);
|
|
||||||
objectives = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnosticsFor(...groups: Array<Diagnostic[] | undefined>): Diagnostic[] {
|
|
||||||
return groups.flatMap((group) => group ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeFromPath(path: string): RouteState {
|
|
||||||
if (path.startsWith('/repositories')) {
|
|
||||||
return { page: 'repository' };
|
|
||||||
}
|
|
||||||
if (path.startsWith('/objectives')) {
|
|
||||||
const [, , objectiveId] = path.split('/');
|
|
||||||
return { page: 'objectives', objectiveId: objectiveId || undefined };
|
|
||||||
}
|
|
||||||
return { page: 'overview' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRouteFromHash() {
|
|
||||||
const hashPath = window.location.hash.replace(/^#/, '') || '/';
|
|
||||||
currentPath = hashPath.startsWith('/') ? hashPath : `/${hashPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string | null | undefined): string {
|
|
||||||
return value ?? 'not recorded';
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortHash(hash: string | null | undefined): string {
|
|
||||||
return hash ? hash.slice(0, 12) : 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void loadWorkspace();
|
|
||||||
void loadHosts();
|
|
||||||
void loadWorkers();
|
|
||||||
void loadRepository();
|
|
||||||
void loadRepositoryLog();
|
|
||||||
void loadRepositoryTickets();
|
|
||||||
void loadObjectives();
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
updateRouteFromHash();
|
|
||||||
window.addEventListener('hashchange', updateRouteFromHash);
|
|
||||||
return () => window.removeEventListener('hashchange', updateRouteFromHash);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<WorkspacePage view="overview" />
|
||||||
<title>Yoi Workspace Control Plane</title>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Local single-workspace Yoi control plane bootstrap"
|
|
||||||
/>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="workspace-layout">
|
|
||||||
<WorkspaceSidebar {workspace} {workspaceError} />
|
|
||||||
|
|
||||||
<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,
|
|
||||||
bounded local Repository summaries, and the local Host / Worker execution
|
|
||||||
view. Ticket and Objective lifecycle authority stays in the existing local
|
|
||||||
record workflow.
|
|
||||||
</p>
|
|
||||||
<p class="page-links" aria-label="Workspace page links">
|
|
||||||
<a href="#/">Overview</a>
|
|
||||||
<a href="#/repositories/local">Repository</a>
|
|
||||||
<a href="#/objectives">Objectives</a>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if route.page === 'repository'}
|
|
||||||
<section class="grid runtime">
|
|
||||||
<div class="card">
|
|
||||||
<h2>Repository summary</h2>
|
|
||||||
{#if repository}
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>ID</dt>
|
|
||||||
<dd><code>{repository.id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Kind</dt>
|
|
||||||
<dd>{repository.kind}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Workspace root</dt>
|
|
||||||
<dd><code>{repository.workspace_root}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Record authority</dt>
|
|
||||||
<dd>{repository.record_authority}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Git</dt>
|
|
||||||
<dd>{repository.git.status}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{:else if repositoryError}
|
|
||||||
<p class="error">{repositoryError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/repositories/local</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Git summary</h2>
|
|
||||||
{#if repository}
|
|
||||||
{#if repository.git.status === 'available'}
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>Root</dt>
|
|
||||||
<dd><code>{repository.git.root ?? 'unknown'}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Branch</dt>
|
|
||||||
<dd>{repository.git.branch ?? 'unknown'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>HEAD</dt>
|
|
||||||
<dd><code>{shortHash(repository.git.head)}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Dirty</dt>
|
|
||||||
<dd>{repository.git.dirty === null || repository.git.dirty === undefined ? 'unknown' : repository.git.dirty ? 'yes' : 'no'} <small>{repository.git.dirty_scope}</small></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Remote</dt>
|
|
||||||
<dd>
|
|
||||||
{#if repository.git.remote}
|
|
||||||
<code>{repository.git.remote.name}</code> · {repository.git.remote.url}
|
|
||||||
{#if repository.git.remote.redacted}<small>credentials redacted</small>{/if}
|
|
||||||
{:else}
|
|
||||||
not configured
|
|
||||||
{/if}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
{:else}
|
|
||||||
<p>Git metadata is unavailable for this local Repository.</p>
|
|
||||||
{/if}
|
|
||||||
{:else if repositoryError}
|
|
||||||
<p class="error">{repositoryError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for Git summary…</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Recent Git log</h2>
|
|
||||||
{#if repositoryLog}
|
|
||||||
{#if repositoryLog.items.length === 0}
|
|
||||||
<p>No recent commits are available from the bounded Git log API.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Commit</th>
|
|
||||||
<th>Subject</th>
|
|
||||||
<th>Author</th>
|
|
||||||
<th>Timestamp</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each repositoryLog.items as commit (commit.hash)}
|
|
||||||
<tr>
|
|
||||||
<td><code>{shortHash(commit.hash)}</code></td>
|
|
||||||
<td>{commit.subject}</td>
|
|
||||||
<td>{commit.author_name} <small>{commit.author_email}</small></td>
|
|
||||||
<td>{commit.timestamp}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if repositoryLogError}
|
|
||||||
<p class="error">{repositoryLogError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/repositories/local/log</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Repository Ticket Kanban</h2>
|
|
||||||
<p class="section-note">
|
|
||||||
Read-only grouping of canonical Ticket records. No drag/drop or lifecycle mutation is exposed.
|
|
||||||
</p>
|
|
||||||
{#if repositoryTickets}
|
|
||||||
<div class="kanban">
|
|
||||||
{#each repositoryTickets.columns as column (column.state)}
|
|
||||||
<article class="kanban-column">
|
|
||||||
<h3>{column.state} <span>{column.items.length}</span></h3>
|
|
||||||
{#if column.items.length === 0}
|
|
||||||
<p class="muted">No tickets.</p>
|
|
||||||
{:else}
|
|
||||||
<ul>
|
|
||||||
{#each column.items as ticket (ticket.id)}
|
|
||||||
<li>
|
|
||||||
<strong>{ticket.title}</strong>
|
|
||||||
<small><code>{ticket.id}</code> · updated {formatDate(ticket.updated_at)}</small>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if repositoryTicketsError}
|
|
||||||
<p class="error">{repositoryTicketsError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/repositories/local/tickets</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{@const repositoryDiagnostics = diagnosticsFor(repository?.git.diagnostics, repositoryLog?.diagnostics, repositoryTickets?.diagnostics)}
|
|
||||||
{#if repositoryDiagnostics.length > 0}
|
|
||||||
<section class="card diagnostics">
|
|
||||||
<h2>Repository diagnostics</h2>
|
|
||||||
<ul>
|
|
||||||
{#each repositoryDiagnostics as diagnostic}
|
|
||||||
<li>
|
|
||||||
<strong>{diagnostic.severity}</strong>
|
|
||||||
<code>{diagnostic.code}</code>
|
|
||||||
<span>{diagnostic.message}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
{:else if route.page === 'objectives'}
|
|
||||||
<section class="card">
|
|
||||||
<h2>Objectives</h2>
|
|
||||||
<p class="section-note">
|
|
||||||
Objectives are read from canonical filesystem records through <code>/api/objectives</code>.
|
|
||||||
</p>
|
|
||||||
{#if objectives}
|
|
||||||
{#if objectives.items.length === 0}
|
|
||||||
<p>No Objective records are present.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="stack">
|
|
||||||
{#each objectives.items as objective (objective.id)}
|
|
||||||
<article class="runtime-card selected-card" class:selected={route.objectiveId === objective.id}>
|
|
||||||
<div class="runtime-heading">
|
|
||||||
<strong>{objective.title}</strong>
|
|
||||||
<span>{objective.state}</span>
|
|
||||||
</div>
|
|
||||||
<p>{objective.summary || 'No summary text is available.'}</p>
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>ID</dt>
|
|
||||||
<dd><code>{objective.id}</code></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Updated</dt>
|
|
||||||
<dd>{formatDate(objective.updated_at)}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Linked tickets</dt>
|
|
||||||
<dd>{objective.linked_tickets?.length ? objective.linked_tickets.join(', ') : 'none'}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<p><a href={`#/objectives/${objective.id}`}>Detail placeholder</a></p>
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if objectives.invalid_records.length > 0}
|
|
||||||
<p class="error">{objectives.invalid_records.length} invalid objective record(s) hidden.</p>
|
|
||||||
{/if}
|
|
||||||
{:else if objectivesError}
|
|
||||||
<p class="error">{objectivesError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/objectives</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{#if route.objectiveId}
|
|
||||||
<section class="card detail-placeholder">
|
|
||||||
<h2>Objective detail</h2>
|
|
||||||
<p>
|
|
||||||
Selected Objective <code>{route.objectiveId}</code>. This slice keeps detail navigation as a
|
|
||||||
static SPA placeholder; canonical Objective content remains in the filesystem record.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<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 workspaceError}
|
|
||||||
<p class="error">{workspaceError}</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>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:global(*) {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(body) {
|
|
||||||
margin: 0;
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
font-family:
|
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: 32px 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell {
|
|
||||||
display: grid;
|
|
||||||
gap: 24px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero p {
|
|
||||||
max-width: 68ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-links {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-links a,
|
|
||||||
.card a {
|
|
||||||
color: #7dd3fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-links a {
|
|
||||||
border: 1px solid rgba(125, 211, 252, 0.28);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
color: #38bdf8;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0 0 16px;
|
|
||||||
font-size: clamp(2.5rem, 8vw, 5rem);
|
|
||||||
line-height: 0.95;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2,
|
|
||||||
h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
p,
|
|
||||||
li,
|
|
||||||
dd {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
color: #bae6fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(15, 23, 42, 0.75);
|
|
||||||
padding: 24px;
|
|
||||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-note,
|
|
||||||
.muted {
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stack {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime-card,
|
|
||||||
.kanban-column {
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
background: rgba(15, 23, 42, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-card.selected {
|
|
||||||
border-color: rgba(56, 189, 248, 0.5);
|
|
||||||
background: rgba(14, 165, 233, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime-heading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime-heading span {
|
|
||||||
color: #86efac;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime-heading span.warn {
|
|
||||||
color: #fcd34d;
|
|
||||||
}
|
|
||||||
|
|
||||||
dl {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
|
|
||||||
padding: 10px 8px;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
small {
|
|
||||||
color: #94a3b8;
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban {
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr));
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column h3 {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
color: #cbd5e1;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column ul {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kanban-column li {
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(30, 41, 59, 0.72);
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagnostics {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagnostics li {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-placeholder {
|
|
||||||
border-style: dashed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
|
|
|
||||||
5
web/workspace/src/routes/objectives/+page.svelte
Normal file
5
web/workspace/src/routes/objectives/+page.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<WorkspacePage view="objectives" />
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
|
||||||
|
|
||||||
|
let { data }: { data: { objectiveId: string } } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<WorkspacePage view="objective" objectiveId={data.objectiveId} />
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = ({ params }) => ({
|
||||||
|
objectiveId: params.objectiveId
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<WorkspacePage view="repository" />
|
||||||
Loading…
Reference in New Issue
Block a user