feat(usage): add manual historical cache data repair
This commit is contained in:
1 parent
f695193e24
commit
44dcdf13a8
7 files changed
+263
-40
No files matched your search
@@ -2452,6 +2452,7 @@ fn main() {
|
||||
usage::get_usage_analysis,
|
||||
usage::get_usage_events,
|
||||
usage::get_usage_pricing,
|
||||
usage::repair_usage_cache_records,
|
||||
usage::save_usage_model_price,
|
||||
usage::delete_usage_model_price,
|
||||
usage::sync_usage_model_prices,
|
||||
|
||||
+35
-37
@@ -35,7 +35,6 @@ const LEGACY_USAGE_EVENTS_DIR: &str = "events";
|
||||
const LEGACY_USAGE_INBOX_DIR: &str = "inbox";
|
||||
const LEGACY_JSON_MIGRATION_KEY: &str = "legacy_json_v1";
|
||||
const USAGE_DATABASE_MIGRATION_KEY: &str = "keeper_v3";
|
||||
const CLAUDE_INPUT_MIGRATION_KEY: &str = "claude_input_inclusive_v1";
|
||||
const USAGE_FAILURE_MIGRATION_KEY: &str = "failure_details_v4";
|
||||
const USAGE_UPDATED_EVENT: &str = "usage-records-updated";
|
||||
const USAGE_SCHEMA_VERSION: u8 = 1;
|
||||
@@ -266,6 +265,14 @@ pub(crate) struct UsageOverview {
|
||||
timeline: Vec<UsageTimelinePoint>,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UsageRepairResult {
|
||||
scanned: u64,
|
||||
repaired: u64,
|
||||
backup_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ModelPrice {
|
||||
@@ -529,24 +536,18 @@ fn initialize_usage_storage_at(root: &Path) -> Result<(), String> {
|
||||
.pragma_update(None, "journal_mode", "WAL")
|
||||
.map_err(|error| format!("启用 SQLite WAL 失败: {error}"))?;
|
||||
migrate_usage_database(&mut connection, root)?;
|
||||
migrate_claude_input_tokens(&mut connection, root)?;
|
||||
migrate_legacy_json_storage(&mut connection, root)?;
|
||||
cleanup_usage_inbox(&connection, Local::now())
|
||||
}
|
||||
|
||||
fn migrate_claude_input_tokens(connection: &mut Connection, root: &Path) -> Result<(), String> {
|
||||
let already_done: Option<String> = connection
|
||||
.query_row(
|
||||
"SELECT value FROM usage_metadata WHERE key = ?1",
|
||||
params![CLAUDE_INPUT_MIGRATION_KEY],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| format!("检查 Claude 使用记录迁移状态失败: {error}"))?;
|
||||
if already_done.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
#[tauri::command]
|
||||
pub(crate) fn repair_usage_cache_records() -> Result<UsageRepairResult, String> {
|
||||
let root = usage_root_dir()?;
|
||||
repair_usage_cache_records_at(&root)
|
||||
}
|
||||
|
||||
fn repair_usage_cache_records_at(root: &Path) -> Result<UsageRepairResult, String> {
|
||||
let mut connection = open_usage_database_at(root)?;
|
||||
let candidate_count: i64 = connection
|
||||
.query_row(
|
||||
r#"SELECT COUNT(*) FROM usage_events
|
||||
@@ -560,7 +561,11 @@ fn migrate_claude_input_tokens(connection: &mut Connection, root: &Path) -> Resu
|
||||
)
|
||||
.map_err(|error| format!("检查 Claude 使用记录异常行失败: {error}"))?;
|
||||
|
||||
if candidate_count > 0 {
|
||||
if candidate_count <= 0 {
|
||||
return Ok(UsageRepairResult::default());
|
||||
}
|
||||
|
||||
let backup_path = {
|
||||
let backup_dir = root.join(USAGE_BACKUP_DIR_NAME);
|
||||
fs::create_dir_all(&backup_dir)
|
||||
.map_err(|error| format!("创建 Claude 使用记录迁移备份目录失败: {error}"))?;
|
||||
@@ -574,7 +579,8 @@ fn migrate_claude_input_tokens(connection: &mut Connection, root: &Path) -> Resu
|
||||
params![backup_path.to_string_lossy().to_string()],
|
||||
)
|
||||
.map_err(|error| format!("备份 Claude 使用记录失败: {error}"))?;
|
||||
}
|
||||
backup_path.to_string_lossy().to_string()
|
||||
};
|
||||
|
||||
let transaction = connection
|
||||
.transaction()
|
||||
@@ -598,21 +604,14 @@ fn migrate_claude_input_tokens(connection: &mut Connection, root: &Path) -> Resu
|
||||
[],
|
||||
)
|
||||
.map_err(|error| format!("迁移 Claude 使用记录失败: {error}"))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO usage_metadata (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![CLAUDE_INPUT_MIGRATION_KEY, Local::now().to_rfc3339()],
|
||||
)
|
||||
.map_err(|error| format!("写入 Claude 使用记录迁移标记失败: {error}"))?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| format!("提交 Claude 使用记录迁移失败: {error}"))?;
|
||||
|
||||
if migrated > 0 {
|
||||
eprintln!("migrated {migrated} Claude usage rows to inclusive input token semantics");
|
||||
}
|
||||
Ok(())
|
||||
Ok(UsageRepairResult {
|
||||
scanned: candidate_count.max(0) as u64,
|
||||
repaired: migrated as u64,
|
||||
backup_path: Some(backup_path),
|
||||
})
|
||||
}
|
||||
|
||||
fn migrate_usage_database(connection: &mut Connection, root: &Path) -> Result<(), String> {
|
||||
@@ -3301,7 +3300,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_legacy_claude_input_tokens_once() {
|
||||
fn repairs_legacy_claude_input_tokens_on_demand() {
|
||||
let root = test_root("claude-input-migration");
|
||||
let connection = open_test_database(&root);
|
||||
connection
|
||||
@@ -3316,15 +3315,12 @@ mod tests {
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"DELETE FROM usage_metadata WHERE key = ?1",
|
||||
params![CLAUDE_INPUT_MIGRATION_KEY],
|
||||
)
|
||||
.unwrap();
|
||||
drop(connection);
|
||||
|
||||
initialize_usage_storage_at(&root).unwrap();
|
||||
let result = repair_usage_cache_records_at(&root).unwrap();
|
||||
assert_eq!(result.scanned, 1);
|
||||
assert_eq!(result.repaired, 1);
|
||||
assert!(result.backup_path.is_some());
|
||||
let connection = open_usage_database_at(&root).unwrap();
|
||||
let row: (i64, i64, i64) = connection
|
||||
.query_row(
|
||||
@@ -3336,7 +3332,9 @@ mod tests {
|
||||
assert_eq!(row, (720, 740, 620));
|
||||
drop(connection);
|
||||
|
||||
initialize_usage_storage_at(&root).unwrap();
|
||||
let second = repair_usage_cache_records_at(&root).unwrap();
|
||||
assert_eq!(second.scanned, 0);
|
||||
assert_eq!(second.repaired, 0);
|
||||
let rerun_connection = open_usage_database_at(&root).unwrap();
|
||||
let rerun_input: i64 = rerun_connection
|
||||
.query_row(
|
||||
|
||||
@@ -113,6 +113,7 @@ export const jaOverrides = {
|
||||
'usage.tab.analysis': '分析',
|
||||
'usage.tab.events': 'リクエスト詳細',
|
||||
'usage.tab.pricing': '料金統計',
|
||||
'usage.tab.dataManagement': 'データ管理',
|
||||
'usage.refresh': '更新',
|
||||
'usage.filter.reset': 'フィルター解除',
|
||||
'usage.filter.timeRange': '期間',
|
||||
@@ -179,6 +180,18 @@ export const jaOverrides = {
|
||||
'usage.pricing.saved': 'モデル料金を保存し、概要のコストを更新しました',
|
||||
'usage.pricing.deleted': 'モデル料金を削除し、概要のコストを更新しました',
|
||||
'usage.pricing.deleteConfirm': 'モデル {model} の料金を削除しますか?',
|
||||
'usage.dataManagement.title': '履歴データ管理',
|
||||
'usage.dataManagement.description': 'キャッシュ Token の不整合がある使用履歴をスキャンして修復します。',
|
||||
'usage.dataManagement.manualBadge': '手動操作',
|
||||
'usage.dataManagement.notice': '修復前にデータベースをバックアップします。判定可能な Claude / Anthropic の記録のみ処理します。',
|
||||
'usage.dataManagement.actionTitle': 'キャッシュ記録を修復',
|
||||
'usage.dataManagement.actionDescription': 'キャッシュ読み取りと作成を含む入力 Token の集計方式に統一します。',
|
||||
'usage.dataManagement.run': 'スキャンして修復',
|
||||
'usage.dataManagement.running': '処理中…',
|
||||
'usage.dataManagement.confirm': '履歴キャッシュ記録をスキャンして修復しますか?先にデータベースをバックアップします。',
|
||||
'usage.dataManagement.scanned': 'スキャン件数',
|
||||
'usage.dataManagement.repaired': '修復件数',
|
||||
'usage.dataManagement.backup': 'バックアップ',
|
||||
'usage.trend.title': 'リクエストと Token の推移',
|
||||
'usage.trend.description': 'システムのローカル時刻ごとに集計',
|
||||
'usage.token.title': 'Token の内訳',
|
||||
|
||||
@@ -113,6 +113,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.tab.analysis': 'Analysis',
|
||||
'usage.tab.events': 'Request Details',
|
||||
'usage.tab.pricing': 'Price Statistics',
|
||||
'usage.tab.dataManagement': 'Data Management',
|
||||
'usage.refresh': 'Refresh',
|
||||
'usage.filter.reset': 'Reset Filters',
|
||||
'usage.filter.timeRange': 'Time range',
|
||||
@@ -179,6 +180,18 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.pricing.saved': 'Model price saved; overview cost updated',
|
||||
'usage.pricing.deleted': 'Model price deleted; overview cost updated',
|
||||
'usage.pricing.deleteConfirm': 'Delete the price for {model}?',
|
||||
'usage.dataManagement.title': 'Historical Data Management',
|
||||
'usage.dataManagement.description': 'Scan and repair historical usage records with inconsistent cache token accounting.',
|
||||
'usage.dataManagement.manualBadge': 'Manual action',
|
||||
'usage.dataManagement.notice': 'A database backup is created first. Only identifiable Claude / Anthropic records are processed.',
|
||||
'usage.dataManagement.actionTitle': 'Repair cache records',
|
||||
'usage.dataManagement.actionDescription': 'Use input token accounting that includes cache reads and cache creation.',
|
||||
'usage.dataManagement.run': 'Scan and repair',
|
||||
'usage.dataManagement.running': 'Processing…',
|
||||
'usage.dataManagement.confirm': 'Scan and repair historical cache records? A database backup will be created first.',
|
||||
'usage.dataManagement.scanned': 'Scanned',
|
||||
'usage.dataManagement.repaired': 'Repaired',
|
||||
'usage.dataManagement.backup': 'Backup file',
|
||||
'usage.trend.title': 'Request and Token Trend',
|
||||
'usage.trend.description': 'Grouped by local system hour',
|
||||
'usage.token.title': 'Token Breakdown',
|
||||
|
||||
@@ -111,6 +111,7 @@ export const zhCN = {
|
||||
'usage.tab.analysis': '分析',
|
||||
'usage.tab.events': '请求明细',
|
||||
'usage.tab.pricing': '价格统计',
|
||||
'usage.tab.dataManagement': '数据管理',
|
||||
'usage.refresh': '刷新数据',
|
||||
'usage.filter.reset': '重置筛选',
|
||||
'usage.filter.timeRange': '时间范围',
|
||||
@@ -177,6 +178,18 @@ export const zhCN = {
|
||||
'usage.pricing.saved': '模型价格已保存,总览成本已更新',
|
||||
'usage.pricing.deleted': '模型价格已删除,总览成本已更新',
|
||||
'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?',
|
||||
'usage.dataManagement.title': '历史数据管理',
|
||||
'usage.dataManagement.description': '扫描并修复缓存 Token 口径异常的历史使用记录。',
|
||||
'usage.dataManagement.manualBadge': '手动操作',
|
||||
'usage.dataManagement.notice': '修复前会自动备份数据库,仅处理可确认的 Claude / Anthropic 异常记录。',
|
||||
'usage.dataManagement.actionTitle': '修复异常缓存记录',
|
||||
'usage.dataManagement.actionDescription': '将输入 Token 统一为包含缓存读取和缓存创建的统计口径。',
|
||||
'usage.dataManagement.run': '扫描并修复',
|
||||
'usage.dataManagement.running': '处理中…',
|
||||
'usage.dataManagement.confirm': '确定扫描并修复历史缓存记录吗?修复前会自动创建数据库备份。',
|
||||
'usage.dataManagement.scanned': '扫描记录',
|
||||
'usage.dataManagement.repaired': '修复记录',
|
||||
'usage.dataManagement.backup': '备份文件',
|
||||
'usage.trend.title': '请求与 Token 趋势',
|
||||
'usage.trend.description': '按系统本地小时聚合',
|
||||
'usage.token.title': 'Token 构成',
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Terminal,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { getCurrentLocale, useI18n } from '../i18n';
|
||||
@@ -29,7 +30,7 @@ import type { MessageKey } from '../i18n/resources';
|
||||
import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics';
|
||||
import { formatUsageNumber } from '../services/usageNumber';
|
||||
|
||||
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing';
|
||||
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing' | 'data-management';
|
||||
type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom';
|
||||
|
||||
type CollectorStatus = {
|
||||
@@ -158,6 +159,12 @@ type UsagePricing = {
|
||||
savedPrices: number;
|
||||
};
|
||||
|
||||
type UsageRepairResult = {
|
||||
scanned: number;
|
||||
repaired: number;
|
||||
backupPath: string | null;
|
||||
};
|
||||
|
||||
type ModelPriceSyncResult = {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
@@ -185,7 +192,9 @@ const emptyAnalysis: UsageAnalysis = { models: [], providers: [], sources: [], a
|
||||
const loadTab = (): UsageTab => {
|
||||
try {
|
||||
const saved = localStorage.getItem(TAB_KEY);
|
||||
return saved === 'analysis' || saved === 'events' || saved === 'pricing' ? saved : 'overview';
|
||||
return saved === 'analysis' || saved === 'events' || saved === 'pricing' || saved === 'data-management'
|
||||
? saved
|
||||
: 'overview';
|
||||
} catch {
|
||||
return 'overview';
|
||||
}
|
||||
@@ -356,7 +365,7 @@ export function UsageRecordsPage() {
|
||||
setStatus(nextStatus);
|
||||
setOptionsAnalysis(nextOptions);
|
||||
setEvents(nextEvents);
|
||||
} else {
|
||||
} else if (activeTab === 'pricing') {
|
||||
const [nextStatus, nextOptions, nextPricing] = await Promise.all([
|
||||
statusRequest,
|
||||
optionsRequest,
|
||||
@@ -366,6 +375,11 @@ export function UsageRecordsPage() {
|
||||
setStatus(nextStatus);
|
||||
setOptionsAnalysis(nextOptions);
|
||||
setPricing(nextPricing);
|
||||
} else {
|
||||
const [nextStatus, nextOptions] = await Promise.all([statusRequest, optionsRequest]);
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setStatus(nextStatus);
|
||||
setOptionsAnalysis(nextOptions);
|
||||
}
|
||||
setError('');
|
||||
} catch (requestError) {
|
||||
@@ -475,6 +489,14 @@ export function UsageRecordsPage() {
|
||||
<CircleDollarSign size={15} />
|
||||
<span>{t('usage.tab.pricing')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={activeTab === 'data-management' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('data-management')}
|
||||
>
|
||||
<Wrench size={15} />
|
||||
<span>{t('usage.tab.dataManagement')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="usage-topbar-actions">
|
||||
@@ -677,6 +699,69 @@ export function UsageRecordsPage() {
|
||||
{activeTab === 'pricing' && pricing ? (
|
||||
<PricingView pricing={pricing} query={buildQueries().query} onChanged={() => loadData(true)} />
|
||||
) : null}
|
||||
{activeTab === 'data-management' ? <UsageDataManagementView /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageDataManagementView() {
|
||||
const { t } = useI18n();
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<UsageRepairResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const repair = async () => {
|
||||
if (!window.confirm(t('usage.dataManagement.confirm'))) return;
|
||||
setRunning(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const next = await invoke<UsageRepairResult>('repair_usage_cache_records');
|
||||
setResult(next);
|
||||
} catch (requestError) {
|
||||
setError(String(requestError));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel usage-data-management-panel">
|
||||
<div className="usage-data-management-heading">
|
||||
<div>
|
||||
<Wrench size={20} aria-hidden="true" />
|
||||
<div>
|
||||
<h2>{t('usage.dataManagement.title')}</h2>
|
||||
<p>{t('usage.dataManagement.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="usage-data-management-badge">{t('usage.dataManagement.manualBadge')}</span>
|
||||
</div>
|
||||
|
||||
<div className="usage-data-management-notice">
|
||||
<TriangleAlert size={17} aria-hidden="true" />
|
||||
<span>{t('usage.dataManagement.notice')}</span>
|
||||
</div>
|
||||
|
||||
<div className="usage-data-management-action">
|
||||
<div>
|
||||
<strong>{t('usage.dataManagement.actionTitle')}</strong>
|
||||
<span>{t('usage.dataManagement.actionDescription')}</span>
|
||||
</div>
|
||||
<button type="button" className="primary-button" onClick={() => void repair()} disabled={running}>
|
||||
<Wrench size={15} />
|
||||
{running ? t('usage.dataManagement.running') : t('usage.dataManagement.run')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="management-alert error">{error}</div> : null}
|
||||
{result ? (
|
||||
<div className="usage-data-management-result">
|
||||
<div><span>{t('usage.dataManagement.scanned')}</span><strong>{result.scanned.toLocaleString()}</strong></div>
|
||||
<div><span>{t('usage.dataManagement.repaired')}</span><strong>{result.repaired.toLocaleString()}</strong></div>
|
||||
<div><span>{t('usage.dataManagement.backup')}</span><strong title={result.backupPath ?? undefined}>{result.backupPath ?? '—'}</strong></div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+100
@@ -7718,6 +7718,95 @@ body.table-col-resizing * {
|
||||
background: var(--theme-fffdf8);
|
||||
}
|
||||
|
||||
.usage-data-management-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.usage-data-management-heading,
|
||||
.usage-data-management-action,
|
||||
.usage-data-management-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.usage-data-management-heading > div {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.usage-data-management-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.usage-data-management-heading p,
|
||||
.usage-data-management-action span {
|
||||
display: block;
|
||||
margin: 6px 0 0;
|
||||
color: var(--theme-716b61);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.usage-data-management-badge {
|
||||
padding: 5px 9px;
|
||||
border: 1px solid var(--theme-d8d4cc);
|
||||
border-radius: 999px;
|
||||
color: var(--theme-716b61);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.usage-data-management-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 22px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--theme-c77b30) 28%, var(--theme-d8d4cc));
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--theme-c77b30) 8%, transparent);
|
||||
color: var(--theme-716b61);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.usage-data-management-action {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--theme-d8d4cc);
|
||||
border-radius: 12px;
|
||||
background: var(--theme-f8f5ee);
|
||||
}
|
||||
|
||||
.usage-data-management-result {
|
||||
align-items: stretch;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.usage-data-management-result > div {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--theme-d8d4cc);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.usage-data-management-result span {
|
||||
color: var(--theme-716b61);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.usage-data-management-result strong {
|
||||
overflow: hidden;
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-pricing-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -9010,6 +9099,17 @@ body.table-col-resizing * {
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.usage-data-management-heading,
|
||||
.usage-data-management-action,
|
||||
.usage-data-management-result {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.usage-data-management-action .primary-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user