Add files via upload

This commit is contained in:
公明
2026-07-23 18:56:18 +08:00
committed by GitHub
parent dbc4ead040
commit a83490f29c
15 changed files with 5102 additions and 668 deletions
+593 -88
View File
@@ -14,6 +14,8 @@
tasksPageSize: 10,
tasksTotal: 0,
tasksPendingQueuedCount: null,
tasksStatusCounts: { success: 0, failed: 0, pending: 0, cancelled: 0 },
tasksFilter: { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' },
events: [],
eventsPage: 1,
eventsPageSize: 10,
@@ -47,6 +49,111 @@
// API 基础路径
const API_BASE = '/api/c2';
function activeC2ProjectId() {
try {
return (typeof getActiveProjectId === 'function' ? getActiveProjectId() : '') || '';
} catch (e) {
return '';
}
}
function c2ResourceProjectId(item) {
return String((item && (item.project_id || item.projectId)) || '').trim();
}
function c2LooksLikeUuid(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(value || '').trim());
}
function c2ProjectNameMap() {
try {
return (window.projectNameById && typeof window.projectNameById === 'object')
? window.projectNameById
: {};
} catch (e) {
return {};
}
}
/** 下拉展示用项目名:优先可读名称,绝不回退成 UUID */
function c2ProjectDisplayName(id, nameHint) {
const idStr = String(id || '').trim();
if (!idStr) return '';
let name = String(nameHint || '').trim();
if (!name || name === idStr || c2LooksLikeUuid(name)) {
const mapped = String(c2ProjectNameMap()[idStr] || '').trim();
if (mapped && mapped !== idStr && !c2LooksLikeUuid(mapped)) name = mapped;
}
if (!name || name === idStr || c2LooksLikeUuid(name)) {
if (typeof window.getProjectName === 'function') {
const viaFn = String(window.getProjectName(idStr) || '').trim();
if (viaFn && viaFn !== idStr && !c2LooksLikeUuid(viaFn)) name = viaFn;
}
}
if (!name || name === idStr || c2LooksLikeUuid(name)) {
return c2t('batchManageModal.unknownProject') || '未知项目';
}
return name;
}
function c2ProjectOptionsHtml(selectedId, projectList) {
const selected = String(selectedId || '').trim();
let html = `<option value="">${escapeHtml(c2t('assets.unboundProject') || '暂不绑定')}</option>`;
let entries = [];
if (Array.isArray(projectList) && projectList.length) {
entries = projectList
.filter((p) => p && p.id)
.map((p) => [String(p.id), String(p.name || '').trim()]);
} else {
entries = Object.entries(c2ProjectNameMap());
}
const seen = new Set();
entries.sort((a, b) => c2ProjectDisplayName(a[0], a[1]).localeCompare(c2ProjectDisplayName(b[0], b[1]), undefined, { sensitivity: 'base' }));
entries.forEach(([id, name]) => {
if (!id || seen.has(id)) return;
seen.add(id);
const label = c2ProjectDisplayName(id, name);
html += `<option value="${escapeHtml(id)}"${id === selected ? ' selected' : ''}>${escapeHtml(label)}</option>`;
});
if (selected && !seen.has(selected)) {
html += `<option value="${escapeHtml(selected)}" selected>${escapeHtml(c2ProjectDisplayName(selected))}</option>`;
}
return html;
}
function ensureC2ProjectsLoaded() {
if (typeof window.ensureProjectsLoaded === 'function') return window.ensureProjectsLoaded();
if (typeof window.fetchAllProjects === 'function') {
return window.fetchAllProjects(false).then((list) => {
if (typeof window.rebuildProjectNameMap === 'function') window.rebuildProjectNameMap(list || []);
return list || [];
});
}
return Promise.resolve([]);
}
/** 确保当前选中项目有可读名称(孤儿 / 未进缓存的 ID) */
function ensureC2SelectedProjectNamed(projectId) {
const id = String(projectId || '').trim();
if (!id) return Promise.resolve();
const mapped = String(c2ProjectNameMap()[id] || '').trim();
if (mapped && mapped !== id && !c2LooksLikeUuid(mapped)) return Promise.resolve();
if (typeof window.fetchProjectSummary !== 'function') return Promise.resolve();
return window.fetchProjectSummary(id).then((p) => {
if (p && p.id && typeof window.rememberProjectsInNameMap === 'function') {
window.rememberProjectsInNameMap([p]);
}
}).catch(function () { /* ignore */ });
}
function c2ProjectBindSelectHtml(listener) {
return `<select class="c2-project-bind-select" data-id="${escapeHtml(listener.id || '')}" title="${escapeHtml(c2t('assets.project') || '所属项目')}" onclick="event.stopPropagation()" onchange="C2.bindListenerProject(this.dataset.id, this.value)">${c2ProjectOptionsHtml(c2ResourceProjectId(listener))}</select>`;
}
function withC2ProjectQuery(url) {
return url;
}
window.__c2DownloadPayload = function(filename) {
const url = `${API_BASE}/payloads/${filename}/download`;
const fetchFn = (typeof apiFetch === 'function') ? apiFetch : fetch;
@@ -339,56 +446,116 @@
return String(os || '?').substring(0, 3).toLowerCase();
}
function isEmptyInfoValue(value) {
if (value == null) return true;
const s = String(value).trim();
if (!s || s === '-') return true;
const lower = s.toLowerCase();
return lower === 'unknown' || lower === 'n/a' || lower === 'null' || lower === 'undefined';
}
function displayInfoValue(value, fallback) {
if (isEmptyInfoValue(value)) return fallback != null ? fallback : '—';
return String(value);
}
function sessionMetaLine(s) {
const user = displayInfoValue(s.username, '');
const os = displayInfoValue(s.os, '');
const arch = displayInfoValue(s.arch, '');
const right = [os, arch].filter(Boolean).join('/') || '—';
if (!user) return right;
return user + '@' + right;
}
function sessionInfoRow(label, value, opts) {
const empty = isEmptyInfoValue(value);
const display = empty ? '—' : String(value);
const valueClasses = ['c2-session-info-dl__value'];
if (opts && opts.mono) valueClasses.push('is-mono');
if (opts && opts.accent) valueClasses.push('is-accent');
if (opts && opts.warn) valueClasses.push('is-warn');
if (opts && opts.accent && !empty) valueClasses.push('is-accent');
if (opts && opts.warn && !empty) valueClasses.push('is-warn');
if (empty) valueClasses.push('is-empty');
const rowCls = opts && opts.full ? ' c2-session-info-dl__row--full' : '';
const copyBtn = (opts && opts.copy && !empty)
? `<button type="button" class="c2-session-info-copy" title="${escapeHtml(c2t('c2.sessions.infoCopy'))}" aria-label="${escapeHtml(c2t('c2.sessions.infoCopy'))}" onclick="event.stopPropagation(); C2.copyText(${JSON.stringify(String(value))})">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
</button>`
: '';
return `
<div class="c2-session-info-dl__row${rowCls}">
<dt class="c2-session-info-dl__label">${escapeHtml(label)}</dt>
<dd class="${valueClasses.join(' ')}">${escapeHtml(value == null || value === '' ? '-' : String(value))}</dd>
<dd class="${valueClasses.join(' ')}">
<span class="c2-session-info-dl__text" title="${escapeHtml(empty ? '' : display)}">${escapeHtml(display)}</span>
${copyBtn}
</dd>
</div>`;
}
function renderSessionInfoPanel(s, adminVal, sleepLine) {
const lastCheckin = formatTime(s.lastCheckIn);
const lastRel = formatRelativeTime(s.lastCheckIn);
const checkinDisplay = lastRel ? `${lastCheckin} (${lastRel})` : lastCheckin;
const checkinDisplay = lastRel ? `${lastCheckin} · ${lastRel}` : lastCheckin;
const noteText = s.note && String(s.note).trim() ? String(s.note) : '';
const noteEmpty = !noteText;
return `
<div class="c2-session-info-panel">
<section class="c2-session-info-block">
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionIdentity'))}</div>
<dl class="c2-session-info-dl">
${sessionInfoRow(c2t('c2.sessions.infoSessionId'), s.id, { mono: true, accent: true, full: true })}
${sessionInfoRow(c2t('c2.sessions.infoImplantUuid'), s.implantUuid, { mono: true, full: true })}
${sessionInfoRow(c2t('c2.sessions.infoHostname'), s.hostname)}
${sessionInfoRow(c2t('c2.sessions.infoUsername'), s.username)}
</dl>
</section>
<section class="c2-session-info-block">
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionSystem'))}</div>
<dl class="c2-session-info-dl">
${sessionInfoRow(c2t('c2.sessions.infoOs'), s.os)}
${sessionInfoRow(c2t('c2.sessions.infoArch'), s.arch, { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoPid'), s.pid, { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoProcess'), s.processName || '-', { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoAdmin'), adminVal, { warn: s.isAdmin, full: true })}
</dl>
</section>
<section class="c2-session-info-block">
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionNetwork'))}</div>
<dl class="c2-session-info-dl">
${sessionInfoRow(c2t('c2.sessions.infoInternalIp'), s.internalIp || '-', { mono: true, accent: true })}
${sessionInfoRow(c2t('c2.sessions.infoSleep'), sleepLine)}
${sessionInfoRow(c2t('c2.sessions.infoFirstSeen'), formatTime(s.firstSeenAt), { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoLastCheckin'), checkinDisplay, { mono: true, accent: true })}
</dl>
</section>
<section class="c2-session-info-block c2-session-info-block--note">
<div class="c2-session-info-block__head">${escapeHtml(c2t('c2.sessions.infoSectionNote'))}</div>
<div class="c2-session-info-note">${escapeHtml(s.note || c2t('c2.sessions.infoNoteEmpty'))}</div>
<div class="c2-session-info-grid">
<section class="c2-session-info-block">
<div class="c2-session-info-block__head">
<span class="c2-session-info-block__icon c2-session-info-block__icon--id" aria-hidden="true"></span>
<span>${escapeHtml(c2t('c2.sessions.infoSectionIdentity'))}</span>
</div>
<dl class="c2-session-info-dl">
${sessionInfoRow(c2t('c2.sessions.infoSessionId'), s.id, { mono: true, accent: true, copy: true })}
${sessionInfoRow(c2t('c2.sessions.infoImplantUuid'), s.implantUuid, { mono: true, copy: true })}
${sessionInfoRow(c2t('c2.sessions.infoHostname'), s.hostname)}
${sessionInfoRow(c2t('c2.sessions.infoUsername'), s.username)}
</dl>
</section>
<section class="c2-session-info-block">
<div class="c2-session-info-block__head">
<span class="c2-session-info-block__icon c2-session-info-block__icon--sys" aria-hidden="true"></span>
<span>${escapeHtml(c2t('c2.sessions.infoSectionSystem'))}</span>
</div>
<dl class="c2-session-info-dl">
${sessionInfoRow(c2t('c2.sessions.infoOs'), s.os)}
${sessionInfoRow(c2t('c2.sessions.infoArch'), s.arch, { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoPid'), s.pid, { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoProcess'), s.processName || '-', { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoAdmin'), adminVal, { warn: !!s.isAdmin })}
</dl>
</section>
<section class="c2-session-info-block">
<div class="c2-session-info-block__head">
<span class="c2-session-info-block__icon c2-session-info-block__icon--net" aria-hidden="true"></span>
<span>${escapeHtml(c2t('c2.sessions.infoSectionNetwork'))}</span>
</div>
<dl class="c2-session-info-dl">
${sessionInfoRow(c2t('c2.sessions.infoInternalIp'), s.internalIp || '-', { mono: true, accent: true, copy: true })}
${sessionInfoRow(c2t('c2.sessions.infoSleep'), sleepLine)}
${sessionInfoRow(c2t('c2.sessions.infoFirstSeen'), formatTime(s.firstSeenAt), { mono: true })}
${sessionInfoRow(c2t('c2.sessions.infoLastCheckin'), checkinDisplay, { mono: true, accent: true })}
</dl>
</section>
</div>
<section class="c2-session-info-block c2-session-info-block--note${noteEmpty ? ' is-empty' : ''}" id="c2-session-note-block">
<div class="c2-session-info-block__head">
<span class="c2-session-info-block__icon c2-session-info-block__icon--note" aria-hidden="true"></span>
<span>${escapeHtml(c2t('c2.sessions.infoSectionNote'))}</span>
<button type="button" class="c2-session-note-edit-btn" data-require-permission="c2:write" onclick='C2.beginEditSessionNote(${JSON.stringify(s.id)})'>${escapeHtml(c2t('c2.sessions.noteEdit'))}</button>
</div>
<div class="c2-session-info-note${noteEmpty ? ' is-empty' : ''}" id="c2-session-note-view">${escapeHtml(noteText || c2t('c2.sessions.infoNoteEmpty'))}</div>
<div class="c2-session-note-editor" id="c2-session-note-editor" hidden>
<textarea id="c2-session-note-input" class="c2-session-note-textarea" maxlength="2000" rows="4" placeholder="${escapeHtml(c2t('c2.sessions.notePlaceholder'))}">${escapeHtml(noteText)}</textarea>
<div class="c2-session-note-editor__footer">
<span class="c2-session-note-counter" id="c2-session-note-counter">${noteText.length}/2000</span>
<div class="c2-session-note-editor__actions">
<button type="button" class="btn-secondary btn-sm" onclick="C2.cancelEditSessionNote()">${escapeHtml(c2t('common.cancel'))}</button>
<button type="button" class="btn-primary btn-sm" id="c2-session-note-save" onclick='C2.saveSessionNote(${JSON.stringify(s.id)})'>${escapeHtml(c2t('common.save'))}</button>
</div>
</div>
</div>
</section>
</div>`;
}
@@ -398,6 +565,7 @@
// ============================================================================
function apiRequest(method, url, data) {
if (method === 'GET') url = withC2ProjectQuery(url);
const options = {
method: method,
headers: { 'Content-Type': 'application/json' }
@@ -448,6 +616,21 @@
return c2t('c2.fmt.durationMin', { n: (ms / 60000).toFixed(1) });
}
function formatBytes(n) {
const num = Number(n);
if (!Number.isFinite(num) || num < 0) return String(n == null ? '-' : n);
if (num < 1024) return String(Math.round(num)) + ' B';
const units = ['KB', 'MB', 'GB', 'TB'];
let v = num / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
const digits = v >= 100 ? 0 : (v >= 10 ? 1 : 2);
return v.toFixed(digits) + ' ' + units[i];
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
@@ -556,6 +739,11 @@
}
}
C2.copyText = function(text) {
if (text == null || text === '') return;
copyToClipboard(String(text));
};
// ============================================================================
// 页面初始化
// ============================================================================
@@ -611,7 +799,8 @@
C2.loadListeners = function() {
Promise.all([
apiRequest('GET', `${API_BASE}/listeners`),
apiRequest('GET', `${API_BASE}/profiles`).catch(function() { return {}; })
apiRequest('GET', `${API_BASE}/profiles`).catch(function() { return {}; }),
ensureC2ProjectsLoaded().catch(function() { return []; })
]).then(function(results) {
var ldata = results[0];
var pdata = results[1];
@@ -670,6 +859,7 @@
? '<div class="c2-listener-kv"><span class="c2-listener-kv-label">' + escapeHtml(c2t('c2.listeners.callbackShort')) + '</span><span class="c2-listener-kv-val c2-listener-mono">' + escapeHtml(cb) + '</span></div>'
: '';
const remarkRow = l.remark ? '<div class="c2-listener-remark">' + escapeHtml(l.remark) + '</div>' : '';
const projectRow = '<div class="c2-listener-kv"><span class="c2-listener-kv-label">' + escapeHtml(c2t('assets.project') || '所属项目') + '</span><span class="c2-listener-kv-val">' + c2ProjectBindSelectHtml(l) + '</span></div>';
const startedHtml = formatListenerStartedHtml(l.startedAt);
const pillLabel = escapeHtml(listenerCardStatusPillLabel(st));
const typeMark = escapeHtml(listenerTypeShortLabel(l.type));
@@ -697,6 +887,7 @@
<span class="c2-listener-kv-val c2-listener-mono"><span class="c2-status-dot ${escapeHtml(st)}"></span>${bindVal}</span>
</div>
${cbRow}
${projectRow}
${profileBadge}
${remarkRow}
${startedHtml}
@@ -751,8 +942,15 @@
</div>
`;
C2.ensureProfilesLoaded().then(() => {
const createSelectedProjectId = activeC2ProjectId();
Promise.all([
C2.ensureProfilesLoaded(),
ensureC2ProjectsLoaded().catch(() => []),
ensureC2SelectedProjectNamed(createSelectedProjectId)
]).then(function (results) {
const projects = results[1] || [];
const profileOpts = listenerProfileSelectHtml('');
const projectOpts = c2ProjectOptionsHtml(createSelectedProjectId, projects);
const emptyProfHintCreate = (C2.profiles && C2.profiles.length > 0)
? ''
: `<div class="form-hint form-hint--warning" style="margin-bottom:6px;">${escapeHtml(c2t('c2.listeners.malleableProfileEmptyListHint'))}</div>`;
@@ -777,6 +975,10 @@
</select>
</div>
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('assets.project') || '所属项目')}</label>
<select id="c2-listener-project-id" class="form-control c2-form-select-native">${projectOpts}</select>
</div>
<div class="c2-form-row">
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
@@ -864,6 +1066,7 @@
const body = {
name, type, bind_host: bindHost, bind_port: bindPort, remark,
callback_host: callbackHost,
project_id: (document.getElementById('c2-listener-project-id')?.value || '').trim(),
profile_id: profileId
};
if (type === 'tcp_reverse' && legacyShell) {
@@ -909,6 +1112,38 @@
});
};
C2.bindListenerProject = function(id, projectId) {
if (typeof requirePermission === 'function' && !requirePermission('c2:write')) {
C2.renderListeners();
return;
}
const listener = C2.listeners.find(x => x.id === id);
if (!listener) return;
const cfg = C2.getListenerConfig(listener);
const body = {
name: listener.name || '',
bind_host: listener.bindHost || '127.0.0.1',
bind_port: parseInt(listener.bindPort, 10) || 0,
remark: listener.remark || '',
callback_host: C2.getListenerCallbackHost(listener),
project_id: String(projectId || '').trim(),
profile_id: listenerResolvedProfileId(listener),
config: cfg
};
apiRequest('PUT', `${API_BASE}/listeners/${id}`, body).then(data => {
if (data && data.error) {
showToast(data.error, 'error');
C2.renderListeners();
return;
}
showToast(c2t('projects.projectBound') || '已绑定项目', 'success');
C2.loadListeners();
}).catch(err => {
showToast(err && err.message ? err.message : '绑定项目失败', 'error');
C2.renderListeners();
});
};
C2.editListener = function(id) {
const l = C2.listeners.find(x => x.id === id);
if (!l) return;
@@ -931,9 +1166,16 @@
</div>
`;
C2.ensureProfilesLoaded().then(() => {
const editSelectedProjectId = c2ResourceProjectId(l);
Promise.all([
C2.ensureProfilesLoaded(),
ensureC2ProjectsLoaded().catch(() => []),
ensureC2SelectedProjectNamed(editSelectedProjectId)
]).then(function (results) {
const projects = results[1] || [];
const resolvedPid = listenerResolvedProfileId(l);
const profileOpts = listenerProfileSelectHtml(resolvedPid);
const projectOpts = c2ProjectOptionsHtml(editSelectedProjectId, projects);
const lt = String(l.type || '').toLowerCase();
const httpHint = (lt === 'http_beacon' || lt === 'https_beacon')
? ''
@@ -951,6 +1193,10 @@
<label>${escapeHtml(c2t('c2.listeners.name'))}</label>
<input type="text" id="c2-listener-name" class="form-control" value="${escapeHtml(l.name)}">
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('assets.project') || '所属项目')}</label>
<select id="c2-listener-project-id" class="form-control c2-form-select-native">${projectOpts}</select>
</div>
<div class="c2-form-row">
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
@@ -1009,6 +1255,7 @@
const body = {
name, bind_host: bindHost, bind_port: bindPort, remark,
callback_host: callbackHost,
project_id: (document.getElementById('c2-listener-project-id')?.value || '').trim(),
profile_id: profileId
};
if (legacyEl) {
@@ -1119,9 +1366,16 @@
const batchBtn = document.getElementById('c2-sessions-batch-delete');
const filteredBtn = document.getElementById('c2-sessions-delete-filtered');
const ids = C2.collectCheckedSessionIds();
if (batchBtn) batchBtn.disabled = ids.length === 0;
if (batchBtn) {
batchBtn.disabled = ids.length === 0;
batchBtn.classList.toggle('btn-danger', ids.length > 0);
batchBtn.classList.toggle('btn-secondary', ids.length === 0);
}
const total = (C2.sessions || []).length;
if (filteredBtn) filteredBtn.disabled = total === 0;
if (filteredBtn) {
filteredBtn.disabled = total === 0;
filteredBtn.classList.toggle('is-danger-soft', total > 0);
}
const countEl = document.getElementById('c2-sessions-count');
if (countEl) {
countEl.textContent = c2t('c2.sessions.filterCount', { n: total, selected: ids.length });
@@ -1191,7 +1445,12 @@
return;
}
list.innerHTML = C2.sessions.map(s => `
list.innerHTML = C2.sessions.map(s => {
const userLabel = displayInfoValue(s.username, '—');
const osArch = [displayInfoValue(s.os, ''), displayInfoValue(s.arch, '')].filter(Boolean).join('/') || '—';
const userEmpty = isEmptyInfoValue(s.username);
const osEmpty = isEmptyInfoValue(s.os) && isEmptyInfoValue(s.arch);
return `
<div class="c2-session-item ${s.id === C2.selectedSessionId ? 'active' : ''}"
data-status="${escapeHtml(s.status || '')}"
onclick="C2.selectSession('${s.id}')">
@@ -1206,21 +1465,21 @@
<span class="c2-session-status ${s.status}">${escapeHtml(sessionStatusLabel(s.status))}</span>
</div>
<div class="c2-session-chips">
<span class="c2-session-chip">${escapeHtml(s.username)}</span>
<span class="c2-session-chip c2-session-chip--mono">${escapeHtml(s.os)}/${escapeHtml(s.arch)}</span>
<span class="c2-session-chip${userEmpty ? ' c2-session-chip--empty' : ''}">${escapeHtml(userLabel)}</span>
<span class="c2-session-chip c2-session-chip--mono${osEmpty ? ' c2-session-chip--empty' : ''}">${escapeHtml(osArch)}</span>
${s.isAdmin ? '<span class="c2-session-chip c2-session-chip--warn">' + escapeHtml(c2t('c2.sessions.rootBadge')) + '</span>' : ''}
</div>
<div class="c2-session-chips c2-session-chips--sub">
<span class="c2-session-chip c2-session-chip--dim">${escapeHtml(s.internalIp || '-')}</span>
<span class="c2-session-chip c2-session-chip--dim">PID ${s.pid}</span>
<span class="c2-session-chip c2-session-chip--dim">${escapeHtml(s.internalIp || '')}</span>
<span class="c2-session-chip c2-session-chip--dim">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
</div>
<div class="c2-session-item-footer">
<span class="c2-session-meta c2-session-item-time" title="${escapeHtml(formatTime(s.lastCheckIn))}">${escapeHtml(formatRelativeTime(s.lastCheckIn) || formatTime(s.lastCheckIn))}</span>
<button type="button" class="c2-session-card-delete" data-require-permission="c2:delete" onclick="event.stopPropagation(); C2.deleteSessionRecord('${s.id}');">${escapeHtml(c2t('c2.sessions.cardDeleteSession'))}</button>
</div>
</div>
</div>
`).join('');
</div>`;
}).join('');
if (C2.selectedSessionId && !C2.sessions.find(s => s.id === C2.selectedSessionId)) {
C2.selectedSessionId = null;
@@ -1270,11 +1529,11 @@
<span class="c2-session-badge ${escapeHtml(s.status || '')}">${escapeHtml(sessionStatusLabel(s.status))}</span>
${s.isAdmin ? '<span class="c2-session-hero-root">' + escapeHtml(c2t('c2.sessions.rootBadge')) + '</span>' : ''}
</div>
<div class="c2-session-hero__sub">${escapeHtml(s.username)}@${escapeHtml(s.os)}/${escapeHtml(s.arch)}</div>
<div class="c2-session-hero__sub${isEmptyInfoValue(s.username) && isEmptyInfoValue(s.os) ? ' is-muted' : ''}">${escapeHtml(sessionMetaLine(s))}</div>
<div class="c2-session-hero__chips">
<span class="c2-session-hero-chip is-mono" title="${escapeHtml(c2t('c2.sessions.infoSessionId'))}">${escapeHtml(s.id)}</span>
<span class="c2-session-hero-chip">${escapeHtml(s.internalIp || '-')}</span>
<span class="c2-session-hero-chip">PID ${s.pid}</span>
<span class="c2-session-hero-chip">${escapeHtml(s.internalIp || '')}</span>
<span class="c2-session-hero-chip">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
</div>
</div>
</div>
@@ -1601,6 +1860,88 @@
});
};
C2.beginEditSessionNote = function(id) {
const block = document.getElementById('c2-session-note-block');
const view = document.getElementById('c2-session-note-view');
const editor = document.getElementById('c2-session-note-editor');
const input = document.getElementById('c2-session-note-input');
const editBtn = block && block.querySelector('.c2-session-note-edit-btn');
if (!view || !editor || !input) return;
const s = (C2.sessions || []).find(x => x.id === id);
const note = s && s.note ? String(s.note) : '';
input.value = note;
view.hidden = true;
editor.hidden = false;
if (block) block.classList.remove('is-empty');
if (editBtn) editBtn.hidden = true;
C2.syncSessionNoteCounter();
if (!input.dataset.boundCounter) {
input.dataset.boundCounter = '1';
input.addEventListener('input', function () { C2.syncSessionNoteCounter(); });
}
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
};
C2.syncSessionNoteCounter = function() {
const input = document.getElementById('c2-session-note-input');
const counter = document.getElementById('c2-session-note-counter');
if (!input || !counter) return;
counter.textContent = String(input.value.length) + '/2000';
};
C2.cancelEditSessionNote = function() {
const block = document.getElementById('c2-session-note-block');
const view = document.getElementById('c2-session-note-view');
const editor = document.getElementById('c2-session-note-editor');
const editBtn = block && block.querySelector('.c2-session-note-edit-btn');
if (!view || !editor) return;
editor.hidden = true;
view.hidden = false;
if (editBtn) editBtn.hidden = false;
if (block) {
const s = (C2.sessions || []).find(x => x.id === C2.selectedSessionId);
const empty = !(s && s.note && String(s.note).trim());
block.classList.toggle('is-empty', empty);
}
};
C2.saveSessionNote = function(id) {
if (!id) return;
const input = document.getElementById('c2-session-note-input');
const saveBtn = document.getElementById('c2-session-note-save');
if (!input) return;
const note = String(input.value || '').trim();
if (note.length > 2000) {
showToast(c2t('c2.sessions.toastNoteTooLong'), 'warn');
return;
}
if (saveBtn) saveBtn.disabled = true;
apiRequest('PUT', `${API_BASE}/sessions/${encodeURIComponent(id)}/note`, { note: note }).then(data => {
if (saveBtn) saveBtn.disabled = false;
if (data.error) {
showToast(String(data.error), 'error');
return;
}
const saved = data.note != null ? String(data.note) : note;
const s = (C2.sessions || []).find(x => x.id === id);
if (s) s.note = saved;
showToast(c2t('c2.sessions.toastNoteSaved'), 'success');
const block = document.getElementById('c2-session-note-block');
const view = document.getElementById('c2-session-note-view');
const empty = !saved.trim();
if (view) {
view.textContent = empty ? c2t('c2.sessions.infoNoteEmpty') : saved;
view.classList.toggle('is-empty', empty);
}
if (block) block.classList.toggle('is-empty', empty);
C2.cancelEditSessionNote();
}).catch(err => {
if (saveBtn) saveBtn.disabled = false;
showToast(err.message || String(err), 'error');
});
};
C2.killSession = function(id) {
if (!confirm(c2t('c2.sessions.confirmExitSession'))) return;
apiRequest('POST', `${API_BASE}/tasks`, {
@@ -2072,6 +2413,8 @@
});
container.setAttribute('tabindex', '0');
C2.bindTerminalScrollbarAutoHide(container);
C2.terminalInstance = term;
if (C2.terminalResizeObserver) {
@@ -2090,6 +2433,42 @@
});
};
C2.bindTerminalScrollbarAutoHide = function(container) {
if (!container) return;
if (C2._terminalScrollbarHideTimer) {
clearTimeout(C2._terminalScrollbarHideTimer);
C2._terminalScrollbarHideTimer = null;
}
const show = function () {
container.classList.add('is-scrollbar-visible');
if (C2._terminalScrollbarHideTimer) clearTimeout(C2._terminalScrollbarHideTimer);
C2._terminalScrollbarHideTimer = setTimeout(function () {
container.classList.remove('is-scrollbar-visible');
C2._terminalScrollbarHideTimer = null;
}, 900);
};
const bindViewport = function () {
const vp = container.querySelector('.xterm-viewport');
if (!vp) return false;
if (vp.dataset.scrollbarBound === '1') return true;
vp.dataset.scrollbarBound = '1';
vp.addEventListener('scroll', show, { passive: true });
return true;
};
if (!container.dataset.scrollbarUiBound) {
container.dataset.scrollbarUiBound = '1';
container.addEventListener('wheel', show, { passive: true });
container.addEventListener('touchmove', show, { passive: true });
}
if (!bindViewport()) {
const mo = new MutationObserver(function () {
if (bindViewport()) mo.disconnect();
});
mo.observe(container, { childList: true, subtree: true });
setTimeout(function () { mo.disconnect(); }, 3000);
}
};
C2.fitTerminal = function() {
const container = document.getElementById('c2-terminal-container');
if (!container || !C2.terminalFitAddon || !C2.terminalInstance) return;
@@ -2392,18 +2771,22 @@
</thead>
<tbody>
${entries.map(function(entry) {
const sizeLabel = entry.isDir ? '—' : formatBytes(entry.size);
const iconCls = entry.isDir ? 'c2-file-icon c2-file-icon--dir' : 'c2-file-icon c2-file-icon--file';
return `
<tr>
<td class="c2-file-name">
<span class="c2-file-icon">${entry.isDir ? '📁' : '📄'}</span>
${escapeHtml(entry.name)}
<td>
<div class="c2-file-name">
<span class="${iconCls}" aria-hidden="true"></span>
<span class="c2-file-name-text" title="${escapeHtml(entry.name)}">${escapeHtml(entry.name)}</span>
</div>
</td>
<td>${escapeHtml(entry.size)}</td>
<td title="${escapeHtml(String(entry.size || ''))}">${escapeHtml(sizeLabel)}</td>
<td>${escapeHtml(entry.mode)}</td>
<td>
${entry.isDir
? `<button class="btn-ghost btn-sm" onclick='C2.openDirectory(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.open'))}</button>`
: `<button class="btn-ghost btn-sm" onclick='C2.downloadFile(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.download'))}</button>`
? `<button class="btn-ghost btn-sm c2-file-action-btn" onclick='C2.openDirectory(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.open'))}</button>`
: `<button class="btn-ghost btn-sm c2-file-action-btn" onclick='C2.downloadFile(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.download'))}</button>`
}
</td>
</tr>
@@ -2717,16 +3100,20 @@
// ============================================================================
C2.loadTasks = function(page) {
bindTasksFilterUiOnce();
readTasksFilterFromDom();
const p = page != null ? page : (C2.tasksPage || 1);
C2.tasksPage = p;
const ps = C2.tasksPageSize || 10;
apiRequest('GET', `${API_BASE}/tasks?page=${encodeURIComponent(String(p))}&page_size=${encodeURIComponent(String(ps))}`).then(data => {
const qs = buildTasksQueryParams(p, ps);
apiRequest('GET', `${API_BASE}/tasks?${qs}`).then(data => {
if (data.error) {
showToast(String(data.error), 'error');
return;
}
C2.tasks = data.tasks || [];
C2.tasksTotal = typeof data.total === 'number' ? data.total : (C2.tasks.length || 0);
C2.tasksStatusCounts = data.status_counts || { success: 0, failed: 0, pending: 0, cancelled: 0 };
if (typeof data.pending_queued_count === 'number') {
C2.tasksPendingQueuedCount = data.pending_queued_count;
}
@@ -2736,8 +3123,10 @@
return;
}
C2.renderTasks();
C2.renderTasksSummary();
C2.renderTasksPagination();
C2.syncTasksToolbar();
syncTasksTimePresetButtons();
}).catch(err => {
showToast(err.message || String(err), 'error');
});
@@ -2857,6 +3246,105 @@
}).catch(err => showToast(err.message || String(err), 'error'));
};
function readTasksFilterFromDom() {
const statusEl = document.getElementById('c2-tasks-filter-status');
const typeEl = document.getElementById('c2-tasks-filter-type');
const sessionEl = document.getElementById('c2-tasks-filter-session');
C2.tasksFilter = C2.tasksFilter || { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' };
C2.tasksFilter.status = statusEl ? statusEl.value.trim() : '';
C2.tasksFilter.task_type = typeEl ? typeEl.value.trim() : '';
C2.tasksFilter.session_id = sessionEl ? sessionEl.value.trim() : '';
}
function buildTasksQueryParams(page, pageSize) {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
const f = C2.tasksFilter || {};
if (f.status) params.set('status', f.status);
if (f.task_type) params.set('task_type', f.task_type);
if (f.session_id) params.set('session_id', f.session_id);
if (f.since) params.set('since', f.since);
return params.toString();
}
function tasksTimePresetToSince(preset) {
if (!preset || preset === 'all') return '';
const now = Date.now();
const map = { '15m': 15 * 60 * 1000, '1h': 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000 };
const ms = map[preset];
if (!ms) return '';
return new Date(now - ms).toISOString();
}
function syncTasksTimePresetButtons() {
const wrap = document.getElementById('c2-tasks-time-presets');
if (!wrap) return;
const active = (C2.tasksFilter && C2.tasksFilter.timePreset) || 'all';
wrap.querySelectorAll('.c2-tasks-time-preset-btn').forEach(btn => {
btn.classList.toggle('is-active', btn.getAttribute('data-preset') === active);
});
}
function bindTasksFilterUiOnce() {
if (document.documentElement.dataset.c2TasksFilterBound === '1') return;
document.documentElement.dataset.c2TasksFilterBound = '1';
const presets = document.getElementById('c2-tasks-time-presets');
if (presets) {
presets.addEventListener('click', (e) => {
const btn = e.target.closest('.c2-tasks-time-preset-btn');
if (!btn) return;
const preset = btn.getAttribute('data-preset') || 'all';
C2.tasksFilter = C2.tasksFilter || { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' };
C2.tasksFilter.timePreset = preset;
C2.tasksFilter.since = tasksTimePresetToSince(preset);
syncTasksTimePresetButtons();
C2.loadTasks(1);
});
}
const sessionInput = document.getElementById('c2-tasks-filter-session');
if (sessionInput) {
sessionInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') C2.applyTasksFilters();
});
}
}
C2.applyTasksFilters = function() {
readTasksFilterFromDom();
C2.loadTasks(1);
};
C2.resetTasksFilters = function() {
C2.tasksFilter = { status: '', task_type: '', session_id: '', since: '', timePreset: 'all' };
const statusEl = document.getElementById('c2-tasks-filter-status');
const typeEl = document.getElementById('c2-tasks-filter-type');
const sessionEl = document.getElementById('c2-tasks-filter-session');
if (statusEl) statusEl.value = '';
if (typeEl) typeEl.value = '';
if (sessionEl) sessionEl.value = '';
syncTasksTimePresetButtons();
C2.loadTasks(1);
};
C2.renderTasksSummary = function() {
const summary = document.getElementById('c2-tasks-summary');
if (!summary) return;
const total = C2.tasksTotal || 0;
const counts = C2.tasksStatusCounts || { success: 0, failed: 0, pending: 0 };
const set = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = String(val);
};
set('c2-tasks-stat-total', total);
set('c2-tasks-stat-success', counts.success || 0);
set('c2-tasks-stat-failed', counts.failed || 0);
set('c2-tasks-stat-pending', counts.pending || 0);
summary.hidden = total === 0;
};
C2.loadSessionTasks = function(sessionId) {
apiRequest('GET', `${API_BASE}/tasks?session_id=${encodeURIComponent(sessionId)}&limit=50`).then(data => {
const container = document.getElementById('c2-session-tasks-list');
@@ -2941,7 +3429,7 @@
}
if (C2.tasks.length === 0) {
container.innerHTML = '<div class="c2-empty">' + escapeHtml(c2t('c2.tasks.emptyAll')) + '</div>';
container.innerHTML = '<div class="c2-tasks-empty">' + escapeHtml(c2t('c2.tasks.emptyAll')) + '</div>';
if (selAll) selAll.disabled = true;
C2.syncTasksToolbar();
return;
@@ -2949,59 +3437,76 @@
if (selAll) selAll.disabled = false;
const delTitle = escapeHtml(c2t('c2.tasks.deleteOne'));
const cancelTitle = escapeHtml(c2t('c2.tasks.cancelBtn'));
const dash = '<span class="c2-tasks-cell-muted">—</span>';
const deleteIcon = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
container.innerHTML = `
<table class="c2-task-table">
<table class="c2-tasks-table">
<thead>
<tr>
<th class="c2-task-table-col-check"></th>
<th>${escapeHtml(c2t('c2.tasks.colTask'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colSession'))}</th>
<th class="c2-tasks-table-col-check">
<label class="c2-task-check-label" title="${escapeHtml(c2t('c2.tasks.selectAll'))}">
<input type="checkbox" id="c2-tasks-select-all" onchange="C2.onTasksSelectAll(this.checked)">
</label>
</th>
<th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colType'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colCommand'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colSession'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colTask'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colDuration'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th>
<th class="c2-task-table-col-actions">${escapeHtml(c2t('c2.tasks.colActions'))}</th>
<th class="c2-tasks-table-col-actions">${escapeHtml(c2t('c2.tasks.colActions'))}</th>
</tr>
</thead>
<tbody>
${C2.tasks.map(t => {
const rawId = t.id || '';
const shortTaskId = rawId.length > 14 ? escapeHtml(rawId.substring(0, 12)) + '\u2026' : escapeHtml(rawId);
const sid = t.sessionId ? escapeHtml(String(t.sessionId).substring(0, 8)) + '\u2026' : '-';
const tid = escapeHtml(rawId);
const status = String(t.status || '');
const rowStatus = escapeHtml(status || 'queued');
const typeCat = taskTypeCategory(t.taskType);
const sessionFull = t.sessionId ? String(t.sessionId) : '';
const sessionShort = sessionFull
? escapeHtml(sessionFull.substring(0, 10)) + (sessionFull.length > 10 ? '\u2026' : '')
: '';
const taskShort = rawId
? escapeHtml(rawId.substring(0, 10)) + (rawId.length > 10 ? '\u2026' : '')
: '';
const cmd = formatTaskCommand(t);
const cmdShort = truncateCommand(cmd, 48);
const cmdEsc = escapeHtml(cmd);
const canCancel = status === 'queued' || status === 'sent';
return `
<tr>
<td class="c2-task-table-col-check">
<label class="c2-task-check-label" onclick="event.stopPropagation();">
<input type="checkbox" class="c2-task-row-check" data-id="${escapeHtml(rawId)}" onchange="C2.syncTasksToolbar()">
<tr class="c2-tasks-row c2-tasks-row--${rowStatus}" data-task-id="${tid}" onclick="C2.viewTask('${tid}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();C2.viewTask('${tid}')}" role="button" tabindex="0">
<td class="c2-tasks-table-col-check" onclick="event.stopPropagation();">
<label class="c2-task-check-label">
<input type="checkbox" class="c2-task-row-check" data-id="${tid}" onchange="C2.syncTasksToolbar()">
</label>
</td>
<td>${shortTaskId}</td>
<td>${sid}</td>
<td>${escapeHtml(t.taskType || '')}</td>
<td class="c2-task-command-cell" title="${escapeHtml(cmd)}">${cmdShort ? escapeHtml(cmdShort) : '<span class="c2-muted">-</span>'}</td>
<td><span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></td>
<td>${formatDuration(t.durationMs)}</td>
<td>${formatTime(t.createdAt)}</td>
<td class="c2-task-table-col-actions">
<div class="c2-task-table-actions">
<button type="button" class="btn-secondary btn-small" data-c2-task-action="view" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.view'))}</button>
${t.status === 'queued' || t.status === 'sent'
? `<button type="button" class="btn-danger btn-small" data-c2-task-action="cancel" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
: ''}
<button type="button" class="btn-danger btn-small" data-c2-task-action="delete" data-task-id="${escapeHtml(rawId)}" title="${delTitle}" aria-label="${delTitle}">${escapeHtml(c2t('c2.tasks.deleteBtn'))}</button>
<td class="c2-tasks-col-time">${escapeHtml(formatTime(t.createdAt))}</td>
<td><span class="c2-status-badge ${escapeHtml(status)}">${escapeHtml(taskStatusLabel(status))}</span></td>
<td><span class="c2-task-type-badge c2-task-type-badge--${typeCat}">${escapeHtml(t.taskType || '-')}</span></td>
<td class="c2-tasks-col-command" title="${cmdEsc}">${cmdEsc || dash}</td>
<td class="c2-tasks-col-mono" title="${escapeHtml(sessionFull)}">${sessionShort || dash}</td>
<td class="c2-tasks-col-mono" title="${tid}">${taskShort || dash}</td>
<td class="c2-tasks-col-duration">${formatDuration(t.durationMs)}</td>
<td class="c2-tasks-table-col-actions" onclick="event.stopPropagation();">
<div class="c2-tasks-row-actions">
${canCancel
? `<button type="button" class="c2-tasks-cancel-btn" data-c2-task-action="cancel" data-task-id="${tid}" title="${cancelTitle}" aria-label="${cancelTitle}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
: ''}
<button type="button" class="c2-tasks-delete-btn" data-require-permission="c2:delete" data-c2-task-action="delete" data-task-id="${tid}" title="${delTitle}" aria-label="${delTitle}">${deleteIcon}</button>
</div>
</td>
</tr>
`;
</tr>`;
}).join('')}
</tbody>
</table>
`;
C2.syncTasksToolbar();
if (typeof applyTranslations === 'function') applyTranslations(container);
if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container);
};
C2.viewTask = function(id) {