web: add workspace ticket routes
This commit is contained in:
parent
681034db1e
commit
7dda898830
|
|
@ -445,12 +445,14 @@
|
||||||
.runtime-card {
|
.runtime-card {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
.ticket-list,
|
||||||
.objective-list {
|
.objective-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
.ticket-row,
|
||||||
.objective-row {
|
.objective-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) max-content;
|
grid-template-columns: minmax(0, 1fr) max-content;
|
||||||
|
|
@ -463,27 +465,34 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background-color 140ms ease;
|
transition: background-color 140ms ease;
|
||||||
}
|
}
|
||||||
|
.ticket-row:hover,
|
||||||
|
.ticket-row:focus-visible,
|
||||||
.objective-row:hover,
|
.objective-row:hover,
|
||||||
.objective-row:focus-visible {
|
.objective-row:focus-visible {
|
||||||
background: var(--interactive-hover);
|
background: var(--interactive-hover);
|
||||||
}
|
}
|
||||||
|
.ticket-row.selected,
|
||||||
.objective-row.selected {
|
.objective-row.selected {
|
||||||
background: var(--interactive-selected);
|
background: var(--interactive-selected);
|
||||||
}
|
}
|
||||||
|
.ticket-row.selected .ticket-title,
|
||||||
.objective-row.selected .objective-title {
|
.objective-row.selected .objective-title {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.ticket-main,
|
||||||
.objective-main {
|
.objective-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.ticket-title-row,
|
||||||
.objective-title-row {
|
.objective-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.ticket-title,
|
||||||
.objective-title {
|
.objective-title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -500,12 +509,14 @@
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
.ticket-summary,
|
||||||
.objective-summary {
|
.objective-summary {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
.ticket-meta,
|
||||||
.objective-meta {
|
.objective-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -518,10 +529,12 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
.ticket-row,
|
||||||
.objective-row {
|
.objective-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
.ticket-meta,
|
||||||
.objective-meta {
|
.objective-meta {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
@ -542,6 +555,9 @@
|
||||||
.runtime-heading span.warn {
|
.runtime-heading span.warn {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
.detail-heading.compact {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
.detail-heading h3 {
|
.detail-heading h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -549,6 +565,36 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
.ticket-detail-grid {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.risk-flags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.risk-flags span,
|
||||||
|
.warning-pill {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.18rem 0.55rem;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--warning);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.ticket-body pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-panel);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
dl {
|
dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => {
|
Deno.test("root layout bootstraps only the scoped workspace entry", async () => {
|
||||||
const layout = await Deno.readTextFile(
|
const layout = await Deno.readTextFile(
|
||||||
new URL("./../../../routes/+layout.ts", import.meta.url),
|
new URL("./../../../routes/+layout.ts", import.meta.url),
|
||||||
);
|
);
|
||||||
|
|
@ -47,24 +47,17 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped
|
||||||
"unscoped layout may use only the workspace-id bootstrap endpoint",
|
"unscoped layout may use only the workspace-id bootstrap endpoint",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
layout.includes("throw redirect(307") && layout.includes("workspaceRoute("),
|
layout.includes("throw redirect(307") &&
|
||||||
"unscoped layout should redirect to the scoped workspace route",
|
layout.includes("workspaceRoute(workspace.data.workspace_id)") &&
|
||||||
|
!layout.includes("scopedCompatibilityRoute") &&
|
||||||
|
!layout.includes("workspaceRoute(workspaceId, pathname)"),
|
||||||
|
"root layout should redirect only to the scoped workspace entry",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!layout.includes("`/api${path}`") &&
|
!layout.includes("`/api${path}`") &&
|
||||||
!layout.includes('"/api/repositories"'),
|
!layout.includes('"/api/repositories"'),
|
||||||
"layout must not fall back to unscoped workspace-scoped API calls",
|
"layout must not fall back to unscoped workspace-scoped API calls",
|
||||||
);
|
);
|
||||||
|
|
||||||
const unscopedSettings = await Deno.readTextFile(
|
|
||||||
new URL("./../../../routes/settings/+page.svelte", import.meta.url),
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
unscopedSettings.includes("Redirecting to scoped settings") &&
|
|
||||||
!unscopedSettings.includes("fetch(") &&
|
|
||||||
!unscopedSettings.includes("settingsApiPath"),
|
|
||||||
"unscoped settings route should remain a thin redirect shim, not a data/control surface",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Skill API paths use workspace backend scoped endpoints", () => {
|
Deno.test("Skill API paths use workspace backend scoped endpoints", () => {
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,13 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
workspacePage.includes("href={`/w/${workspaceId}/runtimes`}") &&
|
workspacePage.includes("ticketsHref") &&
|
||||||
workspacePage.includes("href={`/w/${workspaceId}/workers`}"),
|
workspacePage.includes("runtimeSettingsHref") &&
|
||||||
"top workspace page should link to Runtimes and Workers pages",
|
workspacePage.includes("workersHref") &&
|
||||||
|
workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") &&
|
||||||
|
workspacePage.includes("workspaceRoute(workspaceId, '/settings/runtimes')") &&
|
||||||
|
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
|
||||||
|
"top workspace page should link to Tickets, Runtime Inventory under Settings, and the Workers page",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!workspacePage.includes("workerConsoleHref") &&
|
!workspacePage.includes("workerConsoleHref") &&
|
||||||
|
|
@ -51,8 +55,66 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!sidebar.includes("CompanionNavSection") &&
|
!sidebar.includes("CompanionNavSection") &&
|
||||||
|
sidebar.includes("TicketsNavSection") &&
|
||||||
sidebar.includes("WorkersNavSection"),
|
sidebar.includes("WorkersNavSection"),
|
||||||
"standalone Companion/Console navigation should not remain canonical",
|
"standalone Companion/Console navigation should not remain canonical and Tickets should be primary workspace navigation",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async () => {
|
||||||
|
const ticketsNav = await Deno.readTextFile(
|
||||||
|
new URL("../sidebar/TicketsNavSection.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
const ticketsLoad = await Deno.readTextFile(
|
||||||
|
new URL("./../../../routes/w/[workspaceId]/tickets/+page.ts", import.meta.url),
|
||||||
|
);
|
||||||
|
const ticketsPage = await Deno.readTextFile(
|
||||||
|
new URL("./../../../routes/w/[workspaceId]/tickets/+page.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
const ticketDetailLoad = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.ts",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const ticketDetailPage = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"./../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") &&
|
||||||
|
ticketsNav.includes("Open Tickets"),
|
||||||
|
"Tickets sidebar section should link to the workspace Tickets surface",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
ticketsLoad.includes('workspaceApiPath(params.workspaceId, "/tickets")') &&
|
||||||
|
ticketsPage.includes("This surface is read-only") &&
|
||||||
|
ticketsPage.includes("workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)"),
|
||||||
|
"Tickets list should read the workspace-scoped Ticket API and link to Ticket details",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") &&
|
||||||
|
ticketDetailPage.includes("event_count") &&
|
||||||
|
ticketDetailPage.includes("artifact_count") &&
|
||||||
|
ticketDetailPage.includes("<pre>{data.ticket.data.body"),
|
||||||
|
"Ticket detail should read one Ticket record and expose body plus metadata without mutation controls",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("root layout does not keep legacy unscoped route compatibility", async () => {
|
||||||
|
const layoutLoad = await Deno.readTextFile(
|
||||||
|
new URL("./../../../routes/+layout.ts", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
!layoutLoad.includes("scopedCompatibilityRoute") &&
|
||||||
|
!layoutLoad.includes('pathname === "/runtimes"') &&
|
||||||
|
!layoutLoad.includes("return workspaceRoute(workspaceId, pathname)") &&
|
||||||
|
layoutLoad.includes("workspaceRoute(workspace.data.workspace_id)"),
|
||||||
|
"root layout should bootstrap the workspace entry only, not preserve legacy unscoped routes",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -188,49 +250,50 @@ Deno.test("Decodal source editor keeps imperative EditorView out of reactive sta
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned workdirs", async () => {
|
Deno.test("workspace Runtime inventory lives under Settings admin routes", async () => {
|
||||||
const sidebar = await Deno.readTextFile(
|
const sidebar = await Deno.readTextFile(
|
||||||
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
|
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const runtimesNav = await Deno.readTextFile(
|
const settingsModel = await Deno.readTextFile(
|
||||||
new URL("../sidebar/RuntimesNavSection.svelte", import.meta.url),
|
new URL("../settings/model.ts", import.meta.url),
|
||||||
);
|
);
|
||||||
const runtimesPage = await Deno.readTextFile(
|
const runtimesPage = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"./../../../routes/w/[workspaceId]/runtimes/+page.svelte",
|
"./../../../routes/w/[workspaceId]/settings/runtimes/+page.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const workdirsPage = await Deno.readTextFile(
|
const workdirsPage = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte",
|
"./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const workdirsLoad = await Deno.readTextFile(
|
const workdirsLoad = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts",
|
"./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.ts",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
sidebar.includes("RuntimesNavSection") &&
|
!sidebar.includes("RuntimesNavSection") &&
|
||||||
runtimesNav.includes("href={runtimesHref}") &&
|
settingsModel.includes('id: "runtime-inventory"') &&
|
||||||
runtimesNav.includes("/runtimes"),
|
settingsModel.includes('return `${SETTINGS_ROUTE}/runtimes`;'),
|
||||||
"sidebar should expose Runtime management navigation",
|
"Runtime inventory should be admin Settings navigation, not primary workspace sidebar navigation",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
|
runtimesPage.includes("Runtime Inventory") &&
|
||||||
runtimesPage.includes("Open workdirs") &&
|
runtimesPage.includes("Open workdirs") &&
|
||||||
runtimesPage.includes("runtimes-table") &&
|
runtimesPage.includes("runtimes-table") &&
|
||||||
runtimesPage.includes("/workdirs"),
|
runtimesPage.includes("/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs"),
|
||||||
"Runtimes page should table Runtimes and link to each Runtime's workdirs",
|
"Settings Runtime Inventory page should table Runtimes and link to each Runtime's workdirs",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
workdirsPage.includes("Workdirs") &&
|
workdirsPage.includes("Runtime Inventory") &&
|
||||||
workdirsPage.includes("workdirs-table") &&
|
workdirsPage.includes("workdirs-table") &&
|
||||||
workdirsLoad.includes("/working-directories"),
|
workdirsLoad.includes("/working-directories"),
|
||||||
"Workdirs page should read Runtime-owned working-directory API while using workdir UI language",
|
"Runtime workdirs should remain backed by Runtime APIs without legacy Runtime route redirects",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export type Diagnostic = {
|
||||||
|
|
||||||
export type SettingsSectionId =
|
export type SettingsSectionId =
|
||||||
| "runtime-connections"
|
| "runtime-connections"
|
||||||
|
| "runtime-inventory"
|
||||||
| "profile-sources"
|
| "profile-sources"
|
||||||
| "backend-config"
|
| "backend-config"
|
||||||
| "workspace-identity";
|
| "workspace-identity";
|
||||||
|
|
@ -85,6 +86,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||||
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
|
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "runtime-inventory",
|
||||||
|
label: "Runtime Inventory",
|
||||||
|
status: "read-only",
|
||||||
|
summary:
|
||||||
|
"Inspect registered Runtime handles and their materialized workdirs from the admin settings surface instead of the normal workspace navigation.",
|
||||||
|
bullets: [
|
||||||
|
"Runtime state is operational Backend/Runtime context, not a workspace content object like Objectives or Repositories.",
|
||||||
|
"Workdir cleanup remains scoped to typed Runtime APIs and stays outside the primary workspace sidebar.",
|
||||||
|
"Console routes may still target a Runtime handle directly, but Runtime discovery belongs under Settings.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "profile-sources",
|
id: "profile-sources",
|
||||||
label: "Profile Sources",
|
label: "Profile Sources",
|
||||||
|
|
@ -145,6 +158,8 @@ export function settingsSectionHref(id: SettingsSectionId): string {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "runtime-connections":
|
case "runtime-connections":
|
||||||
return `${SETTINGS_ROUTE}/runtime-connections`;
|
return `${SETTINGS_ROUTE}/runtime-connections`;
|
||||||
|
case "runtime-inventory":
|
||||||
|
return `${SETTINGS_ROUTE}/runtimes`;
|
||||||
case "profile-sources":
|
case "profile-sources":
|
||||||
return `${SETTINGS_ROUTE}/profiles`;
|
return `${SETTINGS_ROUTE}/profiles`;
|
||||||
case "workspace-identity":
|
case "workspace-identity":
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
type Props = {
|
|
||||||
currentPath?: string;
|
|
||||||
workspaceId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { currentPath = '/', workspaceId }: Props = $props();
|
|
||||||
let runtimesHref = $derived(workspaceId ? `/w/${workspaceId}/runtimes` : '/runtimes');
|
|
||||||
let active = $derived(currentPath === runtimesHref || currentPath.startsWith(`${runtimesHref}/`));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="sidebar-section" aria-labelledby="runtimes-heading">
|
|
||||||
<div class="section-heading-row">
|
|
||||||
<h2 id="runtimes-heading">
|
|
||||||
<a
|
|
||||||
class="section-heading-link"
|
|
||||||
class:active
|
|
||||||
href={runtimesHref}
|
|
||||||
aria-current={active ? 'page' : undefined}
|
|
||||||
>runtimes</a>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
currentPath?: string;
|
||||||
|
workspaceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { currentPath = '/', workspaceId }: Props = $props();
|
||||||
|
let ticketsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/tickets') : '/');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="nav-section">
|
||||||
|
<header class="section-header">
|
||||||
|
<span>Tickets</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<a class="objective-link" class:active={currentPath.startsWith(ticketsHref)} href={ticketsHref}>
|
||||||
|
<span class="item-title">Open Tickets</span>
|
||||||
|
<span class="item-meta">workspace tickets</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||||
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
|
||||||
import RuntimesNavSection from './RuntimesNavSection.svelte';
|
import TicketsNavSection from './TicketsNavSection.svelte';
|
||||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||||
import type { RepositoryListResponse, WorkspaceResponse } from './types';
|
import type { RepositoryListResponse, WorkspaceResponse } from './types';
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@
|
||||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||||
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
|
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
|
||||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||||
<RuntimesNavSection {currentPath} {workspaceId} />
|
<TicketsNavSection {currentPath} {workspaceId} />
|
||||||
<WorkersNavSection {currentPath} {workspaceId} />
|
<WorkersNavSection {currentPath} {workspaceId} />
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,31 @@ export type TicketSummary = {
|
||||||
record_source?: string;
|
record_source?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TicketListResponse = {
|
||||||
|
workspace_id: string;
|
||||||
|
limit: number;
|
||||||
|
items: TicketSummary[];
|
||||||
|
invalid_records: InvalidProjectRecord[];
|
||||||
|
record_authority: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketDetail = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
priority?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
queued_by?: string | null;
|
||||||
|
queued_at?: string | null;
|
||||||
|
risk_flags: string[];
|
||||||
|
body: string;
|
||||||
|
body_truncated: boolean;
|
||||||
|
event_count: number;
|
||||||
|
artifact_count: number;
|
||||||
|
record_source: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TicketKanbanColumn = {
|
export type TicketKanbanColumn = {
|
||||||
state: string;
|
state: string;
|
||||||
items: TicketSummary[];
|
items: TicketSummary[];
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,7 @@ export const load: LayoutLoad = async ({ fetch, params, url }) => {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
|
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
|
||||||
if (workspace.data) {
|
if (workspace.data) {
|
||||||
const scopedPath = workspaceRoute(
|
const scopedPath = workspaceRoute(workspace.data.workspace_id);
|
||||||
workspace.data.workspace_id,
|
|
||||||
url.pathname === "/" ? "" : url.pathname,
|
|
||||||
);
|
|
||||||
throw redirect(307, `${scopedPath}${url.search}`);
|
throw redirect(307, `${scopedPath}${url.search}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<main class="workspace-panel-shell">
|
<main class="workspace-panel-shell">
|
||||||
<section class="workspace-card">
|
<section class="workspace-card">
|
||||||
<h1>Redirecting to scoped workspace…</h1>
|
<h1>Redirecting to scoped workspace…</h1>
|
||||||
<p class="section-note">This compatibility route bootstraps the current workspace id and redirects to the canonical <code>/w/<workspace-id></code> route.</p>
|
<p class="section-note">The workspace entry bootstraps the current workspace id and opens the canonical <code>/w/<workspace-id></code> route.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<main class="objectives-page">
|
|
||||||
<section class="workspace-card">
|
|
||||||
<h1>Redirecting to scoped objectives…</h1>
|
|
||||||
<p class="section-note">Objectives are available at the canonical <code>/w/<workspace-id>/objectives</code> route.</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import type { PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load: PageLoad = async () => ({});
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<main class="objective-detail-page">
|
|
||||||
<section class="workspace-card">
|
|
||||||
<h1>Redirecting to scoped objective…</h1>
|
|
||||||
<p class="section-note">Objective detail routes are canonical under <code>/w/<workspace-id>/objectives/<objective-id></code>.</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import type { PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load: PageLoad = async () => ({});
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<main class="repository-detail-page">
|
|
||||||
<section class="workspace-card">
|
|
||||||
<h1>Redirecting to scoped repository…</h1>
|
|
||||||
<p class="section-note">Repository routes are canonical under <code>/w/<workspace-id>/repositories/<repository-id></code>.</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import type { PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load: PageLoad = async () => ({});
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<main class="worker-console-page">
|
|
||||||
<section class="workspace-card">
|
|
||||||
<h1>Redirecting to scoped Worker Console…</h1>
|
|
||||||
<p class="section-note">Worker Console routes are canonical under <code>/w/<workspace-id>/runtimes/<runtime-id>/workers/<worker-id>/console</code>.</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import type { PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load: PageLoad = async () => ({});
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<main class="settings-page">
|
|
||||||
<section class="workspace-card">
|
|
||||||
<h1>Redirecting to scoped settings…</h1>
|
|
||||||
<p class="section-note">Settings are available at the canonical <code>/w/<workspace-id>/settings</code> route.</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
|
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
|
||||||
|
let ticketsHref = $derived(workspaceRoute(workspaceId, '/tickets'));
|
||||||
|
let runtimeSettingsHref = $derived(workspaceRoute(workspaceId, '/settings/runtimes'));
|
||||||
|
let workersHref = $derived(workspaceRoute(workspaceId, '/workers'));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -39,12 +43,17 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="workspace-actions" aria-label="Workspace sections">
|
<section class="workspace-actions" aria-label="Workspace sections">
|
||||||
<a class="workspace-action-card" href={`/w/${workspaceId}/runtimes`}>
|
<a class="workspace-action-card" href={ticketsHref}>
|
||||||
<span>Runtimes</span>
|
<span>Tickets</span>
|
||||||
<strong>Manage runtimes and workdirs</strong>
|
<strong>Browse workspace tickets</strong>
|
||||||
|
<small>Read typed Ticket records</small>
|
||||||
|
</a>
|
||||||
|
<a class="workspace-action-card" href={runtimeSettingsHref}>
|
||||||
|
<span>Runtime Inventory</span>
|
||||||
|
<strong>Open admin runtime inventory</strong>
|
||||||
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
|
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
|
||||||
</a>
|
</a>
|
||||||
<a class="workspace-action-card" href={`/w/${workspaceId}/workers`}>
|
<a class="workspace-action-card" href={workersHref}>
|
||||||
<span>Workers</span>
|
<span>Workers</span>
|
||||||
<strong>Open worker list</strong>
|
<strong>Open worker list</strong>
|
||||||
<small>Inspect status and attach to consoles</small>
|
<small>Inspect status and attach to consoles</small>
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,15 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Runtimes · Yoi Workspace</title>
|
<title>Runtime Inventory · Settings · Yoi Workspace</title>
|
||||||
<meta name="description" content="Workspace Runtimes" />
|
<meta name="description" content="Workspace Runtime inventory" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="runtimes-page" aria-labelledby="runtimes-heading">
|
<section class="runtimes-page" aria-labelledby="runtimes-heading">
|
||||||
<header class="page-header-row">
|
<header class="page-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h1 id="runtimes-heading">Runtimes</h1>
|
<h1 id="runtimes-heading">Runtime Inventory</h1>
|
||||||
<p>Execution backends available to this workspace.</p>
|
<p>Admin view of execution backends available to this workspace.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
<td>{runtimePlatform(runtime)}</td>
|
<td>{runtimePlatform(runtime)}</td>
|
||||||
<td>{runtime.capabilities.max_workers} workers</td>
|
<td>{runtime.capabilities.max_workers} workers</td>
|
||||||
<td>
|
<td>
|
||||||
<a class="inline-link" href={`/w/${data.workspaceId}/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
|
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
|
||||||
Open workdirs
|
Open workdirs
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -105,7 +105,7 @@
|
||||||
<section class="workdirs-page" aria-labelledby="workdirs-heading">
|
<section class="workdirs-page" aria-labelledby="workdirs-heading">
|
||||||
<header class="page-header-row">
|
<header class="page-header-row">
|
||||||
<div>
|
<div>
|
||||||
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
|
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtime Inventory</a> / {runtimeLabel}</p>
|
||||||
<h1 id="workdirs-heading">Workdirs</h1>
|
<h1 id="workdirs-heading">Workdirs</h1>
|
||||||
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
|
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
|
||||||
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
|
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Tickets · Yoi Workspace</title>
|
||||||
|
<meta name="description" content="Workspace Tickets" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="detail-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Workspace records</p>
|
||||||
|
<h2>Tickets</h2>
|
||||||
|
</div>
|
||||||
|
{#if data.tickets.data}
|
||||||
|
<span>{data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-note">
|
||||||
|
Tickets are read from the typed Ticket backend. This surface is read-only; creation and queue operations remain outside the browser UI for now.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if data.tickets.data}
|
||||||
|
{#if data.tickets.data.items.length === 0}
|
||||||
|
<p>No Ticket records are present.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="ticket-list" aria-label="Workspace Tickets">
|
||||||
|
{#each data.tickets.data.items as ticket (ticket.id)}
|
||||||
|
<a class="ticket-row" href={workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)}>
|
||||||
|
<div class="ticket-main">
|
||||||
|
<div class="ticket-title-row">
|
||||||
|
<strong class="ticket-title">{ticket.title}</strong>
|
||||||
|
<span class="state-pill">{ticket.state}</span>
|
||||||
|
</div>
|
||||||
|
<p class="ticket-summary">
|
||||||
|
{ticket.priority ? `${ticket.priority} priority` : 'priority unspecified'} · {ticket.record_source ?? 'ticket backend'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ticket-meta" aria-label="Ticket metadata">
|
||||||
|
<span>Updated {ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'}</span>
|
||||||
|
{#if ticket.queued_at}
|
||||||
|
<span>Queued {formatDate(ticket.queued_at)}</span>
|
||||||
|
{/if}
|
||||||
|
<code>{ticket.id}</code>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.tickets.data.invalid_records.length > 0}
|
||||||
|
<p class="error">{data.tickets.data.invalid_records.length} invalid Ticket record(s) hidden.</p>
|
||||||
|
{/if}
|
||||||
|
{:else if data.tickets.error}
|
||||||
|
<p class="error">{data.tickets.error}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
15
web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts
Normal file
15
web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load = (async ({ fetch, params }) => {
|
||||||
|
const tickets = await loadJson<TicketListResponse>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(params.workspaceId, "/tickets"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
tickets,
|
||||||
|
};
|
||||||
|
}) satisfies PageLoad;
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.ticket.data?.title ?? data.ticketId} · Tickets · Yoi Workspace</title>
|
||||||
|
<meta name="description" content="Workspace Ticket detail" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<p class="breadcrumb"><a href={workspaceRoute(data.workspaceId, '/tickets')}>Tickets</a> / {data.ticketId}</p>
|
||||||
|
|
||||||
|
{#if data.ticket.data}
|
||||||
|
<div class="detail-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{data.ticket.data.id}</p>
|
||||||
|
<h2>{data.ticket.data.title}</h2>
|
||||||
|
</div>
|
||||||
|
<span class="state-pill">{data.ticket.data.state}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="ticket-detail-grid">
|
||||||
|
<div>
|
||||||
|
<dt>Priority</dt>
|
||||||
|
<dd>{data.ticket.data.priority ?? 'unspecified'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Updated</dt>
|
||||||
|
<dd>{data.ticket.data.updated_at ? formatDate(data.ticket.data.updated_at) : 'unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Created</dt>
|
||||||
|
<dd>{data.ticket.data.created_at ? formatDate(data.ticket.data.created_at) : 'unknown'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Events</dt>
|
||||||
|
<dd>{data.ticket.data.event_count}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Artifacts</dt>
|
||||||
|
<dd>{data.ticket.data.artifact_count}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Source</dt>
|
||||||
|
<dd>{data.ticket.data.record_source}</dd>
|
||||||
|
</div>
|
||||||
|
{#if data.ticket.data.queued_at || data.ticket.data.queued_by}
|
||||||
|
<div>
|
||||||
|
<dt>Queued</dt>
|
||||||
|
<dd>
|
||||||
|
{data.ticket.data.queued_at ? formatDate(data.ticket.data.queued_at) : 'queued'}{data.ticket.data.queued_by ? ` by ${data.ticket.data.queued_by}` : ''}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{#if data.ticket.data.risk_flags.length > 0}
|
||||||
|
<div class="risk-flags" aria-label="Risk flags">
|
||||||
|
{#each data.ticket.data.risk_flags as flag}
|
||||||
|
<span>{flag}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="ticket-body" aria-labelledby="ticket-body-heading">
|
||||||
|
<div class="detail-heading compact">
|
||||||
|
<h3 id="ticket-body-heading">Body</h3>
|
||||||
|
{#if data.ticket.data.body_truncated}
|
||||||
|
<span class="warning-pill">truncated</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<pre>{data.ticket.data.body || 'No body text is available.'}</pre>
|
||||||
|
</section>
|
||||||
|
{:else if data.ticket.error}
|
||||||
|
<p class="error">{data.ticket.error}</p>
|
||||||
|
{:else}
|
||||||
|
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets/{data.ticketId}</code>…</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import type { TicketDetail } from "$lib/workspace/sidebar/types";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load = (async ({ fetch, params }) => {
|
||||||
|
const ticketId = params.ticketId;
|
||||||
|
const ticket = await loadJson<TicketDetail>(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(ticketId)}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
ticketId,
|
||||||
|
ticket,
|
||||||
|
};
|
||||||
|
}) satisfies PageLoad;
|
||||||
Loading…
Reference in New Issue
Block a user