refactor: 리펙토링
This commit is contained in:
@@ -21,9 +21,11 @@ import {
|
||||
type AppFailure,
|
||||
} from "../../../contracts/errors.ts";
|
||||
import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts";
|
||||
import type {
|
||||
BoundMutation,
|
||||
BoundQuery,
|
||||
import {
|
||||
admitQueryResult,
|
||||
type BoundMutation,
|
||||
type BoundQuery,
|
||||
type MutationDuplicatePolicy,
|
||||
} from "../../../contracts/server-state.ts";
|
||||
import { runtimeIdentityToken } from "../../../contracts/query-keys.ts";
|
||||
import { useQueryInvalidationCoordinator } from "./query-invalidation-provider.tsx";
|
||||
@@ -64,6 +66,8 @@ export function useApplicationQuery<Value>(
|
||||
const profile = "profile" in options ? options.profile : undefined;
|
||||
const scope = "scope" in options ? options.scope : undefined;
|
||||
const identity = "identity" in options ? options.identity : undefined;
|
||||
const measureResult =
|
||||
"measureResult" in options ? options.measureResult : undefined;
|
||||
const queryDefinitionId =
|
||||
"definitionId" in options ? options.definitionId : "APPLICATION_QUERY";
|
||||
const [staleFailure, setStaleFailure] = useState(false);
|
||||
@@ -105,22 +109,22 @@ export function useApplicationQuery<Value>(
|
||||
);
|
||||
}
|
||||
if (result.ok) {
|
||||
if (
|
||||
profile &&
|
||||
!isAdmissibleResult(
|
||||
if (profile && measureResult) {
|
||||
const admission = admitQueryResult(
|
||||
measureResult,
|
||||
result.value,
|
||||
profile.maxResultItems,
|
||||
profile.maxEstimatedResultBytes,
|
||||
)
|
||||
) {
|
||||
throw new ApplicationQueryError(
|
||||
createFailure(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
"APPLICATION_QUERY",
|
||||
0,
|
||||
{ code: "RESULT_ADMISSION_LIMIT_EXCEEDED" },
|
||||
),
|
||||
profile,
|
||||
);
|
||||
if (!admission.ok) {
|
||||
throw new ApplicationQueryError(
|
||||
createFailure(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
queryDefinitionId,
|
||||
0,
|
||||
{ code: admission.code },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
@@ -175,26 +179,34 @@ export function useApplicationQuery<Value>(
|
||||
});
|
||||
}
|
||||
|
||||
export function useApplicationMutation<Input, Value>(
|
||||
options:
|
||||
| BoundMutation<Input, Value>
|
||||
| Readonly<{
|
||||
execute(input: Input): Promise<ApplicationResult<Value>>;
|
||||
invalidate?: readonly QueryInvalidationTopic[];
|
||||
optimistic?: Readonly<{
|
||||
queryKey: readonly unknown[];
|
||||
update(previous: unknown, input: Input): unknown;
|
||||
}>;
|
||||
currentData?: unknown;
|
||||
}>,
|
||||
): Readonly<{
|
||||
type LegacyMutationOptions<Input, Value> = Readonly<{
|
||||
execute(input: Input): Promise<ApplicationResult<Value>>;
|
||||
duplicatePolicy?: MutationDuplicatePolicy;
|
||||
invalidate?: readonly QueryInvalidationTopic[];
|
||||
optimistic?: Readonly<{
|
||||
queryKey: readonly unknown[];
|
||||
update(previous: unknown, input: Input): unknown;
|
||||
}>;
|
||||
currentData?: unknown;
|
||||
}>;
|
||||
|
||||
type ApplicationMutationController<Input, Value> = Readonly<{
|
||||
state: AsyncState;
|
||||
submit(input: Input): Promise<ApplicationResult<Value>>;
|
||||
resolveConflict(): Promise<void>;
|
||||
}> {
|
||||
}>;
|
||||
|
||||
export function useApplicationMutation<Input, Value>(
|
||||
options: BoundMutation<Input, Value>,
|
||||
): ApplicationMutationController<Input, Value>;
|
||||
export function useApplicationMutation<Input, Value>(
|
||||
options: LegacyMutationOptions<Input, Value>,
|
||||
): ApplicationMutationController<Input, Value>;
|
||||
export function useApplicationMutation<Input, Value>(
|
||||
options: BoundMutation<Input, Value> | LegacyMutationOptions<Input, Value>,
|
||||
): ApplicationMutationController<Input, Value> {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidationCoordinator = useQueryInvalidationCoordinator();
|
||||
const { execute } = options;
|
||||
const invalidate = useMemo(
|
||||
() => options.invalidate ?? [],
|
||||
[options.invalidate],
|
||||
@@ -204,7 +216,7 @@ export function useApplicationMutation<Input, Value>(
|
||||
const definitionId =
|
||||
"definitionId" in options ? options.definitionId : "LEGACY_MUTATION";
|
||||
const duplicatePolicy =
|
||||
"duplicatePolicy" in options ? options.duplicatePolicy : "JOIN_IDENTICAL";
|
||||
"duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE";
|
||||
const [conflict, setConflict] = useState<AppFailure | null>(null);
|
||||
const scope = "scope" in options ? options.scope : undefined;
|
||||
const mutation = useMutation<Value, ApplicationQueryError, Input>({
|
||||
@@ -220,14 +232,21 @@ export function useApplicationMutation<Input, Value>(
|
||||
),
|
||||
);
|
||||
}
|
||||
const result = await execute(input);
|
||||
const result =
|
||||
"scope" in options
|
||||
? await options.execute(input, { signal: options.scope.signal })
|
||||
: await options.execute(input);
|
||||
if (scope && !scope.isCurrent()) {
|
||||
const effect =
|
||||
!result.ok && result.error.effect !== undefined
|
||||
? result.error.effect
|
||||
: "MAYBE_APPLIED";
|
||||
throw new ApplicationQueryError(
|
||||
createFailure(
|
||||
"SCOPE_GENERATION_CHANGED",
|
||||
definitionId,
|
||||
0,
|
||||
{ code: "MUTATION_SCOPE_CHANGED" },
|
||||
{ code: "MUTATION_SCOPE_CHANGED", effect },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -276,7 +295,7 @@ export function useApplicationMutation<Input, Value>(
|
||||
identityLease?.release();
|
||||
return active;
|
||||
}
|
||||
if (active && duplicatePolicy === "REJECT_DUPLICATE") {
|
||||
if (active && duplicatePolicy === "REJECT_WHILE_ACTIVE") {
|
||||
identityLease?.release();
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
@@ -379,7 +398,7 @@ export function useApplicationMutation<Input, Value>(
|
||||
mutationExecutions(queryClient).delete(identity);
|
||||
}
|
||||
});
|
||||
if (duplicatePolicy !== "ALLOW_INDEPENDENT") {
|
||||
if (duplicatePolicy !== "ALLOW_PARALLEL") {
|
||||
mutationExecutions(queryClient).set(identity, pending);
|
||||
}
|
||||
return pending;
|
||||
@@ -445,36 +464,3 @@ function optimisticLayers(
|
||||
OPTIMISTIC_LAYER_RUNTIMES.set(queryClient, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function isAdmissibleResult(
|
||||
value: unknown,
|
||||
maxItems: number,
|
||||
maxBytes: number,
|
||||
): boolean {
|
||||
try {
|
||||
const seen = new WeakSet<object>();
|
||||
let items = 0;
|
||||
const visit = (candidate: unknown): boolean => {
|
||||
if (candidate === null || ["string", "number", "boolean"].includes(typeof candidate)) {
|
||||
return true;
|
||||
}
|
||||
if (!candidate || typeof candidate !== "object" || seen.has(candidate)) return false;
|
||||
seen.add(candidate);
|
||||
if (Array.isArray(candidate)) {
|
||||
items += candidate.length;
|
||||
return items <= maxItems && candidate.every(visit);
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(candidate);
|
||||
return (
|
||||
(prototype === Object.prototype || prototype === null) &&
|
||||
Object.values(candidate).every(visit)
|
||||
);
|
||||
};
|
||||
return (
|
||||
visit(value) &&
|
||||
new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { hashKey, type QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts";
|
||||
import { OPTIMISTIC_LAYER_BOUNDS } from "../../../contracts/server-state.ts";
|
||||
|
||||
export type OptimisticLayerLease = Readonly<{
|
||||
commit(): void;
|
||||
@@ -88,11 +89,31 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
||||
} else if (entry.scope !== scope) {
|
||||
return null;
|
||||
}
|
||||
// §11.5. Overflow falls back to pessimistic execution; an existing
|
||||
// layer is never silently evicted to make room for a new one.
|
||||
if (entry.layers.length >= OPTIMISTIC_LAYER_BOUNDS.maxLayersPerQueryKey) {
|
||||
if (entry.layers.length === 0) entries.delete(key);
|
||||
return null;
|
||||
}
|
||||
const layer: Layer = {
|
||||
id: nextId++,
|
||||
status: "pending",
|
||||
apply: (value) => update(value, input),
|
||||
};
|
||||
let projected: unknown;
|
||||
try {
|
||||
projected = update(entry.base, input);
|
||||
} catch {
|
||||
if (entry.layers.length === 0) entries.delete(key);
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
estimateLayerBytes(projected) >
|
||||
OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes
|
||||
) {
|
||||
if (entry.layers.length === 0) entries.delete(key);
|
||||
return null;
|
||||
}
|
||||
entry.layers.push(layer);
|
||||
project(key, entry);
|
||||
let settled = false;
|
||||
@@ -120,3 +141,36 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* A local, bounded estimate of one optimistic projection. This is not the
|
||||
* §10.4 query result measurement: it only decides whether a rollback snapshot
|
||||
* stays inside the layer budget, and it stops as soon as the budget is passed.
|
||||
*/
|
||||
function estimateLayerBytes(value: unknown): number {
|
||||
let total = 0;
|
||||
const stack: unknown[] = [value];
|
||||
let visited = 0;
|
||||
while (stack.length > 0) {
|
||||
if (visited++ > 4_096) return Number.POSITIVE_INFINITY;
|
||||
if (total > OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes) return total;
|
||||
const current = stack.pop();
|
||||
if (typeof current === "string") {
|
||||
total += encoder.encode(current).byteLength;
|
||||
} else if (typeof current === "number" || typeof current === "boolean") {
|
||||
total += 8;
|
||||
} else if (Array.isArray(current)) {
|
||||
total += 8;
|
||||
for (const item of current) stack.push(item);
|
||||
} else if (current && typeof current === "object") {
|
||||
total += 8;
|
||||
for (const [key, item] of Object.entries(current)) {
|
||||
total += encoder.encode(key).byteLength;
|
||||
stack.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
QueryClientProvider,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { type ReactNode, useSyncExternalStore } from "react";
|
||||
|
||||
import type { QueryInvalidationCoordinator } from "../../../contracts/query-invalidation.ts";
|
||||
import type { ServerStateScopeRuntime } from "../../../contracts/server-state-scope.ts";
|
||||
import { QueryInvalidationProvider } from "./query-invalidation-provider.tsx";
|
||||
import { ServerStateScopeProvider } from "./server-state-scope-provider.tsx";
|
||||
|
||||
export type ServerStateGenerationSource = Readonly<{
|
||||
getSnapshot(): Readonly<{
|
||||
generation: number;
|
||||
queryClient: QueryClient;
|
||||
queryInvalidation: QueryInvalidationCoordinator;
|
||||
}>;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}>;
|
||||
|
||||
export function ServerStateGenerationProvider({
|
||||
store,
|
||||
scope,
|
||||
children,
|
||||
transitionFallback,
|
||||
}: Readonly<{
|
||||
store: ServerStateGenerationSource;
|
||||
scope: ServerStateScopeRuntime;
|
||||
children: ReactNode;
|
||||
transitionFallback?: ReactNode;
|
||||
}>) {
|
||||
const generation = useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getSnapshot,
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider
|
||||
key={generation.generation}
|
||||
client={generation.queryClient}
|
||||
>
|
||||
<ServerStateScopeProvider
|
||||
runtime={scope}
|
||||
transitionFallback={transitionFallback}
|
||||
>
|
||||
<QueryInvalidationProvider coordinator={generation.queryInvalidation}>
|
||||
{children}
|
||||
</QueryInvalidationProvider>
|
||||
</ServerStateScopeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -16,13 +16,27 @@ const ServerStateScopeContext =
|
||||
export function ServerStateScopeProvider({
|
||||
runtime,
|
||||
children,
|
||||
transitionFallback = null,
|
||||
}: Readonly<{
|
||||
runtime: ServerStateScopeRuntime;
|
||||
children: ReactNode;
|
||||
transitionFallback?: ReactNode;
|
||||
}>) {
|
||||
const phase = useSyncExternalStore(
|
||||
runtime.subscribe,
|
||||
runtime.getPhase,
|
||||
runtime.getPhase,
|
||||
);
|
||||
const content =
|
||||
phase === "READY"
|
||||
? children
|
||||
: phase === "DISPOSED"
|
||||
? null
|
||||
: transitionFallback;
|
||||
|
||||
return (
|
||||
<ServerStateScopeContext.Provider value={runtime}>
|
||||
{children}
|
||||
{content}
|
||||
</ServerStateScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { HTTP_EXECUTION_CEILINGS } from "../../contracts/external-contract-runtime.ts";
|
||||
import { SERVER_STATE_PROFILES } from "../../contracts/server-state.ts";
|
||||
import {
|
||||
COMPOSED_CONTRACT_CONTRIBUTIONS,
|
||||
EXPECTED_CONTRACT_SET_PACKAGES,
|
||||
} from "../../features/installed-contract-contributions.ts";
|
||||
import { ROUTE_REGISTRY } from "../../features/installed-feature-contracts.ts";
|
||||
import type {
|
||||
RuntimeCapabilityId,
|
||||
RuntimeCapabilityStatus,
|
||||
} from "../../contracts/runtime-capabilities.ts";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
DataTable,
|
||||
EmptySurface,
|
||||
PageHeader,
|
||||
type DataTableColumn,
|
||||
} from "../design-system/index.ts";
|
||||
import { useApplication } from "../providers/application-provider.tsx";
|
||||
|
||||
/**
|
||||
* Every number and row on this page is read from an installed registry at
|
||||
* render time. Nothing is transcribed by hand, so deleting a feature removes
|
||||
* its rows and the page keeps describing what the repository actually is.
|
||||
*/
|
||||
|
||||
function kilobytes(bytes: number): string {
|
||||
if (bytes === 0) return "없음";
|
||||
if (bytes >= 1_048_576) return `${bytes / 1_048_576} MiB`;
|
||||
return `${bytes / 1024} KiB`;
|
||||
}
|
||||
|
||||
function seconds(milliseconds: number): string {
|
||||
return milliseconds < 1000
|
||||
? `${milliseconds}ms`
|
||||
: `${milliseconds / 1000}초`;
|
||||
}
|
||||
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: Readonly<{ label: string; value: string; hint?: string }>) {
|
||||
return (
|
||||
<div className="platform-metric">
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
<span className="platform-metric__value">{value}</span>
|
||||
{hint ? <span className="platform-metric__hint">{hint}</span> : null}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RouteRow = (typeof ROUTE_REGISTRY)[keyof typeof ROUTE_REGISTRY];
|
||||
|
||||
const ROUTE_COLUMNS: readonly DataTableColumn<RouteRow>[] = Object.freeze([
|
||||
{
|
||||
id: "routeId",
|
||||
header: "라우트",
|
||||
cell: (row) => <code>{row.routeId}</code>,
|
||||
},
|
||||
{ id: "path", header: "경로", cell: (row) => <code>{row.path}</code> },
|
||||
{
|
||||
id: "access",
|
||||
header: "접근",
|
||||
cell: (row) => (
|
||||
<Badge variant={row.access === "public" ? "success" : "warning"}>
|
||||
{row.access}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "schemas",
|
||||
header: "입력 스키마",
|
||||
cell: (row) =>
|
||||
[row.paramsSchema, row.searchSchema].filter(Boolean).join(" · ") || "없음",
|
||||
},
|
||||
{
|
||||
id: "chunkId",
|
||||
header: "청크",
|
||||
cell: (row) => <code>{row.chunkId}</code>,
|
||||
},
|
||||
]);
|
||||
|
||||
type OperationRow = Readonly<{
|
||||
operationId: string;
|
||||
method: string;
|
||||
pathTemplate: string;
|
||||
retrySemantics: string;
|
||||
retryBudget: number;
|
||||
totalDeadlineMs: number;
|
||||
requestByteLimit: number;
|
||||
responseByteLimit: number;
|
||||
effect: string;
|
||||
recovery: string;
|
||||
}>;
|
||||
|
||||
const OPERATION_COLUMNS: readonly DataTableColumn<OperationRow>[] =
|
||||
Object.freeze([
|
||||
{
|
||||
id: "operationId",
|
||||
header: "오퍼레이션",
|
||||
cell: (row) => (
|
||||
<>
|
||||
<code>{row.operationId}</code>
|
||||
<span className="platform-operation__path">
|
||||
{row.method} {row.pathTemplate}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "retry",
|
||||
header: "재시도",
|
||||
cell: (row) => (
|
||||
<>
|
||||
<Badge variant={row.retrySemantics === "SAFE" ? "success" : "warning"}>
|
||||
{row.retrySemantics}
|
||||
</Badge>
|
||||
<span className="platform-operation__path">
|
||||
예산 {row.retryBudget}회
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "effect",
|
||||
header: "효과 확정성",
|
||||
cell: (row) => row.effect,
|
||||
},
|
||||
{
|
||||
id: "recovery",
|
||||
header: "복구",
|
||||
cell: (row) => row.recovery,
|
||||
},
|
||||
{
|
||||
id: "budget",
|
||||
header: "예산",
|
||||
cell: (row) => (
|
||||
<>
|
||||
<span className="platform-operation__path">
|
||||
요청 {kilobytes(row.requestByteLimit)} · 응답{" "}
|
||||
{kilobytes(row.responseByteLimit)}
|
||||
</span>
|
||||
<span className="platform-operation__path">
|
||||
마감 {seconds(row.totalDeadlineMs)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
type ProfileRow = (typeof SERVER_STATE_PROFILES)[keyof typeof SERVER_STATE_PROFILES];
|
||||
|
||||
const PROFILE_COLUMNS: readonly DataTableColumn<ProfileRow>[] = Object.freeze([
|
||||
{
|
||||
id: "profileId",
|
||||
header: "프로파일",
|
||||
cell: (row) => <code>{row.profileId}</code>,
|
||||
},
|
||||
{ id: "stale", header: "stale", cell: (row) => seconds(row.staleTimeMs) },
|
||||
{ id: "gc", header: "gc", cell: (row) => seconds(row.gcTimeMs) },
|
||||
{
|
||||
id: "refetch",
|
||||
header: "재조회",
|
||||
cell: (row) =>
|
||||
[
|
||||
row.refetchOnMount === "always"
|
||||
? "mount(always)"
|
||||
: row.refetchOnMount && "mount",
|
||||
row.refetchOnFocus && "focus",
|
||||
row.refetchOnReconnect && "reconnect",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
},
|
||||
{
|
||||
id: "budget",
|
||||
header: "결과 예산",
|
||||
cell: (row) =>
|
||||
`${row.maxResultItems}건 · ${kilobytes(row.maxEstimatedResultBytes)}`,
|
||||
},
|
||||
]);
|
||||
|
||||
const CAPABILITY_COPY: Readonly<
|
||||
Record<RuntimeCapabilityId, Readonly<{ label: string; description: string }>>
|
||||
> = Object.freeze({
|
||||
REALTIME: Object.freeze({
|
||||
label: "실시간 수신",
|
||||
description:
|
||||
"WebSocket, SSE, 경계 폴링 런타임은 구현되어 있습니다. 제품 기여물이 엔드포인트와 이벤트 서술자를 제공해야 설치됩니다.",
|
||||
}),
|
||||
WEB_WORKER: Object.freeze({
|
||||
label: "웹 워커",
|
||||
description:
|
||||
"워커 실행 계약과 전용 타입 프로젝트가 준비되어 있습니다. 프로파일링으로 확인된 CPU 작업이 있어야 설치됩니다.",
|
||||
}),
|
||||
SERVICE_WORKER: Object.freeze({
|
||||
label: "서비스 워커",
|
||||
description:
|
||||
"참조 런타임과 두 단계 빌드가 준비되어 있습니다. 설치하면 검증된 정적 자산 캐시와 등록 해제 경로가 함께 켜집니다.",
|
||||
}),
|
||||
OFFLINE_COMMANDS: Object.freeze({
|
||||
label: "오프라인 명령",
|
||||
description:
|
||||
"명령 큐 상태 기계가 준비되어 있습니다. 복구 서술자를 가진 KEYED 오퍼레이션이 있어야 설치됩니다.",
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* A capability that was never selected and one an operator switched off look
|
||||
* identical if both are reported as "off". The snapshot separates them, and so
|
||||
* does this badge.
|
||||
*/
|
||||
function capabilityBadge(
|
||||
status: RuntimeCapabilityStatus,
|
||||
): Readonly<{ text: string; variant: "success" | "warning" | "neutral" }> {
|
||||
if (status.selected === 0) return { text: "미선택", variant: "neutral" };
|
||||
if (status.active === 0) {
|
||||
return { text: "운영자가 비활성화함", variant: "warning" };
|
||||
}
|
||||
return { text: `활성 (${status.active})`, variant: "success" };
|
||||
}
|
||||
|
||||
function buildOperationRows(): readonly OperationRow[] {
|
||||
return Object.freeze(
|
||||
[...COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.values()].map(
|
||||
(installed) => {
|
||||
const { contract, frontend } = installed;
|
||||
return Object.freeze({
|
||||
operationId: contract.operationId,
|
||||
method: contract.method,
|
||||
pathTemplate: contract.pathTemplate,
|
||||
retrySemantics: contract.retrySemantics,
|
||||
retryBudget: frontend.retryBudget,
|
||||
totalDeadlineMs: frontend.totalDeadlineMs,
|
||||
requestByteLimit: frontend.requestByteLimit,
|
||||
responseByteLimit: frontend.responseByteLimit,
|
||||
effect:
|
||||
contract.commandEffect === null
|
||||
? "해당 없음"
|
||||
: contract.commandEffect.successEffect,
|
||||
recovery:
|
||||
contract.commandRecovery === null
|
||||
? "해당 없음"
|
||||
: contract.commandRecovery.mode,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlatformOverviewPage() {
|
||||
const { runtime } = useApplication();
|
||||
const [release, setRelease] = useState<
|
||||
Awaited<ReturnType<typeof runtime.getReleaseSummary>> | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void runtime.getReleaseSummary().then((summary) => {
|
||||
if (active) setRelease(summary);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [runtime]);
|
||||
|
||||
const routes = Object.values(ROUTE_REGISTRY);
|
||||
const operations = buildOperationRows();
|
||||
const capabilities = runtime.getCapabilitySnapshot();
|
||||
const activeCapabilityCount = capabilities.filter(
|
||||
(status) => status.active > 0,
|
||||
).length;
|
||||
const fixtureContributions =
|
||||
COMPOSED_CONTRACT_CONTRIBUTIONS.contributions.filter(
|
||||
(contribution) => contribution.source.kind === "TEMPLATE_FIXTURE",
|
||||
).length;
|
||||
|
||||
return (
|
||||
<section className="ui-page">
|
||||
<PageHeader
|
||||
eyebrow="예제"
|
||||
title="플랫폼 구성"
|
||||
description="이 화면의 모든 값은 설치된 레지스트리에서 렌더 시점에 읽습니다. 손으로 옮겨 적은 숫자가 없으므로 코드가 바뀌면 이 화면도 함께 바뀝니다."
|
||||
/>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-release-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-release-title">릴리스 신원</h2>
|
||||
<p>
|
||||
부팅 시 경계 검사를 통과한 런타임 설정과 릴리스 매니페스트에서 옵니다.
|
||||
</p>
|
||||
</header>
|
||||
<dl className="platform-metric-grid" aria-live="polite">
|
||||
{release ? (
|
||||
<>
|
||||
<Metric label="빌드" value={release.buildId} />
|
||||
<Metric label="릴리스" value={release.releaseId} />
|
||||
<Metric
|
||||
label="설정 스키마"
|
||||
value={release.configSchemaVersion}
|
||||
hint={
|
||||
release.apiContractVersion
|
||||
? `레거시 계약 ${release.apiContractVersion}`
|
||||
: "계약 집합 사용"
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label="계약 집합 다이제스트"
|
||||
value={
|
||||
release.contractSetDigest
|
||||
? `${release.contractSetDigest.slice(0, 20)}…`
|
||||
: "없음"
|
||||
}
|
||||
hint={
|
||||
release.contractSetDigest
|
||||
? "릴리스 매니페스트 V2"
|
||||
: "릴리스 매니페스트 V1"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Metric label="상태" value="런타임 정보를 확인하고 있습니다." />
|
||||
)}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-summary-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-summary-title">설치 요약</h2>
|
||||
<p>레지스트리 항목 수를 그대로 센 값입니다.</p>
|
||||
</header>
|
||||
<dl className="platform-metric-grid">
|
||||
<Metric label="라우트" value={`${routes.length}개`} />
|
||||
<Metric label="HTTP 오퍼레이션" value={`${operations.length}개`} />
|
||||
<Metric
|
||||
label="외부 계약 패키지"
|
||||
value={`${EXPECTED_CONTRACT_SET_PACKAGES.length}개`}
|
||||
hint={`템플릿 픽스처 ${fixtureContributions}개`}
|
||||
/>
|
||||
<Metric
|
||||
label="선택적 런타임 능력"
|
||||
value={`${activeCapabilityCount} / ${capabilities.length}`}
|
||||
hint="런타임 오버라이드 반영"
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-routes-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-routes-title">설치된 라우트</h2>
|
||||
<p>
|
||||
라우트 레지스트리가 단일 진실 공급원입니다. 접근 정책, 코드 분할 청크,
|
||||
입력 스키마가 한 항목에 함께 선언됩니다.
|
||||
</p>
|
||||
</header>
|
||||
<DataTable
|
||||
caption="설치된 라우트 목록"
|
||||
columns={ROUTE_COLUMNS}
|
||||
rows={routes}
|
||||
rowKey={(row) => row.routeId}
|
||||
empty={
|
||||
<EmptySurface
|
||||
title="설치된 라우트가 없습니다."
|
||||
description="라우트 레지스트리가 비어 있습니다."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-contracts-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-contracts-title">계약과 HTTP 오퍼레이션</h2>
|
||||
<p>
|
||||
외부 계약 패키지 {EXPECTED_CONTRACT_SET_PACKAGES.length}개가 설치되어
|
||||
있습니다. 아래 오퍼레이션은 템플릿 픽스처가 제공하며 릴리스 다이제스트에
|
||||
포함되지 않습니다. 제품은 픽스처를 지우고 자기 패키지를 고정합니다.
|
||||
</p>
|
||||
</header>
|
||||
<DataTable
|
||||
caption="설치된 HTTP 오퍼레이션"
|
||||
columns={OPERATION_COLUMNS}
|
||||
rows={operations}
|
||||
rowKey={(row) => row.operationId}
|
||||
empty={
|
||||
<EmptySurface
|
||||
title="설치된 HTTP 오퍼레이션이 없습니다."
|
||||
description="계약 기여물을 추가하면 이 표에 나타납니다."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-server-state-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-server-state-title">서버 상태와 실행 상한</h2>
|
||||
<p>
|
||||
조회는 네 개의 고정 프로파일 중 하나를 골라야 하고, 실행 정책은 아래
|
||||
상한을 넘을 수 없습니다.
|
||||
</p>
|
||||
</header>
|
||||
<DataTable
|
||||
caption="서버 상태 프로파일"
|
||||
columns={PROFILE_COLUMNS}
|
||||
rows={Object.values(SERVER_STATE_PROFILES)}
|
||||
rowKey={(row) => row.profileId}
|
||||
empty={<EmptySurface title="프로파일이 없습니다." />}
|
||||
/>
|
||||
<dl className="platform-metric-grid">
|
||||
<Metric
|
||||
label="응답 상한"
|
||||
value={kilobytes(HTTP_EXECUTION_CEILINGS.hardResponseBytes)}
|
||||
hint={`기본 ${kilobytes(HTTP_EXECUTION_CEILINGS.defaultResponseBytes)}`}
|
||||
/>
|
||||
<Metric
|
||||
label="요청 상한"
|
||||
value={kilobytes(HTTP_EXECUTION_CEILINGS.hardRequestBytes)}
|
||||
hint={`기본 ${kilobytes(HTTP_EXECUTION_CEILINGS.defaultRequestBytes)}`}
|
||||
/>
|
||||
<Metric
|
||||
label="총 마감 상한"
|
||||
value={seconds(HTTP_EXECUTION_CEILINGS.hardTotalDeadlineMs)}
|
||||
hint={`기본 ${seconds(HTTP_EXECUTION_CEILINGS.defaultTotalDeadlineMs)}`}
|
||||
/>
|
||||
<Metric
|
||||
label="재시도 상한"
|
||||
value={`${HTTP_EXECUTION_CEILINGS.hardRetryCount}회`}
|
||||
hint="전송 실패에만 적용"
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="gallery-section"
|
||||
aria-labelledby="platform-capabilities-title"
|
||||
>
|
||||
<header className="gallery-section__header">
|
||||
<h2 id="platform-capabilities-title">선택적 런타임 능력</h2>
|
||||
<p>
|
||||
정적 선택 파일이 단일 진실 공급원입니다. 런타임 설정은 이미 선택된
|
||||
능력을 끌 수만 있고, 설정 문자열로 새 능력을 켜거나 모듈 경로를 만들지
|
||||
못합니다. 여기 표시되는 상태는 정적 선택에 런타임 오버라이드를 적용한
|
||||
결과이므로, 애초에 선택되지 않은 능력과 운영자가 끈 능력이 구분됩니다.
|
||||
</p>
|
||||
</header>
|
||||
<div className="component-grid component-grid--two">
|
||||
{capabilities.map((status) => {
|
||||
const copy = CAPABILITY_COPY[status.capabilityId];
|
||||
const badge = capabilityBadge(status);
|
||||
return (
|
||||
<Card
|
||||
key={status.capabilityId}
|
||||
title={copy.label}
|
||||
footer={<Badge variant={badge.variant}>{badge.text}</Badge>}
|
||||
>
|
||||
<p>{copy.description}</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,8 @@ const PLATFORM_KO_MESSAGES = {
|
||||
"shell.session.actionFailed": "세션 작업을 완료하지 못했습니다.",
|
||||
"route.APP_HOME.navigation": "홈",
|
||||
"route.APP_HOME.title": "Clean Architecture Frontend",
|
||||
"route.EXAMPLES_PLATFORM.navigation": "플랫폼 구성",
|
||||
"route.EXAMPLES_PLATFORM.title": "플랫폼 구성",
|
||||
"route.EXAMPLES_UI.navigation": "UI 구성요소",
|
||||
"route.EXAMPLES_UI.title": "UI 구성요소",
|
||||
"route.EXAMPLES_STATES.navigation": "화면 상태",
|
||||
@@ -190,6 +192,8 @@ const PLATFORM_EN_MESSAGES = {
|
||||
"shell.session.actionFailed": "The session action could not be completed.",
|
||||
"route.APP_HOME.navigation": "Home",
|
||||
"route.APP_HOME.title": "Clean Architecture Frontend",
|
||||
"route.EXAMPLES_PLATFORM.navigation": "Platform composition",
|
||||
"route.EXAMPLES_PLATFORM.title": "Platform composition",
|
||||
"route.EXAMPLES_UI.navigation": "UI components",
|
||||
"route.EXAMPLES_UI.title": "UI components",
|
||||
"route.EXAMPLES_STATES.navigation": "Screen states",
|
||||
|
||||
@@ -59,10 +59,19 @@ export default function HomePage() {
|
||||
<section className="ui-panel starter-actions" aria-labelledby="starter-title">
|
||||
<div>
|
||||
<h2 id="starter-title">준비된 화면 살펴보기</h2>
|
||||
<p>공통 구성요소와 비동기 화면 상태를 예제 라우트에서 확인하세요.</p>
|
||||
<p>
|
||||
설치된 라우트와 계약, 런타임 능력은 플랫폼 구성 화면에서, 공통
|
||||
구성요소와 비동기 화면 상태는 예제 라우트에서 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<Link className="ui-button" to={routePath("EXAMPLES_UI")}>
|
||||
<Link className="ui-button" to={routePath("EXAMPLES_PLATFORM")}>
|
||||
플랫폼 구성 보기
|
||||
</Link>
|
||||
<Link
|
||||
className="ui-button ui-button--secondary"
|
||||
to={routePath("EXAMPLES_UI")}
|
||||
>
|
||||
UI 구성요소 보기
|
||||
</Link>
|
||||
<Link
|
||||
|
||||
@@ -151,7 +151,9 @@ function CanonicalRouteRedirect({
|
||||
params: input.params,
|
||||
search: input.search,
|
||||
});
|
||||
if (source !== target && guard.current.allow(source, target)) {
|
||||
if (source === target) {
|
||||
guard.current.reset();
|
||||
} else if (guard.current.allow(source, target)) {
|
||||
void navigate(target, { replace: true });
|
||||
}
|
||||
}, [input, location.pathname, location.search, navigate]);
|
||||
|
||||
@@ -24,6 +24,10 @@ function runtime(
|
||||
|
||||
export const PLATFORM_ROUTE_RUNTIME = {
|
||||
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.tsx")),
|
||||
EXAMPLES_PLATFORM: runtime(
|
||||
"EXAMPLES_PLATFORM",
|
||||
() => import("../examples/platform-overview-page.tsx"),
|
||||
),
|
||||
EXAMPLES_UI: runtime(
|
||||
"EXAMPLES_UI",
|
||||
() => import("../examples/ui-gallery-page.tsx"),
|
||||
|
||||
@@ -1189,6 +1189,46 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.platform-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: 1rem;
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.platform-metric {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-surface);
|
||||
background: var(--color-panel);
|
||||
}
|
||||
|
||||
.platform-metric dt {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-content-muted);
|
||||
}
|
||||
|
||||
.platform-metric dd {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.platform-metric__value {
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.platform-metric__hint,
|
||||
.platform-operation__path {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-content-muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.badge-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user