Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ openapi:
--output "$$output" \
--outputTypes yaml --parseInternal; \
python3 scripts/patch-agents-openapi.py "$$output/swagger.yaml"; \
go run ./scripts/openapi-split $(OPENAPI_FLAGS) "$$output/swagger.yaml" "$$output/extensions.json" contracts/agents-api/core.openapi.yaml contracts/agents-api/runtime.openapi.yaml; \
python3 scripts/generate-public-api.py $(OPENAPI_FLAGS) --extensions "$$output/extensions.json"
go run ./scripts/openapi-split $(OPENAPI_FLAGS) "$$output/swagger.yaml" "$$output/extensions.json" "$$output/core.json" contracts/agents-api/core.openapi.yaml contracts/agents-api/runtime.openapi.yaml; \
python3 scripts/generate-public-api.py $(OPENAPI_FLAGS) --extensions "$$output/extensions.json" --core "$$output/core.json"

check-sqlc:
python3 scripts/check-sqlc.py
Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/features/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ import { useAgentDetail } from "../resources/detail-queries";


function coverage(summary: ProjectSummary | undefined, locale?: string): string {
if (!summary || summary.coverage.sessions === 0) return MISSING;
return formatPercent(summary.coverage.reported / summary.coverage.sessions, locale);
return summary ? formatPercent(summary.coverage.ratio, locale) : MISSING;
}

/**
Expand Down
7 changes: 3 additions & 4 deletions apps/web/src/features/api-keys/KeyFlowDialogs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@ import type { ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";

import { AgentCoreError } from "@oac/agents-client";
import { type AdminIssuedAPIKey, type AdminProject, AgentCoreError } from "@oac/agents-client";
import { flowError, keyFlowReducer, type KeyFlow } from "./key-flows";
import { KeyFlowDialogs, PendingKeyNotice } from "./KeyFlowDialogs";
import type { KeyFlowControls } from "./use-key-flow";
import { type AdminIssuedKey, type Project } from "../../lib/admin-view";

const project: Project = { id: "proj_7f3a91c2", name: "Production", status: "active", created_at: 100, archived_at: null, active_key_count: 1 };
const project: AdminProject = { id: "proj_7f3a91c2", name: "Production", created_at: "1970-01-01T00:01:40Z", archived_at: null, active_key_count: 1 };
const secret = "pc_live_" + "s".repeat(40);
const issued: AdminIssuedKey = { id: "9f0e1d2c-3b4a-4c5d-8e6f-7a8b9c0d1e2f", project_id: "proj_7f3a91c2", name: "bob-laptop", prefix: "pc_live_Zq8", created_at: 300, revoked_at: null, key: secret };
const issued: AdminIssuedAPIKey = { id: "9f0e1d2c-3b4a-4c5d-8e6f-7a8b9c0d1e2f", project_id: "proj_7f3a91c2", name: "bob-laptop", prefix: "pc_live_Zq8", created_at: "1970-01-01T00:05:00Z", revoked_at: null, key: secret };
const controls = (flow: KeyFlow): KeyFlowControls => ({ flow, dispatch: () => undefined, submit: async () => undefined });
// The how-to-call card under a new key reads the installation through the query cache.
const render = (element: ReactElement) => renderToStaticMarkup(<QueryClientProvider client={new QueryClient()}>{element}</QueryClientProvider>);
Expand Down
29 changes: 15 additions & 14 deletions apps/web/src/features/api-keys/ProjectDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { AdminAPIKey, AdminProject } from "@oac/agents-client";
import { useQuery } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";


import { EmptyState, HelpTip, Kpi, KpiStrip, Section, StatusDot } from "../../components/console-ui";
import { CopyableId, RowActions } from "../../components/list-ui";
import { formatCompact, formatDateTime, formatInteger, formatRelative } from "../../lib/format";
import { epochSeconds, formatCompact, formatDateTime, formatInteger, formatRelative } from "../../lib/format";
import { useConsoleNavigation } from "../../lib/console-navigation";
import type { ConsoleView } from "../../lib/console-routes";
import { admin } from "../../lib/projects";
Expand All @@ -14,11 +15,11 @@ import { prefixLabel } from "./key-flows";
import { loadedFrom, projectKeysQuery, projectSummaryQuery, type Loaded } from "./project-queries";
import { ProjectStatus } from "./ProjectStatus";
import { WriteOperations } from "./WriteOperations";
import { type AdminKey, type Project, type ProjectSummary } from "../../lib/admin-view";
import { type ProjectSummary } from "../../lib/admin-view";
import { TableSkeleton } from "../../components/Skeleton";

/** A project's keys, active first, from the query cache. */
export function useProjectKeys(projectId: string | null): Loaded<AdminKey[]> & { retry: () => void } {
export function useProjectKeys(projectId: string | null): Loaded<AdminAPIKey[]> & { retry: () => void } {
const query = useQuery({ ...projectKeysQuery(projectId ?? ""), enabled: projectId !== null });
const { refetch } = query;
return { ...loadedFrom(query), retry: () => { void refetch(); } };
Expand All @@ -38,11 +39,11 @@ const assetLinks: ReadonlyArray<{ key: "agents" | "environment_templates" | "ski
* write-operation history.
*/
export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: {
project: Project;
project: AdminProject;
keys: ReturnType<typeof useProjectKeys>;
busy: boolean;
onIssue: () => void;
onRevoke: (key: AdminKey, activeCount: number) => void;
onRevoke: (key: AdminAPIKey, activeCount: number) => void;
}) {
const { t, i18n } = useTranslation("keys");
const { t: tCommon } = useTranslation("common");
Expand Down Expand Up @@ -73,8 +74,8 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: {
<dl className="resource-facts" aria-label={t("detail.facts")}>
<div><dt>{t("detail.id")}</dt><dd><CopyableId id={project.id} /></dd></div>
<div><dt>{t("detail.status")}</dt><dd><ProjectStatus project={project} /></dd></div>
<div><dt>{t("detail.created")}</dt><dd>{formatDateTime(project.created_at, locale)}</dd></div>
{project.archived_at !== null ? <div><dt>{t("detail.archived")}</dt><dd>{formatDateTime(project.archived_at, locale)}</dd></div> : null}
<div><dt>{t("detail.created")}</dt><dd>{formatDateTime(epochSeconds(project.created_at), locale)}</dd></div>
{project.archived_at !== null ? <div><dt>{t("detail.archived")}</dt><dd>{formatDateTime(epochSeconds(project.archived_at), locale)}</dd></div> : null}
<div>
<dt>{t("detail.lastActive")}</dt>
<dd title={summary?.last_active_at != null ? formatDateTime(summary.last_active_at, locale) : undefined}>{formatRelative(summary?.last_active_at, now, locale)}</dd>
Expand Down Expand Up @@ -117,7 +118,7 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: {
<Kpi
label={t("detail.usageFigures.coverage")}
help={t("detail.usageFigures.coverageHelp")}
value={summary && !pending ? t("detail.coverageValue", { reported: formatInteger(summary.coverage.reported, locale), total: formatInteger(summary.coverage.sessions, locale) }) : "—"}
value={summary && !pending ? t("detail.coverageValue", { reported: formatInteger(summary.coverage.measured_sessions, locale), total: formatInteger(summary.coverage.total_sessions, locale) }) : "—"}
/>
</KpiStrip>
</Section>
Expand All @@ -132,7 +133,7 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: {
</span>
) : null}</>}
help={t("detail.keysHelp")}
actions={project.status === "active" ? (
actions={project.archived_at === null ? (
<button className="button outline" type="button" onClick={onIssue} disabled={busy}>
<Plus size={14} aria-hidden="true" />{t("actions.issue")}
</button>
Expand All @@ -145,7 +146,7 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: {
) : !keys.value.length && !unknownUsage ? (
<EmptyState
title={t("detail.noKeys")}
action={project.status === "active" ? <button className="button outline" type="button" onClick={onIssue} disabled={busy}>{t("actions.issue")}</button> : undefined}
action={project.archived_at === null ? <button className="button outline" type="button" onClick={onIssue} disabled={busy}>{t("actions.issue")}</button> : undefined}
/>
) : (
<div className="table-frame">
Expand Down Expand Up @@ -177,13 +178,13 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: {
<td>
<span className="key-status">
<StatusDot tone={revoked ? "neutral" : "ok"} label={revoked ? t("detail.keyStatus.revoked") : t("detail.keyStatus.active")} />
{revoked ? <span className="key-status-date">{formatDateTime(key.revoked_at, locale)}</span> : null}
{revoked ? <span className="key-status-date">{formatDateTime(epochSeconds(key.revoked_at), locale)}</span> : null}
</span>
</td>
<td className="key-nowrap">{formatDateTime(key.created_at, locale)}</td>
<td className="key-nowrap">{formatDateTime(epochSeconds(key.created_at), locale)}</td>
{keyUsage(byKey?.get(key.id))}
<td className="actions-cell">
{!revoked && project.status === "active" ? (
{!revoked && project.archived_at === null ? (
<RowActions>
<button className="text-action danger" type="button" aria-label={t("actions.revokeLabel", { name: key.name })} disabled={busy} onClick={() => onRevoke(key, activeCount)}>
{t("actions.revoke")}
Expand All @@ -210,7 +211,7 @@ export function ProjectDetail({ project, keys, busy, onIssue, onRevoke }: {
)}
</Section>

{project.status === "active" ? <ProjectHowToCall /> : null}
{project.archived_at === null ? <ProjectHowToCall /> : null}

<WriteOperations projectId={project.id} keys={keys.value} />
</>
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/features/api-keys/ProjectStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { AdminProject } from "@oac/agents-client";
import { useTranslation } from "react-i18next";


import { StatusDot } from "../../components/console-ui";
import { type Project } from "../../lib/admin-view";

export function ProjectStatus({ project }: { project: Project }) {
export function ProjectStatus({ project }: { project: AdminProject }) {
const { t } = useTranslation("keys");
return project.status === "archived"
return project.archived_at !== null
? <StatusDot tone="neutral" label={t("status.archived")} />
: <StatusDot tone="ok" label={t("status.active")} />;
}
24 changes: 12 additions & 12 deletions apps/web/src/features/api-keys/ProjectsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AdminAPIKey, AdminProject } from "@oac/agents-client";
import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, FolderKanban, Plus } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
Expand All @@ -10,7 +11,7 @@ import { ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../
import { Modal } from "../../components/Modal";
import { useFailureToast } from "../../components/Toast";
import { useConsoleIntent, useConsoleNavigation } from "../../lib/console-navigation";
import { formatDateTime, formatInteger, formatRelative } from "../../lib/format";
import { epochSeconds, formatDateTime, formatInteger, formatRelative } from "../../lib/format";
import { admin, useProjects } from "../../lib/projects";
import { activeKeyNames, archiveKeyCount, flowError, isAbort, isArchiveConfirmed, isUsableName, matchesProject, normalizeName, prefixLabel, projectNameProblem, type FlowError } from "./key-flows";
import { FlowErrorMessage, KeyFlowDialogs, NameField, PendingKeyNotice } from "./KeyFlowDialogs";
Expand All @@ -22,18 +23,17 @@ import { projectsQuery } from "../../lib/queries";
import { ProjectStatus } from "./ProjectStatus";
import { useKeyFlow } from "./use-key-flow";
import "./api-keys.css";
import { type AdminKey, archiveProject, createProject, type Project, renameProject, revokeKey } from "../../lib/admin-view";
import { TableSkeleton } from "../../components/Skeleton";

type Dialog =
/** `thenIssue`: Getting started continues from the new project to its first key. */
| { kind: "create"; name: string; thenIssue?: boolean }
| { kind: "rename"; project: Project; name: string }
| { kind: "rename"; project: AdminProject; name: string }
/** `typed`: the project name, required while it has active keys. */
| { kind: "archive"; project: Project; typed: string }
| { kind: "revoke"; project: Project; key: AdminKey; activeCount: number };
| { kind: "archive"; project: AdminProject; typed: string }
| { kind: "revoke"; project: AdminProject; key: AdminAPIKey; activeCount: number };

const manageable = (project: Project) => project.status === "active";
const manageable = (project: AdminProject) => project.archived_at === null;

/**
* Platform › Projects and keys. A project owns the assets shared by all of
Expand All @@ -58,7 +58,7 @@ export function ProjectsPage() {
useEffect(() => { setSelectedId(params.id ?? null); }, [params]);

// After a key is issued (or its outcome is uncertain), re-read the project and its keys.
const keyChanged = useCallback((project: Project) => {
const keyChanged = useCallback((project: AdminProject) => {
void invalidateProjects(queryClient, { projectId: project.id });
}, [queryClient]);
const controls = useKeyFlow(keyChanged);
Expand Down Expand Up @@ -138,17 +138,17 @@ export function ProjectsPage() {
setDialogError(null);
try {
if (current.kind === "create") {
const project = await createProject(normalizeName(current.name));
const project = await admin.createProject({ name: normalizeName(current.name) });
// The project page opens anew and finds the project in the list at once;
// the re-read below confirms it. The key dialog follows as its intent.
queryClient.setQueryData(projectsQuery.queryKey, (list) => (list && !list.some((entry) => entry.id === project.id) ? [...list, project] : list ?? [project]));
navigate("projects", { id: project.id }, current.thenIssue ? "issue-key" : undefined);
} else if (current.kind === "rename") {
await renameProject(current.project.id, normalizeName(current.name));
await admin.renameProject(current.project.id, { name: normalizeName(current.name) });
} else if (current.kind === "archive") {
await archiveProject(current.project.id);
await admin.archiveProject(current.project.id);
} else {
await revokeKey(current.project.id, current.key.id);
await admin.revokeAPIKey(current.project.id, current.key.id);
}
setDialog(null);
} catch (error) {
Expand Down Expand Up @@ -260,7 +260,7 @@ export function ProjectsPage() {
</th>
<td><ProjectStatus project={project} /></td>
<td className="numeric">{formatInteger(project.active_key_count, locale)}</td>
<td className="key-nowrap">{formatDateTime(project.created_at, locale)}</td>
<td className="key-nowrap">{formatDateTime(epochSeconds(project.created_at), locale)}</td>
<td className="key-nowrap" title={lastActive !== null ? formatDateTime(lastActive, locale) : undefined}>{formatRelative(lastActive, now, locale)}</td>
<td className="actions-cell" onClick={(event) => event.stopPropagation()}>
{manageable(project) ? (
Expand Down
Loading
Loading