web: add workspace ticket routes

This commit is contained in:
2026-07-23 02:44:20 +09:00
parent 681034db1e
commit 7dda898830
28 changed files with 397 additions and 114 deletions
@@ -1,8 +1,12 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
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>
<svelte:head>
@@ -39,12 +43,17 @@
</section>
<section class="workspace-actions" aria-label="Workspace sections">
<a class="workspace-action-card" href={`/w/${workspaceId}/runtimes`}>
<span>Runtimes</span>
<strong>Manage runtimes and workdirs</strong>
<a class="workspace-action-card" href={ticketsHref}>
<span>Tickets</span>
<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>
</a>
<a class="workspace-action-card" href={`/w/${workspaceId}/workers`}>
<a class="workspace-action-card" href={workersHref}>
<span>Workers</span>
<strong>Open worker list</strong>
<small>Inspect status and attach to consoles</small>
@@ -10,15 +10,15 @@
</script>
<svelte:head>
<title>Runtimes · Yoi Workspace</title>
<meta name="description" content="Workspace Runtimes" />
<title>Runtime Inventory · Settings · Yoi Workspace</title>
<meta name="description" content="Workspace Runtime inventory" />
</svelte:head>
<section class="runtimes-page" aria-labelledby="runtimes-heading">
<header class="page-header-row">
<div>
<h1 id="runtimes-heading">Runtimes</h1>
<p>Execution backends available to this workspace.</p>
<h1 id="runtimes-heading">Runtime Inventory</h1>
<p>Admin view of execution backends available to this workspace.</p>
</div>
</header>
@@ -53,7 +53,7 @@
<td>{runtimePlatform(runtime)}</td>
<td>{runtime.capabilities.max_workers} workers</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
</a>
</td>
@@ -105,7 +105,7 @@
<section class="workdirs-page" aria-labelledby="workdirs-heading">
<header class="page-header-row">
<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>
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
{#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>
@@ -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;