feat(backend+frontend): Sentinel lockdown mode + stale UX overhaul
Backend (main.go):
- negCacheEntry gains IsSentinel bool; lockdownTTL = 90min constant
- Lockdown gate at top of handleProxy: serves stale with X-MSDL-Lockdown:1
header or 429 {"error":"...","lockdown":true} — zero MS API calls made
- Sentinel stale path: sets 90min lockdown neg cache, adds lockdown header,
suppresses background refresh (retrying extends the Sentinel block timer)
- Non-Sentinel stale path: unchanged (60s neg cache, bg refresh fires)
- Expose X-MSDL-Lockdown in Access-Control-Expose-Headers
Frontend (ProductDetailPage.tsx):
- isLockdown state: set from X-MSDL-Lockdown header or data.lockdown field
- Stale state hides expired download links entirely; shows CLI recommendation
inside the card instead of sending users to look for options elsewhere
- Lockdown: red banner explaining 90min cooldown + highlighted CLI card,
no Refresh button (it cannot help while Sentinel block is active)
- Normal stale: amber banner + highlighted CLI card + secondary Refresh button
- Outer CliHandoff suppressed when already shown inside card (stale states)
Frontend (CliHandoff.tsx):
- CLI command uses msdl.exe (primary audience is Windows users)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0525015ca9
commit
3141635300
3 changed files with 232 additions and 97 deletions
|
|
@ -115,18 +115,20 @@ var (
|
|||
linkCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
// --- Negative cache (60s — prevents thundering herd during rate-limit events) ---
|
||||
// --- Negative cache (60s normal / 90min Sentinel lockdown) ---
|
||||
|
||||
type negCacheEntry struct {
|
||||
Message string
|
||||
HTTPCode int
|
||||
ExpiresAt time.Time
|
||||
Message string
|
||||
HTTPCode int
|
||||
ExpiresAt time.Time
|
||||
IsSentinel bool // Sentinel WAF block: use lockdownTTL and suppress background refresh
|
||||
}
|
||||
|
||||
var (
|
||||
negCache = make(map[string]negCacheEntry)
|
||||
negCacheMu sync.RWMutex
|
||||
negCacheTTL = 60 * time.Second
|
||||
lockdownTTL = 90 * time.Minute
|
||||
)
|
||||
|
||||
// --- Singleflight (one in-flight Microsoft fetch per unique key) ---
|
||||
|
|
@ -950,7 +952,7 @@ func enableCORS(next http.Handler) http.Handler {
|
|||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "X-MSDL-Link-Status, X-MSDL-Link-Expires")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "X-MSDL-Link-Status, X-MSDL-Link-Expires, X-MSDL-Lockdown")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
|
|
@ -1075,6 +1077,38 @@ func handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||
negKey := "link:" + cacheKey
|
||||
forceRefresh := r.URL.Query().Get("force") == "true"
|
||||
|
||||
// 0. Sentinel lockdown gate — checked before everything, including force refresh.
|
||||
// When a Sentinel block is active, zero Microsoft API calls are made regardless
|
||||
// of force=true, to avoid extending the block timer.
|
||||
negCacheMu.RLock()
|
||||
lockdownEntry, hasLockdown := negCache[negKey]
|
||||
negCacheMu.RUnlock()
|
||||
if hasLockdown && time.Now().Before(lockdownEntry.ExpiresAt) && lockdownEntry.IsSentinel {
|
||||
atomic.AddInt64(&mLinkNegHits, 1)
|
||||
w.Header().Set("X-MSDL-Lockdown", "1")
|
||||
linkCacheMu.RLock()
|
||||
staleEntry, hasStale := linkCache[cacheKey]
|
||||
linkCacheMu.RUnlock()
|
||||
if hasStale {
|
||||
atomic.AddInt64(&mLinkStale, 1)
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> lockdown active, serving stale (expires %s)\n",
|
||||
productID, skuID, lockdownEntry.ExpiresAt.Format(time.RFC3339))
|
||||
w.Header().Set("X-MSDL-Link-Status", "stale")
|
||||
if exp := extractRawExpiry(staleEntry.RawJSON); exp != "" {
|
||||
w.Header().Set("X-MSDL-Link-Expires", exp)
|
||||
}
|
||||
w.Write(staleEntry.RawJSON)
|
||||
return
|
||||
}
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> lockdown active, no stale available\n", productID, skuID)
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": lockdownEntry.Message,
|
||||
"lockdown": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Check link cache (dynamic TTL, stale-on-failure) — skipped when force=true
|
||||
linkCacheMu.RLock()
|
||||
cached, hasCached := linkCache[cacheKey]
|
||||
|
|
@ -1137,10 +1171,33 @@ func handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
|
||||
if err != nil {
|
||||
// 6. Stale-on-failure: serve expired cache entry rather than erroring out
|
||||
// Also fire a background refresh so the next request gets fresh data.
|
||||
isSentinel := strings.Contains(err.Error(), "Sentinel")
|
||||
|
||||
// Stale-on-failure: serve expired cache entry rather than erroring out.
|
||||
if hasCached {
|
||||
atomic.AddInt64(&mLinkStale, 1)
|
||||
|
||||
if isSentinel {
|
||||
// Sentinel lockdown: set 90-min neg cache, add lockdown header, skip
|
||||
// background refresh — retrying extends the Sentinel block timer.
|
||||
negCacheMu.Lock()
|
||||
negCache[negKey] = negCacheEntry{
|
||||
Message: err.Error(),
|
||||
HTTPCode: http.StatusTooManyRequests,
|
||||
ExpiresAt: time.Now().Add(lockdownTTL),
|
||||
IsSentinel: true,
|
||||
}
|
||||
negCacheMu.Unlock()
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> Sentinel lockdown (90min), serving stale\n", productID, skuID)
|
||||
w.Header().Set("X-MSDL-Lockdown", "1")
|
||||
w.Header().Set("X-MSDL-Link-Status", "stale")
|
||||
if exp := extractRawExpiry(cached.RawJSON); exp != "" {
|
||||
w.Header().Set("X-MSDL-Link-Expires", exp)
|
||||
}
|
||||
w.Write(cached.RawJSON)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> fetch failed (%v), serving stale\n", productID, skuID, err)
|
||||
go func() {
|
||||
// Use singleflight so concurrent stale serves don't all hit Microsoft
|
||||
|
|
@ -1168,17 +1225,32 @@ func handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// No stale data available.
|
||||
code := http.StatusBadGateway
|
||||
// 4. Negative cache for rate-limit errors (only when no stale available)
|
||||
if _, isRL := err.(*rateLimitError); isRL {
|
||||
code = http.StatusTooManyRequests
|
||||
ttl := negCacheTTL
|
||||
if isSentinel {
|
||||
ttl = lockdownTTL
|
||||
}
|
||||
negCacheMu.Lock()
|
||||
negCache[negKey] = negCacheEntry{
|
||||
Message: err.Error(),
|
||||
HTTPCode: code,
|
||||
ExpiresAt: time.Now().Add(negCacheTTL),
|
||||
Message: err.Error(),
|
||||
HTTPCode: code,
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
IsSentinel: isSentinel,
|
||||
}
|
||||
negCacheMu.Unlock()
|
||||
if isSentinel {
|
||||
log.Printf("/proxy: product_id=%s sku_id=%s -> Sentinel lockdown (90min), no stale\n", productID, skuID)
|
||||
w.Header().Set("X-MSDL-Lockdown", "1")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"lockdown": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
respondJSONError(w, code, err.Error())
|
||||
return
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@ export default function CliHandoff({ productId, langName, langDisplay, defaultOp
|
|||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const command = langName
|
||||
? `msdl --id ${productId} --lang "${langName}"`
|
||||
: `msdl --id ${productId}`
|
||||
? `msdl.exe --id ${productId} --lang "${langName}"`
|
||||
: `msdl.exe --id ${productId}`
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(command)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export default function ProductDetailPage() {
|
|||
const [expiryRemaining, setExpiryRemaining] = useState<number | null>(null)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [linkStatus, setLinkStatus] = useState<'fresh' | 'cached' | 'stale' | null>(null)
|
||||
const [isLockdown, setIsLockdown] = useState(false)
|
||||
const expiryTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const [meta, setMeta] = useState<{ badge: string; archs: string[]; active: boolean }>({ badge: '', archs: [], active: false })
|
||||
|
|
@ -199,17 +200,23 @@ export default function ProductDetailPage() {
|
|||
setLinkExpiry(null)
|
||||
setExpiryRemaining(null)
|
||||
setLinkStatus(null)
|
||||
setIsLockdown(false)
|
||||
if (expiryTimerRef.current) clearInterval(expiryTimerRef.current)
|
||||
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/proxy?product_id=${productId}&sku_id=${selectedSku.Id}`)
|
||||
const status = r.headers.get('X-MSDL-Link-Status') as 'fresh' | 'cached' | 'stale' | null
|
||||
const expiresHeader = r.headers.get('X-MSDL-Link-Expires')
|
||||
const lockdownHeader = r.headers.get('X-MSDL-Lockdown')
|
||||
const data = await r.json()
|
||||
if (data.error) throw new Error(data.error)
|
||||
if (data.error) {
|
||||
if (data.lockdown) setIsLockdown(true)
|
||||
throw new Error(data.error)
|
||||
}
|
||||
if (!data.ProductDownloadOptions?.length) throw new Error('No download links returned.')
|
||||
setDownloadLinks(data.ProductDownloadOptions)
|
||||
setLinkStatus(status)
|
||||
if (lockdownHeader === '1') setIsLockdown(true)
|
||||
let expiry: Date | null = null
|
||||
if (expiresHeader) {
|
||||
const d = new Date(expiresHeader)
|
||||
|
|
@ -271,11 +278,17 @@ export default function ProductDetailPage() {
|
|||
const r = await fetch(`${API_BASE}/proxy?product_id=${productId}&sku_id=${selectedSku.Id}&force=true`)
|
||||
const status = r.headers.get('X-MSDL-Link-Status') as 'fresh' | 'cached' | 'stale' | null
|
||||
const expiresHeader = r.headers.get('X-MSDL-Link-Expires')
|
||||
const lockdownHeader = r.headers.get('X-MSDL-Lockdown')
|
||||
const data = await r.json()
|
||||
if (data.error) throw new Error(data.error)
|
||||
if (data.error) {
|
||||
if (data.lockdown) setIsLockdown(true)
|
||||
throw new Error(data.error)
|
||||
}
|
||||
if (!data.ProductDownloadOptions?.length) throw new Error('No download links returned.')
|
||||
setDownloadLinks(data.ProductDownloadOptions)
|
||||
setLinkStatus(status)
|
||||
if (lockdownHeader === '1') setIsLockdown(true)
|
||||
else setIsLockdown(false)
|
||||
let expiry: Date | null = null
|
||||
if (expiresHeader) {
|
||||
const d = new Date(expiresHeader)
|
||||
|
|
@ -556,91 +569,141 @@ export default function ProductDetailPage() {
|
|||
transition={{ type: 'spring', stiffness: 300, damping: 28 }}
|
||||
>
|
||||
<div className="pt-4 border-t border-white/6 space-y-3">
|
||||
{/* Stale warning */}
|
||||
{linkStatus === 'stale' && (
|
||||
<div className="flex items-start gap-3 p-3.5 rounded-xl bg-amber-500/8 border border-amber-500/20 text-amber-400">
|
||||
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
|
||||
<p className="text-[12px] leading-relaxed">
|
||||
This link is cached and may have expired — Microsoft is currently limiting automated requests. Use the options below for a guaranteed fresh download.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expiry row */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className={`flex items-center gap-2 p-3 rounded-lg border text-[11px] flex-1 ${
|
||||
showRefresh
|
||||
? 'bg-red-500/6 border-red-500/15 text-red-400/80'
|
||||
: 'bg-amber-500/6 border-amber-500/12 text-amber-400/80'
|
||||
}`}>
|
||||
<AlertTriangle size={12} className="flex-shrink-0" />
|
||||
{expiryRemaining !== null
|
||||
? expiryRemaining <= 0
|
||||
? 'Links expired — refresh to get new ones'
|
||||
: `Expires in ${formatRemaining(expiryRemaining)} · Direct from Microsoft CDN`
|
||||
: 'Links expire in 24 hours · Direct from Microsoft CDN'
|
||||
}
|
||||
</div>
|
||||
{showRefresh && (
|
||||
<button
|
||||
onClick={handleRefreshLinks}
|
||||
disabled={isRefreshing}
|
||||
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-blue-500/12 border border-blue-500/20 text-blue-400 text-[11px] font-medium hover:bg-blue-500/20 disabled:opacity-40 transition-all flex-shrink-0"
|
||||
>
|
||||
<motion.span
|
||||
animate={{ rotate: isRefreshing ? 360 : 0 }}
|
||||
transition={isRefreshing ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}}
|
||||
className="inline-block"
|
||||
>
|
||||
<RefreshCw size={11} />
|
||||
</motion.span>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
{downloadLinks.map(link => {
|
||||
const filename = link.Uri.split('/').pop()?.split('?')[0] ?? 'download.iso'
|
||||
const arch = link.Architecture || archFromUri(link.Uri)
|
||||
const isCopied = copiedUri === link.Uri
|
||||
return (
|
||||
<div
|
||||
key={link.Uri}
|
||||
className="flex items-center justify-between gap-3 p-3.5 rounded-xl bg-white/3 border border-white/5 hover:border-white/9 transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-mono text-white truncate">{filename}</p>
|
||||
<p className="text-[11px] text-zinc-600 mt-0.5">{arch}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{linkStatus === 'stale' ? (
|
||||
/* Stale state: hide links, show banner + CLI recommendation */
|
||||
<>
|
||||
{isLockdown ? (
|
||||
/* Sentinel lockdown — no Refresh, no links, CLI only */
|
||||
<>
|
||||
<div className="flex items-start gap-3 p-3.5 rounded-xl bg-red-500/8 border border-red-500/20 text-red-400">
|
||||
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
|
||||
<p className="text-[12px] leading-relaxed">
|
||||
Our server has been temporarily blocked by Microsoft and is in a 90-minute cooldown. Use the CLI to get your link instantly — it bypasses our server entirely.
|
||||
</p>
|
||||
</div>
|
||||
{selectedSku && (
|
||||
<CliHandoff
|
||||
productId={productId!}
|
||||
langName={selectedSku.Language}
|
||||
langDisplay={selectedSku.LocalizedLanguage}
|
||||
highlight
|
||||
defaultOpen
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Normal stale — show warning, CLI, and Refresh */
|
||||
<>
|
||||
<div className="flex items-start gap-3 p-3.5 rounded-xl bg-amber-500/8 border border-amber-500/20 text-amber-400">
|
||||
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
|
||||
<p className="text-[12px] leading-relaxed">
|
||||
These links are cached and have expired — Microsoft is currently limiting automated requests.
|
||||
</p>
|
||||
</div>
|
||||
{selectedSku && (
|
||||
<CliHandoff
|
||||
productId={productId!}
|
||||
langName={selectedSku.Language}
|
||||
langDisplay={selectedSku.LocalizedLanguage}
|
||||
highlight
|
||||
defaultOpen
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCopy(link.Uri)}
|
||||
className="p-2 rounded-lg text-zinc-600 hover:text-white hover:bg-white/6 transition-all"
|
||||
title="Copy link"
|
||||
onClick={handleRefreshLinks}
|
||||
disabled={isRefreshing}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-xl bg-white/4 border border-white/8 text-zinc-400 text-[12px] font-medium hover:bg-white/7 disabled:opacity-40 transition-all"
|
||||
>
|
||||
{isCopied
|
||||
? <Check size={13} className="text-green-400" />
|
||||
: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="w-3.5 h-3.5" aria-hidden="true">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
}
|
||||
<motion.span
|
||||
animate={{ rotate: isRefreshing ? 360 : 0 }}
|
||||
transition={isRefreshing ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}}
|
||||
className="inline-block"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</motion.span>
|
||||
Try refreshing links
|
||||
</button>
|
||||
<a
|
||||
href={link.Uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-500/12 border border-blue-500/20 text-blue-400 text-[11px] font-medium hover:bg-blue-500/20 transition-all"
|
||||
>
|
||||
<Download size={11} />
|
||||
{arch}
|
||||
<ExternalLink size={10} className="opacity-50" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Fresh / cached state: show expiry row + links */
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className={`flex items-center gap-2 p-3 rounded-lg border text-[11px] flex-1 ${
|
||||
showRefresh
|
||||
? 'bg-red-500/6 border-red-500/15 text-red-400/80'
|
||||
: 'bg-amber-500/6 border-amber-500/12 text-amber-400/80'
|
||||
}`}>
|
||||
<AlertTriangle size={12} className="flex-shrink-0" />
|
||||
{expiryRemaining !== null
|
||||
? expiryRemaining <= 0
|
||||
? 'Links expired — refresh to get new ones'
|
||||
: `Expires in ${formatRemaining(expiryRemaining)} · Direct from Microsoft CDN`
|
||||
: 'Links expire in 24 hours · Direct from Microsoft CDN'
|
||||
}
|
||||
</div>
|
||||
{showRefresh && (
|
||||
<button
|
||||
onClick={handleRefreshLinks}
|
||||
disabled={isRefreshing}
|
||||
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-blue-500/12 border border-blue-500/20 text-blue-400 text-[11px] font-medium hover:bg-blue-500/20 disabled:opacity-40 transition-all flex-shrink-0"
|
||||
>
|
||||
<motion.span
|
||||
animate={{ rotate: isRefreshing ? 360 : 0 }}
|
||||
transition={isRefreshing ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}}
|
||||
className="inline-block"
|
||||
>
|
||||
<RefreshCw size={11} />
|
||||
</motion.span>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{downloadLinks.map(link => {
|
||||
const filename = link.Uri.split('/').pop()?.split('?')[0] ?? 'download.iso'
|
||||
const arch = link.Architecture || archFromUri(link.Uri)
|
||||
const isCopied = copiedUri === link.Uri
|
||||
return (
|
||||
<div
|
||||
key={link.Uri}
|
||||
className="flex items-center justify-between gap-3 p-3.5 rounded-xl bg-white/3 border border-white/5 hover:border-white/9 transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-mono text-white truncate">{filename}</p>
|
||||
<p className="text-[11px] text-zinc-600 mt-0.5">{arch}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleCopy(link.Uri)}
|
||||
className="p-2 rounded-lg text-zinc-600 hover:text-white hover:bg-white/6 transition-all"
|
||||
title="Copy link"
|
||||
>
|
||||
{isCopied
|
||||
? <Check size={13} className="text-green-400" />
|
||||
: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="w-3.5 h-3.5" aria-hidden="true">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
<a
|
||||
href={link.Uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-500/12 border border-blue-500/20 text-blue-400 text-[11px] font-medium hover:bg-blue-500/20 transition-all"
|
||||
>
|
||||
<Download size={11} />
|
||||
{arch}
|
||||
<ExternalLink size={10} className="opacity-50" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
@ -656,8 +719,8 @@ export default function ProductDetailPage() {
|
|||
productId={productId!}
|
||||
languageName={selectedSku?.LocalizedLanguage ?? ''}
|
||||
/>
|
||||
{/* Collapsed CLI only when highlighted card isn't already shown inside the card */}
|
||||
{!(error !== null && languages.length > 0) && (
|
||||
{/* Skip outer CLI card when already shown inside the main card (any stale or error) */}
|
||||
{linkStatus !== 'stale' && !(error !== null && languages.length > 0) && (
|
||||
selectedSku ? (
|
||||
<CliHandoff
|
||||
productId={productId!}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue