feat(portal): audit incident review changes
This commit is contained in:
@@ -351,6 +351,8 @@ struct RiskIncidentCandidate {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
recommendation: Option<String>,
|
||||
incident_review: IncidentReviewState,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
incident_review_audit: Vec<IncidentReviewAuditEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -477,6 +479,28 @@ struct IncidentReviewResponse {
|
||||
review: IncidentReviewState,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
struct IncidentReviewAuditEntry {
|
||||
candidate_id: String,
|
||||
old_status: String,
|
||||
new_status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reviewer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
comment: Option<String>,
|
||||
changed_at_utc: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
struct IncidentReviewAuditSummary {
|
||||
total_changes: usize,
|
||||
confirmed_count: usize,
|
||||
false_positive_count: usize,
|
||||
postponed_count: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
last_change_utc: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IncidentAuditEntry {
|
||||
generated_at_utc: String,
|
||||
@@ -811,11 +835,13 @@ struct ReportMarkdownContext<'a> {
|
||||
business_risk_history: &'a [BusinessRiskHistoryItem],
|
||||
business_risk_history_summary: &'a BusinessRiskHistorySummary,
|
||||
risk_incident_candidates: &'a [RiskIncidentCandidate],
|
||||
incident_review_audit_summary: &'a IncidentReviewAuditSummary,
|
||||
}
|
||||
|
||||
struct ReportRuntimeInputs<'a> {
|
||||
incident_state: &'a IncidentStateFile,
|
||||
incident_reviews: &'a IncidentReviewFile,
|
||||
incident_review_audit: &'a [IncidentReviewAuditEntry],
|
||||
evidence: &'a DlpEvidenceResponse,
|
||||
}
|
||||
|
||||
@@ -835,17 +861,21 @@ fn run() -> Result<i32> {
|
||||
if args.json_smoke {
|
||||
let snapshot = build_snapshot(&args);
|
||||
let incident_state = load_incident_state_best_effort(&args);
|
||||
let incident_reviews = load_incident_review_best_effort(&args);
|
||||
let incident_review_audit = load_incident_review_audit_best_effort(&args);
|
||||
let evidence = build_dlp_evidence_response(&args);
|
||||
let ueba_baseline_path = ueba_baseline_state_path(&args);
|
||||
let smoke = json!({
|
||||
"health": build_health(&snapshot),
|
||||
"summary": build_summary(&snapshot),
|
||||
"reports": build_reports(&snapshot, ReportRuntimeInputs {
|
||||
incident_state: &incident_state,
|
||||
incident_reviews: &load_incident_review_best_effort(&args),
|
||||
evidence: &build_dlp_evidence_response(&args),
|
||||
incident_reviews: &incident_reviews,
|
||||
incident_review_audit: &incident_review_audit,
|
||||
evidence: &evidence,
|
||||
}, &args.workforce_policy_path, &args.ueba_policy_path, &ueba_baseline_path, false),
|
||||
"incidents": build_incidents(&snapshot, &incident_state),
|
||||
"dlp_evidence": build_dlp_evidence_response(&args),
|
||||
"dlp_evidence": evidence,
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&smoke)?);
|
||||
return Ok(if build_health(&snapshot).ok { 0 } else { 2 });
|
||||
@@ -944,6 +974,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
||||
let snapshot = cached_snapshot(args, snapshot_cache);
|
||||
let incident_state = load_incident_state_best_effort(args);
|
||||
let incident_reviews = load_incident_review_best_effort(args);
|
||||
let incident_review_audit = load_incident_review_audit_best_effort(args);
|
||||
let evidence = build_dlp_evidence_response(args);
|
||||
let ueba_baseline_path = ueba_baseline_state_path(args);
|
||||
respond_json(
|
||||
@@ -953,6 +984,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
||||
ReportRuntimeInputs {
|
||||
incident_state: &incident_state,
|
||||
incident_reviews: &incident_reviews,
|
||||
incident_review_audit: &incident_review_audit,
|
||||
evidence: &evidence,
|
||||
},
|
||||
&args.workforce_policy_path,
|
||||
@@ -2046,7 +2078,13 @@ fn build_reports(
|
||||
let business_risk_history_summary = summarize_business_risk_history(&business_risk_history);
|
||||
let mut risk_incident_candidates =
|
||||
build_risk_incident_candidates(snapshot, &business_risk, &business_risk_history);
|
||||
apply_incident_reviews_to_candidates(&mut risk_incident_candidates, inputs.incident_reviews);
|
||||
apply_incident_reviews_to_candidates(
|
||||
&mut risk_incident_candidates,
|
||||
inputs.incident_reviews,
|
||||
inputs.incident_review_audit,
|
||||
);
|
||||
let incident_review_audit_summary =
|
||||
summarize_incident_review_audit(inputs.incident_review_audit);
|
||||
let trend = workforce_trend_json(snapshot);
|
||||
let insight_items = workforce_insight_items(snapshot);
|
||||
let workforce_policy_explain =
|
||||
@@ -2158,6 +2196,7 @@ fn build_reports(
|
||||
business_risk_history: &business_risk_history,
|
||||
business_risk_history_summary: &business_risk_history_summary,
|
||||
risk_incident_candidates: &risk_incident_candidates,
|
||||
incident_review_audit_summary: &incident_review_audit_summary,
|
||||
},
|
||||
);
|
||||
json!({
|
||||
@@ -2249,6 +2288,7 @@ fn build_reports(
|
||||
"business_risk_history": business_risk_history,
|
||||
"business_risk_history_summary": business_risk_history_summary,
|
||||
"risk_incident_candidates": risk_incident_candidates,
|
||||
"incident_review_audit_summary": incident_review_audit_summary,
|
||||
"workforce_policy": workforce_policy_explain,
|
||||
"workforce": {
|
||||
"department_comparison": department_items,
|
||||
@@ -2664,6 +2704,7 @@ fn build_risk_incident_candidates(
|
||||
fn apply_incident_reviews_to_candidates(
|
||||
candidates: &mut [RiskIncidentCandidate],
|
||||
reviews: &IncidentReviewFile,
|
||||
audit_entries: &[IncidentReviewAuditEntry],
|
||||
) {
|
||||
for candidate in candidates {
|
||||
candidate.incident_review =
|
||||
@@ -2678,9 +2719,39 @@ fn apply_incident_reviews_to_candidates(
|
||||
comment: None,
|
||||
updated_at: String::new(),
|
||||
});
|
||||
candidate.incident_review_audit = audit_entries
|
||||
.iter()
|
||||
.filter(|entry| entry.candidate_id == candidate.id)
|
||||
.cloned()
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_incident_review_audit(
|
||||
entries: &[IncidentReviewAuditEntry],
|
||||
) -> IncidentReviewAuditSummary {
|
||||
let mut summary = IncidentReviewAuditSummary {
|
||||
total_changes: entries.len(),
|
||||
..IncidentReviewAuditSummary::default()
|
||||
};
|
||||
for entry in entries {
|
||||
match entry.new_status.as_str() {
|
||||
"CONFIRMED" => summary.confirmed_count += 1,
|
||||
"FALSE_POSITIVE" => summary.false_positive_count += 1,
|
||||
"POSTPONED" => summary.postponed_count += 1,
|
||||
_ => {}
|
||||
}
|
||||
if summary
|
||||
.last_change_utc
|
||||
.as_ref()
|
||||
.is_none_or(|current| entry.changed_at_utc > *current)
|
||||
{
|
||||
summary.last_change_utc = Some(entry.changed_at_utc.clone());
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
fn stable_high_risk_candidates(history: &[BusinessRiskHistoryItem]) -> Vec<RiskIncidentCandidate> {
|
||||
let mut by_department = BTreeMap::<String, Vec<&BusinessRiskHistoryItem>>::new();
|
||||
for item in history {
|
||||
@@ -2720,6 +2791,7 @@ fn stable_high_risk_candidates(history: &[BusinessRiskHistoryItem]) -> Vec<RiskI
|
||||
.to_string(),
|
||||
),
|
||||
incident_review: IncidentReviewState::default(),
|
||||
incident_review_audit: Vec::new(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -2748,6 +2820,7 @@ fn low_trust_risk_candidates(
|
||||
last_seen_utc: Some(snapshot.generated_at_utc.clone()),
|
||||
recommendation: Some(item.recommendation.clone()),
|
||||
incident_review: IncidentReviewState::default(),
|
||||
incident_review_audit: Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -2777,6 +2850,7 @@ fn agent_quality_candidates(
|
||||
last_seen_utc: Some(node.last_seen_utc.clone()),
|
||||
recommendation: Some(node.recommendation.clone()),
|
||||
incident_review: IncidentReviewState::default(),
|
||||
incident_review_audit: Vec::new(),
|
||||
});
|
||||
}
|
||||
if let Some(error) = &node.collector_error {
|
||||
@@ -2798,6 +2872,7 @@ fn agent_quality_candidates(
|
||||
"Проверить журнал агента и восстановить основной сбор.".to_string(),
|
||||
),
|
||||
incident_review: IncidentReviewState::default(),
|
||||
incident_review_audit: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2830,6 +2905,7 @@ fn agent_coverage_candidates(snapshot: &Snapshot) -> Vec<RiskIncidentCandidate>
|
||||
last_seen_utc: Some(snapshot.generated_at_utc.clone()),
|
||||
recommendation: Some(node.recommendation.clone()),
|
||||
incident_review: IncidentReviewState::default(),
|
||||
incident_review_audit: Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -4385,6 +4461,11 @@ fn render_report_markdown(
|
||||
);
|
||||
append_risk_incident_candidates_markdown(&mut text, context.risk_incident_candidates);
|
||||
append_incident_review_markdown(&mut text, context.risk_incident_candidates);
|
||||
append_incident_review_audit_markdown(
|
||||
&mut text,
|
||||
context.risk_incident_candidates,
|
||||
context.incident_review_audit_summary,
|
||||
);
|
||||
append_ueba_risk_markdown(&mut text, context.ueba_risk);
|
||||
append_workforce_policy_markdown(&mut text, context.workforce_policy);
|
||||
text.push_str("\nПримечание: DLP/case показатели являются derived detections/cases и требуют регламентной валидации перед подачей как подтвержденные инциденты.\n");
|
||||
@@ -4666,6 +4747,48 @@ fn append_incident_review_markdown(text: &mut String, candidates: &[RiskIncident
|
||||
}
|
||||
}
|
||||
|
||||
fn append_incident_review_audit_markdown(
|
||||
text: &mut String,
|
||||
candidates: &[RiskIncidentCandidate],
|
||||
summary: &IncidentReviewAuditSummary,
|
||||
) {
|
||||
text.push_str("\n## Аудит проверки инцидентов\n\n");
|
||||
text.push_str(&format!("- Всего изменений: {}\n", summary.total_changes));
|
||||
text.push_str(&format!("- Подтверждено: {}\n", summary.confirmed_count));
|
||||
text.push_str(&format!(
|
||||
"- Ложных срабатываний: {}\n",
|
||||
summary.false_positive_count
|
||||
));
|
||||
text.push_str(&format!("- Отложено: {}\n", summary.postponed_count));
|
||||
text.push_str(&format!(
|
||||
"- Последнее изменение: {}\n",
|
||||
summary.last_change_utc.as_deref().unwrap_or("-")
|
||||
));
|
||||
let mut shown = 0usize;
|
||||
for entry in candidates
|
||||
.iter()
|
||||
.flat_map(|candidate| candidate.incident_review_audit.iter())
|
||||
.rev()
|
||||
{
|
||||
text.push_str(&format!(
|
||||
"- {}: {} -> {}, reviewer={}, changed_at={}, comment={}\n",
|
||||
entry.candidate_id,
|
||||
entry.old_status,
|
||||
entry.new_status,
|
||||
entry.reviewer.as_deref().unwrap_or("-"),
|
||||
entry.changed_at_utc,
|
||||
entry.comment.as_deref().unwrap_or("-")
|
||||
));
|
||||
shown += 1;
|
||||
if shown >= 20 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if shown == 0 {
|
||||
text.push_str("- История изменений отсутствует.\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn append_ueba_risk_markdown(text: &mut String, risk: &Value) {
|
||||
text.push_str("\n## UEBA риск\n\n");
|
||||
text.push_str(&format!(
|
||||
@@ -5337,16 +5460,33 @@ fn apply_incident_review(args: &Cli, actor: &str, body: &str) -> Result<Incident
|
||||
.or_else(|| Some(sanitize_text(actor, 80)))
|
||||
.filter(|value| !value.is_empty());
|
||||
let comment = sanitize_optional_text(request.comment, 500);
|
||||
let mut state = load_incident_review(args)?;
|
||||
let old_status = state
|
||||
.reviews
|
||||
.get(&candidate_id)
|
||||
.map(|review| review.status.clone())
|
||||
.unwrap_or_else(|| "NEW".to_string());
|
||||
let changed_at_utc = now();
|
||||
let review = IncidentReviewState {
|
||||
candidate_id: candidate_id.clone(),
|
||||
status: status.to_string(),
|
||||
reviewer,
|
||||
comment,
|
||||
updated_at: now(),
|
||||
reviewer: reviewer.clone(),
|
||||
comment: comment.clone(),
|
||||
updated_at: changed_at_utc.clone(),
|
||||
};
|
||||
let mut state = load_incident_review(args)?;
|
||||
state.reviews.insert(candidate_id, review.clone());
|
||||
state.reviews.insert(candidate_id.clone(), review.clone());
|
||||
save_incident_review(args, &state)?;
|
||||
append_incident_review_audit(
|
||||
args,
|
||||
&IncidentReviewAuditEntry {
|
||||
candidate_id,
|
||||
old_status,
|
||||
new_status: status.to_string(),
|
||||
reviewer,
|
||||
comment,
|
||||
changed_at_utc,
|
||||
},
|
||||
)?;
|
||||
Ok(IncidentReviewResponse { ok: true, review })
|
||||
}
|
||||
|
||||
@@ -5370,6 +5510,16 @@ fn load_incident_review_best_effort(args: &Cli) -> IncidentReviewFile {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_incident_review_audit_best_effort(args: &Cli) -> Vec<IncidentReviewAuditEntry> {
|
||||
match load_incident_review_audit(args) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
eprintln!("detmir-portal incident review audit read failed: {err:#}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_incident_state(args: &Cli) -> Result<IncidentStateFile> {
|
||||
let path = incident_state_path(args);
|
||||
if !path.exists() {
|
||||
@@ -5388,6 +5538,25 @@ fn load_incident_review(args: &Cli) -> Result<IncidentReviewFile> {
|
||||
serde_json::from_str(&data).with_context(|| format!("parse {}", path.display()))
|
||||
}
|
||||
|
||||
fn load_incident_review_audit(args: &Cli) -> Result<Vec<IncidentReviewAuditEntry>> {
|
||||
let path = incident_review_audit_path(args);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let data = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
let mut entries = Vec::new();
|
||||
for (index, line) in data.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entry = serde_json::from_str(line)
|
||||
.with_context(|| format!("parse {} line {}", path.display(), index + 1))?;
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> {
|
||||
fs::create_dir_all(&args.state_dir)
|
||||
.with_context(|| format!("create {}", args.state_dir.display()))?;
|
||||
@@ -5425,6 +5594,21 @@ fn append_incident_audit(args: &Cli, entry: &IncidentAuditEntry) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_incident_review_audit(args: &Cli, entry: &IncidentReviewAuditEntry) -> Result<()> {
|
||||
let path = incident_review_audit_path(args);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("open {}", path.display()))?;
|
||||
serde_json::to_writer(&mut file, entry)?;
|
||||
file.write_all(b"\n")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn incident_state_path(args: &Cli) -> PathBuf {
|
||||
args.state_dir.join("incidents-state.json")
|
||||
}
|
||||
@@ -5433,6 +5617,12 @@ fn incident_review_path(args: &Cli) -> PathBuf {
|
||||
args.state_dir.join("data").join("incident_reviews.json")
|
||||
}
|
||||
|
||||
fn incident_review_audit_path(args: &Cli) -> PathBuf {
|
||||
args.state_dir
|
||||
.join("data")
|
||||
.join("incident_review_audit.jsonl")
|
||||
}
|
||||
|
||||
fn build_dlp_evidence_response(args: &Cli) -> DlpEvidenceResponse {
|
||||
let generated_at_utc = now();
|
||||
let db_available = args.dlp_db_path.exists();
|
||||
@@ -7316,6 +7506,14 @@ mod tests {
|
||||
stored.reviews["risk-candidate-123"].comment.as_deref(),
|
||||
Some("confirmed by manual check")
|
||||
);
|
||||
assert!(incident_review_audit_path(&args).ends_with("data/incident_review_audit.jsonl"));
|
||||
let audit = load_incident_review_audit(&args).unwrap();
|
||||
assert_eq!(audit.len(), 1);
|
||||
assert_eq!(audit[0].candidate_id, "risk-candidate-123");
|
||||
assert_eq!(audit[0].old_status, "NEW");
|
||||
assert_eq!(audit[0].new_status, "CONFIRMED");
|
||||
assert_eq!(audit[0].reviewer.as_deref(), Some("operator"));
|
||||
assert_eq!(summarize_incident_review_audit(&audit).confirmed_count, 1);
|
||||
let mut candidates = vec![RiskIncidentCandidate {
|
||||
id: "risk-candidate-123".to_string(),
|
||||
department: Some("Подразделение".to_string()),
|
||||
@@ -7328,9 +7526,11 @@ mod tests {
|
||||
last_seen_utc: None,
|
||||
recommendation: None,
|
||||
incident_review: IncidentReviewState::default(),
|
||||
incident_review_audit: Vec::new(),
|
||||
}];
|
||||
apply_incident_reviews_to_candidates(&mut candidates, &stored);
|
||||
apply_incident_reviews_to_candidates(&mut candidates, &stored, &audit);
|
||||
assert_eq!(candidates[0].incident_review.status, "CONFIRMED");
|
||||
assert_eq!(candidates[0].incident_review_audit.len(), 1);
|
||||
assert_eq!(
|
||||
validate_incident_review_status("FALSE_POSITIVE").unwrap(),
|
||||
"FALSE_POSITIVE"
|
||||
@@ -7713,6 +7913,7 @@ mod tests {
|
||||
ReportRuntimeInputs {
|
||||
incident_state: &IncidentStateFile::default(),
|
||||
incident_reviews: &IncidentReviewFile::default(),
|
||||
incident_review_audit: &[],
|
||||
evidence: &evidence,
|
||||
},
|
||||
&missing_policy,
|
||||
@@ -7823,6 +8024,7 @@ mod tests {
|
||||
report["risk_incident_candidates"][0]["incident_review"]["status"],
|
||||
"NEW"
|
||||
);
|
||||
assert_eq!(report["incident_review_audit_summary"]["total_changes"], 0);
|
||||
assert!(
|
||||
report["executive_points"]
|
||||
.as_array()
|
||||
@@ -7880,6 +8082,12 @@ mod tests {
|
||||
.unwrap()
|
||||
.contains("## Проверка кандидатов в инциденты")
|
||||
);
|
||||
assert!(
|
||||
report["markdown"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("## Аудит проверки инцидентов")
|
||||
);
|
||||
assert!(report["markdown"].as_str().unwrap().contains("причины:"));
|
||||
assert!(
|
||||
report["markdown"]
|
||||
|
||||
@@ -604,6 +604,29 @@ h1 {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.review-history {
|
||||
margin-top: 6px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.review-history summary {
|
||||
cursor: pointer;
|
||||
color: var(--link);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.review-history ul {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 6px 0 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.review-history li {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.heatmap-table td {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
@@ -1779,7 +1779,28 @@ function renderCandidateReview(item) {
|
||||
const comment = review.comment || "комментария нет";
|
||||
const reviewer = review.reviewer || "проверяющий не указан";
|
||||
const updated = review.updated_at || "не обновлялось";
|
||||
return `<span class="badge ${statusClass(status)}">${ui(reviewStatusText(status))}</span><br><span class="muted small">${ui(reviewer)} · ${ui(updated)}</span><br><span class="muted small">${ui(comment)}</span>`;
|
||||
const audit = Array.isArray(item?.incident_review_audit) ? item.incident_review_audit.slice(-4).reverse() : [];
|
||||
const history = audit.length ? `
|
||||
<details class="review-history">
|
||||
<summary>История изменений</summary>
|
||||
<ul>
|
||||
${audit.map(entry => `
|
||||
<li>
|
||||
<span>${ui(reviewStatusText(entry.old_status))} → ${ui(reviewStatusText(entry.new_status))}</span><br>
|
||||
<span class="muted small">${ui(entry.reviewer || "проверяющий не указан")} · ${ui(entry.changed_at_utc || "-")}</span><br>
|
||||
<span class="muted small">${ui(entry.comment || "комментария нет")}</span>
|
||||
</li>
|
||||
`).join("")}
|
||||
</ul>
|
||||
</details>
|
||||
` : `<span class="muted small">История изменений отсутствует</span>`;
|
||||
return `
|
||||
<span class="badge ${statusClass(status)}">${ui(reviewStatusText(status))}</span><br>
|
||||
<span class="muted small">Изменил: ${ui(reviewer)}</span><br>
|
||||
<span class="muted small">Когда: ${ui(updated)}</span><br>
|
||||
<span class="muted small">Комментарий: ${ui(comment)}</span>
|
||||
${history}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCandidateReviewActions(item) {
|
||||
|
||||
Reference in New Issue
Block a user