ENH - add live admin dashboard statistics (#27)

This commit is contained in:
Leon.Schmidt
2026-08-22 18:33:52 +02:00
committed by GitHub
parent 99e943dbc5
commit 2059e19e1c
10 changed files with 288 additions and 687 deletions
@@ -152,24 +152,23 @@ describe('standalone admin panel contracts', () => {
}
})
it('removes manual reload and applies a persistent global accent choice', () => {
it('uses a fixed blue accent and replaces appearance controls with live statistics', () => {
expect(source).not.toContain('<RefreshCw')
expect(source).not.toContain("kind: 'refresh'")
expect(source).toContain("'sky-phone-admin-accent'")
expect(source).toContain("'sky-phone-admin-font-family'")
expect(source).toContain("'sky-phone-admin-font-size'")
expect(source).toContain("'--admin-accent': accentColor")
expect(source).toContain("'--admin-font-family': activeAdminFontFamily")
expect(source).toContain(
"'--admin-font-scale': String(adminFontSize / 100)",
)
expect(source).toContain('class="admin-panel-color-value"')
expect(source).toContain('class="admin-panel-rgb-fields"')
expect(source).toContain('class="admin-panel-rgb-range"')
expect(source).toContain('class="admin-panel-font-select"')
expect(source).toContain('class="admin-panel-font-select__menu"')
expect(source).toContain('class="admin-panel-font-size-control"')
expect(source).not.toContain('type="color"')
expect(source).not.toContain("'sky-phone-admin-accent'")
expect(source).not.toContain("'sky-phone-admin-font-family'")
expect(source).not.toContain("'sky-phone-admin-font-size'")
expect(source).not.toContain("t('appearance.")
expect(source).toContain('--admin-accent: #00b8e4')
expect(source).toContain('class="admin-panel-statistics-layout"')
expect(source).toContain('class="admin-panel-activity-grid"')
expect(source).toContain('class="admin-panel-coverage-list"')
expect(source).toContain('statisticsTimer = window.setInterval')
expect(source).toContain('15_000')
expect(server).toContain('AS `messages_today`')
expect(server).toContain('AS `calls_today`')
expect(server).toContain('AS `active_devices`')
expect(server).toContain('auditEntries = tonumber(stats.audit_entries)')
expect(source).toContain('color-mix(in srgb, var(--admin-green)')
expect(source).toContain('--admin-toggle-on: #63d471')
expect(source).toContain('background: var(--admin-toggle-on)')
+202 -550
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import {
ChartNoAxesCombined,
BadgeDollarSign,
BriefcaseBusiness,
Check,
ChevronDown,
ChevronRight,
CircleUserRound,
Clipboard,
@@ -16,7 +16,6 @@ import {
LoaderCircle,
LockKeyhole,
MessageSquare,
Palette,
PhoneCall,
PhoneForwarded,
Save,
@@ -81,17 +80,6 @@ type AdminTab =
type ConfiguratorScope = 'config' | 'media'
type DeviceAction = 'reset-passcode' | 'change-number' | 'factory-reset'
type PendingAction = { kind: 'close' } | { kind: 'player'; source: number }
type AccentChannel = 'blue' | 'green' | 'red'
type AdminFontFamily =
| 'classic'
| 'georgia'
| 'inter'
| 'mono'
| 'system'
| 'tahoma'
| 'trebuchet'
| 'verdana'
const emit = defineEmits<{ close: [] }>()
const admin = useAdminStore()
const phone = usePhoneStore()
@@ -109,61 +97,11 @@ const revealDialogImei = ref('')
const deviceAction = ref<DeviceAction | null>(null)
const deviceActionInput = ref('')
const discardDialog = ref(false)
const fontMenuOpen = ref(false)
const pendingAction = ref<PendingAction | null>(null)
const toast = ref('')
const toastTone = ref<'error' | 'success'>('success')
let toastTimer: number | undefined
const accentOptions = [
{ color: '#74d66f', key: 'emerald' },
{ color: '#4f9cff', key: 'blue' },
{ color: '#a875ff', key: 'violet' },
{ color: '#f0a24b', key: 'orange' },
{ color: '#ef6969', key: 'red' },
] as const
const accentChannels = ['red', 'green', 'blue'] as const
const fontFamilyOptions: Array<{
key: AdminFontFamily
value: string
}> = [
{ key: 'inter', value: 'var(--sky-font-family, Inter, sans-serif)' },
{
key: 'system',
value:
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
},
{ key: 'classic', value: 'Arial, Helvetica, sans-serif' },
{ key: 'verdana', value: 'Verdana, Geneva, sans-serif' },
{ key: 'tahoma', value: 'Tahoma, Geneva, sans-serif' },
{
key: 'trebuchet',
value: '"Trebuchet MS", "Lucida Grande", sans-serif',
},
{ key: 'georgia', value: 'Georgia, "Times New Roman", serif' },
{
key: 'mono',
value:
'ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace',
},
]
const accentColor = ref('#74d66f')
const adminFontFamily = ref<AdminFontFamily>('inter')
const adminFontSize = ref(100)
const adminFontSizePreview = ref(100)
const activeAdminFontFamily = computed(
() =>
fontFamilyOptions.find((option) => option.key === adminFontFamily.value)
?.value ?? fontFamilyOptions[0].value,
)
const accentRgb = computed(() => {
const color = accentColor.value.replace('#', '')
return {
red: Number.parseInt(color.slice(0, 2), 16),
green: Number.parseInt(color.slice(2, 4), 16),
blue: Number.parseInt(color.slice(4, 6), 16),
}
})
let statisticsTimer: number | undefined
const filteredPlayers = computed(() => {
const needle = playerQuery.value.trim().toLocaleLowerCase(phone.lang)
@@ -595,7 +533,9 @@ function queueAction(action: PendingAction): void {
function selectTab(nextTab: AdminTab): void {
tab.value = nextTab
fontMenuOpen.value = false
if (nextTab === 'overview' && admin.initialized && !admin.loading) {
void admin.load()
}
if (nextTab === 'configurator' && !admin.configurator) {
void admin.loadConfigurator().then((loaded) => {
if (!loaded) showToast(errorText(), 'error')
@@ -603,61 +543,13 @@ function selectTab(nextTab: AdminTab): void {
}
}
function selectAccent(color: string): void {
if (!/^#[0-9a-f]{6}$/i.test(color)) return
accentColor.value = color.toLowerCase()
window.localStorage.setItem('sky-phone-admin-accent', accentColor.value)
function formatStatistic(value: number): string {
return value.toLocaleString(phone.lang)
}
function updateAccent(event: Event): void {
const input = event.currentTarget as HTMLInputElement
if (/^#[0-9a-f]{6}$/i.test(input.value)) selectAccent(input.value)
else input.value = accentColor.value.toUpperCase()
}
function updateAccentChannel(channel: AccentChannel, event: Event): void {
const input = event.currentTarget as HTMLInputElement
const value = Math.min(255, Math.max(0, Number(input.value) || 0))
const channels = { ...accentRgb.value, [channel]: value }
selectAccent(
`#${channels.red.toString(16).padStart(2, '0')}${channels.green
.toString(16)
.padStart(2, '0')}${channels.blue.toString(16).padStart(2, '0')}`,
)
}
function accentChannelGradient(channel: AccentChannel): string {
const { red, green, blue } = accentRgb.value
if (channel === 'red') {
return `linear-gradient(90deg, rgb(0, ${green}, ${blue}), rgb(255, ${green}, ${blue}))`
}
if (channel === 'green') {
return `linear-gradient(90deg, rgb(${red}, 0, ${blue}), rgb(${red}, 255, ${blue}))`
}
return `linear-gradient(90deg, rgb(${red}, ${green}, 0), rgb(${red}, ${green}, 255))`
}
function selectAdminFontFamily(family: AdminFontFamily): void {
adminFontFamily.value = family
fontMenuOpen.value = false
window.localStorage.setItem('sky-phone-admin-font-family', family)
}
function closeFontMenu(event: FocusEvent): void {
const menu = event.currentTarget as HTMLElement
if (!menu.contains(event.relatedTarget as Node | null)) {
fontMenuOpen.value = false
}
}
function updateAdminFontSize(event: Event): void {
const size = Number((event.currentTarget as HTMLInputElement).value)
adminFontSize.value = Math.min(115, Math.max(90, size))
adminFontSizePreview.value = adminFontSize.value
window.localStorage.setItem(
'sky-phone-admin-font-size',
String(adminFontSize.value),
)
function statisticPercentage(value: number, total: number): number {
if (total <= 0) return 0
return Math.min(100, Math.round((value / total) * 100))
}
async function runAction(action: PendingAction): Promise<void> {
@@ -918,10 +810,6 @@ function auditDescription(entry: AdminAuditEntry): string {
function onKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape') return
event.preventDefault()
if (fontMenuOpen.value) {
fontMenuOpen.value = false
return
}
if (revealDialogImei.value) {
revealDialogImei.value = ''
return
@@ -941,46 +829,21 @@ function onKeydown(event: KeyboardEvent): void {
onMounted(() => {
document.addEventListener('keydown', onKeydown)
const storedAccent = window.localStorage.getItem('sky-phone-admin-accent')
if (storedAccent && /^#[0-9a-f]{6}$/i.test(storedAccent)) {
accentColor.value = storedAccent.toLowerCase()
}
const storedFontFamily = window.localStorage.getItem(
'sky-phone-admin-font-family',
)
if (
storedFontFamily &&
fontFamilyOptions.some((option) => option.key === storedFontFamily)
) {
adminFontFamily.value = storedFontFamily as AdminFontFamily
}
const storedFontSize = Number(
window.localStorage.getItem('sky-phone-admin-font-size'),
)
if (storedFontSize >= 90 && storedFontSize <= 115) {
adminFontSize.value = storedFontSize
adminFontSizePreview.value = storedFontSize
}
void refreshData()
statisticsTimer = window.setInterval(() => {
if (tab.value === 'overview' && !admin.loading) void admin.load()
}, 15_000)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown)
if (toastTimer) window.clearTimeout(toastTimer)
if (statisticsTimer) window.clearInterval(statisticsTimer)
})
</script>
<template>
<div
class="admin-panel-overlay"
role="dialog"
:aria-label="t('name')"
:style="{
'--admin-accent': accentColor,
'--admin-font-family': activeAdminFontFamily,
'--admin-font-scale': String(adminFontSize / 100),
}"
>
<div class="admin-panel-overlay" role="dialog" :aria-label="t('name')">
<div class="admin-panel-window">
<header class="admin-panel-header">
<div class="admin-panel-brand">
@@ -1384,22 +1247,22 @@ onBeforeUnmount(() => {
<article>
<UsersRound :size="18" />
<span>{{ t('overview.online') }}</span>
<strong>{{ admin.stats.online }}</strong>
<strong>{{ formatStatistic(admin.stats.online) }}</strong>
</article>
<article>
<Smartphone :size="18" />
<span>{{ t('overview.devices') }}</span>
<strong>{{ admin.stats.devices }}</strong>
<strong>{{ formatStatistic(admin.stats.devices) }}</strong>
</article>
<article>
<Database :size="18" />
<span>{{ t('overview.accounts') }}</span>
<strong>{{ admin.stats.accounts }}</strong>
<strong>{{ formatStatistic(admin.stats.accounts) }}</strong>
</article>
<article>
<ScrollText :size="18" />
<span>{{ t('overview.audit') }}</span>
<strong>{{ admin.audit.length }}</strong>
<strong>{{ formatStatistic(admin.stats.auditEntries) }}</strong>
</article>
</div>
@@ -1408,146 +1271,100 @@ onBeforeUnmount(() => {
>
<div class="admin-panel-section-card__heading">
<div>
<span>{{ t('appearance.eyebrow') }}</span>
<h2>{{ t('appearance.title') }}</h2>
<p>{{ t('appearance.body') }}</p>
<span>{{ t('statistics.eyebrow') }}</span>
<h2>{{ t('statistics.title') }}</h2>
<p>{{ t('statistics.body') }}</p>
</div>
<Palette :size="20" />
<ChartNoAxesCombined :size="20" />
</div>
<div class="admin-panel-accent-picker">
<button
v-for="option in accentOptions"
:key="option.color"
type="button"
:class="{ 'is-active': accentColor === option.color }"
:aria-label="t('appearance.colors.' + option.key)"
:title="t('appearance.colors.' + option.key)"
@click="selectAccent(option.color)"
>
<span :style="{ backgroundColor: option.color }"></span>
<strong>{{ t('appearance.colors.' + option.key) }}</strong>
<Check v-if="accentColor === option.color" :size="15" />
</button>
</div>
<div class="admin-panel-appearance-controls">
<section class="admin-panel-appearance-control is-color">
<div>
<strong>{{ t('appearance.controls.customColor') }}</strong>
<small>{{
t('appearance.controls.customColorBody')
}}</small>
</div>
<div class="admin-panel-rgb-picker">
<label class="admin-panel-color-value">
<span :style="{ backgroundColor: accentColor }"></span>
<input
v-config-input-width
type="text"
maxlength="7"
:value="accentColor.toUpperCase()"
:aria-label="t('appearance.controls.hex')"
@change="updateAccent"
/>
</label>
<div class="admin-panel-rgb-fields">
<label v-for="channel in accentChannels" :key="channel">
<span>{{ t(`appearance.controls.${channel}`) }}</span>
<input
class="admin-panel-rgb-range"
type="range"
min="0"
max="255"
:value="accentRgb[channel]"
:style="{
background: accentChannelGradient(channel),
}"
:aria-label="t(`appearance.controls.${channel}`)"
@input="updateAccentChannel(channel, $event)"
/>
<input
v-config-input-width
type="number"
min="0"
max="255"
:value="accentRgb[channel]"
:aria-label="`${t(`appearance.controls.${channel}`)} ${t('appearance.controls.value')}`"
@input="updateAccentChannel(channel, $event)"
/>
</label>
<div class="admin-panel-statistics-layout">
<section class="admin-panel-activity-statistics">
<div class="admin-panel-statistics-heading">
<div>
<strong>{{ t('statistics.today') }}</strong>
<small>{{ t('statistics.todayBody') }}</small>
</div>
</div>
<div class="admin-panel-activity-grid">
<article>
<MessageSquare :size="17" />
<strong>{{
formatStatistic(admin.stats.messagesToday)
}}</strong>
<span>{{ t('statistics.messagesToday') }}</span>
</article>
<article>
<PhoneCall :size="17" />
<strong>{{
formatStatistic(admin.stats.callsToday)
}}</strong>
<span>{{ t('statistics.callsToday') }}</span>
</article>
<article>
<ScrollText :size="17" />
<strong>{{
formatStatistic(admin.stats.auditToday)
}}</strong>
<span>{{ t('statistics.auditToday') }}</span>
</article>
</div>
</section>
<section class="admin-panel-appearance-control">
<span>
<strong>{{ t('appearance.controls.fontFamily') }}</strong>
<small>{{ t('appearance.controls.fontFamilyBody') }}</small>
</span>
<div
class="admin-panel-font-select"
@focusout="closeFontMenu"
>
<button
type="button"
:aria-label="t('appearance.controls.fontFamily')"
aria-haspopup="listbox"
:aria-expanded="fontMenuOpen"
@click="fontMenuOpen = !fontMenuOpen"
<section class="admin-panel-coverage-statistics">
<div class="admin-panel-statistics-heading">
<div>
<strong>{{ t('statistics.coverage') }}</strong>
<small>{{ t('statistics.coverageBody') }}</small>
</div>
</div>
<div class="admin-panel-coverage-list">
<article
v-for="metric in [
{
key: 'linkedDevices',
value: admin.stats.linkedDevices,
},
{
key: 'simDevices',
value: admin.stats.simDevices,
},
{
key: 'activeDevices',
value: admin.stats.activeDevices,
},
]"
:key="metric.key"
>
<span :style="{ fontFamily: activeAdminFontFamily }">
{{ t(`appearance.controls.fonts.${adminFontFamily}`) }}
</span>
<ChevronDown :size="14" />
</button>
<div
v-if="fontMenuOpen"
class="admin-panel-font-select__menu"
role="listbox"
:aria-label="t('appearance.controls.fontFamily')"
>
<button
v-for="option in fontFamilyOptions"
:key="option.key"
type="button"
role="option"
:aria-selected="adminFontFamily === option.key"
:class="{ 'is-active': adminFontFamily === option.key }"
:style="{ fontFamily: option.value }"
@click="selectAdminFontFamily(option.key)"
>
<div>
<strong>{{ t(`statistics.${metric.key}`) }}</strong>
<span>{{
t(`appearance.controls.fonts.${option.key}`)
t('statistics.ofDevices', {
count: formatStatistic(metric.value),
total: formatStatistic(admin.stats.devices),
})
}}</span>
<Check
v-if="adminFontFamily === option.key"
:size="13"
/>
</button>
</div>
</div>
<div
class="admin-panel-statistics-progress"
role="progressbar"
:aria-label="t(`statistics.${metric.key}`)"
:aria-valuemin="0"
:aria-valuemax="100"
:aria-valuenow="
statisticPercentage(metric.value, admin.stats.devices)
"
>
<span
:style="{
width: `${statisticPercentage(metric.value, admin.stats.devices)}%`,
}"
></span>
</div>
</article>
</div>
</section>
<label class="admin-panel-appearance-control">
<span>
<strong>{{ t('appearance.controls.fontSize') }}</strong>
<small>{{ t('appearance.controls.fontSizeBody') }}</small>
</span>
<div class="admin-panel-font-size-control">
<input
v-model.number="adminFontSizePreview"
type="range"
min="90"
max="115"
step="5"
:aria-label="t('appearance.controls.fontSize')"
@change="updateAdminFontSize"
/>
<strong>{{ adminFontSizePreview }}%</strong>
</div>
</label>
</div>
</article>
</section>
<section
@@ -2488,7 +2305,8 @@ onBeforeUnmount(() => {
--admin-text: #f0f3f0;
--admin-muted: #818781;
--admin-dim: #555b55;
--admin-green: var(--admin-accent, #74d66f);
--admin-accent: #00b8e4;
--admin-green: var(--admin-accent);
--admin-green-soft: color-mix(in srgb, var(--admin-green) 9%, transparent);
--admin-toggle-on: #63d471;
--admin-row-hover: linear-gradient(
@@ -2512,10 +2330,7 @@ onBeforeUnmount(() => {
padding: 2.5vh 2.5vw;
color: var(--admin-text);
background: transparent;
font-family: var(
--admin-font-family,
var(--sky-font-family, Inter, sans-serif)
);
font-family: var(--sky-font-family, Inter, sans-serif);
pointer-events: auto;
}
@@ -2529,7 +2344,6 @@ onBeforeUnmount(() => {
box-shadow:
0 20px 56px rgba(0, 0, 0, 0.72),
inset 0 1px rgba(255, 255, 255, 0.018);
zoom: var(--admin-font-scale, 1);
}
.admin-panel-header {
@@ -2653,15 +2467,19 @@ onBeforeUnmount(() => {
}
.admin-panel-save.is-ready {
color: #74d66f;
color: var(--admin-green);
background: transparent;
filter: drop-shadow(0 0 5px rgba(116, 214, 111, 0.42));
filter: drop-shadow(
0 0 5px color-mix(in srgb, var(--admin-green) 42%, transparent)
);
}
.admin-panel-save.is-ready:hover:not(:disabled) {
color: color-mix(in srgb, #74d66f 82%, white);
color: color-mix(in srgb, var(--admin-green) 82%, white);
background: transparent;
filter: drop-shadow(0 0 7px rgba(116, 214, 111, 0.65));
filter: drop-shadow(
0 0 7px color-mix(in srgb, var(--admin-green) 65%, transparent)
);
}
.admin-panel-close:hover {
@@ -3352,293 +3170,127 @@ button:disabled {
gap: 8px;
}
.admin-panel-accent-picker {
.admin-panel-statistics-layout {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 3px;
padding: 3px;
border: 0;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 8px;
}
.admin-panel-activity-statistics,
.admin-panel-coverage-statistics {
min-width: 0;
padding: 10px;
border-radius: 3px;
background: #0b0d0c;
}
.admin-panel-accent-picker button {
min-height: 38px;
.admin-panel-statistics-heading {
margin-bottom: 9px;
}
.admin-panel-statistics-heading > div {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 16px;
align-items: center;
gap: 7px;
padding: 7px 9px;
border: 1px solid transparent;
border-radius: 2px;
color: var(--admin-muted);
background: transparent;
text-align: left;
cursor: pointer;
gap: 2px;
}
.admin-panel-accent-picker button:hover,
.admin-panel-accent-picker button.is-active {
color: var(--admin-text);
background: var(--admin-row-active);
}
.admin-panel-accent-picker button > span {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
}
.admin-panel-accent-picker strong {
overflow: hidden;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-panel-accent-picker svg {
color: var(--admin-green);
}
.admin-panel-appearance-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
margin-top: 8px;
background: #0a0c0b;
}
.admin-panel-appearance-control {
min-height: 62px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.8fr);
align-items: center;
gap: 12px;
padding: 10px 12px;
background: #121412;
}
.admin-panel-appearance-control.is-color {
grid-column: 1 / -1;
grid-template-columns: minmax(170px, 0.7fr) minmax(320px, 1.3fr);
}
.admin-panel-appearance-control > div:first-child,
.admin-panel-appearance-control > span:first-child {
min-width: 0;
display: grid;
gap: 3px;
}
.admin-panel-appearance-control strong {
.admin-panel-statistics-heading strong {
color: #d9ddd9;
font-size: 9px;
font-weight: 600;
}
.admin-panel-appearance-control small {
.admin-panel-statistics-heading small {
color: var(--admin-muted);
font-size: 8px;
line-height: 1.3;
line-height: 1.35;
}
.admin-panel-rgb-picker {
.admin-panel-activity-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 3px;
}
.admin-panel-activity-grid article {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(230px, 1fr);
grid-template-columns: 20px minmax(0, 1fr);
align-items: center;
gap: 14px;
gap: 2px 6px;
padding: 10px;
background: #121412;
}
.admin-panel-color-value {
min-width: 96px;
height: 32px;
display: flex;
align-items: center;
gap: 7px;
padding: 0 8px;
border-radius: 3px;
outline: 1px solid var(--admin-border-strong);
background: #1a1d1b;
}
.admin-panel-color-value > span {
width: 16px;
height: 16px;
flex: 0 0 auto;
border-radius: 3px;
box-shadow: 0 0 8px color-mix(in srgb, var(--admin-green) 48%, transparent);
}
.admin-panel-color-value input {
min-width: 0;
height: 24px;
padding: 0;
border: 0;
outline: 0;
color: #cbd0cb;
background: transparent;
font: inherit;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 8px;
text-transform: uppercase;
}
.admin-panel-rgb-fields {
display: grid;
gap: 6px;
}
.admin-panel-rgb-fields label {
min-width: 0;
display: grid;
grid-template-columns: 12px minmax(90px, 1fr) 42px;
align-items: center;
gap: 7px;
}
.admin-panel-rgb-fields span {
color: var(--admin-muted);
font-size: 8px;
font-weight: 650;
text-transform: uppercase;
}
.admin-panel-rgb-fields input[type='number'] {
width: 42px !important;
max-width: 42px;
height: 24px;
padding: 0 6px;
border: 0;
border-radius: 3px;
outline: 1px solid var(--admin-border-strong);
color: var(--admin-text);
background: #1a1d1b;
font: inherit;
font-size: 8px;
appearance: textfield;
}
.admin-panel-rgb-fields input[type='number']::-webkit-inner-spin-button,
.admin-panel-rgb-fields input[type='number']::-webkit-outer-spin-button {
appearance: none;
}
.admin-panel-rgb-range {
width: 100%;
height: 4px;
padding: 0;
border: 0;
border-radius: 999px;
outline: 0;
appearance: none;
cursor: pointer;
}
.admin-panel-rgb-range::-webkit-slider-thumb {
width: 13px;
height: 13px;
border: 2px solid #f3f5f3;
border-radius: 50%;
background: var(--admin-green);
box-shadow: 0 0 0 2px #171a18;
appearance: none;
}
.admin-panel-font-select {
position: relative;
min-width: 0;
}
.admin-panel-font-select > button {
width: 100%;
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 9px;
border: 0;
border-radius: 3px;
outline: 1px solid var(--admin-border-strong);
color: var(--admin-text);
background: #1a1d1b;
font: inherit;
font-size: 9px;
text-align: left;
cursor: pointer;
}
.admin-panel-font-select > button svg {
color: var(--admin-muted);
transition: transform 150ms ease;
}
.admin-panel-font-select > button[aria-expanded='true'] svg {
transform: rotate(180deg);
}
.admin-panel-font-select__menu {
position: absolute;
z-index: 12;
top: calc(100% + 5px);
right: 0;
left: 0;
max-height: 210px;
overflow-y: auto;
display: grid;
gap: 1px;
padding: 4px;
border-radius: 4px;
outline: 1px solid rgba(255, 255, 255, 0.09);
background: #101211;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.58);
}
.admin-panel-font-select__menu button {
min-height: 30px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px;
border: 0;
border-radius: 2px;
color: var(--admin-muted);
background: transparent;
font-size: 9px;
text-align: left;
cursor: pointer;
}
.admin-panel-font-select__menu button:hover,
.admin-panel-font-select__menu button.is-active {
color: var(--admin-text);
background: var(--admin-row-active);
}
.admin-panel-font-select__menu svg {
.admin-panel-activity-grid svg {
color: var(--admin-green);
}
.admin-panel-font-size-control {
display: grid;
grid-template-columns: minmax(80px, 1fr) 38px;
align-items: center;
gap: 9px;
.admin-panel-activity-grid strong {
overflow: hidden;
font-size: 13px;
font-weight: 620;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-panel-font-size-control input {
width: 100%;
.admin-panel-activity-grid span {
grid-column: 1 / -1;
overflow: hidden;
color: var(--admin-muted);
font-size: 7px;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.admin-panel-coverage-list {
display: grid;
gap: 8px;
}
.admin-panel-coverage-list article {
display: grid;
gap: 5px;
}
.admin-panel-coverage-list article > div:first-child {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.admin-panel-coverage-list strong,
.admin-panel-coverage-list span {
overflow: hidden;
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-panel-coverage-list strong {
color: #d9ddd9;
font-weight: 560;
}
.admin-panel-coverage-list span {
color: var(--admin-muted);
}
.admin-panel-statistics-progress {
height: 3px;
accent-color: var(--admin-green);
cursor: pointer;
overflow: hidden;
border-radius: 999px;
background: #1d201e;
}
.admin-panel-font-size-control > strong {
color: var(--admin-green);
text-align: right;
.admin-panel-statistics-progress > span {
height: 100%;
display: block;
border-radius: inherit;
background: var(--admin-green);
box-shadow: 0 0 8px color-mix(in srgb, var(--admin-green) 52%, transparent);
transition: width 180ms ease;
}
.admin-panel-section-card__heading {
+12 -1
View File
@@ -15,7 +15,18 @@ import type {
} from '@/types/admin'
import { nuiCall, type NuiResponse } from '@/utils/nui'
const EMPTY_STATS: AdminStats = { accounts: 0, devices: 0, online: 0 }
const EMPTY_STATS: AdminStats = {
accounts: 0,
activeDevices: 0,
auditEntries: 0,
auditToday: 0,
callsToday: 0,
devices: 0,
linkedDevices: 0,
messagesToday: 0,
online: 0,
simDevices: 0,
}
export const useAdminStore = defineStore('admin', {
state: () => ({
+15 -34
View File
@@ -848,40 +848,21 @@ const adminPanelFallbackLocales = {
auditFeature: 'Review sensitive admin actions',
configuratorFeature: 'Manage config.lua and media.lua through SQL',
},
appearance: {
eyebrow: 'Appearance',
title: 'Interface',
body: 'Personalize colors and typography across the admin workspace.',
colors: {
emerald: 'Emerald',
blue: 'Blue',
violet: 'Violet',
orange: 'Orange',
red: 'Red',
},
controls: {
customColor: 'Custom color',
customColorBody: 'Choose any RGB accent or enter its channel values.',
red: 'R',
green: 'G',
blue: 'B',
hex: 'HEX color',
value: 'value',
fontFamily: 'Font family',
fontFamilyBody: 'Choose the typeface used by the complete admin panel.',
fontSize: 'Font size',
fontSizeBody: 'Scale text and controls for comfortable readability.',
fonts: {
inter: 'Inter',
system: 'System',
classic: 'Classic',
verdana: 'Verdana',
tahoma: 'Tahoma',
trebuchet: 'Trebuchet',
georgia: 'Georgia',
mono: 'Monospace',
},
},
statistics: {
eyebrow: 'Live data',
title: 'Phone statistics',
body: 'Current usage and device status, refreshed every 15 seconds.',
today: 'Activity today',
todayBody: 'Phone and admin activity since midnight.',
messagesToday: 'Messages',
callsToday: 'Calls',
auditToday: 'Admin actions',
coverage: 'Device coverage',
coverageBody: 'Current share of all stored phones.',
linkedDevices: 'Account linked',
simDevices: 'SIM assigned',
activeDevices: 'Updated in 24 hours',
ofDevices: '{count} of {total} devices',
},
configurator: {
context: 'Runtime configuration',
+7
View File
@@ -1,7 +1,14 @@
export type AdminStats = {
accounts: number
activeDevices: number
auditEntries: number
auditToday: number
callsToday: number
devices: number
linkedDevices: number
messagesToday: number
online: number
simDevices: number
}
export type AdminPlayerSummary = {
+12 -1
View File
@@ -4740,7 +4740,18 @@ function adminMockBootstrap() {
source,
}
}),
stats: { accounts: 24, devices: 31, online: 2 },
stats: {
accounts: 24,
activeDevices: 19,
auditEntries: 37,
auditToday: 4,
callsToday: 18,
devices: 31,
linkedDevices: 22,
messagesToday: 146,
online: 2,
simDevices: 27,
},
}
}
+1 -27
View File
@@ -205,7 +205,7 @@ Locales["de"] = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin-Navigation", refresh = "Admin-Daten aktualisieren", loading = "Geschützte Daten werden geladen...",
tabs = { overview = "Übersicht", players = "Spieler", devices = "Geräte", apps = "Apps", accounts = "Accounts", messages = "Nachrichten", calls = "Anrufe", moderation = "Moderation", audit = "Audit", configurator = "Phone Configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Spieler, Geräte, Apps und Handydaten.", stats = "Server-Handystatistik", online = "Online", devices = "Geräte", accounts = "Accounts", audit = "Audit-Einträge", control = "Navigation", features = "Module", featuresBody = "Öffne ein Verwaltungsmodul.", recent = "Letzte Aktivitäten", playerFeature = "Identität, Finanzen, Job und Dienst", deviceFeature = "IMEI, SIM, Nummer und Aktivität", appFeature = "Handy-Apps installieren oder entfernen", accountFeature = "Accountzugriff und geschützte Zugangsdaten", messageFeature = "Letzte SMS-Aktivitäten prüfen", callFeature = "Letzte Anrufaktivitäten prüfen", moderationFeature = "Zugriff, Nummer oder Gerätedaten zurücksetzen", auditFeature = "Sensible Admin-Aktionen prüfen", configuratorFeature = "config.lua und media.lua über SQL verwalten" },
appearance = { eyebrow = "Darstellung", title = "Akzentfarbe", body = "Ändere den Akzent im gesamten Admin-Arbeitsbereich.", colors = { emerald = "Smaragd", blue = "Blau", violet = "Violett", orange = "Orange", red = "Rot" } },
statistics = { eyebrow = "Live-Daten", title = "Handy-Statistiken", body = "Aktuelle Nutzung und Gerätestatus, automatisch alle 15 Sekunden aktualisiert.", today = "Aktivität heute", todayBody = "Handy- und Admin-Aktivität seit Mitternacht.", messagesToday = "Nachrichten", callsToday = "Anrufe", auditToday = "Admin-Aktionen", coverage = "Geräteabdeckung", coverageBody = "Aktueller Anteil aller gespeicherten Handys.", linkedDevices = "Account verknüpft", simDevices = "SIM zugewiesen", activeDevices = "In 24 Stunden aktualisiert", ofDevices = "{count} von {total} Geräten" },
configurator = { context = "Runtime-Konfiguration", eyebrow = "Systemwerkzeug", sections = "Konfiguration", search = "Einstellungen oder Pfade suchen", configScope = "config.lua", mediaScope = "media.lua", noResults = "Keine passenden Einstellungen", loading = "SQL-Konfiguration wird geladen...", title = "Phone Configurator", body = "Verwalte Handy- und Media-Einstellungen im geschützten Admin-Bereich.", disabledTitle = "SQL-Konfiguration ist nicht aktiv", disabledBody = "Aktiviere den Configurator am Anfang der config.lua und starte sky_phone neu. Bis dahin bleiben die Dateiwerte aktiv und die Bearbeitung gesperrt.", manualSave = "Manuelles Speichern", refreshNotice = "Nichts wird automatisch gespeichert. Der grüne Haken prüft config.lua und media.lua in SQL und aktualisiert die aktive Server-, Client-, Media- und UI-Konfiguration sofort intern.", fieldCount = "{count} Felder", secretConfigured = "Secret gesetzt · Ersatzwert eingeben", invalidValue = "Prüfe den markierten Tabellen- oder Zahlenwert.", saved = "SQL-Konfiguration gespeichert und übernommen.", descriptions = { featureToggle = "Schaltet {name} ein oder aus.", boolean = "Legt fest, ob {name} erlaubt ist.", number = "Legt den Zahlenwert für {name} fest.", text = "Legt den Textwert für {name} fest.", optionalText = "Legt den optionalen Wert für {name} fest; der Schalter deaktiviert ihn.", list = "Verwaltet alle Einträge für {name}.", table = "Bündelt die zusammengehörigen Einstellungen für {name}.", credential = "Speichert den geschützten Zugangsschlüssel für {name}.", url = "Legt die URL oder den Endpunkt für {name} fest.", hosts = "Legt die erlaubten Domains für {name} fest.", milliseconds = "Legt die Zeit für {name} in Millisekunden fest.", seconds = "Legt die Zeit für {name} in Sekunden fest.", rateLimit = "Begrenzt die Aktionen für {name} pro Minute.", byteLimit = "Legt die maximal erlaubte Datengröße für {name} fest.", textLimit = "Legt die maximal erlaubte Textlänge für {name} fest.", distance = "Legt die Distanz in der Spielwelt für {name} fest.", coordinates = "Legt Weltkoordinaten oder Ausrichtung für {name} fest.", gameAsset = "Legt das GTA-Modell oder Prop für {name} fest.", animation = "Legt die Animationsdatei für {name} fest.", access = "Legt Jobs, Gruppen oder Berechtigungsstufen für {name} fest.", integration = "Wählt Framework oder Anbieter für {name} aus.", path = "Legt den Speicher- oder Ressourcenpfad für {name} fest.", color = "Legt die Oberflächenfarbe für {name} fest.", displayText = "Legt den Spielern angezeigten Text für {name} fest.", phoneNumber = "Legt die Telefon- oder Servicenummer für {name} fest.", routing = "Steuert die Verteilung eingehender Anfragen für {name}.", command = "Legt den Chat-Befehl zum Öffnen oder Ausführen von {name} fest.", locale = "Wählt die Sprache für {name} aus.", debug = "Steuert ausführliche Diagnoseausgaben für {name}.", mediaQuality = "Legt Medienqualität oder Lautstärke für {name} fest.", amount = "Legt die maximale oder angezeigte Menge für {name} fest." }, table = { list = "Liste", table = "Schlüsseltabelle", vector = "Vektor", entry = "Eintrag", general = "Allgemein", addRow = "Zeile hinzufügen", addField = "Feld hinzufügen", remove = "Entfernen", emptyList = "Noch keine Zeilen. Füge die erste Zeile mit Plus hinzu.", emptyTable = "Noch keine Felder. Füge unten den ersten Schlüssel hinzu.", keyPlaceholder = "Neuer Schlüssel", convertToList = "Als Liste nutzen", convertToMap = "Als typisierte Schlüsseltabelle nutzen", convertToTable = "Als Schlüsseltabelle nutzen", types = { string = "Text", number = "Zahl", boolean = "Schalter", list = "Liste", table = "Tabelle" } } },
players = { eyebrow = "Aktive Sitzungen", title = "Online-Spieler", online = "Jetzt online", empty = "Keine Spieler gefunden", emptyBody = "Passe die Suche an oder aktualisiere die Spielerliste." },
search = { players = "Name, ID, Job oder Nummer suchen", apps = "Apps suchen", clear = "Suche leeren" },
@@ -2175,29 +2175,3 @@ Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
AllowedJobs = "Erlaubte Jobs",
Websites = "Webseiten",
}
Locales["de"].Nui.AdminPanel.appearance.title = "Oberfläche"
Locales["de"].Nui.AdminPanel.appearance.body = "Passe Farben und Typografie im gesamten Admin-Bereich an."
Locales["de"].Nui.AdminPanel.appearance.controls = {
customColor = "Eigene Farbe",
customColorBody = "Wähle einen beliebigen RGB-Akzent oder trage die Farbwerte ein.",
red = "R",
green = "G",
blue = "B",
hex = "HEX-Farbe",
value = "Wert",
fontFamily = "Schriftart",
fontFamilyBody = "Wähle die Schrift für das gesamte Admin-Panel.",
fontSize = "Schriftgröße",
fontSizeBody = "Skaliere Text und Bedienelemente für eine angenehme Lesbarkeit.",
fonts = {
inter = "Inter",
system = "System",
classic = "Klassisch",
verdana = "Verdana",
tahoma = "Tahoma",
trebuchet = "Trebuchet",
georgia = "Georgia",
mono = "Monospace",
},
}
+1 -27
View File
@@ -205,7 +205,7 @@ Locales["en"] = {
name = "Phone Admin", subtitle = "Administration", navigation = "Admin navigation", refresh = "Refresh admin data", loading = "Loading protected data...",
tabs = { overview = "Overview", players = "Players", devices = "Devices", apps = "Apps", accounts = "Accounts", messages = "Messages", calls = "Calls", moderation = "Moderation", audit = "Audit", configurator = "Phone configurator" },
overview = { eyebrow = "Server", title = "Dashboard", body = "Players, devices, apps, and phone data.", stats = "Server phone statistics", online = "Online", devices = "Devices", accounts = "Accounts", audit = "Audit entries", control = "Navigation", features = "Modules", featuresBody = "Open an administration module.", recent = "Recent activity", playerFeature = "Identity, finances, job, and duty", deviceFeature = "IMEI, SIM, number, and activity", appFeature = "Install or remove phone apps", accountFeature = "Account access and protected credentials", messageFeature = "Review recent SMS activity", callFeature = "Review recent call activity", moderationFeature = "Reset access, number, or device data", auditFeature = "Review sensitive admin actions", configuratorFeature = "Manage config.lua and media.lua through SQL" },
appearance = { eyebrow = "Appearance", title = "Accent color", body = "Change the accent across the complete admin workspace.", colors = { emerald = "Emerald", blue = "Blue", violet = "Violet", orange = "Orange", red = "Red" } },
statistics = { eyebrow = "Live data", title = "Phone statistics", body = "Current usage and device status, refreshed every 15 seconds.", today = "Activity today", todayBody = "Phone and admin activity since midnight.", messagesToday = "Messages", callsToday = "Calls", auditToday = "Admin actions", coverage = "Device coverage", coverageBody = "Current share of all stored phones.", linkedDevices = "Account linked", simDevices = "SIM assigned", activeDevices = "Updated in 24 hours", ofDevices = "{count} of {total} devices" },
configurator = { context = "Runtime configuration", eyebrow = "System tool", sections = "Configuration", search = "Search settings or paths", configScope = "config.lua", mediaScope = "media.lua", noResults = "No matching settings", loading = "Loading SQL configuration...", title = "Phone configurator", body = "Manage phone and media settings from the protected admin workspace.", disabledTitle = "SQL configuration is not active", disabledBody = "Enable the configurator at the beginning of config.lua and restart sky_phone. Until then, file values remain active and editing is locked.", manualSave = "Manual save", refreshNotice = "Nothing is written automatically. The green check verifies config.lua and media.lua in SQL and refreshes the active server, client, media and UI configuration immediately.", fieldCount = "{count} fields", secretConfigured = "Secret configured · enter a replacement", invalidValue = "Check the highlighted table or number value.", saved = "SQL configuration saved and applied.", descriptions = { featureToggle = "Turns {name} on or off.", boolean = "Controls whether {name} is allowed.", number = "Sets the numeric value for {name}.", text = "Sets the text value used for {name}.", optionalText = "Sets the optional value for {name}; switch it off to disable it.", list = "Manages all entries used for {name}.", table = "Groups the related settings for {name}.", credential = "Stores the protected credential used by {name}.", url = "Sets the URL or endpoint used by {name}.", hosts = "Defines which domains are allowed for {name}.", milliseconds = "Sets the timing for {name} in milliseconds.", seconds = "Sets the timing for {name} in seconds.", rateLimit = "Limits how many {name} actions are allowed per minute.", byteLimit = "Sets the maximum data size allowed for {name}.", textLimit = "Sets the maximum text length allowed for {name}.", distance = "Sets the world distance used for {name}.", coordinates = "Sets the world coordinates or orientation for {name}.", gameAsset = "Sets the GTA model or prop used for {name}.", animation = "Sets the animation asset used for {name}.", access = "Defines the jobs, groups or permission level for {name}.", integration = "Selects the connected framework or provider for {name}.", path = "Sets the storage or resource path used for {name}.", color = "Sets the interface color used for {name}.", displayText = "Sets the text shown to players for {name}.", phoneNumber = "Sets the phone or service number used for {name}.", routing = "Controls how incoming requests are routed for {name}.", command = "Sets the chat command used to open or run {name}.", locale = "Selects the language used for {name}.", debug = "Controls detailed diagnostic output for {name}.", mediaQuality = "Sets the media quality or volume used for {name}.", amount = "Sets the maximum or displayed amount for {name}." }, table = { list = "List", table = "Key table", vector = "Vector", entry = "Entry", general = "General", addRow = "Add row", addField = "Add field", remove = "Remove", emptyList = "No rows yet. Add the first row with plus.", emptyTable = "No fields yet. Add the first key below.", keyPlaceholder = "New key", convertToList = "Use list", convertToMap = "Use typed key table", convertToTable = "Use key table", types = { string = "Text", number = "Number", boolean = "Switch", list = "List", table = "Table" } } },
players = { eyebrow = "Active sessions", title = "Online players", online = "Online now", empty = "No players found", emptyBody = "Adjust the search or refresh the live player list." },
search = { players = "Search name, ID, job, or number", apps = "Search apps", clear = "Clear search" },
@@ -2175,29 +2175,3 @@ Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
AllowedJobs = "Allowed Jobs",
Websites = "Websites",
}
Locales["en"].Nui.AdminPanel.appearance.title = "Interface"
Locales["en"].Nui.AdminPanel.appearance.body = "Personalize colors and typography across the admin workspace."
Locales["en"].Nui.AdminPanel.appearance.controls = {
customColor = "Custom color",
customColorBody = "Choose any RGB accent or enter its channel values.",
red = "R",
green = "G",
blue = "B",
hex = "HEX color",
value = "value",
fontFamily = "Font family",
fontFamilyBody = "Choose the typeface used by the complete admin panel.",
fontSize = "Font size",
fontSizeBody = "Scale text and controls for comfortable readability.",
fonts = {
inter = "Inter",
system = "System",
classic = "Classic",
verdana = "Verdana",
tahoma = "Tahoma",
trebuchet = "Trebuchet",
georgia = "Georgia",
mono = "Monospace",
},
}
+1 -28
View File
@@ -205,7 +205,7 @@ Locales["es"] = {
name = "Administrador de teléfono", subtitle = "Administración", navigation = "Navegación del administrador", refresh = "Actualizar los datos del administrador", loading = "Cargar datos protegidos...",
tabs = { overview = "Sinopsis", players = "Los jugadores", devices = "Los dispositivos", apps = "Aplicaciones", accounts = "Cuentas", messages = "Mensajes", calls = "Las llamadas", moderation = "Moderación", audit = "Auditoría", configurator = "Configuración de teléfono" },
overview = { eyebrow = "El servidor", title = "Tablero de control", body = "Jugadores, dispositivos, aplicaciones y datos telefónicos.", stats = "Estadísticas telefónicas del servidor", online = "En línea", devices = "Los dispositivos", accounts = "Cuentas", audit = "Entradas de auditoría", control = "Navegación", features = "Los módulos", featuresBody = "Abre un módulo de administración.", recent = "Actividad reciente", playerFeature = "Identidad, finanzas, trabajo y deber", deviceFeature = "IMEI, SIM, número y actividad", appFeature = "Instalar o eliminar aplicaciones telefónicas", accountFeature = "Acceso a la cuenta y credenciales protegidas", messageFeature = "Revisar la actividad reciente de SMS", callFeature = "Revisar la actividad reciente de las llamadas", moderationFeature = "Resetear datos de acceso, número o dispositivo", auditFeature = "Revisar las acciones del administrador sensibles", configuratorFeature = "Administrar config.lua y media.lua a través de SQL" },
appearance = { eyebrow = "Apariencia", title = "Color de acento", body = "Cambia el acento en todo el espacio de trabajo del administrador.", colors = { emerald = "Esmeralda", blue = "Azul", violet = "Violeta", orange = "Naranja", red = "Rojo" } },
statistics = { eyebrow = "Datos en vivo", title = "Estadísticas del teléfono", body = "Uso actual y estado de los dispositivos, actualizado cada 15 segundos.", today = "Actividad de hoy", todayBody = "Actividad telefónica y administrativa desde medianoche.", messagesToday = "Mensajes", callsToday = "Llamadas", auditToday = "Acciones de administrador", coverage = "Cobertura de dispositivos", coverageBody = "Proporción actual de todos los teléfonos guardados.", linkedDevices = "Cuenta vinculada", simDevices = "SIM asignada", activeDevices = "Actualizado en 24 horas", ofDevices = "{count} de {total} dispositivos" },
configurator = { context = "Configuración de tiempo de ejecución", eyebrow = "Herramienta del sistema", sections = "Configuración", search = "Configuración de búsqueda o caminos", configScope = "config.lua", mediaScope = "media.lua", noResults = "Ninguna configuración correspondiente", loading = "Cargar la configuración de SQL...", title = "Configuración de teléfono", body = "Gestionar la configuración de teléfono y medios desde el espacio de trabajo de administrador protegido.", disabledTitle = "Configuración SQL no está activa", disabledBody = "Habilitar el configurador al principio de config.lua y reiniciar sky_phone. Hasta entonces, los valores del archivo permanecen activos y la edición está bloqueada.", manualSave = "Salvamiento manual", refreshNotice = "Nada se escribe automáticamente. La verificación verde verifica config.lua y media.lua en SQL y actualiza inmediatamente la configuración del servidor activo, el cliente, los medios y la interfaz de usuario.", fieldCount = "Campos {count}", secretConfigured = "Configuración secreta · introducir un reemplazo", invalidValue = "Comprueba el valor de la tabla o número resaltado.", saved = "Configuración SQL guardada y aplicada.", descriptions = { featureToggle = "Enciende o apaga {name}.", boolean = "Controla si {name} está permitido.", number = "Establece el valor numérico para {name}.", text = "Establece el valor de texto utilizado para {name}.", optionalText = "Establece el valor opcional para {name}; apague para desactivarlo.", list = "Gestiona todas las entradas utilizadas para {name}.", table = "Agrupa las configuraciones relacionadas para {name}.", credential = "Almacena la credencial protegida utilizada por {name}.", url = "Establece la URL o el punto final utilizado por {name}.", hosts = "Define qué dominios están permitidos para {name}.", milliseconds = "Establece el tiempo de {name} en milisegundos.", seconds = "Establece el tiempo de {name} en segundos.", rateLimit = "Limita la cantidad de acciones {name} permitidas por minuto.", byteLimit = "Establece el tamaño máximo de datos permitido para {name}.", textLimit = "Establece la longitud máxima de texto permitida para {name}.", distance = "Establece la distancia mundial utilizada para {name}.", coordinates = "Establece las coordenadas del mundo o la orientación para {name}.", gameAsset = "Establece el modelo GTA o el accesorio utilizado para {name}.", animation = "Establece el activo de animación utilizado para {name}.", access = "Define los puestos de trabajo, los grupos o el nivel de permiso para {name}.", integration = "Selecciona el marco o proveedor conectado para {name}.", path = "Establece la ruta de almacenamiento o recurso utilizada para {name}.", color = "Establece el color de interfaz utilizado para {name}.", displayText = "Establece el texto que se muestra a los jugadores para {name}.", phoneNumber = "Establece el número de teléfono o servicio utilizado para {name}.", routing = "Controla cómo se envía las solicitudes entrantes para {name}.", command = "Establece el comando de chat utilizado para abrir o ejecutar {name}.", locale = "Selecciona el idioma utilizado para {name}.", debug = "Controla el diagnóstico detallado de {name}.", mediaQuality = "Establece la calidad o volumen de los medios utilizados para {name}.", amount = "Establece la cantidad máxima o mostrada para {name}." }, table = { list = "Lista", table = "Mesa de llaves", vector = "Vector", entry = "Entrada", general = "Generales", addRow = "Añadir fila", addField = "Añadir campo", remove = "Eliminar", emptyList = "Aún no hay filas. Agregue la primera fila con más.", emptyTable = "Aún no hay campos. Añade la primera llave de abajo.", keyPlaceholder = "Nueva llave", convertToList = "Lista de uso", convertToMap = "Usa la tabla de teclas digitalizada", convertToTable = "Usa la tabla de teclas", types = { string = "El texto", number = "Número", boolean = "Cambiador", list = "Lista", table = "Cuadro" } } },
players = { eyebrow = "sesiones activas", title = "Jugadores en línea", online = "En línea ahora", empty = "No se encontraron jugadores", emptyBody = "Ajusta la búsqueda o actualiza la lista de jugadores en vivo." },
search = { players = "Nombre, identificación, trabajo o número de búsqueda", apps = "Aplicaciones de búsqueda", clear = "Limpiar búsqueda" },
@@ -2175,30 +2175,3 @@ Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
AllowedJobs = "Trabajos permitidos",
Websites = "Las páginas web",
}
Locales["es"].Nui.AdminPanel.appearance.title = "Interfaz"
Locales["es"].Nui.AdminPanel.appearance.body = "Personaliza los colores y la tipografía en el espacio de trabajo del administrador."
Locales["es"].Nui.AdminPanel.appearance.controls = {
customColor = "Color personalizado",
customColorBody = "Seleccione cualquier acento RGB o ingrese sus valores de canal.",
red = "R",
green = "G",
blue = "EN EL CASO DE B",
hex = "El color HEX",
value = "el valor",
fontFamily = "Familia de fuentes",
fontFamilyBody = "Seleccione la tipografía utilizada por el panel de administración completo.",
fontSize = "Tamaño de fuente",
fontSizeBody = "Escala el texto y los controles para una fácil legibilidad.",
fonts = {
inter = "Inter",
system = "El sistema",
classic = "Clásico",
verdana = "Verdana",
tahoma = "Tahoma",
trebuchet = "Arbúsqueda",
georgia = "Georgia",
mono = "Monospacio",
},
}
+22 -3
View File
@@ -463,16 +463,35 @@ Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
local totals = Bridge.Database.Query([[
SELECT
(SELECT COUNT(*) FROM `sky_phone_devices`) AS `devices`,
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`
(SELECT COUNT(*) FROM `sky_phone_accounts`) AS `accounts`,
(SELECT COUNT(*) FROM `sky_phone_devices` WHERE `account_id` IS NOT NULL) AS `linked_devices`,
(SELECT COUNT(*) FROM `sky_phone_devices` WHERE `sim_id` IS NOT NULL) AS `sim_devices`,
(SELECT COUNT(*) FROM `sky_phone_devices`
WHERE `updated_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)) AS `active_devices`,
(SELECT COUNT(*) FROM `sky_phone_sms_messages`
WHERE `created_at` >= CURRENT_DATE) AS `messages_today`,
(SELECT COUNT(*) FROM `sky_phone_calls`
WHERE `started_at` >= CURRENT_DATE) AS `calls_today`,
(SELECT COUNT(*) FROM `sky_phone_admin_audit`) AS `audit_entries`,
(SELECT COUNT(*) FROM `sky_phone_admin_audit`
WHERE `created_at` >= CURRENT_DATE) AS `audit_today`
]], {})
local stats = totals[1] or {}
return {
success = true,
data = {
players = players,
stats = {
online = #players,
devices = tonumber(totals[1] and totals[1].devices) or 0,
accounts = tonumber(totals[1] and totals[1].accounts) or 0,
devices = tonumber(stats.devices) or 0,
accounts = tonumber(stats.accounts) or 0,
linkedDevices = tonumber(stats.linked_devices) or 0,
simDevices = tonumber(stats.sim_devices) or 0,
activeDevices = tonumber(stats.active_devices) or 0,
messagesToday = tonumber(stats.messages_today) or 0,
callsToday = tonumber(stats.calls_today) or 0,
auditEntries = tonumber(stats.audit_entries) or 0,
auditToday = tonumber(stats.audit_today) or 0,
},
audit = load_audit(),
},