@@ -49,8 +323,84 @@ export function OrganizationActivityWorkloadsTab() {
workloads={workloads}
query={workloadsQuery}
showRunnerColumn
+ showDuration
+ showSearch={false}
+ rowLinkMode="row"
+ getWorkloadLink={(workload) => {
+ const workloadId = workload.meta?.id;
+ if (!workloadId) return null;
+ return `/organizations/${organizationId}/workloads/${workloadId}`;
+ }}
+ getAgentName={(workload) => workload.agentName || ''}
+ getRunnerName={(workload) => workload.runnerName || ''}
+ getAgentLink={(workload) =>
+ workload.agentId ? `/organizations/${organizationId}/agents/${workload.agentId}` : null
+ }
+ getRunnerLink={(workload) =>
+ workload.runnerId ? `/organizations/${organizationId}/runners/${workload.runnerId}` : null
+ }
+ agentLabel="Agent"
+ runnerLabel="Runner"
+ controls={{
+ sortKey,
+ sortDirection,
+ onSort: handleSort,
+ }}
+ filterBar={
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ }
+ hasActiveFilters={hasActiveFilters}
testIdPrefix="organization-workloads"
/>
+ {rangeError ?
{rangeError}
: null}
);
}
diff --git a/src/pages/OrganizationOverviewTab.tsx b/src/pages/OrganizationOverviewTab.tsx
index cf70fcc..2a981ef 100644
--- a/src/pages/OrganizationOverviewTab.tsx
+++ b/src/pages/OrganizationOverviewTab.tsx
@@ -1,3 +1,4 @@
+import { useMemo } from 'react';
import { NavLink, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { agentsClient, appsClient, organizationsClient, runnersClient, secretsClient } from '@/api/client';
@@ -13,12 +14,16 @@ export function OrganizationOverviewTab() {
const { id } = useParams();
const organizationId = id ?? '';
+ const notificationRooms = useMemo(
+ () => (organizationId ? [`organization:${organizationId}`] : []),
+ [organizationId],
+ );
useNotifications({
- rooms: organizationId ? [`organization:${organizationId}`] : [],
events: ['workload.updated'],
invalidateKeys: [['workloads', organizationId, 'overview']],
- enabled: Boolean(organizationId),
+ rooms: notificationRooms,
+ enabled: Boolean(organizationId) && notificationRooms.length > 0,
});
const membersQuery = useQuery({
diff --git a/src/pages/OrganizationThreadsTab.tsx b/src/pages/OrganizationThreadsTab.tsx
index 7a1ccf4..4c860e7 100644
--- a/src/pages/OrganizationThreadsTab.tsx
+++ b/src/pages/OrganizationThreadsTab.tsx
@@ -1,32 +1,193 @@
-import { useMemo } from 'react';
+import { useMemo, useState } from 'react';
import { NavLink, useParams } from 'react-router-dom';
import { Code, ConnectError } from '@connectrpc/connect';
-import { useInfiniteQuery } from '@tanstack/react-query';
+import { create } from '@bufbuild/protobuf';
+import { TimestampSchema, type Timestamp } from '@bufbuild/protobuf/wkt';
+import { useInfiniteQuery, useQueryClient, type InfiniteData } from '@tanstack/react-query';
import { threadsClient } from '@/api/client';
import { LoadMoreButton } from '@/components/LoadMoreButton';
+import { MultiSelectFilter } from '@/components/MultiSelectFilter';
+import { SortableHeader } from '@/components/SortableHeader';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
-import { ThreadStatus } from '@/gen/agynio/api/threads/v1/threads_pb';
+import { Input } from '@/components/ui/input';
+import {
+ ListOrganizationThreadsSortField,
+ SortDirection as ThreadsSortDirection,
+ type Thread,
+ ThreadStatus,
+} from '@/gen/agynio/api/threads/v1/threads_pb';
+import type { NotificationEnvelope } from '@/gen/agynio/api/notifications/v1/notifications_pb';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
-import { useIdentityHandles } from '@/hooks/useIdentityHandles';
+import { useNotifications } from '@/hooks/useNotifications';
+import { type SortDirection } from '@/hooks/useListControls';
+import { useUserContext } from '@/context/UserContext';
import { EMPTY_PLACEHOLDER, formatDateOnly, formatThreadStatus, truncate } from '@/lib/format';
import { DEFAULT_PAGE_SIZE } from '@/lib/pagination';
+type ThreadSortKey = 'status' | 'messages' | 'created' | 'updated';
+
+const THREAD_STATUS_OPTIONS = [ThreadStatus.ACTIVE, ThreadStatus.ARCHIVED, ThreadStatus.DEGRADED];
+
+type ThreadsPage = {
+ threads: Thread[];
+ nextPageToken?: string;
+};
+
+const parseDateInput = (value: string, isEnd = false): Date | null => {
+ if (!value) return null;
+ const [year, month, day] = value.split('-').map((segment) => Number(segment));
+ if (!year || !month || !day) return null;
+ const date = new Date(year, month - 1, day);
+ if (isEnd) {
+ date.setHours(23, 59, 59, 999);
+ } else {
+ date.setHours(0, 0, 0, 0);
+ }
+ return date;
+};
+
+const toTimestamp = (date: Date): Timestamp =>
+ create(TimestampSchema, {
+ seconds: BigInt(Math.floor(date.getTime() / 1000)),
+ nanos: 0,
+ });
+
+const formatNickname = (nickname?: string) => {
+ const trimmed = nickname?.trim();
+ if (!trimmed) return '';
+ return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;
+};
+
+const extractThreadId = (payload?: NotificationEnvelope['payload']): string | null => {
+ if (!payload) return null;
+ const resolveString = (value: unknown): string | null =>
+ typeof value === 'string' && value.trim().length > 0 ? value : null;
+ const direct = resolveString(payload.threadId ?? payload.thread_id ?? payload.id);
+ if (direct) return direct;
+ const thread = payload.thread;
+ if (!thread || typeof thread !== 'object' || Array.isArray(thread)) return null;
+ const threadRecord = thread as Record
;
+ return resolveString(threadRecord.threadId ?? threadRecord.thread_id ?? threadRecord.id);
+};
+
+const resetPagination = (
+ _data: InfiniteData | undefined,
+ firstPage: TPage,
+): InfiniteData => ({ pages: [firstPage], pageParams: [''] });
+
+const upsertThread = (
+ data: InfiniteData | undefined,
+ thread: Thread,
+): InfiniteData | undefined => {
+ if (!data) return data;
+ if (!thread.id) return data;
+
+ let found = false;
+ const nextPages = data.pages.map((page) => {
+ const nextThreads = page.threads.map((item) => {
+ if (item.id === thread.id) {
+ found = true;
+ return thread;
+ }
+ return item;
+ });
+ return { ...page, threads: nextThreads };
+ });
+
+ if (!found && nextPages.length > 0) {
+ const firstPage = nextPages[0];
+ const withoutDuplicate = firstPage.threads.filter((item) => item.id !== thread.id);
+ const nextThreads = [thread, ...withoutDuplicate].slice(0, DEFAULT_PAGE_SIZE);
+ nextPages[0] = { ...firstPage, threads: nextThreads };
+ }
+
+ return { ...data, pages: nextPages };
+};
+
export function OrganizationThreadsTab() {
useDocumentTitle('Threads');
const { id } = useParams();
const organizationId = id ?? '';
+ const { identityId } = useUserContext();
+ const queryClient = useQueryClient();
+ const [participantFilter, setParticipantFilter] = useState([]);
+ const [statusFilter, setStatusFilter] = useState([]);
+ const [createdAfter, setCreatedAfter] = useState('');
+ const [createdBefore, setCreatedBefore] = useState('');
+ const [sortKey, setSortKey] = useState('created');
+ const [sortDirection, setSortDirection] = useState('desc');
+
+ const notificationRooms = useMemo(
+ () => (identityId ? [`thread_participant:${identityId}`] : []),
+ [identityId],
+ );
+
+ const { rangeError, startDate, endDate } = useMemo(() => {
+ const parsedStart = parseDateInput(createdAfter, false);
+ const parsedEnd = parseDateInput(createdBefore, true);
+ if (parsedStart && parsedEnd && parsedStart > parsedEnd) {
+ return { rangeError: 'Start date must be before end date.', startDate: parsedStart, endDate: parsedEnd };
+ }
+ return { rangeError: '', startDate: parsedStart, endDate: parsedEnd };
+ }, [createdAfter, createdBefore]);
- const threadsQuery = useInfiniteQuery({
- queryKey: ['threads', organizationId, 'list'],
- queryFn: ({ pageParam }) =>
- threadsClient.getOrganizationThreads({
- organizationId,
- pageSize: DEFAULT_PAGE_SIZE,
- pageToken: pageParam,
- status: ThreadStatus.UNSPECIFIED,
- }),
+ const filterKey = useMemo(
+ () => ({ participants: participantFilter, status: statusFilter, createdAfter, createdBefore }),
+ [participantFilter, statusFilter, createdAfter, createdBefore],
+ );
+ const sortSpec = useMemo(() => {
+ const fieldMap: Record = {
+ status: ListOrganizationThreadsSortField.STATUS,
+ messages: ListOrganizationThreadsSortField.MESSAGE_COUNT,
+ created: ListOrganizationThreadsSortField.CREATED,
+ updated: ListOrganizationThreadsSortField.UPDATED,
+ };
+ return {
+ field: fieldMap[sortKey],
+ direction: sortDirection === 'asc' ? ThreadsSortDirection.ASC : ThreadsSortDirection.DESC,
+ };
+ }, [sortDirection, sortKey]);
+ const statusValues = useMemo(
+ () => statusFilter.map((value) => Number(value) as ThreadStatus).filter((value) => value > 0),
+ [statusFilter],
+ );
+
+ const filterSpec = useMemo(() => {
+ const createdAfterValue = rangeError ? undefined : startDate ? toTimestamp(startDate) : undefined;
+ const createdBeforeValue = rangeError ? undefined : endDate ? toTimestamp(endDate) : undefined;
+ const hasFilters =
+ participantFilter.length > 0 ||
+ statusValues.length > 0 ||
+ createdAfterValue !== undefined ||
+ createdBeforeValue !== undefined;
+ if (!hasFilters) return undefined;
+ return {
+ participantIdIn: participantFilter,
+ statusIn: statusValues,
+ createdAfter: createdAfterValue,
+ createdBefore: createdBeforeValue,
+ };
+ }, [participantFilter, statusValues, startDate, endDate, rangeError]);
+
+ const listThreadsQueryKey = useMemo(
+ () => ['threads', organizationId, 'list', filterKey, sortSpec] as const,
+ [filterKey, organizationId, sortSpec],
+ );
+
+ const fetchListThreadsPage = (pageToken: string): Promise =>
+ threadsClient.listOrganizationThreads({
+ organizationId,
+ pageSize: DEFAULT_PAGE_SIZE,
+ pageToken,
+ filter: filterSpec,
+ sort: sortSpec,
+ });
+
+ const listThreadsQuery = useInfiniteQuery({
+ queryKey: listThreadsQueryKey,
+ queryFn: ({ pageParam }) => fetchListThreadsPage(pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined,
enabled: Boolean(organizationId),
@@ -35,68 +196,199 @@ export function OrganizationThreadsTab() {
});
const threads = useMemo(
- () => threadsQuery.data?.pages.flatMap((page) => page.threads) ?? [],
- [threadsQuery.data?.pages],
+ () => listThreadsQuery.data?.pages.flatMap((page) => page.threads) ?? [],
+ [listThreadsQuery.data?.pages],
);
- const isLoading = threadsQuery.isPending;
- const isError = threadsQuery.isError;
+ const visibleThreads = threads;
+ const isLoading = listThreadsQuery.isPending;
+ const isError = listThreadsQuery.isError;
const isPermissionDenied =
- threadsQuery.error instanceof ConnectError && threadsQuery.error.code === Code.PermissionDenied;
+ listThreadsQuery.error instanceof ConnectError && listThreadsQuery.error.code === Code.PermissionDenied;
- const identityIds = useMemo(() => {
- const ids = new Set();
+ const participantOptions = useMemo(() => {
+ const participantMap = new Map();
threads.forEach((thread) => {
thread.participants.forEach((participant) => {
- if (participant.id) ids.add(participant.id);
+ if (!participant.id) return;
+ const nickname = formatNickname(participant.nickname);
+ if (!nickname) return;
+ const label = nickname;
+ participantMap.set(participant.id, {
+ value: participant.id,
+ label,
+ });
});
});
- return Array.from(ids);
+ return Array.from(participantMap.values()).sort((left, right) => left.label.localeCompare(right.label));
}, [threads]);
- const { formatHandle } = useIdentityHandles(identityIds);
+ const statusOptions = useMemo(
+ () =>
+ THREAD_STATUS_OPTIONS.map((status) => ({
+ value: String(status),
+ label: formatThreadStatus(status),
+ })),
+ [],
+ );
+
+ const hasActiveFilters =
+ participantFilter.length > 0 ||
+ statusFilter.length > 0 ||
+ createdAfter.length > 0 ||
+ createdBefore.length > 0;
+ const hasActiveControls = hasActiveFilters || sortKey !== 'created' || sortDirection !== 'desc';
+ const handleSort = (key: ThreadSortKey) => {
+ if (key === sortKey) {
+ setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
+ return;
+ }
+ setSortKey(key);
+ setSortDirection('asc');
+ };
+
+ useNotifications({
+ events: ['message.created'],
+ rooms: notificationRooms,
+ enabled: Boolean(organizationId) && notificationRooms.length > 0,
+ onEvent: (envelope) => {
+ if (hasActiveControls) {
+ void (async () => {
+ try {
+ const firstPage = await fetchListThreadsPage('');
+ queryClient.setQueryData>(listThreadsQueryKey, (data) =>
+ resetPagination(data, firstPage),
+ );
+ } catch (error) {
+ console.error('[useNotifications] thread refetch error:', error);
+ }
+ })();
+ return;
+ }
+
+ const threadId = extractThreadId(envelope.payload);
+ if (!threadId) return;
+ void (async () => {
+ try {
+ const response = await threadsClient.getThread({ threadId });
+ const thread = response.thread;
+ if (!thread) return;
+ queryClient.setQueryData>(listThreadsQueryKey, (data) =>
+ upsertThread(data, thread),
+ );
+ } catch (error) {
+ console.error('[useNotifications] thread update error:', error);
+ }
+ })();
+ },
+ });
return (
+
+ {rangeError ?
{rangeError}
: null}
{isLoading ?
Loading threads...
: null}
{isError ? (
{isPermissionDenied ? 'You do not have permission to view threads.' : 'Failed to load threads.'}
) : null}
- {threads.length === 0 && !isLoading && !isError ? (
+ {visibleThreads.length === 0 && !isLoading && !isError ? (
- No threads yet.
+ {hasActiveFilters ? 'No results found.' : 'No threads yet.'}
) : null}
- {threads.length > 0 ? (
+ {visibleThreads.length > 0 ? (
-
+
Thread
Participants
- Status
- Messages
- Created
+
+
+
+
- {threads.map((thread) => {
+ {visibleThreads.map((thread) => {
const threadId = thread.id;
const messageCount = thread.messageCount ?? 0;
const participantHandles = thread.participants
- .map((participant) => formatHandle(participant.id))
- .filter((handle) => handle !== EMPTY_PLACEHOLDER);
- const participantsLabel =
- participantHandles.length > 0
- ? truncate(participantHandles.join(', '), 60)
- : EMPTY_PLACEHOLDER;
+ .map((participant) => formatNickname(participant.nickname) || participant.id)
+ .filter((handle) => handle);
+ const participantsLabel = participantHandles.length > 0
+ ? truncate(participantHandles.join(', '), 60)
+ : EMPTY_PLACEHOLDER;
return (
@@ -120,6 +412,9 @@ export function OrganizationThreadsTab() {
{formatDateOnly(thread.createdAt)}
+
+ {formatDateOnly(thread.updatedAt)}
+
);
})}
@@ -128,9 +423,11 @@ export function OrganizationThreadsTab() {
) : null}
threadsQuery.fetchNextPage()}
+ hasMore={listThreadsQuery.hasNextPage}
+ isLoading={listThreadsQuery.isFetchingNextPage}
+ onClick={() => {
+ void listThreadsQuery.fetchNextPage();
+ }}
/>
);
diff --git a/src/pages/RunnerDetailPage.tsx b/src/pages/RunnerDetailPage.tsx
index 409811f..5f9423e 100644
--- a/src/pages/RunnerDetailPage.tsx
+++ b/src/pages/RunnerDetailPage.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { runnersClient } from '@/api/client';
@@ -38,15 +38,6 @@ export function RunnerDetailPage() {
const [labelEntries, setLabelEntries] = useState([]);
const [runnerName, setRunnerName] = useState('');
const [runnerNameError, setRunnerNameError] = useState('');
- const notificationRooms = organizationId ? [`organization:${organizationId}`] : [];
-
- useNotifications({
- rooms: notificationRooms,
- events: ['workload.updated'],
- invalidateKeys: [['workloads', 'runner', runnerId]],
- enabled: Boolean(runnerId && organizationId),
- });
-
const runnerQuery = useQuery({
queryKey: ['runners', runnerId],
queryFn: () => runnersClient.getRunner({ id: runnerId }),
@@ -56,6 +47,19 @@ export function RunnerDetailPage() {
});
const runner = runnerQuery.data?.runner;
+ const notificationRooms = useMemo(() => {
+ const rooms = new Set();
+ if (organizationId) rooms.add(`organization:${organizationId}`);
+ if (runner?.organizationId) rooms.add(`organization:${runner.organizationId}`);
+ return Array.from(rooms);
+ }, [organizationId, runner?.organizationId]);
+
+ useNotifications({
+ events: ['workload.updated'],
+ invalidateKeys: [['workloads', 'runner', runnerId]],
+ rooms: notificationRooms,
+ enabled: Boolean(runnerId) && notificationRooms.length > 0,
+ });
const isOrgRunner = Boolean(organizationId) && runner?.organizationId === organizationId;
const canManageRunner = !isOrgContext || isOrgRunner;
diff --git a/src/pages/VolumeDetailPage.tsx b/src/pages/VolumeDetailPage.tsx
new file mode 100644
index 0000000..a7dae53
--- /dev/null
+++ b/src/pages/VolumeDetailPage.tsx
@@ -0,0 +1,214 @@
+import { useMemo } from 'react';
+import { NavLink, useLocation, useParams } from 'react-router-dom';
+import { Code, ConnectError } from '@connectrpc/connect';
+import { useQuery } from '@tanstack/react-query';
+import { runnersClient } from '@/api/client';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { AttachmentKind, type Attachment, VolumeStatus } from '@/gen/agynio/api/runners/v1/runners_pb';
+import { useDocumentTitle } from '@/hooks/useDocumentTitle';
+import { useNotifications } from '@/hooks/useNotifications';
+import { EMPTY_PLACEHOLDER, formatTimestamp, formatVolumeStatus, truncate } from '@/lib/format';
+
+const ATTACHMENT_KIND_LABELS: Record = {
+ [AttachmentKind.UNSPECIFIED]: 'Attachment',
+ [AttachmentKind.AGENT]: 'Agent',
+ [AttachmentKind.MCP]: 'MCP',
+ [AttachmentKind.HOOK]: 'Hook',
+};
+
+const formatAttachmentLabel = (attachment: Attachment) => {
+ const name = attachment.name?.trim() || attachment.id || '';
+ if (!name) return EMPTY_PLACEHOLDER;
+ const kindLabel = ATTACHMENT_KIND_LABELS[attachment.kind] ?? 'Attachment';
+ return kindLabel === 'Attachment' ? name : `${kindLabel} ${name}`;
+};
+
+const getStatusVariant = (status: VolumeStatus) => {
+ if (status === VolumeStatus.ACTIVE) return 'default';
+ if (status === VolumeStatus.PROVISIONING) return 'secondary';
+ if (status === VolumeStatus.FAILED) return 'destructive';
+ return 'outline';
+};
+
+export function VolumeDetailPage() {
+ const { id: organizationIdParam, volumeId: volumeIdParam } = useParams();
+ const organizationId = organizationIdParam ?? '';
+ const volumeId = volumeIdParam ?? '';
+ const location = useLocation();
+
+ const notificationRooms = useMemo(() => {
+ const rooms: string[] = [];
+ if (organizationId) rooms.push(`organization:${organizationId}`);
+ if (volumeId) rooms.push(`volume:${volumeId}`);
+ return rooms;
+ }, [organizationId, volumeId]);
+
+ useNotifications({
+ events: ['volume.updated'],
+ invalidateKeys: [['volumes', volumeId, 'detail']],
+ rooms: notificationRooms,
+ enabled: Boolean(volumeId) && notificationRooms.length > 0,
+ });
+
+ const volumeQuery = useQuery({
+ queryKey: ['volumes', volumeId, 'detail'],
+ queryFn: () => runnersClient.getVolume({ id: volumeId }),
+ enabled: Boolean(volumeId),
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+ });
+
+ const volume = volumeQuery.data?.volume ?? null;
+ const isNotFoundError = volumeQuery.error instanceof ConnectError && volumeQuery.error.code === Code.NotFound;
+ const isOrgMismatch = Boolean(volume && organizationId && volume.organizationId !== organizationId);
+ const isMissing = !volume && !volumeQuery.isPending && !volumeQuery.isError;
+ const showNotFound = isNotFoundError || isOrgMismatch || isMissing;
+ const showError = volumeQuery.isError && !isNotFoundError;
+
+ const volumeTitle = volume?.volumeName || volume?.volumeId ? `Volume ${truncate(volume.volumeName || volume.volumeId, 18)}` : 'Volume';
+ useDocumentTitle(volumeTitle);
+
+ const fromState =
+ typeof location.state === 'object' &&
+ location.state !== null &&
+ 'from' in location.state &&
+ typeof (location.state as { from?: unknown }).from === 'string'
+ ? (location.state as { from: string }).from
+ : undefined;
+ const fallbackBack = organizationId ? `/organizations/${organizationId}/activity/storage` : '/organizations';
+ const backHref = fromState || fallbackBack;
+ const backLabel = fromState ? '← Back' : organizationId ? '← Back to Storage' : '← Back';
+
+ const volumeName = volume?.volumeName || volume?.volumeId || volume?.meta?.id || EMPTY_PLACEHOLDER;
+ const volumeIdLabel = volume?.volumeId || volume?.meta?.id || EMPTY_PLACEHOLDER;
+ const sizeLabel = volume?.sizeGb ? `${volume.sizeGb} GB` : EMPTY_PLACEHOLDER;
+ const runnerId = volume?.runnerId || '';
+ const agentId = volume?.agentId || '';
+ const runnerLink = organizationId && runnerId ? `/organizations/${organizationId}/runners/${runnerId}` : '';
+ const agentLink = organizationId && agentId ? `/organizations/${organizationId}/agents/${agentId}` : '';
+ const threadLink = organizationId && volume?.threadId ? `/organizations/${organizationId}/threads/${volume.threadId}` : '';
+ const attachments = volume?.attachments ?? [];
+
+ return (
+
+
+
+
+ {volumeQuery.isPending ?
Loading volume...
: null}
+ {showError ?
Failed to load volume.
: null}
+ {showNotFound ?
Volume not found.
: null}
+ {volume && !showNotFound ? (
+
+
+
+
+
Details
+
Identifiers and storage status.
+
+
+
+
+
Volume ID
+
{volumeIdLabel}
+
+
+
Status
+
{formatVolumeStatus(volume.status)}
+
+
+
+
Organization ID
+
{volume.organizationId || EMPTY_PLACEHOLDER}
+
+
+
Runner
+
+ {runnerLink ? (
+
+ {runnerId || EMPTY_PLACEHOLDER}
+
+ ) : (
+ runnerId || EMPTY_PLACEHOLDER
+ )}
+
+
+
+
Agent
+
+ {agentLink ? (
+
+ {agentId || EMPTY_PLACEHOLDER}
+
+ ) : (
+ agentId || EMPTY_PLACEHOLDER
+ )}
+
+
+
+
Thread
+
+ {threadLink ? (
+
+ {truncate(volume.threadId, 18)}
+
+ ) : (
+ truncate(volume.threadId, 18)
+ )}
+
+
+
+
Instance ID
+
{volume.instanceId || EMPTY_PLACEHOLDER}
+
+
+
Created
+
{formatTimestamp(volume.meta?.createdAt)}
+
+
+
Removed
+
{formatTimestamp(volume.removedAt)}
+
+
+
Last Metering Sample
+
{formatTimestamp(volume.lastMeteringSampledAt)}
+
+
+
+
+
+
+
+
Attachments
+
Active attachment targets for this volume.
+
+ {attachments.length === 0 ? (
+ No attachments reported.
+ ) : (
+
+ {attachments.map((attachment) => {
+ const label = formatAttachmentLabel(attachment);
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/pages/WorkloadDetailPage.tsx b/src/pages/WorkloadDetailPage.tsx
index 264819d..249259e 100644
--- a/src/pages/WorkloadDetailPage.tsx
+++ b/src/pages/WorkloadDetailPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { NavLink, useLocation, useParams } from 'react-router-dom';
import { Code, ConnectError } from '@connectrpc/connect';
import { useQuery } from '@tanstack/react-query';
@@ -14,6 +14,7 @@ import { useNotifications } from '@/hooks/useNotifications';
import {
EMPTY_PLACEHOLDER,
formatContainerStatus,
+ formatDurationBetween,
formatTimestamp,
formatWorkloadStatus,
truncate,
@@ -254,11 +255,18 @@ export function WorkloadDetailPage() {
const workloadId = workloadIdParam ?? '';
const location = useLocation();
+ const notificationRooms = useMemo(() => {
+ const rooms: string[] = [];
+ if (organizationId) rooms.push(`organization:${organizationId}`);
+ if (workloadId) rooms.push(`workload:${workloadId}`);
+ return rooms;
+ }, [organizationId, workloadId]);
+
useNotifications({
- rooms: workloadId ? [`workload:${workloadId}`] : [],
events: ['workload.status_changed', 'workload.updated'],
invalidateKeys: [['workloads', workloadId, 'detail']],
- enabled: Boolean(workloadId),
+ rooms: notificationRooms,
+ enabled: Boolean(workloadId) && notificationRooms.length > 0,
});
const workloadQuery = useQuery({
@@ -328,6 +336,21 @@ export function WorkloadDetailPage() {
: '← Back to Runners';
const workloadIdLabel = workload?.meta?.id ?? EMPTY_PLACEHOLDER;
+ const agentName = workload?.agentName?.trim();
+ const runnerName = workload?.runnerName?.trim();
+ const agentId = workload?.agentId ?? '';
+ const runnerId = workload?.runnerId ?? '';
+ const agentLink = organizationId && agentId && agentName ? `/organizations/${organizationId}/agents/${agentId}` : '';
+ const runnerLink = organizationId && runnerId && runnerName ? `/organizations/${organizationId}/runners/${runnerId}` : '';
+ const agentLabel = agentName || EMPTY_PLACEHOLDER;
+ const runnerLabel = runnerName || EMPTY_PLACEHOLDER;
+ const durationEnd = workload
+ ? workload.removedAt ??
+ (workload.status === WorkloadStatus.STOPPED || workload.status === WorkloadStatus.FAILED
+ ? workload.lastActivityAt
+ : undefined)
+ : undefined;
+ const durationLabel = workload ? formatDurationBetween(workload.meta?.createdAt, durationEnd) : EMPTY_PLACEHOLDER;
const allocatedCpu = workload ? `${workload.allocatedCpuMillicores.toLocaleString()} m` : EMPTY_PLACEHOLDER;
const allocatedRam = workload ? `${workload.allocatedRamBytes.toString()} bytes` : EMPTY_PLACEHOLDER;
@@ -363,16 +386,32 @@ export function WorkloadDetailPage() {
{workload.organizationId || EMPTY_PLACEHOLDER}
-
Runner ID
-
{workload.runnerId || EMPTY_PLACEHOLDER}
+
Runner
+
+ {runnerLink ? (
+
+ {runnerLabel}
+
+ ) : (
+ runnerLabel
+ )}
+
Thread ID
{workload.threadId || EMPTY_PLACEHOLDER}
-
Agent ID
-
{workload.agentId || EMPTY_PLACEHOLDER}
+
Agent
+
+ {agentLink ? (
+
+ {agentLabel}
+
+ ) : (
+ agentLabel
+ )}
+
Instance ID
@@ -386,6 +425,10 @@ export function WorkloadDetailPage() {
Created
{formatTimestamp(workload.meta?.createdAt)}
+
+
Duration
+
{durationLabel}
+
Last Activity
{formatTimestamp(workload.lastActivityAt)}