App logo (icon set, welcome screen, sidebar, page icon); despawn N bots to ease the load

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-09-30 23:41:20 +05:00
1 parent 8686990a30
commit 313d3e1c08
31 files changed
+98 -3

No files matched your search

+5
View File
@@ -115,6 +115,11 @@ impl Ra {
Ok(first_number(&self.bot_command("botcmd despawnall")?))
}
/// Log one bot out (the character stays saved). False when that bot was not online.
pub fn despawn_bot(&mut self, guid: u64) -> Result<bool> {
Ok(self.bot_command(&format!("botcmd despawn {guid}"))?.to_lowercase().contains("despawned"))
}
/// Log every bot out and delete every bot character for good (the caller saves a recovery point first).
pub fn purge_all(&mut self) -> Result<String> {
let out = self.bot_command("botcmd purgeall")?;
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CoA Server Manager</title>
<link rel="icon" type="image/png" href="/src/assets/logo.png" />
</head>
<body>
<div id="root"></div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

+29
View File
@@ -539,6 +539,34 @@ async fn companions_take_offline(state: State<'_, AppState>, id: String) -> std:
.await
}
/// Log `count` randomly chosen online companions out to lower the load. Uses the per-bot command that every server
/// build has, so it works on older builds too.
#[tauri::command]
async fn companions_despawn_some(state: State<'_, AppState>, id: String, count: u32) -> std::result::Result<CompanionAction, UiError> {
let root = path_of(&state, &id)?;
let _guard = BusyGuard::acquire(&state, &id)?;
blocking(move || {
if !(1..=5000).contains(&count) {
return Err(Error::Invalid("Choose a number between 1 and 5000.".into()));
}
let prefix = coa_core::population::bot_account_prefix(&root).to_uppercase();
let db = coa_core::db::Db::from_repack(&root, coa_core::db::Account::App)?;
let rows = db.query(&format!(
"SELECT c.guid FROM acore_characters.characters c JOIN acore_auth.account a ON a.id=c.account WHERE c.online=1 AND UPPER(a.username) LIKE '{prefix}%' ORDER BY RAND() LIMIT {count};"
))?;
let guids: Vec<u64> = rows.lines().filter_map(|l| l.trim().parse().ok()).collect();
let mut ra = Ra::connect(&root)?;
let mut done = 0;
for g in guids {
if ra.despawn_bot(g).unwrap_or(false) {
done += 1;
}
}
Ok(CompanionAction { count: done })
})
.await
}
/// Delete every companion for good. A recovery point of the characters and accounts is saved first.
#[tauri::command]
async fn companions_delete_all(app: AppHandle, state: State<'_, AppState>, id: String) -> std::result::Result<CompanionAction, UiError> {
@@ -976,6 +1004,7 @@ pub fn run() {
companions_stop_spawning,
companions_take_offline,
companions_delete_all,
companions_despawn_some,
open_link,
companion_sizes,
add_companions,
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+6
View File
@@ -562,6 +562,12 @@ const de: Partial<Record<keyof typeof en, string>> = {
"comp.deleteDone": "Alle Begleiter wurden gelöscht. Zuvor wurde ein Wiederherstellungspunkt gespeichert.",
"err.companion_command_unsupported.title": "Diese Serverversion kann das noch nicht",
"err.companion_command_unsupported.message": "Das Bot-Modul dieses Server-Builds kennt diesen Befehl nicht. Aktualisiere den Server oder starte ihn neu, um Laufendes zu stoppen.",
// Despawn some
"comp.despawnSome": "{n} Bots entfernen",
"comp.despawnCount": "Wie viele abmelden",
"comp.despawnHint": "Entlastet sofort: Jeder Bot online kostet Serverzeit. Sie bleiben gespeichert und kommen beim nächsten Start zurück, wenn die automatische Anmeldung an ist.",
"comp.despawnSomeDone": "{n} Begleiter abgemeldet. Sie bleiben gespeichert.",
};
export default de;
+6
View File
@@ -562,6 +562,12 @@ const en = {
"comp.deleteDone": "All companions were deleted. A recovery point was saved first.",
"err.companion_command_unsupported.title": "This server version can not do that yet",
"err.companion_command_unsupported.message": "The bot module of this server build has no such command. Update the server, or restart it to stop what is running.",
// Despawn some
"comp.despawnSome": "Despawn {n} bots",
"comp.despawnCount": "How many to log out",
"comp.despawnHint": "Eases the load right away: every online bot costs server time. They stay saved and return at the next start if automatic login is on.",
"comp.despawnSomeDone": "{n} companions logged out. They stay saved.",
} as const;
export default en;
+6
View File
@@ -562,6 +562,12 @@ const es: Partial<Record<keyof typeof en, string>> = {
"comp.deleteDone": "Se eliminaron todos los compañeros. Antes se guardó un punto de recuperación.",
"err.companion_command_unsupported.title": "Esta versión del servidor aún no puede hacerlo",
"err.companion_command_unsupported.message": "El módulo de bots de esta versión del servidor no tiene ese comando. Actualiza el servidor o reinícialo para detener lo que esté en marcha.",
// Despawn some
"comp.despawnSome": "Retirar {n} bots",
"comp.despawnCount": "Cuántos desconectar",
"comp.despawnHint": "Alivia la carga al instante: cada bot conectado cuesta tiempo de servidor. Quedan guardados y vuelven en el próximo inicio si el inicio de sesión automático está activado.",
"comp.despawnSomeDone": "{n} compañeros desconectados. Quedan guardados.",
};
export default es;
+6
View File
@@ -562,6 +562,12 @@ const fr: Partial<Record<keyof typeof en, string>> = {
"comp.deleteDone": "Tous les compagnons ont été supprimés. Un point de restauration a été enregistré d’abord.",
"err.companion_command_unsupported.title": "Cette version du serveur ne sait pas encore le faire",
"err.companion_command_unsupported.message": "Le module de bots de cette version du serveur n’a pas cette commande. Mettez le serveur à jour ou redémarrez-le pour arrêter ce qui est en cours.",
// Despawn some
"comp.despawnSome": "Retirer {n} bots",
"comp.despawnCount": "Combien déconnecter",
"comp.despawnHint": "Allège aussitôt la charge : chaque bot en ligne coûte du temps serveur. Ils restent enregistrés et reviennent au prochain démarrage si la connexion automatique est activée.",
"comp.despawnSomeDone": "{n} compagnons déconnectés. Ils restent enregistrés.",
};
export default fr;
+6
View File
@@ -562,6 +562,12 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"comp.deleteDone": "Все компаньоны удалены. Сначала была сохранена точка восстановления.",
"err.companion_command_unsupported.title": "Эта версия сервера пока этого не умеет",
"err.companion_command_unsupported.message": "В модуле ботов этой сборки сервера нет такой команды. Обновите сервер или перезапустите его, чтобы остановить текущую работу.",
// Despawn some
"comp.despawnSome": "Убрать ботов: {n}",
"comp.despawnCount": "Сколько убрать",
"comp.despawnHint": "Сразу снижает нагрузку: каждый бот в сети отнимает время сервера. Они остаются сохранёнными и вернутся при следующем запуске, если включён автоматический вход.",
"comp.despawnSomeDone": "Убрано из игры компаньонов: {n}. Они остаются сохранёнными.",
};
export default ru;
+1
View File
@@ -291,6 +291,7 @@ export const api = {
population: (id: string) => invoke<Population | null>("get_population", { id }),
stopSpawning: (id: string) => invoke<{ count: number }>("companions_stop_spawning", { id }),
takeOffline: (id: string) => invoke<{ count: number }>("companions_take_offline", { id }),
despawnSome: (id: string, count: number) => invoke<{ count: number }>("companions_despawn_some", { id, count }),
deleteAllCompanions: (id: string) => invoke<{ count: number }>("companions_delete_all", { id }),
openLink: (url: string) => invoke<void>("open_link", { url }),
performance: (id: string) => invoke<Performance | null>("get_performance", { id }),
+25 -1
View File
@@ -23,6 +23,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
const [job, setJob] = useState<{ baseline: number; target: number; since: number } | null>(null);
const [startNote, setStartNote] = useState<string | null>(null);
const [startBusy, setStartBusy] = useState(false);
const [despawnN, setDespawnN] = useState("10");
useEffect(() => {
void api
@@ -67,6 +68,14 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
});
};
const dn = Number(despawnN);
const dnValid = Number.isInteger(dn) && dn >= 1 && dn <= 5000;
const despawnSome = () =>
act(async () => {
const r = await api.despawnSome(serverId, dn);
return t("comp.despawnSomeDone", { n: r.count });
});
const deleteAll = () => {
if (!window.confirm(t("comp.deleteConfirm", { n: pop?.bots_total ?? 0 }))) return;
void act(async () => {
@@ -213,7 +222,22 @@ export function CompanionsCard({ serverId }: { serverId: string }) {
{running && pop && pop.bots_total > 0 && (
<div className="mt-5 border-t border-line pt-4">
<h3 className="text-sm font-semibold">{t("comp.manageTitle")}</h3>
<div className="mt-2 flex flex-wrap gap-2">
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
<label htmlFor="despawn-n" className="text-muted">{t("comp.despawnCount")}</label>
<input
id="despawn-n"
value={despawnN}
onChange={(e) => setDespawnN(e.target.value)}
inputMode="numeric"
aria-invalid={!dnValid}
className="w-20 rounded-md border border-line bg-bg px-3 py-1.5 text-right outline-none focus:border-gold"
/>
<Button size="sm" variant="secondary" disabled={busy || !dnValid} onClick={() => void despawnSome()}>
{t("comp.despawnSome", { n: dnValid ? dn : "…" })}
</Button>
</div>
<p className="mt-1 text-xs text-muted">{t("comp.despawnHint")}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="secondary" disabled={busy} onClick={takeOffline}>
{t("comp.offline")}
</Button>
+5 -1
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { Archive, Bot, Gauge, Globe, Server, Settings, Terminal, Users, type LucideIcon } from "lucide-react";
import { type ServerSummary } from "@/lib/api";
import { useT, type Key } from "@/i18n";
import logo from "@/assets/logo.png";
import { cn } from "@/lib/utils";
import { Overview } from "@/screens/Overview";
import { Placeholder } from "@/screens/Placeholder";
@@ -41,7 +42,10 @@ export function Shell(props: {
return (
<div className="flex h-full">
<nav className="flex w-60 shrink-0 flex-col border-r border-line bg-[#0b0c0e] p-3" aria-label={t("nav.main")}>
<div className="px-3 pb-4 pt-2 text-sm font-semibold tracking-wide text-gold">{t("app.name")}</div>
<div className="flex items-center gap-2 px-3 pb-4 pt-2 text-sm font-semibold tracking-wide text-gold">
<img src={logo} alt="" aria-hidden className="h-7 w-7" />
{t("app.name")}
</div>
<ul className="flex flex-col gap-0.5">
{NAV.map(({ id, label, icon: Icon }) => (
<li key={id}>
+2 -1
View File
@@ -1,5 +1,6 @@
import { FolderSearch, Download } from "lucide-react";
import { useT } from "@/i18n";
import logo from "@/assets/logo.png";
import { LanguagePicker } from "@/components/LanguagePicker";
export function Welcome({ onImport, onInstall, onBack }: { onImport: () => void; onInstall: () => void; onBack?: () => void }) {
@@ -8,7 +9,7 @@ export function Welcome({ onImport, onInstall, onBack }: { onImport: () => void;
<main className="relative flex h-full flex-col items-center justify-center px-8">
<LanguagePicker className="absolute right-6 top-5" />
<div className="mb-10 text-center">
<div className="mx-auto mb-5 h-12 w-12 rounded-full border-2 border-gold/80 bg-card-2" aria-hidden />
<img src={logo} alt="" aria-hidden className="mx-auto mb-5 h-24 w-24 drop-shadow-[0_6px_24px_rgb(201_162_74/0.35)]" />
<h1 className="text-3xl font-semibold tracking-tight">{t("welcome.title")}</h1>
<p className="mt-2 text-muted">{t("welcome.subtitle")}</p>
</div>