|
|
|
@@ -1,16 +1,18 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
// 처리 머신 보드 v2 — 파이프라인 흐름 뷰 (plan ds-board-engines-1, R2 통합안).
|
|
|
|
|
// 메인 = 좌→우 흐름 노드(병목 amber·실패 뱃지), 노드 클릭 = 상세 패널(안1 변형),
|
|
|
|
|
// 실패 뱃지 클릭 = 실패 처리 드로어 (재시도/건너뛰기 — 영구 실패의 유일한 조치 경로).
|
|
|
|
|
// 데이터 = GET /api/queue/overview (60s 폴링 store) + GET /api/queue/failed (드로어 열 때).
|
|
|
|
|
// 처리 머신 보드 v3 — 통합안 (plan ds-board-merged: C2 머신레인 + C3 번다운/정직ETA).
|
|
|
|
|
// · 머신 3레인(GPU/맥미니/맥북) = "누가 일하나" + 요약 오프로드(맥북 합류) 가시화
|
|
|
|
|
// · 지배 백로그 번다운 패널 = "언제 끝나나" + 유입 차감한 정직 ETA(summarize_eta)
|
|
|
|
|
// · 신선도 '갱신 N초 전' + stale 경고 / 실패 드로어·상세 패널은 v2 자산 재사용.
|
|
|
|
|
// 데이터 = GET /api/queue/overview (60s 폴링 store) + GET /api/queue/failed (드로어).
|
|
|
|
|
import { api } from '$lib/api';
|
|
|
|
|
import { refreshQueueOverview } from '$lib/stores/queueOverview';
|
|
|
|
|
import { refreshQueueOverview, queueUpdatedAt } from '$lib/stores/queueOverview';
|
|
|
|
|
import { addToast } from '$lib/stores/toast';
|
|
|
|
|
import {
|
|
|
|
|
AUX_NODES,
|
|
|
|
|
FLOW_NODES,
|
|
|
|
|
MACHINE_META,
|
|
|
|
|
type FlowNodeDef,
|
|
|
|
|
type FlowMachine,
|
|
|
|
|
etaShort,
|
|
|
|
|
flowStageLabel,
|
|
|
|
|
formatAgeSec,
|
|
|
|
@@ -20,6 +22,7 @@
|
|
|
|
|
FailedItem,
|
|
|
|
|
FailedListResponse,
|
|
|
|
|
MachineCurrentItem,
|
|
|
|
|
MachineOverview,
|
|
|
|
|
QueueOverview,
|
|
|
|
|
QueueStageRow,
|
|
|
|
|
RetryResponse,
|
|
|
|
@@ -82,14 +85,6 @@
|
|
|
|
|
);
|
|
|
|
|
const totalFailed = $derived(overview.totals.failed);
|
|
|
|
|
|
|
|
|
|
// 머신 스트립 — overview.machines 의 state/처리율 + 정적 모델 메타
|
|
|
|
|
const machineStrip = $derived(
|
|
|
|
|
overview.machines.map((m) => ({
|
|
|
|
|
...m,
|
|
|
|
|
meta: MACHINE_META[m.key],
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ─── 선택 상태 (노드 상세 / 실패 드로어 — 동시에 하나만) ───
|
|
|
|
|
let selected = $state<string | null>(null);
|
|
|
|
|
let failOpen = $state(false);
|
|
|
|
@@ -194,22 +189,105 @@
|
|
|
|
|
await Promise.all([loadFailures(), refreshQueueOverview()]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── trend_24h 스파크라인 (summarize 유입 vs 소화 — API 가 주는데 미렌더이던 슬롯) ───
|
|
|
|
|
const spark = $derived.by(() => {
|
|
|
|
|
// ─── 머신 레인 (C2) — mainNodes 를 머신별로 그룹 + 머신 카드(state/처리율) 결합 ───
|
|
|
|
|
const machineByKey = $derived(
|
|
|
|
|
new Map<FlowMachine, MachineOverview>(overview.machines.map((m) => [m.key as FlowMachine, m])),
|
|
|
|
|
);
|
|
|
|
|
const LANE_ORDER: FlowMachine[] = ['gpu', 'macmini', 'macbook'];
|
|
|
|
|
const lanes = $derived(
|
|
|
|
|
LANE_ORDER.map((key) => ({
|
|
|
|
|
key,
|
|
|
|
|
meta: MACHINE_META[key],
|
|
|
|
|
card: machineByKey.get(key) ?? null,
|
|
|
|
|
nodes: mainNodes.filter((n) => n.def.machine === key),
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 요약 오프로드 분담 — 맥미니 vs 맥북 (A-1 summarize_by_machine)
|
|
|
|
|
const split = $derived(overview.summarize_by_machine);
|
|
|
|
|
const splitTotal1h = $derived(Math.max(1, split.macmini.done_1h + split.macbook.done_1h));
|
|
|
|
|
const macbookSharePct = $derived(Math.round((split.macbook.done_1h / splitTotal1h) * 100));
|
|
|
|
|
// 맥북이 요약을 실제로 가져가는 중인가 (합류 표식 게이트)
|
|
|
|
|
const offloadActive = $derived(split.macbook.done_1h > 0);
|
|
|
|
|
|
|
|
|
|
// ─── 지배 백로그 = 요약. 정직 ETA(유입 차감) — summarize_eta ───
|
|
|
|
|
const eta = $derived(overview.summarize_eta);
|
|
|
|
|
// 정직 ETA 라벨: eta_minutes null = 유입이 소화를 앞섬(소진 불가)
|
|
|
|
|
const honestEtaLabel = $derived(
|
|
|
|
|
eta.pending === 0
|
|
|
|
|
? '비어 있음'
|
|
|
|
|
: eta.eta_minutes != null
|
|
|
|
|
? etaShort(eta.eta_minutes)
|
|
|
|
|
: '소진 불가',
|
|
|
|
|
);
|
|
|
|
|
const honestEtaWarn = $derived(eta.pending > 0 && eta.eta_minutes == null);
|
|
|
|
|
|
|
|
|
|
/** 단계별 정직 ETA(순소화율) — 노드용. 유입>소화면 null(소진 불가) */
|
|
|
|
|
function netEtaLabel(n: NodeStats): string | null {
|
|
|
|
|
if (n.pending === 0) return '한가';
|
|
|
|
|
const net = n.done1h - n.created1h;
|
|
|
|
|
if (net > 0) return etaShort(Math.round((n.pending / net) * 60));
|
|
|
|
|
if (n.created1h > n.done1h) return '유입 우세';
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── 신선도 (B-4) — '갱신 N초 전' + stale 경고 (폴링 60s) ───
|
|
|
|
|
let now = $state(Date.now());
|
|
|
|
|
$effect(() => {
|
|
|
|
|
const id = setInterval(() => (now = Date.now()), 1000);
|
|
|
|
|
return () => clearInterval(id);
|
|
|
|
|
});
|
|
|
|
|
const ageSec = $derived(
|
|
|
|
|
$queueUpdatedAt != null ? Math.max(0, Math.round((now - $queueUpdatedAt) / 1000)) : null,
|
|
|
|
|
);
|
|
|
|
|
const stale = $derived(ageSec != null && ageSec > 90);
|
|
|
|
|
const freshLabel = $derived(
|
|
|
|
|
ageSec == null
|
|
|
|
|
? '갱신 대기'
|
|
|
|
|
: ageSec < 60
|
|
|
|
|
? `갱신 ${ageSec}초 전`
|
|
|
|
|
: `갱신 ${Math.round(ageSec / 60)}분 전`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ─── 24h 번다운 (C3) — 요약 유입 vs 소화 + 맥북 합류 변곡점 마커 ───
|
|
|
|
|
const burn = $derived.by(() => {
|
|
|
|
|
const t = overview.trend_24h;
|
|
|
|
|
if (!t || t.length === 0) return null;
|
|
|
|
|
const max = Math.max(1, ...t.map((b) => Math.max(b.inflow, b.done)));
|
|
|
|
|
const w = 120;
|
|
|
|
|
const h = 24;
|
|
|
|
|
const w = 300;
|
|
|
|
|
const h = 64;
|
|
|
|
|
const step = w / Math.max(1, t.length - 1);
|
|
|
|
|
const pts = (sel: (b: (typeof t)[number]) => number) =>
|
|
|
|
|
t.map((b, i) => `${(i * step).toFixed(1)},${(h - (sel(b) / max) * (h - 3) + 1).toFixed(1)}`).join(' ');
|
|
|
|
|
return { inflow: pts((b) => b.inflow), done: pts((b) => b.done) };
|
|
|
|
|
const y = (v: number) => (h - (v / max) * (h - 8) + 4).toFixed(1);
|
|
|
|
|
const line = (sel: (b: (typeof t)[number]) => number) =>
|
|
|
|
|
t.map((b, i) => `${(i * step).toFixed(1)},${y(sel(b))}`).join(' ');
|
|
|
|
|
const doneLine = line((b) => b.done);
|
|
|
|
|
const area = `0,${h} ${doneLine} ${w.toFixed(1)},${h}`;
|
|
|
|
|
// 합류 변곡점 = done 최대 버킷 (맥북 야간 drain 합류 추정)
|
|
|
|
|
let mi = 0;
|
|
|
|
|
t.forEach((b, i) => {
|
|
|
|
|
if (b.done > t[mi].done) mi = i;
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
w,
|
|
|
|
|
h,
|
|
|
|
|
area,
|
|
|
|
|
doneLine,
|
|
|
|
|
inflowLine: line((b) => b.inflow),
|
|
|
|
|
markX: (mi * step).toFixed(1),
|
|
|
|
|
markHour: t[mi].hour,
|
|
|
|
|
markDone: t[mi].done,
|
|
|
|
|
peak: max,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 머신 상태 dot 색 클래스
|
|
|
|
|
function dotClass(state: string): string {
|
|
|
|
|
return state === 'active' ? 'bg-success' : state === 'deferred' ? 'bg-warning' : 'bg-faint';
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="mt-5">
|
|
|
|
|
<!-- 헤더: 타이틀 + 요약 24h 스파크라인 + 실패 합계 -->
|
|
|
|
|
<!-- 헤더: 타이틀 + 신선도 + 실패 합계 -->
|
|
|
|
|
<div class="flex items-center justify-between gap-3 mb-3">
|
|
|
|
|
<div class="text-[11px] font-bold text-dim uppercase tracking-wider">처리 머신</div>
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
@@ -219,80 +297,107 @@
|
|
|
|
|
onclick={openFailures}
|
|
|
|
|
>실패 {totalFailed}건 처리</button>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if spark}
|
|
|
|
|
<div class="flex items-center gap-2 text-[10px] text-faint tabular-nums" title="요약(summarize) 단계 24시간 — 유입(회색) vs 소화(녹색)">
|
|
|
|
|
<svg width="120" height="24" viewBox="0 0 120 24" class="block">
|
|
|
|
|
<polyline points={spark.inflow} fill="none" stroke="currentColor" stroke-width="1.5" class="text-faint" />
|
|
|
|
|
<polyline points={spark.done} fill="none" stroke="currentColor" stroke-width="1.5" class="text-success" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span>요약 24h 유입/소화</span>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
<span class="flex items-center gap-1.5 text-[10px] tabular-nums {stale ? 'text-warning' : 'text-faint'}" title="60초 폴링">
|
|
|
|
|
<span class="w-1.5 h-1.5 rounded-full {stale ? 'bg-warning' : 'bg-success'}"></span>
|
|
|
|
|
{freshLabel}{#if stale} · 갱신 지연{/if}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 머신 스트립 -->
|
|
|
|
|
<div class="flex flex-wrap gap-2 mb-3">
|
|
|
|
|
{#each machineStrip as m (m.key)}
|
|
|
|
|
<div class="flex items-center gap-2 bg-surface border border-default rounded-full px-3.5 py-1.5 text-xs">
|
|
|
|
|
<span class="w-2 h-2 rounded-full shrink-0 {m.state === 'active' ? 'bg-success' : m.state === 'deferred' ? 'bg-warning' : 'bg-faint'}"></span>
|
|
|
|
|
<span class="font-bold text-text">{m.meta?.label ?? m.label}</span>
|
|
|
|
|
<span class="text-[10px] text-faint font-mono">{m.meta?.model}</span>
|
|
|
|
|
<span class="text-[11px] text-dim tabular-nums">{formatRate(m.done_1h)}/h</span>
|
|
|
|
|
{#if m.key === 'macbook' && m.deferred_pending > 0}
|
|
|
|
|
<span class="text-[10px] font-semibold text-warning tabular-nums">보류 {m.deferred_pending}</span>
|
|
|
|
|
{/if}
|
|
|
|
|
<!-- 지배 백로그 스트립 (요약) + 정직 ETA -->
|
|
|
|
|
<div class="flex items-center flex-wrap gap-x-3 gap-y-1 bg-surface border border-warning/50 rounded-card px-3.5 py-2 mb-3">
|
|
|
|
|
<span class="text-[9px] font-bold text-warning border border-warning/60 rounded-full px-2 py-px">지배 백로그</span>
|
|
|
|
|
<span class="text-xs font-bold text-text">요약</span>
|
|
|
|
|
<span class="text-[11px] text-dim tabular-nums">대기 <b class="text-text">{eta.pending.toLocaleString()}</b> · 순소화 <b class="text-text">{formatRate(eta.done_rate_1h)}</b>/h · 유입 {formatRate(eta.inflow_rate_1h)}/h</span>
|
|
|
|
|
<span class="ml-auto flex items-center gap-1.5 border rounded-full px-2.5 py-0.5 {honestEtaWarn ? 'border-warning text-warning' : 'border-accent text-accent'}">
|
|
|
|
|
<span class="text-[10px] font-semibold">정직 ETA</span>
|
|
|
|
|
<span class="text-xs font-bold tabular-nums">{honestEtaLabel}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 머신 레인 (누가 일하나 + 요약 오프로드) -->
|
|
|
|
|
<div class="grid gap-2 mb-3">
|
|
|
|
|
{#each lanes as lane (lane.key)}
|
|
|
|
|
<div class="bg-surface border border-default rounded-card px-3.5 py-2.5">
|
|
|
|
|
<div class="flex items-center gap-2 flex-wrap mb-2">
|
|
|
|
|
<span class="w-2 h-2 rounded-full shrink-0 {dotClass(lane.card?.state ?? 'idle')}"></span>
|
|
|
|
|
<span class="text-[9px] font-bold rounded px-1.5 py-px mtag-{lane.key}">{lane.meta.label}</span>
|
|
|
|
|
<span class="text-[10px] text-faint font-mono">{lane.meta.model}</span>
|
|
|
|
|
<span class="text-[11px] text-dim tabular-nums ml-1">{formatRate(lane.card?.done_1h ?? 0)}/h</span>
|
|
|
|
|
{#if lane.key === 'macbook' && (lane.card?.deferred_pending ?? 0) > 0}
|
|
|
|
|
<span class="text-[10px] font-semibold text-warning tabular-nums">보류 {lane.card?.deferred_pending}</span>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if lane.card?.state === 'deferred'}
|
|
|
|
|
<span class="text-[9px] text-warning">잠듦 — 요약은 맥미니로 복귀</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex items-stretch gap-1.5 flex-wrap">
|
|
|
|
|
{#each lane.nodes as n (n.def.key)}
|
|
|
|
|
{@const idle = n.pending + n.processing + n.doneToday + n.failed === 0}
|
|
|
|
|
<button
|
|
|
|
|
class="relative text-left rounded-lg border px-2.5 py-1.5 transition-colors cursor-pointer hover:bg-surface-hover min-w-[96px]
|
|
|
|
|
{idle ? 'border-dashed border-default opacity-55' : n.inflowDominant ? 'border-warning' : 'border-default'}
|
|
|
|
|
{selected === n.def.key ? 'node-sel' : ''}"
|
|
|
|
|
onclick={() => toggleNode(n.def.key)}
|
|
|
|
|
title="{n.def.label} — 클릭하면 상세"
|
|
|
|
|
>
|
|
|
|
|
{#if n.failed > 0}
|
|
|
|
|
<span class="absolute -top-1.5 -right-1 text-[9px] font-extrabold bg-error text-white rounded-full px-1.5">{n.failed}</span>
|
|
|
|
|
{/if}
|
|
|
|
|
<div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">
|
|
|
|
|
{n.def.label}
|
|
|
|
|
{#if n.processing > 0}<span class="inline-block w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="text-sm font-extrabold tabular-nums leading-tight text-text">{n.pending.toLocaleString()}<span class="text-[9px] text-faint font-normal ml-0.5">대기</span></div>
|
|
|
|
|
<div class="text-[9px] text-dim tabular-nums whitespace-nowrap">{formatRate(n.done1h)}/h · 오늘 {n.doneToday.toLocaleString()}</div>
|
|
|
|
|
{#if n.def.key === 'summarize'}
|
|
|
|
|
<div class="mt-1 h-1 w-full rounded-full overflow-hidden flex" title="맥미니 {split.macmini.done_1h}/h · 맥북 {split.macbook.done_1h}/h">
|
|
|
|
|
<span class="block h-full mtag-macmini-bar" style="width:{100 - macbookSharePct}%"></span>
|
|
|
|
|
<span class="block h-full mtag-macbook-bar" style="width:{macbookSharePct}%"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="text-[9px] text-faint tabular-nums whitespace-nowrap mt-0.5">맥미니 {split.macmini.done_1h} · 맥북 {split.macbook.done_1h}/h</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</button>
|
|
|
|
|
{/each}
|
|
|
|
|
{#if lane.key === 'macbook' && offloadActive}
|
|
|
|
|
<button
|
|
|
|
|
class="text-left rounded-lg border border-dashed border-warning/50 px-2.5 py-1.5 cursor-pointer hover:bg-surface-hover min-w-[96px]"
|
|
|
|
|
onclick={() => toggleNode('summarize')}
|
|
|
|
|
title="맥북이 요약을 맥미니에서 가져와 처리 중"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">요약 합류 <span class="text-[8px] font-bold text-warning">OFFLOAD</span></div>
|
|
|
|
|
<div class="text-sm font-extrabold tabular-nums leading-tight text-text">{split.macbook.done_1h}<span class="text-[9px] text-faint font-normal ml-0.5">/h</span></div>
|
|
|
|
|
<div class="text-[9px] text-dim tabular-nums whitespace-nowrap">요약의 {macbookSharePct}% 담당</div>
|
|
|
|
|
</button>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 흐름 노드 — pt/px 헤드룸 = 실패 뱃지(-top/-right 돌출)가 스크롤 컨테이너에 잘리지 않게 -->
|
|
|
|
|
<div class="flex items-stretch overflow-x-auto pt-2.5 pb-1 px-2 -mx-2">
|
|
|
|
|
{#each mainNodes as n, i (n.def.key)}
|
|
|
|
|
{#if i > 0}
|
|
|
|
|
<div class="flex items-center text-faint text-sm px-1.5 shrink-0" aria-hidden="true">→</div>
|
|
|
|
|
{/if}
|
|
|
|
|
<div
|
|
|
|
|
class="relative bg-surface border-[1.5px] rounded-card px-3 py-2.5 min-w-[124px] shrink-0 text-left transition-colors cursor-pointer hover:bg-surface-hover
|
|
|
|
|
{n.inflowDominant ? 'border-warning' : n.etaMinutes != null && n.def.stages.includes('chunk') ? 'border-success' : 'border-default'}
|
|
|
|
|
{selected === n.def.key ? 'node-sel' : ''}"
|
|
|
|
|
role="button"
|
|
|
|
|
tabindex="0"
|
|
|
|
|
onclick={() => toggleNode(n.def.key)}
|
|
|
|
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleNode(n.def.key); } }}
|
|
|
|
|
title="{n.def.label} — 클릭하면 상세"
|
|
|
|
|
>
|
|
|
|
|
{#if n.failed > 0}
|
|
|
|
|
<button
|
|
|
|
|
class="absolute -top-2 -right-1.5 text-[9px] font-extrabold bg-error text-white rounded-full px-1.5 py-px shadow cursor-pointer"
|
|
|
|
|
onclick={(e) => { e.stopPropagation(); openFailures(); }}
|
|
|
|
|
title="실패 {n.failed}건 — 클릭하면 실패 처리"
|
|
|
|
|
>{n.failed}</button>
|
|
|
|
|
{/if}
|
|
|
|
|
<span class="inline-block text-[9px] font-bold rounded px-1.5 py-px mb-1.5 mtag-{n.def.machine}">
|
|
|
|
|
{MACHINE_META[n.def.machine].label} · {n.def.engine}
|
|
|
|
|
</span>
|
|
|
|
|
<div class="text-xs font-bold text-text flex items-center gap-1.5">
|
|
|
|
|
{n.def.label}
|
|
|
|
|
{#if n.processing > 0}
|
|
|
|
|
<span class="inline-block w-1.5 h-1.5 rounded-full bg-accent animate-pulse" title="처리 중 {n.processing}"></span>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if n.inflowDominant}
|
|
|
|
|
<span class="text-[9px] font-bold text-warning">유입 우세</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="text-base font-extrabold tabular-nums tracking-tight leading-tight mt-0.5 text-text">
|
|
|
|
|
{n.pending.toLocaleString()}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="text-[10px] text-dim tabular-nums">
|
|
|
|
|
{formatRate(n.done1h)}/h · 오늘 {n.doneToday.toLocaleString()}
|
|
|
|
|
{#if n.etaMinutes != null && !n.inflowDominant && n.pending > 0}
|
|
|
|
|
· <span class="text-accent font-semibold">{etaShort(n.etaMinutes)}</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<!-- 번다운 / ETA 패널 -->
|
|
|
|
|
{#if burn}
|
|
|
|
|
<div class="bg-surface border border-default rounded-card px-3.5 py-3 mb-1">
|
|
|
|
|
<div class="flex items-center gap-2 mb-2">
|
|
|
|
|
<span class="text-[11px] font-bold text-text">요약 백로그 24시간</span>
|
|
|
|
|
<span class="text-[9px] text-faint">유입(회색) vs 소화(녹색)</span>
|
|
|
|
|
{#if offloadActive}<span class="text-[9px] text-warning ml-auto">맥북 합류 {burn.markHour} — 소화 급증</span>{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
<svg viewBox="0 0 {burn.w} {burn.h}" class="block w-full" style="height:64px" preserveAspectRatio="none" role="img" aria-label="요약 백로그 24시간 번다운">
|
|
|
|
|
<polygon points={burn.area} fill="currentColor" class="text-success" opacity="0.12" />
|
|
|
|
|
<polyline points={burn.inflowLine} fill="none" stroke="currentColor" stroke-width="1.2" class="text-faint" />
|
|
|
|
|
<polyline points={burn.doneLine} fill="none" stroke="currentColor" stroke-width="1.6" class="text-success" />
|
|
|
|
|
{#if offloadActive}
|
|
|
|
|
<line x1={burn.markX} y1="0" x2={burn.markX} y2={burn.h} stroke="currentColor" stroke-width="1" stroke-dasharray="2 2" class="text-warning" opacity="0.7" />
|
|
|
|
|
{/if}
|
|
|
|
|
</svg>
|
|
|
|
|
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 pt-2 border-t border-default text-[10px] text-dim tabular-nums">
|
|
|
|
|
{#each mainNodes.filter((n) => n.pending > 0 && n.def.key !== 'summarize') as n (n.def.key)}
|
|
|
|
|
<span class="whitespace-nowrap">{n.def.label} 대기 <b class="text-text">{n.pending.toLocaleString()}</b>{#if netEtaLabel(n)} · <span class="text-accent font-semibold">{netEtaLabel(n)}</span>{/if}</span>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- 보조 라인 -->
|
|
|
|
|
<p class="text-[10px] text-faint mt-1.5 tabular-nums">
|
|
|
|
@@ -413,6 +518,9 @@
|
|
|
|
|
.mtag-gpu { background: #e7eef6; color: #3b6ea5; }
|
|
|
|
|
.mtag-macmini { background: #efe9f7; color: #8a5fbf; }
|
|
|
|
|
.mtag-macbook { background: #f7eedd; color: #b07a10; }
|
|
|
|
|
/* 요약 오프로드 분담 막대 채움 (맥미니 보라 / 맥북 황) */
|
|
|
|
|
.mtag-macmini-bar { background: #8a5fbf; }
|
|
|
|
|
.mtag-macbook-bar { background: #b07a10; }
|
|
|
|
|
.node-sel { outline: 2px solid #3b6ea5; outline-offset: 1px; }
|
|
|
|
|
.detail-frame { border-color: #3b6ea5; }
|
|
|
|
|
.detail-head { background: #e7eef6; }
|
|
|
|
|