void;
+ onHostsChange: (hosts: string[]) => void;
onRetry: () => void;
}) {
return (
-
+
{loading ? (
(24);
+ const [overviewHosts, setOverviewHosts] = useState([]);
const [cleanupOpen, setCleanupOpen] = useState(false);
const detailSortState = parseSortValue(detailSort);
@@ -56,10 +57,17 @@ function AccessLogsPageContent() {
};
const overviewQuery = useQuery({
- queryKey: ['openflare', 'access-logs', 'overview', overviewHours],
+ queryKey: [
+ 'openflare',
+ 'access-logs',
+ 'overview',
+ overviewHours,
+ overviewHosts,
+ ],
queryFn: () =>
AccessLogService.getOverview({
hours: overviewHours,
+ hosts: overviewHosts.length > 0 ? overviewHosts : undefined,
}),
enabled: tab === 'overview',
});
@@ -179,7 +187,9 @@ function AccessLogsPageContent() {
overviewQuery.error instanceof Error ? overviewQuery.error : null
}
hours={overviewHours}
+ hosts={overviewHosts}
onHoursChange={setOverviewHours}
+ onHostsChange={setOverviewHosts}
onRetry={() => void overviewQuery.refetch()}
/>
diff --git a/frontend/lib/services/openflare/access-log.service.ts b/frontend/lib/services/openflare/access-log.service.ts
index e8c3db0d..236ecfdb 100644
--- a/frontend/lib/services/openflare/access-log.service.ts
+++ b/frontend/lib/services/openflare/access-log.service.ts
@@ -20,10 +20,16 @@ import type {
function buildSearchParams(filters: object): Record {
const params: Record = {};
- Object.entries(
- filters as Record,
- ).forEach(([key, value]) => {
+ Object.entries(filters as Record).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
+ if (Array.isArray(value)) {
+ const items = value
+ .map((item) => String(item).trim())
+ .filter((item) => item !== '');
+ if (items.length === 0) return;
+ params[key] = items;
+ return;
+ }
params[key] = value;
});
return params;
diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts
index f023eabd..ae9f0f01 100644
--- a/frontend/lib/services/openflare/types.ts
+++ b/frontend/lib/services/openflare/types.ts
@@ -503,6 +503,7 @@ export interface AccessLogFilters {
export interface AccessLogOverviewFilters {
node_id?: string;
host?: string;
+ hosts?: string[];
hours?: number;
}
diff --git a/internal/apps/openflare/observability/access_log_logics.go b/internal/apps/openflare/observability/access_log_logics.go
index 8a89ace8..73386cd6 100644
--- a/internal/apps/openflare/observability/access_log_logics.go
+++ b/internal/apps/openflare/observability/access_log_logics.go
@@ -216,9 +216,10 @@ type AccessLogIPAnalysisView struct {
// AccessLogOverviewQuery filters access log overview queries.
type AccessLogOverviewQuery struct {
- NodeID string `json:"node_id"`
- Host string `json:"host"`
- Hours int `json:"hours"`
+ NodeID string `json:"node_id"`
+ Host string `json:"host"`
+ Hosts []string `json:"hosts"`
+ Hours int `json:"hours"`
}
// AccessLogOverviewMetricPoint is a single overview trend bucket.
@@ -284,6 +285,7 @@ func GetAccessLogOverview(ctx context.Context, input AccessLogOverviewQuery) (*A
query := model.OpenFlareAccessLogQuery{
NodeID: normalized.NodeID,
Host: normalized.Host,
+ Hosts: normalized.Hosts,
Since: since,
Until: now,
}
@@ -330,13 +332,45 @@ func normalizeAccessLogOverviewQuery(input AccessLogOverviewQuery) AccessLogOver
if hours > maxAccessLogOverviewHours {
hours = maxAccessLogOverviewHours
}
+ hosts := normalizeAccessLogOverviewHosts(input.Hosts)
+ host := strings.TrimSpace(input.Host)
+ if len(hosts) == 0 && host != "" {
+ // Single host query uses Hosts exact-match for consistency with multi-select.
+ hosts = []string{host}
+ host = ""
+ }
+ if len(hosts) > 0 {
+ host = ""
+ }
return AccessLogOverviewQuery{
NodeID: strings.TrimSpace(input.NodeID),
- Host: strings.TrimSpace(input.Host),
+ Host: host,
+ Hosts: hosts,
Hours: hours,
}
}
+func normalizeAccessLogOverviewHosts(hosts []string) []string {
+ if len(hosts) == 0 {
+ return nil
+ }
+ seen := make(map[string]struct{}, len(hosts))
+ result := make([]string, 0, len(hosts))
+ for _, host := range hosts {
+ trimmed := strings.TrimSpace(host)
+ if trimmed == "" {
+ continue
+ }
+ key := strings.ToLower(trimmed)
+ if _, exists := seen[key]; exists {
+ continue
+ }
+ seen[key] = struct{}{}
+ result = append(result, trimmed)
+ }
+ return result
+}
+
func valueCountDistribution(
ctx context.Context,
query model.OpenFlareAccessLogQuery,
@@ -427,6 +461,7 @@ func buildAccessLogOverviewTrends(
buckets, err := model.ListOpenFlareAccessLogBuckets(ctx, model.OpenFlareAccessLogBucketQuery{
NodeID: query.NodeID,
Host: query.Host,
+ Hosts: query.Hosts,
Since: query.Since,
Until: query.Until,
FoldMinutes: 60,
diff --git a/internal/apps/openflare/observability/routers.go b/internal/apps/openflare/observability/routers.go
index 4e1a9ace..1ad41c6a 100644
--- a/internal/apps/openflare/observability/routers.go
+++ b/internal/apps/openflare/observability/routers.go
@@ -19,7 +19,8 @@ import (
// @Produce json
// @Security SessionCookie
// @Param node_id query string false "节点 ID"
-// @Param host query string false "请求 Host"
+// @Param host query string false "请求 Host(单域名)"
+// @Param hosts query []string false "请求 Host 列表(多域名精确匹配)"
// @Param hours query int false "统计时间范围(小时)"
// @Success 200 {object} response.Any{data=observability.AccessLogOverview} "访问日志概览"
// @Failure 400 {object} response.Any "参数错误"
@@ -31,6 +32,7 @@ func GetAccessLogOverviewHandler(c *gin.Context) {
result, err := GetAccessLogOverview(c.Request.Context(), AccessLogOverviewQuery{
NodeID: c.Query("node_id"),
Host: c.Query("host"),
+ Hosts: c.QueryArray("hosts"),
Hours: readQueryInt(c, "hours"),
})
if apiutil.AbortBadRequestOnError(c, err) {