mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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(required = false) String keyword,
|
||||
@RequestParam(required = false) String skillType,
|
||||
@RequestParam(required = false) Boolean enabled) {
|
||||
return R.ok(skillService.pageSkills(page, size, keyword, skillType, enabled));
|
||||
@RequestParam(required = false) Boolean enabled,
|
||||
@RequestParam(required = false) String scanStatus) {
|
||||
return R.ok(skillService.pageSkills(page, size, keyword, skillType, enabled, scanStatus));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取各类型技能计数(tab 徽章用)")
|
||||
@ -52,6 +53,12 @@ public class SkillController {
|
||||
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 = "获取已启用技能列表")
|
||||
@GetMapping("/enabled")
|
||||
public R<List<SkillEntity>> listEnabled() {
|
||||
|
||||
@ -84,6 +84,19 @@ public class SkillEntity {
|
||||
*/
|
||||
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)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -5,14 +5,17 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.repository.SkillMapper;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@ -38,6 +41,7 @@ public class SkillPackageResolver {
|
||||
private final SkillDependencyChecker dependencyChecker;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
private final SkillMapper skillMapper;
|
||||
|
||||
/**
|
||||
* 解析技能实体为运行时技能包(完整流程)
|
||||
@ -72,9 +76,75 @@ public class SkillPackageResolver {
|
||||
// 4. 综合判定 runtimeAvailable
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:内容解析 ====================
|
||||
|
||||
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {
|
||||
|
||||
@ -104,6 +104,21 @@ public class SkillRuntimeService {
|
||||
.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
|
||||
*/
|
||||
|
||||
@ -67,9 +67,15 @@ public class SkillService {
|
||||
* <p>RFC-042 §2.1 — replaces the unbounded {@code /skills} list. Filters
|
||||
* are all optional; empty or {@code null} means "no filter". Keyword
|
||||
* 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,
|
||||
String skillType, Boolean enabled) {
|
||||
String skillType, Boolean enabled,
|
||||
String scanStatus) {
|
||||
Page<SkillEntity> pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1));
|
||||
LambdaQueryWrapper<SkillEntity> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
@ -86,6 +92,9 @@ public class SkillService {
|
||||
if (enabled != null) {
|
||||
wrapper.eq(SkillEntity::getEnabled, enabled);
|
||||
}
|
||||
if (scanStatus != null && !scanStatus.isBlank()) {
|
||||
wrapper.eq(SkillEntity::getSecurityScanStatus, scanStatus.trim().toUpperCase());
|
||||
}
|
||||
|
||||
wrapper.orderByDesc(SkillEntity::getBuiltin)
|
||||
.orderByDesc(SkillEntity::getCreateTime);
|
||||
@ -93,6 +102,25 @@ public class SkillService {
|
||||
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}
|
||||
* rollup. Feeds the SkillMarket tab badges without pulling every row.
|
||||
|
||||
@ -144,16 +144,20 @@ export const conversationApi = {
|
||||
|
||||
// ==================== Skill ====================
|
||||
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?: number
|
||||
size?: number
|
||||
keyword?: string
|
||||
skillType?: string
|
||||
enabled?: boolean
|
||||
/** 'PASSED' / 'FAILED' — filters by security_scan_status (RFC-042 §2.3.5) */
|
||||
scanStatus?: string
|
||||
} = {}) => http.get('/skills', { params }),
|
||||
/** Tab count aggregate — returns { all, builtin, mcp, dynamic } */
|
||||
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'),
|
||||
get: (id: string | number) => http.get(`/skills/${id}`),
|
||||
create: (data: any) => http.post('/skills', data),
|
||||
|
||||
@ -1613,6 +1613,19 @@ export default {
|
||||
all: 'All statuses',
|
||||
enabled: 'Enabled',
|
||||
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',
|
||||
emptyDesc: 'Add skills to enhance your agents\' capabilities',
|
||||
|
||||
@ -1623,6 +1623,19 @@ export default {
|
||||
all: '全部状态',
|
||||
enabled: '已启用',
|
||||
disabled: '已禁用',
|
||||
scanFailed: '扫描失败',
|
||||
},
|
||||
security: {
|
||||
scanned: '已扫描',
|
||||
scanFailed: '扫描失败',
|
||||
findingsTitle: '安全扫描失败',
|
||||
rescan: '重新扫描',
|
||||
rescanning: '扫描中...',
|
||||
rescanPassed: '重新扫描通过',
|
||||
rescanStillFailed: '重新扫描仍未通过,请查看详情',
|
||||
rescanFailed: '重新扫描失败',
|
||||
noPersistedFindings: '本次尚无持久化的扫描详情,点击"重新扫描"以重新生成。',
|
||||
fix: '修复建议',
|
||||
},
|
||||
empty: '暂无技能',
|
||||
emptyDesc: '添加技能以增强 Agent 的能力',
|
||||
|
||||
@ -212,6 +212,10 @@ export interface Skill {
|
||||
sourceConversationId?: string
|
||||
/** RFC-023: 安全扫描状态 (PASSED / FAILED / null) */
|
||||
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) */
|
||||
|
||||
@ -49,10 +49,11 @@
|
||||
type="search"
|
||||
: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="true">{{ t('skills.filter.enabled') }}</option>
|
||||
<option value="false">{{ t('skills.filter.disabled') }}</option>
|
||||
<option value="enabled">{{ t('skills.filter.enabled') }}</option>
|
||||
<option value="disabled">{{ t('skills.filter.disabled') }}</option>
|
||||
<option value="scan_failed">{{ t('skills.filter.scanFailed') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -89,12 +90,19 @@
|
||||
<span v-if="skill.sourceConversationId" class="runtime-badge rt-synthesized" title="Auto-synthesized from conversation">
|
||||
🤖 AI
|
||||
</span>
|
||||
<!-- Security Scan Status (RFC-023) -->
|
||||
<span v-if="skill.securityScanStatus === 'FAILED'" class="runtime-badge rt-blocked">
|
||||
🛡️ Scan Failed
|
||||
</span>
|
||||
<!-- Security Scan Status (RFC-023, expandable per RFC-042 §2.3) -->
|
||||
<button
|
||||
v-if="skill.securityScanStatus === 'FAILED'"
|
||||
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">
|
||||
✓ Scanned
|
||||
✓ {{ t('skills.security.scanned') }}
|
||||
</span>
|
||||
<!-- Security Badge (runtime) -->
|
||||
<span v-if="getSecurityBadge(skill)" class="runtime-badge" :class="getSecurityBadge(skill)?.cls">
|
||||
@ -117,6 +125,52 @@
|
||||
</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">
|
||||
<span v-for="tag in parseTags(skill.tags)" :key="tag" class="skill-tag">{{ tag }}</span>
|
||||
</div>
|
||||
@ -154,9 +208,10 @@
|
||||
class="skill-pagination"
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.size"
|
||||
:page-sizes="[12, 24, 48]"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:hide-on-single-page="false"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@size-change="onPageSizeChange"
|
||||
@current-change="loadSkills"
|
||||
@ -268,16 +323,20 @@ const editingSkill = ref<Skill | null>(null)
|
||||
const refreshing = 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({
|
||||
page: 1,
|
||||
size: 24,
|
||||
size: 10,
|
||||
keyword: '',
|
||||
skillType: 'all' as string,
|
||||
/** '' = all, 'true' = enabled only, 'false' = disabled only (string to avoid tri-state checkbox quirks) */
|
||||
enabledFilter: '' as string,
|
||||
/** '' = all | 'enabled' | 'disabled' | 'scan_failed' (RFC-042 §2.3.5 unified status filter) */
|
||||
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(() => [
|
||||
{ label: t('skills.tabs.all'), value: 'all', 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 }
|
||||
if (query.keyword) params.keyword = query.keyword.trim()
|
||||
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 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 ====================
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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-card { padding: 18px; transition: all 0.15s; display: flex; flex-direction: column; min-height: 280px; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user