feat(kb+apm): KB Phase 2-A 自動萃取 + KB-D Markdown 詳情面板 + APM 趨勢圖
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 7m28s
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 7m28s
- KB-A: 新增 knowledge_extractor_service.py (Ollama llama3.2:3b 本地推理)
- KB-A: incident_service.py resolve hook (fire-and-forget asyncio.create_task)
- KB-D: 引入 react-markdown + remark-gfm,知識庫詳情面板 Markdown 渲染
- KB-D: 批准/封存按鈕串接 API (POST /knowledge/{id}/approve, PATCH status)
- KB-D: i18n 新增 approving/archiving 載入狀態文字
- APM: apm/page.tsx 整合 TimeSeriesChart sparkline (使用 trend[] 欄位)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -682,6 +682,16 @@ class IncidentService:
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# KB Phase 2-A: 自動萃取 KB 草稿 (fire-and-forget, 2026-04-03 ogt)
|
||||
try:
|
||||
import asyncio
|
||||
from src.services.knowledge_extractor_service import get_knowledge_extractor
|
||||
asyncio.create_task(
|
||||
get_knowledge_extractor().extract_from_incident(incident)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("kb_extract_task_create_failed", incident_id=incident_id)
|
||||
|
||||
return incident
|
||||
|
||||
async def find_by_proposal_id(self, proposal_id: str) -> Incident | None:
|
||||
|
||||
228
apps/api/src/services/knowledge_extractor_service.py
Normal file
228
apps/api/src/services/knowledge_extractor_service.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Knowledge Extractor Service — KB Phase 2-A
|
||||
==========================================
|
||||
Incident resolved 後自動萃取 KB 草稿。
|
||||
|
||||
設計原則:
|
||||
- 強制使用 Ollama llama3.2:3b(本地推理,符合 Phase 24 D7 隱私規則)
|
||||
- fire-and-forget:失敗不影響 resolve 主流程
|
||||
- logger.exception 保留完整 Stack Trace 供 Prompt 調優
|
||||
|
||||
2026-04-03 ogt: KB Phase 2-A 初始實作
|
||||
"""
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_OLLAMA_BASE = "http://192.168.0.188:11434"
|
||||
_EXTRACT_MODEL = "llama3.2:3b"
|
||||
_EXTRACT_TIMEOUT = 30.0 # 秒,容忍慢速
|
||||
|
||||
# Linear / Nothing.tech 風格的 SRE KB Prompt
|
||||
_PROMPT_TEMPLATE = """你是一位資深 SRE 工程師,請用**繁體中文**撰寫一份知識庫條目(Markdown 格式)。
|
||||
|
||||
## 事件資訊
|
||||
- 事件 ID:{incident_id}
|
||||
- 嚴重度:{severity}
|
||||
- 發生時間:{created_at}
|
||||
- 解決時間:{resolved_at}
|
||||
|
||||
## 觸發信號
|
||||
{signals}
|
||||
|
||||
## 請輸出以下結構的 Markdown(只輸出 Markdown,不要其他說明文字):
|
||||
|
||||
# [一句話摘要標題]
|
||||
|
||||
## 問題描述
|
||||
(簡述發生了什麼問題,2-3 句)
|
||||
|
||||
## 根本原因
|
||||
(分析可能的根本原因,條列式)
|
||||
|
||||
## 解決方法
|
||||
(列出實際採取的解決步驟,條列式)
|
||||
|
||||
## 預防措施
|
||||
(如何避免未來再發生,條列式)
|
||||
|
||||
## 相關標籤
|
||||
`{severity}` `ai_extracted`
|
||||
"""
|
||||
|
||||
# 信號關鍵字 → KB 分類映射
|
||||
_CATEGORY_KEYWORDS: dict[str, list[str]] = {
|
||||
"infrastructure": ["k8s", "pod", "node", "deploy", "container", "namespace", "kubectl",
|
||||
"memory", "cpu", "disk", "oom", "evict", "crashloop"],
|
||||
"application": ["api", "http", "latency", "5xx", "4xx", "error rate", "timeout",
|
||||
"connection", "database", "redis", "postgres", "slow"],
|
||||
"ai_system": ["ai", "llm", "openclaw", "nemo", "ollama", "gemini", "claude",
|
||||
"router", "provider", "inference", "token"],
|
||||
"security": ["ssl", "cert", "auth", "permission", "scan", "vuln", "exploit",
|
||||
"unauthorized", "403", "401"],
|
||||
}
|
||||
|
||||
|
||||
class KnowledgeExtractorService:
|
||||
"""
|
||||
Incident → KB 草稿自動萃取器
|
||||
|
||||
使用 Ollama llama3.2:3b 本地推理,產生 Markdown 格式的 SRE 知識條目。
|
||||
"""
|
||||
|
||||
async def extract_from_incident(self, incident) -> bool:
|
||||
"""
|
||||
從已解決的 Incident 萃取 KB 草稿。
|
||||
|
||||
Args:
|
||||
incident: Incident 物件(需有 incident_id, severity, signals, created_at)
|
||||
|
||||
Returns:
|
||||
True = 萃取成功,False = 失敗(已記錄 Stack Trace)
|
||||
"""
|
||||
try:
|
||||
# 1. 組 Prompt
|
||||
signals_text = "\n".join(
|
||||
f"- {s.description}" for s in (incident.signals or [])
|
||||
) or "(無信號記錄)"
|
||||
|
||||
prompt = _PROMPT_TEMPLATE.format(
|
||||
incident_id=incident.incident_id,
|
||||
severity=incident.severity.value,
|
||||
created_at=str(getattr(incident, "created_at", "未知"))[:19],
|
||||
resolved_at=str(getattr(incident, "resolved_at", "未知"))[:19],
|
||||
signals=signals_text,
|
||||
)
|
||||
|
||||
# 2. 呼叫 Ollama(直接 HTTP,不走 AIRouter 避免路由邏輯開銷)
|
||||
markdown_content = await self._call_ollama(prompt)
|
||||
if not markdown_content:
|
||||
logger.warning(
|
||||
"kb_extract_empty_response",
|
||||
incident_id=incident.incident_id,
|
||||
model=_EXTRACT_MODEL,
|
||||
)
|
||||
return False
|
||||
|
||||
# 3. 萃取標題(第一行 `# 標題`)
|
||||
title = self._extract_title(markdown_content, incident)
|
||||
|
||||
# 4. 推斷分類
|
||||
category = self._infer_category(incident)
|
||||
|
||||
# 5. 建立 KB 條目
|
||||
from src.models.knowledge import EntrySource, EntryType, KnowledgeEntryCreate
|
||||
from src.services.knowledge_service import get_knowledge_service
|
||||
|
||||
entry_data = KnowledgeEntryCreate(
|
||||
title=title,
|
||||
content=markdown_content,
|
||||
entry_type=EntryType.INCIDENT_CASE,
|
||||
category=category,
|
||||
tags=[incident.severity.value, "ai_extracted", category],
|
||||
source=EntrySource.AI_EXTRACTED,
|
||||
related_incident_id=incident.incident_id,
|
||||
created_by="openclaw_ai",
|
||||
)
|
||||
await get_knowledge_service().create_entry(entry_data)
|
||||
|
||||
logger.info(
|
||||
"kb_extract_success",
|
||||
incident_id=incident.incident_id,
|
||||
title=title,
|
||||
category=category,
|
||||
model=_EXTRACT_MODEL,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
# 統帥指示:保留完整 Stack Trace 供初期 Prompt 調優
|
||||
logger.exception(
|
||||
"kb_extract_failed",
|
||||
incident_id=getattr(incident, "incident_id", "unknown"),
|
||||
)
|
||||
return False
|
||||
|
||||
async def _call_ollama(self, prompt: str) -> str | None:
|
||||
"""
|
||||
直接呼叫 Ollama REST API。
|
||||
|
||||
不走 AIRouter 是刻意設計:
|
||||
- KB 萃取是背景工作,不需要完整的路由/閘門/Cache 邏輯
|
||||
- 強制本地,不允許 fallback 到 cloud provider
|
||||
"""
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_EXTRACT_TIMEOUT) as client:
|
||||
r = await client.post(
|
||||
f"{_OLLAMA_BASE}/api/generate",
|
||||
json={
|
||||
"model": _EXTRACT_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.3, # 低溫:減少幻覺
|
||||
"num_predict": 800, # 控制長度
|
||||
"stop": ["\n\n\n"], # 防止無限生成
|
||||
},
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
text = r.json().get("response", "").strip()
|
||||
return text or None
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"kb_ollama_call_failed",
|
||||
model=_EXTRACT_MODEL,
|
||||
base=_OLLAMA_BASE,
|
||||
)
|
||||
return None
|
||||
|
||||
def _extract_title(self, markdown: str, incident) -> str:
|
||||
"""
|
||||
從 Markdown 第一行 `# 標題` 萃取標題。
|
||||
Fallback:使用 incident_id + 第一個 signal 描述。
|
||||
"""
|
||||
for line in markdown.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("# "):
|
||||
title = stripped[2:].strip()
|
||||
if title:
|
||||
return title[:200] # DB column max 255
|
||||
|
||||
# Fallback
|
||||
signals = incident.signals or []
|
||||
desc = signals[0].description[:60] if signals else "未知事件"
|
||||
return f"[AI 萃取] {incident.incident_id}: {desc}"
|
||||
|
||||
def _infer_category(self, incident) -> str:
|
||||
"""
|
||||
依 signals 關鍵字推斷 KB 分類。
|
||||
依序比對,第一個匹配的分類獲勝。
|
||||
"""
|
||||
text = " ".join(
|
||||
s.description.lower() for s in (incident.signals or [])
|
||||
)
|
||||
for category, keywords in _CATEGORY_KEYWORDS.items():
|
||||
if any(k in text for k in keywords):
|
||||
return category
|
||||
|
||||
# 保守 fallback
|
||||
return "infrastructure"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Singleton
|
||||
# =============================================================================
|
||||
|
||||
_extractor: KnowledgeExtractorService | None = None
|
||||
|
||||
|
||||
def get_knowledge_extractor() -> KnowledgeExtractorService:
|
||||
global _extractor
|
||||
if _extractor is None:
|
||||
_extractor = KnowledgeExtractorService()
|
||||
return _extractor
|
||||
@@ -698,7 +698,9 @@
|
||||
"relatedPlaybook": "Related Playbook",
|
||||
"relatedIncident": "Related Incident",
|
||||
"approve": "Approve",
|
||||
"approving": "Approving...",
|
||||
"archive": "Archive",
|
||||
"archiving": "Archiving...",
|
||||
"status": {
|
||||
"draft": "Draft",
|
||||
"review": "In Review",
|
||||
|
||||
@@ -699,7 +699,9 @@
|
||||
"relatedPlaybook": "相關 Playbook",
|
||||
"relatedIncident": "相關事件",
|
||||
"approve": "審核通過",
|
||||
"approving": "審核中...",
|
||||
"archive": "封存",
|
||||
"archiving": "封存中...",
|
||||
"status": {
|
||||
"draft": "草稿",
|
||||
"review": "審核中",
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
"next-intl": "^4.8.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.8.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"zod": "^3.22.0",
|
||||
"zustand": "^4.5.0"
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
/**
|
||||
* APM Page — 黃金指標 (Golden Signals)
|
||||
* @created 2026-04-01 ogt - 路由佔位
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/metrics/gold 真實數據
|
||||
* @updated 2026-04-03 Claude Code - 串接 /api/v1/metrics/gold 真實數據 + TimeSeriesChart 趨勢圖
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppLayout } from '@/components/layout'
|
||||
import { TimeSeriesChart } from '@/components/charts/time-series-chart'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
const SIGNOZ_URL = 'http://192.168.0.188:3301'
|
||||
@@ -17,6 +18,7 @@ interface GoldMetricItem {
|
||||
label: string
|
||||
value: number | string
|
||||
unit: string | null
|
||||
trend: number[]
|
||||
status: string
|
||||
}
|
||||
|
||||
@@ -33,6 +35,13 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
unknown: '#87867f',
|
||||
}
|
||||
|
||||
const STATUS_CHART_COLOR: Record<string, 'success' | 'warning' | 'error' | 'primary'> = {
|
||||
healthy: 'success',
|
||||
warning: 'warning',
|
||||
critical: 'error',
|
||||
unknown: 'primary',
|
||||
}
|
||||
|
||||
export default function ApmPage({ params }: { params: { locale: string } }) {
|
||||
const t = useTranslations('apm')
|
||||
const [data, setData] = useState<GoldMetricsResponse | null>(null)
|
||||
@@ -64,20 +73,37 @@ export default function ApmPage({ params }: { params: { locale: string } }) {
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '32px', textAlign: 'center', color: '#cc2200', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('error')}</div>
|
||||
) : data && data.metrics.length > 0 ? (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||
{data.metrics.map((m, i) => (
|
||||
<div key={i} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px' }}>
|
||||
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{m.label}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace', lineHeight: 1.2 }}>
|
||||
{typeof m.value === 'number' ? m.value.toFixed(2) : m.value}
|
||||
{m.unit && <span style={{ fontSize: 13, color: '#87867f', marginLeft: 4 }}>{m.unit}</span>}
|
||||
{/* Metric Cards with Sparklines */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||
{data.metrics.map((m, i) => {
|
||||
const trendPoints = (m.trend ?? []).map((v, idx) => ({ timestamp: idx, value: v }))
|
||||
const hasTrend = trendPoints.length > 1
|
||||
const chartColor = STATUS_CHART_COLOR[m.status] ?? 'primary'
|
||||
return (
|
||||
<div key={i} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '16px 18px 12px' }}>
|
||||
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{m.label}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace', lineHeight: 1.2 }}>
|
||||
{typeof m.value === 'number' ? m.value.toFixed(2) : m.value}
|
||||
{m.unit && <span style={{ fontSize: 13, color: '#87867f', marginLeft: 4 }}>{m.unit}</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, marginBottom: hasTrend ? 10 : 0, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: STATUS_COLOR[m.status] ?? '#87867f', display: 'inline-block' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: STATUS_COLOR[m.status] ?? '#87867f', textTransform: 'uppercase' }}>{m.status}</span>
|
||||
</div>
|
||||
{hasTrend && (
|
||||
<TimeSeriesChart
|
||||
data={trendPoints}
|
||||
height={48}
|
||||
color={chartColor}
|
||||
unit={m.unit ?? undefined}
|
||||
showYAxis={false}
|
||||
showGradient={true}
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: STATUS_COLOR[m.status] ?? '#87867f', display: 'inline-block' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: STATUS_COLOR[m.status] ?? '#87867f', textTransform: 'uppercase' }}>{m.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '12px 16px' }}>
|
||||
<span style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
Search, BookOpen, FileText, Shield, Cpu,
|
||||
Server, Eye, Bot, ChevronRight, Plus,
|
||||
} from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -105,6 +107,9 @@ export default function KnowledgeBasePage({
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedEntry, setSelectedEntry] = useState<KnowledgeEntry | null>(null)
|
||||
|
||||
// KB-D: approve/archive state (2026-04-03 ogt)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
|
||||
const fetchEntries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -133,6 +138,47 @@ export default function KnowledgeBasePage({
|
||||
fetchEntries()
|
||||
}, [fetchEntries])
|
||||
|
||||
// KB-D: Approve / Archive handlers (2026-04-03 ogt)
|
||||
const handleApprove = useCallback(async () => {
|
||||
if (!selectedEntry || actionLoading) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
const res = await fetch(`${apiBase}/api/v1/knowledge/${selectedEntry.id}/approve`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (res.ok) {
|
||||
setSelectedEntry({ ...selectedEntry, status: 'approved' })
|
||||
fetchEntries()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Approve failed', err)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}, [selectedEntry, actionLoading, fetchEntries])
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
if (!selectedEntry || actionLoading) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||||
const res = await fetch(`${apiBase}/api/v1/knowledge/${selectedEntry.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'archived' }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setSelectedEntry({ ...selectedEntry, status: 'archived' })
|
||||
fetchEntries()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Archive failed', err)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}, [selectedEntry, actionLoading, fetchEntries])
|
||||
|
||||
const totalCount = categories.reduce((sum, c) => sum + c.count, 0)
|
||||
|
||||
return (
|
||||
@@ -350,9 +396,15 @@ export default function KnowledgeBasePage({
|
||||
<span>{new Date(selectedEntry.updated_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
{/* Content (Markdown-like rendering) */}
|
||||
<div className="prose prose-sm prose-neutral max-w-none font-body text-sm text-secondary whitespace-pre-wrap mb-4">
|
||||
{selectedEntry.content}
|
||||
{/* Content — react-markdown (KB-D 2026-04-03 ogt) */}
|
||||
<div className="prose prose-sm prose-neutral max-w-none font-body text-sm text-secondary mb-4
|
||||
[&_h1]:text-base [&_h1]:font-semibold [&_h1]:font-heading [&_h1]:text-primary [&_h1]:mt-4 [&_h1]:mb-2
|
||||
[&_h2]:text-xs [&_h2]:font-semibold [&_h2]:font-heading [&_h2]:text-primary [&_h2]:uppercase [&_h2]:tracking-wider [&_h2]:mt-4 [&_h2]:mb-1.5
|
||||
[&_li]:marker:text-claw-blue [&_code]:bg-nothing-gray-100 [&_code]:px-1 [&_code]:rounded [&_code]:text-[11px] [&_code]:font-mono
|
||||
[&_p]:leading-relaxed [&_ul]:pl-4 [&_ol]:pl-4">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{selectedEntry.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
@@ -378,14 +430,22 @@ export default function KnowledgeBasePage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{selectedEntry.status !== 'approved' && (
|
||||
{/* Actions (KB-D wired 2026-04-03 ogt) */}
|
||||
{selectedEntry.status !== 'approved' && selectedEntry.status !== 'archived' && (
|
||||
<div className="flex gap-2 pt-3 border-t border-nothing-gray-100">
|
||||
<button className="flex-1 text-xs font-body font-medium py-1.5 rounded bg-status-healthy/10 text-status-healthy hover:bg-status-healthy/20 transition-colors">
|
||||
{t('approve')}
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={actionLoading}
|
||||
className="flex-1 text-xs font-body font-medium py-1.5 rounded bg-status-healthy/10 text-status-healthy hover:bg-status-healthy/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? t('approving') : t('approve')}
|
||||
</button>
|
||||
<button className="flex-1 text-xs font-body font-medium py-1.5 rounded bg-nothing-gray-100 text-muted hover:bg-nothing-gray-200 transition-colors">
|
||||
{t('archive')}
|
||||
<button
|
||||
onClick={handleArchive}
|
||||
disabled={actionLoading}
|
||||
className="flex-1 text-xs font-body font-medium py-1.5 rounded bg-nothing-gray-100 text-muted hover:bg-nothing-gray-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? t('archiving') : t('archive')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
869
pnpm-lock.yaml
generated
869
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user