Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/components/TrafficDashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const mockLogs = [
source_ip: "192.168.1.100",
response_time_ms: 120,
bytes_sent: 1024,
created_at: new Date().toISOString(),
created_at: "2024-01-15T10:00:01Z",
},
{
id: "2",
Expand All @@ -81,7 +81,7 @@ const mockLogs = [
source_ip: "10.0.0.50",
response_time_ms: 2500,
bytes_sent: 512,
created_at: new Date().toISOString(),
created_at: "2024-01-15T10:00:00Z",
},
];

Expand Down Expand Up @@ -249,7 +249,8 @@ describe("TrafficDashboard", () => {
],
};
const wrapper = mountDashboard({ stats: statsWithNoUnknown });
expect(wrapper.text()).not.toContain("Unknown Domains");
const unknownPanel = wrapper.find(".panel-stack .unknown-list");
expect(unknownPanel.exists()).toBe(false);
});
});

Expand Down
163 changes: 162 additions & 1 deletion src/components/TrafficDashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,110 @@
<span>No performance issues detected</span>
</div>
</div>

<!-- Unknown Domains Tab -->
<div v-show="activeSubTab === 'unknown'" class="tab-content">
<div v-if="loadingUnknown && !unknownStats" class="loading-inline">
<i class="pi pi-spin pi-spinner" /> Loading unknown domain stats...
</div>

<template v-else-if="unknownStats">
<div class="stats-grid">
<div class="stat-card" :class="{ error: (unknownStats.total_requests || 0) > 0 }">
<div class="stat-header">
<span class="stat-label">Total Requests</span>
</div>
<span class="stat-value">{{ formatNumber(unknownStats.total_requests || 0) }}</span>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Unique Domains</span>
</div>
<span class="stat-value">{{ unknownStats.top_domains?.length || 0 }}</span>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Unique IPs</span>
</div>
<span class="stat-value">{{ unknownStats.top_ips?.length || 0 }}</span>
</div>
</div>

<div class="two-col">
<div class="panel">
<div class="panel-header">
<h3>Top Unknown Domains</h3>
<span class="count">{{ unknownStats.top_domains?.length || 0 }}</span>
</div>
<div v-if="unknownStats.top_domains?.length" class="deployment-list">
<div
v-for="entry in unknownStats.top_domains"
:key="entry.domain"
class="deployment-row"
@click="navigateToDeploymentLogs(entry.domain)"
>
<div class="dep-main">
<code class="dep-name">{{ entry.domain }}</code>
<span class="dep-stat">Last seen {{ formatLogTime(entry.last_seen) }}</span>
</div>
<div class="dep-stats">
<span class="dep-stat">{{ formatNumber(entry.request_count) }} req</span>
</div>
<i class="pi pi-chevron-right" />
</div>
</div>
<div v-else class="empty-inline">No unknown domain requests</div>
</div>

<div class="panel">
<div class="panel-header">
<h3>Top Source IPs</h3>
<span class="count">{{ unknownStats.top_ips?.length || 0 }}</span>
</div>
<div v-if="unknownStats.top_ips?.length" class="suspicious-list">
<div v-for="entry in unknownStats.top_ips" :key="entry.ip" class="suspicious-row">
<div class="suspicious-info">
<code>{{ entry.ip }}</code>
<div class="unknown-domains-list">
<span v-for="domain in entry.domains.slice(0, 3)" :key="domain" class="tag">
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded number 3 for entry.domains.slice(0, 3) would be more maintainable if defined as a constant within the THRESHOLDS object. This makes it easier to adjust display limits consistently across similar UI elements without searching through the code.

Suggested change
<span v-for="domain in entry.domains.slice(0, 3)" :key="domain" class="tag">
<span v-for="domain in entry.domains.slice(0, THRESHOLDS.MAX_UNKNOWN_DOMAIN_IPS_DISPLAY)" :key="domain" class="tag">

{{ domain }}
</span>
<span v-if="entry.domains.length > 3" class="tag">+{{ entry.domains.length - 3 }}</span>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the previous suggestion, referencing a constant from THRESHOLDS for entry.domains.length - 3 would improve maintainability and consistency.

Suggested change
<span v-if="entry.domains.length > 3" class="tag">+{{ entry.domains.length - 3 }}</span>
<span v-if="entry.domains.length > THRESHOLDS.MAX_UNKNOWN_DOMAIN_IPS_DISPLAY" class="tag">+{{ entry.domains.length - THRESHOLDS.MAX_UNKNOWN_DOMAIN_IPS_DISPLAY }}</span>

</div>
</div>
<div class="suspicious-actions">
<span class="suspicious-stat">{{ formatNumber(entry.request_count) }} req</span>
<button class="btn-action" title="View requests" @click="filterByIP(entry.ip)">
<i class="pi pi-eye" />
</button>
<button class="btn-action danger" title="Block IP" @click="blockIP(entry.ip)">
<i class="pi pi-ban" />
</button>
</div>
</div>
</div>
<div v-else class="empty-inline">No source IP data</div>
</div>
</div>

<div class="unknown-info-panel">
<i class="pi pi-info-circle" />
<div>
<p>These are requests to domains not matching any configured deployment:</p>
<ul>
<li>Reconnaissance attempts probing your server</li>
<li>Misconfigured DNS pointing to your IP</li>
<li>Bots scanning for vulnerable hosts</li>
</ul>
</div>
</div>
</template>

<div v-else class="empty-state-sm">
<i class="pi pi-question-circle" />
<span>No unknown domain data available</span>
</div>
</div>
</div>

<div v-else class="empty-state">
Expand Down Expand Up @@ -470,7 +574,7 @@ import { storeToRefs } from "pinia";
import { useTrafficStore } from "@/stores/traffic";
import { useDeploymentsStore } from "@/stores/deployments";
import { useNotificationsStore } from "@/stores/notifications";
import { securityApi } from "@/services/api";
import { securityApi, trafficApi, type UnknownDomainStats } from "@/services/api";
import ConfirmModal from "@/components/ConfirmModal.vue";

const props = defineProps<{
Expand All @@ -494,6 +598,9 @@ const showBlockIPModal = ref(false);
const ipToBlock = ref("");
const blockingIP = ref(false);

const unknownStats = ref<UnknownDomainStats | null>(null);
const loadingUnknown = ref(false);

// Detection Thresholds - can be moved to backend/config later
const THRESHOLDS = {
// IP Analysis
Expand Down Expand Up @@ -540,6 +647,7 @@ const subTabs = [
{ id: "overview", label: "Overview", icon: "pi pi-chart-bar" },
{ id: "logs", label: "Logs", icon: "pi pi-list" },
{ id: "performance", label: "Performance", icon: "pi pi-bolt" },
{ id: "unknown", label: "Unknown Domains", icon: "pi pi-question-circle" },
];

const logFilters = reactive({
Expand Down Expand Up @@ -872,6 +980,18 @@ const getStartTime = (range: string): string => {
return now.toISOString();
};

const fetchUnknownDomains = async () => {
loadingUnknown.value = true;
try {
const response = await trafficApi.getUnknownDomainStats(timeRange.value);
unknownStats.value = response.data.stats;
} catch (e: any) {
console.error("Failed to fetch unknown domain stats:", e);
} finally {
loadingUnknown.value = false;
}
Comment on lines +990 to +992
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To maintain a consistent error reporting mechanism across the application, it's recommended to use the notifications store for displaying errors encountered during API calls. This provides a unified user experience for error feedback.

Suggested change
} finally {
loadingUnknown.value = false;
}
notifications.error("Error", `Failed to fetch unknown domain stats: ${e.message}`);

};

const navigateToDeploymentLogs = (name: string) => {
logFilters.deployment = name;
logFilters.offset = 0;
Expand Down Expand Up @@ -1058,6 +1178,7 @@ const formatHour = (hourStr: string): string => {

watch(activeSubTab, (tab) => {
if (tab === "logs" && logs.value.length === 0) fetchLogs();
if (tab === "unknown" && !unknownStats.value) fetchUnknownDomains();
});

onMounted(() => {
Expand Down Expand Up @@ -2093,6 +2214,46 @@ onMounted(() => {
background: #2563eb;
}

.unknown-domains-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.25rem;
}

.unknown-info-panel {
display: flex;
gap: 0.75rem;
padding: 0.75rem;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: var(--radius-sm);
margin-top: 0.5rem;
}

.unknown-info-panel > i {
color: #0284c7;
font-size: 1rem;
flex-shrink: 0;
}

.unknown-info-panel p {
margin: 0 0 0.375rem 0;
font-size: 0.75rem;
color: #0369a1;
}

.unknown-info-panel ul {
margin: 0;
padding-left: 1rem;
font-size: 0.6875rem;
color: #0369a1;
}

.unknown-info-panel li {
margin-bottom: 0.125rem;
}

@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
Expand Down
25 changes: 25 additions & 0 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,13 +720,38 @@ export interface TrafficStats {
deployment_stats: DeploymentTrafficStats[];
}

export interface UnknownDomainEntry {
domain: string;
request_count: number;
last_seen: string;
}

export interface UnknownDomainIPEntry {
ip: string;
request_count: number;
domains: string[];
last_seen: string;
}

export interface UnknownDomainStats {
total_requests: number;
top_domains: UnknownDomainEntry[];
top_ips: UnknownDomainIPEntry[];
recent_logs: TrafficLog[];
}

export const trafficApi = {
getLogs: (params?: TrafficFilter) =>
apiClient.get<{ logs: TrafficLog[]; total: number; limit: number; offset: number }>("/traffic/logs", { params }),

getStats: (params?: { deployment?: string; since?: string }) =>
apiClient.get<{ stats: TrafficStats }>("/traffic/stats", { params }),

getUnknownDomainStats: (since?: string) =>
apiClient.get<{ stats: UnknownDomainStats }>("/traffic/unknown-domains", {
params: since ? { since } : undefined,
}),

cleanup: (days?: number) => apiClient.post<{ deleted: number }>("/traffic/cleanup", { days }),

getDeploymentStats: (name: string, since?: string) =>
Expand Down