diff --git a/packages/api/generated-schema-clean.gql b/packages/api/generated-schema-clean.gql index 958d0c37..e0f7717e 100644 --- a/packages/api/generated-schema-clean.gql +++ b/packages/api/generated-schema-clean.gql @@ -10493,6 +10493,26 @@ type Project implements Node { """Skip the first `n` values.""" offset: Int ): [User!] + + """Reads and enables pagination through a set of `ProjectVisitorMetric`.""" + visitorMetrics( + """Only read the first `n` values of the set.""" + first: Int + + """Skip the first `n` values.""" + offset: Int + period: ActivityStatsPeriod + ): [ProjectVisitorMetric!] + + """Reads and enables pagination through a set of `Visitor`.""" + visitors( + """Only read the first `n` values of the set.""" + first: Int + + """Skip the first `n` values.""" + offset: Int + period: ActivityStatsPeriod + ): [Visitor!] } enum ProjectAccessControlSetting { @@ -11051,6 +11071,28 @@ enum ProjectsSharedBasemapsOrderBy { NATURAL } +type ProjectVisitorMetric implements Node { + interval: Interval! + month: Int! + + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + nodeId: ID! + + """ + Reads a single `Project` that is related to this `ProjectVisitorMetric`. + """ + project: Project + projectId: Int! + timestamp: Datetime! + topBrowsers: JSON! + topCountries: JSON! + topDeviceTypes: JSON! + topOperatingSystems: JSON! + topReferrers: JSON! +} + type PublicProjectDetail { accessControl: ProjectAccessControlSetting accessStatus: ProjectAccessStatus @@ -16653,24 +16695,19 @@ input VisitorCondition { } type VisitorMetric implements Node { - id: Int! interval: Interval! - lastUpdated: Datetime! + month: Int! """ A globally unique identifier. Can be used in various places throughout the system to identify this single value. """ nodeId: ID! - - """Reads a single `Project` that is related to this `VisitorMetric`.""" - project: Project - projectId: Int - start: Datetime! - topBrowsers: JSON - topCountries: JSON - topDeviceTypes: JSON - topOperatingSystems: JSON - topReferrers: JSON + timestamp: Datetime! + topBrowsers: JSON! + topCountries: JSON! + topDeviceTypes: JSON! + topOperatingSystems: JSON! + topReferrers: JSON! } """A connection to a list of `Visitor` values.""" diff --git a/packages/api/generated-schema.gql b/packages/api/generated-schema.gql index 958d0c37..e0f7717e 100644 --- a/packages/api/generated-schema.gql +++ b/packages/api/generated-schema.gql @@ -10493,6 +10493,26 @@ type Project implements Node { """Skip the first `n` values.""" offset: Int ): [User!] + + """Reads and enables pagination through a set of `ProjectVisitorMetric`.""" + visitorMetrics( + """Only read the first `n` values of the set.""" + first: Int + + """Skip the first `n` values.""" + offset: Int + period: ActivityStatsPeriod + ): [ProjectVisitorMetric!] + + """Reads and enables pagination through a set of `Visitor`.""" + visitors( + """Only read the first `n` values of the set.""" + first: Int + + """Skip the first `n` values.""" + offset: Int + period: ActivityStatsPeriod + ): [Visitor!] } enum ProjectAccessControlSetting { @@ -11051,6 +11071,28 @@ enum ProjectsSharedBasemapsOrderBy { NATURAL } +type ProjectVisitorMetric implements Node { + interval: Interval! + month: Int! + + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + nodeId: ID! + + """ + Reads a single `Project` that is related to this `ProjectVisitorMetric`. + """ + project: Project + projectId: Int! + timestamp: Datetime! + topBrowsers: JSON! + topCountries: JSON! + topDeviceTypes: JSON! + topOperatingSystems: JSON! + topReferrers: JSON! +} + type PublicProjectDetail { accessControl: ProjectAccessControlSetting accessStatus: ProjectAccessStatus @@ -16653,24 +16695,19 @@ input VisitorCondition { } type VisitorMetric implements Node { - id: Int! interval: Interval! - lastUpdated: Datetime! + month: Int! """ A globally unique identifier. Can be used in various places throughout the system to identify this single value. """ nodeId: ID! - - """Reads a single `Project` that is related to this `VisitorMetric`.""" - project: Project - projectId: Int - start: Datetime! - topBrowsers: JSON - topCountries: JSON - topDeviceTypes: JSON - topOperatingSystems: JSON - topReferrers: JSON + timestamp: Datetime! + topBrowsers: JSON! + topCountries: JSON! + topDeviceTypes: JSON! + topOperatingSystems: JSON! + topReferrers: JSON! } """A connection to a list of `Visitor` values.""" diff --git a/packages/api/migrations/committed/000325.sql b/packages/api/migrations/committed/000325.sql new file mode 100644 index 00000000..00e4264d --- /dev/null +++ b/packages/api/migrations/committed/000325.sql @@ -0,0 +1,324 @@ +--! Previous: sha1:b82a912cd7e5484ad4decb12c0d3b45d4b9dd9c0 +--! Hash: sha1:d505e142def2d8b3d2842f68a10aa95b512b8cd6 + +-- Enter migration here +create table if not exists project_visitors ( + project_id int not null references projects(id), + interval interval not null, + timestamp timestamptz not null, + count integer not null, + unique(project_id, interval, timestamp) +); + + +CREATE OR REPLACE FUNCTION public.projects_visitors(p projects, period public.activity_stats_period) RETURNS SETOF public.visitors + LANGUAGE sql + STABLE + SECURITY DEFINER + AS $$ + select + ( + case period + when '24hrs' then '15 minutes'::interval + when '7-days' then '1 hour'::interval + when '30-days' then '1 day'::interval + when '6-months' then '1 day'::interval + when '1-year' then '1 day'::interval + when 'all-time' then '1 day'::interval + else '1 day'::interval + end + ), + dd as timestamp, + coalesce( + ( + select + count + from + project_visitors + where timestamp = dd and + interval = ( + case period + when '24hrs' then '15 minutes'::interval + when '7-days' then '1 hour'::interval + when '30-days' then '1 day'::interval + when '6-months' then '1 day'::interval + when '1-year' then '1 day'::interval + when 'all-time' then '1 day'::interval + else '1 day'::interval + end + ) and + project_id = p.id + ) + , 0 + ) as count + FROM generate_series( + ( + case period + when '24hrs' then ( + (date_trunc('hour', now()) + floor(date_part('minute', now())::int / 15) * interval '15 min') - '24 hours'::interval + ) + when '7-days' then date_trunc('day', now() - '7 days'::interval) + when '30-days' then date_trunc('day', now())::date - '30 days'::interval + when '6-months' then date_trunc('day', now())::date - '6 months'::interval + when '1-year' then date_trunc('day', now())::date - '1 year'::interval + when 'all-time' then date_trunc('day', now())::date - '1 month'::interval + else date_trunc('day', now())::date - '1 month'::interval + end + ), + ( + now() + ), + ( + case period + when '24hrs' then '15 minutes'::interval + when '7-days' then '1 hour'::interval + when '30-days' then '1 day'::interval + when '6-months' then '1 day'::interval + when '1-year' then '1 day'::interval + when 'all-time' then '1 day'::interval + else '1 day'::interval + end + ) + ) dd + where session_is_admin(p.id) + ORDER BY timestamp; + -- project_id = p.id and + -- interval = ( + -- case period + -- when '24hrs' then '15 minutes'::interval + -- when '7-days' then '1 hour'::interval + -- when '30-days' then '1 day'::interval + -- when '6-months' then '1 day'::interval + -- when '1-year' then '1 day'::interval + -- when 'all-time' then '1 day'::interval + -- else '1 day'::interval + -- end + -- ) + -- and timestamp >= now() - ( + -- case period + -- when '24hrs' then '24 hours'::interval + -- when '7-days' then '7 days'::interval + -- when '30-days' then '30 days'::interval + -- when '6-months' then '6 months'::interval + -- when '1-year' then '1 year'::interval + -- else '1 year'::interval + -- end + -- ) + -- order by timestamp asc + $$; + +grant execute on function projects_visitors to seasketch_user; + +comment on function projects_visitors is '@simpleCollections only'; + +CREATE OR REPLACE FUNCTION public.projects_activity(p public.projects, period public.activity_stats_period) RETURNS public.project_activity_stats + LANGUAGE plpgsql STABLE SECURITY DEFINER + AS $$ + declare + stats project_activity_stats; + begin + select + (select coalesce(count(*), 0) from project_participants where project_id = p.id)::integer, + (select coalesce(sum(size), 0) from data_upload_outputs where project_id = p.id)::bigint, + ( + select coalesce(sum(forums_post_count(forums.*)), 0) from forums where project_id = p.id + ), + ( + select coalesce(count(*), 0) from sketches where sketch_class_id in ( + select id from sketch_classes where project_id = p.id + ) + ), + ( + select coalesce(sum(surveys_submitted_response_count(surveys.*)),0) from surveys where project_id = p.id + ), + ( + select coalesce(count(*), 0) from data_sources where project_id = p.id and type in ('seasketch-vector', 'seasketch-raster', 'seasketch-mvt') + ), + (select coalesce(count(*), 0) from data_sources where project_id = p.id)::integer, + + coalesce(sum(new_users), 0)::integer, + coalesce(sum(new_sketches), 0)::integer, + coalesce(sum(new_data_sources), 0)::integer, + coalesce(sum(new_forum_posts), 0)::integer, + coalesce(sum(new_survey_responses), 0)::integer, + coalesce(sum(new_uploaded_layers), 0)::integer, + coalesce(sum(new_uploaded_bytes), 0)::bigint + into stats + from + project_activity + where project_activity.project_id = p.id and + date >= now() - ( + case period + when '24hrs' then '24 hours'::interval + when '7-days' then '7 days'::interval + when '30-days' then '30 days'::interval + when '6-months' then '6 months'::interval + when '1-year' then '1 year'::interval + else '1 day'::interval + end + ) and + (session_is_admin(p.id) or p.access_control = 'public'); + return stats; + end; + $$; + +CREATE OR REPLACE FUNCTION public.visitors(period public.activity_stats_period) RETURNS SETOF public.visitors + LANGUAGE sql STABLE SECURITY DEFINER + AS $$ + select + ( + case period + when '24hrs' then '15 minutes'::interval + when '7-days' then '1 hour'::interval + when '30-days' then '1 day'::interval + when '6-months' then '1 day'::interval + when '1-year' then '1 day'::interval + when 'all-time' then '1 day'::interval + else '1 day'::interval + end + ), + dd as timestamp, + coalesce( + ( + select + count + from + visitors + where timestamp = dd + and interval = ( + case period + when '24hrs' then '15 minutes'::interval + when '7-days' then '1 hour'::interval + when '30-days' then '1 day'::interval + when '6-months' then '1 day'::interval + when '1-year' then '1 day'::interval + when 'all-time' then '1 day'::interval + else '1 day'::interval + end + ) + ) + , 0 + ) as count + from generate_series( + ( + case period + when '24hrs' then ( + (date_trunc('hour', now()) + floor(date_part('minute', now())::int / 15) * interval '15 min') - '24 hours'::interval + ) + when '7-days' then date_trunc('day', now() - '7 days'::interval) + when '30-days' then date_trunc('day', now())::date - '30 days'::interval + when '6-months' then date_trunc('day', now())::date - '6 months'::interval + when '1-year' then date_trunc('day', now())::date - '1 year'::interval + when 'all-time' then date_trunc('day', now())::date - '1 month'::interval + else date_trunc('day', now())::date - '1 month'::interval + end + ), + ( + now() + ), + ( + case period + when '24hrs' then '15 minutes'::interval + when '7-days' then '1 hour'::interval + when '30-days' then '1 day'::interval + when '6-months' then '1 day'::interval + when '1-year' then '1 day'::interval + when 'all-time' then '1 day'::interval + else '1 day'::interval + end + ) + ) dd + where session_is_superuser() + order by timestamp asc + $$; + +drop table if exists visitor_metrics cascade; +create table if not exists visitor_metrics ( + interval interval not null, + timestamp timestamptz not null, + month integer not null generated always as (date_part('month', timestamp AT TIME ZONE 'UTC')) stored, + top_referrers jsonb not null, + top_operating_systems jsonb not null, + top_browsers jsonb not null, + top_device_types jsonb not null, + top_countries jsonb not null, + primary key(interval, month) +); + +drop table if exists project_visitor_metrics cascade; +create table if not exists project_visitor_metrics ( + project_id int not null references projects(id), + interval interval not null, + timestamp timestamptz not null, + month integer not null generated always as (date_part('month', timestamp AT TIME ZONE 'UTC')) stored, + top_referrers jsonb not null, + top_operating_systems jsonb not null, + top_browsers jsonb not null, + top_device_types jsonb not null, + top_countries jsonb not null, + primary key(project_id, interval, month) +); + +CREATE OR REPLACE FUNCTION public.visitor_metrics(period public.activity_stats_period) RETURNS SETOF public.visitor_metrics + LANGUAGE sql STABLE SECURITY DEFINER + AS $$ + select * from visitor_metrics where session_is_superuser() and + interval = ( + case period + when '24hrs' then '24 hours'::interval + when '7-days' then '7 days'::interval + when '30-days' then '30 days'::interval + else '1 day'::interval + end + ) order by timestamp desc limit 1; + $$; + + +grant execute on function visitor_metrics to seasketch_user; + +comment on function visitor_metrics is '@simpleCollections only'; + +create or replace function projects_visitor_metrics(p projects, period activity_stats_period) + returns setof project_visitor_metrics + language sql + stable + security definer + as $$ + select * from project_visitor_metrics where session_is_admin(p.id) and + project_id = p.id and + interval = ( + case period + when '24hrs' then '24 hours'::interval + when '7-days' then '7 days'::interval + when '30-days' then '30 days'::interval + else '1 day'::interval + end + ) order by timestamp desc limit 1; + $$; + +grant execute on function projects_visitor_metrics to seasketch_user; +comment on function projects_visitor_metrics is '@simpleCollections only'; + +create or replace function schedule_visitor_metric_collection_for_all_projects() + returns void + language plpgsql + stable + security definer + as $$ + declare + p projects; + begin + for p in select * from projects loop + perform graphile_worker.add_job( + 'collectProjectVisitorStats', + payload := json_build_object( + 'id', p.id, + 'slug', p.slug + ), + run_at := NOW() + '5 seconds', + queue_name := 'project-visitor-stats', + job_key := 'collectProjectVisitorStats:' || p.id + ); + end loop; + end; + $$; diff --git a/packages/api/schema.sql b/packages/api/schema.sql index 19cca729..91d6e400 100644 --- a/packages/api/schema.sql +++ b/packages/api/schema.sql @@ -10991,13 +10991,23 @@ CREATE FUNCTION public.projects_activity(p public.projects, period public.activi stats project_activity_stats; begin select - coalesce(sum(registered_users), 0)::integer, - coalesce(sum(uploads_storage_used), 0)::bigint, - coalesce(sum(forum_posts), 0)::integer, - coalesce(sum(sketches), 0)::integer, - coalesce(sum(survey_responses), 0)::integer, - coalesce(sum(uploaded_layers), 0)::integer, - coalesce(sum(data_sources), 0)::integer, + (select coalesce(count(*), 0) from project_participants where project_id = p.id)::integer, + (select coalesce(sum(size), 0) from data_upload_outputs where project_id = p.id)::bigint, + ( + select coalesce(sum(forums_post_count(forums.*)), 0) from forums where project_id = p.id + ), + ( + select coalesce(count(*), 0) from sketches where sketch_class_id in ( + select id from sketch_classes where project_id = p.id + ) + ), + ( + select coalesce(sum(surveys_submitted_response_count(surveys.*)),0) from surveys where project_id = p.id + ), + ( + select coalesce(count(*), 0) from data_sources where project_id = p.id and type in ('seasketch-vector', 'seasketch-raster', 'seasketch-mvt') + ), + (select coalesce(count(*), 0) from data_sources where project_id = p.id)::integer, coalesce(sum(new_users), 0)::integer, coalesce(sum(new_sketches), 0)::integer, @@ -12000,6 +12010,167 @@ List of all banned users. Listing only accessible to admins. '; +-- +-- Name: project_visitor_metrics; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.project_visitor_metrics ( + project_id integer NOT NULL, + "interval" interval NOT NULL, + "timestamp" timestamp with time zone NOT NULL, + month integer GENERATED ALWAYS AS (date_part('month'::text, timezone('UTC'::text, "timestamp"))) STORED NOT NULL, + top_referrers jsonb NOT NULL, + top_operating_systems jsonb NOT NULL, + top_browsers jsonb NOT NULL, + top_device_types jsonb NOT NULL, + top_countries jsonb NOT NULL +); + + +-- +-- Name: projects_visitor_metrics(public.projects, public.activity_stats_period); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.projects_visitor_metrics(p public.projects, period public.activity_stats_period) RETURNS SETOF public.project_visitor_metrics + LANGUAGE sql STABLE SECURITY DEFINER + AS $$ + select * from project_visitor_metrics where session_is_admin(p.id) and + project_id = p.id and + interval = ( + case period + when '24hrs' then '24 hours'::interval + when '7-days' then '7 days'::interval + when '30-days' then '30 days'::interval + else '1 day'::interval + end + ) order by timestamp desc limit 1; + $$; + + +-- +-- Name: FUNCTION projects_visitor_metrics(p public.projects, period public.activity_stats_period); Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON FUNCTION public.projects_visitor_metrics(p public.projects, period public.activity_stats_period) IS '@simpleCollections only'; + + +-- +-- Name: visitors; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.visitors ( + "interval" interval NOT NULL, + "timestamp" timestamp with time zone NOT NULL, + count integer NOT NULL +); + + +-- +-- Name: projects_visitors(public.projects, public.activity_stats_period); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.projects_visitors(p public.projects, period public.activity_stats_period) RETURNS SETOF public.visitors + LANGUAGE sql STABLE SECURITY DEFINER + AS $$ + select + ( + case period + when '24hrs' then '15 minutes'::interval + when '7-days' then '1 hour'::interval + when '30-days' then '1 day'::interval + when '6-months' then '1 day'::interval + when '1-year' then '1 day'::interval + when 'all-time' then '1 day'::interval + else '1 day'::interval + end + ), + dd as timestamp, + coalesce( + ( + select + count + from + project_visitors + where timestamp = dd and + interval = ( + case period + when '24hrs' then '15 minutes'::interval + when '7-days' then '1 hour'::interval + when '30-days' then '1 day'::interval + when '6-months' then '1 day'::interval + when '1-year' then '1 day'::interval + when 'all-time' then '1 day'::interval + else '1 day'::interval + end + ) and + project_id = p.id + ) + , 0 + ) as count + FROM generate_series( + ( + case period + when '24hrs' then ( + (date_trunc('hour', now()) + floor(date_part('minute', now())::int / 15) * interval '15 min') - '24 hours'::interval + ) + when '7-days' then date_trunc('day', now() - '7 days'::interval) + when '30-days' then date_trunc('day', now())::date - '30 days'::interval + when '6-months' then date_trunc('day', now())::date - '6 months'::interval + when '1-year' then date_trunc('day', now())::date - '1 year'::interval + when 'all-time' then date_trunc('day', now())::date - '1 month'::interval + else date_trunc('day', now())::date - '1 month'::interval + end + ), + ( + now() + ), + ( + case period + when '24hrs' then '15 minutes'::interval + when '7-days' then '1 hour'::interval + when '30-days' then '1 day'::interval + when '6-months' then '1 day'::interval + when '1-year' then '1 day'::interval + when 'all-time' then '1 day'::interval + else '1 day'::interval + end + ) + ) dd + where session_is_admin(p.id) + ORDER BY timestamp; + -- project_id = p.id and + -- interval = ( + -- case period + -- when '24hrs' then '15 minutes'::interval + -- when '7-days' then '1 hour'::interval + -- when '30-days' then '1 day'::interval + -- when '6-months' then '1 day'::interval + -- when '1-year' then '1 day'::interval + -- when 'all-time' then '1 day'::interval + -- else '1 day'::interval + -- end + -- ) + -- and timestamp >= now() - ( + -- case period + -- when '24hrs' then '24 hours'::interval + -- when '7-days' then '7 days'::interval + -- when '30-days' then '30 days'::interval + -- when '6-months' then '6 months'::interval + -- when '1-year' then '1 year'::interval + -- else '1 year'::interval + -- end + -- ) + -- order by timestamp asc + $$; + + +-- +-- Name: FUNCTION projects_visitors(p public.projects, period public.activity_stats_period); Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON FUNCTION public.projects_visitors(p public.projects, period public.activity_stats_period) IS '@simpleCollections only'; + + -- -- Name: public_sprites(); Type: FUNCTION; Schema: public; Owner: - -- @@ -12737,6 +12908,32 @@ Remove participant admin privileges. '; +-- +-- Name: schedule_visitor_metric_collection_for_all_projects(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.schedule_visitor_metric_collection_for_all_projects() RETURNS void + LANGUAGE plpgsql STABLE SECURITY DEFINER + AS $$ + declare + p projects; + begin + for p in select * from projects loop + perform graphile_worker.add_job( + 'collectProjectVisitorStats', + payload := json_build_object( + 'id', p.id, + 'slug', p.slug + ), + run_at := NOW() + '5 seconds', + queue_name := 'project-visitor-stats', + job_key := 'collectProjectVisitorStats:' || p.id + ); + end loop; + end; + $$; + + -- -- Name: search_overlays(text, text, integer, boolean, integer); Type: FUNCTION; Schema: public; Owner: - -- @@ -15760,16 +15957,14 @@ $$; -- CREATE TABLE public.visitor_metrics ( - id integer NOT NULL, - project_id integer, - start timestamp with time zone NOT NULL, - last_updated timestamp with time zone NOT NULL, "interval" interval NOT NULL, - top_referrers jsonb, - top_operating_systems jsonb, - top_browsers jsonb, - top_device_types jsonb, - top_countries jsonb + "timestamp" timestamp with time zone NOT NULL, + month integer GENERATED ALWAYS AS (date_part('month'::text, timezone('UTC'::text, "timestamp"))) STORED NOT NULL, + top_referrers jsonb NOT NULL, + top_operating_systems jsonb NOT NULL, + top_browsers jsonb NOT NULL, + top_device_types jsonb NOT NULL, + top_countries jsonb NOT NULL ); @@ -15780,15 +15975,15 @@ CREATE TABLE public.visitor_metrics ( CREATE FUNCTION public.visitor_metrics(period public.activity_stats_period) RETURNS SETOF public.visitor_metrics LANGUAGE sql STABLE SECURITY DEFINER AS $$ - select * from visitor_metrics where session_is_superuser() and project_id is null and + select * from visitor_metrics where session_is_superuser() and interval = ( case period - when '24hrs' then '1 day'::interval + when '24hrs' then '24 hours'::interval when '7-days' then '7 days'::interval when '30-days' then '30 days'::interval else '1 day'::interval end - ) order by start desc limit 1; + ) order by timestamp desc limit 1; $$; @@ -15799,17 +15994,6 @@ CREATE FUNCTION public.visitor_metrics(period public.activity_stats_period) RETU COMMENT ON FUNCTION public.visitor_metrics(period public.activity_stats_period) IS '@simpleCollections only'; --- --- Name: visitors; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.visitors ( - "interval" interval NOT NULL, - "timestamp" timestamp with time zone NOT NULL, - count integer NOT NULL -); - - -- -- Name: visitors(public.activity_stats_period); Type: FUNCTION; Schema: public; Owner: - -- @@ -15818,10 +16002,7 @@ CREATE FUNCTION public.visitors(period public.activity_stats_period) RETURNS SET LANGUAGE sql STABLE SECURITY DEFINER AS $$ select - * - from visitors - where session_is_superuser() and - interval = ( + ( case period when '24hrs' then '15 minutes'::interval when '7-days' then '1 hour'::interval @@ -15831,17 +16012,59 @@ CREATE FUNCTION public.visitors(period public.activity_stats_period) RETURNS SET when 'all-time' then '1 day'::interval else '1 day'::interval end - ) - and timestamp >= now() - ( - case period - when '24hrs' then '24 hours'::interval - when '7-days' then '7 days'::interval - when '30-days' then '30 days'::interval - when '6-months' then '6 months'::interval - when '1-year' then '1 year'::interval - else '1 year'::interval - end - ) + ), + dd as timestamp, + coalesce( + ( + select + count + from + visitors + where timestamp = dd + and interval = ( + case period + when '24hrs' then '15 minutes'::interval + when '7-days' then '1 hour'::interval + when '30-days' then '1 day'::interval + when '6-months' then '1 day'::interval + when '1-year' then '1 day'::interval + when 'all-time' then '1 day'::interval + else '1 day'::interval + end + ) + ) + , 0 + ) as count + from generate_series( + ( + case period + when '24hrs' then ( + (date_trunc('hour', now()) + floor(date_part('minute', now())::int / 15) * interval '15 min') - '24 hours'::interval + ) + when '7-days' then date_trunc('day', now() - '7 days'::interval) + when '30-days' then date_trunc('day', now())::date - '30 days'::interval + when '6-months' then date_trunc('day', now())::date - '6 months'::interval + when '1-year' then date_trunc('day', now())::date - '1 year'::interval + when 'all-time' then date_trunc('day', now())::date - '1 month'::interval + else date_trunc('day', now())::date - '1 month'::interval + end + ), + ( + now() + ), + ( + case period + when '24hrs' then '15 minutes'::interval + when '7-days' then '1 hour'::interval + when '30-days' then '1 day'::interval + when '6-months' then '1 day'::interval + when '1-year' then '1 day'::interval + when 'all-time' then '1 day'::interval + else '1 day'::interval + end + ) + ) dd + where session_is_superuser() order by timestamp asc $$; @@ -16642,6 +16865,18 @@ COMMENT ON COLUMN public.project_participants.approved IS 'Approval status for p COMMENT ON COLUMN public.project_participants.share_profile IS 'Whether user profile can be shared with project administrators, and other users if participating in the forum.'; +-- +-- Name: project_visitors; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.project_visitors ( + project_id integer NOT NULL, + "interval" interval NOT NULL, + "timestamp" timestamp with time zone NOT NULL, + count integer NOT NULL +); + + -- -- Name: projects_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- @@ -16974,20 +17209,6 @@ ALTER TABLE public.users ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( ); --- --- Name: visitor_metrics_id_seq; Type: SEQUENCE; Schema: public; Owner: - --- - -ALTER TABLE public.visitor_metrics ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME public.visitor_metrics_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1 -); - - -- -- Name: jobs id; Type: DEFAULT; Schema: graphile_worker; Owner: - -- @@ -17443,6 +17664,22 @@ ALTER TABLE ONLY public.project_participants ADD CONSTRAINT project_participants_pkey PRIMARY KEY (user_id, project_id); +-- +-- Name: project_visitor_metrics project_visitor_metrics_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.project_visitor_metrics + ADD CONSTRAINT project_visitor_metrics_pkey PRIMARY KEY (project_id, "interval", month); + + +-- +-- Name: project_visitors project_visitors_project_id_interval_timestamp_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.project_visitors + ADD CONSTRAINT project_visitors_project_id_interval_timestamp_key UNIQUE (project_id, "interval", "timestamp"); + + -- -- Name: projects projects_legacy_id_key; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -17687,7 +17924,7 @@ ALTER TABLE ONLY public.users -- ALTER TABLE ONLY public.visitor_metrics - ADD CONSTRAINT visitor_metrics_pkey PRIMARY KEY (id); + ADD CONSTRAINT visitor_metrics_pkey PRIMARY KEY ("interval", month); -- @@ -18425,34 +18662,6 @@ CREATE INDEX user_profiles_user_id_idx ON public.user_profiles USING btree (user CREATE INDEX users_sub ON public.users USING btree (sub); --- --- Name: visitor_metrics_project_id_start_interval_idx; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX visitor_metrics_project_id_start_interval_idx ON public.visitor_metrics USING btree (project_id, start, "interval"); - - --- --- Name: visitor_metrics_project_id_start_interval_idx1; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX visitor_metrics_project_id_start_interval_idx1 ON public.visitor_metrics USING btree (project_id, start, "interval"); - - --- --- Name: visitor_metrics_project_id_start_interval_idx2; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX visitor_metrics_project_id_start_interval_idx2 ON public.visitor_metrics USING btree (project_id, start, "interval"); - - --- --- Name: visitor_metrics_project_id_start_interval_idx3; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX visitor_metrics_project_id_start_interval_idx3 ON public.visitor_metrics USING btree (project_id, start, "interval"); - - -- -- Name: visitors_interval; Type: INDEX; Schema: public; Owner: - -- @@ -19572,6 +19781,22 @@ ALTER TABLE ONLY public.project_participants ADD CONSTRAINT project_participants_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; +-- +-- Name: project_visitor_metrics project_visitor_metrics_project_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.project_visitor_metrics + ADD CONSTRAINT project_visitor_metrics_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id); + + +-- +-- Name: project_visitors project_visitors_project_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.project_visitors + ADD CONSTRAINT project_visitors_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id); + + -- -- Name: projects projects_creator_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -19999,14 +20224,6 @@ ALTER TABLE ONLY public.user_profiles COMMENT ON CONSTRAINT user_profiles_user_id_fkey ON public.user_profiles IS '@omit many'; --- --- Name: visitor_metrics visitor_metrics_project_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.visitor_metrics - ADD CONSTRAINT visitor_metrics_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id); - - -- -- Name: job_queues; Type: ROW SECURITY; Schema: graphile_worker; Owner: - -- @@ -20579,13 +20796,6 @@ CREATE POLICY select_user_profile ON public.user_profiles FOR SELECT TO seasketc WHERE ((project_participants.user_id = user_profiles.user_id) AND (project_participants.share_profile = true) AND public.session_is_admin(project_participants.project_id)))))); --- --- Name: visitor_metrics select_visitor_metrics; Type: POLICY; Schema: public; Owner: - --- - -CREATE POLICY select_visitor_metrics ON public.visitor_metrics FOR SELECT USING ((public.session_is_superuser() OR ((project_id IS NOT NULL) AND public.session_is_admin(project_id)))); - - -- -- Name: visitors select_visitors_policy; Type: POLICY; Schema: public; Owner: - -- @@ -20897,12 +21107,6 @@ CREATE POLICY user_profile_update ON public.user_profiles FOR UPDATE USING (publ ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY; --- --- Name: visitor_metrics; Type: ROW SECURITY; Schema: public; Owner: - --- - -ALTER TABLE public.visitor_metrics ENABLE ROW LEVEL SECURITY; - -- -- Name: visitors; Type: ROW SECURITY; Schema: public; Owner: - -- @@ -27384,6 +27588,29 @@ REVOKE ALL ON FUNCTION public.projects_users_banned_from_forums(project public.p GRANT ALL ON FUNCTION public.projects_users_banned_from_forums(project public.projects) TO seasketch_user; +-- +-- Name: FUNCTION projects_visitor_metrics(p public.projects, period public.activity_stats_period); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.projects_visitor_metrics(p public.projects, period public.activity_stats_period) FROM PUBLIC; +GRANT ALL ON FUNCTION public.projects_visitor_metrics(p public.projects, period public.activity_stats_period) TO seasketch_user; + + +-- +-- Name: TABLE visitors; Type: ACL; Schema: public; Owner: - +-- + +GRANT SELECT ON TABLE public.visitors TO seasketch_user; + + +-- +-- Name: FUNCTION projects_visitors(p public.projects, period public.activity_stats_period); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.projects_visitors(p public.projects, period public.activity_stats_period) FROM PUBLIC; +GRANT ALL ON FUNCTION public.projects_visitors(p public.projects, period public.activity_stats_period) TO seasketch_user; + + -- -- Name: FUNCTION public_sprites(); Type: ACL; Schema: public; Owner: - -- @@ -27537,6 +27764,13 @@ REVOKE ALL ON FUNCTION public.revoke_admin_access("projectId" integer, "userId" GRANT ALL ON FUNCTION public.revoke_admin_access("projectId" integer, "userId" integer) TO seasketch_user; +-- +-- Name: FUNCTION schedule_visitor_metric_collection_for_all_projects(); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.schedule_visitor_metric_collection_for_all_projects() FROM PUBLIC; + + -- -- Name: FUNCTION search_overlays(lang text, query text, "projectId" integer, draft boolean, "limit" integer); Type: ACL; Schema: public; Owner: - -- @@ -31520,13 +31754,6 @@ REVOKE ALL ON FUNCTION public.visitor_metrics(period public.activity_stats_perio GRANT ALL ON FUNCTION public.visitor_metrics(period public.activity_stats_period) TO seasketch_user; --- --- Name: TABLE visitors; Type: ACL; Schema: public; Owner: - --- - -GRANT SELECT ON TABLE public.visitors TO seasketch_user; - - -- -- Name: FUNCTION visitors(period public.activity_stats_period); Type: ACL; Schema: public; Owner: - -- diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 1b05dbf7..922b3b29 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -244,6 +244,7 @@ run({ * * * * * cleanupDeletedOverlayRecords * * * * * collectActivityStats * * * * * collectVisitorStats + * * * * * identifyVisitedProjects `, }).then((runner) => { runner.events.on("job:start", ({ worker, job }) => { diff --git a/packages/api/src/visitorMetrics.ts b/packages/api/src/visitorMetrics.ts index 69e9d3ac..f30a7b88 100644 --- a/packages/api/src/visitorMetrics.ts +++ b/packages/api/src/visitorMetrics.ts @@ -39,7 +39,7 @@ export async function getVisitorMetrics(start: Date, end: Date, slug?: string) { topOperatingSystems: [], }; - const filter = { + const filter: any = { AND: [ { datetime_geq: start.toISOString(), @@ -51,6 +51,12 @@ export async function getVisitorMetrics(start: Date, end: Date, slug?: string) { ], }; + if (slug) { + filter.AND.push({ + requestPath_like: `/${slug}%`, + }); + } + const response = await client.request<{ viewer?: { accounts: { @@ -145,6 +151,77 @@ export async function getVisitorMetrics(start: Date, end: Date, slug?: string) { return metrics; } +export async function getVisitedSlugs(start: Date, end: Date) { + const filter = { + AND: [ + { + datetime_geq: start.toISOString(), + datetime_leq: end.toISOString(), + }, + { + siteTag: process.env.CLOUDFLARE_SITE_TAG, + }, + ], + }; + + const response = await client.request<{ + viewer?: { + accounts: { + topPaths: { + count: number; + dimensions: { + metric: string; + }; + }[]; + }[]; + }; + }>(TOP_PATHS_QUERY, { + accountTag: process.env.CLOUDFLARE_ACCOUNT_TAG, + filter, + order: "sum_visits_DESC", + }); + if (!response.viewer?.accounts[0]) { + throw new Error("No account found in response"); + } + const account = response.viewer?.accounts[0]; + const slugs = new Set(); + for (const path of account.topPaths) { + const slug = path.dimensions.metric.split("/")[1]; + if (slug) { + slugs.add(slug); + } + } + return [...slugs]; +} + +const TOP_PATHS_QUERY = gql` + query GetRumAnalyticsTopNs { + viewer { + accounts(filter: { accountTag: $accountTag }) { + topPaths: rumPageloadEventsAdaptiveGroups( + filter: $filter + limit: 5000 + orderBy: [$order] + ) { + count + avg { + sampleInterval + __typename + } + sum { + visits + __typename + } + dimensions { + metric: requestPath + } + } + } + __typename + } + } +`; + const VISITOR_METRICS_QUERY = gql` query GetRumAnalyticsTopNs { viewer { @@ -283,6 +360,13 @@ export async function getRealUserVisits( ], }; + if (slug) { + filter.AND.push({ + // @ts-ignore + requestPath_like: `/${slug}%`, + }); + } + const response = await client.request<{ viewer?: { accounts: { diff --git a/packages/api/tasks/collectActivityStats.ts b/packages/api/tasks/collectActivityStats.ts index 8bcd6f45..94ca9682 100644 --- a/packages/api/tasks/collectActivityStats.ts +++ b/packages/api/tasks/collectActivityStats.ts @@ -17,7 +17,10 @@ export default async function collectActivityStats( const recentProjectActivity = await client.query(` select get_projects_with_recent_activity() as project_ids `); - if (recentProjectActivity.rowCount === 0) { + if ( + recentProjectActivity.rowCount === 0 || + !recentProjectActivity.rows[0].project_ids + ) { return; } const projectIds = recentProjectActivity.rows[0].project_ids as number[]; diff --git a/packages/api/tasks/collectProjectVisitorStats.ts b/packages/api/tasks/collectProjectVisitorStats.ts new file mode 100644 index 00000000..05929448 --- /dev/null +++ b/packages/api/tasks/collectProjectVisitorStats.ts @@ -0,0 +1,108 @@ +import { Helpers } from "graphile-worker"; +import { getVisitorMetrics, getRealUserVisits } from "../src/visitorMetrics"; + +const intervals: ("24 hours" | "7 days" | "30 days")[] = [ + "24 hours", + "7 days", + "30 days", +]; + +export default async function collectProjectVisitorStats( + payload: { id: number; slug: string }, + helpers: Helpers +) { + console.log("running collectProjectVisitorStats", payload); + await helpers.withPgClient(async (client) => { + const now = Date.now(); + for (const interval of intervals) { + const times = await client.query( + `select $1::timestamp - $2::interval as start, ($1::timestamp) as end`, + [new Date(now), interval] + ); + if (!times.rows[0]) { + throw new Error("No start date found"); + } + const start = new Date(times.rows[0].start); + const end = new Date(times.rows[0].end); + const realUserVisits = await getRealUserVisits( + start, + end, + interval, + payload.slug + ); + for (const visit of realUserVisits) { + await client.query( + ` + insert into project_visitors ( + project_id, + interval, + timestamp, + count + ) values ( + $1::int, + $2::interval, + $3::timestamp, + $4::int + ) on conflict(project_id, interval, timestamp) do update + set + count = $4::int + `, + [ + payload.id, + interval === "24 hours" + ? "15 minutes" + : interval === "7 days" + ? "1 hour" + : "1 day", + visit.datetime.toISOString(), + visit.count, + ] + ); + } + + // Collect metrics + const data = await getVisitorMetrics(start, end, payload.slug); + await client.query( + ` + insert into project_visitor_metrics ( + project_id, + interval, + timestamp, + top_referrers, + top_operating_systems, + top_browsers, + top_device_types, + top_countries + ) values ( + $8, + $1::interval, + $2::timestamp, + $3, + $4, + $5, + $6, + $7 + ) + on conflict (project_id,interval,month) + do update set + top_referrers = EXCLUDED.top_referrers, + top_operating_systems = EXCLUDED.top_operating_systems, + top_browsers = EXCLUDED.top_browsers, + top_device_types = EXCLUDED.top_device_types, + top_countries = EXCLUDED.top_countries, + timestamp = EXCLUDED.timestamp + `, + [ + interval, + times.rows[0].end, + JSON.stringify(data.topReferrers), + JSON.stringify(data.topOperatingSystems), + JSON.stringify(data.topBrowsers), + JSON.stringify(data.topDeviceTypes), + JSON.stringify(data.topCountries), + payload.id, + ] + ); + } + }); +} diff --git a/packages/api/tasks/collectVisitorStats.ts b/packages/api/tasks/collectVisitorStats.ts index 8636dd16..9aad40ad 100644 --- a/packages/api/tasks/collectVisitorStats.ts +++ b/packages/api/tasks/collectVisitorStats.ts @@ -17,9 +17,6 @@ export default async function collectVisitorStats( // Global visitor_metrics // These records will have a null project_id. After the global stats are // collected, we will collect project specific stats - - // First, determine if there are any existing records for this interval. - // If so, update it. const times = await client.query( `select $1::timestamp - $2::interval as start, ($1::timestamp) as end`, [new Date(now), interval] @@ -30,163 +27,44 @@ export default async function collectVisitorStats( const start = new Date(times.rows[0].start); const end = new Date(times.rows[0].end); const data = await getVisitorMetrics(start, end); - - const existingRecord = await client.query( + await client.query( ` - select - id - from - visitor_metrics - where - project_id is null and - interval = $1::interval - order by start desc - limit 1 + insert into visitor_metrics ( + interval, + timestamp, + top_referrers, + top_operating_systems, + top_browsers, + top_device_types, + top_countries + ) values ( + $1::interval, + $2::timestamp, + $3, + $4, + $5, + $6, + $7 + ) + on conflict (interval,month) + do update set + top_referrers = EXCLUDED.top_referrers, + top_operating_systems = EXCLUDED.top_operating_systems, + top_browsers = EXCLUDED.top_browsers, + top_device_types = EXCLUDED.top_device_types, + top_countries = EXCLUDED.top_countries, + timestamp = EXCLUDED.timestamp `, - [interval] + [ + interval, + times.rows[0].end, + JSON.stringify(data.topReferrers), + JSON.stringify(data.topOperatingSystems), + JSON.stringify(data.topBrowsers), + JSON.stringify(data.topDeviceTypes), + JSON.stringify(data.topCountries), + ] ); - const row = existingRecord.rows[0]; - if (row) { - await client.query( - ` - update visitor_metrics - set - top_referrers = $1, - top_operating_systems = $2, - top_browsers = $3, - top_device_types = $4, - top_countries = $5, - last_updated = now(), - start = now() - interval - where - id = $6 - `, - [ - JSON.stringify(data.topReferrers), - JSON.stringify(data.topOperatingSystems), - JSON.stringify(data.topBrowsers), - JSON.stringify(data.topDeviceTypes), - JSON.stringify(data.topCountries), - existingRecord.rows[0].id, - ] - ); - } else { - await client.query( - ` - insert into visitor_metrics ( - project_id, - interval, - start, - top_referrers, - top_operating_systems, - top_browsers, - top_device_types, - top_countries, - last_updated - ) values ( - null, - $1::interval, - $2::timestamp, - $3, - $4, - $5, - $6, - $7, - now() - ) - `, - [ - interval, - times.rows[0].start, - JSON.stringify(data.topReferrers), - JSON.stringify(data.topOperatingSystems), - JSON.stringify(data.topBrowsers), - JSON.stringify(data.topDeviceTypes), - JSON.stringify(data.topCountries), - ] - ); - } - - // 30 day intervals are special. They get "rolled off" into a permanent - // record for use in doing annual (or longer) reporting. - if (interval === "30 days") { - // check to see if there is an existing 30 day record that is younger - // than 30 days old. If not, create a new one. - // First, get current 30 day interval record - const current = await client.query( - ` - select - id, - start, - last_updated - from - visitor_metrics - where - project_id is null and - interval = '30 days' - order by start desc - limit 1 - ` - ); - if (!current.rows[0]) { - throw new Error("No current 30 day record found"); - } - // Is there another record older than the current one? - const older = await client.query( - ` - select - id, - start, - start + (interval * 2) < now() as is_old - from - visitor_metrics - where - project_id is null and - interval = '30 days' and - start < now() and - id != $1 - order by start desc - limit 1 - `, - [current.rows[0].id] - ); - if (older.rowCount === 0 || older.rows[0].is_old === true) { - // create a new record. This will actually replace the existing - // "current" record since it will have a newer start date. That's fine - await client.query( - ` - insert into visitor_metrics ( - project_id, - interval, - start, - top_referrers, - top_operating_systems, - top_browsers, - top_device_types, - top_countries, - last_updated - ) values ( - null, - '30 days', - now() - interval '30 days', - $1, - $2, - $3, - $4, - $5, - now() - ) - `, - [ - JSON.stringify(data.topReferrers), - JSON.stringify(data.topOperatingSystems), - JSON.stringify(data.topBrowsers), - JSON.stringify(data.topDeviceTypes), - JSON.stringify(data.topCountries), - ] - ); - } - } // Next, get real-user visits const realUserVisits = await getRealUserVisits(start, end, interval); diff --git a/packages/api/tasks/identifyVisitedProjects.ts b/packages/api/tasks/identifyVisitedProjects.ts new file mode 100644 index 00000000..260ff190 --- /dev/null +++ b/packages/api/tasks/identifyVisitedProjects.ts @@ -0,0 +1,38 @@ +import { Helpers } from "graphile-worker"; +import { getVisitedSlugs } from "../src/visitorMetrics"; + +export default async function identifyVisitedProjects( + payload: {}, + helpers: Helpers +) { + return await helpers.withPgClient(async (client) => { + const times = await client.query( + `select now() - '15 minutes'::interval as start, now() as end` + ); + const potentialSlugs = await getVisitedSlugs( + new Date(times.rows[0].start), + new Date(times.rows[0].end) + ); + if (potentialSlugs.length === 0) { + return; + } + const projectIds = await client.query( + `select id, slug from projects where slug = any($1)`, + [potentialSlugs] + ); + if (projectIds.rowCount === 0) { + return; + } + for (const row of projectIds.rows) { + console.log(`schedule record_project_activity for project ${row.id}`); + await helpers.addJob( + "collectProjectVisitorStats", + { id: row.id, slug: row.slug }, + { + jobKey: `collectProjectVisitorStats:${row.id}`, + queueName: "project-visitor-stats", + } + ); + } + }); +} diff --git a/packages/api/tasks/recordProjectActivity.ts b/packages/api/tasks/recordProjectActivity.ts index 6522f755..abbdeb92 100644 --- a/packages/api/tasks/recordProjectActivity.ts +++ b/packages/api/tasks/recordProjectActivity.ts @@ -1,8 +1,4 @@ import { Helpers } from "graphile-worker"; -import { PoolClient } from "pg"; - -const endpoint = `https://api.cloudflare.com/client/v4/graphql`; - /** * Database triggers are setup to run this task (debounced) whenever * a table of contents item or data upload ouput is deleted. This ensures @@ -19,7 +15,6 @@ export default async function recordProjectActivity( helpers: Helpers ) { await helpers.withPgClient(async (client) => { - console.log("recording project activity"); await client.query("select record_project_activity($1)", [ payload.projectId, ]); diff --git a/packages/client/src/Dashboard.tsx b/packages/client/src/Dashboard.tsx index 3876ddd7..03261dd0 100644 --- a/packages/client/src/Dashboard.tsx +++ b/packages/client/src/Dashboard.tsx @@ -16,7 +16,7 @@ import { } from "@radix-ui/react-icons"; import { ReactNode, useMemo, useState } from "react"; import { ChatIcon } from "@heroicons/react/outline"; -import React, { useRef, useEffect } from "react"; +import { useRef, useEffect } from "react"; import * as d3 from "d3"; import Warning from "./components/Warning"; @@ -287,7 +287,7 @@ export function StatItem({ ); } -function VisitorMetrics({ +export function VisitorMetrics({ label, data, }: { @@ -302,6 +302,7 @@ function VisitorMetrics({

{label}