Split view, personal modes, budget warnings, server move script
- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
scripts/smoke-test.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81ee084ff2
commit
d152ad836d
26 files changed
+836
-52
No files matched your search
@@ -151,6 +151,124 @@ pub fn plan_usage(app: &App, provider: &str, limit: f64) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// One budget gauge: what is used of a limit (monthly budgets, Go plan windows).
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct Meter {
|
||||
/// Stable id for alert state, e.g. "provider:anthropic" or "plan:opencode-go:5h".
|
||||
pub key: String,
|
||||
pub provider: String,
|
||||
/// "total", "provider", "images" or the plan window ("5h", "week", "month").
|
||||
pub kind: String,
|
||||
pub used: f64,
|
||||
pub limit: f64,
|
||||
pub ratio: f64,
|
||||
}
|
||||
|
||||
/// All configured budgets and plan windows with their current use (no remote calls).
|
||||
pub fn meters(app: &App) -> Result<Vec<Meter>> {
|
||||
let cfg = app.config();
|
||||
let spent = app.store.cost_by_provider(month_start())?;
|
||||
let mut out = Vec::new();
|
||||
let mut push = |key: String, provider: &str, kind: &str, used: f64, limit: f64| {
|
||||
if limit > 0.0 {
|
||||
out.push(Meter { key, provider: provider.into(), kind: kind.into(), used, limit, ratio: used / limit });
|
||||
}
|
||||
};
|
||||
push("total".into(), "total", "total", spent.values().sum(), cfg.server.monthly_budget);
|
||||
for (name, p) in cfg.providers.iter().filter(|(_, p)| p.enabled) {
|
||||
push(format!("provider:{name}"), name, "provider", spent.get(name).copied().unwrap_or(0.0), p.monthly_budget.unwrap_or(0.0));
|
||||
if let Some(limit) = p.plan_limit.filter(|l| *l > 0.0) {
|
||||
let plan = plan_usage(app, name, limit)?;
|
||||
for w in plan["windows"].as_array().into_iter().flatten() {
|
||||
let window = w["window"].as_str().unwrap_or("");
|
||||
push(format!("plan:{name}:{window}"), name, window, w["used"].as_f64().unwrap_or(0.0), w["limit"].as_f64().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.images.enabled {
|
||||
push("images".into(), "fal", "images", spent.get("fal").copied().unwrap_or(0.0), cfg.images.monthly_budget.unwrap_or(0.0));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
const ALERT_STATE: &str = "budget_alert_state";
|
||||
/// Alert state is global; it is kept under a pseudo user id.
|
||||
const SYSTEM: &str = "_system";
|
||||
|
||||
fn alert_text(m: &Meter, level: u8, de: bool) -> String {
|
||||
let pct = (m.ratio * 100.0).round() as i64;
|
||||
let what = match (m.kind.as_str(), de) {
|
||||
("total", true) => "Gesamtbudget des Monats".to_string(),
|
||||
("total", false) => "Monthly total budget".to_string(),
|
||||
("provider", true) => format!("Monatsbudget {}", m.provider),
|
||||
("provider", false) => format!("Monthly budget of {}", m.provider),
|
||||
("images", true) => "Bildbudget des Monats".to_string(),
|
||||
("images", false) => "Monthly image budget".to_string(),
|
||||
("5h", true) => format!("{}: 5-Stunden-Limit", m.provider),
|
||||
("5h", false) => format!("{}: 5-hour limit", m.provider),
|
||||
("week", true) => format!("{}: Wochenlimit", m.provider),
|
||||
("week", false) => format!("{}: weekly limit", m.provider),
|
||||
(_, true) => format!("{}: Monatslimit des Plans", m.provider),
|
||||
(_, false) => format!("{}: monthly plan limit", m.provider),
|
||||
};
|
||||
match (level, de) {
|
||||
(2, true) => format!("{what} ausgeschöpft ({pct} %)"),
|
||||
(2, false) => format!("{what} used up ({pct} %)"),
|
||||
(_, true) => format!("{what} zu {pct} % verbraucht"),
|
||||
(_, false) => format!("{what} {pct} % used"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push admins when a budget or plan window crosses the warning share or 100 %. Each
|
||||
/// level is sent once; dropping below (new month, rolling window) re-arms it.
|
||||
pub async fn check_alerts(app: &App) -> Result<()> {
|
||||
let warn = app.config().server.budget_warning;
|
||||
let mut state: std::collections::BTreeMap<String, u8> = app.store.user_setting(SYSTEM, ALERT_STATE)?;
|
||||
let mut alerts = Vec::new();
|
||||
let meters = meters(app)?;
|
||||
for m in &meters {
|
||||
let level = if m.ratio >= 1.0 { 2 } else if warn > 0.0 && m.ratio >= warn { 1 } else { 0 };
|
||||
let before = state.get(&m.key).copied().unwrap_or(0);
|
||||
if level > before {
|
||||
alerts.push((m.clone(), level));
|
||||
}
|
||||
state.insert(m.key.clone(), level);
|
||||
}
|
||||
state.retain(|k, _| meters.iter().any(|m| &m.key == k));
|
||||
app.store.set_user_setting(SYSTEM, ALERT_STATE, &state)?;
|
||||
if alerts.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for user in app.store.users()?.into_iter().filter(|u| u.is_admin() && !u.disabled) {
|
||||
let prefs: Value = app.store.user_setting(&user.id, "prefs").unwrap_or_default();
|
||||
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
|
||||
let body = alerts.iter().map(|(m, l)| alert_text(m, *l, de)).collect::<Vec<_>>().join("\n");
|
||||
let used_up = alerts.iter().any(|(_, l)| *l == 2);
|
||||
let title = match (used_up, de) {
|
||||
(true, true) => "CodeCraft – Budget ausgeschöpft",
|
||||
(true, false) => "CodeCraft – budget used up",
|
||||
(false, true) => "CodeCraft – Budget fast verbraucht",
|
||||
(false, false) => "CodeCraft – budget nearly used",
|
||||
};
|
||||
let payload = json!({"kind": "budget", "title": title, "body": body, "tag": "budget", "url": "/#/stats"});
|
||||
let (sent, _) = crate::push::send_to_user(app, &user.id, &payload).await;
|
||||
tracing::info!(user = %user.username, sent, alerts = alerts.len(), "budget alert");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn spawn_alerts(app: std::sync::Arc<App>) {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(120)).await;
|
||||
loop {
|
||||
if let Err(e) = check_alerts(&app).await {
|
||||
tracing::warn!("budget alerts: {e:#}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(600)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn overview(app: &App) -> Result<Value> {
|
||||
let cfg = app.config();
|
||||
let spent = app.store.cost_by_provider(month_start())?;
|
||||
@@ -384,6 +502,14 @@ mod tests {
|
||||
assert_eq!(mc.price_tiers[0].above_context, 200000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alert_texts() {
|
||||
let m = super::Meter { key: "plan:go:5h".into(), provider: "opencode-go".into(), kind: "5h".into(), used: 1.7, limit: 2.0, ratio: 0.85 };
|
||||
assert_eq!(super::alert_text(&m, 1, true), "opencode-go: 5-Stunden-Limit zu 85 % verbraucht");
|
||||
let t = super::Meter { key: "total".into(), provider: "total".into(), kind: "total".into(), used: 10.0, limit: 10.0, ratio: 1.0 };
|
||||
assert_eq!(super::alert_text(&t, 2, false), "Monthly total budget used up (100 %)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_entry_flags() {
|
||||
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
||||
|
||||
Reference in new issue
Block a user