Compare commits

..
Author SHA1 Message Date
Brandon Thomas bc32d7c2ee cleanup 2026-04-02 00:31:49 -04:00
Brandon Thomas bf0703f0c1 refactor web errors with cause + notification details 2026-04-02 00:20:23 -04:00
Brandon Thomas c7943bce59 AdvancedCommonWebError with wrapped errors 2026-04-01 23:41:51 -04:00
Brandon Thomas c5f62daa2b categorize seedance credits error (#1285) 2026-04-01 22:51:25 -04:00
bflatastic 7122e4507f fixed issue where duration isn't passed through when clicking on recreate 2026-04-02 09:35:44 +07:00
Tanish Manku 238cb10499 Add copy prompt button and prompt preview to failed task notifications (#1284)
- Copy button on failed task cards copies the prompt to clipboard with a
3s check/green feedback
- Prompt preview row with marquee scroll on hover (only when text
overflows), tooltip showing full prompt up to 300 chars
- Fix dismiss and action button icon centering across all card types
(InProgress, Completed, Failed)
- Add marquee keyframe animation to tailwind.config.ts
2026-04-01 19:08:43 -07:00
Brandon Thomas 1ebb124f05 cleaned up pager (#1283) 2026-04-01 22:00:38 -04:00
Brandon Thomas e7bb263755 mysql backup tool 2026-04-01 13:15:27 -04:00
Tanish Manku bbfea2ae96 Code cleanup: Delete PageEdit dead code and migrate shared types to ui-pagedraw (#1281)
PageEdit was never rendered in the app. Removes all PageEdit files
(~4000 lines) and migrates the shared types (BaseSelectorImage,
ImageBundle) and HistoryStack component to @storyteller/ui-pagedraw
which already owns the canonical definitions.
2026-04-01 09:29:17 -07:00
26 changed files with 1262 additions and 343 deletions
+27
View File
@@ -11,6 +11,33 @@
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token`
- When implementing `fmt::Display` for error types, use the pattern: `write!(f, "{:?}", self)`
## File Layout (Top-to-Bottom Reading Order)
Organize code so a human reading top-to-bottom encounters the most important things first and
can drill down into details only when curious. Think of it like a newspaper: headline first,
then the story, then the fine print.
### Ordering within a file
1. **Constants** at the top, right after imports.
2. **The primary public type** (the main struct/enum the file is about) comes next.
A reader opening this file should immediately see what it defines.
3. **Supporting/subordinate types** (sub-structs, helper enums, serialization DTOs) go
below the primary type, not above it. Ask: "Would a reader need to know this type
exists before seeing the main type?" — if no, push it down.
4. **Type ordering for API types**: Request, then Response (success), then Error.
5. **`impl` blocks** after their type definitions.
### Ordering within an `impl` block
1. **Constructors** (`new`, `from_*`, `builder`) at the top.
2. **Public methods** next — these are the main entry points a reader cares about.
3. **Private helper methods** below public methods.
- Among helpers: place "meaty" helpers (substantial logic, direct continuations of the
public entry point) above "leaf" helpers (small formatters, simple lookups).
- A method that calls another method in the same file should appear *above* the method
it calls. This lets readers follow the call chain downward.
## Naming
- Handler functions: `{verb}_{noun}_handler` (e.g. `get_health_check_handler`, `login_handler`)
+11
View File
@@ -62,3 +62,14 @@ artcraft/
- A mix of wreq and reqwest for Rust HTTP clients
- TypeScript with Nx, React, Vite, Zustand, and Three.js
- Use two spaces for indentation
### File Layout
Organize for top-to-bottom reading. Important things first, details later.
- **Constants** at the top (after imports)
- **Structs/enums** next; outer structs above inner sub-structs
- **API types** in order: Request, Response, Error
- **In impl blocks**: constructors first, then public methods, then private helpers
- Private helpers go *below* the methods that call them
- Among helpers: meatier logic above leaf-level formatters
@@ -1,6 +1,8 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::StatusCode;
#[derive(Debug)]
pub enum Seedance2ProSpecificApiError {
/// The session cookies are expired or invalid.
@@ -8,6 +10,12 @@ pub enum Seedance2ProSpecificApiError {
/// The video generation request was flagged as a content violation.
VideoGenerationViolation(String),
/// The account does not have enough credits to perform the operation.
BillingError {
status_code: StatusCode,
body: String,
},
}
impl Error for Seedance2ProSpecificApiError {}
@@ -17,6 +25,7 @@ impl Display for Seedance2ProSpecificApiError {
match self {
Self::UnauthorizedSessionExpired => write!(f, "Unauthorized: session cookies expired or invalid."),
Self::VideoGenerationViolation(body) => write!(f, "Video generation violation: {}", body),
Self::BillingError { status_code, body } => write!(f, "Billing error (status {}): {}", status_code, body),
}
}
}
@@ -5,6 +5,7 @@ use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
use crate::requests::generate_video::request_types::*;
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
use crate::utils::categorize_seedance2pro_error::categorize_seedance2pro_error;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
use wreq::Client;
@@ -308,10 +309,7 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
info!("Response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
return Err(categorize_seedance2pro_error(status, response_body));
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
@@ -0,0 +1,111 @@
use wreq::StatusCode;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
/// Known error message substrings that indicate a billing/credits error.
const BILLING_ERROR_MARKERS: &[&str] = &[
"credits not enough",
];
/// Categorize a non-success HTTP response into a specific or generic error.
///
/// Checks the response body for known patterns (e.g. billing errors) and returns
/// a specific error variant when possible, falling back to
/// `UncategorizedBadResponseWithStatusAndBody` otherwise.
pub fn categorize_seedance2pro_error(
status_code: StatusCode,
body: String,
) -> Seedance2ProError {
if is_billing_error(&body) {
return Seedance2ProSpecificApiError::BillingError {
status_code,
body,
}.into();
}
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
}.into()
}
/// Check if the response body contains a known billing error marker.
fn is_billing_error(body: &str) -> bool {
let body_lower = body.to_lowercase();
BILLING_ERROR_MARKERS.iter().any(|marker| body_lower.contains(marker))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::seedance2pro_error::Seedance2ProError;
#[test]
fn real_not_enough_credits_response() {
let body = std::fs::read_to_string("test_data/responses/not_enough_credits.json")
.expect("Failed to read test data file");
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
match error {
Seedance2ProError::ApiSpecific(
Seedance2ProSpecificApiError::BillingError { status_code, body }
) => {
assert_eq!(status_code, StatusCode::BAD_REQUEST);
assert!(body.contains("credits not enough"));
}
other => panic!("Expected BillingError, got: {:?}", other),
}
}
#[test]
fn billing_error_case_insensitive() {
let body = r#"{"error":"Credits Not Enough"}"#.to_string();
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
match error {
Seedance2ProError::ApiSpecific(
Seedance2ProSpecificApiError::BillingError { .. }
) => {}
other => panic!("Expected BillingError, got: {:?}", other),
}
}
#[test]
fn unknown_error_falls_back_to_uncategorized() {
let body = r#"{"error":"something else went wrong"}"#.to_string();
let error = categorize_seedance2pro_error(
StatusCode::INTERNAL_SERVER_ERROR,
body.clone(),
);
match error {
Seedance2ProError::ApiGeneric(
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: b }
) => {
assert_eq!(status_code, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(b, body);
}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
#[test]
fn empty_body_falls_back_to_uncategorized() {
let error = categorize_seedance2pro_error(
StatusCode::BAD_REQUEST,
String::new(),
);
match error {
Seedance2ProError::ApiGeneric(
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
) => {}
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
}
}
}
@@ -1 +1,2 @@
pub mod categorize_seedance2pro_error;
pub mod common_headers;
@@ -0,0 +1,25 @@
[
{
"error": {
"json": {
"message": "credits not enough",
"code": -32600,
"data": {
"code": "BAD_REQUEST",
"httpStatus": 400,
"path": "workflow.runTask",
"zodError": null
},
"stack": null
},
"meta": {
"values": {
"stack": [
"undefined"
]
},
"v": 1
}
}
}
]
+49 -85
View File
@@ -66,7 +66,11 @@ pub struct PageSentResult {
pub short_id: Option<String>,
}
// =============== Implementation ===============
impl PagerClient {
// --- Constructor ---
pub fn new(
client_config: PagerClientConfig,
application_name: Option<String>,
@@ -77,6 +81,8 @@ impl PagerClient {
Self { client_config, application_name, environment, hostname, service_id }
}
// --- Public interface ---
pub fn is_noop(&self) -> bool {
matches!(self.client_config, PagerClientConfig::NoOp)
}
@@ -94,6 +100,8 @@ impl PagerClient {
}
}
// --- Private helpers (called by send_page) ---
async fn send_page_via_rootly(
&self,
notification: &NotificationDetails,
@@ -115,92 +123,13 @@ impl PagerClient {
debug!("Sending page via Rootly (source={}): {}", source, notification.title);
let mut labels: Vec<(String, String)> = Vec::new();
let labels = self.build_labels(notification);
if let Some(name) = &self.application_name {
labels.push(("application_name".to_string(), name.clone()));
}
if let Some(id) = &self.service_id {
labels.push(("service_id".to_string(), id.clone()));
}
if let Some(env) = &self.environment {
labels.push(("environment".to_string(), env.clone()));
}
if let Some(h) = &self.hostname {
labels.push(("hostname".to_string(), h.clone()));
}
if let Some(method) = &notification.http_method {
labels.push(("http_method".to_string(), method.clone()));
}
if let Some(path) = &notification.http_path {
labels.push(("http_path".to_string(), path.clone()));
}
if let Some(status_code) = notification.http_status_code {
labels.push(("http_status_code".to_string(), status_code.to_string()));
}
let labels = if labels.is_empty() { None } else { Some(labels) };
// Enrich the description with context and hostname if present.
let description = match &notification.description {
Some(desc) => {
let mut parts = vec![desc.clone()];
match (&self.application_name, &self.service_id) {
(Some(name), Some(id)) => {
parts.push(format!("Application: {} (service_id: {})", name, id));
}
(Some(name), None) => {
parts.push(format!("Application: {}", name));
}
(None, Some(id)) => {
parts.push(format!("Service ID: {}", id));
}
(None, None) => {}
}
if let Some(method) = &notification.http_method {
parts.push(format!("HTTP Method: {}", method));
}
if let Some(path) = &notification.http_path {
parts.push(format!("HTTP Path: {}", path));
}
if let Some(status_code) = notification.http_status_code {
parts.push(format!("HTTP Status Code: {}", status_code));
}
if let Some(user_token) = &notification.user_token {
parts.push(format!("User Token: {}", user_token));
}
if let Some(media_file_token) = &notification.media_file_token {
parts.push(format!("Media File Token: {}", media_file_token));
}
if let Some(inference_job_token) = &notification.inference_job_token {
parts.push(format!("Inference Job Token: {}", inference_job_token));
}
if let Some(third_party_id) = &notification.third_party_id {
parts.push(format!("Third Party ID: {}", third_party_id));
}
if let Some(h) = &self.hostname {
parts.push(format!("Hostname: {}", h));
}
Some(parts.join("\n\n"))
}
None => None,
};
let description = notification.build_enriched_description(
self.application_name.as_deref(),
self.service_id.as_deref(),
self.hostname.as_deref(),
);
// https://docs.rootly.com/api-reference/alerts/creates-an-alert
let result = create_alert(CreateAlertArgs {
@@ -241,4 +170,39 @@ impl PagerClient {
}
}
}
/// Build structured labels from the client config and notification fields.
fn build_labels(&self, notification: &NotificationDetails) -> Option<Vec<(String, String)>> {
let mut labels: Vec<(String, String)> = Vec::new();
if let Some(name) = &self.application_name {
labels.push(("application_name".to_string(), name.clone()));
}
if let Some(id) = &self.service_id {
labels.push(("service_id".to_string(), id.clone()));
}
if let Some(env) = &self.environment {
labels.push(("environment".to_string(), env.clone()));
}
if let Some(h) = &self.hostname {
labels.push(("hostname".to_string(), h.clone()));
}
if let Some(method) = &notification.http_method {
labels.push(("http_method".to_string(), method.clone()));
}
if let Some(path) = &notification.http_path {
labels.push(("http_path".to_string(), path.clone()));
}
if let Some(status_code) = notification.http_status_code {
labels.push(("http_status_code".to_string(), status_code.to_string()));
}
if labels.is_empty() { None } else { Some(labels) }
}
}
@@ -63,6 +63,7 @@ mod tests {
media_file_token: None,
inference_job_token: None,
third_party_id: None,
maybe_error: None,
}
}
@@ -222,6 +223,7 @@ mod tests {
media_file_token: None,
inference_job_token: None,
third_party_id: None,
maybe_error: None,
}
}
@@ -1,162 +1,220 @@
use chrono::{DateTime, Utc};
use std::fmt::{Debug, Display};
use crate::notification::generate_deduplication_key::generate_deduplication_key;
use crate::notification::notification_urgency::NotificationUrgency;
/// Details for a pager notification.
#[derive(Clone, Debug)]
#[derive(Debug)]
pub struct NotificationDetails {
/// Title or summary of the alert.
pub title: String,
pub(crate) title: String,
/// Full details for the alert.
pub description: Option<String>,
/// When the event occurred.
pub event_time: DateTime<Utc>,
/// HTTP method associated with the event, if any.
pub http_method: Option<String>,
/// HTTP endpoint path associated with the event, if any.
pub http_path: Option<String>,
/// HTTP status code associated with the event, if any.
pub http_status_code: Option<u16>,
/// Whether this notification originated from an error.
pub is_from_error: bool,
pub(crate) description: Option<String>,
/// Urgency level for the notification.
pub urgency: Option<NotificationUrgency>,
pub(crate) urgency: Option<NotificationUrgency>,
/// When the event occurred.
pub(crate) event_time: DateTime<Utc>,
/// The error that triggered this notification, if any.
pub(crate) maybe_error: Option<std::sync::Arc<dyn std::error::Error + Send + Sync + 'static>>,
/// Whether this notification originated from an error.
pub(crate) is_from_error: bool,
/// HTTP method associated with the event, if any.
pub(crate) http_method: Option<String>,
/// HTTP endpoint path associated with the event, if any.
pub(crate) http_path: Option<String>,
/// HTTP status code associated with the event, if any.
pub(crate) http_status_code: Option<u16>,
/// User token associated with the event, if any.
pub user_token: Option<String>,
pub(crate) user_token: Option<String>,
/// Media file token associated with the event, if any.
pub media_file_token: Option<String>,
pub(crate) media_file_token: Option<String>,
/// Inference job token associated with the event, if any.
pub inference_job_token: Option<String>,
pub(crate) inference_job_token: Option<String>,
/// Third-party identifier associated with the event, if any.
pub third_party_id: Option<String>,
pub(crate) third_party_id: Option<String>,
}
impl NotificationDetails {
/// Return a key that can group notifications for deduplication,
/// which will prevent spamming the same page over and over.
pub fn to_deduplication_key(&self) -> String {
generate_deduplication_key(self)
}
/// Create a notification with a title and description.
pub fn with_title_and_description(title: String, description: String) -> Self {
Self {
title,
description: Some(description),
event_time: Utc::now(),
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: false,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
}
}
// =============== Description Building ===============
/// Create a notification with just a title.
pub fn with_title(title: String) -> Self {
Self {
title,
description: None,
event_time: Utc::now(),
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: false,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
}
}
/// Create a notification from any error type.
/// Build the full enriched description for this notification.
///
/// Formats the error into a structured description that includes:
/// - The error message
/// - The error's source chain (if any)
/// - A backtrace (if available via `std::backtrace`)
/// - The timestamp of the event
pub fn from_error<E: Debug + Display>(error: &E) -> Self {
let title = format!("{}", error);
/// Assembles atomic sections in a consistent order:
/// 1. User-provided description
/// 2. Error chain (if a boxed error is attached)
/// 3. Event time
/// 4. HTTP context
/// 5. Service tokens
/// 6. Application/service identity and hostname
pub(crate) fn build_enriched_description(
&self,
application_name: Option<&str>,
service_id: Option<&str>,
hostname: Option<&str>,
) -> Option<String> {
let mut sections: Vec<String> = Vec::new();
// Truncate the title to a reasonable length for alert titles.
let title = if title.len() > 200 {
format!("{}...", &title[..197])
// 1. User-provided description
if let Some(desc) = &self.description {
sections.push(desc.clone());
}
// 2. Error chain
if let Some(error_section) = self.format_error_chain() {
sections.push(error_section);
}
// 3. Event time
sections.push(self.format_event_time());
// 4. HTTP context
if let Some(http_section) = self.format_http_context() {
sections.push(http_section);
}
// 5. Service tokens
if let Some(tokens_section) = self.format_service_tokens() {
sections.push(tokens_section);
}
// 6. Application/service identity and hostname
if let Some(identity_section) = Self::format_app_identity(application_name, service_id, hostname) {
sections.push(identity_section);
}
if sections.is_empty() {
None
} else {
title
};
Some(sections.join("\n\n"))
}
}
let event_time = Utc::now();
/// Format the error chain from the boxed error, walking the source chain.
fn format_error_chain(&self) -> Option<String> {
let error = self.maybe_error.as_ref()?;
let mut description_parts: Vec<String> = Vec::new();
let mut parts: Vec<String> = Vec::new();
description_parts.push(format!("Event time: {}", event_time.format("%Y-%m-%d %H:%M:%S UTC")));
description_parts.push(String::new());
description_parts.push(format!("Error: {}", error));
// Top-level error
parts.push(format!("Error: {}", error));
// Include the Debug representation if it differs from Display (often has more detail).
// Debug representation if it differs
let debug_repr = format!("{:?}", error);
let display_repr = format!("{}", error);
if debug_repr != display_repr {
description_parts.push(String::new());
description_parts.push(format!("Debug: {}", debug_repr));
parts.push(format!("Debug: {}", debug_repr));
}
// Attempt to walk the error source chain.
// NB: We use the Debug trait here since we can't require std::error::Error
// (that would require E: 'static + Error which is too restrictive for callers).
// Walk the source chain
let mut depth = 0;
let mut source = error.source();
while let Some(cause) = source {
depth += 1;
parts.push(format!("Caused by ({depth}): {cause}"));
// Try to capture a backtrace from the current call site.
#[cfg(feature = "backtrace")]
{
let bt = std::backtrace::Backtrace::capture();
if bt.status() == std::backtrace::BacktraceStatus::Captured {
description_parts.push(String::new());
description_parts.push(format!("Backtrace:\n{}", bt));
let cause_debug = format!("{:?}", cause);
let cause_display = format!("{}", cause);
if cause_debug != cause_display {
parts.push(format!(" Debug ({depth}): {cause_debug}"));
}
source = cause.source();
}
let description = description_parts.join("\n");
Self {
title,
description: Some(description),
event_time,
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: true,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
}
Some(parts.join("\n"))
}
/// Create a notification from an error, with a custom title prefix.
pub fn from_error_with_context<E: Debug + Display>(context: &str, error: &E) -> Self {
let mut notification = Self::from_error(error);
notification.title = format!("{}: {}", context, notification.title);
if notification.title.len() > 200 {
notification.title = format!("{}...", &notification.title[..197]);
/// Format the event time.
fn format_event_time(&self) -> String {
format!("Event time: {}", self.event_time.format("%Y-%m-%d %H:%M:%S UTC"))
}
/// Format HTTP context (method, path, status code) if any fields are present.
fn format_http_context(&self) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(method) = &self.http_method {
parts.push(format!("HTTP Method: {}", method));
}
notification
if let Some(path) = &self.http_path {
parts.push(format!("HTTP Path: {}", path));
}
if let Some(status_code) = self.http_status_code {
parts.push(format!("HTTP Status Code: {}", status_code));
}
if parts.is_empty() { None } else { Some(parts.join("\n")) }
}
/// Format service tokens (user, media file, inference job, third party) if any are present.
fn format_service_tokens(&self) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(user_token) = &self.user_token {
parts.push(format!("User Token: {}", user_token));
}
if let Some(media_file_token) = &self.media_file_token {
parts.push(format!("Media File Token: {}", media_file_token));
}
if let Some(inference_job_token) = &self.inference_job_token {
parts.push(format!("Inference Job Token: {}", inference_job_token));
}
if let Some(third_party_id) = &self.third_party_id {
parts.push(format!("Third Party ID: {}", third_party_id));
}
if parts.is_empty() { None } else { Some(parts.join("\n")) }
}
/// Format the application name, service ID, and hostname.
fn format_app_identity(
application_name: Option<&str>,
service_id: Option<&str>,
hostname: Option<&str>,
) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
match (application_name, service_id) {
(Some(name), Some(id)) => {
parts.push(format!("Application: {} (service_id: {})", name, id));
}
(Some(name), None) => {
parts.push(format!("Application: {}", name));
}
(None, Some(id)) => {
parts.push(format!("Service ID: {}", id));
}
(None, None) => {}
}
if let Some(h) = hostname {
parts.push(format!("Hostname: {}", h));
}
if parts.is_empty() { None } else { Some(parts.join("\n")) }
}
}
@@ -1,22 +1,28 @@
use chrono::{DateTime, Utc};
use std::fmt::{Debug, Display};
use std::sync::Arc;
use chrono::{DateTime, Utc};
use crate::notification::notification_details::NotificationDetails;
use crate::notification::notification_urgency::NotificationUrgency;
pub struct NotificationDetailsBuilder {
title: String,
description: Option<String>,
event_time: DateTime<Utc>,
http_method: Option<String>,
http_path: Option<String>,
http_status_code: Option<u16>,
is_from_error: bool,
urgency: Option<NotificationUrgency>,
user_token: Option<String>,
media_file_token: Option<String>,
inference_job_token: Option<String>,
third_party_id: Option<String>,
pub(crate) description: Option<String>,
pub(crate) urgency: Option<NotificationUrgency>,
pub(crate) event_time: DateTime<Utc>,
pub(crate) maybe_error: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
pub(crate) is_from_error: bool,
pub(crate) http_method: Option<String>,
pub(crate) http_path: Option<String>,
pub(crate) http_status_code: Option<u16>,
pub(crate) user_token: Option<String>,
pub(crate) media_file_token: Option<String>,
pub(crate) inference_job_token: Option<String>,
pub(crate) third_party_id: Option<String>,
}
impl NotificationDetailsBuilder {
@@ -25,12 +31,13 @@ impl NotificationDetailsBuilder {
Self {
title,
description: None,
urgency: None,
event_time: Utc::now(),
maybe_error: None,
is_from_error: false,
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: false,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
@@ -38,29 +45,30 @@ impl NotificationDetailsBuilder {
}
}
/// Create a builder from an error.
///
/// Sets `is_from_error` to true and derives the summary and description
/// from the error, matching the behavior of `NotificationDetails::from_error`.
pub fn from_error<E: Debug + Display>(error: &E) -> Self {
// TODO(bt,2026-03-30): Clean this up
let details = NotificationDetails::from_error(error);
/// Create a builder from a boxed error. Converts to `Arc` internally for shareability.
pub fn from_boxed_error(error: Box<dyn std::error::Error + Send + Sync + 'static>) -> Self {
Self::from_error(Arc::from(error))
}
/// Create a builder from an `Arc`'d error.
pub fn from_error(error: Arc<dyn std::error::Error + Send + Sync + 'static>) -> Self {
Self {
title: details.title,
description: details.description,
event_time: details.event_time,
title: "Notification from Error".to_string(),
description: None,
urgency: None,
event_time: Utc::now(),
maybe_error: Some(error),
is_from_error: true,
http_method: None,
http_path: None,
http_status_code: None,
is_from_error: true,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
}
}
pub fn set_title(mut self, title: String) -> Self {
self.title = title;
self
@@ -71,6 +79,16 @@ impl NotificationDetailsBuilder {
self
}
pub fn set_urgency(mut self, urgency: Option<NotificationUrgency>) -> Self {
self.urgency = urgency;
self
}
pub fn set_error(mut self, error: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
self.maybe_error = error;
self
}
pub fn set_http_method(mut self, http_method: Option<String>) -> Self {
self.http_method = http_method;
self
@@ -86,11 +104,6 @@ impl NotificationDetailsBuilder {
self
}
pub fn set_urgency(mut self, urgency: Option<NotificationUrgency>) -> Self {
self.urgency = urgency;
self
}
pub fn set_user_token(mut self, user_token: Option<String>) -> Self {
self.user_token = user_token;
self
@@ -115,12 +128,13 @@ impl NotificationDetailsBuilder {
NotificationDetails {
title: self.title,
description: self.description,
urgency: self.urgency,
event_time: self.event_time,
maybe_error: self.maybe_error,
is_from_error: self.is_from_error,
http_method: self.http_method,
http_path: self.http_path,
http_status_code: self.http_status_code,
is_from_error: self.is_from_error,
urgency: self.urgency,
user_token: self.user_token,
media_file_token: self.media_file_token,
inference_job_token: self.inference_job_token,
@@ -11,7 +11,9 @@ pub fn alert_pager_and_return_err<T>(
err: anyhow::Error,
job: Option<&PendingSeedance2ProJob>,
) -> anyhow::Result<T> {
let mut builder = NotificationDetailsBuilder::from_error(&err)
let err_message = format!("{:#}", err);
let mut builder = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title(title.to_string())
.set_urgency(Some(NotificationUrgency::Medium));
@@ -28,5 +30,5 @@ pub fn alert_pager_and_return_err<T>(
error!("Failed to enqueue pager alert: {:?}", pager_err);
}
Err(err)
Err(anyhow::anyhow!(err_message))
}
@@ -85,7 +85,7 @@ pub async fn process_failed_job(
err,
);
let notification = NotificationDetailsBuilder::from_error(&err)
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Seedance2Pro refund failed".to_string())
.set_inference_job_token(Some(job.job_token.to_string()))
.set_third_party_id(Some(job.order_id.to_string()))
@@ -140,7 +140,7 @@ pub async fn process_failed_job(
err
);
let notification = NotificationDetailsBuilder::from_error(&err)
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Seedance2Pro mark job failed error".to_string())
.set_urgency(Some(NotificationUrgency::Medium))
.build();
@@ -9,7 +9,9 @@ pub fn alert_pager_and_return_err<T>(
title: &str,
err: anyhow::Error,
) -> anyhow::Result<T> {
let notification = NotificationDetailsBuilder::from_error(&err)
let err_message = format!("{:#}", err);
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title(title.to_string())
.set_urgency(Some(NotificationUrgency::Medium))
.build();
@@ -18,5 +20,5 @@ pub fn alert_pager_and_return_err<T>(
error!("Failed to enqueue pager alert: {:?}", pager_err);
}
Err(err)
Err(anyhow::anyhow!(err_message))
}
@@ -0,0 +1,332 @@
use std::fmt::{Display, Formatter};
use std::sync::Arc;
use actix_http::StatusCode;
use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError};
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
/// An error type for actix-web handlers that wraps causal errors for debugging
/// and paging while presenting safe, generic HTTP responses to users.
///
/// ## Usage
///
/// ```ignore
/// pub async fn my_handler(...) -> Result<Json<MyResponse>, AdvancedCommonWebError> {
/// // sqlx errors, anyhow errors, session errors — all convert via ?
/// let user = require_user_session_using_connection(&req, ...)?;
/// let data = some_db_query(&pool).await?;
///
/// if data.is_none() {
/// return Err(AdvancedCommonWebError::NotFound);
/// }
///
/// Ok(Json(MyResponse { data }))
/// }
/// ```
///
/// Errors converted via `From<T>` become `UncaughtServerError` (always 500).
/// The wrapped cause is never shown to users but is available to the error
/// alerting middleware for paging and logging.
pub enum AdvancedCommonWebError {
/// 400 Bad Request with a user-facing message.
BadInputWithSimpleMessage(String),
/// 401 Unauthorized.
NotAuthorized,
/// 404 Not Found.
NotFound,
/// 402 Payment Required.
PaymentRequired,
/// Uncaught errors are always 500 Internal Server Error.
/// The user will never see the error cause or message, but our
/// middleware will handle alerting, logging, etc.
///
/// Stored in `Arc` so the error alerting middleware can clone it for paging
/// without consuming the original.
UncaughtServerError(Arc<dyn std::error::Error + Send + Sync + 'static>),
}
// =============== Public accessors ===============
impl AdvancedCommonWebError {
/// Wrap any error as an `UncaughtServerError`.
pub fn from_error(error: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::UncaughtServerError(Arc::new(error))
}
/// Extract the wrapped causal error (only present for `UncaughtServerError`).
pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
match self {
Self::UncaughtServerError(err) => Some(err.as_ref()),
_ => None,
}
}
/// Clone the wrapped causal error as an `Arc` (only present for `UncaughtServerError`).
/// Useful for passing the error to the pager system without consuming the original.
pub fn clone_cause_arc(&self) -> Option<Arc<dyn std::error::Error + Send + Sync + 'static>> {
match self {
Self::UncaughtServerError(err) => Some(Arc::clone(err)),
_ => None,
}
}
/// Whether this is a server error (500).
pub fn is_server_error(&self) -> bool {
matches!(self, Self::UncaughtServerError(_))
}
}
// =============== Display / Debug / Error ===============
impl Display for AdvancedCommonWebError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadInputWithSimpleMessage(msg) => write!(f, "Bad input: {}", msg),
Self::NotAuthorized => write!(f, "Not authorized"),
Self::NotFound => write!(f, "Not found"),
Self::PaymentRequired => write!(f, "Payment required"),
Self::UncaughtServerError(err) => write!(f, "Server error: {}", err),
}
}
}
impl std::fmt::Debug for AdvancedCommonWebError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadInputWithSimpleMessage(msg) => write!(f, "BadInputWithSimpleMessage({:?})", msg),
Self::NotAuthorized => write!(f, "NotAuthorized"),
Self::NotFound => write!(f, "NotFound"),
Self::PaymentRequired => write!(f, "PaymentRequired"),
Self::UncaughtServerError(err) => write!(f, "UncaughtServerError({:?})", err),
}
}
}
impl std::error::Error for AdvancedCommonWebError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::UncaughtServerError(err) => Some(err.as_ref()),
_ => None,
}
}
}
// =============== Actix ResponseError ===============
impl ResponseError for AdvancedCommonWebError {
fn status_code(&self) -> StatusCode {
match self {
Self::BadInputWithSimpleMessage(_) => StatusCode::BAD_REQUEST,
Self::NotAuthorized => StatusCode::UNAUTHORIZED,
Self::NotFound => StatusCode::NOT_FOUND,
Self::PaymentRequired => StatusCode::PAYMENT_REQUIRED,
Self::UncaughtServerError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
let status = self.status_code();
match self {
Self::BadInputWithSimpleMessage(msg) => {
HttpResponse::BadRequest()
.json(JsonErrorWithMessage {
success: false,
error_code: status.as_u16(),
error_code_str: status.canonical_reason(),
message: msg,
})
}
_ => {
HttpResponseBuilder::new(status)
.json(JsonErrorWithoutMessage {
success: false,
error_code: status.as_u16(),
error_code_str: status.canonical_reason(),
})
}
}
}
}
// =============== From impls (automatic ? conversion) ===============
impl From<sqlx::Error> for AdvancedCommonWebError {
fn from(err: sqlx::Error) -> Self {
Self::from_error(err)
}
}
impl From<anyhow::Error> for AdvancedCommonWebError {
fn from(err: anyhow::Error) -> Self {
// anyhow::Error doesn't impl std::error::Error, so we go through Box -> Arc.
let boxed: Box<dyn std::error::Error + Send + Sync> = err.into();
Self::UncaughtServerError(Arc::from(boxed))
}
}
impl From<serde_json::Error> for AdvancedCommonWebError {
fn from(err: serde_json::Error) -> Self {
Self::from_error(err)
}
}
impl From<RequireUserSessionError> for AdvancedCommonWebError {
fn from(value: RequireUserSessionError) -> Self {
match value {
RequireUserSessionError::NotAuthorized => Self::NotAuthorized,
RequireUserSessionError::ServerError => Self::from_error(value),
}
}
}
// =============== Serialization helpers (private) ===============
#[derive(Debug, Serialize)]
struct JsonErrorWithoutMessage {
success: bool,
error_code: u16,
error_code_str: Option<&'static str>,
}
#[derive(Debug, Serialize)]
struct JsonErrorWithMessage<'a> {
success: bool,
error_code: u16,
error_code_str: Option<&'static str>,
message: &'a str,
}
// =============== Tests ===============
#[cfg(test)]
mod tests {
use super::*;
use actix_http::body::MessageBody;
#[test]
fn bad_input_returns_400_with_message() {
let error = AdvancedCommonWebError::BadInputWithSimpleMessage("name is required".to_string());
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert!(!error.is_server_error());
assert!(error.cause().is_none());
let response = error.error_response();
let bytes = response.into_body().try_into_bytes().unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(body.contains("\"message\":\"name is required\""));
assert!(body.contains("\"error_code\":400"));
}
#[test]
fn not_found_returns_404() {
let error = AdvancedCommonWebError::NotFound;
assert_eq!(error.status_code(), StatusCode::NOT_FOUND);
assert!(!error.is_server_error());
}
#[test]
fn not_authorized_returns_401() {
let error = AdvancedCommonWebError::NotAuthorized;
assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn payment_required_returns_402() {
let error = AdvancedCommonWebError::PaymentRequired;
assert_eq!(error.status_code(), StatusCode::PAYMENT_REQUIRED);
}
#[test]
fn uncaught_io_error_returns_500_and_hides_cause() {
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "disk exploded");
let error = AdvancedCommonWebError::from_error(io_err);
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(error.is_server_error());
// Cause is accessible for middleware
let cause = error.cause().unwrap();
assert_eq!(format!("{}", cause), "disk exploded");
// But never shown to the user in the HTTP response
let response = error.error_response();
let bytes = response.into_body().try_into_bytes().unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(!body.contains("disk exploded"));
assert!(body.contains("\"error_code\":500"));
}
#[test]
fn anyhow_error_converts_to_500_via_from() {
let error: AdvancedCommonWebError = anyhow::anyhow!("something broke in the pipeline").into();
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(error.is_server_error());
let cause = error.cause().unwrap();
assert!(format!("{}", cause).contains("something broke in the pipeline"));
}
#[test]
fn nested_anyhow_error_preserves_context_chain() {
let inner = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused");
let with_context = anyhow::Error::new(inner).context("failed to connect to database");
let error: AdvancedCommonWebError = with_context.into();
assert!(error.is_server_error());
let cause = error.cause().unwrap();
let display = format!("{}", cause);
assert!(display.contains("failed to connect to database"));
// The original io error should be in the source chain
let source = cause.source();
assert!(source.is_some());
}
#[test]
fn require_user_session_not_authorized_maps_to_401() {
let error: AdvancedCommonWebError = RequireUserSessionError::NotAuthorized.into();
assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
assert!(!error.is_server_error());
}
#[test]
fn require_user_session_server_error_wraps_cause() {
let error: AdvancedCommonWebError = RequireUserSessionError::ServerError.into();
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(error.is_server_error());
assert!(error.cause().is_some());
assert!(format!("{}", error.cause().unwrap()).contains("ServerError"));
}
#[test]
fn serde_json_error_converts_to_500() {
let bad_json = "not json at all{{{";
let serde_err: serde_json::Error = serde_json::from_str::<serde_json::Value>(bad_json).unwrap_err();
let error: AdvancedCommonWebError = serde_err.into();
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(error.is_server_error());
let cause = error.cause().unwrap();
assert!(format!("{}", cause).contains("expected"));
}
#[test]
fn error_source_returns_wrapped_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "root cause");
let error = AdvancedCommonWebError::from_error(io_err);
let source = std::error::Error::source(&error);
assert!(source.is_some());
assert_eq!(format!("{}", source.unwrap()), "root cause");
}
#[test]
fn non_server_errors_have_no_source() {
assert!(std::error::Error::source(&AdvancedCommonWebError::NotFound).is_none());
assert!(std::error::Error::source(&AdvancedCommonWebError::NotAuthorized).is_none());
assert!(std::error::Error::source(&AdvancedCommonWebError::PaymentRequired).is_none());
}
}
@@ -1,3 +1,4 @@
pub mod advanced_common_web_error;
pub mod common_web_error;
pub mod media;
pub mod media_file_origin_details;
@@ -222,7 +222,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
Err(err) => {
warn!("Regular session fallback also failed for user {:?}: {:?}", user_token, err);
if let Err(page_err) = server_state.pager.enqueue_page(
NotificationDetailsBuilder::from_error(&err)
NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Seedance 2.0 generation failed (whitelist + fallback)".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build()
@@ -256,7 +256,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
Err(err) => {
warn!("Error calling seedance2pro generate_video: {:?}", err);
if let Err(page_err) = server_state.pager.enqueue_page(
NotificationDetailsBuilder::from_error(&err)
NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Seedance 2.0 generation failed".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build()
@@ -45,6 +45,8 @@ impl ResponseError for FalWebhookError {
}
}
impl std::error::Error for FalWebhookError {}
// NB: Not using derive_more::Display since Clion doesn't understand it.
impl fmt::Display for FalWebhookError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -117,27 +119,32 @@ pub async fn fal_webhook_handler(
}
};
if let Err(ref err) = result {
error!("FAL webhook error for request_id {}: {:?}", request_id, err);
match result {
Ok(success) => Ok(success),
Err(err) => {
error!("FAL webhook error for request_id {}: {:?}", request_id, err);
let notification = NotificationDetailsBuilder::from_error(err)
.set_title("FAL webhook processing failed".to_string())
.set_description(Some(format!(
"FAL webhook failed for request_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
request_id, err, raw_body,
)))
.set_third_party_id(Some(request_id.to_string()))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some(http_request.method().to_string()))
.set_http_path(Some(http_request.path().to_string()))
.build();
let description = format!(
"FAL webhook failed for request_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
request_id, err, raw_body,
);
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
error!("Failed to enqueue FAL webhook pager alert: {:?}", pager_err);
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("FAL webhook processing failed".to_string())
.set_description(Some(description))
.set_third_party_id(Some(request_id.to_string()))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some(http_request.method().to_string()))
.set_http_path(Some(http_request.path().to_string()))
.build();
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
error!("Failed to enqueue FAL webhook pager alert: {:?}", pager_err);
}
Err(FalWebhookError::ServerError)
}
}
result
}
/// Send a pager alert for early parse failures (before we have a request_id).
@@ -0,0 +1,49 @@
use log::{debug, warn};
use pager::client::pager::Pager;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
/// Check `AdvancedCommonWebError` and alert on uncaught server errors.
/// Returns `true` if the error was handled (alerted or intentionally skipped).
pub(super) fn check_advanced_common_web_error(
pager: &Pager,
method: &str,
path: &str,
error: &AdvancedCommonWebError,
) -> bool {
if !error.is_server_error() {
// Non-500 errors (400, 401, 404, 402) are intentional — don't alert.
return true;
}
let title = format!("UncaughtServerError on {} {}", method, path);
// Clone the Arc'd causal error so the notification owns a reference to it.
let cause_arc = error.clone_cause_arc();
let mut builder = if let Some(arc_err) = cause_arc {
NotificationDetailsBuilder::from_error(arc_err)
.set_title(title)
} else {
NotificationDetailsBuilder::from_title(title)
};
builder = builder
.set_http_method(Some(method.to_string()))
.set_http_path(Some(path.to_string()))
.set_http_status_code(Some(500))
.set_urgency(Some(NotificationUrgency::Medium));
let notification = builder.build();
if let Err(err) = pager.enqueue_page(notification) {
warn!("Error alerting middleware: failed to enqueue page: {:?}", err);
} else {
debug!("Error alerting middleware: enqueued alert for AdvancedCommonWebError::UncaughtServerError");
}
true
}
@@ -6,8 +6,10 @@ use actix_web::Error;
use pager::client::pager::Pager;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::state::flags::paging_flags::PagingFlags;
use super::check_advanced_common_web_error::check_advanced_common_web_error;
use super::check_common_web_error::check_common_web_error;
use super::check_status_code_fallback::check_status_code_fallback;
@@ -121,6 +123,12 @@ fn check_ok_response_for_alerts<B>(
if let Some(err) = response.response().error() {
// --- Typed matchers (add new check_*.rs modules and downcast branches here) ---
if let Some(advanced_err) = err.as_error::<AdvancedCommonWebError>() {
if check_advanced_common_web_error(pager, method, path, advanced_err) {
return;
}
}
if let Some(common_err) = err.as_error::<CommonWebError>() {
if check_common_web_error(pager, method, path, common_err) {
return;
@@ -141,6 +149,12 @@ fn check_err_for_alerts(
) {
// --- Typed matchers (add new check_*.rs modules and downcast branches here) ---
if let Some(advanced_err) = err.as_error::<AdvancedCommonWebError>() {
if check_advanced_common_web_error(pager, method, path, advanced_err) {
return;
}
}
if let Some(common_err) = err.as_error::<CommonWebError>() {
if check_common_web_error(pager, method, path, common_err) {
return;
@@ -1,3 +1,4 @@
mod check_advanced_common_web_error;
mod check_common_web_error;
mod check_status_code_fallback;
@@ -35,7 +35,7 @@ pub async fn db_health_checker_thread(
Ok(_) => {},
}
let notification = NotificationDetailsBuilder::from_error(&database_error)
let notification = NotificationDetailsBuilder::from_boxed_error(database_error.into())
.set_title("DB health check thread failed check".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build();
@@ -11,6 +11,8 @@ import {
faBomb,
faCircleExclamation,
faTriangleExclamation,
faCopy,
faCheck,
} from "@fortawesome/pro-solid-svg-icons";
import { Modal } from "@storyteller/ui-modal";
import {
@@ -222,7 +224,7 @@ const InProgressCard = ({
</div>
{onDismiss && (
<button
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
@@ -302,7 +304,7 @@ const CompletedCard = ({
</div>
{onDismiss && (
<button
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
@@ -344,6 +346,14 @@ const FailedCard = ({
task: FailedTask;
onDismiss?: () => void;
}) => {
const [copied, setCopied] = useState(false);
const [marqueePlaying, setMarqueePlaying] = useState(false);
const [promptOverflows, setPromptOverflows] = useState(false);
const promptRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = promptRef.current;
if (el) setPromptOverflows(el.scrollWidth > el.clientWidth);
}, [task.prompt]);
const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed";
const hasRefImages =
task.refImageUrls && task.refImageUrls.length > 0;
@@ -454,19 +464,73 @@ const FailedCard = ({
</Tooltip>
</div>
)}
{task.prompt && (
<Tooltip
content={task.prompt.length > 300 ? task.prompt.slice(0, 300) + "…" : task.prompt}
position="bottom"
strategy="fixed"
className="max-w-[280px] text-wrap text-xs"
zIndex={50}
delay={400}
>
<div
className="mt-1 overflow-hidden"
onMouseEnter={promptOverflows ? () => setMarqueePlaying(true) : undefined}
onMouseLeave={promptOverflows ? () => setMarqueePlaying(false) : undefined}
>
<div
ref={promptRef}
key={marqueePlaying ? "playing" : "idle"}
className="whitespace-nowrap text-[11px] italic text-base-fg/40"
style={marqueePlaying ? {
animation: "marquee 8s linear infinite",
animationDelay: "0.5s",
animationFillMode: "both",
} : undefined}
>
{task.prompt}
</div>
</div>
</Tooltip>
)}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{task.prompt && (
<Tooltip
content={copied ? "Copied!" : "Copy prompt"}
position="bottom"
strategy="fixed"
className="text-xs"
zIndex={50}
delay={300}
>
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
aria-label="Copy prompt"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(task.prompt!);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
}}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className={copied ? "text-green-400" : ""} />
</button>
</Tooltip>
)}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
)}
</div>
{onDismiss && (
<button
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<FontAwesomeIcon icon={faXmark} />
</button>
)}
</div>
</div>
);
@@ -22,10 +22,15 @@ export default {
"0%": { backgroundPosition: "200% 0" },
"100%": { backgroundPosition: "-200% 0" },
},
marquee: {
"0%": { transform: "translateX(0%)" },
"100%": { transform: "translateX(-100%)" },
},
},
animation: {
fadeIn: "fadeIn 0.2s ease-in-out",
shimmer: "shimmer 2s ease-in-out infinite",
marquee: "marquee 8s linear infinite paused",
},
},
colors: {
@@ -12,8 +12,6 @@ import {
EnqueueImageToVideoRequest,
} from "@storyteller/tauri-api";
import {
faMessageXmark,
faMessageCheck,
faWaveformLines,
faClock,
faTriangleExclamation,
@@ -42,6 +40,17 @@ import { twMerge } from "tailwind-merge";
import { toast } from "@storyteller/ui-toaster";
import { GenerationProvider } from "@storyteller/api-enums";
declare global {
interface Window {
__storeTaskEnqueueMeta?: (meta: {
prompt?: string;
refImageUrls?: string[];
modelType?: string;
timestamp: number;
}) => void;
}
}
type GROK_ASPECT_RATIO = "landscape" | "portrait" | "square";
const DEFAULT_RESOLUTIONS: SizeOption[] = [
@@ -106,8 +115,6 @@ export const PromptBoxVideo = ({
const [content, setContent] = useState<React.ReactNode>(null);
const prompt = usePromptVideoStore((s) => s.prompt);
const setPrompt = usePromptVideoStore((s) => s.setPrompt);
const useSystemPrompt = usePromptVideoStore((s) => s.useSystemPrompt);
const setUseSystemPrompt = usePromptVideoStore((s) => s.setUseSystemPrompt);
const generateWithSound = usePromptVideoStore((s) => s.generateWithSound);
const setGenerateWithSound = usePromptVideoStore(
(s) => s.setGenerateWithSound,
@@ -224,52 +231,67 @@ export const PromptBoxVideo = ({
);
};
// Sync duration with model default when switching models
// Sync duration with model default when switching models.
// Read duration from the store directly to avoid stale closure issues
// when the model and duration are updated together (e.g. during recreate).
useEffect(() => {
const currentDuration = usePromptVideoStore.getState().duration;
if (selectedModel?.durationOptions && selectedModel.defaultDuration) {
if (
duration === null ||
!selectedModel.durationOptions.includes(duration)
currentDuration === null ||
!selectedModel.durationOptions.includes(currentDuration)
) {
setDuration(selectedModel.defaultDuration);
}
} else if (duration !== null) {
} else if (currentDuration !== null) {
setDuration(null);
}
}, [selectedModel]);
// Sync resolution with model default when switching models
// Sync resolution with model default when switching models.
// Read from store directly to avoid stale closure (same as duration above).
useEffect(() => {
const currentResolution = usePromptVideoStore.getState().resolution;
if (selectedModel?.resolutionOptions && selectedModel.defaultResolution) {
if (!selectedModel.resolutionOptions.includes(resolution as string)) {
if (
!selectedModel.resolutionOptions.includes(currentResolution as string)
) {
setResolution(selectedModel.defaultResolution);
}
}
}, [selectedModel]);
// Reset input mode when switching to a model that doesn't support reference
// Reset input mode when switching to a model that doesn't support reference.
// Read from store directly to avoid stale closure (same as duration above).
useEffect(() => {
if (!selectedModel?.supportsReferenceMode && inputMode === "reference") {
const currentInputMode = usePromptVideoStore.getState().inputMode;
if (
!selectedModel?.supportsReferenceMode &&
currentInputMode === "reference"
) {
setInputMode("keyframe");
setReferenceVideos([]);
setReferenceAudios([]);
}
}, [selectedModel]);
// Reset generation count when switching away from seedance 2.0
// Reset generation count when switching away from seedance 2.0.
// Read from store directly to avoid stale closure (same as duration above).
useEffect(() => {
if (selectedModel?.id !== "seedance_2p0" && generationCount > 1) {
const currentGenerationCount =
usePromptVideoStore.getState().generationCount;
if (selectedModel?.id !== "seedance_2p0" && currentGenerationCount > 1) {
setGenerationCount(1);
}
}, [selectedModel]);
const durationRange = selectedModel?.durationOptions?.length
? {
min: selectedModel.durationOptions[0]!,
max: selectedModel.durationOptions[
selectedModel.durationOptions.length - 1
]!,
}
min: selectedModel.durationOptions[0]!,
max: selectedModel.durationOptions[
selectedModel.durationOptions.length - 1
]!,
}
: null;
const effectiveDuration = duration ?? selectedModel?.defaultDuration ?? 5;
const [localDuration, setLocalDuration] = useState(effectiveDuration);
@@ -286,9 +308,9 @@ export const PromptBoxVideo = ({
const resolutionPickerOptions: PopoverItem[] | null =
selectedModel?.resolutionOptions
? selectedModel.resolutionOptions.map((r) => ({
label: r,
selected: r === resolution,
}))
label: r,
selected: r === resolution,
}))
: null;
const handleResolutionSelect = (selectedItem: PopoverItem) => {
@@ -298,17 +320,17 @@ export const PromptBoxVideo = ({
const inputModeOptions: PopoverItem[] | null =
selectedModel?.supportsReferenceMode
? [
{
label: "Keyframe",
description: "First/Last frame",
selected: inputMode === "keyframe",
},
{
label: "Reference",
description: "Multi-media ref",
selected: inputMode === "reference",
},
]
{
label: "Keyframe",
description: "First/Last frame",
selected: inputMode === "keyframe",
},
{
label: "Reference",
description: "Multi-media ref",
selected: inputMode === "reference",
},
]
: null;
const handleInputModeSelect = (selectedItem: PopoverItem) => {
@@ -413,26 +435,26 @@ export const PromptBoxVideo = ({
const mentionItems = isReferenceMode
? [
...referenceImages.map((img, i) => ({
label: `@Image${i + 1}`,
type: "image" as const,
preview: img.url,
})),
...referenceVideos.map((vid, i) => ({
label: `@Video${i + 1}`,
type: "video" as const,
preview: vid.url,
})),
...referenceAudios.map((_aud, i) => ({
label: `@Audio${i + 1}`,
type: "audio" as const,
preview: undefined as string | undefined,
})),
].filter((item) =>
mentionFilter
? item.label.toLowerCase().includes(mentionFilter.toLowerCase())
: true,
)
...referenceImages.map((img, i) => ({
label: `@Image${i + 1}`,
type: "image" as const,
preview: img.url,
})),
...referenceVideos.map((vid, i) => ({
label: `@Video${i + 1}`,
type: "video" as const,
preview: vid.url,
})),
...referenceAudios.map((_aud, i) => ({
label: `@Audio${i + 1}`,
type: "audio" as const,
preview: undefined as string | undefined,
})),
].filter((item) =>
mentionFilter
? item.label.toLowerCase().includes(mentionFilter.toLowerCase())
: true,
)
: [];
const insertMention = (label: string) => {
@@ -503,7 +525,9 @@ export const PromptBoxVideo = ({
return;
}
if (prompt.length > maxLen) {
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
toast.error(
`Prompt exceeds the ${maxLen} character limit for this model`,
);
return;
}
@@ -621,24 +645,20 @@ export const PromptBoxVideo = ({
window.__storeTaskEnqueueMeta?.({
prompt,
refImageUrls: referenceImages
?.map((img) => img.url)
.filter(Boolean),
refImageUrls: referenceImages?.map((img) => img.url).filter(Boolean),
modelType: (selectedModel as any)?.tauriId || String(selectedModel),
timestamp: Date.now(),
});
const subscriberIds: string[] = [];
const enqueuePromises: Promise<void>[] = [];
const enqueuePromises: Promise<unknown>[] = [];
for (let i = 0; i < count; i++) {
const subscriberId = crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
subscriberIds.push(subscriberId);
enqueuePromises.push(
EnqueueImageToVideo(buildRequest(subscriberId)) as Promise<void>,
);
enqueuePromises.push(EnqueueImageToVideo(buildRequest(subscriberId)));
}
try {
@@ -904,7 +924,9 @@ export const PromptBoxVideo = ({
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}>
<span
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
>
{prompt.length} / {maxLen}
</span>
</div>
@@ -981,7 +1003,6 @@ export const PromptBoxVideo = ({
</Tooltip>
)}
{selectedModel?.generateWithSound && (
<Tooltip
content={generateWithSound ? "Sound: ON" : "Sound: OFF"}
@@ -1013,7 +1034,6 @@ export const PromptBoxVideo = ({
/>
</Tooltip>
)}
</div>
<div className="flex items-center gap-2">
{modelNeedsAnImageButNoneAreSelected && (
@@ -1043,7 +1063,9 @@ export const PromptBoxVideo = ({
onClick={handleEnqueue}
disabled={!prompt.trim()}
loading={isEnqueueing}
credits={credits != null ? credits * generationCount : credits}
credits={
credits != null ? credits * generationCount : credits
}
>
Generate
</GenerateButton>
@@ -1052,13 +1074,20 @@ export const PromptBoxVideo = ({
</div>
</div>
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
<Tooltip
content={isExpanded ? "Collapse" : "Expand"}
position="top"
className="-mb-2"
>
<button
type="button"
onClick={toggleExpand}
className="text-base-fg/30 hover:text-base-fg/90 transition-colors px-3 py-0.5"
>
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
<FontAwesomeIcon
icon={isExpanded ? faChevronUp : faChevronDown}
className="text-xs"
/>
</button>
</Tooltip>
</div>
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""
Back up a single production database table to a local SQL file using mysqldump.
Reads connection info from secrets.toml (same format as the other database scripts).
Usage:
python mysql_dump_table.py --table=users
python mysql_dump_table.py --table=media_files --directory=/tmp/backups
Requirements:
pip install pymysql
mysqldump must be on PATH
"""
import argparse
import os
import shutil
import subprocess
import sys
import tomllib
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
try:
import pymysql
except ImportError:
print("pymysql is required: pip install pymysql")
sys.exit(1)
SECRETS_PATH = Path(__file__).parent / "secrets.toml"
DEFAULT_BACKUP_DIR = Path.home() / "database" / "backups"
ALLOWED_TABLES = [
"audit_logs",
"batch_generations",
"favorites",
"featured_items",
"generic_inference_jobs",
"google_sign_in_accounts",
"media_files",
"model_weights",
"prompt_context_items",
"prompts",
"tag_uses",
"tags",
"user_bookmarks",
"user_ratings",
"user_roles",
"user_sessions",
"user_stripe_customer_links",
"user_subscriptions",
"users",
"wallet_ledger_entries",
"wallets",
]
def load_config():
if not SECRETS_PATH.exists():
print(f"Error: secrets.toml not found at {SECRETS_PATH}")
print(f"Copy secrets.toml.example to secrets.toml and fill in the connection URL.")
sys.exit(1)
with open(SECRETS_PATH, "rb") as f:
config = tomllib.load(f)
url = config["database"]["url"]
parsed = urlparse(url)
return {
"host": parsed.hostname,
"port": parsed.port or 3306,
"user": parsed.username,
"password": parsed.password,
"database": parsed.path.lstrip("/"),
}
def connect(config):
return pymysql.connect(
host=config["host"],
port=config["port"],
user=config["user"],
password=config["password"],
database=config["database"],
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
def table_exists(conn, table_name):
with conn.cursor() as cursor:
cursor.execute("SHOW TABLES LIKE %s", (table_name,))
return cursor.fetchone() is not None
def count_rows(conn, table_name):
with conn.cursor() as cursor:
cursor.execute(f"SELECT COUNT(*) AS cnt FROM `{table_name}`")
result = cursor.fetchone()
return result["cnt"]
def main():
parser = argparse.ArgumentParser(description="Back up a database table using mysqldump.")
parser.add_argument("--table", required=True, help="Name of the table to back up.")
parser.add_argument("--directory", default=None, help=f"Directory to save the dump. Default: {DEFAULT_BACKUP_DIR}")
args = parser.parse_args()
table_name = args.table
backup_dir = Path(args.directory) if args.directory else DEFAULT_BACKUP_DIR
# Validate table is in allowed list
if table_name not in ALLOWED_TABLES:
print(f"Error: '{table_name}' is not in the allowed tables list.")
print(f"Allowed tables: {', '.join(sorted(ALLOWED_TABLES))}")
sys.exit(1)
# Check mysqldump is available
if shutil.which("mysqldump") is None:
print("Error: mysqldump not found on PATH.")
sys.exit(1)
config = load_config()
# Verify table exists and count rows
conn = connect(config)
try:
if not table_exists(conn, table_name):
print(f"Error: table '{table_name}' does not exist in database '{config['database']}'.")
sys.exit(1)
row_count = count_rows(conn, table_name)
finally:
conn.close()
# Build output path
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
output_file = backup_dir / f"{table_name}_{timestamp}.sql"
# Confirm with user
print(f"Table: {table_name}")
print(f"Database: {config['database']} @ {config['host']}:{config['port']}")
print(f"Rows: {row_count:,}")
print(f"Output: {output_file}")
print()
confirm = input("Proceed with backup? [y/N] ").strip().lower()
if confirm not in ("y", "yes"):
print("Aborted.")
sys.exit(0)
# Run mysqldump
cmd = [
"mysqldump",
f"--host={config['host']}",
f"--port={config['port']}",
f"--user={config['user']}",
f"--password={config['password']}",
"--single-transaction",
"--routines",
"--triggers",
"--set-gtid-purged=OFF",
config["database"],
table_name,
]
print(f"\nRunning mysqldump for '{table_name}'...")
with open(output_file, "w") as f:
result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
print(f"Error: mysqldump failed (exit code {result.returncode})")
if result.stderr:
# Filter out the password warning which is expected
for line in result.stderr.strip().splitlines():
if "Using a password on the command line" not in line:
print(f" {line}")
sys.exit(1)
file_size = output_file.stat().st_size
if file_size < 100:
print(f"Warning: output file is suspiciously small ({file_size} bytes).")
print(f"Done. Backup saved to: {output_file} ({file_size:,} bytes)")
if __name__ == "__main__":
main()