mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
fixup 3d on admin dash, add databox (#1823)
This commit is contained in:
@@ -95,6 +95,7 @@ use artcraft_api_defs::moderation::debug_logs::debug_log_entry::*;
|
||||
use artcraft_api_defs::moderation::debug_logs::moderation_list_all_debug_logs::*;
|
||||
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_token::*;
|
||||
use artcraft_api_defs::moderation::debug_logs::moderation_list_debug_logs_for_user::*;
|
||||
use crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::*;
|
||||
use crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::*;
|
||||
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::*;
|
||||
@@ -640,6 +641,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::omni_api::job_status::batch_get_job_status_handler::omni_api_batch_get_job_status_handler,
|
||||
// Moderation
|
||||
crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler,
|
||||
crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::moderator_list_databox_dashboards_handler,
|
||||
crate::http_server::endpoints::moderation::info::moderator_token_info_handler::moderator_get_token_info_handler,
|
||||
crate::http_server::endpoints::moderation::user::moderator_list_subscribing_users_by_signup_date::moderator_list_subscribing_users_by_signup_date_handler,
|
||||
crate::http_server::endpoints::moderation::user::moderator_list_users_by_signup_date::moderator_list_users_by_signup_date_handler,
|
||||
@@ -724,6 +726,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::update_gpt_image_job_status_handler,
|
||||
),
|
||||
components(schemas(
|
||||
DataboxDashboardEntry,
|
||||
ListDataboxDashboardsResponse,
|
||||
ModeratorGetUserSpendSummaryResponse,
|
||||
UserSpendSummaryView,
|
||||
ModeratorReengagementListResponse,
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub mod moderator_list_databox_dashboards_handler;
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_web::web::Json;
|
||||
use actix_web::{web, HttpRequest};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::user_session::require_moderator::require_moderator;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListDataboxDashboardsResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// Configured Databox datawalls, in display order.
|
||||
pub databoards: Vec<DataboxDashboardEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DataboxDashboardEntry {
|
||||
/// Human-readable dashboard name.
|
||||
pub name: String,
|
||||
|
||||
/// Databox datawall id.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// List the Databox metrics dashboards configured for the admin dashboard.
|
||||
/// Dashboards whose env vars aren't set are omitted.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "Moderation",
|
||||
path = "/v1/moderation/dashboards/databox",
|
||||
responses(
|
||||
(status = 200, description = "Success", body = ListDataboxDashboardsResponse),
|
||||
(status = 401, description = "Not authorized", body = CommonWebError),
|
||||
(status = 500, description = "Server error", body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn moderator_list_databox_dashboards_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListDataboxDashboardsResponse>, CommonWebError> {
|
||||
|
||||
require_moderator(&http_request, &server_state.session_checker, &server_state.mysql_pool).await?;
|
||||
|
||||
let databox = &server_state.dashboards.databox;
|
||||
|
||||
let databoards = [
|
||||
("DAUs", databox.daus_id.as_ref()),
|
||||
("Daily Generations", databox.daily_generations_id.as_ref()),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(name, maybe_id)| {
|
||||
maybe_id.map(|id| DataboxDashboardEntry {
|
||||
name: name.to_string(),
|
||||
id: id.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ListDataboxDashboardsResponse {
|
||||
success: true,
|
||||
databoards,
|
||||
}))
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod alerts;
|
||||
pub mod dashboards;
|
||||
pub mod debug_logs;
|
||||
pub mod info;
|
||||
pub mod ip_bans;
|
||||
|
||||
+7
@@ -35,6 +35,7 @@ use crate::http_server::endpoints::moderation::user::moderator_list_users_by_sig
|
||||
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_stripe_customer_id_handler::moderator_user_lookup_by_stripe_customer_id_handler;
|
||||
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::moderator_user_lookup_handler;
|
||||
use crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler;
|
||||
use crate::http_server::endpoints::moderation::dashboards::moderator_list_databox_dashboards_handler::moderator_list_databox_dashboards_handler;
|
||||
use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler;
|
||||
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_all_debug_logs_handler::moderation_list_all_debug_logs_handler;
|
||||
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_token_handler::moderation_list_debug_logs_for_token_handler;
|
||||
@@ -106,6 +107,12 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
.service(web::scope("/dashboards")
|
||||
.service(web::resource("/databox")
|
||||
.route(web::get().to(moderator_list_databox_dashboards_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok()))
|
||||
)
|
||||
)
|
||||
.service(web::scope("/debug_logs")
|
||||
.service(web::resource("/list/{token}")
|
||||
.route(web::get().to(moderation_list_debug_logs_for_token_handler))
|
||||
|
||||
@@ -34,6 +34,7 @@ use crate::startup::setup_bans::{
|
||||
load_static_container_ip_bans, load_troll_user_token_bans,
|
||||
};
|
||||
use crate::startup::setup_inference_providers::setup_inference_providers;
|
||||
use crate::startup::setup_dashboards::setup_dashboards;
|
||||
use crate::startup::setup_seedance_video_bucket::setup_seedance_video_bucket;
|
||||
use crate::startup::setup_static_feature_flags::setup_static_feature_flags;
|
||||
use crate::startup::setup_stripe_artcraft::setup_stripe_artcraft;
|
||||
@@ -262,6 +263,7 @@ pub async fn setup_dependencies(server_hostname: &str) -> AnyhowResult<SetupResu
|
||||
},
|
||||
temp_dir_creator: ScopedTempDirCreator::auto_setup(),
|
||||
google_sign_in_cert: GoogleSignInCert::new(),
|
||||
dashboards: setup_dashboards(),
|
||||
};
|
||||
|
||||
Ok(SetupResult {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod build_dependencies;
|
||||
pub mod setup_bans;
|
||||
pub mod setup_dashboards;
|
||||
pub mod setup_metrics;
|
||||
pub mod setup_pager;
|
||||
pub mod setup_seedance_video_bucket;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Optional setup for the admin dashboard's embedded metrics dashboards.
|
||||
//!
|
||||
//! Reads the `DATABOX_DASHBOARD_*` env vars. Each is independent and
|
||||
//! optional — a missing var means that dashboard is simply not offered to
|
||||
//! the admin dashboard. Never blocks startup.
|
||||
|
||||
use std::env;
|
||||
|
||||
use log::info;
|
||||
|
||||
use crate::state::server_state::{Dashboards, DataboxDashboards};
|
||||
|
||||
/// Build the dashboard configuration from the environment.
|
||||
pub fn setup_dashboards() -> Dashboards {
|
||||
let daus_id = read_non_empty_env("DATABOX_DASHBOARD_DAUS");
|
||||
let daily_generations_id = read_non_empty_env("DATABOX_DASHBOARD_DAILY_GENERATIONS");
|
||||
|
||||
info!(
|
||||
"Databox dashboards configured: daus={}, daily_generations={}",
|
||||
daus_id.is_some(),
|
||||
daily_generations_id.is_some(),
|
||||
);
|
||||
|
||||
Dashboards {
|
||||
databox: DataboxDashboards {
|
||||
daus_id,
|
||||
daily_generations_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an env var, treating unset or whitespace-only as absent.
|
||||
fn read_non_empty_env(name: &str) -> Option<String> {
|
||||
match env::var(name) {
|
||||
Ok(value) if !value.trim().is_empty() => Some(value.trim().to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,10 @@ pub struct ServerState {
|
||||
pub google_sign_in_cert: GoogleSignInCert,
|
||||
|
||||
pub temp_dir_creator: ScopedTempDirCreator,
|
||||
|
||||
/// Embedded metrics dashboards for the admin dashboard, configured from
|
||||
/// optional env vars at startup.
|
||||
pub dashboards: Dashboards,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -276,3 +280,17 @@ pub struct TrollBans {
|
||||
pub struct ResendData {
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
/// Embedded metrics dashboards surfaced to admins/mods.
|
||||
#[derive(Clone)]
|
||||
pub struct Dashboards {
|
||||
pub databox: DataboxDashboards,
|
||||
}
|
||||
|
||||
/// Databox datawall IDs. Each is `None` when its env var isn't configured,
|
||||
/// in which case the dashboard is simply omitted from the moderation API.
|
||||
#[derive(Clone)]
|
||||
pub struct DataboxDashboards {
|
||||
pub daus_id: Option<String>,
|
||||
pub daily_generations_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -57,6 +57,12 @@ export interface UserReferralListItem {
|
||||
maybe_referral_code_token: string | null;
|
||||
}
|
||||
|
||||
/** A Databox datawall configured server-side for the admin home page. */
|
||||
export interface DataboxDashboard {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** Which table the customer id was found in. */
|
||||
export type ModeratorStripeCustomerIdSource = "customer_link" | "subscription";
|
||||
|
||||
@@ -475,6 +481,25 @@ export class ModerationApi extends ApiManager {
|
||||
}));
|
||||
}
|
||||
|
||||
// Databox metrics dashboards configured server-side, in display order.
|
||||
// Dashboards without a configured id are omitted by the server.
|
||||
public async ListDataboxDashboards(): Promise<
|
||||
ApiResponse<DataboxDashboard[]>
|
||||
> {
|
||||
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/dashboards/databox`;
|
||||
return await this.get<{
|
||||
success: boolean;
|
||||
databoards: DataboxDashboard[];
|
||||
error_message?: string;
|
||||
}>({ endpoint })
|
||||
.then((response) => ({
|
||||
success: response.success,
|
||||
data: response.databoards || [],
|
||||
errorMessage: response.error_message,
|
||||
}))
|
||||
.catch((err) => ({ success: false, errorMessage: err.message }));
|
||||
}
|
||||
|
||||
public async SendAlert(params: {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
|
||||
@@ -120,9 +120,7 @@ export function MediaCard({
|
||||
{/* Top-left: media class + upload indicator */}
|
||||
<div className="absolute top-1.5 left-1.5 z-10 flex items-center gap-1 flex-wrap">
|
||||
<span className="px-2 py-1 rounded-md bg-black/40 text-[10px] font-medium text-white backdrop-blur-sm uppercase">
|
||||
{mediaClass === "dimensional" || mediaClass === "mesh" || mediaClass === "splat"
|
||||
? "3D"
|
||||
: mediaClass || "Unknown"}
|
||||
{mediaClass === "dimensional" ? "3D" : mediaClass || "Unknown"}
|
||||
</span>
|
||||
{isUserUpload && (
|
||||
<span
|
||||
|
||||
@@ -1,78 +1,31 @@
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { IconChartBar, IconDashboard, IconWorld } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { IconDashboard } from "@tabler/icons-react";
|
||||
import { ModerationApi, type DataboxDashboard } from "@/api/ModerationApi";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
|
||||
const activeUsersData = [
|
||||
{ date: "02/05", activeUsers: 812 },
|
||||
{ date: "02/06", activeUsers: 945 },
|
||||
{ date: "02/07", activeUsers: 1023 },
|
||||
{ date: "02/08", activeUsers: 876 },
|
||||
{ date: "02/09", activeUsers: 654 },
|
||||
{ date: "02/10", activeUsers: 598 },
|
||||
{ date: "02/11", activeUsers: 901 },
|
||||
{ date: "02/12", activeUsers: 1102 },
|
||||
{ date: "02/13", activeUsers: 1045 },
|
||||
{ date: "02/14", activeUsers: 978 },
|
||||
{ date: "02/15", activeUsers: 1134 },
|
||||
{ date: "02/16", activeUsers: 756 },
|
||||
{ date: "02/17", activeUsers: 689 },
|
||||
{ date: "02/18", activeUsers: 1023 },
|
||||
{ date: "02/19", activeUsers: 1187 },
|
||||
{ date: "02/20", activeUsers: 1056 },
|
||||
{ date: "02/21", activeUsers: 998 },
|
||||
{ date: "02/22", activeUsers: 1210 },
|
||||
{ date: "02/23", activeUsers: 834 },
|
||||
{ date: "02/24", activeUsers: 712 },
|
||||
{ date: "02/25", activeUsers: 967 },
|
||||
{ date: "02/26", activeUsers: 1089 },
|
||||
{ date: "02/27", activeUsers: 1145 },
|
||||
{ date: "02/28", activeUsers: 1034 },
|
||||
{ date: "03/01", activeUsers: 1267 },
|
||||
{ date: "03/02", activeUsers: 890 },
|
||||
{ date: "03/03", activeUsers: 756 },
|
||||
{ date: "03/04", activeUsers: 1123 },
|
||||
{ date: "03/05", activeUsers: 1198 },
|
||||
{ date: "03/06", activeUsers: 704 },
|
||||
];
|
||||
|
||||
const countryData = [
|
||||
{ country: "United States", activeUsers: 704 },
|
||||
{ country: "India", activeUsers: 105 },
|
||||
{ country: "Canada", activeUsers: 100 },
|
||||
{ country: "China", activeUsers: 74 },
|
||||
{ country: "Brazil", activeUsers: 59 },
|
||||
{ country: "Argentina", activeUsers: 57 },
|
||||
{ country: "Australia", activeUsers: 51 },
|
||||
];
|
||||
|
||||
const activeUsersConfig = {
|
||||
activeUsers: {
|
||||
label: "Active Users",
|
||||
color: "hsl(221, 83%, 53%)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const countryConfig = {
|
||||
activeUsers: {
|
||||
label: "Active Users",
|
||||
color: "hsl(142, 71%, 45%)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const totalActiveUsers = activeUsersData.reduce(
|
||||
(sum, d) => sum + d.activeUsers,
|
||||
0,
|
||||
);
|
||||
function databoxEmbedUrl(id: string): string {
|
||||
return `https://app.databox.com/datawall/${encodeURIComponent(id)}?i`;
|
||||
}
|
||||
|
||||
export function DashboardHome() {
|
||||
usePageTitle("Dashboard");
|
||||
const [databoards, setDataboards] = useState<DataboxDashboard[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const api = new ModerationApi();
|
||||
api.ListDataboxDashboards().then((resp) => {
|
||||
if (cancelled) return;
|
||||
setDataboards(resp.success && resp.data ? resp.data : []);
|
||||
setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -83,87 +36,27 @@ export function DashboardHome() {
|
||||
<p className="text-muted-foreground">Welcome to the admin dashboard.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
{/* Active Users Over Time */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconChartBar className="size-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Active Users (DUMMY CONTENT RIGHT NOW)
|
||||
</CardTitle>
|
||||
</div>
|
||||
<span className="text-2xl font-bold tabular-nums">
|
||||
{totalActiveUsers.toLocaleString()}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
config={activeUsersConfig}
|
||||
className="aspect-auto h-[300px] w-full"
|
||||
{isLoading ? (
|
||||
<Skeleton className="w-full rounded-xl" style={{ paddingTop: "63%" }} />
|
||||
) : (
|
||||
databoards.map((databoard) => (
|
||||
<div key={databoard.id} className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold">{databoard.name}</h2>
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-xl border"
|
||||
style={{ paddingTop: "63%" }}
|
||||
>
|
||||
<BarChart data={activeUsersData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent hideLabel={false} />}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="activeUsers"
|
||||
fill="var(--color-activeUsers)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Users by Country */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconWorld className="size-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Users by Country (DUMMY CONTENT RIGHT NOW)
|
||||
</CardTitle>
|
||||
<iframe
|
||||
src={databoxEmbedUrl(databoard.id)}
|
||||
title={databoard.name}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
config={countryConfig}
|
||||
className="aspect-auto h-[300px] w-full"
|
||||
>
|
||||
<BarChart data={countryData} layout="vertical">
|
||||
<CartesianGrid horizontal={false} />
|
||||
<XAxis type="number" tickLine={false} axisLine={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="country"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
width={100}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent hideLabel={false} />}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="activeUsers"
|
||||
fill="var(--color-activeUsers)"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,9 +26,8 @@ const MEDIA_CLASS_OPTIONS = [
|
||||
{ value: "", label: "All" },
|
||||
{ value: "image", label: "Image" },
|
||||
{ value: "video", label: "Video" },
|
||||
// "dimensional" is the deprecated pre-split class; rows persist until the
|
||||
// backfill lands, so the 3D filter matches all three classes.
|
||||
{ value: "mesh,splat,dimensional", label: "3D" },
|
||||
{ value: "mesh", label: "Meshes" },
|
||||
{ value: "splat", label: "Splats" },
|
||||
] as const;
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
Reference in New Issue
Block a user