added awareness training campaign mode, report, template mode, and stats

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-08-15 00:12:51 +02:00
1 parent 6804710d3c
commit a7fbd573f6
37 files changed
+1540 -233

No files matched your search

+8 -4
View File
@@ -1130,7 +1130,8 @@ export class API {
urlPath: urlPath,
lureURLMode,
lureCodeAlgo,
lureCodeLength
lureCodeLength,
isTraining
}) => {
return await postJSON(this.getPath('/campaign/template'), {
name: name,
@@ -1151,7 +1152,8 @@ export class API {
urlPath: urlPath,
lureURLMode: lureURLMode,
lureCodeAlgo: lureCodeAlgo,
lureCodeLength: lureCodeLength
lureCodeLength: lureCodeLength,
isTraining: isTraining
});
},
@@ -1201,7 +1203,8 @@ export class API {
urlPath: urlPath,
lureURLMode,
lureCodeAlgo,
lureCodeLength
lureCodeLength,
isTraining
}) => {
return await postJSON(this.getPath(`/campaign/template/${id}`), {
name: name,
@@ -1222,7 +1225,8 @@ export class API {
urlPath: urlPath,
lureURLMode: lureURLMode,
lureCodeAlgo: lureCodeAlgo,
lureCodeLength: lureCodeLength
lureCodeLength: lureCodeLength,
isTraining: isTraining
});
},
@@ -497,7 +497,8 @@
? `${formatDateString(new Date(campaign.createdAt))} - ?`
: `${formatDateString(campaign.start)} - ${formatDateString(campaign.end)}`;
let tooltip = `${campaign.name} - ${status}, ${dateRange}`;
const kind = campaign.isTraining ? 'Training - ' : '';
let tooltip = `${campaign.name} - ${kind}${status}, ${dateRange}`;
if (showCompany && campaign.company?.name) {
tooltip = `[${campaign.company.name}] ${tooltip}`;
@@ -685,6 +686,19 @@
</span>
</button>
{/each}
<span class="flex items-center text-xs text-gray-700 dark:text-gray-300">
<svg
class="h-3.5 w-3.5 mr-1 text-training-completed"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M11.7 2.805a.75.75 0 0 1 .6 0A60.65 60.65 0 0 1 22.83 8.72a.75.75 0 0 1-.231 1.337 49.948 49.948 0 0 0-9.902 3.912l-.003.002c-.114.06-.227.119-.34.18a.75.75 0 0 1-.707 0A50.88 50.88 0 0 0 7.5 12.173v-.224c0-.131.067-.248.172-.311a54.6 54.6 0 0 1 4.653-2.52.75.75 0 0 0-.65-1.352 56.1 56.1 0 0 0-4.78 2.589 1.858 1.858 0 0 0-.859 1.228 49.8 49.8 0 0 0-4.634-1.527.75.75 0 0 1-.231-1.337A60.65 60.65 0 0 1 11.7 2.805Z"
/>
</svg>
Training
</span>
</div>
</div>
@@ -832,9 +846,22 @@
</div>
{/if}
<div
class="campaign-name truncate text-[10px] sm:text-xs leading-snug font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100"
class="campaign-name flex items-center gap-1 truncate text-[10px] sm:text-xs leading-snug font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100"
>
{truncateText(campaign.name, 24)}
{#if campaign.isTraining}
<svg
class="flex-shrink-0 h-3 w-3 text-training-completed"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-label="Training"
>
<path
d="M11.7 2.805a.75.75 0 0 1 .6 0A60.65 60.65 0 0 1 22.83 8.72a.75.75 0 0 1-.231 1.337 49.948 49.948 0 0 0-9.902 3.912l-.003.002c-.114.06-.227.119-.34.18a.75.75 0 0 1-.707 0A50.88 50.88 0 0 0 7.5 12.173v-.224c0-.131.067-.248.172-.311a54.6 54.6 0 0 1 4.653-2.52.75.75 0 0 0-.65-1.352 56.1 56.1 0 0 0-4.78 2.589 1.858 1.858 0 0 0-.859 1.228 49.8 49.8 0 0 0-4.634-1.527.75.75 0 0 1-.231-1.337A60.65 60.65 0 0 1 11.7 2.805Z"
/>
</svg>
{/if}
<span class="truncate">{truncateText(campaign.name, 24)}</span>
</div>
</div>
</a>
@@ -34,6 +34,7 @@
const MOVING_AVG_N_KEY = 'campaignTrend.movingAvgN';
const LOG_SCALE_KEY = 'campaignTrend.useLogScale';
const RELATIVE_METRICS_KEY = 'campaignTrend.useRelativeMetrics';
const CHART_MODE_KEY = 'campaignTrend.chartMode';
let chartContainer;
let sizingContainer;
@@ -204,6 +205,9 @@
let xScale, yScale;
let useLogScale = false;
let useRelativeMetrics = false;
// 'phishing' shows the read/click/submission/report funnel, 'training' shows the
// awareness training funnel. Only offered when training campaigns exist.
let chartMode = 'phishing';
// load saved settings from localStorage
try {
@@ -224,6 +228,26 @@
// ignore errors
}
try {
const storedChartMode = localStorage.getItem(CHART_MODE_KEY);
if (storedChartMode === 'training' || storedChartMode === 'phishing') {
chartMode = storedChartMode;
}
} catch (e) {
// ignore errors
}
// the training mode is only meaningful when there are training campaigns
$: hasTraining = (campaignStats || []).some((s) => s.isTraining);
$: if (!hasTraining && chartMode !== 'phishing') {
chartMode = 'phishing';
}
$: try {
localStorage.setItem(CHART_MODE_KEY, chartMode);
} catch (e) {
// ignore errors
}
// Time range filter for campaigns
const timeRanges = [
{ label: 'Last 3 months', value: '3' },
@@ -261,12 +285,48 @@
});
})();
const metrics = [
// a metric with a `mavg` also renders a dashed moving-average line and legend toggle
const phishingMetrics = [
{ key: 'openRate', label: 'Read Rate', color: '#4cb5b5', suffix: '%' },
{ key: 'clickRate', label: 'Click Rate', color: '#f96dcf', suffix: '%' },
{ key: 'submissionRate', label: 'Submission Rate', color: '#f42e41', suffix: '%' },
{ key: 'reportRate', label: 'Report Rate', color: '#1e40af', suffix: '%' }
{
key: 'clickRate',
label: 'Click Rate',
color: '#f96dcf',
suffix: '%',
mavg: { color: '#eea5fa', label: 'Click MA' }
},
{
key: 'submissionRate',
label: 'Submission Rate',
color: '#f42e41',
suffix: '%',
mavg: { color: '#ff6a91', label: 'Submit MA' }
},
{
key: 'reportRate',
label: 'Report Rate',
color: '#1e40af',
suffix: '%',
mavg: { color: '#60a5fa', label: 'Report MA' }
}
];
const trainingMetrics = [
{
key: 'startedRate',
label: 'Started Rate',
color: '#5b93e6',
suffix: '%',
mavg: { color: '#93c5fd', label: 'Started MA' }
},
{
key: 'completionRate',
label: 'Completion Rate',
color: '#2fa968',
suffix: '%',
mavg: { color: '#69e1ab', label: 'Completed MA' }
}
];
$: metrics = chartMode === 'training' ? trainingMetrics : phishingMetrics;
// toggle metric visibility (reassign to trigger svelte reactivity and persist)
function toggleMetric(metricKey) {
@@ -281,14 +341,12 @@
const n = Math.min(trendN, chartData.length);
if (n === 0) return null;
const slice = chartData.slice(-n);
const avg = (arr, key) => arr.reduce((sum, d) => sum + (d[key] || 0), 0) / n;
return {
n,
openRate: avg(slice, 'openRate'),
clickRate: avg(slice, 'clickRate'),
submissionRate: avg(slice, 'submissionRate'),
reportRate: avg(slice, 'reportRate')
};
const avg = (key) => slice.reduce((sum, d) => sum + (d[key] || 0), 0) / n;
const out = { n };
for (const m of metrics) {
out[m.key] = avg(m.key);
}
return out;
})();
// --- Force chart rerender ---
@@ -298,9 +356,12 @@
.join('-');
// --- Data processing ---
function processData(stats, useRelativeMetrics) {
function processData(stats, useRelativeMetrics, mode) {
// phishing and training campaigns are different sets with different funnels,
// so the chart shows one at a time based on the selected mode
const wantTraining = mode === 'training';
const sortedStats = [...(stats || [])]
.filter((stat) => stat.campaignClosedAt)
.filter((stat) => stat.campaignClosedAt && !!stat.isTraining === wantTraining)
.sort(
(a, b) => new Date(a.campaignClosedAt).getTime() - new Date(b.campaignClosedAt).getTime()
);
@@ -310,25 +371,39 @@
return d > 0 ? Math.round((n / d) * 1000) / 10 : 0;
}
return sortedStats.map((stat, index) => ({
index: index + 1,
date: stat.campaignClosedAt ? new Date(stat.campaignClosedAt) : null,
name: stat.campaignName || `Campaign ${index + 1}`,
campaignId: stat.campaignId,
openRate: pct(stat.trackingPixelLoaded, stat.totalRecipients),
clickRate: useRelativeMetrics
? pct(stat.websiteVisits, stat.trackingPixelLoaded)
: pct(stat.websiteVisits, stat.totalRecipients),
submissionRate: useRelativeMetrics
? pct(stat.dataSubmissions, stat.websiteVisits)
: pct(stat.dataSubmissions, stat.totalRecipients),
reportRate: pct(stat.reported, stat.totalRecipients),
totalRecipients: stat.totalRecipients
}));
return sortedStats.map((stat, index) => {
const base = {
index: index + 1,
date: stat.campaignClosedAt ? new Date(stat.campaignClosedAt) : null,
name: stat.campaignName || `Campaign ${index + 1}`,
campaignId: stat.campaignId,
totalRecipients: stat.totalRecipients
};
if (wantTraining) {
return {
...base,
startedRate: pct(stat.trainingStarted, stat.totalRecipients),
completionRate: useRelativeMetrics
? pct(stat.trainingCompleted, stat.trainingStarted)
: pct(stat.trainingCompleted, stat.totalRecipients)
};
}
return {
...base,
openRate: pct(stat.trackingPixelLoaded, stat.totalRecipients),
clickRate: useRelativeMetrics
? pct(stat.websiteVisits, stat.trackingPixelLoaded)
: pct(stat.websiteVisits, stat.totalRecipients),
submissionRate: useRelativeMetrics
? pct(stat.dataSubmissions, stat.websiteVisits)
: pct(stat.dataSubmissions, stat.totalRecipients),
reportRate: pct(stat.reported, stat.totalRecipients)
};
});
}
// Use filtered campaign stats based on selected time range and relative metrics toggle
$: chartData = processData(filteredCampaignStats, useRelativeMetrics);
// Use filtered campaign stats based on selected time range, relative metrics and mode
$: chartData = processData(filteredCampaignStats, useRelativeMetrics, chartMode);
function createChart() {
if (!chartContainer || chartData.length < 2) return;
@@ -385,10 +460,9 @@
});
}
});
// Only draw moving average for clickRate, submissionRate, and reportRate that are visible
['clickRate', 'submissionRate', 'reportRate'].forEach((metricKey) => {
const metric = metrics.find((m) => m.key === metricKey);
if (metric && visibleMetrics[`mavg-${metricKey}`]) {
// draw the moving average for any metric that supports one and is toggled on
metrics.forEach((metric) => {
if (metric.mavg && visibleMetrics[`mavg-${metric.key}`]) {
createMovingAverageLine(svg, metric, movingAvgN);
}
});
@@ -456,15 +530,8 @@
if (started) {
path.setAttribute('d', pathData);
path.setAttribute('fill', 'none');
// Use a lighter shade of the metric color for moving average
let avgColor = metric.color;
if (metric.key === 'openRate') {
avgColor = '#93c5fd'; // light blue
} else if (metric.key === 'submissionRate') {
avgColor = '#ff6a91'; // lighter red, closer to #f42e41
} else if (metric.key === 'reportRate') {
avgColor = '#60a5fa'; // lighter blue for report rate
}
// lighter shade defined on the metric, falling back to the metric color
const avgColor = (metric.mavg && metric.mavg.color) || metric.color;
path.setAttribute('stroke', avgColor);
path.setAttribute('stroke-width', '1.2');
path.setAttribute('stroke-dasharray', '6,4');
@@ -822,29 +889,12 @@
strokeDasharray: null,
opacity: 1
});
if (
metric.key === 'clickRate' ||
metric.key === 'submissionRate' ||
metric.key === 'reportRate'
) {
// Use a lighter version of the main color for moving averages
let avgColor = metric.color;
let avgLabel = '';
if (metric.key === 'clickRate') {
avgColor = '#eea5fa'; // before-page-visited, lighter pink
avgLabel = 'Click MA';
} else if (metric.key === 'submissionRate') {
avgColor = '#ff6a91'; // lighter red, closer to #f42e41
avgLabel = 'Submit MA';
} else if (metric.key === 'reportRate') {
avgColor = '#60a5fa'; // lighter blue for report rate
avgLabel = 'Report MA';
}
if (metric.mavg) {
legendItems.push({
type: 'mavg',
key: metric.key,
label: avgLabel,
color: avgColor,
label: metric.mavg.label,
color: metric.mavg.color,
class: `legend-line legend-mavg legend-mavg-${metric.key}`,
labelClass: `legend-label legend-mavg legend-mavg-${metric.key}`,
dataMetric: `mavg-${metric.key}`,
@@ -1226,6 +1276,7 @@
// reference toggles so Svelte tracks them for reactivity
useLogScale;
useRelativeMetrics;
chartMode;
createChart();
}
}
@@ -1319,6 +1370,30 @@
Trendline: Last {trendStats ? trendStats.n : campaignStats.length} Campaigns (average)
</h4>
<div class="flex flex-wrap items-center gap-2 mb-0">
{#if hasTraining}
<div
class="flex items-center rounded overflow-hidden border border-gray-300 dark:border-gray-600 text-xs"
>
<button
type="button"
class="px-2 py-0.5 transition-colors duration-200 {chartMode === 'phishing'
? 'bg-cta-blue text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'}"
on:click={() => (chartMode = 'phishing')}
>
Phishing
</button>
<button
type="button"
class="px-2 py-0.5 transition-colors duration-200 {chartMode === 'training'
? 'bg-training-completed text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'}"
on:click={() => (chartMode = 'training')}
>
Training
</button>
</div>
{/if}
<label
class="flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 transition-colors duration-200"
>
@@ -1383,7 +1458,10 @@
</div>
<div class="mb-8"></div>
{#if campaignStats.length > 0}
<div class="grid grid-cols-4 gap-2 sm:gap-4">
<div
class="grid gap-2 sm:gap-4"
style="grid-template-columns: repeat({metrics.length}, minmax(0, 1fr));"
>
{#each metrics as metric}
<div class="text-center">
<div class="flex items-center justify-center">
@@ -26,6 +26,8 @@
campaign_recipient_deny_page_visited: '#ff6b35',
campaign_recipient_submitted_data: '#f42e41',
campaign_recipient_reported: '#2c3e50',
campaign_recipient_training_started: '#5b93e6',
campaign_recipient_training_completed: '#2fa968',
campaign_recipient_info: '#94cae6'
});
@@ -62,6 +64,10 @@
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
campaign_recipient_reported:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.072 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>',
campaign_recipient_training_started:
'<svg fill="currentColor" viewBox="0 0 24 24"><path d="M11.7 2.805a.75.75 0 0 1 .6 0A60.65 60.65 0 0 1 22.83 8.72a.75.75 0 0 1-.231 1.337 49.949 49.949 0 0 0-9.902 3.912l-.003.002a48.9 48.9 0 0 0-.34.18.75.75 0 0 1-.707 0A50.88 50.88 0 0 0 7.5 12.173v-.224c0-.131.067-.248.172-.311a54.615 54.615 0 0 1 4.653-2.52.75.75 0 0 0-.65-1.352 56.123 56.123 0 0 0-4.78 2.589 1.858 1.858 0 0 0-.859 1.228 49.803 49.803 0 0 0-4.634-1.527.75.75 0 0 1-.231-1.337A60.653 60.653 0 0 1 11.7 2.805Z"/></svg>',
campaign_recipient_training_completed:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
campaign_recipient_info:
'<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'
});
@@ -130,6 +136,8 @@
campaign_recipient_deny_page_visited: true,
campaign_recipient_submitted_data: true,
campaign_recipient_reported: true,
campaign_recipient_training_started: true,
campaign_recipient_training_completed: true,
campaign_recipient_info: true
};
+1 -1
View File
@@ -43,7 +43,7 @@
</script>
<div
class="bg-white dark:bg-gray-900/80 p-6 rounded-lg shadow-md dark:shadow-none border-l-[12px] {borderColor} hover:shadow-lg dark:hover:shadow-none transition-all duration-200 dark:ring-1 dark:ring-gray-600/30"
class="h-full bg-white dark:bg-gray-900/80 p-6 rounded-lg shadow-md dark:shadow-none border-l-[12px] {borderColor} hover:shadow-lg dark:hover:shadow-none transition-all duration-200 dark:ring-1 dark:ring-gray-600/30"
>
<div
class="text-grayblue-dark dark:text-gray-500 text-sm font-semibold transition-colors duration-200"
@@ -0,0 +1,6 @@
<span
title="Awareness training"
class="select-none px-1 border-2 border-training-completed relative -top-1 rounded-lg text-xs text-training-completed bg-white dark:bg-gray-800 transition-colors duration-200"
>
training
</span>
+12
View File
@@ -41,6 +41,18 @@ export const eventNameMap = {
priority: 95,
color: 'bg-reported'
},
// the milestones outrank the raw page visits they are recorded on top of, as
// they do in the backend priorities
campaign_recipient_training_started: {
name: 'Training Started',
priority: 72,
color: 'bg-training-started'
},
campaign_recipient_training_completed: {
name: 'Training Completed',
priority: 75,
color: 'bg-training-completed'
},
campaign_recipient_info: {
name: 'Info',
priority: 25,
@@ -33,6 +33,8 @@
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import { page } from '$app/stores';
import TableCellCheckbox from '$lib/components/table/TableCellCheckbox.svelte';
import CheckboxField from '$lib/components/CheckboxField.svelte';
import TrainingLabel from '$lib/components/TrainingLabel.svelte';
import BulkActionBar from '$lib/components/table/BulkActionBar.svelte';
import {
createTableSelection,
@@ -69,7 +71,8 @@
urlPath: '',
lureURLMode: 'query',
lureCodeAlgo: 'crockford32',
lureCodeLength: 12
lureCodeLength: 12,
isTraining: false
};
// codes are matched exactly, so the choice is about which glyphs appear and
@@ -412,6 +415,7 @@
lureURLMode: formValues.lureURLMode,
lureCodeAlgo: formValues.lureCodeAlgo,
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength),
isTraining: formValues.isTraining,
companyID: contextCompanyID
});
if (!res.success) {
@@ -454,7 +458,8 @@
urlPath: formValues.urlPath || '',
lureURLMode: formValues.lureURLMode,
lureCodeAlgo: formValues.lureCodeAlgo,
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength)
lureCodeLength: clampLureCodeLength(formValues.lureCodeLength),
isTraining: formValues.isTraining
});
if (!res.success) {
modalError = res.error;
@@ -516,7 +521,8 @@
urlPath: '',
lureURLMode: 'query',
lureCodeAlgo: 'crockford32',
lureCodeLength: 12
lureCodeLength: 12,
isTraining: false
};
modalError = '';
showAdvancedOptions = false;
@@ -632,6 +638,7 @@
formValues.lureURLMode = template.lureURLMode || 'query';
formValues.lureCodeAlgo = template.lureCodeAlgo || 'crockford32';
formValues.lureCodeLength = template.lureCodeLength || 12;
formValues.isTraining = !!template.isTraining;
// set advanced options visibility based on template configuration
showAdvancedOptions = !!(
@@ -712,7 +719,12 @@
title={template.name}
class="block w-full py-1 text-left"
>
{template.name}
<span class="flex items-center gap-2">
{#if template.isTraining}
<TrainingLabel />
{/if}
{template.name}
</span>
</button>
</TableCell>
<TableCell>
@@ -980,6 +992,14 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
>
</div>
</div>
<CheckboxField
id="isTraining"
inline
bind:value={formValues.isTraining}
toolTipText="Tracks training events instead of phishing events."
>
Awareness training
</CheckboxField>
</div>
<!-- Delivery Configuration Section -->
+13 -3
View File
@@ -46,6 +46,7 @@
import TableDropDownButton from '$lib/components/table/TableDropDownButton.svelte';
import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte';
import TestLabel from '$lib/components/TestLabel.svelte';
import TrainingLabel from '$lib/components/TrainingLabel.svelte';
import TableCellCheckbox from '$lib/components/table/TableCellCheckbox.svelte';
import BulkActionBar from '$lib/components/table/BulkActionBar.svelte';
import {
@@ -169,6 +170,8 @@
'campaign_recipient_after_page_visited',
'campaign_recipient_evasion_page_visited',
'campaign_recipient_deny_page_visited',
'campaign_recipient_training_started',
'campaign_recipient_training_completed',
'campaign_closed'
];
@@ -183,7 +186,9 @@
campaign_recipient_before_page_visited: 'Before Page Visited',
campaign_recipient_page_visited: 'Page Visited',
campaign_recipient_after_page_visited: 'After Page Visited',
campaign_recipient_deny_page_visited: 'Deny Page Visited'
campaign_recipient_deny_page_visited: 'Deny Page Visited',
campaign_recipient_training_started: 'Training Started',
campaign_recipient_training_completed: 'Training Completed'
};
// create display options array with nice names
@@ -203,10 +208,12 @@
campaign_recipient_before_page_visited: 1 << 6, // 64
campaign_recipient_page_visited: 1 << 7, // 128
campaign_recipient_after_page_visited: 1 << 8, // 256
campaign_recipient_deny_page_visited: 1 << 9 // 512
campaign_recipient_deny_page_visited: 1 << 9, // 512
campaign_recipient_training_started: 1 << 10, // 1024
campaign_recipient_training_completed: 1 << 11 // 2048
};
const WEBHOOK_EVENT_ALL_BITS = 1023; // 2^10 - 1, all 10 events selected
const WEBHOOK_EVENT_ALL_BITS = 4095; // 2^12 - 1, all 12 events selected
// convert array of event names to bitwise int
// if all events are selected, return 0 to preserve the "all events" semantic
@@ -1613,6 +1620,9 @@
{#if campaign.isTest}
<TestLabel />
{/if}
{#if campaign.isTraining}
<TrainingLabel />
{/if}
{campaign.name}
</TableCellLink>
<TableCell>
+166 -34
View File
@@ -78,6 +78,7 @@
saveSubmittedData: false,
saveBrowserMetadata: false,
isAnonymous: false,
isTraining: false,
scheduleAt: null,
allowDenyIDs: [],
@@ -113,7 +114,9 @@
trackingPixelLoaded: 0,
websiteLoaded: 0,
submittedData: 0,
reported: 0
reported: 0,
trainingStarted: 0,
trainingCompleted: 0
};
// @ts-ignore
const recipientTableUrlParams = newTableURLParams({
@@ -320,6 +323,7 @@
campaign.closeAt = t.closeAt;
campaign.closedAt = t.closedAt;
campaign.isTest = t.isTest;
campaign.isTraining = t.isTraining;
campaign.constraintWeekDays = t.constraintWeekDays;
campaign.constraintStartTime = t.constraintStartTime;
campaign.constraintEndTime = t.constraintEndTime;
@@ -509,6 +513,8 @@
result.websiteLoaded = res.data.clickedLink;
result.submittedData = res.data.submittedData;
result.reported = res.data.reported;
result.trainingStarted = res.data.trainingStarted;
result.trainingCompleted = res.data.trainingCompleted;
} catch (e) {
addToast('Failed to load campaign result stats', 'Error');
console.error('failed to load campaign result stats', e);
@@ -1640,29 +1646,70 @@
</svg>
</StatsCard>
<StatsCard
title="Website Visits"
value={result.websiteLoaded}
borderColor="border-page-visited"
iconColor="text-page-visited"
percentages={[
{
value: Math.round((result.websiteLoaded / result.recipients) * 100),
relativeTo: 'of recipients',
baseValue: result.recipients
},
{
value: Math.round((result.websiteLoaded / result.emailsSent) * 100),
relativeTo: 'of sent',
baseValue: result.emailsSent
},
{
value: Math.round((result.websiteLoaded / result.trackingPixelLoaded) * 100),
relativeTo: 'of reads',
baseValue: result.trackingPixelLoaded
}
]}
>
{#if campaign.isTraining}
<StatsCard
title="Training Started"
value={result.trainingStarted}
borderColor="border-training-started"
iconColor="text-training-started"
percentages={[
{
value: Math.round((result.trainingStarted / result.recipients) * 100),
relativeTo: 'of recipients',
baseValue: result.recipients
},
{
value: Math.round((result.trainingStarted / result.emailsSent) * 100),
relativeTo: 'of sent',
baseValue: result.emailsSent
},
{
value: Math.round((result.trainingStarted / result.trackingPixelLoaded) * 100),
relativeTo: 'of reads',
baseValue: result.trackingPixelLoaded
}
]}
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 ml-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.26 10.147a60.436 60.436 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342M6.75 15a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0v-3.675A55.378 55.378 0 0112 8.443m-7.007 11.55A5.981 5.981 0 006.75 15.75v-1.5"
/>
</svg>
</StatsCard>
{:else}
<StatsCard
title="Website Visits"
value={result.websiteLoaded}
borderColor="border-page-visited"
iconColor="text-page-visited"
percentages={[
{
value: Math.round((result.websiteLoaded / result.recipients) * 100),
relativeTo: 'of recipients',
baseValue: result.recipients
},
{
value: Math.round((result.websiteLoaded / result.emailsSent) * 100),
relativeTo: 'of sent',
baseValue: result.emailsSent
},
{
value: Math.round((result.websiteLoaded / result.trackingPixelLoaded) * 100),
relativeTo: 'of reads',
baseValue: result.trackingPixelLoaded
}
]}
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
@@ -1680,11 +1727,47 @@
</svg>
</StatsCard>
<StatsCard
title="Data Submitted"
value={result.submittedData}
borderColor="border-submitted-data"
iconColor="text-submitted-data"
{/if}
{#if campaign.isTraining}
<StatsCard
title="In progress"
value={Math.max(result.trainingStarted - result.trainingCompleted, 0)}
borderColor="border-training-started"
iconColor="text-training-started"
percentages={[
{
value: Math.round(
((result.trainingStarted - result.trainingCompleted) / result.trainingStarted) *
100
),
relativeTo: 'of started',
baseValue: result.trainingStarted
}
]}
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 ml-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</StatsCard>
{:else}
<StatsCard
title="Data Submitted"
value={result.submittedData}
borderColor="border-submitted-data"
iconColor="text-submitted-data"
percentages={[
{
value: Math.round((result.submittedData / result.recipients) * 100),
@@ -1725,11 +1808,59 @@
</svg>
</StatsCard>
<StatsCard
title="Reported"
value={result.reported}
borderColor="border-reported"
iconColor="text-reported"
{/if}
{#if campaign.isTraining}
<StatsCard
title="Training Completed"
value={result.trainingCompleted}
borderColor="border-training-completed"
iconColor="text-training-completed"
percentages={[
{
value: Math.round((result.trainingCompleted / result.recipients) * 100),
relativeTo: 'of recipients',
baseValue: result.recipients
},
{
value: Math.round((result.trainingCompleted / result.emailsSent) * 100),
relativeTo: 'of sent',
baseValue: result.emailsSent
},
{
value: Math.round((result.trainingCompleted / result.trackingPixelLoaded) * 100),
relativeTo: 'of reads',
baseValue: result.trackingPixelLoaded
},
{
value: Math.round((result.trainingCompleted / result.trainingStarted) * 100),
relativeTo: 'of started',
baseValue: result.trainingStarted
}
]}
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 ml-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</StatsCard>
{:else}
<StatsCard
title="Reported"
value={result.reported}
borderColor="border-reported"
iconColor="text-reported"
percentages={[
{
value: Math.round((result.reported / result.recipients) * 100),
@@ -1769,6 +1900,7 @@
/>
</svg>
</StatsCard>
{/if}
</div>
<div class=" mb-6">
<SubHeadline>Event Timeline</SubHeadline>
+38 -5
View File
@@ -42,6 +42,14 @@
let calendarCampaigns = [];
let campaignStats = [];
// training completion rate across closed training campaigns, derived from the
// campaign stats snapshots already loaded for the trend chart
$: trainingStats = (campaignStats || []).filter((s) => s.isTraining);
$: trainingStarted = trainingStats.reduce((sum, s) => sum + (s.trainingStarted || 0), 0);
$: trainingCompleted = trainingStats.reduce((sum, s) => sum + (s.trainingCompleted || 0), 0);
$: trainingCompletionRate =
trainingStarted > 0 ? Math.round((trainingCompleted / trainingStarted) * 100) : 0;
let isCampaignStatsLoading = true; // start as true to show ghost on initial load
let calendarStartDate = null;
@@ -252,8 +260,8 @@
<SubHeadline>{contextCompanyName}</SubHeadline>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 mt-4">
<a href="/dashboard/campaigns">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8 mt-4">
<a href="/dashboard/campaigns" class="block h-full">
<StatsCard
title="Active campaigns"
value={active}
@@ -278,7 +286,7 @@
</StatsCard>
</a>
<a href="/dashboard/campaigns">
<a href="/dashboard/campaigns" class="block h-full">
<StatsCard
title="Upcoming campaigns"
value={scheduled}
@@ -303,7 +311,7 @@
</StatsCard>
</a>
<a href="/dashboard/campaigns">
<a href="/dashboard/campaigns" class="block h-full">
<StatsCard
title="Completed campaigns"
value={finished}
@@ -328,7 +336,7 @@
</StatsCard>
</a>
<a href="/recipient">
<a href="/recipient" class="block h-full">
<StatsCard
title="Repeat offenders"
value={repeatOffenders}
@@ -352,6 +360,31 @@
</svg>
</StatsCard>
</a>
<a href="/campaign" class="block h-full">
<StatsCard
title="Trainings completed"
value={trainingCompleted}
borderColor="border-training-completed"
iconColor="text-training-completed"
>
<svg
slot="icon"
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.26 10.147a60.436 60.436 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342M6.75 15a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0v-3.675A55.378 55.378 0 0112 8.443m-7.007 11.55A5.981 5.981 0 006.75 15.75v-1.5"
/>
</svg>
</StatsCard>
</a>
</div>
<SubHeadline>{contextCompanyName ? 'Campaign Trends' : 'Shared Campaign Trends'}</SubHeadline>
@@ -39,7 +39,9 @@
campaignsTrackingPixelLoaded: 0,
campaignsPhishingPageLoaded: 0,
campaignsDataSubmitted: 0,
campaignsReported: 0
campaignsReported: 0,
trainingsAssigned: 0,
trainingsCompleted: 0
};
let isGroupsLoading = false;
let isEventsLoading = false;
@@ -158,7 +160,7 @@
{/if}
<BigButton on:click={onClickExport}>Export events</BigButton>
<div>
<div class="grid mr-1/12 md:grid-cols-2 lg:grid-cols-7 gap-6 mb-8 mt-4">
<div class="grid grid-cols-1 lg:grid-cols-7 gap-6 mb-8 mt-4">
<!-- Campaigns card -->
<StatsCard
title="Campaigns"
@@ -333,6 +335,14 @@
</svg>
</StatsCard>
</div>
{#if stats.trainingsAssigned > 0}
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 mb-8 text-sm">
<span class="font-semibold text-grayblue-dark dark:text-gray-400">Training</span>
<span class="text-grayblue-dark dark:text-gray-400">Assigned <span class="font-semibold text-pc-darkblue dark:text-gray-200">{stats.trainingsAssigned}</span></span>
<span class="text-grayblue-dark dark:text-gray-400">Completed <span class="font-semibold text-training-completed">{stats.trainingsCompleted}</span></span>
</div>
{/if}
<SubHeadline>Groups</SubHeadline>
<Table
columns={['Name']}
@@ -30,6 +30,9 @@
let reportTemplateID = null;
let reportTemplateError = '';
let isReportTemplateSubmitting = false;
// the phishing and training reports are separate templates; edit one at a time
let reportRows = [];
let reportKind = 'phishing'; // 'phishing' | 'training'
onMount(async () => {
try {
@@ -90,15 +93,11 @@
const openReportTemplateModal = async () => {
try {
showIsLoading();
reportTemplateContent = '';
reportTemplateID = null;
reportTemplateError = '';
reportKind = 'phishing';
const response = await api.reportTemplate.getAll(null);
if (response.success && response.data?.rows?.length > 0) {
const tmpl = response.data.rows[0];
reportTemplateContent = tmpl.content || '';
reportTemplateID = tmpl.id || null;
}
reportRows = response.success ? response.data?.rows || [] : [];
selectReportKind('phishing');
} catch (error) {
console.error('Failed to load report template:', error);
reportTemplateError = 'Failed to load template';
@@ -108,6 +107,25 @@
}
};
// loads the cached template row for the selected kind into the editor
const selectReportKind = (kind) => {
reportKind = kind;
reportTemplateError = '';
const row = reportRows.find((r) => !!r.isTraining === (kind === 'training'));
reportTemplateContent = row?.content || '';
reportTemplateID = row?.id || null;
};
// re-fetch the templates after a save so the active kind keeps its id
const refreshReportRows = async () => {
const response = await api.reportTemplate.getAll(null);
reportRows = response.success ? response.data?.rows || [] : [];
const row = reportRows.find((r) => !!r.isTraining === (reportKind === 'training'));
if (row?.id) {
reportTemplateID = row.id;
}
};
const closeReportTemplateModal = () => {
isReportTemplateModalVisible = false;
reportTemplateError = '';
@@ -124,12 +142,16 @@
content: reportTemplateContent
});
} else {
response = await api.reportTemplate.create({ content: reportTemplateContent });
response = await api.reportTemplate.create({
content: reportTemplateContent,
isTraining: reportKind === 'training'
});
if (response.success && response.data?.id) {
reportTemplateID = response.data.id;
}
}
if (response.success) {
await refreshReportRows();
addToast('Report template saved', 'Success');
if (!saveOnly) {
isReportTemplateModalVisible = false;
@@ -219,6 +241,31 @@
<div
class="w-80vw col-start-1 col-end-4 row-start-1 py-8 px-6 flex flex-col bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
>
<div class="flex items-center gap-2 mb-4">
<span class="text-sm font-semibold text-gray-600 dark:text-gray-300">Report for</span>
<div
class="flex items-center rounded overflow-hidden border border-gray-300 dark:border-gray-600 text-sm"
>
<button
type="button"
class="px-3 py-1 transition-colors duration-200 {reportKind === 'phishing'
? 'bg-cta-blue text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
on:click={() => selectReportKind('phishing')}
>
Phishing
</button>
<button
type="button"
class="px-3 py-1 transition-colors duration-200 {reportKind === 'training'
? 'bg-training-completed text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
on:click={() => selectReportKind('training')}
>
Training
</button>
</div>
</div>
<Editor contentType="report" bind:value={reportTemplateContent} />
<FormError message={reportTemplateError} />
</div>