전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
504
apps/web/src/pages/AdminAi.tsx
Normal file
504
apps/web/src/pages/AdminAi.tsx
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppShell } from "../components/shell/AppShell";
|
||||
import { Button, Field, Icon, Input, Kicker } from "../components/ui";
|
||||
import {
|
||||
adminApi,
|
||||
adminEngineApi,
|
||||
type AdminEngineConfigResponse,
|
||||
type AdminHealthResponse,
|
||||
type AdminUsageResponse,
|
||||
} from "../lib/api";
|
||||
import "./admin/admin-ai.css";
|
||||
|
||||
const WINDOW_OPTIONS = [7, 30, 90] as const;
|
||||
const ENGINE_MODES = ["claude_cli", "claude_api", "openai", "solar"] as const;
|
||||
|
||||
const ENGINE_MODE_LABEL: Record<string, string> = {
|
||||
claude_cli: "Claude CLI 게이트웨이",
|
||||
messages_api: "클라우드 연결",
|
||||
claude_api: "Anthropic API",
|
||||
openai: "OpenAI 호환",
|
||||
solar: "Solar",
|
||||
};
|
||||
|
||||
function countLabel(value: number): string {
|
||||
return Number.isFinite(value) ? Math.max(0, Math.round(value)).toLocaleString("ko-KR") : "0";
|
||||
}
|
||||
|
||||
function costLabel(value: number, precise = false): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "$0";
|
||||
return `$${value.toFixed(precise || value < 0.01 ? 6 : 4)}`;
|
||||
}
|
||||
|
||||
function rateLabel(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0%";
|
||||
return `${(value * 100).toFixed(1).replace(/\.0$/, "")}%`;
|
||||
}
|
||||
|
||||
function dateTimeLabel(value?: number | null): string {
|
||||
if (!value || !Number.isFinite(value)) return "기록 없음";
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value * 1000));
|
||||
}
|
||||
|
||||
function dayLabel(value: string): string {
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("ko-KR", { month: "numeric", day: "numeric" }).format(date);
|
||||
}
|
||||
|
||||
function sourceLabel(usage: AdminUsageResponse): string {
|
||||
return usage.durable && usage.source === "database" ? "운영 DB 원장" : "서버 런타임 임시 집계";
|
||||
}
|
||||
|
||||
function budgetStatusLabel(status: AdminUsageResponse["budget"]["status"]): string {
|
||||
if (status === "exceeded") return "예산 초과";
|
||||
if (status === "warn") return "예산 주의";
|
||||
if (status === "ok") return "예산 정상";
|
||||
return "예산 경고 비활성";
|
||||
}
|
||||
|
||||
function healthStatusLabel(status?: string): string {
|
||||
if (status === "ok") return "정상";
|
||||
if (status === "down") return "중단";
|
||||
if (status === "degraded") return "제한 운영";
|
||||
return "확인 불가";
|
||||
}
|
||||
|
||||
export default function AdminAi() {
|
||||
const [windowDays, setWindowDays] = useState<(typeof WINDOW_OPTIONS)[number]>(30);
|
||||
const [usage, setUsage] = useState<AdminUsageResponse | null>(null);
|
||||
const [health, setHealth] = useState<AdminHealthResponse | null>(null);
|
||||
const [engine, setEngine] = useState<AdminEngineConfigResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const initialLoad = useRef(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [usageResult, healthResult, engineResult] = await Promise.allSettled([
|
||||
adminApi.usage(windowDays),
|
||||
adminApi.health(),
|
||||
adminEngineApi.get(),
|
||||
]);
|
||||
if (usageResult.status === "fulfilled") setUsage(usageResult.value);
|
||||
if (healthResult.status === "fulfilled") setHealth(healthResult.value);
|
||||
if (engineResult.status === "fulfilled") setEngine(engineResult.value);
|
||||
const failures = [usageResult, healthResult, engineResult]
|
||||
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
|
||||
.map((result) =>
|
||||
result.reason instanceof Error ? result.reason.message : "일부 운영 정보를 불러오지 못했습니다.",
|
||||
);
|
||||
if (failures.length > 0) setError([...new Set(failures)].join(" · "));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [windowDays]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoad.current) {
|
||||
initialLoad.current = false;
|
||||
void load();
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void adminApi.usage(windowDays)
|
||||
.then((nextUsage) => {
|
||||
if (active) setUsage(nextUsage);
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (active) {
|
||||
setError(cause instanceof Error ? cause.message : "AI 사용량을 불러오지 못했습니다.");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [windowDays]);
|
||||
|
||||
const saveEngine = async () => {
|
||||
if (!engine || !engine.engine_url.trim() || !engine.model.trim()) return;
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await adminEngineApi.update({
|
||||
engine_mode: engine.engine_mode,
|
||||
engine_url: engine.engine_url.trim(),
|
||||
model: engine.model.trim(),
|
||||
});
|
||||
setEngine(next);
|
||||
setSaved(true);
|
||||
setHealth(await adminApi.health().catch(() => health));
|
||||
window.setTimeout(() => setSaved(false), 2400);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "AI 운영 설정을 저장하지 못했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const daily = usage?.daily_cost ?? [];
|
||||
const maxDailyCost = Math.max(0, ...daily.map((item) => item.cost_usd));
|
||||
const totalTokens = (usage?.tokens_in ?? 0) + (usage?.tokens_out ?? 0);
|
||||
const coverage = 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;
|
||||
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 engineService = health?.services.find((service) => service.key === "engine");
|
||||
const cache = usage?.evaluator_cache;
|
||||
const sortedBreakdown = useMemo(
|
||||
() => [...(usage?.by_provider ?? [])].sort((a, b) => b.cost_usd - a.cost_usd),
|
||||
[usage],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell contextLabel="AI 운영" navRole="admin" wide>
|
||||
<div className="aic" data-testid="admin-ai-page" aria-busy={loading}>
|
||||
<header className="aic-head">
|
||||
<div>
|
||||
<Kicker>관리자 · AI CONTROL LEDGER</Kicker>
|
||||
<h1>AI 운영과 DB 계량</h1>
|
||||
<p>상담 응답 엔진의 적용값과 실제 DB에 기록된 호출·토큰·비용을 한 화면에서 확인합니다.</p>
|
||||
</div>
|
||||
<div className="aic-head__actions">
|
||||
<span className="aic-head__stamp">
|
||||
최근 집계 <b>{dateTimeLabel(usage?.generated_at)}</b>
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leading={<Icon name="arrow-flat" size={15} />}
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
>
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className="aic-alert" role="alert">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="aic-source" aria-label="계량 데이터 원천">
|
||||
<span className={`aic-source__dot ${usage?.durable ? "is-live" : ""}`} aria-hidden="true" />
|
||||
<div>
|
||||
<b>{usage ? sourceLabel(usage) : "계량 원천 확인 중"}</b>
|
||||
<span>
|
||||
{usage?.durable
|
||||
? "호출 완료 시점의 provider·model·token·cost 필드를 DB에서 다시 집계합니다."
|
||||
: "DB 계량이 아니므로 서버 재시작 시 집계가 사라질 수 있습니다."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="aic-window" role="group" aria-label="계량 기간">
|
||||
{WINDOW_OPTIONS.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
className={windowDays === days ? "is-active" : ""}
|
||||
aria-pressed={windowDays === days}
|
||||
onClick={() => setWindowDays(days)}
|
||||
>
|
||||
{days}일
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="aic-ledger" aria-label="AI 사용 핵심 지표">
|
||||
<article>
|
||||
<span>누적 비용</span>
|
||||
<b>{usage ? costLabel(usage.cost_usd) : "—"}</b>
|
||||
<small>{windowDays}일 DB 집계</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>계량 호출</span>
|
||||
<b>{usage ? countLabel(usage.metered_turns) : "—"}</b>
|
||||
<small>전체 {usage ? countLabel(usage.total_turns) : "—"}턴</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>입력 토큰</span>
|
||||
<b>{usage ? countLabel(usage.tokens_in) : "—"}</b>
|
||||
<small>프롬프트·컨텍스트</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>출력 토큰</span>
|
||||
<b>{usage ? countLabel(usage.tokens_out) : "—"}</b>
|
||||
<small>생성 응답</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>호출당 비용</span>
|
||||
<b>{usage ? costLabel(costPerTurn, true) : "—"}</b>
|
||||
<small>
|
||||
1천 토큰당 {usage ? (totalTokens > 0 ? costLabel(costPerThousandTokens, true) : "계산 불가") : "—"}
|
||||
</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div className="aic-grid">
|
||||
<div className="aic-column aic-column--main">
|
||||
<section className="aic-panel aic-budget">
|
||||
<div className="aic-panel__head">
|
||||
<div>
|
||||
<span className="aic-eyebrow">SPEND CONTROL</span>
|
||||
<h2>예산과 계량 커버리지</h2>
|
||||
</div>
|
||||
<span className={`aic-status aic-status--${usage?.budget.status ?? "disabled"}`}>
|
||||
{usage ? budgetStatusLabel(usage.budget.status) : "확인 중"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="aic-budget__split">
|
||||
<div>
|
||||
<div className="aic-metricline">
|
||||
<span>예산 사용</span>
|
||||
<b>
|
||||
{usage?.budget.status === "disabled"
|
||||
? "한도 미설정"
|
||||
: `${costLabel(usage?.cost_usd ?? 0)} / ${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>
|
||||
<p>
|
||||
{usage?.budget.status === "disabled"
|
||||
? "서버의 ADMIN_USAGE_BUDGET_USD를 설정하면 80% 주의와 100% 초과 상태를 표시합니다."
|
||||
: `잔여 ${costLabel(usage?.budget.remaining_usd ?? 0)} · ${rateLabel(usage?.budget.used_ratio ?? 0)} 사용`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="aic-metricline">
|
||||
<span>DB 계량 커버리지</span>
|
||||
<b>{rateLabel(coverage)}</b>
|
||||
</div>
|
||||
<div className="aic-track aic-track--coverage" aria-label={`DB 계량 커버리지 ${rateLabel(coverage)}`}>
|
||||
<span style={{ width: `${Math.min(100, coverage * 100)}%` }} />
|
||||
</div>
|
||||
<p>
|
||||
{unmeteredTurns > 0
|
||||
? `${countLabel(unmeteredTurns)}개 턴은 provider·model·token·cost 계량 필드가 없습니다.`
|
||||
: "조회 기간의 모든 완료 턴에 계량 정보가 연결되어 있습니다."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="aic-panel">
|
||||
<div className="aic-panel__head">
|
||||
<div>
|
||||
<span className="aic-eyebrow">DAILY JOURNAL</span>
|
||||
<h2>일별 비용 흐름</h2>
|
||||
</div>
|
||||
<span className="aic-panel__meta">{daily.length}일 기록</span>
|
||||
</div>
|
||||
{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;
|
||||
return (
|
||||
<div className="aic-chart__day" key={item.day} title={`${item.day} ${costLabel(item.cost_usd)}`}>
|
||||
<span className="aic-chart__value">{costLabel(item.cost_usd)}</span>
|
||||
<span className="aic-chart__rail">
|
||||
<span className="aic-chart__bar" style={{ height: `${height}%` }} />
|
||||
</span>
|
||||
<span className="aic-chart__date">{dayLabel(item.day)}</span>
|
||||
<small>{countLabel(item.turns)}턴</small>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="aic-empty">선택한 기간에 비용이 계량된 AI 호출이 없습니다.</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="aic-panel">
|
||||
<div className="aic-panel__head">
|
||||
<div>
|
||||
<span className="aic-eyebrow">MODEL LEDGER</span>
|
||||
<h2>Provider·모델별 사용 원장</h2>
|
||||
</div>
|
||||
<span className="aic-panel__meta">비용 높은 순</span>
|
||||
</div>
|
||||
{sortedBreakdown.length > 0 ? (
|
||||
<div className="aic-table-wrap">
|
||||
<table className="aic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Provider / 모델</th>
|
||||
<th scope="col">호출</th>
|
||||
<th scope="col">입력</th>
|
||||
<th scope="col">출력</th>
|
||||
<th scope="col">비용</th>
|
||||
<th scope="col">호출당</th>
|
||||
<th scope="col">비용 비중</th>
|
||||
</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>{countLabel(row.tokens_in)}</td>
|
||||
<td>{countLabel(row.tokens_out)}</td>
|
||||
<td>{costLabel(row.cost_usd)}</td>
|
||||
<td>{costLabel(row.turns > 0 ? row.cost_usd / row.turns : 0, true)}</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aic-empty">모델별 계량 행이 아직 없습니다.</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="aic-column aic-column--side">
|
||||
<section className="aic-panel aic-engine">
|
||||
<div className="aic-panel__head">
|
||||
<div>
|
||||
<span className="aic-eyebrow">RUNTIME ROUTE</span>
|
||||
<h2>AI 엔진 설정</h2>
|
||||
</div>
|
||||
<span className={`aic-runtime aic-runtime--${engineService?.status ?? "degraded"}`}>
|
||||
{healthStatusLabel(engineService?.status)}
|
||||
</span>
|
||||
</div>
|
||||
{engine ? (
|
||||
<>
|
||||
<div className="aic-mode" role="radiogroup" aria-label="AI 운영 방식">
|
||||
{ENGINE_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={engine.engine_mode === mode}
|
||||
className={engine.engine_mode === mode ? "is-active" : ""}
|
||||
onClick={() => setEngine({ ...engine, engine_mode: mode })}
|
||||
>
|
||||
{ENGINE_MODE_LABEL[mode]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Field label="연결 주소" hint="응답 생성 게이트웨이 또는 API base URL입니다." htmlFor="aic-engine-url">
|
||||
<Input
|
||||
id="aic-engine-url"
|
||||
value={engine.engine_url}
|
||||
onChange={(event) => setEngine({ ...engine, engine_url: event.target.value })}
|
||||
aria-label="AI 연결 주소"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="기본 모델" hint="개별 평가 모델 override가 없을 때 적용됩니다." htmlFor="aic-engine-model">
|
||||
<Input
|
||||
id="aic-engine-model"
|
||||
value={engine.model}
|
||||
onChange={(event) => setEngine({ ...engine, model: event.target.value })}
|
||||
aria-label="AI 기본 모델"
|
||||
/>
|
||||
</Field>
|
||||
<dl className="aic-config-meta">
|
||||
<div><dt>저장 원천</dt><dd>{engine.durable ? "DB 영구 저장" : "런타임 적용"}</dd></div>
|
||||
<div><dt>현재 소스</dt><dd>{engine.source}</dd></div>
|
||||
<div><dt>최근 변경</dt><dd>{dateTimeLabel(engine.updated_at)}</dd></div>
|
||||
<div><dt>변경자</dt><dd>{engine.updated_by ?? "기록 없음"}</dd></div>
|
||||
</dl>
|
||||
<div className="aic-engine__foot">
|
||||
<span>{saved ? <><Icon name="check" size={15} /> 저장됨</> : engineService?.detail ?? "엔진 상태를 확인합니다."}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
leading={<Icon name="check" size={14} />}
|
||||
onClick={() => void saveEngine()}
|
||||
disabled={saving || !engine.engine_url.trim() || !engine.model.trim()}
|
||||
>
|
||||
{saving ? "저장 중" : "운영 설정 저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="aic-empty">현재 AI 엔진 설정을 불러오는 중입니다.</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="aic-panel">
|
||||
<div className="aic-panel__head">
|
||||
<div>
|
||||
<span className="aic-eyebrow">EVALUATOR CACHE</span>
|
||||
<h2>평가 캐시 효율</h2>
|
||||
</div>
|
||||
<span className={`aic-runtime ${cache?.enabled ? "aic-runtime--ok" : ""}`}>
|
||||
{cache?.enabled ? "사용 중" : "비활성"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="aic-cache-score">
|
||||
<b>{cache ? rateLabel(cache.hit_rate) : "—"}</b>
|
||||
<span>hit-rate</span>
|
||||
</div>
|
||||
<dl className="aic-mini-grid">
|
||||
<div><dt>요청</dt><dd>{countLabel(cache?.requests ?? 0)}</dd></div>
|
||||
<div><dt>적중</dt><dd>{countLabel(cache?.hits ?? 0)}</dd></div>
|
||||
<div><dt>미스</dt><dd>{countLabel(cache?.misses ?? 0)}</dd></div>
|
||||
<div><dt>저장</dt><dd>{countLabel(cache?.stores ?? 0)}</dd></div>
|
||||
<div><dt>현재 엔트리</dt><dd>{countLabel(cache?.entries ?? 0)}</dd></div>
|
||||
<div><dt>축출</dt><dd>{countLabel(cache?.evictions ?? 0)}</dd></div>
|
||||
</dl>
|
||||
<p className="aic-note">캐시는 성공적으로 파싱된 평가 결과만 보관하며 원문 프롬프트와 응답은 저장하지 않습니다.</p>
|
||||
</section>
|
||||
|
||||
<section className="aic-panel">
|
||||
<div className="aic-panel__head">
|
||||
<div>
|
||||
<span className="aic-eyebrow">METERING INTEGRITY</span>
|
||||
<h2>DB 계량 상태</h2>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="aic-integrity">
|
||||
<div>
|
||||
<dt>원천</dt>
|
||||
<dd>{usage ? sourceLabel(usage) : "확인 중"}</dd>
|
||||
<span>{usage?.source ?? "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<dt>영속성</dt>
|
||||
<dd>{usage?.durable ? "재시작 후 유지" : "런타임 한정"}</dd>
|
||||
<span>{usage?.durable ? "durable=true" : "durable=false"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<dt>미계량 턴</dt>
|
||||
<dd>{countLabel(unmeteredTurns)}건</dd>
|
||||
<span>{unmeteredTurns > 0 ? "감사 필요" : "누락 없음"}</span>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue