Skip to content

Commit 11ed9b2

Browse files
feat: filter ui
Signed-off-by: Henry Gressmann <[email protected]>
1 parent 3140a67 commit 11ed9b2

File tree

14 files changed

+333
-114
lines changed

14 files changed

+333
-114
lines changed

biome.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@
1111
"clientKind": "git",
1212
"defaultBranch": "main"
1313
},
14+
"linter": {
15+
"rules": {
16+
"suspicious": {
17+
"noArrayIndexKey": "off"
18+
},
19+
"a11y": {
20+
"useSemanticElements": "off"
21+
}
22+
}
23+
},
1424
"files": {
1525
"ignore": ["**/node_modules/*", "**/api/dashboard.ts"]
1626
}

src/web/routes/dashboard.rs

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::app::reports::{self, DateRange, Dimension, Metric, ReportStats};
1+
use crate::app::reports::{self, DateRange, Dimension, DimensionFilter, Metric, ReportStats};
22
use crate::app::Liwan;
33
use crate::utils::validate::{self, can_access_project};
44
use crate::web::session::SessionUser;
@@ -24,12 +24,14 @@ struct GraphResponse {
2424
#[derive(Object)]
2525
struct StatsRequest {
2626
range: DateRange,
27+
filters: Vec<DimensionFilter>,
2728
}
2829

2930
#[derive(Object)]
3031
#[oai(rename_all = "camelCase")]
3132
struct GraphRequest {
3233
range: DateRange,
34+
filters: Vec<DimensionFilter>,
3335
data_points: u32,
3436
metric: Metric,
3537
}
@@ -46,6 +48,7 @@ struct StatsResponse {
4648
#[oai(rename_all = "camelCase")]
4749
struct DimensionRequest {
4850
range: DateRange,
51+
filters: Vec<DimensionFilter>,
4952
metric: Metric,
5053
dimension: Dimension,
5154
}
@@ -89,9 +92,16 @@ impl DashboardAPI {
8992
}
9093

9194
let conn = app.events_conn().http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
92-
let report =
93-
reports::overall_report(&conn, &entities, "pageview", &req.range, req.data_points, &[], &req.metric)
94-
.http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
95+
let report = reports::overall_report(
96+
&conn,
97+
&entities,
98+
"pageview",
99+
&req.range,
100+
req.data_points,
101+
&req.filters,
102+
&req.metric,
103+
)
104+
.http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
95105

96106
Ok(Json(GraphResponse { data: report }))
97107
}
@@ -113,10 +123,10 @@ impl DashboardAPI {
113123

114124
let conn = app.events_conn().http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
115125

116-
let stats = reports::overall_stats(&conn, &entities, "pageview", &req.range, &[])
126+
let stats = reports::overall_stats(&conn, &entities, "pageview", &req.range, &req.filters)
117127
.http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
118128

119-
let stats_prev = reports::overall_stats(&conn, &entities, "pageview", &req.range.prev(), &[])
129+
let stats_prev = reports::overall_stats(&conn, &entities, "pageview", &req.range.prev(), &req.filters)
120130
.http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
121131

122132
let online = reports::online_users(&conn, &entities).http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -141,9 +151,16 @@ impl DashboardAPI {
141151

142152
let conn = app.events_conn().http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
143153

144-
let stats =
145-
reports::dimension_report(&conn, &entities, "pageview", &req.range, &req.dimension, &[], &req.metric)
146-
.http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
154+
let stats = reports::dimension_report(
155+
&conn,
156+
&entities,
157+
"pageview",
158+
&req.range,
159+
&req.dimension,
160+
&req.filters,
161+
&req.metric,
162+
)
163+
.http_status(StatusCode::INTERNAL_SERVER_ERROR)?;
147164

148165
let mut data = Vec::new();
149166
for (key, value) in stats {

web/src/api/constants.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Dimension, Metric } from "./types";
1+
import type { Dimension, DimensionFilter, Metric } from "./types";
22

33
export const metricNames: Record<Metric, string> = {
44
views: "Total Views",
@@ -18,3 +18,19 @@ export const dimensionNames: Record<Dimension, string> = {
1818
country: "Country",
1919
fqdn: "Domain",
2020
};
21+
22+
export const filterNames: Record<DimensionFilter["filterType"], string> = {
23+
contains: "contains",
24+
equal: "equals",
25+
is_null: "is null",
26+
not_contains: "does not contain",
27+
not_equal: "does not equal",
28+
};
29+
30+
export const filterNamesCapitalized: Record<DimensionFilter["filterType"], string> = {
31+
contains: "Contains",
32+
equal: "Equals",
33+
is_null: "Is Null",
34+
not_contains: "Does Not Contain",
35+
not_equal: "Does Not Equal",
36+
};

web/src/api/dashboard.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

web/src/api/hooks.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useMemo } from "react";
22

33
import { toDataPoints } from "../components/graph";
4-
import type { DateRange, Dimension, DimensionTableRow, Metric, ProjectResponse } from "./types";
4+
import type { DateRange, Dimension, DimensionFilter, DimensionTableRow, Metric, ProjectResponse } from "./types";
55

66
import { api } from ".";
77
import { queryClient, useQuery } from "./query";
@@ -56,10 +56,12 @@ export const useDimension = ({
5656
dimension,
5757
metric,
5858
range,
59+
filters,
5960
}: {
6061
project: ProjectResponse;
6162
dimension: Dimension;
6263
metric: Metric;
64+
filters: DimensionFilter[];
6365
range: DateRange;
6466
}): {
6567
data: DimensionTableRow[] | undefined;
@@ -70,13 +72,14 @@ export const useDimension = ({
7072
} => {
7173
const { data, isLoading, error } = useQuery({
7274
placeholderData: (prev) => prev,
73-
queryKey: ["dimension", project.id, dimension, metric, range],
75+
queryKey: ["dimension", project.id, dimension, metric, range, filters],
7476
queryFn: () =>
7577
api["/api/dashboard/project/{project_id}/dimension"]
7678
.post({
7779
params: { project_id: project.id },
7880
json: {
7981
dimension,
82+
filters,
8083
metric,
8184
range,
8285
},
@@ -94,10 +97,12 @@ export const useProjectData = ({
9497
project,
9598
metric,
9699
rangeName = "last7Days",
100+
filters = [],
97101
}: {
98102
project?: ProjectResponse;
99103
metric: Metric;
100104
rangeName?: RangeName;
105+
filters?: DimensionFilter[];
101106
}) => {
102107
const { range, graphRange, dataPoints } = useMemo(() => resolveRange(rangeName), [rangeName]);
103108

@@ -115,17 +120,16 @@ export const useProjectData = ({
115120
} = useQuery({
116121
refetchInterval,
117122
staleTime,
118-
queryKey: ["project_stats", project?.id, range],
123+
queryKey: ["project_stats", project?.id, range, metric, filters],
119124

120125
enabled: project !== undefined,
121126
queryFn: () =>
122127
api["/api/dashboard/project/{project_id}/stats"]
123-
.post({ json: { range }, params: { project_id: project?.id ?? "" } })
128+
.post({ json: { range, filters }, params: { project_id: project?.id ?? "" } })
124129
.json(),
125130
placeholderData: (prev) => prev,
126131
});
127132

128-
const json = { range, metric, dataPoints };
129133
const {
130134
data: graph,
131135
isError: isErrorGraph,
@@ -136,7 +140,9 @@ export const useProjectData = ({
136140
enabled: project !== undefined,
137141
queryKey: ["project_graph", project?.id, range, graphRange, metric, dataPoints],
138142
queryFn: () =>
139-
api["/api/dashboard/project/{project_id}/graph"].post({ json, params: { project_id: project?.id ?? "" } }).json(),
143+
api["/api/dashboard/project/{project_id}/graph"]
144+
.post({ json: { range, metric, dataPoints, filters }, params: { project_id: project?.id ?? "" } })
145+
.json(),
140146
placeholderData: (prev) => prev,
141147
});
142148

@@ -157,6 +163,9 @@ export const useProjectData = ({
157163
};
158164
};
159165

166+
export type ProjectDataGraph = ReturnType<typeof useProjectData>["graph"];
167+
export type ProjectDataStats = ReturnType<typeof useProjectData>["stats"];
168+
160169
export const invalidateProjects = () => queryClient.invalidateQueries({ queryKey: ["projects"] });
161170
export const invalidateEntities = () => queryClient.invalidateQueries({ queryKey: ["entities"] });
162171
export const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: ["users"] });

web/src/api/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import type dashboardspec from "./dashboard";
33

44
export type DashboardSpec = NormalizeOAS<typeof dashboardspec>;
55
export type Metric = OASModel<DashboardSpec, "Metric">;
6+
export type DateRange = OASModel<DashboardSpec, "DateRange">;
67
export type Dimension = OASModel<DashboardSpec, "Dimension">;
8+
export type DimensionFilter = OASModel<DashboardSpec, "DimensionFilter">;
79
export type DimensionTableRow = OASModel<DashboardSpec, "DimensionTableRow">;
8-
export type DateRange = OASModel<DashboardSpec, "DateRange">;
10+
911
export type ProjectResponse = OASModel<DashboardSpec, "ProjectResponse">;
1012
export type EntityResponse = OASModel<DashboardSpec, "EntityResponse">;
1113
export type UserResponse = OASModel<DashboardSpec, "UserResponse">;

web/src/components/dimensions/index.tsx

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,50 +17,37 @@ import { BrowserIcon, MobileDeviceIcon, OSIcon, ReferrerIcon } from "../icons";
1717
import { countryCodeToFlag, formatFullUrl, formatHost, getHref, tryParseUrl } from "../../utils";
1818
import { DetailsModal } from "./modal";
1919
import { formatMetricVal } from "../../utils";
20+
import type { ProjectQuery } from "../project";
2021

2122
export const cardStyles = styles.card;
2223

2324
export const DimensionCard = ({
24-
project,
2525
dimension,
26-
metric,
27-
range,
26+
query,
2827
}: {
29-
project: ProjectResponse;
3028
dimension: Dimension;
31-
metric: Metric;
32-
range: DateRange;
29+
query: ProjectQuery;
3330
}) => {
3431
return (
3532
<article className={styles.card}>
3633
<div className={styles.dimensionHeader}>
3734
<div>{dimensionNames[dimension]}</div>
38-
<div>{metricNames[metric]}</div>
35+
<div>{metricNames[query.metric]}</div>
3936
</div>
40-
<DimensionTable project={project} dimension={dimension} metric={metric} range={range} />
37+
<DimensionTable dimension={dimension} query={query} />
4138
</article>
4239
);
4340
};
4441

45-
export const DimensionTabsCard = ({
46-
project,
47-
metric,
48-
range,
49-
dimensions,
50-
}: { project: ProjectResponse; dimensions: Dimension[]; metric: Metric; range: DateRange }) => {
42+
export const DimensionTabsCard = ({ dimensions, query }: { dimensions: Dimension[]; query: ProjectQuery }) => {
5143
return (
5244
<article className={styles.card}>
53-
<DimensionTabs project={project} dimensions={dimensions} metric={metric} range={range} />
45+
<DimensionTabs dimensions={dimensions} query={query} />
5446
</article>
5547
);
5648
};
5749

58-
export const DimensionTabs = ({
59-
project,
60-
metric,
61-
range,
62-
dimensions,
63-
}: { project: ProjectResponse; dimensions: Dimension[]; metric: Metric; range: DateRange }) => {
50+
export const DimensionTabs = ({ dimensions, query }: { dimensions: Dimension[]; query: ProjectQuery }) => {
6451
return (
6552
<Tabs.Root className={styles.tabs} defaultValue={dimensions[0]}>
6653
<Tabs.List className={styles.tabsList}>
@@ -69,24 +56,22 @@ export const DimensionTabs = ({
6956
{dimensionNames[value]}
7057
</Tabs.Trigger>
7158
))}
72-
<div>{metricNames[metric]}</div>
59+
<div>{metricNames[query.metric]}</div>
7360
</Tabs.List>
7461
{dimensions.map((dimension) => (
7562
<Tabs.Content key={dimension} value={dimension} className={styles.tabsContent}>
76-
<DimensionTable dimension={dimension} metric={metric} range={range} project={project} noHeader />
63+
<DimensionTable dimension={dimension} noHeader query={query} />
7764
</Tabs.Content>
7865
))}
7966
</Tabs.Root>
8067
);
8168
};
8269

8370
export const DimensionTable = ({
84-
project,
8571
dimension,
86-
metric,
87-
range,
88-
}: { project: ProjectResponse; dimension: Dimension; metric: Metric; range: DateRange; noHeader?: boolean }) => {
89-
const { data, biggest, order, isLoading } = useDimension({ project, dimension, metric, range });
72+
query,
73+
}: { dimension: Dimension; noHeader?: boolean; query: ProjectQuery }) => {
74+
const { data, biggest, order, isLoading } = useDimension({ dimension, ...query });
9075

9176
const dataTruncated = data?.slice(0, 6);
9277
return (
@@ -114,7 +99,7 @@ export const DimensionTable = ({
11499
</div>
115100
)}
116101
</div>
117-
<DetailsModal project={project} dimension={dimension} metric={metric} range={range} />
102+
<DetailsModal dimension={dimension} query={query} />
118103
</>
119104
);
120105
};
@@ -178,11 +163,9 @@ const dimensionLabels: Record<Dimension, (value: DimensionTableRow) => React.Rea
178163
<ReferrerIcon referrer={value.dimensionValue} icon={value.icon} size={24} />
179164
{value.displayName || value.dimensionValue || "Unknown"}
180165
{value.dimensionValue && isValidFqdn(value.dimensionValue) && (
181-
<>
182-
<a href={`https://${value.dimensionValue}`} target="_blank" rel="noreferrer" className={styles.external}>
183-
<SquareArrowOutUpRightIcon size={16} />
184-
</a>
185-
</>
166+
<a href={`https://${value.dimensionValue}`} target="_blank" rel="noreferrer" className={styles.external}>
167+
<SquareArrowOutUpRightIcon size={16} />
168+
</a>
186169
)}
187170
</>
188171
),

web/src/components/dimensions/modal.tsx

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,23 @@ import {
1515
type ProjectResponse,
1616
} from "../../api";
1717
import { useDeferredValue, useMemo, useState } from "react";
18+
import type { ProjectQuery } from "../project";
1819

19-
export const DetailsModal = ({
20-
project,
21-
dimension,
22-
metric,
23-
range,
24-
}: { project: ProjectResponse; dimension: Dimension; metric: Metric; range: DateRange }) => {
25-
const { data, biggest, order, isLoading } = useDimension({ project, dimension, metric, range });
20+
export const DetailsModal = ({ dimension, query }: { dimension: Dimension; query: ProjectQuery }) => {
21+
const { data, biggest, order, isLoading } = useDimension({ dimension, ...query });
2622

27-
const [query, setQuery] = useState("");
28-
const deferredQuery = useDeferredValue(query);
23+
const [filter, setFilter] = useState("");
24+
const deferredFilter = useDeferredValue(filter);
2925

3026
const results = useMemo(() => {
31-
if (!deferredQuery || !data) return data;
32-
return fuzzysort.go(deferredQuery, data, { keys: ["displayName", "dimensionValue", "value"] }).map((r) => r.obj);
33-
}, [deferredQuery, data]);
27+
if (!deferredFilter || !data) return data;
28+
return fuzzysort.go(deferredFilter, data, { keys: ["displayName", "dimensionValue", "value"] }).map((r) => r.obj);
29+
}, [deferredFilter, data]);
3430

3531
return (
3632
<Dialog
37-
title={`${dimensionNames[dimension]} - ${metricNames[metric]}`}
38-
description={`Detailed breakdown of ${dimensionNames[dimension]} by ${metricNames[metric]}`}
33+
title={`${dimensionNames[dimension]} - ${metricNames[query.metric]}`}
34+
description={`Detailed breakdown of ${dimensionNames[dimension]} by ${metricNames[query.metric]}`}
3935
hideTitle
4036
hideDescription
4137
showClose
@@ -51,13 +47,13 @@ export const DetailsModal = ({
5147
<div className={styles.dimensionTable} style={{ "--count": data?.length } as React.CSSProperties}>
5248
<div className={styles.dimensionHeader}>
5349
<div>{dimensionNames[dimension]}</div>
54-
<div>{metricNames[metric]}</div>
50+
<div>{metricNames[query.metric]}</div>
5551
</div>
5652
<input
5753
type="search"
5854
placeholder="Search..."
59-
value={query}
60-
onChange={(e) => setQuery(e.target.value)}
55+
value={filter}
56+
onChange={(e) => setFilter(e.target.value)}
6157
className={styles.search}
6258
/>
6359
{results?.map((d) => {

0 commit comments

Comments
 (0)