Compare commits
6 Commits
e610599879
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf898d78be | ||
|
|
73593adb5c | ||
|
|
323061df02 | ||
|
|
ea885973c7 | ||
|
|
e0edc8f1e3 | ||
|
|
44873ddb39 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,6 +20,7 @@ build/
|
|||||||
# Models / artifacts (downloaded HF caches, trained LGBM)
|
# Models / artifacts (downloaded HF caches, trained LGBM)
|
||||||
backend/artifacts/
|
backend/artifacts/
|
||||||
backend/.cache/
|
backend/.cache/
|
||||||
|
backend/data/
|
||||||
.huggingface/
|
.huggingface/
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
|
|||||||
@@ -46,11 +46,27 @@ def reseed_symbols() -> dict:
|
|||||||
|
|
||||||
호출 예 (Windows cmd):
|
호출 예 (Windows cmd):
|
||||||
curl -X POST http://localhost:8000/api/refresh/seed/symbols
|
curl -X POST http://localhost:8000/api/refresh/seed/symbols
|
||||||
|
|
||||||
|
KRX 가 주말/장 마감 시간에 비정상 응답을 줄 때도 SEED 10 종목은 항상 보장하므로
|
||||||
|
엔드포인트는 200 을 돌려준다. 부분 성공 정보는 응답 body 에 담아 사용자가 판단.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
report = seed_symbols()
|
report = seed_symbols()
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"inserted": report.inserted,
|
"inserted": report.inserted,
|
||||||
"updated": report.updated,
|
"updated": report.updated,
|
||||||
"seed_marked": report.seed_marked,
|
"seed_marked": report.seed_marked,
|
||||||
"markets": report.markets,
|
"markets": report.markets,
|
||||||
}
|
}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
# seed_symbols 내부에서 다 잡지만, 만에 하나 외부로 새는 예외 (logger 포매터
|
||||||
|
# 자체 버그 등) 도 200 으로 흡수해서 SEED 10 만이라도 살리는 게 UX 목표.
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"inserted": 0,
|
||||||
|
"updated": 0,
|
||||||
|
"seed_marked": 0,
|
||||||
|
"markets": {},
|
||||||
|
"error": repr(e)[:300],
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,8 +78,11 @@ def seed_symbols() -> SeedReport:
|
|||||||
_upsert_seed_tickers()
|
_upsert_seed_tickers()
|
||||||
seed_marked = len(SEED_TICKERS)
|
seed_marked = len(SEED_TICKERS)
|
||||||
logger.info("seed_symbols: seed-tickers upserted (%d)", seed_marked)
|
logger.info("seed_symbols: seed-tickers upserted (%d)", seed_marked)
|
||||||
except Exception: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.exception("seed_symbols: seed-tickers upsert failed (critical)")
|
# logger.exception 은 Python 3.11 의 traceback 포매터가 pykrx 소스의 한글 주석
|
||||||
|
# 'df = 가...' 바이트를 만나면 UnicodeDecodeError 를 던지는 버그가 있어, 그 예외가
|
||||||
|
# try 밖으로 escape 해서 500 을 만든다. 그래서 traceback 안 찍는다.
|
||||||
|
logger.error("seed_symbols: seed-tickers upsert failed: %s", repr(e)[:300])
|
||||||
seed_marked = 0
|
seed_marked = 0
|
||||||
|
|
||||||
# 2) KRX 전 종목 — fetch 실패해도 부분 성공 허용
|
# 2) KRX 전 종목 — fetch 실패해도 부분 성공 허용
|
||||||
@@ -92,8 +95,8 @@ def seed_symbols() -> SeedReport:
|
|||||||
for code, name in listing:
|
for code, name in listing:
|
||||||
all_rows.append((code, name, market))
|
all_rows.append((code, name, market))
|
||||||
logger.info("seed_symbols: KRX %s fetched (%d)", market, len(listing))
|
logger.info("seed_symbols: KRX %s fetched (%d)", market, len(listing))
|
||||||
except Exception: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.exception("seed_symbols: KRX %s fetch failed — skip market", market)
|
logger.error("seed_symbols: KRX %s fetch failed — skip market: %s", market, repr(e)[:300])
|
||||||
market_counts[market] = 0
|
market_counts[market] = 0
|
||||||
|
|
||||||
inserted = updated = 0
|
inserted = updated = 0
|
||||||
@@ -122,8 +125,8 @@ def seed_symbols() -> SeedReport:
|
|||||||
inserted += 1
|
inserted += 1
|
||||||
else:
|
else:
|
||||||
updated += 1
|
updated += 1
|
||||||
except Exception: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.exception("seed_symbols: KRX bulk upsert failed (transaction rolled back)")
|
logger.error("seed_symbols: KRX bulk upsert failed (transaction rolled back): %s", repr(e)[:300])
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"seed_symbols done: inserted=%d updated=%d seed_marked=%d markets=%s",
|
"seed_symbols done: inserted=%d updated=%d seed_marked=%d markets=%s",
|
||||||
|
|||||||
@@ -132,3 +132,30 @@ def health_keys() -> dict[str, object]:
|
|||||||
"dart": dart_mod.ping(),
|
"dart": dart_mod.ping(),
|
||||||
# huggingface 는 모델 다운로드 시점에 확인 (별도 ping 호출 비용 회피)
|
# huggingface 는 모델 다운로드 시점에 확인 (별도 ping 호출 비용 회피)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/models")
|
||||||
|
def health_models() -> dict[str, object]:
|
||||||
|
"""Chronos / LGBM 가용성 진단.
|
||||||
|
|
||||||
|
Chronos: lazy 로드 첫 호출이라 30초~수 분 걸릴 수 있음 (HuggingFace 다운로드).
|
||||||
|
LGBM: 체크포인트 디렉토리 스캔 — retrain 안 돈 cold start 에선 비어있음.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.models import chronos as chronos_mod
|
||||||
|
|
||||||
|
lgbm_dir = Path(os.environ.get("LGBM_MODEL_DIR", "/app/data/models"))
|
||||||
|
lgbm_files: list[str] = []
|
||||||
|
if lgbm_dir.exists():
|
||||||
|
lgbm_files = sorted(p.name for p in lgbm_dir.glob("*.pkl"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chronos": chronos_mod.ping(),
|
||||||
|
"lgbm": {
|
||||||
|
"model_dir": str(lgbm_dir),
|
||||||
|
"checkpoint_count": len(lgbm_files),
|
||||||
|
"samples": lgbm_files[:8], # 너무 많으면 잘라서.
|
||||||
|
"status": "ok" if lgbm_files else "no_checkpoints (cold start, run retrain_weekly)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,8 +59,22 @@ def _load() -> None:
|
|||||||
os.environ.setdefault("HF_TOKEN", token)
|
os.environ.setdefault("HF_TOKEN", token)
|
||||||
|
|
||||||
device = _resolve_device()
|
device = _resolve_device()
|
||||||
# bf16 은 RTX 30xx 이상에서 지원. cpu 에선 fp32.
|
# dtype 선택:
|
||||||
dtype = torch.bfloat16 if device == "cuda" else torch.float32
|
# - 이전엔 cuda 면 무조건 bf16 으로 갔는데, torch 2.3.1+cu121 사전빌드 wheel 이
|
||||||
|
# sm_86 (RTX 3070 Ti) 의 일부 T5 커널 binary 를 빠뜨려서 inference 첫 호출에
|
||||||
|
# "no kernel image is available for execution on the device" 발생. ping/load
|
||||||
|
# 까지는 통과해서 진단이 까다로웠음 (실제 005930 케이스에서 관측).
|
||||||
|
# - chronos-t5-small 은 46M params 라 fp32 로도 8GB VRAM 에 여유 충분, 속도
|
||||||
|
# 차이도 일봉 30일 예측에선 무시 가능. 호환성 우선해 default 를 fp32 로.
|
||||||
|
# - 드라이버/torch 업그레이드 후 다시 bf16 시험하려면 .env 에
|
||||||
|
# CHRONOS_DTYPE=bf16 (또는 fp16) 두면 됨.
|
||||||
|
dtype_pref = os.environ.get("CHRONOS_DTYPE", "fp32").lower()
|
||||||
|
if device == "cuda" and dtype_pref == "bf16":
|
||||||
|
dtype = torch.bfloat16
|
||||||
|
elif device == "cuda" and dtype_pref == "fp16":
|
||||||
|
dtype = torch.float16
|
||||||
|
else:
|
||||||
|
dtype = torch.float32
|
||||||
logger.info("loading Chronos %s on %s (dtype=%s)", MODEL_NAME, device, dtype)
|
logger.info("loading Chronos %s on %s (dtype=%s)", MODEL_NAME, device, dtype)
|
||||||
pipe = ChronosPipeline.from_pretrained(
|
pipe = ChronosPipeline.from_pretrained(
|
||||||
MODEL_NAME,
|
MODEL_NAME,
|
||||||
@@ -70,6 +84,26 @@ def _load() -> None:
|
|||||||
_state.update({"loaded": True, "pipe": pipe, "device": device})
|
_state.update({"loaded": True, "pipe": pipe, "device": device})
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_cpu() -> None:
|
||||||
|
"""현재 pipeline 을 폐기하고 CPU 로 강제 재로드.
|
||||||
|
|
||||||
|
cuda 환경에서 'no kernel image is available for execution on the device' 같이
|
||||||
|
런타임에야 드러나는 GPU 비호환 에러가 났을 때 자동 폴백용. 한 번 폴백하면
|
||||||
|
다음 호출부터는 CPU 그대로 사용 (재시도 비용 회피)."""
|
||||||
|
global _state
|
||||||
|
import torch
|
||||||
|
from chronos import ChronosPipeline
|
||||||
|
with _lock:
|
||||||
|
logger.warning("falling back to CPU for Chronos (GPU inference failed)")
|
||||||
|
_state.update({"loaded": False, "pipe": None, "device": None})
|
||||||
|
pipe = ChronosPipeline.from_pretrained(
|
||||||
|
MODEL_NAME,
|
||||||
|
device_map="cpu",
|
||||||
|
torch_dtype=torch.float32,
|
||||||
|
)
|
||||||
|
_state.update({"loaded": True, "pipe": pipe, "device": "cpu"})
|
||||||
|
|
||||||
|
|
||||||
def forecast(
|
def forecast(
|
||||||
series: list[float],
|
series: list[float],
|
||||||
*,
|
*,
|
||||||
@@ -88,14 +122,31 @@ def forecast(
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
|
def _do_predict():
|
||||||
pipe = _state["pipe"]
|
pipe = _state["pipe"]
|
||||||
context = torch.tensor([float(x) for x in series], dtype=torch.float32)
|
context = torch.tensor([float(x) for x in series], dtype=torch.float32)
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
samples = pipe.predict(
|
return pipe.predict(
|
||||||
context=context,
|
context=context,
|
||||||
prediction_length=horizon,
|
prediction_length=horizon,
|
||||||
num_samples=num_samples,
|
num_samples=num_samples,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
samples = _do_predict()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
# cuda 빌드/드라이버 미스매치는 inference 시점에야 드러나는 경우가 많음.
|
||||||
|
# 'no kernel image is available' / 'CUDA error' 같은 신호 잡아서 CPU 로 폴백.
|
||||||
|
msg = str(exc)
|
||||||
|
if _state.get("device") == "cuda" and (
|
||||||
|
"no kernel image" in msg
|
||||||
|
or "CUDA error" in msg
|
||||||
|
or "CUBLAS" in msg
|
||||||
|
):
|
||||||
|
_reload_cpu()
|
||||||
|
samples = _do_predict()
|
||||||
|
else:
|
||||||
|
raise
|
||||||
# samples: (1, num_samples, prediction_length)
|
# samples: (1, num_samples, prediction_length)
|
||||||
arr = samples[0].cpu().float().numpy()
|
arr = samples[0].cpu().float().numpy()
|
||||||
q10 = np.quantile(arr, 0.10, axis=0).tolist()
|
q10 = np.quantile(arr, 0.10, axis=0).tolist()
|
||||||
|
|||||||
@@ -87,30 +87,41 @@ def predict(code: str, *, horizons: tuple[int, ...] = (1, 3, 5)) -> EnsemblePred
|
|||||||
|
|
||||||
sources_used: list[str] = []
|
sources_used: list[str] = []
|
||||||
cf: ChronosForecast | None = None
|
cf: ChronosForecast | None = None
|
||||||
|
chronos_err: str | None = None
|
||||||
try:
|
try:
|
||||||
cf = chronos_forecast(closes, horizon=max_h, num_samples=30)
|
cf = chronos_forecast(closes, horizon=max_h, num_samples=30)
|
||||||
sources_used.append("chronos")
|
sources_used.append("chronos")
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("chronos forecast failed for %s: %s", code, exc)
|
chronos_err = f"{type(exc).__name__}: {exc}"
|
||||||
|
logger.warning("chronos forecast failed for %s: %s", code, chronos_err)
|
||||||
|
|
||||||
steps: list[EnsembleStep] = []
|
steps: list[EnsembleStep] = []
|
||||||
lgbm_raw: dict[int, LgbmForecast] = {}
|
lgbm_raw: dict[int, LgbmForecast] = {}
|
||||||
for h in horizons:
|
for h in horizons:
|
||||||
lf: LgbmForecast | None = None
|
lf: LgbmForecast | None = None
|
||||||
|
lgbm_err: str | None = None
|
||||||
try:
|
try:
|
||||||
lf = lgbm_predict(code, h)
|
lf = lgbm_predict(code, h)
|
||||||
if lf is not None:
|
if lf is not None:
|
||||||
sources_used.append(f"lgbm_h{h}")
|
sources_used.append(f"lgbm_h{h}")
|
||||||
lgbm_raw[h] = lf
|
lgbm_raw[h] = lf
|
||||||
|
else:
|
||||||
|
# predict_one 이 None 반환 = 체크포인트 파일 없음 (cold start).
|
||||||
|
lgbm_err = "model checkpoint not found (run retrain_weekly)"
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("lgbm predict failed for %s h=%d: %s", code, h, exc)
|
lgbm_err = f"{type(exc).__name__}: {exc}"
|
||||||
|
logger.warning("lgbm predict failed for %s h=%d: %s", code, h, lgbm_err)
|
||||||
|
|
||||||
# 가중치 (DB 없으면 default 0.6/0.4).
|
# 가중치 (DB 없으면 default 0.6/0.4).
|
||||||
w = load_weights(code, h)
|
w = load_weights(code, h)
|
||||||
wc, wl = w.w_chronos, w.w_lgbm
|
wc, wl = w.w_chronos, w.w_lgbm
|
||||||
# 한쪽이 없으면 다른 쪽 전부.
|
# 한쪽이 없으면 다른 쪽 전부.
|
||||||
if cf is None and lf is None:
|
if cf is None and lf is None:
|
||||||
raise RuntimeError(f"both chronos & lgbm failed for {code} h={h}")
|
# 사용자가 브라우저에서 바로 원인을 보게 두 에러를 그대로 노출.
|
||||||
|
raise RuntimeError(
|
||||||
|
f"both chronos & lgbm failed for {code} h={h}; "
|
||||||
|
f"chronos={chronos_err or 'unknown'}; lgbm={lgbm_err or 'unknown'}"
|
||||||
|
)
|
||||||
if cf is None:
|
if cf is None:
|
||||||
wc, wl = 0.0, 1.0
|
wc, wl = 0.0, 1.0
|
||||||
if lf is None:
|
if lf is None:
|
||||||
|
|||||||
57
restart-ci.bat
Normal file
57
restart-ci.bat
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
@echo off
|
||||||
|
REM stock_chart_site - SSH/CI 친화 재시작 스크립트
|
||||||
|
REM
|
||||||
|
REM restart.bat 과의 차이: pause 가 없음. SSH 비대화형 (예: ssh user@host "restart-ci.bat")
|
||||||
|
REM 에서 멈추지 않고 끝까지 실행. 에러는 종료 코드로만 알린다.
|
||||||
|
REM
|
||||||
|
REM 일반 사용 시엔 restart.bat 을 쓰는게 출력 검토에 편하다.
|
||||||
|
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo === stock_chart_site restart-ci ===
|
||||||
|
|
||||||
|
where docker >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] docker not found
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
docker info >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] Docker Desktop not running
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set USE_GPU=0
|
||||||
|
where nvidia-smi >nul 2>&1
|
||||||
|
if not errorlevel 1 (
|
||||||
|
nvidia-smi >nul 2>&1
|
||||||
|
if not errorlevel 1 set USE_GPU=1
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%USE_GPU%"=="1" (
|
||||||
|
echo [GPU] using GPU profile
|
||||||
|
set COMPOSE_FILES=-f docker-compose.yml -f docker-compose.gpu.yml
|
||||||
|
) else (
|
||||||
|
echo [CPU] using CPU profile
|
||||||
|
set COMPOSE_FILES=-f docker-compose.yml
|
||||||
|
)
|
||||||
|
|
||||||
|
for /f %%i in ('docker compose %COMPOSE_FILES% ps --status running --quiet backend web 2^>nul ^| find /v /c ""') do set RUN_COUNT=%%i
|
||||||
|
if "%RUN_COUNT%"=="0" (
|
||||||
|
echo [ERROR] backend/web not running. run build.bat first.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo === docker compose up -d --force-recreate --no-deps backend web ===
|
||||||
|
docker compose %COMPOSE_FILES% up -d --force-recreate --no-deps backend web
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] restart failed
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo === status ===
|
||||||
|
docker compose %COMPOSE_FILES% ps
|
||||||
|
|
||||||
|
endlocal
|
||||||
|
exit /b 0
|
||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
type IChartApi,
|
type IChartApi,
|
||||||
type ISeriesApi,
|
type ISeriesApi,
|
||||||
type LineData,
|
type LineData,
|
||||||
type SeriesMarker,
|
|
||||||
type Time,
|
|
||||||
type UTCTimestamp,
|
type UTCTimestamp,
|
||||||
} from "lightweight-charts";
|
} from "lightweight-charts";
|
||||||
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
||||||
@@ -104,28 +102,9 @@ export function StockChart({ chart, prediction }: Props) {
|
|||||||
close: p.close as number,
|
close: p.close as number,
|
||||||
}));
|
}));
|
||||||
candleRef.current.setData(data);
|
candleRef.current.setData(data);
|
||||||
|
// 오늘 표시는 차트 본체 위가 아니라 컨테이너 아래 캡션 (return JSX) 으로 옮김.
|
||||||
// 오늘 날짜 마커: 일/주/월봉에서만 표시 (10분봉은 데이터 자체가 오늘 하루라 무의미).
|
// lightweight-charts 의 timeScale tick 자체에 라벨을 끼울 공식 API 가 없어서,
|
||||||
// markers 는 데이터 포인트의 time 과 일치해야 표시되므로, 오늘 또는 가장 가까운 과거
|
// 시각적으로 동일한 위치 (시간축 바로 아래) 에 별도 div 로 렌더.
|
||||||
// 거래일을 찾는다.
|
|
||||||
if (!isIntraday && chart.today) {
|
|
||||||
const todayTs = isoToUtcTs(chart.today);
|
|
||||||
// 차트의 마지막 데이터가 오늘이면 그 위에, 아니면 마지막 데이터 위에 "오늘" 표시.
|
|
||||||
const lastTs = data.length > 0 ? (data[data.length - 1].time as UTCTimestamp) : null;
|
|
||||||
const markerTime = (lastTs && lastTs <= todayTs ? lastTs : todayTs) as UTCTimestamp;
|
|
||||||
const markers: SeriesMarker<Time>[] = [
|
|
||||||
{
|
|
||||||
time: markerTime,
|
|
||||||
position: "aboveBar",
|
|
||||||
color: "#fbbf24",
|
|
||||||
shape: "arrowDown",
|
|
||||||
text: "오늘",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
candleRef.current.setMarkers(markers);
|
|
||||||
} else {
|
|
||||||
candleRef.current.setMarkers([]);
|
|
||||||
}
|
|
||||||
chartRef.current?.timeScale().fitContent();
|
chartRef.current?.timeScale().fitContent();
|
||||||
}, [chart, isIntraday]);
|
}, [chart, isIntraday]);
|
||||||
|
|
||||||
@@ -203,9 +182,29 @@ export function StockChart({ chart, prediction }: Props) {
|
|||||||
chartRef.current.timeScale().fitContent();
|
chartRef.current.timeScale().fitContent();
|
||||||
}, [prediction, isIntraday]);
|
}, [prediction, isIntraday]);
|
||||||
|
|
||||||
|
// 오늘 라벨 — 차트 본체에 마커 대신 시간축 바로 아래에 작은 캡션으로.
|
||||||
|
// 10분봉은 데이터 자체가 오늘 하루라 굳이 라벨 불필요.
|
||||||
|
const todayLabel =
|
||||||
|
!isIntraday && chart.today
|
||||||
|
? new Date(chart.today + "T00:00:00").toLocaleDateString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
weekday: "short",
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[460px] w-full rounded-md border border-zinc-800 bg-zinc-900/30 p-2">
|
<div className="w-full rounded-md border border-zinc-800 bg-zinc-900/30 p-2">
|
||||||
|
<div className="h-[460px] w-full">
|
||||||
<div ref={containerRef} className="h-full w-full" />
|
<div ref={containerRef} className="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
{todayLabel && (
|
||||||
|
<div className="mt-1 flex items-center justify-end gap-2 px-2 text-xs text-zinc-400">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-amber-400" />
|
||||||
|
<span>오늘 · {todayLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user