관리자 증거와 계정 흐름을 정돈
This commit is contained in:
parent
21bbe3f98d
commit
34aff65cb0
9 changed files with 1100 additions and 193 deletions
|
|
@ -3071,7 +3071,7 @@ export interface components {
|
|||
* @default provider_reported
|
||||
* @enum {string}
|
||||
*/
|
||||
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "unavailable";
|
||||
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "reference_upper_bound" | "partial" | "partial_upper_bound" | "unavailable";
|
||||
/** Cost Usd */
|
||||
cost_usd: number;
|
||||
/**
|
||||
|
|
@ -3111,6 +3111,12 @@ export interface components {
|
|||
};
|
||||
/** AdminUsageBudget */
|
||||
AdminUsageBudget: {
|
||||
/**
|
||||
* Cost Basis
|
||||
* @default provider_reported
|
||||
* @enum {string}
|
||||
*/
|
||||
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "reference_upper_bound" | "partial" | "partial_upper_bound" | "unavailable";
|
||||
/** Limit Usd */
|
||||
limit_usd: number;
|
||||
/** Remaining Usd */
|
||||
|
|
@ -3119,12 +3125,18 @@ export interface components {
|
|||
* Status
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "disabled" | "ok" | "warn" | "exceeded";
|
||||
status: "disabled" | "ok" | "warn" | "exceeded" | "indeterminate";
|
||||
/** Used Ratio */
|
||||
used_ratio: number;
|
||||
};
|
||||
/** AdminUsageDailyCost */
|
||||
AdminUsageDailyCost: {
|
||||
/**
|
||||
* Cost Basis
|
||||
* @default provider_reported
|
||||
* @enum {string}
|
||||
*/
|
||||
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "reference_upper_bound" | "partial" | "partial_upper_bound" | "unavailable";
|
||||
/** Cost Usd */
|
||||
cost_usd: number;
|
||||
/** Day */
|
||||
|
|
@ -3160,6 +3172,12 @@ export interface components {
|
|||
budget: components["schemas"]["AdminUsageBudget"];
|
||||
/** By Provider */
|
||||
by_provider: components["schemas"]["AdminUsageBreakdown"][];
|
||||
/**
|
||||
* Cost Basis
|
||||
* @default provider_reported
|
||||
* @enum {string}
|
||||
*/
|
||||
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "reference_upper_bound" | "partial" | "partial_upper_bound" | "unavailable";
|
||||
/** Cost Usd */
|
||||
cost_usd: number;
|
||||
/** Daily Cost */
|
||||
|
|
|
|||
|
|
@ -31,6 +31,21 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
|
|||
solar: "Solar",
|
||||
};
|
||||
|
||||
type UsageCostBasis =
|
||||
| "provider_estimate"
|
||||
| "provider_reported"
|
||||
| "reference_rate"
|
||||
| "reference_upper_bound"
|
||||
| "partial"
|
||||
| "partial_upper_bound"
|
||||
| "unavailable";
|
||||
|
||||
const EXACT_COST_BASES = new Set<UsageCostBasis>([
|
||||
"provider_estimate",
|
||||
"provider_reported",
|
||||
"reference_rate",
|
||||
]);
|
||||
|
||||
function countLabel(value: number): string {
|
||||
return Number.isFinite(value) ? Math.max(0, Math.round(value)).toLocaleString("ko-KR") : "0";
|
||||
}
|
||||
|
|
@ -52,6 +67,65 @@ function costTitle(value: number): string {
|
|||
return `$${exact === "0" ? value.toPrecision(3) : exact}`;
|
||||
}
|
||||
|
||||
function readCostBasis(
|
||||
value: unknown,
|
||||
fallback: UsageCostBasis = "provider_reported",
|
||||
): UsageCostBasis {
|
||||
if (!value || typeof value !== "object" || !("cost_basis" in value)) return fallback;
|
||||
const basis = (value as { cost_basis?: unknown }).cost_basis;
|
||||
if (
|
||||
basis === "provider_estimate" ||
|
||||
basis === "provider_reported" ||
|
||||
basis === "reference_rate" ||
|
||||
basis === "reference_upper_bound" ||
|
||||
basis === "partial" ||
|
||||
basis === "partial_upper_bound" ||
|
||||
basis === "unavailable"
|
||||
) {
|
||||
return basis;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function isExactCostBasis(value: UsageCostBasis): boolean {
|
||||
return EXACT_COST_BASES.has(value);
|
||||
}
|
||||
|
||||
function isUnknownCostBasis(value: UsageCostBasis): boolean {
|
||||
return value === "unavailable" || value === "partial_upper_bound";
|
||||
}
|
||||
|
||||
function costWithBasis(value: number, basis: UsageCostBasis, digits: 2 | 4 = 2): string {
|
||||
if (isUnknownCostBasis(basis)) return "미산정";
|
||||
if (basis === "reference_upper_bound") return `≤${costLabel(value, digits)}`;
|
||||
if (basis === "partial") return `${costLabel(value, digits)}+`;
|
||||
return costLabel(value, digits);
|
||||
}
|
||||
|
||||
function costTitleWithBasis(value: number, basis: UsageCostBasis): string {
|
||||
if (isUnknownCostBasis(basis)) return "미산정";
|
||||
if (basis === "reference_upper_bound") return `≤${costTitle(value)}`;
|
||||
if (basis === "partial") return `${costTitle(value)}+`;
|
||||
return costTitle(value);
|
||||
}
|
||||
|
||||
function budgetRemainingLabel(
|
||||
value: number | null | undefined,
|
||||
basis: UsageCostBasis,
|
||||
): string {
|
||||
if (isUnknownCostBasis(basis) || value === null || value === undefined) return "잔여 미산정";
|
||||
if (basis === "reference_upper_bound") return `잔여 ≥${costLabel(value)}`;
|
||||
if (basis === "partial") return `잔여 ≤${costLabel(value)}`;
|
||||
return `잔여 ${costLabel(value)}`;
|
||||
}
|
||||
|
||||
function budgetRateLabel(value: number, basis: UsageCostBasis): string {
|
||||
if (isUnknownCostBasis(basis)) return "사용률 미산정";
|
||||
if (basis === "reference_upper_bound") return `≤${rateLabel(value)} 사용`;
|
||||
if (basis === "partial") return `≥${rateLabel(value)} 사용`;
|
||||
return `${rateLabel(value)} 사용`;
|
||||
}
|
||||
|
||||
function rateLabel(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0%";
|
||||
return `${(value * 100).toFixed(1).replace(/\.0$/, "")}%`;
|
||||
|
|
@ -61,6 +135,9 @@ function costBasisLabel(value?: string): string {
|
|||
if (value === "provider_estimate") return "SDK 추정";
|
||||
if (value === "provider_reported") return "Provider 보고";
|
||||
if (value === "reference_rate") return "참조단가";
|
||||
if (value === "reference_upper_bound") return "참조 상한";
|
||||
if (value === "partial") return "일부 산정";
|
||||
if (value === "partial_upper_bound") return "일부 상한";
|
||||
if (value === "unavailable") return "미산정";
|
||||
return "기록값";
|
||||
}
|
||||
|
|
@ -85,13 +162,18 @@ function sourceLabel(usage: AdminUsageResponse): string {
|
|||
return usage.durable && usage.source === "database" ? "운영 DB 원장" : "서버 런타임 임시 집계";
|
||||
}
|
||||
|
||||
function budgetStatusLabel(status: AdminUsageResponse["budget"]["status"]): string {
|
||||
function budgetStatusLabel(status: string): string {
|
||||
if (status === "exceeded") return "예산 초과";
|
||||
if (status === "warn") return "예산 주의";
|
||||
if (status === "ok") return "예산 정상";
|
||||
if (status === "indeterminate") return "예산 미확정";
|
||||
return "예산 경고 비활성";
|
||||
}
|
||||
|
||||
function budgetStatusClass(status?: string): string {
|
||||
return status === "indeterminate" ? "disabled" : status ?? "disabled";
|
||||
}
|
||||
|
||||
function healthStatusLabel(status?: string): string {
|
||||
if (status === "ok") return "정상";
|
||||
if (status === "down") return "중단";
|
||||
|
|
@ -229,8 +311,15 @@ export default function AdminAi() {
|
|||
}
|
||||
};
|
||||
|
||||
const usageCostBasis = readCostBasis(usage);
|
||||
const budgetCostBasis = readCostBasis(usage?.budget, usageCostBasis);
|
||||
const daily = usage?.daily_cost ?? [];
|
||||
const maxDailyCost = Math.max(0, ...daily.map((item) => item.cost_usd));
|
||||
const maxDailyCost = Math.max(
|
||||
0,
|
||||
...daily
|
||||
.filter((item) => !isUnknownCostBasis(readCostBasis(item, usageCostBasis)))
|
||||
.map((item) => item.cost_usd),
|
||||
);
|
||||
const totalTokens = (usage?.tokens_in ?? 0) + (usage?.tokens_out ?? 0);
|
||||
const dbCoverage = usage && usage.total_turns > 0 ? usage.metered_turns / usage.total_turns : 0;
|
||||
const unmeteredTurns = usage ? Math.max(0, usage.total_turns - usage.metered_turns) : 0;
|
||||
|
|
@ -238,6 +327,12 @@ export default function AdminAi() {
|
|||
const tokenUnmeteredTurns = usage?.token_unmetered_turns ?? 0;
|
||||
const costPerTurn = usage && usage.metered_turns > 0 ? usage.cost_usd / usage.metered_turns : 0;
|
||||
const costPerThousandTokens = usage && totalTokens > 0 ? (usage.cost_usd / totalTokens) * 1000 : 0;
|
||||
const canShowCostAverage =
|
||||
isExactCostBasis(usageCostBasis) || usageCostBasis === "reference_upper_bound";
|
||||
const canShowCostShare = isExactCostBasis(usageCostBasis);
|
||||
const budgetEnabled = Boolean(usage && usage.budget.status !== "disabled");
|
||||
const budgetRate = usage?.budget.used_ratio ?? 0;
|
||||
const budgetRateText = budgetRateLabel(budgetRate, budgetCostBasis);
|
||||
const engineService = health?.services.find((service) => service.key === "engine");
|
||||
const capabilityModels = capabilities?.models ?? [];
|
||||
const selectedModel = capabilityModels.find((model) => model.id === engine?.model);
|
||||
|
|
@ -316,12 +411,15 @@ export default function AdminAi() {
|
|||
<article>
|
||||
<span>누적 비용 추정</span>
|
||||
{/* 합계 금액은 소수 2자리, 원본 정밀도는 title 로 남긴다. */}
|
||||
<b title={usage ? costTitle(usage.cost_usd) : undefined}>
|
||||
{usage ? costLabel(usage.cost_usd) : "—"}
|
||||
<b title={usage ? costTitleWithBasis(usage.cost_usd, usageCostBasis) : undefined}>
|
||||
{usage ? costWithBasis(usage.cost_usd, usageCostBasis) : "—"}
|
||||
</b>
|
||||
<small>
|
||||
{usage
|
||||
? `기록 ${costLabel(usage.recorded_cost_usd ?? usage.cost_usd)} · 참조 ${costLabel(usage.estimated_cost_usd ?? 0)}`
|
||||
? `기록 ${costLabel(usage.recorded_cost_usd ?? usage.cost_usd)} · 참조 ${costWithBasis(
|
||||
usage.estimated_cost_usd ?? 0,
|
||||
usageCostBasis,
|
||||
)} · ${costBasisLabel(usageCostBasis)}`
|
||||
: `${windowDays}일 DB 집계`}
|
||||
</small>
|
||||
</article>
|
||||
|
|
@ -343,11 +441,27 @@ export default function AdminAi() {
|
|||
<article>
|
||||
<span>호출당 비용</span>
|
||||
{/* 단가는 2자리면 0이 되므로 4자리. 원본 정밀도는 title. */}
|
||||
<b title={usage ? costTitle(costPerTurn) : undefined}>
|
||||
{usage ? costLabel(costPerTurn, 4) : "—"}
|
||||
<b
|
||||
title={
|
||||
usage && canShowCostAverage
|
||||
? costTitleWithBasis(costPerTurn, usageCostBasis)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{usage && canShowCostAverage ? costWithBasis(costPerTurn, usageCostBasis, 4) : "—"}
|
||||
</b>
|
||||
<small title={usage && totalTokens > 0 ? costTitle(costPerThousandTokens) : undefined}>
|
||||
1천 토큰당 {usage ? (totalTokens > 0 && tokenUnmeteredTurns === 0 ? costLabel(costPerThousandTokens, 4) : "부분 계량") : "—"}
|
||||
<small
|
||||
title={
|
||||
usage && totalTokens > 0 && tokenUnmeteredTurns === 0 && canShowCostAverage
|
||||
? costTitleWithBasis(costPerThousandTokens, usageCostBasis)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
1천 토큰당 {usage
|
||||
? totalTokens > 0 && tokenUnmeteredTurns === 0 && canShowCostAverage
|
||||
? costWithBasis(costPerThousandTokens, usageCostBasis, 4)
|
||||
: "—"
|
||||
: "—"}
|
||||
</small>
|
||||
</article>
|
||||
</section>
|
||||
|
|
@ -361,7 +475,7 @@ export default function AdminAi() {
|
|||
<span className="aic-eyebrow">예산 관리</span>
|
||||
<h2>예산과 계량 커버리지</h2>
|
||||
</div>
|
||||
<span className={`aic-status aic-status--${usage?.budget.status ?? "disabled"}`}>
|
||||
<span className={`aic-status aic-status--${budgetStatusClass(usage?.budget.status)}`}>
|
||||
{usage ? budgetStatusLabel(usage.budget.status) : "확인 중"}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -371,29 +485,40 @@ export default function AdminAi() {
|
|||
<span>예산 사용</span>
|
||||
<b
|
||||
title={
|
||||
usage && usage.budget.status !== "disabled"
|
||||
? `${costTitle(usage.cost_usd)} / ${costTitle(usage.budget.limit_usd)}`
|
||||
budgetEnabled && usage
|
||||
? `${costTitleWithBasis(usage.cost_usd, budgetCostBasis)} / ${costTitle(
|
||||
usage.budget.limit_usd,
|
||||
)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{usage?.budget.status === "disabled"
|
||||
? "한도 미설정"
|
||||
: `${costLabel(usage?.cost_usd ?? 0)} / ${costLabel(usage?.budget.limit_usd ?? 0)}`}
|
||||
: `${costWithBasis(usage?.cost_usd ?? 0, budgetCostBasis)} / ${costLabel(
|
||||
usage?.budget.limit_usd ?? 0,
|
||||
)}`}
|
||||
</b>
|
||||
</div>
|
||||
<div className="aic-track" aria-label={`예산 사용률 ${rateLabel(usage?.budget.used_ratio ?? 0)}`}>
|
||||
<span style={{ width: `${Math.min(100, (usage?.budget.used_ratio ?? 0) * 100)}%` }} />
|
||||
<div
|
||||
className="aic-track"
|
||||
aria-label={budgetEnabled ? `예산 ${budgetRateText}` : "예산 사용률 0%"}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: `${isUnknownCostBasis(budgetCostBasis) ? 0 : Math.min(100, budgetRate * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
title={
|
||||
usage && usage.budget.status !== "disabled"
|
||||
? `잔여 ${costTitle(usage.budget.remaining_usd ?? 0)}`
|
||||
budgetEnabled && usage
|
||||
? budgetRemainingLabel(usage.budget.remaining_usd, budgetCostBasis)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{usage?.budget.status === "disabled"
|
||||
? "서버의 ADMIN_USAGE_BUDGET_USD를 설정하면 80% 주의와 100% 초과 상태를 표시합니다."
|
||||
: `잔여 ${costLabel(usage?.budget.remaining_usd ?? 0)} · ${rateLabel(usage?.budget.used_ratio ?? 0)} 사용`}
|
||||
: `${budgetRemainingLabel(usage?.budget.remaining_usd, budgetCostBasis)} · ${budgetRateText}`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -426,11 +551,21 @@ export default function AdminAi() {
|
|||
{daily.length > 0 ? (
|
||||
<div className="aic-chart" role="img" aria-label={`${windowDays}일 일별 AI 비용 막대 차트`}>
|
||||
{daily.map((item) => {
|
||||
const height = maxDailyCost > 0 ? Math.max(3, (item.cost_usd / maxDailyCost) * 100) : 3;
|
||||
const itemCostBasis = readCostBasis(item, usageCostBasis);
|
||||
const height =
|
||||
maxDailyCost > 0 && !isUnknownCostBasis(itemCostBasis)
|
||||
? Math.max(3, (item.cost_usd / maxDailyCost) * 100)
|
||||
: 3;
|
||||
return (
|
||||
// 막대 위 라벨은 일별 합계라 소수 2자리, 원본 정밀도는 title 로 남긴다.
|
||||
<div className="aic-chart__day" key={item.day} title={`${item.day} ${costTitle(item.cost_usd)}`}>
|
||||
<span className="aic-chart__value">{costLabel(item.cost_usd)}</span>
|
||||
<div
|
||||
className="aic-chart__day"
|
||||
key={item.day}
|
||||
title={`${item.day} ${costTitleWithBasis(item.cost_usd, itemCostBasis)} · ${costBasisLabel(itemCostBasis)}`}
|
||||
>
|
||||
<span className="aic-chart__value">
|
||||
{costWithBasis(item.cost_usd, itemCostBasis)}
|
||||
</span>
|
||||
<span className="aic-chart__rail">
|
||||
<span className="aic-chart__bar" style={{ height: `${height}%` }} />
|
||||
</span>
|
||||
|
|
@ -468,48 +603,83 @@ export default function AdminAi() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedBreakdown.map((row) => (
|
||||
<tr key={`${row.provider}:${row.model}`}>
|
||||
<td>
|
||||
<b>{row.model}</b>
|
||||
<span>{row.provider}</span>
|
||||
</td>
|
||||
<td>{countLabel(row.turns)}</td>
|
||||
<td className="aic-token-cell">
|
||||
<span>{row.token_metered_turns > 0 ? countLabel(row.tokens_in) : "미계량"}</span>
|
||||
{row.token_unmetered_turns > 0 && row.token_metered_turns > 0 ? (
|
||||
<small>{countLabel(row.token_metered_turns)}/{countLabel(row.turns)}회</small>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="aic-token-cell">
|
||||
<span>{row.token_metered_turns > 0 ? countLabel(row.tokens_out) : "미계량"}</span>
|
||||
{row.token_unmetered_turns > 0 && row.token_metered_turns > 0 ? (
|
||||
<small>{countLabel(row.token_metered_turns)}/{countLabel(row.turns)}회</small>
|
||||
) : null}
|
||||
</td>
|
||||
{/* 비용 미보고 모델은 공식 참조단가 추정임을 숫자와 함께 명시한다. */}
|
||||
<td
|
||||
className="aic-cost-cell"
|
||||
title={row.rate_label ?? costTitle(row.cost_usd)}
|
||||
>
|
||||
<span>
|
||||
{row.cost_basis === "unavailable" ? "미산정" : costLabel(row.cost_usd)}
|
||||
</span>
|
||||
<small>{costBasisLabel(row.cost_basis)}</small>
|
||||
</td>
|
||||
<td title={costTitle(row.turns > 0 ? row.cost_usd / row.turns : 0)}>
|
||||
{row.cost_basis === "unavailable"
|
||||
? "—"
|
||||
: costLabel(row.turns > 0 ? row.cost_usd / row.turns : 0, 4)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="aic-share">
|
||||
<span style={{ width: `${usage && usage.cost_usd > 0 ? (row.cost_usd / usage.cost_usd) * 100 : 0}%` }} />
|
||||
</span>
|
||||
{rateLabel(usage && usage.cost_usd > 0 ? row.cost_usd / usage.cost_usd : 0)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sortedBreakdown.map((row) => {
|
||||
const rowCostBasis = readCostBasis(row, usageCostBasis);
|
||||
const canShowRowAverage =
|
||||
isExactCostBasis(rowCostBasis) || rowCostBasis === "reference_upper_bound";
|
||||
const showRowShare = canShowCostShare && isExactCostBasis(rowCostBasis);
|
||||
const costShare =
|
||||
showRowShare && usage && usage.cost_usd > 0
|
||||
? row.cost_usd / usage.cost_usd
|
||||
: 0;
|
||||
return (
|
||||
<tr key={`${row.provider}:${row.model}`}>
|
||||
<td>
|
||||
<b>{row.model}</b>
|
||||
<span>{row.provider}</span>
|
||||
</td>
|
||||
<td>{countLabel(row.turns)}</td>
|
||||
<td className="aic-token-cell">
|
||||
<span>
|
||||
{row.token_metered_turns > 0
|
||||
? countLabel(row.tokens_in)
|
||||
: "미계량"}
|
||||
</span>
|
||||
{row.token_unmetered_turns > 0 && row.token_metered_turns > 0 ? (
|
||||
<small>
|
||||
{countLabel(row.token_metered_turns)}/{countLabel(row.turns)}회
|
||||
</small>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="aic-token-cell">
|
||||
<span>
|
||||
{row.token_metered_turns > 0
|
||||
? countLabel(row.tokens_out)
|
||||
: "미계량"}
|
||||
</span>
|
||||
{row.token_unmetered_turns > 0 && row.token_metered_turns > 0 ? (
|
||||
<small>
|
||||
{countLabel(row.token_metered_turns)}/{countLabel(row.turns)}회
|
||||
</small>
|
||||
) : null}
|
||||
</td>
|
||||
{/* 비용 미보고 모델은 공식 참조단가 추정임을 숫자와 함께 명시한다. */}
|
||||
<td
|
||||
className="aic-cost-cell"
|
||||
title={
|
||||
row.rate_label ?? costTitleWithBasis(row.cost_usd, rowCostBasis)
|
||||
}
|
||||
>
|
||||
<span>{costWithBasis(row.cost_usd, rowCostBasis)}</span>
|
||||
<small>{costBasisLabel(rowCostBasis)}</small>
|
||||
</td>
|
||||
<td
|
||||
title={
|
||||
canShowRowAverage
|
||||
? costTitleWithBasis(
|
||||
row.turns > 0 ? row.cost_usd / row.turns : 0,
|
||||
rowCostBasis,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{canShowRowAverage
|
||||
? costWithBasis(
|
||||
row.turns > 0 ? row.cost_usd / row.turns : 0,
|
||||
rowCostBasis,
|
||||
4,
|
||||
)
|
||||
: "—"}
|
||||
</td>
|
||||
<td>
|
||||
<span className="aic-share">
|
||||
<span style={{ width: `${costShare * 100}%` }} />
|
||||
</span>
|
||||
{showRowShare ? rateLabel(costShare) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ type GateItem =
|
|||
| { kind: "model_change_gate"; value: ModelChangeGateView }
|
||||
| { kind: "release_gate"; value: ReleaseGateView };
|
||||
|
||||
type ApprovalDecision = HumanApprovalRequest["decision"];
|
||||
type DecisionIntent = "approve" | "hold" | "reject";
|
||||
|
||||
function shortId(value: string, length = 10): string {
|
||||
return value.length > length ? `${value.slice(0, length)}…` : value;
|
||||
}
|
||||
|
|
@ -76,7 +79,7 @@ function gateTitle(item: GateItem): string {
|
|||
return `릴리스 ${item.value.release_id}`;
|
||||
}
|
||||
|
||||
function gateDecision(item: GateItem): HumanApprovalRequest["decision"] {
|
||||
function computedGateDecision(item: GateItem): ApprovalDecision {
|
||||
if (item.kind === "release_gate") {
|
||||
return item.value.qualified ? "approve_promotion" : "reject";
|
||||
}
|
||||
|
|
@ -85,6 +88,43 @@ function gateDecision(item: GateItem): HumanApprovalRequest["decision"] {
|
|||
return "keep_quarantine";
|
||||
}
|
||||
|
||||
function gateApprovalDecision(item: GateItem): ApprovalDecision {
|
||||
if (item.kind === "model_change_gate" && item.value.gate_decision === "rollback") {
|
||||
return "authorize_rollback";
|
||||
}
|
||||
return "approve_promotion";
|
||||
}
|
||||
|
||||
function gateApprovalCandidateReady(item: GateItem): boolean {
|
||||
if (item.kind === "release_gate") return item.value.qualified;
|
||||
return item.value.gate_decision !== "quarantine";
|
||||
}
|
||||
|
||||
function decisionIntent(
|
||||
decision: ApprovalDecision | undefined,
|
||||
approvalDecision: ApprovalDecision,
|
||||
): DecisionIntent | null {
|
||||
if (decision === approvalDecision) return "approve";
|
||||
if (decision === "keep_quarantine") return "hold";
|
||||
if (decision === "reject") return "reject";
|
||||
return null;
|
||||
}
|
||||
|
||||
function decisionIntentLabel(intent: DecisionIntent | null): string {
|
||||
if (intent === "approve") return "승인";
|
||||
if (intent === "hold") return "보류";
|
||||
if (intent === "reject") return "반려";
|
||||
return "결정";
|
||||
}
|
||||
|
||||
function recordedDecisionLabel(decision: ApprovalDecision): string {
|
||||
if (decision === "approve_content") return "콘텐츠 승인 원장 기록됨";
|
||||
if (decision === "approve_promotion") return "승격 승인 원장 기록됨";
|
||||
if (decision === "authorize_rollback") return "롤백 승인 원장 기록됨";
|
||||
if (decision === "keep_quarantine") return "보류 원장 기록됨";
|
||||
return "반려 원장 기록됨";
|
||||
}
|
||||
|
||||
function decisionLabel(item: GateItem): string {
|
||||
if (item.kind === "release_gate") {
|
||||
return item.value.qualified ? "승격 후보" : "게이트 미통과";
|
||||
|
|
@ -94,12 +134,23 @@ function decisionLabel(item: GateItem): string {
|
|||
return "격리 유지";
|
||||
}
|
||||
|
||||
function actionLabel(item: GateItem): string {
|
||||
const decision = gateDecision(item);
|
||||
if (decision === "approve_promotion") return "사람 승인 기록";
|
||||
if (decision === "authorize_rollback") return "롤백 승인 기록";
|
||||
if (decision === "keep_quarantine") return "격리 결정 기록";
|
||||
return "거부 결정 기록";
|
||||
function submitLabel(intent: DecisionIntent | null, approvalDecision: ApprovalDecision): string {
|
||||
if (intent === "approve" && approvalDecision === "authorize_rollback") return "롤백 승인 기록";
|
||||
if (intent === "approve") return "승인 기록";
|
||||
if (intent === "hold") return "보류 기록";
|
||||
if (intent === "reject") return "반려 기록";
|
||||
return "결정 선택 필요";
|
||||
}
|
||||
|
||||
function submitVariant(
|
||||
intent: DecisionIntent | null,
|
||||
approvalDecision: ApprovalDecision,
|
||||
): "primary" | "secondary" | "danger" {
|
||||
if (intent === "reject" || (intent === "approve" && approvalDecision === "authorize_rollback")) {
|
||||
return "danger";
|
||||
}
|
||||
if (intent === "hold") return "secondary";
|
||||
return "primary";
|
||||
}
|
||||
|
||||
function artifactsFor(
|
||||
|
|
@ -174,19 +225,100 @@ function ArtifactRail({ artifacts }: { artifacts: GateArtifactView[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
function DecisionSelector({
|
||||
name,
|
||||
decision,
|
||||
approvalDecision,
|
||||
approvalDescription,
|
||||
approvalAllowed,
|
||||
approvalBlockedReason,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
decision: ApprovalDecision | undefined;
|
||||
approvalDecision: ApprovalDecision;
|
||||
approvalDescription: string;
|
||||
approvalAllowed: boolean;
|
||||
approvalBlockedReason: string;
|
||||
onChange: (decision: ApprovalDecision) => void;
|
||||
}) {
|
||||
const options: Array<{
|
||||
intent: DecisionIntent;
|
||||
value: ApprovalDecision;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
intent: "approve",
|
||||
value: approvalDecision,
|
||||
label: "승인",
|
||||
description: approvalAllowed ? approvalDescription : approvalBlockedReason,
|
||||
},
|
||||
{
|
||||
intent: "hold",
|
||||
value: "keep_quarantine",
|
||||
label: "보류",
|
||||
description: "격리 상태를 유지하고 재검토",
|
||||
},
|
||||
{
|
||||
intent: "reject",
|
||||
value: "reject",
|
||||
label: "반려",
|
||||
description: "후보를 반려하고 원장에 기록",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<fieldset className="cic-decision-picker">
|
||||
<legend>사람 결정</legend>
|
||||
<div className="cic-decision-options">
|
||||
{options.map((option) => {
|
||||
const disabled = option.intent === "approve" && !approvalAllowed;
|
||||
const descriptionId = `${name}-${option.intent}-description`;
|
||||
return (
|
||||
<label
|
||||
className={`cic-decision-option is-${option.intent} ${
|
||||
decision === option.value ? "is-selected" : ""
|
||||
} ${disabled ? "is-disabled" : ""}`}
|
||||
key={option.intent}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={decision === option.value}
|
||||
disabled={disabled}
|
||||
aria-label={option.label}
|
||||
aria-describedby={descriptionId}
|
||||
onChange={() => onChange(option.value)}
|
||||
/>
|
||||
<strong>{option.label}</strong>
|
||||
<small id={descriptionId}>{option.description}</small>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentQualificationCard({
|
||||
item,
|
||||
view,
|
||||
decision,
|
||||
reason,
|
||||
onDecisionChange,
|
||||
onReasonChange,
|
||||
onApprove,
|
||||
onSubmit,
|
||||
approving,
|
||||
}: {
|
||||
item: ContentQualificationView;
|
||||
view: ContinuousImprovementViewResponse;
|
||||
decision: ApprovalDecision | undefined;
|
||||
reason: string;
|
||||
onDecisionChange: (decision: ApprovalDecision) => void;
|
||||
onReasonChange: (value: string) => void;
|
||||
onApprove: () => void;
|
||||
onSubmit: () => void;
|
||||
approving: boolean;
|
||||
}) {
|
||||
const payload = item.draft_payload;
|
||||
|
|
@ -200,19 +332,33 @@ function ContentQualificationCard({
|
|||
);
|
||||
const boundarySafe = isBoundarySafe(view);
|
||||
const evidenceReady = item.source_provenance_uris.length > 0;
|
||||
const canApprove =
|
||||
boundarySafe && Boolean(payload) && evidenceReady && !approval && !catalog && reason.trim().length > 0;
|
||||
const approvalDecision: ApprovalDecision = "approve_content";
|
||||
const approvalAllowed = boundarySafe && Boolean(payload) && evidenceReady && !catalog;
|
||||
const intent = decisionIntent(decision, approvalDecision);
|
||||
const canSubmit =
|
||||
Boolean(intent) &&
|
||||
!approval &&
|
||||
!catalog &&
|
||||
reason.trim().length > 0 &&
|
||||
(intent !== "approve" || approvalAllowed);
|
||||
const requirementId = `content-approval-requirement-${item.qualification_id}`;
|
||||
const titleId = `content-qualification-title-${item.qualification_id}`;
|
||||
const blockedReason = !boundarySafe
|
||||
const approvalBlockedReason = !boundarySafe
|
||||
? "데이터 경계 위반으로 승인 차단"
|
||||
: !payload
|
||||
? "검수 가능한 visible payload가 없어 승인 차단"
|
||||
: !evidenceReady
|
||||
? "repo provenance가 없어 승인 차단"
|
||||
: "승인 조건 충족";
|
||||
const requirementCopy = catalog
|
||||
? "이미 카탈로그에 반영되어 추가 결정을 기록할 수 없음"
|
||||
: !intent
|
||||
? "승인·보류·반려 중 하나를 명시적으로 선택해야 함"
|
||||
: intent === "approve" && !approvalAllowed
|
||||
? approvalBlockedReason
|
||||
: !reason.trim()
|
||||
? "승인 사유를 먼저 입력해야 함"
|
||||
: "visible payload와 repo provenance 검토 준비됨";
|
||||
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 함`
|
||||
: `${decisionIntentLabel(intent)} 결정과 사유를 append-only 원장에 기록할 준비됨`;
|
||||
|
||||
return (
|
||||
<article
|
||||
|
|
@ -294,9 +440,12 @@ function ContentQualificationCard({
|
|||
|
||||
{approval ? (
|
||||
<div className="cic-effect" role="status">
|
||||
<Icon name={catalog ? "check" : "alert"} size={17} />
|
||||
<Icon
|
||||
name={approval.decision === "reject" ? "alert" : catalog ? "check" : "shield"}
|
||||
size={17}
|
||||
/>
|
||||
<div>
|
||||
<strong>{catalog ? "카탈로그 승인 원장 기록됨" : "후보 결정 원장 기록됨"}</strong>
|
||||
<strong>{recordedDecisionLabel(approval.decision)}</strong>
|
||||
<span>
|
||||
{approval.decision} · {dateTimeLabel(approval.created_at)}
|
||||
</span>
|
||||
|
|
@ -307,14 +456,27 @@ function ContentQualificationCard({
|
|||
className="cic-approval-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (canApprove && !approving) onApprove();
|
||||
if (canSubmit && !approving) onSubmit();
|
||||
}}
|
||||
>
|
||||
<label htmlFor={`content-reason-${item.qualification_id}`}>
|
||||
<span>콘텐츠 승인 사유</span>
|
||||
<DecisionSelector
|
||||
name={`content-decision-${item.qualification_id}`}
|
||||
decision={decision}
|
||||
approvalDecision={approvalDecision}
|
||||
approvalDescription="카탈로그 게시를 승인"
|
||||
approvalAllowed={approvalAllowed}
|
||||
approvalBlockedReason={approvalBlockedReason}
|
||||
onChange={onDecisionChange}
|
||||
/>
|
||||
<label
|
||||
className="cic-approval-reason"
|
||||
htmlFor={`content-reason-${item.qualification_id}`}
|
||||
>
|
||||
<span>콘텐츠 결정 사유</span>
|
||||
<input
|
||||
type="text"
|
||||
id={`content-reason-${item.qualification_id}`}
|
||||
name={`content-approval-reason-${item.qualification_id}`}
|
||||
name={`content-decision-reason-${item.qualification_id}`}
|
||||
value={reason}
|
||||
onChange={(event) => onReasonChange(event.target.value)}
|
||||
aria-describedby={requirementId}
|
||||
|
|
@ -325,18 +487,18 @@ function ContentQualificationCard({
|
|||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
variant={submitVariant(intent, approvalDecision)}
|
||||
type="submit"
|
||||
disabled={!canApprove || approving}
|
||||
disabled={!canSubmit || approving}
|
||||
aria-describedby={requirementId}
|
||||
>
|
||||
{approving ? "승인 원장 기록 중…" : "카탈로그 승인"}
|
||||
{approving ? "결정 기록 중…" : submitLabel(intent, approvalDecision)}
|
||||
</Button>
|
||||
<p
|
||||
id={requirementId}
|
||||
className={`cic-approval-requirement ${canApprove ? "is-ready" : !boundarySafe ? "is-blocked" : ""}`}
|
||||
className={`cic-approval-requirement ${canSubmit ? "is-ready" : intent === "approve" && !approvalAllowed ? "is-blocked" : ""}`}
|
||||
>
|
||||
{blockedReason}
|
||||
{requirementCopy}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
|
@ -346,15 +508,19 @@ function ContentQualificationCard({
|
|||
|
||||
function PipelineSection({
|
||||
view,
|
||||
decisions,
|
||||
reasons,
|
||||
setDecision,
|
||||
setReason,
|
||||
approve,
|
||||
submit,
|
||||
approvingId,
|
||||
}: {
|
||||
view: ContinuousImprovementViewResponse;
|
||||
decisions: Partial<Record<string, ApprovalDecision>>;
|
||||
reasons: Record<string, string>;
|
||||
setDecision: (id: string, decision: ApprovalDecision) => void;
|
||||
setReason: (id: string, value: string) => void;
|
||||
approve: (item: ContentQualificationView) => void;
|
||||
submit: (item: ContentQualificationView) => void;
|
||||
approvingId: string | null;
|
||||
}) {
|
||||
const latest = view.content_qualifications.slice(0, 4);
|
||||
|
|
@ -399,9 +565,11 @@ function PipelineSection({
|
|||
key={item.qualification_id}
|
||||
item={item}
|
||||
view={view}
|
||||
decision={decisions[item.qualification_id]}
|
||||
reason={reasons[item.qualification_id] ?? ""}
|
||||
onDecisionChange={(decision) => setDecision(item.qualification_id, decision)}
|
||||
onReasonChange={(value) => setReason(item.qualification_id, value)}
|
||||
onApprove={() => approve(item)}
|
||||
onSubmit={() => submit(item)}
|
||||
approving={approvingId === item.qualification_id}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -414,17 +582,21 @@ function PipelineSection({
|
|||
function GateCard({
|
||||
item,
|
||||
view,
|
||||
decision,
|
||||
reason,
|
||||
onDecisionChange,
|
||||
onReasonChange,
|
||||
onApprove,
|
||||
onSubmit,
|
||||
approving,
|
||||
boundarySafe,
|
||||
}: {
|
||||
item: GateItem;
|
||||
view: ContinuousImprovementViewResponse;
|
||||
decision: ApprovalDecision | undefined;
|
||||
reason: string;
|
||||
onDecisionChange: (decision: ApprovalDecision) => void;
|
||||
onReasonChange: (value: string) => void;
|
||||
onApprove: () => void;
|
||||
onSubmit: () => void;
|
||||
approving: boolean;
|
||||
boundarySafe: boolean;
|
||||
}) {
|
||||
|
|
@ -437,16 +609,32 @@ function GateCard({
|
|||
const effect = view.lifecycle_events.find(
|
||||
(event) => event.target_kind === item.kind && event.target_id === id,
|
||||
);
|
||||
const canSubmit = boundarySafe && complete && !approval && reason.trim().length > 0;
|
||||
const blockedReason = !boundarySafe
|
||||
const approvalDecision = gateApprovalDecision(item);
|
||||
const approvalCandidateReady = gateApprovalCandidateReady(item);
|
||||
const approvalAllowed = boundarySafe && complete && approvalCandidateReady;
|
||||
const intent = decisionIntent(decision, approvalDecision);
|
||||
const canSubmit =
|
||||
Boolean(intent) &&
|
||||
!approval &&
|
||||
reason.trim().length > 0 &&
|
||||
(intent !== "approve" || approvalAllowed);
|
||||
const approvalBlockedReason = !boundarySafe
|
||||
? "데이터 경계 위반으로 승인 차단"
|
||||
: !complete
|
||||
? "필수 증거 4종이 모두 있어야 기록 가능"
|
||||
: !reason.trim()
|
||||
? "승인 사유를 먼저 입력해야 함"
|
||||
: undefined;
|
||||
? "필수 증거 4종 미완료로 승인 차단"
|
||||
: !approvalCandidateReady
|
||||
? item.kind === "release_gate"
|
||||
? "계산 게이트 미통과로 승격 승인 차단"
|
||||
: "계산 결과가 격리 유지라 승격 승인 차단"
|
||||
: "승인 조건 충족";
|
||||
const requirementId = `approval-requirement-${id}`;
|
||||
const requirementCopy = blockedReason ?? "증거 4종과 승인 사유가 준비됨";
|
||||
const requirementCopy = !intent
|
||||
? "승인·보류·반려 중 하나를 명시적으로 선택해야 함"
|
||||
: intent === "approve" && !approvalAllowed
|
||||
? approvalBlockedReason
|
||||
: !reason.trim()
|
||||
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 함`
|
||||
: `${decisionIntentLabel(intent)} 결정과 사유를 append-only 원장에 기록할 준비됨`;
|
||||
|
||||
return (
|
||||
<article className="cic-gate-card" data-gate-id={id}>
|
||||
|
|
@ -458,7 +646,7 @@ function GateCard({
|
|||
<h3>{gateTitle(item)}</h3>
|
||||
<code>{shortId(id, 14)}</code>
|
||||
</div>
|
||||
<span className={`cic-decision cic-decision--${gateDecision(item)}`}>
|
||||
<span className={`cic-decision cic-decision--${computedGateDecision(item)}`}>
|
||||
{decisionLabel(item)}
|
||||
</span>
|
||||
</header>
|
||||
|
|
@ -475,9 +663,12 @@ function GateCard({
|
|||
|
||||
{approval ? (
|
||||
<div className="cic-effect" role="status">
|
||||
<Icon name="check" size={17} />
|
||||
<Icon
|
||||
name={approval.decision === "reject" ? "alert" : approval.decision === "keep_quarantine" ? "shield" : "check"}
|
||||
size={17}
|
||||
/>
|
||||
<div>
|
||||
<strong>append-only 승인 원장 기록됨</strong>
|
||||
<strong>{recordedDecisionLabel(approval.decision)}</strong>
|
||||
<span>
|
||||
{approval.decision} · {dateTimeLabel(approval.created_at)}
|
||||
{effect ? ` · effect ${shortId(effect.lifecycle_event_id, 8)}` : ""}
|
||||
|
|
@ -489,14 +680,26 @@ function GateCard({
|
|||
className="cic-approval-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (canSubmit && !approving) onApprove();
|
||||
if (canSubmit && !approving) onSubmit();
|
||||
}}
|
||||
>
|
||||
<label htmlFor={`reason-${id}`}>
|
||||
<span>사람 승인 사유</span>
|
||||
<DecisionSelector
|
||||
name={`gate-decision-${item.kind}-${id}`}
|
||||
decision={decision}
|
||||
approvalDecision={approvalDecision}
|
||||
approvalDescription={
|
||||
approvalDecision === "authorize_rollback" ? "롤백 실행을 승인" : "승격 효과를 승인"
|
||||
}
|
||||
approvalAllowed={approvalAllowed}
|
||||
approvalBlockedReason={approvalBlockedReason}
|
||||
onChange={onDecisionChange}
|
||||
/>
|
||||
<label className="cic-approval-reason" htmlFor={`reason-${id}`}>
|
||||
<span>사람 결정 사유</span>
|
||||
<input
|
||||
type="text"
|
||||
id={`reason-${id}`}
|
||||
name={`approval-reason-${id}`}
|
||||
name={`decision-reason-${id}`}
|
||||
value={reason}
|
||||
onChange={(event) => onReasonChange(event.target.value)}
|
||||
aria-describedby={requirementId}
|
||||
|
|
@ -507,16 +710,16 @@ function GateCard({
|
|||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={gateDecision(item) === "authorize_rollback" ? "danger" : "primary"}
|
||||
variant={submitVariant(intent, approvalDecision)}
|
||||
type="submit"
|
||||
disabled={!canSubmit || approving}
|
||||
aria-describedby={requirementId}
|
||||
>
|
||||
{approving ? "원장 기록 중…" : complete ? actionLabel(item) : "증거 4종 미완료"}
|
||||
{approving ? "결정 기록 중…" : submitLabel(intent, approvalDecision)}
|
||||
</Button>
|
||||
<p
|
||||
id={requirementId}
|
||||
className={`cic-approval-requirement ${!boundarySafe ? "is-blocked" : canSubmit ? "is-ready" : ""}`}
|
||||
className={`cic-approval-requirement ${canSubmit ? "is-ready" : intent === "approve" && !approvalAllowed ? "is-blocked" : ""}`}
|
||||
>
|
||||
{requirementCopy}
|
||||
</p>
|
||||
|
|
@ -528,15 +731,19 @@ function GateCard({
|
|||
|
||||
function GateSection({
|
||||
view,
|
||||
decisions,
|
||||
reasons,
|
||||
setDecision,
|
||||
setReason,
|
||||
approve,
|
||||
submit,
|
||||
approvingId,
|
||||
}: {
|
||||
view: ContinuousImprovementViewResponse;
|
||||
decisions: Partial<Record<string, ApprovalDecision>>;
|
||||
reasons: Record<string, string>;
|
||||
setDecision: (id: string, decision: ApprovalDecision) => void;
|
||||
setReason: (id: string, value: string) => void;
|
||||
approve: (item: GateItem) => void;
|
||||
submit: (item: GateItem) => void;
|
||||
approvingId: string | null;
|
||||
}) {
|
||||
const items: GateItem[] = [
|
||||
|
|
@ -572,9 +779,11 @@ function GateSection({
|
|||
key={`${item.kind}:${gateId(item)}`}
|
||||
item={item}
|
||||
view={view}
|
||||
decision={decisions[gateId(item)]}
|
||||
reason={reasons[gateId(item)] ?? ""}
|
||||
onDecisionChange={(decision) => setDecision(gateId(item), decision)}
|
||||
onReasonChange={(value) => setReason(gateId(item), value)}
|
||||
onApprove={() => approve(item)}
|
||||
onSubmit={() => submit(item)}
|
||||
approving={approvingId === gateId(item)}
|
||||
boundarySafe={boundarySafe}
|
||||
/>
|
||||
|
|
@ -704,6 +913,7 @@ export function ContinuousImprovementCockpit() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [approvingId, setApprovingId] = useState<string | null>(null);
|
||||
const [decisions, setDecisions] = useState<Partial<Record<string, ApprovalDecision>>>({});
|
||||
const [reasons, setReasons] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
|
|
@ -726,49 +936,68 @@ export function ContinuousImprovementCockpit() {
|
|||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
const approve = useCallback(
|
||||
const recordGateDecision = useCallback(
|
||||
async (item: GateItem) => {
|
||||
if (!view) return;
|
||||
const id = gateId(item);
|
||||
const artifacts = artifactsFor(view, item.kind, id);
|
||||
const approvalDecision = gateApprovalDecision(item);
|
||||
const decision = decisions[id];
|
||||
const intent = decisionIntent(decision, approvalDecision);
|
||||
const reason = reasons[id]?.trim() ?? "";
|
||||
const alreadyApproved = view.approvals.some(
|
||||
(candidate) => candidate.target_kind === item.kind && candidate.target_id === id,
|
||||
);
|
||||
if (!isBoundarySafe(view) || !hasCompleteArtifacts(artifacts) || alreadyApproved || !reason) {
|
||||
const approvalAllowed =
|
||||
isBoundarySafe(view) && hasCompleteArtifacts(artifacts) && gateApprovalCandidateReady(item);
|
||||
if (!decision || !intent || alreadyApproved || !reason || (intent === "approve" && !approvalAllowed)) {
|
||||
return;
|
||||
}
|
||||
setApprovingId(id);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const evidenceRefs = [...new Set(artifacts.map((artifact) => artifact.provenance_uri))];
|
||||
const artifactEvidenceRefs = [
|
||||
...new Set(artifacts.map((artifact) => artifact.provenance_uri)),
|
||||
];
|
||||
const evidenceRefs =
|
||||
artifactEvidenceRefs.length > 0
|
||||
? artifactEvidenceRefs
|
||||
: [`audit://continuous-improvement/human-review/${id}`];
|
||||
const result = await continuousImprovementApi.appendApproval({
|
||||
submission_id: randomUuid(),
|
||||
approval_event_id: randomUuid(),
|
||||
effect_record_id: randomUuid(),
|
||||
target_kind: item.kind,
|
||||
target_id: id,
|
||||
decision: gateDecision(item),
|
||||
decision,
|
||||
reason_code: reason,
|
||||
evidence_refs: evidenceRefs,
|
||||
});
|
||||
setNotice(`append-only 승인과 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`);
|
||||
setNotice(
|
||||
intent === "approve"
|
||||
? `${decisionIntentLabel(intent)}과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`
|
||||
: `${decisionIntentLabel(intent)} 결정을 append-only 원장에 기록 완료`,
|
||||
);
|
||||
setDecisions((current) => ({ ...current, [id]: undefined }));
|
||||
setReasons((current) => ({ ...current, [id]: "" }));
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "승인 원장을 기록하지 못했어.");
|
||||
setError(cause instanceof Error ? cause.message : "사람 결정 원장을 기록하지 못했어.");
|
||||
} finally {
|
||||
setApprovingId(null);
|
||||
}
|
||||
},
|
||||
[load, reasons, view],
|
||||
[decisions, load, reasons, view],
|
||||
);
|
||||
|
||||
const approveQualification = useCallback(
|
||||
const recordContentDecision = useCallback(
|
||||
async (item: ContentQualificationView) => {
|
||||
if (!view) return;
|
||||
const id = item.qualification_id;
|
||||
const approvalDecision: ApprovalDecision = "approve_content";
|
||||
const decision = decisions[id];
|
||||
const intent = decisionIntent(decision, approvalDecision);
|
||||
const reason = reasons[id]?.trim() ?? "";
|
||||
const alreadyApproved = view.approvals.some(
|
||||
(candidate) =>
|
||||
|
|
@ -777,13 +1006,15 @@ export function ContinuousImprovementCockpit() {
|
|||
const alreadyCataloged = view.catalog_entries.some(
|
||||
(candidate) => candidate.qualification_id === id,
|
||||
);
|
||||
const approvalAllowed =
|
||||
isBoundarySafe(view) && Boolean(item.draft_payload) && item.source_provenance_uris.length > 0;
|
||||
if (
|
||||
!isBoundarySafe(view) ||
|
||||
!item.draft_payload ||
|
||||
item.source_provenance_uris.length === 0 ||
|
||||
!decision ||
|
||||
!intent ||
|
||||
alreadyApproved ||
|
||||
alreadyCataloged ||
|
||||
!reason
|
||||
!reason ||
|
||||
(intent === "approve" && !approvalAllowed)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -803,20 +1034,25 @@ export function ContinuousImprovementCockpit() {
|
|||
effect_record_id: randomUuid(),
|
||||
target_kind: "content_qualification",
|
||||
target_id: id,
|
||||
decision: "approve_content",
|
||||
decision,
|
||||
reason_code: reason,
|
||||
evidence_refs: evidenceRefs,
|
||||
});
|
||||
setNotice(`카탈로그 승인과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`);
|
||||
setNotice(
|
||||
intent === "approve"
|
||||
? `콘텐츠 승인과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`
|
||||
: `${decisionIntentLabel(intent)} 결정을 append-only 원장에 기록 완료`,
|
||||
);
|
||||
setDecisions((current) => ({ ...current, [id]: undefined }));
|
||||
setReasons((current) => ({ ...current, [id]: "" }));
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "콘텐츠 승인 원장을 기록하지 못했어.");
|
||||
setError(cause instanceof Error ? cause.message : "콘텐츠 결정 원장을 기록하지 못했어.");
|
||||
} finally {
|
||||
setApprovingId(null);
|
||||
}
|
||||
},
|
||||
[load, reasons, view],
|
||||
[decisions, load, reasons, view],
|
||||
);
|
||||
|
||||
const orderedEvents = useMemo(
|
||||
|
|
@ -853,7 +1089,12 @@ export function ContinuousImprovementCockpit() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="cic" data-testid="continuous-improvement-cockpit" aria-busy={loading}>
|
||||
<div
|
||||
className="cic"
|
||||
data-testid="continuous-improvement-cockpit"
|
||||
data-vignette-admin-root="true"
|
||||
aria-busy={loading}
|
||||
>
|
||||
<header className="cic-hero">
|
||||
<div>
|
||||
<Kicker>관리자 · CONTINUOUS IMPROVEMENT OS</Kicker>
|
||||
|
|
@ -890,16 +1131,24 @@ export function ContinuousImprovementCockpit() {
|
|||
<BoundaryStrip view={view} />
|
||||
<PipelineSection
|
||||
view={view}
|
||||
decisions={decisions}
|
||||
reasons={reasons}
|
||||
setDecision={(id, decision) =>
|
||||
setDecisions((current) => ({ ...current, [id]: decision }))
|
||||
}
|
||||
setReason={(id, value) => setReasons((current) => ({ ...current, [id]: value }))}
|
||||
approve={(item) => void approveQualification(item)}
|
||||
submit={(item) => void recordContentDecision(item)}
|
||||
approvingId={approvingId}
|
||||
/>
|
||||
<GateSection
|
||||
view={view}
|
||||
decisions={decisions}
|
||||
reasons={reasons}
|
||||
setDecision={(id, decision) =>
|
||||
setDecisions((current) => ({ ...current, [id]: decision }))
|
||||
}
|
||||
setReason={(id, value) => setReasons((current) => ({ ...current, [id]: value }))}
|
||||
approve={(item) => void approve(item)}
|
||||
submit={(item) => void recordGateDecision(item)}
|
||||
approvingId={approvingId}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -596,19 +596,108 @@
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.cic-approval-form label,
|
||||
.cic-approval-form label > span {
|
||||
display: block;
|
||||
.cic-decision-picker {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.cic-approval-form label > span {
|
||||
.cic-decision-picker legend,
|
||||
.cic-approval-form .cic-approval-reason > span {
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cic-approval-form input {
|
||||
.cic-decision-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cic-decision-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-surface-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cic-decision-option:focus-within {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--border-focus) 24%, transparent);
|
||||
}
|
||||
|
||||
.cic-decision-option.is-selected.is-approve {
|
||||
border-color: var(--accent-deep);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.cic-decision-option.is-selected.is-hold {
|
||||
border-color: var(--warn-solid);
|
||||
background: var(--warn-tint);
|
||||
}
|
||||
|
||||
.cic-decision-option.is-selected.is-reject {
|
||||
border-color: var(--crit-solid);
|
||||
background: var(--crit-tint);
|
||||
}
|
||||
|
||||
.cic-decision-option.is-disabled {
|
||||
opacity: 0.68;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cic-decision-option input[type="radio"] {
|
||||
grid-row: 1 / -1;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.cic-decision-option.is-hold input[type="radio"] {
|
||||
accent-color: var(--warn-solid);
|
||||
}
|
||||
|
||||
.cic-decision-option.is-reject input[type="radio"] {
|
||||
accent-color: var(--crit-solid);
|
||||
}
|
||||
|
||||
.cic-decision-option strong,
|
||||
.cic-decision-option small {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cic-decision-option strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cic-decision-option small {
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cic-approval-form .cic-approval-reason,
|
||||
.cic-approval-form .cic-approval-reason > span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cic-approval-form input[type="text"] {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 7px 9px;
|
||||
|
|
@ -621,7 +710,7 @@
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cic-approval-form input:focus-visible {
|
||||
.cic-approval-form input[type="text"]:focus-visible {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--border-focus) 24%, transparent);
|
||||
}
|
||||
|
|
@ -1094,6 +1183,10 @@
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cic-decision-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cic-approval-form .vg-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,32 @@
|
|||
gap:var(--sp-6);
|
||||
padding:clamp(22px,4vw,44px);
|
||||
}
|
||||
.ob-account{
|
||||
min-width:0;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:var(--sp-3);
|
||||
padding-bottom:var(--sp-4);
|
||||
border-bottom:1px solid var(--border-subtle);
|
||||
}
|
||||
.ob-account__identity{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:5px;
|
||||
}
|
||||
.ob-account__identity span{
|
||||
color:var(--text-muted);
|
||||
font-size:12px;
|
||||
font-weight:730;
|
||||
}
|
||||
.ob-account__identity strong{
|
||||
min-width:0;
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
font-weight:760;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.ob-head{
|
||||
display:grid;
|
||||
gap:8px;
|
||||
|
|
@ -252,6 +278,13 @@
|
|||
.ob-page{
|
||||
padding:14px;
|
||||
}
|
||||
.ob-account{
|
||||
align-items:stretch;
|
||||
flex-direction:column;
|
||||
}
|
||||
.ob-account .vg-btn{
|
||||
width:100%;
|
||||
}
|
||||
.ob-fields{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue