mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Phase 7a: real population (players vs companions), hardware-aware companion sizes, RA bot spawn, Bots page card
Verified: fixture received 'botcmd spawnleveled 12' over RA from the UI and the counts moved; population query also read-only verified against the live server (33 companions online). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
981ee4d4d9
commit
9504e9133b
9 files changed
+351
-3
No files matched your search
@@ -0,0 +1,10 @@
|
||||
fn main() {
|
||||
let root = std::env::args().nth(1).expect("server folder");
|
||||
let root = std::path::Path::new(&root);
|
||||
println!("{:?}", coa_core::population::query(root));
|
||||
let h = coa_core::population::hardware();
|
||||
println!("{h:?}");
|
||||
for s in coa_core::population::sizes(&h) {
|
||||
println!("{} {} {:?}", s.title, s.bots, s.warning);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod install;
|
||||
pub mod layout;
|
||||
pub mod logging;
|
||||
pub mod pkgsource;
|
||||
pub mod population;
|
||||
pub mod process;
|
||||
pub mod manifest;
|
||||
pub mod migrations;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Who is online (real players vs bots) and what the machine can carry.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::parser::{unquote, ConfFile};
|
||||
use crate::db::{Account, Db};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Population {
|
||||
pub online_total: u32,
|
||||
pub bots_online: u32,
|
||||
pub players_online: u32,
|
||||
/// Bot characters that exist (online or not).
|
||||
pub bots_total: u32,
|
||||
}
|
||||
|
||||
/// Bot accounts share a name prefix (`CoaBots.RandomSpawn.AccountPrefix`, default `CoaBotHost`).
|
||||
pub fn bot_account_prefix(root: &Path) -> String {
|
||||
std::fs::read(root.join("Core/configs/modules/mod_coa_playerbots.conf"))
|
||||
.ok()
|
||||
.and_then(|b| ConfFile::parse_bytes(&b).ok())
|
||||
.and_then(|c| c.get("CoaBots.RandomSpawn.AccountPrefix").map(|v| unquote(v).to_string()))
|
||||
.filter(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric()))
|
||||
.unwrap_or_else(|| "CoaBotHost".into())
|
||||
}
|
||||
|
||||
fn parse_counts(out: &str) -> Result<Population> {
|
||||
let f: Vec<u32> = out.split_whitespace().filter_map(|x| x.parse().ok()).collect();
|
||||
if f.len() != 3 {
|
||||
return Err(Error::Invalid(format!("unexpected population reply: {out:?}")));
|
||||
}
|
||||
let (online_total, bots_online, bots_total) = (f[0], f[1], f[2]);
|
||||
Ok(Population { online_total, bots_online, players_online: online_total.saturating_sub(bots_online), bots_total })
|
||||
}
|
||||
|
||||
/// Read-only query with the game's own database account. Requires the database to be running.
|
||||
pub fn query(root: &Path) -> Result<Population> {
|
||||
let prefix = bot_account_prefix(root).to_uppercase();
|
||||
let db = Db::from_repack(root, Account::App)?;
|
||||
let sql = format!(
|
||||
"SELECT (SELECT COUNT(*) FROM acore_characters.characters WHERE online=1), \
|
||||
(SELECT COUNT(*) 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}%'), \
|
||||
(SELECT COUNT(*) FROM acore_characters.characters c JOIN acore_auth.account a ON a.id=c.account WHERE UPPER(a.username) LIKE '{prefix}%');"
|
||||
);
|
||||
parse_counts(&db.query(&sql)?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Hardware {
|
||||
pub cores: u32,
|
||||
pub ram_gb: f64,
|
||||
pub free_ram_gb: f64,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn hardware() -> Hardware {
|
||||
use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
|
||||
let mut st: MEMORYSTATUSEX = unsafe { std::mem::zeroed() };
|
||||
st.dwLength = std::mem::size_of::<MEMORYSTATUSEX>() as u32;
|
||||
let ok = unsafe { GlobalMemoryStatusEx(&mut st) } != 0;
|
||||
let gb = |b: u64| b as f64 / (1u64 << 30) as f64;
|
||||
Hardware {
|
||||
cores: std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(2),
|
||||
ram_gb: if ok { gb(st.ullTotalPhys) } else { 8.0 },
|
||||
free_ram_gb: if ok { gb(st.ullAvailPhys) } else { 4.0 },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn hardware() -> Hardware {
|
||||
Hardware { cores: std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(2), ram_gb: 8.0, free_ram_gb: 4.0 }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SizeOption {
|
||||
pub id: &'static str,
|
||||
pub title: &'static str,
|
||||
pub bots: u32,
|
||||
/// None = comfortable on this machine; Some = why to think twice.
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
/// Recommended sizes with a hardware-aware note. Nothing is forbidden: advanced users may exceed them.
|
||||
pub fn sizes(h: &Hardware) -> Vec<SizeOption> {
|
||||
let need = |bots: u32, cores: u32, ram: f64| -> Option<String> {
|
||||
if h.cores < cores || h.ram_gb < ram {
|
||||
Some(format!("{bots} companions may need more CPU and memory than this computer has ({} cores, {:.0} GB).", h.cores, h.ram_gb))
|
||||
} else if h.free_ram_gb < ram / 2.0 {
|
||||
Some("Free some memory first; other programs are using most of it.".into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
vec![
|
||||
SizeOption { id: "small", title: "Small", bots: 50, warning: None },
|
||||
SizeOption { id: "medium", title: "Medium", bots: 250, warning: need(250, 4, 8.0) },
|
||||
SizeOption { id: "large", title: "Large", bots: 500, warning: need(500, 8, 16.0) },
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_counts_and_never_goes_negative() {
|
||||
let p = parse_counts("12\t9\t400").unwrap();
|
||||
assert_eq!((p.online_total, p.bots_online, p.players_online, p.bots_total), (12, 9, 3, 400));
|
||||
assert_eq!(parse_counts("5\t7\t7").unwrap().players_online, 0);
|
||||
assert!(parse_counts("garbage").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_comes_from_the_bot_config_and_is_sanitised() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
assert_eq!(bot_account_prefix(d.path()), "CoaBotHost");
|
||||
let f = d.path().join("Core/configs/modules");
|
||||
std::fs::create_dir_all(&f).unwrap();
|
||||
std::fs::write(f.join("mod_coa_playerbots.conf"), "CoaBots.RandomSpawn.AccountPrefix = \"MyBots\"\n").unwrap();
|
||||
assert_eq!(bot_account_prefix(d.path()), "MyBots");
|
||||
std::fs::write(f.join("mod_coa_playerbots.conf"), "CoaBots.RandomSpawn.AccountPrefix = \"x' OR 1=1 --\"\n").unwrap();
|
||||
assert_eq!(bot_account_prefix(d.path()), "CoaBotHost", "anything that is not letters/digits is ignored (SQL safety)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_warn_on_weak_hardware_only() {
|
||||
let weak = sizes(&Hardware { cores: 2, ram_gb: 4.0, free_ram_gb: 2.0 });
|
||||
assert!(weak[0].warning.is_none() && weak[1].warning.is_some() && weak[2].warning.is_some());
|
||||
let strong = sizes(&Hardware { cores: 16, ram_gb: 32.0, free_ram_gb: 20.0 });
|
||||
assert!(strong.iter().all(|s| s.warning.is_none()));
|
||||
let busy = sizes(&Hardware { cores: 16, ram_gb: 32.0, free_ram_gb: 2.0 });
|
||||
assert!(busy[2].warning.as_deref().unwrap().contains("memory"));
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,15 @@ impl Ra {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the bot module to create `count` leveling bots (throttled by the module itself). Only a number is sent.
|
||||
pub fn spawn_bots(&mut self, count: u32) -> Result<String> {
|
||||
if !(1..=2000).contains(&count) {
|
||||
return Err(Error::Invalid("Choose between 1 and 2000 companions.".into()));
|
||||
}
|
||||
let out = self.command(&format!("botcmd spawnleveled {count}"))?;
|
||||
Ok(out.lines().last().unwrap_or("").to_string())
|
||||
}
|
||||
|
||||
/// Give `name` administrator rights on all realms (GM level 3).
|
||||
pub fn make_administrator(&mut self, name: &str) -> Result<()> {
|
||||
validate_account(name, "placeholder")?;
|
||||
@@ -150,6 +159,16 @@ mod tests {
|
||||
assert_eq!(h.join().unwrap(), ["local", "secretra", "account create Player1 hunter22"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_bots_sends_only_a_bounded_number() {
|
||||
let (port, h) = fake_ra("Queued 50 bots");
|
||||
let mut ra = Ra::connect_to(port, "u", "p").unwrap();
|
||||
assert_eq!(ra.spawn_bots(50).unwrap(), "Queued 50 bots");
|
||||
assert!(ra.spawn_bots(0).is_err() && ra.spawn_bots(5000).is_err());
|
||||
drop(ra);
|
||||
assert_eq!(h.join().unwrap()[2], "botcmd spawnleveled 50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_account_and_input_validation() {
|
||||
let (port, _h) = fake_ra("Account already exist.");
|
||||
|
||||
+62
-1
@@ -463,6 +463,64 @@ async fn rollback_update(state: State<'_, AppState>, id: String, txn: String) ->
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_population(state: State<'_, AppState>, id: String) -> std::result::Result<Option<coa_core::population::Population>, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
Ok(tauri::async_runtime::spawn_blocking(move || {
|
||||
let o = coa_core::process::observe(&root, &layout::read_ports(&root));
|
||||
if o.mysql.state != coa_core::process::ServiceState::Running {
|
||||
return None;
|
||||
}
|
||||
coa_core::population::query(&root).ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(None))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CompanionSizes {
|
||||
hardware: coa_core::population::Hardware,
|
||||
sizes: Vec<coa_core::population::SizeOption>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn companion_sizes() -> CompanionSizes {
|
||||
let hardware = coa_core::population::hardware();
|
||||
let sizes = coa_core::population::sizes(&hardware);
|
||||
CompanionSizes { hardware, sizes }
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CompanionsResult {
|
||||
spawned: Option<String>,
|
||||
}
|
||||
|
||||
/// Turn on automatic bot login for `count` bots and, if the server is running, ask it to create them.
|
||||
#[tauri::command]
|
||||
async fn add_companions(state: State<'_, AppState>, id: String, count: u32) -> std::result::Result<CompanionsResult, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||
blocking(move || {
|
||||
if !(1..=2000).contains(&count) {
|
||||
return Err(Error::Invalid("Choose between 1 and 2000 companions.".into()));
|
||||
}
|
||||
let meta = meta_dir(&root)?;
|
||||
let mut changes = BTreeMap::new();
|
||||
changes.insert("CoaBots.AutoLoginOnStartup".to_string(), Value::Bool(true));
|
||||
changes.insert("CoaBots.AutoLogin.MaxCount".to_string(), Value::from(count));
|
||||
config::save(&root, &meta, Scope::Bots, &changes)?;
|
||||
let o = coa_core::process::observe(&root, &layout::read_ports(&root));
|
||||
let spawned = if o.world.state == coa_core::process::ServiceState::Running {
|
||||
Some(Ra::connect(&root)?.spawn_bots(count)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tracing::info!(count, spawned = spawned.is_some(), "companions requested");
|
||||
Ok(CompanionsResult { spawned })
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let dir = data_dir();
|
||||
let _ = coa_core::logging::init(&dir.join("logs").join("manager.log"));
|
||||
@@ -497,7 +555,10 @@ pub fn run() {
|
||||
check_update,
|
||||
pending_update,
|
||||
apply_update,
|
||||
rollback_update
|
||||
rollback_update,
|
||||
get_population,
|
||||
companion_sizes,
|
||||
add_companions
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running CoA Server Manager");
|
||||
|
||||
@@ -197,6 +197,18 @@ export interface UpdateTxn {
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface Population {
|
||||
online_total: number;
|
||||
bots_online: number;
|
||||
players_online: number;
|
||||
bots_total: number;
|
||||
}
|
||||
|
||||
export interface CompanionSizes {
|
||||
hardware: { cores: number; ram_gb: number; free_ram_gb: number };
|
||||
sizes: { id: string; title: string; bots: number; warning: string | null }[];
|
||||
}
|
||||
|
||||
export const api = {
|
||||
defaultInstallDir: () => invoke<string>("default_install_dir"),
|
||||
scan: (path: string) => invoke<ScanReport>("scan_server", { path }),
|
||||
@@ -225,6 +237,9 @@ export const api = {
|
||||
applyUpdate: (id: string, resolutions: Record<string, "keep" | "replace">, source?: string) =>
|
||||
invoke<{ txn: UpdateTxn }>("apply_update", { id, source: source ?? null, resolutions }),
|
||||
rollbackUpdate: (id: string, txn: string) => invoke<UpdateTxn>("rollback_update", { id, txn }),
|
||||
population: (id: string) => invoke<Population | null>("get_population", { id }),
|
||||
companionSizes: () => invoke<CompanionSizes>("companion_sizes"),
|
||||
addCompanions: (id: string, count: number) => invoke<{ spawned: string | null }>("add_companions", { id, count }),
|
||||
previewPreset: (id: string, scope: Scope, preset: string) => invoke<PresetPreview>("preview_preset", { id, scope, preset }),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { api, asUiError, type CompanionSizes, type Population, type UiError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export function CompanionsCard({ serverId }: { serverId: string }) {
|
||||
const [sizes, setSizes] = useState<CompanionSizes | null>(null);
|
||||
const [pop, setPop] = useState<Population | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [choice, setChoice] = useState<string>("small");
|
||||
const [custom, setCustom] = useState("100");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<UiError | null>(null);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.companionSizes().then(setSizes);
|
||||
let alive = true;
|
||||
const poll = async () => {
|
||||
const s = await api.status(serverId).catch(() => null);
|
||||
if (!alive || !s) return;
|
||||
const up = s.observed.world.state === "running";
|
||||
setRunning(up);
|
||||
setPop(up ? await api.population(serverId).catch(() => null) : null);
|
||||
};
|
||||
void poll();
|
||||
const t = setInterval(poll, 4000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [serverId]);
|
||||
|
||||
const count = choice === "custom" ? Number(custom) : (sizes?.sizes.find((s) => s.id === choice)?.bots ?? 0);
|
||||
const warning = choice === "custom" ? (count > 500 ? "Large populations may need more CPU and memory. Increase gradually." : null) : (sizes?.sizes.find((s) => s.id === choice)?.warning ?? null);
|
||||
const valid = Number.isInteger(count) && count >= 1 && count <= 2000;
|
||||
|
||||
async function add() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNote(null);
|
||||
try {
|
||||
const r = await api.addCompanions(serverId, count);
|
||||
setNote(r.spawned ? `Creating ${count} companions. They appear gradually over the next minutes.` : `Saved. ${count} companions will log in when the server starts.`);
|
||||
} catch (e) {
|
||||
setError(asUiError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mt-6 p-6">
|
||||
<h2 className="font-semibold">AI companions</h2>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{pop
|
||||
? `${pop.bots_online} online now · ${pop.bots_total} created in total`
|
||||
: running
|
||||
? "Reading the current population…"
|
||||
: "Start the server to see how many companions are online."}
|
||||
</p>
|
||||
|
||||
<fieldset className="mt-4">
|
||||
<legend className="text-sm text-muted">Would you like more companions?</legend>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{sizes?.sizes.map((s) => (
|
||||
<label key={s.id} className={`cursor-pointer rounded-md border px-3 py-2 text-sm ${choice === s.id ? "border-gold bg-gold/10" : "border-line hover:bg-white/5"}`}>
|
||||
<input type="radio" name="size" className="sr-only" checked={choice === s.id} onChange={() => setChoice(s.id)} />
|
||||
{s.title} <span className="text-muted">· {s.bots}</span>
|
||||
</label>
|
||||
))}
|
||||
<label className={`cursor-pointer rounded-md border px-3 py-2 text-sm ${choice === "custom" ? "border-gold bg-gold/10" : "border-line hover:bg-white/5"}`}>
|
||||
<input type="radio" name="size" className="sr-only" checked={choice === "custom"} onChange={() => setChoice("custom")} />
|
||||
Custom
|
||||
</label>
|
||||
{choice === "custom" && (
|
||||
<input aria-label="Number of companions" value={custom} onChange={(e) => setCustom(e.target.value)} inputMode="numeric" className="w-24 rounded-md border border-line bg-bg px-3 py-2 text-right text-sm outline-none focus:border-gold" />
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{warning && <p className="mt-3 text-sm text-warn" role="status">{warning}</p>}
|
||||
{sizes && <p className="mt-2 text-xs text-muted">This computer: {sizes.hardware.cores} cores, {sizes.hardware.ram_gb.toFixed(0)} GB memory.</p>}
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<Button variant="primary" disabled={busy || !valid} onClick={() => void add()}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
|
||||
Add {valid ? count : ""} companions
|
||||
</Button>
|
||||
{!running && <span className="text-xs text-muted">The server is stopped: they will be created the next time it runs.</span>}
|
||||
</div>
|
||||
{note && <p className="mt-3 text-sm text-ok" role="status">{note}</p>}
|
||||
{error && <p className="mt-3 text-sm text-bad" role="alert">{error.human.code === "unknown" ? error.technical : error.human.message}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { api, asUiError, type Human, type ServerSummary, type ServiceStatus, type StatusView } from "@/lib/api";
|
||||
import { api, asUiError, type Human, type Population, type ServerSummary, type ServiceStatus, type StatusView } from "@/lib/api";
|
||||
import { cn, formatUptime } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -36,12 +36,17 @@ export function Overview({ server, onForget }: { server: ServerSummary; onForget
|
||||
const [action, setAction] = useState<Action>(null);
|
||||
const [failure, setFailure] = useState<Failure | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [pop, setPop] = useState<Population | null>(null);
|
||||
const alive = useRef(true);
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
const s = await api.status(server.id);
|
||||
if (alive.current) setStatus(s);
|
||||
if (s.observed.world.state === "running") {
|
||||
const p = await api.population(server.id).catch(() => null);
|
||||
if (alive.current) setPop(p);
|
||||
} else if (alive.current) setPop(null);
|
||||
} catch {
|
||||
/* transient; next poll retries */
|
||||
}
|
||||
@@ -139,7 +144,7 @@ export function Overview({ server, onForget }: { server: ServerSummary; onForget
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted">Players online</dt>
|
||||
<dd className="mt-0.5 text-lg text-muted">—</dd>
|
||||
<dd className="mt-0.5 text-lg">{pop ? `${pop.players_online}` : "—"}{pop && pop.bots_online > 0 ? <span className="ml-2 text-sm text-muted">+ {pop.bots_online} companions</span> : null}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { CompanionsCard } from "@/screens/CompanionsCard";
|
||||
|
||||
const RESTART_LABEL = { runtime: "Applies immediately", world: "Needs a world server restart", full: "Needs a full server restart" } as const;
|
||||
|
||||
@@ -242,6 +243,8 @@ export function SettingsPage(props: { serverId: string; scope: Scope; title: str
|
||||
<h1 className="text-2xl font-semibold">{props.title}</h1>
|
||||
<p className="mt-1 text-muted">{props.question}</p>
|
||||
|
||||
{scope === "bots" && <CompanionsCard serverId={serverId} />}
|
||||
|
||||
{view.drift_keys.length > 0 && (
|
||||
<Card className="mt-5 border-warn/40 p-4" role="status">
|
||||
<p className="font-medium text-warn">{view.drift_keys.length} setting(s) were edited by hand</p>
|
||||
|
||||
Reference in new issue
Block a user