mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
feat(skill-market): security scan visibility, rescan action, pagination fix
This commit is contained in:
parent
aa6e2b6afe
commit
af8c2fe6a9
@ -42,8 +42,9 @@ public class SkillController {
|
|||||||
@RequestParam(defaultValue = "20") int size,
|
@RequestParam(defaultValue = "20") int size,
|
||||||
@RequestParam(required = false) String keyword,
|
@RequestParam(required = false) String keyword,
|
||||||
@RequestParam(required = false) String skillType,
|
@RequestParam(required = false) String skillType,
|
||||||
@RequestParam(required = false) Boolean enabled) {
|
@RequestParam(required = false) Boolean enabled,
|
||||||
return R.ok(skillService.pageSkills(page, size, keyword, skillType, enabled));
|
@RequestParam(required = false) String scanStatus) {
|
||||||
|
return R.ok(skillService.pageSkills(page, size, keyword, skillType, enabled, scanStatus));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取各类型技能计数(tab 徽章用)")
|
@Operation(summary = "获取各类型技能计数(tab 徽章用)")
|
||||||
@ -52,6 +53,12 @@ public class SkillController {
|
|||||||
return R.ok(skillService.countByType());
|
return R.ok(skillService.countByType());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "重新扫描单个技能(RFC-042 §2.3.4)")
|
||||||
|
@PostMapping("/{id}/rescan")
|
||||||
|
public R<SkillEntity> rescan(@PathVariable Long id) {
|
||||||
|
return R.ok(skillService.rescanSecurity(id));
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取已启用技能列表")
|
@Operation(summary = "获取已启用技能列表")
|
||||||
@GetMapping("/enabled")
|
@GetMapping("/enabled")
|
||||||
public R<List<SkillEntity>> listEnabled() {
|
public R<List<SkillEntity>> listEnabled() {
|
||||||
|
|||||||
@ -84,6 +84,19 @@ public class SkillEntity {
|
|||||||
*/
|
*/
|
||||||
private String securityScanStatus;
|
private String securityScanStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-042 §2.3 — persisted JSON array of the last scan's findings
|
||||||
|
* ({@code [{ruleId,severity,category,title,description,filePath,
|
||||||
|
* lineNumber,snippet,remediation}]}). Populated by
|
||||||
|
* {@code SkillPackageResolver} after every scan so the admin UI can
|
||||||
|
* render "why blocked" without re-resolving.
|
||||||
|
*/
|
||||||
|
@TableField(value = "security_scan_result", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
|
private String securityScanResult;
|
||||||
|
|
||||||
|
/** RFC-042 §2.3 — wall-clock time of the last scan write-back. */
|
||||||
|
private LocalDateTime securityScanTime;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -5,14 +5,17 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import vip.mate.skill.model.SkillEntity;
|
import vip.mate.skill.model.SkillEntity;
|
||||||
|
import vip.mate.skill.repository.SkillMapper;
|
||||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -38,6 +41,7 @@ public class SkillPackageResolver {
|
|||||||
private final SkillDependencyChecker dependencyChecker;
|
private final SkillDependencyChecker dependencyChecker;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final SkillWorkspaceManager workspaceManager;
|
private final SkillWorkspaceManager workspaceManager;
|
||||||
|
private final SkillMapper skillMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析技能实体为运行时技能包(完整流程)
|
* 解析技能实体为运行时技能包(完整流程)
|
||||||
@ -72,9 +76,75 @@ public class SkillPackageResolver {
|
|||||||
// 4. 综合判定 runtimeAvailable
|
// 4. 综合判定 runtimeAvailable
|
||||||
resolveRuntimeAvailability(resolved);
|
resolveRuntimeAvailability(resolved);
|
||||||
|
|
||||||
|
// 5. RFC-042 §2.3 — persist the scan outcome so the admin UI can show
|
||||||
|
// findings after a restart (previously they lived only in memory).
|
||||||
|
persistScanOutcome(entity, resolved);
|
||||||
|
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write back the latest scan status / findings JSON / timestamp when
|
||||||
|
* they differ from what's already on the row. Keeps the DB in sync
|
||||||
|
* without re-writing on every idempotent refresh.
|
||||||
|
*
|
||||||
|
* <p>Diff-based so a fresh resolve loop across N enabled skills is
|
||||||
|
* effectively free when nothing has changed on disk. Errors here are
|
||||||
|
* non-fatal — the scan result is already attached to {@code resolved},
|
||||||
|
* so the UI will still see it for this request.
|
||||||
|
*/
|
||||||
|
private void persistScanOutcome(SkillEntity entity, ResolvedSkill resolved) {
|
||||||
|
if (entity == null || entity.getId() == null) return;
|
||||||
|
|
||||||
|
String newStatus = deriveScanStatus(resolved);
|
||||||
|
String newJson = serializeFindings(resolved.getSecurityFindings());
|
||||||
|
boolean statusChanged = !Objects.equals(entity.getSecurityScanStatus(), newStatus);
|
||||||
|
boolean findingsChanged = !Objects.equals(entity.getSecurityScanResult(), newJson);
|
||||||
|
|
||||||
|
if (!statusChanged && !findingsChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SkillEntity update = new SkillEntity();
|
||||||
|
update.setId(entity.getId());
|
||||||
|
update.setSecurityScanStatus(newStatus);
|
||||||
|
update.setSecurityScanResult(newJson);
|
||||||
|
update.setSecurityScanTime(LocalDateTime.now());
|
||||||
|
skillMapper.updateById(update);
|
||||||
|
// Keep the in-memory entity coherent with the DB so the next
|
||||||
|
// resolve in the same tick doesn't redundantly write again.
|
||||||
|
entity.setSecurityScanStatus(newStatus);
|
||||||
|
entity.setSecurityScanResult(newJson);
|
||||||
|
entity.setSecurityScanTime(update.getSecurityScanTime());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to persist scan outcome for skill '{}': {}", entity.getName(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse the resolver's rich security state back into the {@code
|
||||||
|
* PASSED / FAILED / null} tri-state used on the row.
|
||||||
|
*/
|
||||||
|
private String deriveScanStatus(ResolvedSkill resolved) {
|
||||||
|
if (resolved.isSecurityBlocked()) return "FAILED";
|
||||||
|
List<ResolvedSkill.SecurityFinding> findings = resolved.getSecurityFindings();
|
||||||
|
if (findings != null && !findings.isEmpty()) return "PASSED"; // scanned and found non-blocking issues
|
||||||
|
// No block, no findings — treat as scanned-clean (still PASSED so
|
||||||
|
// listEnabledSkills() doesn't treat it as never-scanned).
|
||||||
|
return "PASSED";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String serializeFindings(List<ResolvedSkill.SecurityFinding> findings) {
|
||||||
|
if (findings == null || findings.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(findings);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Failed to serialize findings: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 阶段 1:内容解析 ====================
|
// ==================== 阶段 1:内容解析 ====================
|
||||||
|
|
||||||
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {
|
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {
|
||||||
|
|||||||
@ -104,6 +104,21 @@ public class SkillRuntimeService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rescan one skill on demand (RFC-042 §2.3.4) — runs the full resolver
|
||||||
|
* pipeline (content + security + dependency), which writes the updated
|
||||||
|
* scan result to DB as a side-effect, and then invalidates the active
|
||||||
|
* skills cache so subsequent reads reflect the new status.
|
||||||
|
*/
|
||||||
|
public ResolvedSkill rescanSingle(SkillEntity skill) {
|
||||||
|
ResolvedSkill resolved = packageResolver.resolve(skill);
|
||||||
|
activeSkillsCache.invalidateAll();
|
||||||
|
log.info("Rescanned skill '{}' (id={}): status={}, blocked={}",
|
||||||
|
skill.getName(), skill.getId(),
|
||||||
|
skill.getSecurityScanStatus(), resolved.isSecurityBlocked());
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据名称查找 active skill
|
* 根据名称查找 active skill
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -67,9 +67,15 @@ public class SkillService {
|
|||||||
* <p>RFC-042 §2.1 — replaces the unbounded {@code /skills} list. Filters
|
* <p>RFC-042 §2.1 — replaces the unbounded {@code /skills} list. Filters
|
||||||
* are all optional; empty or {@code null} means "no filter". Keyword
|
* are all optional; empty or {@code null} means "no filter". Keyword
|
||||||
* searches name / description / tags with LIKE.
|
* searches name / description / tags with LIKE.
|
||||||
|
*
|
||||||
|
* <p>{@code scanStatus} (RFC-042 §2.3.5) filters on {@code
|
||||||
|
* security_scan_status}: {@code "FAILED"} surfaces blocked skills so the
|
||||||
|
* admin can inspect findings and rescan, {@code "PASSED"} shows scanned
|
||||||
|
* clean rows, {@code null} / empty means no scan filter.
|
||||||
*/
|
*/
|
||||||
public IPage<SkillEntity> pageSkills(int page, int size, String keyword,
|
public IPage<SkillEntity> pageSkills(int page, int size, String keyword,
|
||||||
String skillType, Boolean enabled) {
|
String skillType, Boolean enabled,
|
||||||
|
String scanStatus) {
|
||||||
Page<SkillEntity> pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1));
|
Page<SkillEntity> pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1));
|
||||||
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<>();
|
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
|
||||||
@ -86,6 +92,9 @@ public class SkillService {
|
|||||||
if (enabled != null) {
|
if (enabled != null) {
|
||||||
wrapper.eq(SkillEntity::getEnabled, enabled);
|
wrapper.eq(SkillEntity::getEnabled, enabled);
|
||||||
}
|
}
|
||||||
|
if (scanStatus != null && !scanStatus.isBlank()) {
|
||||||
|
wrapper.eq(SkillEntity::getSecurityScanStatus, scanStatus.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
wrapper.orderByDesc(SkillEntity::getBuiltin)
|
wrapper.orderByDesc(SkillEntity::getBuiltin)
|
||||||
.orderByDesc(SkillEntity::getCreateTime);
|
.orderByDesc(SkillEntity::getCreateTime);
|
||||||
@ -93,6 +102,25 @@ public class SkillService {
|
|||||||
return skillMapper.selectPage(pageParam, wrapper);
|
return skillMapper.selectPage(pageParam, wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually re-run security + dependency resolution for a single skill
|
||||||
|
* (RFC-042 §2.3.4). Triggered from the admin UI after the user fixes
|
||||||
|
* flagged code and wants an immediate verdict instead of waiting for
|
||||||
|
* the next refresh event.
|
||||||
|
*
|
||||||
|
* <p>The resolver itself persists the outcome — this method just kicks
|
||||||
|
* it and returns the reloaded row.
|
||||||
|
*/
|
||||||
|
public SkillEntity rescanSecurity(Long id) {
|
||||||
|
SkillEntity skill = getSkill(id); // throws MateClawException if missing
|
||||||
|
if (runtimeService == null) {
|
||||||
|
throw new MateClawException("err.skill.runtime_unavailable",
|
||||||
|
"Skill runtime not initialized yet; retry in a moment");
|
||||||
|
}
|
||||||
|
runtimeService.rescanSingle(skill);
|
||||||
|
return skillMapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate skill counts per {@code skill_type}, plus an {@code all}
|
* Aggregate skill counts per {@code skill_type}, plus an {@code all}
|
||||||
* rollup. Feeds the SkillMarket tab badges without pulling every row.
|
* rollup. Feeds the SkillMarket tab badges without pulling every row.
|
||||||
|
|||||||
@ -144,16 +144,20 @@ export const conversationApi = {
|
|||||||
|
|
||||||
// ==================== Skill ====================
|
// ==================== Skill ====================
|
||||||
export const skillApi = {
|
export const skillApi = {
|
||||||
/** RFC-042 §2.1 — paginated skill listing with search/type/enabled filters */
|
/** RFC-042 §2.1 — paginated skill listing with search/type/enabled/scanStatus filters */
|
||||||
page: (params: {
|
page: (params: {
|
||||||
page?: number
|
page?: number
|
||||||
size?: number
|
size?: number
|
||||||
keyword?: string
|
keyword?: string
|
||||||
skillType?: string
|
skillType?: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
/** 'PASSED' / 'FAILED' — filters by security_scan_status (RFC-042 §2.3.5) */
|
||||||
|
scanStatus?: string
|
||||||
} = {}) => http.get('/skills', { params }),
|
} = {}) => http.get('/skills', { params }),
|
||||||
/** Tab count aggregate — returns { all, builtin, mcp, dynamic } */
|
/** Tab count aggregate — returns { all, builtin, mcp, dynamic } */
|
||||||
counts: () => http.get('/skills/counts'),
|
counts: () => http.get('/skills/counts'),
|
||||||
|
/** RFC-042 §2.3.4 — manually rescan a single skill's security */
|
||||||
|
rescan: (id: string | number) => http.post(`/skills/${id}/rescan`),
|
||||||
listEnabled: () => http.get('/skills/enabled'),
|
listEnabled: () => http.get('/skills/enabled'),
|
||||||
get: (id: string | number) => http.get(`/skills/${id}`),
|
get: (id: string | number) => http.get(`/skills/${id}`),
|
||||||
create: (data: any) => http.post('/skills', data),
|
create: (data: any) => http.post('/skills', data),
|
||||||
|
|||||||
@ -1613,6 +1613,19 @@ export default {
|
|||||||
all: 'All statuses',
|
all: 'All statuses',
|
||||||
enabled: 'Enabled',
|
enabled: 'Enabled',
|
||||||
disabled: 'Disabled',
|
disabled: 'Disabled',
|
||||||
|
scanFailed: 'Scan failed',
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
scanned: 'Scanned',
|
||||||
|
scanFailed: 'Scan failed',
|
||||||
|
findingsTitle: 'Security scan failed',
|
||||||
|
rescan: 'Rescan',
|
||||||
|
rescanning: 'Scanning...',
|
||||||
|
rescanPassed: 'Rescan passed',
|
||||||
|
rescanStillFailed: 'Rescan still failed — see findings below',
|
||||||
|
rescanFailed: 'Rescan request failed',
|
||||||
|
noPersistedFindings: 'No persisted findings for this scan. Click "Rescan" to regenerate.',
|
||||||
|
fix: 'Suggested fix',
|
||||||
},
|
},
|
||||||
empty: 'No skills found',
|
empty: 'No skills found',
|
||||||
emptyDesc: 'Add skills to enhance your agents\' capabilities',
|
emptyDesc: 'Add skills to enhance your agents\' capabilities',
|
||||||
|
|||||||
@ -1623,6 +1623,19 @@ export default {
|
|||||||
all: '全部状态',
|
all: '全部状态',
|
||||||
enabled: '已启用',
|
enabled: '已启用',
|
||||||
disabled: '已禁用',
|
disabled: '已禁用',
|
||||||
|
scanFailed: '扫描失败',
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
scanned: '已扫描',
|
||||||
|
scanFailed: '扫描失败',
|
||||||
|
findingsTitle: '安全扫描失败',
|
||||||
|
rescan: '重新扫描',
|
||||||
|
rescanning: '扫描中...',
|
||||||
|
rescanPassed: '重新扫描通过',
|
||||||
|
rescanStillFailed: '重新扫描仍未通过,请查看详情',
|
||||||
|
rescanFailed: '重新扫描失败',
|
||||||
|
noPersistedFindings: '本次尚无持久化的扫描详情,点击"重新扫描"以重新生成。',
|
||||||
|
fix: '修复建议',
|
||||||
},
|
},
|
||||||
empty: '暂无技能',
|
empty: '暂无技能',
|
||||||
emptyDesc: '添加技能以增强 Agent 的能力',
|
emptyDesc: '添加技能以增强 Agent 的能力',
|
||||||
|
|||||||
@ -212,6 +212,10 @@ export interface Skill {
|
|||||||
sourceConversationId?: string
|
sourceConversationId?: string
|
||||||
/** RFC-023: 安全扫描状态 (PASSED / FAILED / null) */
|
/** RFC-023: 安全扫描状态 (PASSED / FAILED / null) */
|
||||||
securityScanStatus?: string
|
securityScanStatus?: string
|
||||||
|
/** RFC-042 §2.3 — JSON-serialised SkillSecurityFinding[] from last scan */
|
||||||
|
securityScanResult?: string
|
||||||
|
/** RFC-042 §2.3 — wall-clock time of the last scan */
|
||||||
|
securityScanTime?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 运行时解析状态(来自 /runtime/status) */
|
/** 运行时解析状态(来自 /runtime/status) */
|
||||||
|
|||||||
@ -49,10 +49,11 @@
|
|||||||
type="search"
|
type="search"
|
||||||
:placeholder="t('skills.search.placeholder')"
|
:placeholder="t('skills.search.placeholder')"
|
||||||
/>
|
/>
|
||||||
<select v-model="query.enabledFilter" class="skill-status-filter" @change="onFilterChange">
|
<select v-model="query.statusFilter" class="skill-status-filter" @change="onFilterChange">
|
||||||
<option value="">{{ t('skills.filter.all') }}</option>
|
<option value="">{{ t('skills.filter.all') }}</option>
|
||||||
<option value="true">{{ t('skills.filter.enabled') }}</option>
|
<option value="enabled">{{ t('skills.filter.enabled') }}</option>
|
||||||
<option value="false">{{ t('skills.filter.disabled') }}</option>
|
<option value="disabled">{{ t('skills.filter.disabled') }}</option>
|
||||||
|
<option value="scan_failed">{{ t('skills.filter.scanFailed') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -89,12 +90,19 @@
|
|||||||
<span v-if="skill.sourceConversationId" class="runtime-badge rt-synthesized" title="Auto-synthesized from conversation">
|
<span v-if="skill.sourceConversationId" class="runtime-badge rt-synthesized" title="Auto-synthesized from conversation">
|
||||||
🤖 AI
|
🤖 AI
|
||||||
</span>
|
</span>
|
||||||
<!-- Security Scan Status (RFC-023) -->
|
<!-- Security Scan Status (RFC-023, expandable per RFC-042 §2.3) -->
|
||||||
<span v-if="skill.securityScanStatus === 'FAILED'" class="runtime-badge rt-blocked">
|
<button
|
||||||
🛡️ Scan Failed
|
v-if="skill.securityScanStatus === 'FAILED'"
|
||||||
</span>
|
type="button"
|
||||||
|
class="runtime-badge rt-blocked scan-badge-button"
|
||||||
|
:aria-expanded="expandedFindings[String(skill.id)] ? 'true' : 'false'"
|
||||||
|
@click="toggleFindings(skill)"
|
||||||
|
>
|
||||||
|
🛡️ {{ t('skills.security.scanFailed') }}
|
||||||
|
<span class="scan-badge-chevron">{{ expandedFindings[String(skill.id)] ? '▾' : '▸' }}</span>
|
||||||
|
</button>
|
||||||
<span v-else-if="skill.securityScanStatus === 'PASSED'" class="runtime-badge rt-ready">
|
<span v-else-if="skill.securityScanStatus === 'PASSED'" class="runtime-badge rt-ready">
|
||||||
✓ Scanned
|
✓ {{ t('skills.security.scanned') }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Security Badge (runtime) -->
|
<!-- Security Badge (runtime) -->
|
||||||
<span v-if="getSecurityBadge(skill)" class="runtime-badge" :class="getSecurityBadge(skill)?.cls">
|
<span v-if="getSecurityBadge(skill)" class="runtime-badge" :class="getSecurityBadge(skill)?.cls">
|
||||||
@ -117,6 +125,52 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="getRuntimeError(skill)" class="runtime-error">{{ getRuntimeError(skill) }}</div>
|
<div v-if="getRuntimeError(skill)" class="runtime-error">{{ getRuntimeError(skill) }}</div>
|
||||||
|
|
||||||
|
<!-- RFC-042 §2.3 — persisted findings panel + rescan control -->
|
||||||
|
<div
|
||||||
|
v-if="skill.securityScanStatus === 'FAILED' && expandedFindings[String(skill.id)]"
|
||||||
|
class="scan-findings-panel"
|
||||||
|
>
|
||||||
|
<div class="scan-findings-header">
|
||||||
|
<span class="scan-findings-title">
|
||||||
|
{{ t('skills.security.findingsTitle') }}
|
||||||
|
<span v-if="skill.securityScanTime" class="scan-findings-time">
|
||||||
|
· {{ formatScanTime(skill.securityScanTime) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="scan-rescan-btn"
|
||||||
|
:disabled="rescanning[String(skill.id)]"
|
||||||
|
@click="rescanSkill(skill)"
|
||||||
|
>
|
||||||
|
{{ rescanning[String(skill.id)] ? t('skills.security.rescanning') : t('skills.security.rescan') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul v-if="parsedFindings(skill).length > 0" class="scan-findings-list">
|
||||||
|
<li
|
||||||
|
v-for="(f, idx) in parsedFindings(skill)"
|
||||||
|
:key="`${skill.id}-f-${idx}`"
|
||||||
|
class="scan-finding-item"
|
||||||
|
:class="`sev-${(f.severity || 'info').toLowerCase()}`"
|
||||||
|
>
|
||||||
|
<div class="scan-finding-head">
|
||||||
|
<span class="scan-finding-sev">[{{ f.severity || 'INFO' }}]</span>
|
||||||
|
<span class="scan-finding-id">{{ f.ruleId || f.category || '—' }}</span>
|
||||||
|
<span v-if="f.filePath" class="scan-finding-loc">
|
||||||
|
{{ f.filePath }}<span v-if="f.lineNumber">:{{ f.lineNumber }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="f.title" class="scan-finding-title">{{ f.title }}</div>
|
||||||
|
<div v-if="f.description" class="scan-finding-desc">{{ f.description }}</div>
|
||||||
|
<div v-if="f.remediation" class="scan-finding-fix">
|
||||||
|
{{ t('skills.security.fix') }}: {{ f.remediation }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="scan-findings-empty">
|
||||||
|
{{ t('skills.security.noPersistedFindings') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="skill-tags" v-if="skill.tags">
|
<div class="skill-tags" v-if="skill.tags">
|
||||||
<span v-for="tag in parseTags(skill.tags)" :key="tag" class="skill-tag">{{ tag }}</span>
|
<span v-for="tag in parseTags(skill.tags)" :key="tag" class="skill-tag">{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -154,9 +208,10 @@
|
|||||||
class="skill-pagination"
|
class="skill-pagination"
|
||||||
v-model:current-page="query.page"
|
v-model:current-page="query.page"
|
||||||
v-model:page-size="query.size"
|
v-model:page-size="query.size"
|
||||||
:page-sizes="[12, 24, 48]"
|
:page-sizes="[10, 20, 50]"
|
||||||
:total="total"
|
:total="total"
|
||||||
layout="total, sizes, prev, pager, next"
|
:hide-on-single-page="false"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
background
|
background
|
||||||
@size-change="onPageSizeChange"
|
@size-change="onPageSizeChange"
|
||||||
@current-change="loadSkills"
|
@current-change="loadSkills"
|
||||||
@ -268,16 +323,20 @@ const editingSkill = ref<Skill | null>(null)
|
|||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const showImportDialog = ref(false)
|
const showImportDialog = ref(false)
|
||||||
|
|
||||||
/** Paginated query state — RFC-042 §2.1 */
|
/** Paginated query state — RFC-042 §2.1 + §2.3.5 */
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 24,
|
size: 10,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
skillType: 'all' as string,
|
skillType: 'all' as string,
|
||||||
/** '' = all, 'true' = enabled only, 'false' = disabled only (string to avoid tri-state checkbox quirks) */
|
/** '' = all | 'enabled' | 'disabled' | 'scan_failed' (RFC-042 §2.3.5 unified status filter) */
|
||||||
enabledFilter: '' as string,
|
statusFilter: '' as string,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Per-skill UI state for the RFC-042 §2.3 findings panel. */
|
||||||
|
const expandedFindings = ref<Record<string, boolean>>({})
|
||||||
|
const rescanning = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
const categoryTabs = computed(() => [
|
const categoryTabs = computed(() => [
|
||||||
{ label: t('skills.tabs.all'), value: 'all', icon: '🗂️' },
|
{ label: t('skills.tabs.all'), value: 'all', icon: '🗂️' },
|
||||||
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' },
|
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' },
|
||||||
@ -349,7 +408,11 @@ async function loadSkills() {
|
|||||||
const params: Record<string, unknown> = { page: query.page, size: query.size }
|
const params: Record<string, unknown> = { page: query.page, size: query.size }
|
||||||
if (query.keyword) params.keyword = query.keyword.trim()
|
if (query.keyword) params.keyword = query.keyword.trim()
|
||||||
if (query.skillType && query.skillType !== 'all') params.skillType = query.skillType
|
if (query.skillType && query.skillType !== 'all') params.skillType = query.skillType
|
||||||
if (query.enabledFilter !== '') params.enabled = query.enabledFilter === 'true'
|
// Map the single status filter onto the backend's two independent params:
|
||||||
|
// enabled (bool) and scanStatus (PASSED/FAILED). scan_failed implies any enabled state.
|
||||||
|
if (query.statusFilter === 'enabled') params.enabled = true
|
||||||
|
else if (query.statusFilter === 'disabled') params.enabled = false
|
||||||
|
else if (query.statusFilter === 'scan_failed') params.scanStatus = 'FAILED'
|
||||||
|
|
||||||
const res: any = await skillApi.page(params)
|
const res: any = await skillApi.page(params)
|
||||||
const data = res.data || {}
|
const data = res.data || {}
|
||||||
@ -467,6 +530,60 @@ async function handleRefreshRuntime() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== RFC-042 §2.3 — persisted scan findings ====================
|
||||||
|
|
||||||
|
/** Parse the DB-persisted JSON findings array. Returns [] on any parse error. */
|
||||||
|
function parsedFindings(skill: Skill): SkillSecurityFinding[] {
|
||||||
|
const raw = skill.securityScanResult
|
||||||
|
if (!raw) return []
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(raw)
|
||||||
|
return Array.isArray(arr) ? (arr as SkillSecurityFinding[]) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFindings(skill: Skill) {
|
||||||
|
const key = String(skill.id)
|
||||||
|
expandedFindings.value = { ...expandedFindings.value, [key]: !expandedFindings.value[key] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rescanSkill(skill: Skill) {
|
||||||
|
const key = String(skill.id)
|
||||||
|
rescanning.value = { ...rescanning.value, [key]: true }
|
||||||
|
try {
|
||||||
|
const res: any = await skillApi.rescan(skill.id)
|
||||||
|
const updated: Skill | undefined = res?.data
|
||||||
|
if (updated) {
|
||||||
|
// Patch the row in-place so the panel updates without a full page reload.
|
||||||
|
const idx = skills.value.findIndex(s => s.id === skill.id)
|
||||||
|
if (idx >= 0) skills.value.splice(idx, 1, { ...skills.value[idx], ...updated })
|
||||||
|
ElMessage.success(
|
||||||
|
updated.securityScanStatus === 'FAILED'
|
||||||
|
? t('skills.security.rescanStillFailed')
|
||||||
|
: t('skills.security.rescanPassed')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Refresh runtime status too so the in-memory badges stay in sync.
|
||||||
|
await loadRuntimeStatus()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.security.rescanFailed'))
|
||||||
|
} finally {
|
||||||
|
rescanning.value = { ...rescanning.value, [key]: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScanTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (isNaN(d.getTime())) return iso
|
||||||
|
return d.toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Runtime Display Helpers ====================
|
// ==================== Runtime Display Helpers ====================
|
||||||
|
|
||||||
function getRuntimeStatus(skill: Skill): SkillRuntimeStatus | null {
|
function getRuntimeStatus(skill: Skill): SkillRuntimeStatus | null {
|
||||||
@ -699,6 +816,107 @@ html.dark .skill-pagination :deep(.el-pagination .el-select .el-input__wrapper)
|
|||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* RFC-042 §2.3 — security scan findings panel (frosted, non-EP) */
|
||||||
|
.scan-badge-button {
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px 2px 8px;
|
||||||
|
}
|
||||||
|
.scan-badge-button:hover { filter: brightness(0.97); }
|
||||||
|
.scan-badge-chevron { font-size: 10px; opacity: 0.75; }
|
||||||
|
|
||||||
|
.scan-findings-panel {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 95, 86, 0.08);
|
||||||
|
border: 1px solid rgba(255, 95, 86, 0.22);
|
||||||
|
backdrop-filter: blur(8px) saturate(1.05);
|
||||||
|
-webkit-backdrop-filter: blur(8px) saturate(1.05);
|
||||||
|
}
|
||||||
|
html.dark .scan-findings-panel {
|
||||||
|
background: rgba(255, 95, 86, 0.12);
|
||||||
|
border-color: rgba(255, 95, 86, 0.28);
|
||||||
|
}
|
||||||
|
.scan-findings-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.scan-findings-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
}
|
||||||
|
.scan-findings-time {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
}
|
||||||
|
.scan-rescan-btn {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
html.dark .scan-rescan-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.scan-rescan-btn:hover:not(:disabled) {
|
||||||
|
background: var(--mc-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.scan-rescan-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-findings-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.scan-finding-item {
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.45);
|
||||||
|
border-left: 3px solid var(--mc-border);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
|
||||||
|
.scan-finding-item.sev-critical { border-left-color: #d32f2f; }
|
||||||
|
.scan-finding-item.sev-high { border-left-color: #f57c00; }
|
||||||
|
.scan-finding-item.sev-medium { border-left-color: #fbc02d; }
|
||||||
|
.scan-finding-item.sev-low { border-left-color: #689f38; }
|
||||||
|
.scan-finding-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.scan-finding-sev { font-weight: 700; color: var(--mc-text-primary); }
|
||||||
|
.scan-finding-id { color: var(--mc-text-secondary); }
|
||||||
|
.scan-finding-loc { color: var(--mc-text-tertiary); }
|
||||||
|
.scan-finding-title { font-weight: 600; margin-top: 3px; color: var(--mc-text-primary); }
|
||||||
|
.scan-finding-desc { color: var(--mc-text-secondary); margin-top: 2px; }
|
||||||
|
.scan-finding-fix { color: var(--mc-text-secondary); margin-top: 4px; font-style: italic; }
|
||||||
|
.scan-findings-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* 技能网格 */
|
/* 技能网格 */
|
||||||
.skill-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
|
.skill-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
|
||||||
.skill-card { padding: 18px; transition: all 0.15s; display: flex; flex-direction: column; min-height: 280px; }
|
.skill-card { padding: 18px; transition: all 0.15s; display: flex; flex-direction: column; min-height: 280px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user