feat: 新增 Skills Usage 监控统计与图表 (#668)
* feat: add skills usage monitoring * fix: localize Skills Usage page copy * fix: keep Skills Usage labels compact
This commit is contained in:
@@ -45,11 +45,52 @@ export interface SkillsData {
|
|||||||
archived: SkillInfo[]
|
archived: SkillInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillUsageRow {
|
||||||
|
skill: string
|
||||||
|
view_count: number
|
||||||
|
manage_count: number
|
||||||
|
total_count: number
|
||||||
|
percentage: number
|
||||||
|
last_used_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillUsageDailySkillRow {
|
||||||
|
skill: string
|
||||||
|
view_count: number
|
||||||
|
manage_count: number
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillUsageDailyRow {
|
||||||
|
date: string
|
||||||
|
view_count: number
|
||||||
|
manage_count: number
|
||||||
|
total_count: number
|
||||||
|
skills: SkillUsageDailySkillRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillUsageStats {
|
||||||
|
period_days: number
|
||||||
|
summary: {
|
||||||
|
total_skill_loads: number
|
||||||
|
total_skill_edits: number
|
||||||
|
total_skill_actions: number
|
||||||
|
distinct_skills_used: number
|
||||||
|
}
|
||||||
|
by_day: SkillUsageDailyRow[]
|
||||||
|
top_skills: SkillUsageRow[]
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSkills(): Promise<SkillsData> {
|
export async function fetchSkills(): Promise<SkillsData> {
|
||||||
const res = await request<SkillListResponse>('/api/hermes/skills')
|
const res = await request<SkillListResponse>('/api/hermes/skills')
|
||||||
return { categories: res.categories, archived: res.archived ?? [] }
|
return { categories: res.categories, archived: res.archived ?? [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchSkillUsageStats(days = 7): Promise<SkillUsageStats> {
|
||||||
|
const params = new URLSearchParams({ days: String(days) })
|
||||||
|
return request<SkillUsageStats>(`/api/hermes/skills/usage/stats?${params}`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSkillContent(skillPath: string): Promise<string> {
|
export async function fetchSkillContent(skillPath: string): Promise<string> {
|
||||||
const res = await request<{ content: string }>(`/api/hermes/skills/${skillPath}`)
|
const res = await request<{ content: string }>(`/api/hermes/skills/${skillPath}`)
|
||||||
return res.content
|
return res.content
|
||||||
|
|||||||
@@ -220,6 +220,14 @@ function openChangelog() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{{ t("sidebar.usage") }}</span>
|
<span>{{ t("sidebar.usage") }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" :class="{ active: selectedKey === 'hermes.skillsUsage' }" @click="handleNav('hermes.skillsUsage')">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 20V10" />
|
||||||
|
<path d="M18 20V4" />
|
||||||
|
<path d="M6 20v-6" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ t("sidebar.skillsUsage") }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default {
|
|||||||
memory: 'Gedachtnis',
|
memory: 'Gedachtnis',
|
||||||
logs: 'Protokolle',
|
logs: 'Protokolle',
|
||||||
usage: 'Nutzung',
|
usage: 'Nutzung',
|
||||||
|
skillsUsage: 'Skill-Nutzung',
|
||||||
channels: 'Kanale',
|
channels: 'Kanale',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
files: 'Dateien',
|
files: 'Dateien',
|
||||||
@@ -762,6 +763,28 @@ jobTriggered: 'Job ausgelost',
|
|||||||
noData: 'Keine Nutzungsdaten',
|
noData: 'Keine Nutzungsdaten',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
skillsUsage: {
|
||||||
|
title: 'Skill-Nutzung',
|
||||||
|
subtitle: 'Skill-Ladevorgänge und -Bearbeitungen aus Hermes-Sitzungen verfolgen',
|
||||||
|
refresh: 'Aktualisieren',
|
||||||
|
periodSelector: 'Zeitraum der Skill-Nutzung',
|
||||||
|
periodLabel: '{days} T',
|
||||||
|
summary: 'Zusammenfassung',
|
||||||
|
totalActions: 'Aktionen',
|
||||||
|
loads: 'Laden',
|
||||||
|
edits: 'Änd.',
|
||||||
|
distinctSkills: 'Skillzahl',
|
||||||
|
topSkills: 'Top-Skills',
|
||||||
|
dailyTrend: 'Trend',
|
||||||
|
periodSummary: 'Letzte {days} Tage',
|
||||||
|
skill: 'Fähigkeit',
|
||||||
|
share: '%',
|
||||||
|
lastUsed: 'Zuletzt',
|
||||||
|
noData: 'Keine Skill-Nutzungsdaten',
|
||||||
|
loadFailed: 'Skill-Nutzung konnte nicht geladen werden',
|
||||||
|
otherSkills: 'Andere Skills',
|
||||||
|
},
|
||||||
|
|
||||||
// Anderungsprotokoll
|
// Anderungsprotokoll
|
||||||
changelog: {
|
changelog: {
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export default {
|
|||||||
memory: 'Memory',
|
memory: 'Memory',
|
||||||
logs: 'Logs',
|
logs: 'Logs',
|
||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
|
skillsUsage: 'Skills Usage',
|
||||||
channels: 'Channels',
|
channels: 'Channels',
|
||||||
gateways: 'Gateways',
|
gateways: 'Gateways',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
@@ -983,6 +984,28 @@ export default {
|
|||||||
cost: 'Cost',
|
cost: 'Cost',
|
||||||
noData: 'No usage data',
|
noData: 'No usage data',
|
||||||
},
|
},
|
||||||
|
skillsUsage: {
|
||||||
|
title: 'Skills Usage',
|
||||||
|
subtitle: 'Track skill loads and edits from Hermes sessions',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
periodSelector: 'Skill usage period',
|
||||||
|
periodLabel: '{days}d',
|
||||||
|
summary: 'Summary',
|
||||||
|
totalActions: 'Actions',
|
||||||
|
loads: 'Loads',
|
||||||
|
edits: 'Edits',
|
||||||
|
distinctSkills: 'Skills',
|
||||||
|
topSkills: 'Top Skills',
|
||||||
|
dailyTrend: 'Daily Trend',
|
||||||
|
periodSummary: 'Last {days} days',
|
||||||
|
skill: 'Skill',
|
||||||
|
share: 'Share',
|
||||||
|
lastUsed: 'Last Used',
|
||||||
|
noData: 'No skill usage data',
|
||||||
|
loadFailed: 'Failed to load skill usage',
|
||||||
|
otherSkills: 'Other skills',
|
||||||
|
},
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
files: {
|
files: {
|
||||||
title: 'Files',
|
title: 'Files',
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default {
|
|||||||
memory: 'Memoria',
|
memory: 'Memoria',
|
||||||
logs: 'Registros',
|
logs: 'Registros',
|
||||||
usage: 'Uso',
|
usage: 'Uso',
|
||||||
|
skillsUsage: 'Uso de habilidades',
|
||||||
channels: 'Canales',
|
channels: 'Canales',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
files: 'Archivos',
|
files: 'Archivos',
|
||||||
@@ -758,6 +759,28 @@ jobTriggered: 'Job ejecutado',
|
|||||||
noData: 'Sin datos de uso',
|
noData: 'Sin datos de uso',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
skillsUsage: {
|
||||||
|
title: 'Uso de habilidades',
|
||||||
|
subtitle: 'Sigue las cargas y ediciones de habilidades en sesiones de Hermes',
|
||||||
|
refresh: 'Actualizar',
|
||||||
|
periodSelector: 'Periodo de uso de habilidades',
|
||||||
|
periodLabel: '{days} d',
|
||||||
|
summary: 'Resumen',
|
||||||
|
totalActions: 'Acciones',
|
||||||
|
loads: 'Cargas',
|
||||||
|
edits: 'Ed.',
|
||||||
|
distinctSkills: 'Habs.',
|
||||||
|
topSkills: 'Top habs.',
|
||||||
|
dailyTrend: 'Tendencia diaria',
|
||||||
|
periodSummary: 'Últimos {days} días',
|
||||||
|
skill: 'Hab.',
|
||||||
|
share: '%',
|
||||||
|
lastUsed: 'Últ. uso',
|
||||||
|
noData: 'No hay datos de uso de habilidades',
|
||||||
|
loadFailed: 'No se pudo cargar el uso de habilidades',
|
||||||
|
otherSkills: 'Otras habs.',
|
||||||
|
},
|
||||||
|
|
||||||
// Registro de cambios
|
// Registro de cambios
|
||||||
changelog: {
|
changelog: {
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default {
|
|||||||
memory: 'Memoire',
|
memory: 'Memoire',
|
||||||
logs: 'Journaux',
|
logs: 'Journaux',
|
||||||
usage: 'Utilisation',
|
usage: 'Utilisation',
|
||||||
|
skillsUsage: 'Utilisation des compétences',
|
||||||
channels: 'Canaux',
|
channels: 'Canaux',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
files: 'Fichiers',
|
files: 'Fichiers',
|
||||||
@@ -758,6 +759,28 @@ jobTriggered: 'Job declenche',
|
|||||||
noData: 'Aucune donnee d\'utilisation',
|
noData: 'Aucune donnee d\'utilisation',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
skillsUsage: {
|
||||||
|
title: 'Utilisation des compétences',
|
||||||
|
subtitle: 'Suivre les chargements et modifications de compétences dans les sessions Hermes',
|
||||||
|
refresh: 'Actualiser',
|
||||||
|
periodSelector: 'Période d\'utilisation des compétences',
|
||||||
|
periodLabel: '{days} j',
|
||||||
|
summary: 'Résumé',
|
||||||
|
totalActions: 'Act.',
|
||||||
|
loads: 'Charg.',
|
||||||
|
edits: 'Modif.',
|
||||||
|
distinctSkills: 'Comp.',
|
||||||
|
topSkills: 'Top comp.',
|
||||||
|
dailyTrend: 'Tendance',
|
||||||
|
periodSummary: '{days} derniers jours',
|
||||||
|
skill: 'Comp.',
|
||||||
|
share: '%',
|
||||||
|
lastUsed: 'Dern. usage',
|
||||||
|
noData: 'Aucune donnée d\'utilisation des compétences',
|
||||||
|
loadFailed: 'Impossible de charger l\'utilisation des compétences',
|
||||||
|
otherSkills: 'Autres comp.',
|
||||||
|
},
|
||||||
|
|
||||||
// Journal des modifications
|
// Journal des modifications
|
||||||
changelog: {
|
changelog: {
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default {
|
|||||||
memory: 'メモリ',
|
memory: 'メモリ',
|
||||||
logs: 'ログ',
|
logs: 'ログ',
|
||||||
usage: '使用量',
|
usage: '使用量',
|
||||||
|
skillsUsage: 'スキル使用状況',
|
||||||
channels: 'チャンネル',
|
channels: 'チャンネル',
|
||||||
terminal: 'ターミナル',
|
terminal: 'ターミナル',
|
||||||
files: 'ファイル',
|
files: 'ファイル',
|
||||||
@@ -758,6 +759,28 @@ export default {
|
|||||||
noData: '使用データがありません',
|
noData: '使用データがありません',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
skillsUsage: {
|
||||||
|
title: 'スキル使用状況',
|
||||||
|
subtitle: 'Hermes セッションでのスキル読み込みと編集を追跡します',
|
||||||
|
refresh: '更新',
|
||||||
|
periodSelector: 'スキル使用期間',
|
||||||
|
periodLabel: '{days}日',
|
||||||
|
summary: '概要',
|
||||||
|
totalActions: '操作数',
|
||||||
|
loads: '読み込み',
|
||||||
|
edits: '編集',
|
||||||
|
distinctSkills: 'スキル数',
|
||||||
|
topSkills: '上位',
|
||||||
|
dailyTrend: '日別',
|
||||||
|
periodSummary: '過去 {days} 日',
|
||||||
|
skill: 'スキル',
|
||||||
|
share: '割合',
|
||||||
|
lastUsed: '最終',
|
||||||
|
noData: 'スキル使用データはありません',
|
||||||
|
loadFailed: 'スキル使用状況の読み込みに失敗しました',
|
||||||
|
otherSkills: 'その他',
|
||||||
|
},
|
||||||
|
|
||||||
// 更新履歴
|
// 更新履歴
|
||||||
changelog: {
|
changelog: {
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default {
|
|||||||
memory: '메모리',
|
memory: '메모리',
|
||||||
logs: '로그',
|
logs: '로그',
|
||||||
usage: '사용량',
|
usage: '사용량',
|
||||||
|
skillsUsage: '스킬 사용량',
|
||||||
channels: '채널',
|
channels: '채널',
|
||||||
terminal: '터미널',
|
terminal: '터미널',
|
||||||
files: '파일',
|
files: '파일',
|
||||||
@@ -758,6 +759,28 @@ export default {
|
|||||||
noData: '사용량 데이터 없음',
|
noData: '사용량 데이터 없음',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
skillsUsage: {
|
||||||
|
title: '스킬 사용량',
|
||||||
|
subtitle: 'Hermes 세션의 스킬 로드와 편집을 추적합니다',
|
||||||
|
refresh: '새로고침',
|
||||||
|
periodSelector: '스킬 사용량 기간',
|
||||||
|
periodLabel: '{days}일',
|
||||||
|
summary: '요약',
|
||||||
|
totalActions: '작업 수',
|
||||||
|
loads: '로드',
|
||||||
|
edits: '편집',
|
||||||
|
distinctSkills: '스킬 수',
|
||||||
|
topSkills: '상위',
|
||||||
|
dailyTrend: '일별',
|
||||||
|
periodSummary: '최근 {days}일',
|
||||||
|
skill: '스킬',
|
||||||
|
share: '비중',
|
||||||
|
lastUsed: '마지막',
|
||||||
|
noData: '스킬 사용량 데이터가 없습니다',
|
||||||
|
loadFailed: '스킬 사용량을 불러오지 못했습니다',
|
||||||
|
otherSkills: '기타',
|
||||||
|
},
|
||||||
|
|
||||||
// 변경 이력
|
// 변경 이력
|
||||||
changelog: {
|
changelog: {
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default {
|
|||||||
memory: 'Memoria',
|
memory: 'Memoria',
|
||||||
logs: 'Logs',
|
logs: 'Logs',
|
||||||
usage: 'Uso',
|
usage: 'Uso',
|
||||||
|
skillsUsage: 'Uso de habilidades',
|
||||||
channels: 'Canais',
|
channels: 'Canais',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
files: 'Arquivos',
|
files: 'Arquivos',
|
||||||
@@ -758,6 +759,28 @@ jobTriggered: 'Job acionado',
|
|||||||
noData: 'Sem dados de uso',
|
noData: 'Sem dados de uso',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
skillsUsage: {
|
||||||
|
title: 'Uso de habilidades',
|
||||||
|
subtitle: 'Acompanhe carregamentos e edições de habilidades nas sessões Hermes',
|
||||||
|
refresh: 'Atualizar',
|
||||||
|
periodSelector: 'Período de uso de habilidades',
|
||||||
|
periodLabel: '{days} d',
|
||||||
|
summary: 'Resumo',
|
||||||
|
totalActions: 'Ações',
|
||||||
|
loads: 'Carga',
|
||||||
|
edits: 'Ed.',
|
||||||
|
distinctSkills: 'Habs.',
|
||||||
|
topSkills: 'Top habs.',
|
||||||
|
dailyTrend: 'Tendência diária',
|
||||||
|
periodSummary: 'Últimos {days} dias',
|
||||||
|
skill: 'Hab.',
|
||||||
|
share: '%',
|
||||||
|
lastUsed: 'Últ. uso',
|
||||||
|
noData: 'Nenhum dado de uso de habilidades',
|
||||||
|
loadFailed: 'Falha ao carregar o uso de habilidades',
|
||||||
|
otherSkills: 'Outras habs.',
|
||||||
|
},
|
||||||
|
|
||||||
// Registro de alteracoes
|
// Registro de alteracoes
|
||||||
changelog: {
|
changelog: {
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export default {
|
|||||||
memory: '記憶',
|
memory: '記憶',
|
||||||
logs: '日誌',
|
logs: '日誌',
|
||||||
usage: '用量',
|
usage: '用量',
|
||||||
|
skillsUsage: '技能用量',
|
||||||
channels: '頻道',
|
channels: '頻道',
|
||||||
gateways: '閘道',
|
gateways: '閘道',
|
||||||
terminal: '終端機',
|
terminal: '終端機',
|
||||||
@@ -986,6 +987,28 @@ export default {
|
|||||||
noData: '目前無用量資料',
|
noData: '目前無用量資料',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
skillsUsage: {
|
||||||
|
title: '技能用量',
|
||||||
|
subtitle: '追蹤 Hermes 工作階段中的技能載入與編輯',
|
||||||
|
refresh: '重新整理',
|
||||||
|
periodSelector: '技能用量期間',
|
||||||
|
periodLabel: '{days}天',
|
||||||
|
summary: '總覽',
|
||||||
|
totalActions: '操作',
|
||||||
|
loads: '載入',
|
||||||
|
edits: '編輯',
|
||||||
|
distinctSkills: '技能數',
|
||||||
|
topSkills: '熱門',
|
||||||
|
dailyTrend: '趨勢',
|
||||||
|
periodSummary: '最近 {days} 天',
|
||||||
|
skill: '技能',
|
||||||
|
share: '占比',
|
||||||
|
lastUsed: '最近',
|
||||||
|
noData: '暫無技能用量資料',
|
||||||
|
loadFailed: '技能用量載入失敗',
|
||||||
|
otherSkills: '其他技能',
|
||||||
|
},
|
||||||
|
|
||||||
// 檔案管理
|
// 檔案管理
|
||||||
files: {
|
files: {
|
||||||
title: '檔案',
|
title: '檔案',
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export default {
|
|||||||
memory: '记忆',
|
memory: '记忆',
|
||||||
logs: '日志',
|
logs: '日志',
|
||||||
usage: '用量',
|
usage: '用量',
|
||||||
|
skillsUsage: '技能用量',
|
||||||
channels: '频道',
|
channels: '频道',
|
||||||
gateways: '网关',
|
gateways: '网关',
|
||||||
terminal: '终端',
|
terminal: '终端',
|
||||||
@@ -985,6 +986,28 @@ export default {
|
|||||||
cost: '费用',
|
cost: '费用',
|
||||||
noData: '暂无用量数据',
|
noData: '暂无用量数据',
|
||||||
},
|
},
|
||||||
|
skillsUsage: {
|
||||||
|
title: '技能用量',
|
||||||
|
subtitle: '跟踪 Hermes 会话中的技能加载和编辑',
|
||||||
|
refresh: '刷新',
|
||||||
|
periodSelector: '技能用量周期',
|
||||||
|
periodLabel: '{days}天',
|
||||||
|
summary: '概览',
|
||||||
|
totalActions: '操作',
|
||||||
|
loads: '加载',
|
||||||
|
edits: '编辑',
|
||||||
|
distinctSkills: '技能数',
|
||||||
|
topSkills: '热门',
|
||||||
|
dailyTrend: '趋势',
|
||||||
|
periodSummary: '最近 {days} 天',
|
||||||
|
skill: '技能',
|
||||||
|
share: '占比',
|
||||||
|
lastUsed: '最近',
|
||||||
|
noData: '暂无技能用量数据',
|
||||||
|
loadFailed: '技能用量加载失败',
|
||||||
|
otherSkills: '其他技能',
|
||||||
|
},
|
||||||
|
|
||||||
// 文件管理
|
// 文件管理
|
||||||
files: {
|
files: {
|
||||||
title: '文件',
|
title: '文件',
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ const router = createRouter({
|
|||||||
name: 'hermes.usage',
|
name: 'hermes.usage',
|
||||||
component: () => import('@/views/hermes/UsageView.vue'),
|
component: () => import('@/views/hermes/UsageView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/hermes/skills-usage',
|
||||||
|
name: 'hermes.skillsUsage',
|
||||||
|
component: () => import('@/views/hermes/SkillsUsageView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/hermes/skills',
|
path: '/hermes/skills',
|
||||||
name: 'hermes.skills',
|
name: 'hermes.skills',
|
||||||
|
|||||||
@@ -0,0 +1,673 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { NButton } from 'naive-ui'
|
||||||
|
import {
|
||||||
|
fetchSkillUsageStats,
|
||||||
|
type SkillUsageDailyRow,
|
||||||
|
type SkillUsageRow,
|
||||||
|
type SkillUsageStats,
|
||||||
|
} from '@/api/hermes/skills'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const periodOptions = [7, 30, 90, 365]
|
||||||
|
const maxVisibleChartSkills = 6
|
||||||
|
const skillPalette = [
|
||||||
|
'#2f6eea',
|
||||||
|
'#f26d3d',
|
||||||
|
'#f49a5c',
|
||||||
|
'#9aa7ff',
|
||||||
|
'#8f2c8f',
|
||||||
|
'#00c2d1',
|
||||||
|
]
|
||||||
|
const otherSkillColor = '#5b6b84'
|
||||||
|
|
||||||
|
interface ChartSegment {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedDays = ref(7)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const statsByPeriod = ref<Record<number, SkillUsageStats | undefined>>({})
|
||||||
|
let requestSeq = 0
|
||||||
|
const latestRequestByPeriod: Record<number, number> = {}
|
||||||
|
|
||||||
|
const stats = computed(() => statsByPeriod.value[selectedDays.value] ?? null)
|
||||||
|
const hasData = computed(() => (stats.value?.summary.total_skill_actions ?? 0) > 0)
|
||||||
|
const maxDailyActions = computed(() => Math.max(...(stats.value?.by_day ?? []).map(day => day.total_count), 1))
|
||||||
|
const isRefreshing = computed(() => loading.value && !!stats.value)
|
||||||
|
const hoveredDayKey = ref<string | null>(null)
|
||||||
|
const hoveredDayIndex = ref<number | null>(null)
|
||||||
|
const hoveredDay = computed(() => stats.value?.by_day.find(day => day.date === hoveredDayKey.value) ?? null)
|
||||||
|
const hoveredSegments = computed(() => hoveredDay.value ? chartSegments(hoveredDay.value) : [])
|
||||||
|
const tooltipAlignment = computed(() => {
|
||||||
|
const dayCount = stats.value?.by_day.length ?? 0
|
||||||
|
if (hoveredDayIndex.value === null || dayCount <= 0) return 'align-right'
|
||||||
|
return hoveredDayIndex.value >= dayCount / 2 ? 'align-left' : 'align-right'
|
||||||
|
})
|
||||||
|
const chartSkills = computed(() => (stats.value?.top_skills ?? []).slice(0, maxVisibleChartSkills))
|
||||||
|
const chartSkillSet = computed(() => new Set(chartSkills.value.map(skill => skill.skill)))
|
||||||
|
|
||||||
|
function formatPercent(value: number): string {
|
||||||
|
return `${value.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastUsed(timestamp: number | null): string {
|
||||||
|
if (!timestamp) return '—'
|
||||||
|
return new Date(timestamp * 1000).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function dailyBarHeight(total: number): string {
|
||||||
|
return `${Math.max(2, (total / maxDailyActions.value) * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentFlex(value: number): number {
|
||||||
|
return Math.max(value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalSkillActions(skill: Pick<SkillUsageRow, 'view_count' | 'manage_count' | 'total_count'>): number {
|
||||||
|
return skill.total_count || skill.view_count + skill.manage_count
|
||||||
|
}
|
||||||
|
|
||||||
|
function otherSkillsLabel(): string {
|
||||||
|
const hiddenCount = Math.max((stats.value?.top_skills.length ?? 0) - maxVisibleChartSkills, 0)
|
||||||
|
return hiddenCount > 0 ? `${t('skillsUsage.otherSkills')} (+${hiddenCount})` : t('skillsUsage.otherSkills')
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorForSkill(skillName: string): string {
|
||||||
|
const index = chartSkills.value.findIndex(skill => skill.skill === skillName)
|
||||||
|
return index >= 0 ? skillPalette[index % skillPalette.length] : otherSkillColor
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartSegments(day: SkillUsageDailyRow): ChartSegment[] {
|
||||||
|
const daySkills = day.skills ?? []
|
||||||
|
const bySkill = new Map(daySkills.map(skill => [skill.skill, totalSkillActions(skill)]))
|
||||||
|
const segments: ChartSegment[] = chartSkills.value
|
||||||
|
.map(skill => ({
|
||||||
|
key: skill.skill,
|
||||||
|
label: skill.skill,
|
||||||
|
count: bySkill.get(skill.skill) ?? 0,
|
||||||
|
color: colorForSkill(skill.skill),
|
||||||
|
}))
|
||||||
|
.filter(segment => segment.count > 0)
|
||||||
|
|
||||||
|
const otherTotal = daySkills
|
||||||
|
.filter(skill => !chartSkillSet.value.has(skill.skill))
|
||||||
|
.reduce((sum, skill) => sum + totalSkillActions(skill), 0)
|
||||||
|
|
||||||
|
if (otherTotal > 0) {
|
||||||
|
segments.push({
|
||||||
|
key: 'other-skills',
|
||||||
|
label: otherSkillsLabel(),
|
||||||
|
count: otherTotal,
|
||||||
|
color: otherSkillColor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTooltip(day: SkillUsageDailyRow, index: number) {
|
||||||
|
hoveredDayKey.value = day.date
|
||||||
|
hoveredDayIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTooltip(day: SkillUsageDailyRow) {
|
||||||
|
if (hoveredDayKey.value === day.date) {
|
||||||
|
hoveredDayKey.value = null
|
||||||
|
hoveredDayIndex.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats(days = selectedDays.value, force = false) {
|
||||||
|
selectedDays.value = days
|
||||||
|
const seq = ++requestSeq
|
||||||
|
latestRequestByPeriod[days] = seq
|
||||||
|
loading.value = true
|
||||||
|
if (!statsByPeriod.value[days] || force) error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const next = await fetchSkillUsageStats(days)
|
||||||
|
if (latestRequestByPeriod[days] === seq) {
|
||||||
|
statsByPeriod.value = {
|
||||||
|
...statsByPeriod.value,
|
||||||
|
[days]: next,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seq === requestSeq) error.value = ''
|
||||||
|
} catch (err: any) {
|
||||||
|
if (seq === requestSeq) error.value = err?.message || t('skillsUsage.loadFailed')
|
||||||
|
} finally {
|
||||||
|
if (seq === requestSeq) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadStats(7)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="skills-usage-view">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="header-title">{{ t('skillsUsage.title') }}</h2>
|
||||||
|
<p class="header-subtitle">{{ t('skillsUsage.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="skills-usage-toolbar">
|
||||||
|
<div class="period-selector" role="group" :aria-label="t('skillsUsage.periodSelector')">
|
||||||
|
<NButton
|
||||||
|
v-for="days in periodOptions"
|
||||||
|
:key="days"
|
||||||
|
size="small"
|
||||||
|
:secondary="selectedDays === days"
|
||||||
|
:quaternary="selectedDays !== days"
|
||||||
|
:type="selectedDays === days ? 'primary' : 'default'"
|
||||||
|
:aria-pressed="selectedDays === days ? 'true' : 'false'"
|
||||||
|
@click="loadStats(days)"
|
||||||
|
>
|
||||||
|
{{ t('skillsUsage.periodLabel', { days }) }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
<NButton size="small" quaternary :loading="loading" @click="loadStats(selectedDays, true)">
|
||||||
|
{{ t('skillsUsage.refresh') }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="skills-usage-content">
|
||||||
|
<div v-if="error && !stats" class="skills-usage-state error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="loading && !stats" class="skills-usage-state">
|
||||||
|
{{ t('common.loading') }}
|
||||||
|
</div>
|
||||||
|
<template v-else-if="stats">
|
||||||
|
<div v-if="error" class="inline-error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<section class="overview-grid">
|
||||||
|
<div class="usage-panel chart-panel" :class="{ 'is-refreshing': isRefreshing }" data-testid="skills-usage-chart">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>{{ t('skillsUsage.dailyTrend') }}</h3>
|
||||||
|
<span>{{ t('skillsUsage.periodSummary', { days: stats.period_days }) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hasData" class="skills-usage-state compact">
|
||||||
|
{{ t('skillsUsage.noData') }}
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="skill-bar-chart">
|
||||||
|
<div
|
||||||
|
v-for="(day, index) in stats.by_day"
|
||||||
|
:key="day.date"
|
||||||
|
class="skill-bar-col"
|
||||||
|
tabindex="0"
|
||||||
|
@mouseenter="showTooltip(day, index)"
|
||||||
|
@focusin="showTooltip(day, index)"
|
||||||
|
@mouseleave="hideTooltip(day)"
|
||||||
|
@focusout="hideTooltip(day)"
|
||||||
|
>
|
||||||
|
<div class="skill-bar-track">
|
||||||
|
<div class="skill-bar-fill" :style="{ height: dailyBarHeight(day.total_count) }">
|
||||||
|
<div
|
||||||
|
v-for="segment in chartSegments(day)"
|
||||||
|
:key="segment.key"
|
||||||
|
class="skill-bar-segment"
|
||||||
|
:data-skill="segment.key"
|
||||||
|
:style="{ flex: segmentFlex(segment.count), background: segment.color }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="hoveredDay" class="floating-tooltip" :class="tooltipAlignment">
|
||||||
|
<div class="tooltip-date">{{ hoveredDay.date }}</div>
|
||||||
|
<div v-for="segment in hoveredSegments" :key="segment.key" class="tooltip-row">
|
||||||
|
<i class="tooltip-dot" :style="{ background: segment.color }" />
|
||||||
|
<span>{{ segment.label }}</span>
|
||||||
|
<strong>{{ segment.count }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-row total">
|
||||||
|
<span>{{ t('skillsUsage.totalActions') }}</span>
|
||||||
|
<strong>{{ hoveredDay.total_count }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bar-dates">
|
||||||
|
<span>{{ stats.by_day[0]?.date.slice(5) }}</span>
|
||||||
|
<span>{{ stats.by_day[stats.by_day.length - 1]?.date.slice(5) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-grid" data-testid="skills-usage-stats" :aria-label="t('skillsUsage.summary')">
|
||||||
|
<div class="summary-card primary">
|
||||||
|
<div class="summary-label">{{ t('skillsUsage.totalActions') }}</div>
|
||||||
|
<div class="summary-value">{{ stats.summary.total_skill_actions }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">{{ t('skillsUsage.loads') }}</div>
|
||||||
|
<div class="summary-value">{{ stats.summary.total_skill_loads }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">{{ t('skillsUsage.edits') }}</div>
|
||||||
|
<div class="summary-value">{{ stats.summary.total_skill_edits }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">{{ t('skillsUsage.distinctSkills') }}</div>
|
||||||
|
<div class="summary-value">{{ stats.summary.distinct_skills_used }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="usage-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>{{ t('skillsUsage.topSkills') }}</h3>
|
||||||
|
<span>{{ t('skillsUsage.periodSummary', { days: stats.period_days }) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hasData" class="skills-usage-state compact">
|
||||||
|
{{ t('skillsUsage.noData') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="skills-table" role="table" :aria-label="t('skillsUsage.topSkills')">
|
||||||
|
<div class="skills-row table-head" role="row">
|
||||||
|
<span role="columnheader">{{ t('skillsUsage.skill') }}</span>
|
||||||
|
<span role="columnheader">{{ t('skillsUsage.loads') }}</span>
|
||||||
|
<span role="columnheader">{{ t('skillsUsage.edits') }}</span>
|
||||||
|
<span role="columnheader">{{ t('skillsUsage.share') }}</span>
|
||||||
|
<span role="columnheader">{{ t('skillsUsage.lastUsed') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="skill in stats.top_skills" :key="skill.skill" class="skills-row" role="row">
|
||||||
|
<span class="skill-name" role="cell">
|
||||||
|
<i class="skill-color-dot" :style="{ background: colorForSkill(skill.skill) }" />
|
||||||
|
{{ skill.skill }}
|
||||||
|
</span>
|
||||||
|
<span role="cell">{{ skill.view_count }}</span>
|
||||||
|
<span role="cell">{{ skill.manage_count }}</span>
|
||||||
|
<span class="share-cell" role="cell">
|
||||||
|
<span class="share-bar"><span :style="{ width: formatPercent(skill.percentage), background: colorForSkill(skill.skill) }" /></span>
|
||||||
|
{{ formatPercent(skill.percentage) }}
|
||||||
|
</span>
|
||||||
|
<span class="last-used" role="cell">{{ formatLastUsed(skill.last_used_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use '@/styles/variables' as *;
|
||||||
|
|
||||||
|
.skills-usage-view {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-usage-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-usage-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.7fr) minmax(260px, 0.9fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card,
|
||||||
|
.usage-panel {
|
||||||
|
background: $bg-secondary;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
padding: 14px;
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: $text-primary;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-panel {
|
||||||
|
padding: 16px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.is-refreshing::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 12px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $accent-primary;
|
||||||
|
box-shadow: 0 0 0 4px $accent-muted;
|
||||||
|
animation: refresh-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes refresh-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.45;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-error {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: $error;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-bar-chart {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
min-height: 180px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-bar-col {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 3px;
|
||||||
|
position: relative;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:focus-visible .skill-bar-track {
|
||||||
|
box-shadow: 0 0 0 2px $accent-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-bar-track {
|
||||||
|
height: 180px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
background: $bg-card;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-bar-fill {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-bar-segment {
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
top: 50px;
|
||||||
|
width: min(360px, calc(100% - 32px));
|
||||||
|
max-height: calc(100% - 70px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $bg-secondary;
|
||||||
|
color: $text-primary;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 20;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.align-left {
|
||||||
|
left: 16px;
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.align-right {
|
||||||
|
right: 16px;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-date {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 8px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: $text-secondary;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.6;
|
||||||
|
|
||||||
|
span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: $text-primary;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.total {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-dates {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-color-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-table {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1fr) 80px 80px minmax(120px, 160px) minmax(160px, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: $text-secondary;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:not(.table-head):hover {
|
||||||
|
background: $bg-card-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head {
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: $text-primary;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-bar {
|
||||||
|
width: 54px;
|
||||||
|
height: 6px;
|
||||||
|
background: $bg-card;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-used {
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-usage-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: $error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-usage-toolbar {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-row {
|
||||||
|
grid-template-columns: minmax(140px, 1fr) repeat(3, 64px);
|
||||||
|
|
||||||
|
span:last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
|
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
|
||||||
} from '../../services/config-helpers'
|
} from '../../services/config-helpers'
|
||||||
import { pinSkill } from '../../services/hermes/hermes-cli'
|
import { pinSkill } from '../../services/hermes/hermes-cli'
|
||||||
|
import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db'
|
||||||
|
|
||||||
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
|
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
|
||||||
function readBundledManifest(manifestContent: string | null): Map<string, string> {
|
function readBundledManifest(manifestContent: string | null): Map<string, string> {
|
||||||
@@ -239,6 +240,18 @@ export async function list(ctx: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function usageStats(ctx: any) {
|
||||||
|
const rawDays = parseInt(String(ctx.query?.days ?? '7'), 10)
|
||||||
|
const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 7
|
||||||
|
|
||||||
|
try {
|
||||||
|
ctx.body = await getSkillUsageStatsFromDb(days)
|
||||||
|
} catch (err: any) {
|
||||||
|
ctx.status = 500
|
||||||
|
ctx.body = { error: `Failed to read skill usage stats: ${err.message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggle(ctx: any) {
|
export async function toggle(ctx: any) {
|
||||||
const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean }
|
const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean }
|
||||||
if (!name || typeof enabled !== 'boolean') {
|
if (!name || typeof enabled !== 'boolean') {
|
||||||
|
|||||||
@@ -783,6 +783,42 @@ export interface HermesUsageStats extends LocalUsageStats {
|
|||||||
total_api_calls: number
|
total_api_calls: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HermesSkillUsageRow {
|
||||||
|
skill: string
|
||||||
|
view_count: number
|
||||||
|
manage_count: number
|
||||||
|
total_count: number
|
||||||
|
percentage: number
|
||||||
|
last_used_at: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesSkillUsageDailySkillRow {
|
||||||
|
skill: string
|
||||||
|
view_count: number
|
||||||
|
manage_count: number
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesSkillUsageDailyRow {
|
||||||
|
date: string
|
||||||
|
view_count: number
|
||||||
|
manage_count: number
|
||||||
|
total_count: number
|
||||||
|
skills: HermesSkillUsageDailySkillRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HermesSkillUsageStats {
|
||||||
|
period_days: number
|
||||||
|
summary: {
|
||||||
|
total_skill_loads: number
|
||||||
|
total_skill_edits: number
|
||||||
|
total_skill_actions: number
|
||||||
|
distinct_skills_used: number
|
||||||
|
}
|
||||||
|
by_day: HermesSkillUsageDailyRow[]
|
||||||
|
top_skills: HermesSkillUsageRow[]
|
||||||
|
}
|
||||||
|
|
||||||
function tableHasColumn(
|
function tableHasColumn(
|
||||||
db: { prepare: (sql: string) => { all: (...params: any[]) => Record<string, unknown>[] } },
|
db: { prepare: (sql: string) => { all: (...params: any[]) => Record<string, unknown>[] } },
|
||||||
tableName: string,
|
tableName: string,
|
||||||
@@ -792,6 +828,197 @@ function tableHasColumn(
|
|||||||
return columns.some(column => String(column.name || '') === columnName)
|
return columns.some(column => String(column.name || '') === columnName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillUsageAction = 'view' | 'manage'
|
||||||
|
|
||||||
|
interface RawSkillUsageEvent {
|
||||||
|
skill: string
|
||||||
|
action: SkillUsageAction
|
||||||
|
timestamp: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSkillNameFromViewContent(content: string): string {
|
||||||
|
const match = content.match(/^\[skill_view\]\s+name=(.+?)(?:\s+\(|\s*$)/)
|
||||||
|
return match?.[1]?.trim() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSkillNameFromManageContent(content: string): string {
|
||||||
|
const bracketMatch = content.match(/^\[skill_manage\]\s+name=(.+?)(?:\s+|\(|$)/)
|
||||||
|
if (bracketMatch?.[1]) return bracketMatch[1].trim()
|
||||||
|
|
||||||
|
const parsed = parseJsonObject(content)
|
||||||
|
const message = typeof parsed?.message === 'string' ? parsed.message : content
|
||||||
|
const quotedMatch = message.match(/skill ['"]([^'"]+)['"]/i)
|
||||||
|
if (quotedMatch?.[1]) return quotedMatch[1].trim()
|
||||||
|
|
||||||
|
const namedMatch = message.match(/\bname=([^\s)]+)/i)
|
||||||
|
return namedMatch?.[1]?.trim() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSkillUsageEvent(row: Record<string, unknown>): RawSkillUsageEvent | null {
|
||||||
|
const content = typeof row.content === 'string' ? row.content : ''
|
||||||
|
const toolName = typeof row.tool_name === 'string' ? row.tool_name : ''
|
||||||
|
const action: SkillUsageAction | null = toolName === 'skill_view' || content.startsWith('[skill_view]')
|
||||||
|
? 'view'
|
||||||
|
: toolName === 'skill_manage' || content.startsWith('[skill_manage]')
|
||||||
|
? 'manage'
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!action) return null
|
||||||
|
|
||||||
|
const skill = action === 'view'
|
||||||
|
? extractSkillNameFromViewContent(content)
|
||||||
|
: extractSkillNameFromManageContent(content)
|
||||||
|
|
||||||
|
if (!skill) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
skill,
|
||||||
|
action,
|
||||||
|
timestamp: normalizeNullableNumber(row.timestamp),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUnixDate(timestamp: number | null): string {
|
||||||
|
if (timestamp == null) return ''
|
||||||
|
return new Date(timestamp * 1000).toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSkillUsageStatsFromDb(
|
||||||
|
days = 7,
|
||||||
|
nowSeconds = Math.floor(Date.now() / 1000),
|
||||||
|
): Promise<HermesSkillUsageStats> {
|
||||||
|
const normalizedDays = Number.isFinite(days) ? days : 7
|
||||||
|
const safeDays = Math.max(1, Math.floor(normalizedDays))
|
||||||
|
const since = nowSeconds - safeDays * 24 * 60 * 60
|
||||||
|
const db = await openSessionDb()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceFilter = tableHasColumn(db, 'sessions', 'source')
|
||||||
|
? " AND COALESCE(s.source, '') != 'api_server'"
|
||||||
|
: ''
|
||||||
|
const hasStartedIndex = db.prepare("PRAGMA index_list(sessions)").all()
|
||||||
|
.some(index => String(index.name || '') === 'idx_sessions_started')
|
||||||
|
const hasMessagesIndex = db.prepare("PRAGMA index_list(messages)").all()
|
||||||
|
.some(index => String(index.name || '') === 'idx_messages_session')
|
||||||
|
const sessionsTable = hasStartedIndex ? 'sessions s INDEXED BY idx_sessions_started' : 'sessions s'
|
||||||
|
const messagesTable = hasMessagesIndex ? 'messages m INDEXED BY idx_messages_session' : 'messages m'
|
||||||
|
const toolPredicate = `
|
||||||
|
m.role = 'tool'
|
||||||
|
AND (
|
||||||
|
m.tool_name IN ('skill_view', 'skill_manage')
|
||||||
|
OR m.content LIKE '[skill_view]%'
|
||||||
|
OR m.content LIKE '[skill_manage]%'
|
||||||
|
)
|
||||||
|
`
|
||||||
|
const recentRows = db.prepare(`
|
||||||
|
SELECT m.tool_name, SUBSTR(m.content, 1, 300) AS content, COALESCE(m.timestamp, s.started_at) AS timestamp
|
||||||
|
FROM ${sessionsTable}
|
||||||
|
JOIN ${messagesTable} ON m.session_id = s.id
|
||||||
|
WHERE s.started_at > ?${sourceFilter}
|
||||||
|
AND ${toolPredicate}
|
||||||
|
`).all(since) as Record<string, unknown>[]
|
||||||
|
const lateRows = db.prepare(`
|
||||||
|
SELECT m.tool_name, SUBSTR(m.content, 1, 300) AS content, COALESCE(m.timestamp, s.started_at) AS timestamp
|
||||||
|
FROM ${sessionsTable}
|
||||||
|
JOIN ${messagesTable} ON m.session_id = s.id
|
||||||
|
WHERE s.started_at <= ?
|
||||||
|
AND COALESCE(m.timestamp, s.started_at) > ?${sourceFilter}
|
||||||
|
AND ${toolPredicate}
|
||||||
|
`).all(since, since) as Record<string, unknown>[]
|
||||||
|
|
||||||
|
const skillMap = new Map<string, { skill: string; view_count: number; manage_count: number; last_used_at: number | null }>()
|
||||||
|
const dayMap = new Map<string, { date: string; view_count: number; manage_count: number }>()
|
||||||
|
const daySkillMap = new Map<string, Map<string, { skill: string; view_count: number; manage_count: number }>>()
|
||||||
|
|
||||||
|
for (const row of [...recentRows, ...lateRows]) {
|
||||||
|
const event = mapSkillUsageEvent(row)
|
||||||
|
if (!event) continue
|
||||||
|
|
||||||
|
const entry = skillMap.get(event.skill) || {
|
||||||
|
skill: event.skill,
|
||||||
|
view_count: 0,
|
||||||
|
manage_count: 0,
|
||||||
|
last_used_at: null,
|
||||||
|
}
|
||||||
|
if (event.action === 'view') entry.view_count += 1
|
||||||
|
else entry.manage_count += 1
|
||||||
|
if (event.timestamp != null && (entry.last_used_at == null || event.timestamp > entry.last_used_at)) {
|
||||||
|
entry.last_used_at = event.timestamp
|
||||||
|
}
|
||||||
|
skillMap.set(event.skill, entry)
|
||||||
|
|
||||||
|
const date = formatUnixDate(event.timestamp)
|
||||||
|
if (date) {
|
||||||
|
const day = dayMap.get(date) || { date, view_count: 0, manage_count: 0 }
|
||||||
|
if (event.action === 'view') day.view_count += 1
|
||||||
|
else day.manage_count += 1
|
||||||
|
dayMap.set(date, day)
|
||||||
|
|
||||||
|
const skillsForDay = daySkillMap.get(date) || new Map<string, { skill: string; view_count: number; manage_count: number }>()
|
||||||
|
const skillForDay = skillsForDay.get(event.skill) || { skill: event.skill, view_count: 0, manage_count: 0 }
|
||||||
|
if (event.action === 'view') skillForDay.view_count += 1
|
||||||
|
else skillForDay.manage_count += 1
|
||||||
|
skillsForDay.set(event.skill, skillForDay)
|
||||||
|
daySkillMap.set(date, skillsForDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalLoads = [...skillMap.values()].reduce((sum, skill) => sum + skill.view_count, 0)
|
||||||
|
const totalEdits = [...skillMap.values()].reduce((sum, skill) => sum + skill.manage_count, 0)
|
||||||
|
const totalActions = totalLoads + totalEdits
|
||||||
|
const byDay = [...dayMap.values()]
|
||||||
|
.map(day => ({
|
||||||
|
...day,
|
||||||
|
total_count: day.view_count + day.manage_count,
|
||||||
|
skills: [...(daySkillMap.get(day.date)?.values() || [])]
|
||||||
|
.map(skill => ({
|
||||||
|
...skill,
|
||||||
|
total_count: skill.view_count + skill.manage_count,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.total_count - a.total_count || a.skill.localeCompare(b.skill)),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
const topSkills = [...skillMap.values()]
|
||||||
|
.map(skill => ({
|
||||||
|
...skill,
|
||||||
|
total_count: skill.view_count + skill.manage_count,
|
||||||
|
percentage: totalActions > 0 ? (skill.view_count + skill.manage_count) / totalActions * 100 : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) =>
|
||||||
|
b.total_count - a.total_count ||
|
||||||
|
b.view_count - a.view_count ||
|
||||||
|
b.manage_count - a.manage_count ||
|
||||||
|
(b.last_used_at || 0) - (a.last_used_at || 0) ||
|
||||||
|
a.skill.localeCompare(b.skill),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
period_days: safeDays,
|
||||||
|
summary: {
|
||||||
|
total_skill_loads: totalLoads,
|
||||||
|
total_skill_edits: totalEdits,
|
||||||
|
total_skill_actions: totalActions,
|
||||||
|
distinct_skills_used: skillMap.size,
|
||||||
|
},
|
||||||
|
by_day: byDay,
|
||||||
|
top_skills: topSkills,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUsageStatsFromDb(
|
export async function getUsageStatsFromDb(
|
||||||
days = 30,
|
days = 30,
|
||||||
nowSeconds = Math.floor(Date.now() / 1000),
|
nowSeconds = Math.floor(Date.now() / 1000),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as ctrl from '../../controllers/hermes/skills'
|
|||||||
export const skillRoutes = new Router()
|
export const skillRoutes = new Router()
|
||||||
|
|
||||||
skillRoutes.get('/api/hermes/skills', ctrl.list)
|
skillRoutes.get('/api/hermes/skills', ctrl.list)
|
||||||
|
skillRoutes.get('/api/hermes/skills/usage/stats', ctrl.usageStats)
|
||||||
skillRoutes.put('/api/hermes/skills/toggle', ctrl.toggle)
|
skillRoutes.put('/api/hermes/skills/toggle', ctrl.toggle)
|
||||||
skillRoutes.put('/api/hermes/skills/pin', ctrl.pin_)
|
skillRoutes.put('/api/hermes/skills/pin', ctrl.pin_)
|
||||||
skillRoutes.get('/api/hermes/skills/:category/:skill/files', ctrl.listFiles)
|
skillRoutes.get('/api/hermes/skills/:category/:skill/files', ctrl.listFiles)
|
||||||
|
|||||||
@@ -39,13 +39,57 @@ function collectLiteralTranslationKeys(): string[] {
|
|||||||
return [...keys].sort()
|
return [...keys].sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPath(messages: Record<string, unknown>, key: string): boolean {
|
function getPath(messages: Record<string, unknown>, key: string): unknown {
|
||||||
let current: unknown = messages
|
let current: unknown = messages
|
||||||
for (const part of key.split('.')) {
|
for (const part of key.split('.')) {
|
||||||
if (!current || typeof current !== 'object' || !(part in current)) return false
|
if (!current || typeof current !== 'object' || !(part in current)) return undefined
|
||||||
current = (current as Record<string, unknown>)[part]
|
current = (current as Record<string, unknown>)[part]
|
||||||
}
|
}
|
||||||
return typeof current !== 'undefined'
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPath(messages: Record<string, unknown>, key: string): boolean {
|
||||||
|
return typeof getPath(messages, key) !== 'undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKILLS_USAGE_LOCALIZED_KEYS = [
|
||||||
|
'sidebar.skillsUsage',
|
||||||
|
'skillsUsage.title',
|
||||||
|
'skillsUsage.subtitle',
|
||||||
|
'skillsUsage.refresh',
|
||||||
|
'skillsUsage.periodSelector',
|
||||||
|
'skillsUsage.periodLabel',
|
||||||
|
'skillsUsage.summary',
|
||||||
|
'skillsUsage.totalActions',
|
||||||
|
'skillsUsage.loads',
|
||||||
|
'skillsUsage.edits',
|
||||||
|
'skillsUsage.distinctSkills',
|
||||||
|
'skillsUsage.topSkills',
|
||||||
|
'skillsUsage.dailyTrend',
|
||||||
|
'skillsUsage.periodSummary',
|
||||||
|
'skillsUsage.skill',
|
||||||
|
'skillsUsage.share',
|
||||||
|
'skillsUsage.lastUsed',
|
||||||
|
'skillsUsage.noData',
|
||||||
|
'skillsUsage.loadFailed',
|
||||||
|
'skillsUsage.otherSkills',
|
||||||
|
]
|
||||||
|
|
||||||
|
const SKILLS_USAGE_COMPACT_LABEL_LIMITS: Record<string, number> = {
|
||||||
|
'skillsUsage.totalActions': 12,
|
||||||
|
'skillsUsage.loads': 10,
|
||||||
|
'skillsUsage.edits': 10,
|
||||||
|
'skillsUsage.distinctSkills': 12,
|
||||||
|
'skillsUsage.topSkills': 16,
|
||||||
|
'skillsUsage.dailyTrend': 16,
|
||||||
|
'skillsUsage.skill': 10,
|
||||||
|
'skillsUsage.share': 10,
|
||||||
|
'skillsUsage.lastUsed': 12,
|
||||||
|
'skillsUsage.otherSkills': 16,
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelLength(value: unknown): number {
|
||||||
|
return typeof value === 'string' ? Array.from(value.replace(/\{[^}]+\}/g, '')).length : Infinity
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('i18n locale coverage', () => {
|
describe('i18n locale coverage', () => {
|
||||||
@@ -75,6 +119,35 @@ describe('i18n locale coverage', () => {
|
|||||||
expect(missing).toEqual([])
|
expect(missing).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('localizes Skills Usage page copy in every non-English locale instead of falling back to English', () => {
|
||||||
|
const englishMessages = rawMessages.en
|
||||||
|
const untranslated = Object.entries(rawMessages).flatMap(([locale, localeMessages]) => {
|
||||||
|
if (locale === 'en') return []
|
||||||
|
|
||||||
|
return SKILLS_USAGE_LOCALIZED_KEYS.flatMap((key) => {
|
||||||
|
const localeValue = getPath(localeMessages, key)
|
||||||
|
if (typeof localeValue === 'undefined') return [`${locale}: ${key} missing`]
|
||||||
|
return localeValue === getPath(englishMessages, key) ? [`${locale}: ${key}`] : []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(untranslated).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
it('keeps Skills Usage summary and table labels compact across locales', () => {
|
||||||
|
const oversized = Object.entries(rawMessages).flatMap(([locale, localeMessages]) =>
|
||||||
|
Object.entries(SKILLS_USAGE_COMPACT_LABEL_LIMITS).flatMap(([key, maxLength]) => {
|
||||||
|
const localeValue = getPath(localeMessages, key)
|
||||||
|
return labelLength(localeValue) > maxLength
|
||||||
|
? [`${locale}: ${key} (${labelLength(localeValue)} > ${maxLength})`]
|
||||||
|
: []
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(oversized).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the coverage scanner rooted in client source files', () => {
|
it('keeps the coverage scanner rooted in client source files', () => {
|
||||||
expect(relative(process.cwd(), SOURCE_ROOT)).toBe(join('packages', 'client', 'src'))
|
expect(relative(process.cwd(), SOURCE_ROOT)).toBe(join('packages', 'client', 'src'))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
|
||||||
|
const fetchSkillUsageStatsMock = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('@/api/hermes/skills', () => ({
|
||||||
|
fetchSkillUsageStats: fetchSkillUsageStatsMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string, params?: Record<string, unknown>) => {
|
||||||
|
if (key === 'skillsUsage.periodLabel') return `${params?.days}d`
|
||||||
|
if (key === 'skillsUsage.periodSummary') return `Last ${params?.days} days`
|
||||||
|
return key
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('naive-ui', async () => {
|
||||||
|
const actual = await vi.importActual<any>('naive-ui')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
NButton: {
|
||||||
|
props: ['loading', 'type', 'size', 'quaternary', 'secondary'],
|
||||||
|
inheritAttrs: false,
|
||||||
|
template: '<button :data-type="type" :aria-pressed="$attrs[\'aria-pressed\']" @click="$emit(\'click\')"><slot /></button>',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import SkillsUsageView from '@/views/hermes/SkillsUsageView.vue'
|
||||||
|
|
||||||
|
const sevenDayStats = {
|
||||||
|
period_days: 7,
|
||||||
|
summary: {
|
||||||
|
total_skill_loads: 3,
|
||||||
|
total_skill_edits: 1,
|
||||||
|
total_skill_actions: 4,
|
||||||
|
distinct_skills_used: 2,
|
||||||
|
},
|
||||||
|
by_day: [
|
||||||
|
{
|
||||||
|
date: '2026-05-10',
|
||||||
|
view_count: 1,
|
||||||
|
manage_count: 0,
|
||||||
|
total_count: 1,
|
||||||
|
skills: [
|
||||||
|
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2026-05-11',
|
||||||
|
view_count: 2,
|
||||||
|
manage_count: 1,
|
||||||
|
total_count: 3,
|
||||||
|
skills: [
|
||||||
|
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
top_skills: [
|
||||||
|
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3, percentage: 75, last_used_at: 1_700_000_000 },
|
||||||
|
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1, percentage: 25, last_used_at: null },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SkillsUsageView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchSkillUsageStatsMock.mockReset()
|
||||||
|
fetchSkillUsageStatsMock.mockResolvedValue(sevenDayStats)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads rolling 7 day skill usage and renders statistics beside a skill-colored visual trend', async () => {
|
||||||
|
const wrapper = mount(SkillsUsageView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(fetchSkillUsageStatsMock).toHaveBeenCalledWith(7)
|
||||||
|
expect(wrapper.text()).toContain('skillsUsage.title')
|
||||||
|
expect(wrapper.find('[data-testid="skills-usage-chart"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.findAll('.skill-bar-col')).toHaveLength(2)
|
||||||
|
expect(wrapper.findAll('.skill-bar-segment[data-skill="hermes-agent"]')).toHaveLength(1)
|
||||||
|
expect(wrapper.findAll('.skill-bar-segment[data-skill="github-pr-workflow"]')).toHaveLength(1)
|
||||||
|
expect(wrapper.find('[data-testid="skills-usage-legend"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-testid="skills-usage-stats"]').text()).toContain('4')
|
||||||
|
expect(wrapper.text()).toContain('hermes-agent')
|
||||||
|
expect(wrapper.text()).toContain('github-pr-workflow')
|
||||||
|
expect(wrapper.text()).toContain('75.0%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads the selected period when the period button changes', async () => {
|
||||||
|
const wrapper = mount(SkillsUsageView)
|
||||||
|
await flushPromises()
|
||||||
|
fetchSkillUsageStatsMock.mockClear()
|
||||||
|
|
||||||
|
const thirtyDayButton = wrapper.findAll('button').find(button => button.text() === '30d')
|
||||||
|
expect(thirtyDayButton).toBeTruthy()
|
||||||
|
|
||||||
|
await thirtyDayButton!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(fetchSkillUsageStatsMock).toHaveBeenCalledWith(30)
|
||||||
|
expect(thirtyDayButton!.attributes('aria-pressed')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flips the chart tooltip away from the hovered side of the bars', async () => {
|
||||||
|
const wrapper = mount(SkillsUsageView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const bars = wrapper.findAll('.skill-bar-col')
|
||||||
|
expect(bars).toHaveLength(2)
|
||||||
|
|
||||||
|
await bars[1].trigger('mouseenter')
|
||||||
|
expect(wrapper.find('.floating-tooltip.align-left').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.floating-tooltip').text()).toContain('2026-05-11')
|
||||||
|
|
||||||
|
await bars[0].trigger('mouseenter')
|
||||||
|
expect(wrapper.find('.floating-tooltip.align-right').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.floating-tooltip').text()).toContain('2026-05-10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps stale data visible while refreshing an already loaded period', async () => {
|
||||||
|
const wrapper = mount(SkillsUsageView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
let resolveRefresh!: (value: unknown) => void
|
||||||
|
fetchSkillUsageStatsMock.mockReturnValueOnce(new Promise(resolve => {
|
||||||
|
resolveRefresh = resolve
|
||||||
|
}))
|
||||||
|
|
||||||
|
const refreshButton = wrapper.findAll('button').find(button => button.text() === 'skillsUsage.refresh')
|
||||||
|
expect(refreshButton).toBeTruthy()
|
||||||
|
|
||||||
|
await refreshButton!.trigger('click')
|
||||||
|
|
||||||
|
expect(fetchSkillUsageStatsMock).toHaveBeenCalledTimes(2)
|
||||||
|
expect(wrapper.find('[data-testid="skills-usage-chart"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('.usage-panel.is-refreshing').exists()).toBe(true)
|
||||||
|
|
||||||
|
resolveRefresh({
|
||||||
|
period_days: 7,
|
||||||
|
summary: { total_skill_loads: 1, total_skill_edits: 0, total_skill_actions: 1, distinct_skills_used: 1 },
|
||||||
|
by_day: [
|
||||||
|
{
|
||||||
|
date: '2026-05-12',
|
||||||
|
view_count: 1,
|
||||||
|
manage_count: 0,
|
||||||
|
total_count: 1,
|
||||||
|
skills: [{ skill: 'test-driven-development', view_count: 1, manage_count: 0, total_count: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
top_skills: [
|
||||||
|
{ skill: 'test-driven-development', view_count: 1, manage_count: 0, total_count: 1, percentage: 100, last_used_at: null },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('test-driven-development')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let an older refresh overwrite newer stats for the same period', async () => {
|
||||||
|
const wrapper = mount(SkillsUsageView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
let resolveOlder!: (value: unknown) => void
|
||||||
|
let resolveNewer!: (value: unknown) => void
|
||||||
|
fetchSkillUsageStatsMock
|
||||||
|
.mockReturnValueOnce(new Promise(resolve => { resolveOlder = resolve }))
|
||||||
|
.mockReturnValueOnce(new Promise(resolve => { resolveNewer = resolve }))
|
||||||
|
|
||||||
|
const refreshButton = wrapper.findAll('button').find(button => button.text() === 'skillsUsage.refresh')
|
||||||
|
expect(refreshButton).toBeTruthy()
|
||||||
|
|
||||||
|
await refreshButton!.trigger('click')
|
||||||
|
await refreshButton!.trigger('click')
|
||||||
|
|
||||||
|
resolveNewer({
|
||||||
|
period_days: 7,
|
||||||
|
summary: { total_skill_loads: 2, total_skill_edits: 0, total_skill_actions: 2, distinct_skills_used: 1 },
|
||||||
|
by_day: [
|
||||||
|
{
|
||||||
|
date: '2026-05-13',
|
||||||
|
view_count: 2,
|
||||||
|
manage_count: 0,
|
||||||
|
total_count: 2,
|
||||||
|
skills: [{ skill: 'newer-skill', view_count: 2, manage_count: 0, total_count: 2 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
top_skills: [
|
||||||
|
{ skill: 'newer-skill', view_count: 2, manage_count: 0, total_count: 2, percentage: 100, last_used_at: null },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('newer-skill')
|
||||||
|
|
||||||
|
resolveOlder({
|
||||||
|
period_days: 7,
|
||||||
|
summary: { total_skill_loads: 1, total_skill_edits: 0, total_skill_actions: 1, distinct_skills_used: 1 },
|
||||||
|
by_day: [
|
||||||
|
{
|
||||||
|
date: '2026-05-12',
|
||||||
|
view_count: 1,
|
||||||
|
manage_count: 0,
|
||||||
|
total_count: 1,
|
||||||
|
skills: [{ skill: 'older-skill', view_count: 1, manage_count: 0, total_count: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
top_skills: [
|
||||||
|
{ skill: 'older-skill', view_count: 1, manage_count: 0, total_count: 1, percentage: 100, last_used_at: null },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('newer-skill')
|
||||||
|
expect(wrapper.text()).not.toContain('older-skill')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
|
||||||
|
const profileMock = vi.hoisted(() => ({
|
||||||
|
getActiveProfileDir: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||||
|
getActiveProfileDir: profileMock.getActiveProfileDir,
|
||||||
|
getProfileDir: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createStateDb(): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'hermes-skill-usage-'))
|
||||||
|
const db = new DatabaseSync(join(dir, 'state.db'))
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source TEXT,
|
||||||
|
started_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_sessions_started ON sessions(started_at);
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT,
|
||||||
|
role TEXT,
|
||||||
|
content TEXT,
|
||||||
|
tool_call_id TEXT,
|
||||||
|
tool_calls TEXT,
|
||||||
|
tool_name TEXT,
|
||||||
|
timestamp INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
|
||||||
|
`)
|
||||||
|
db.close()
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertSession(dir: string, row: { id: string; source?: string; started_at: number }) {
|
||||||
|
const db = new DatabaseSync(join(dir, 'state.db'))
|
||||||
|
db.prepare('INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)')
|
||||||
|
.run(row.id, row.source ?? 'cli', row.started_at)
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertToolResult(dir: string, row: {
|
||||||
|
sessionId: string
|
||||||
|
timestamp: number
|
||||||
|
toolName?: string | null
|
||||||
|
content: string
|
||||||
|
}) {
|
||||||
|
const db = new DatabaseSync(join(dir, 'state.db'))
|
||||||
|
db.prepare('INSERT INTO messages (session_id, role, content, tool_name, timestamp) VALUES (?, ?, ?, ?, ?)')
|
||||||
|
.run(row.sessionId, 'tool', row.content, row.toolName ?? null, row.timestamp)
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAssistantToolCalls(dir: string, sessionId: string, timestamp: number, toolCalls: unknown) {
|
||||||
|
const db = new DatabaseSync(join(dir, 'state.db'))
|
||||||
|
db.prepare('INSERT INTO messages (session_id, role, tool_calls, timestamp) VALUES (?, ?, ?, ?)')
|
||||||
|
.run(sessionId, 'assistant', JSON.stringify(toolCalls), timestamp)
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Hermes skill usage analytics DB aggregation', () => {
|
||||||
|
let profileDir: string | null = null
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
profileMock.getActiveProfileDir.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (profileDir) rmSync(profileDir, { recursive: true, force: true })
|
||||||
|
profileDir = null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts completed skill loads and edits from compact tool result rows inside the requested period', async () => {
|
||||||
|
const now = 1_700_000_000
|
||||||
|
profileDir = createStateDb()
|
||||||
|
profileMock.getActiveProfileDir.mockReturnValue(profileDir)
|
||||||
|
|
||||||
|
insertSession(profileDir, { id: 'recent-cli', source: 'cli', started_at: now - 60 })
|
||||||
|
insertToolResult(profileDir, {
|
||||||
|
sessionId: 'recent-cli',
|
||||||
|
timestamp: now - 50,
|
||||||
|
content: '[skill_view] name=hermes-agent (64,764 chars)',
|
||||||
|
})
|
||||||
|
insertToolResult(profileDir, {
|
||||||
|
sessionId: 'recent-cli',
|
||||||
|
timestamp: now - 45,
|
||||||
|
toolName: 'skill_view',
|
||||||
|
content: '[skill_view] name=hermes-agent (64,764 chars)',
|
||||||
|
})
|
||||||
|
insertToolResult(profileDir, {
|
||||||
|
sessionId: 'recent-cli',
|
||||||
|
timestamp: now - 40,
|
||||||
|
toolName: 'skill_manage',
|
||||||
|
content: JSON.stringify({ success: true, message: "Patched SKILL.md in skill 'hermes-agent' (1 replacement)." }),
|
||||||
|
})
|
||||||
|
insertToolResult(profileDir, {
|
||||||
|
sessionId: 'recent-cli',
|
||||||
|
timestamp: now - 35,
|
||||||
|
content: '[skill_view] name=github-pr-workflow (22,106 chars)',
|
||||||
|
})
|
||||||
|
insertAssistantToolCalls(profileDir, 'recent-cli', now - 30, [
|
||||||
|
{ function: { name: 'skill_view', arguments: JSON.stringify({ name: 'planned-but-not-counted' }) } },
|
||||||
|
])
|
||||||
|
insertToolResult(profileDir, {
|
||||||
|
sessionId: 'recent-cli',
|
||||||
|
timestamp: now - 25,
|
||||||
|
toolName: 'terminal',
|
||||||
|
content: 'noop',
|
||||||
|
})
|
||||||
|
|
||||||
|
insertSession(profileDir, { id: 'web-local-copy', source: 'api_server', started_at: now - 30 })
|
||||||
|
insertToolResult(profileDir, {
|
||||||
|
sessionId: 'web-local-copy',
|
||||||
|
timestamp: now - 20,
|
||||||
|
content: '[skill_view] name=ignored-local-copy (1 chars)',
|
||||||
|
})
|
||||||
|
|
||||||
|
insertSession(profileDir, { id: 'old-cli', source: 'cli', started_at: now - 10 * 86400 })
|
||||||
|
insertToolResult(profileDir, {
|
||||||
|
sessionId: 'old-cli',
|
||||||
|
timestamp: now - 10 * 86400,
|
||||||
|
content: '[skill_view] name=old-skill (1 chars)',
|
||||||
|
})
|
||||||
|
|
||||||
|
insertSession(profileDir, { id: 'long-running-cli', source: 'cli', started_at: now - 10 * 86400 })
|
||||||
|
insertToolResult(profileDir, {
|
||||||
|
sessionId: 'long-running-cli',
|
||||||
|
timestamp: now - 40,
|
||||||
|
content: '[skill_view] name=late-session-skill (1 chars)',
|
||||||
|
})
|
||||||
|
|
||||||
|
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||||
|
const result = await mod.getSkillUsageStatsFromDb(7, now)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
period_days: 7,
|
||||||
|
summary: {
|
||||||
|
total_skill_loads: 4,
|
||||||
|
total_skill_edits: 1,
|
||||||
|
total_skill_actions: 5,
|
||||||
|
distinct_skills_used: 3,
|
||||||
|
},
|
||||||
|
by_day: [
|
||||||
|
{
|
||||||
|
date: '2023-11-14',
|
||||||
|
view_count: 4,
|
||||||
|
manage_count: 1,
|
||||||
|
total_count: 5,
|
||||||
|
skills: [
|
||||||
|
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3 },
|
||||||
|
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1 },
|
||||||
|
{ skill: 'late-session-skill', view_count: 1, manage_count: 0, total_count: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
top_skills: [
|
||||||
|
{
|
||||||
|
skill: 'hermes-agent',
|
||||||
|
view_count: 2,
|
||||||
|
manage_count: 1,
|
||||||
|
total_count: 3,
|
||||||
|
percentage: 60,
|
||||||
|
last_used_at: now - 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
skill: 'github-pr-workflow',
|
||||||
|
view_count: 1,
|
||||||
|
manage_count: 0,
|
||||||
|
total_count: 1,
|
||||||
|
percentage: 20,
|
||||||
|
last_used_at: now - 35,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
skill: 'late-session-skill',
|
||||||
|
view_count: 1,
|
||||||
|
manage_count: 0,
|
||||||
|
total_count: 1,
|
||||||
|
percentage: 20,
|
||||||
|
last_used_at: now - 40,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user