Merge pull request #239 from euzu/feature/ui_refactor_user_view

ui user view refactor, added status filter
fixed grace tiem for connections
This commit is contained in:
euzu
2025-05-06 12:28:51 +02:00
committed by GitHub
15 changed files with 153 additions and 86 deletions
+4 -1
View File
@@ -111,7 +111,9 @@
"PATTERN_IPV6": "Pattern IPv6",
"IPv4": "IPv4",
"IPv6": "IPv6",
"IP": "IP"
"IP": "IP",
"TARGET": "Target",
"TARGETS": "Targets"
},
"TITLE": {
"USER_BOUQUET_EDITOR": "User group editor"
@@ -120,6 +122,7 @@
"NO_CONTENT": "No content",
"INVALID_REGEXP": "Invalid regexp",
"INVALID_FILETYPE": "Invalid filetype",
"TARGET_NOT_EXISTS": "Target does not exist",
"DOWNLOAD": {
"SUCCESS": "Successfully downloaded",
"FAIL": "Failed to download!",
+1
View File
@@ -19,6 +19,7 @@
color: var(--app-header-color);
align-content: center;
padding: 10px;
max-width: var(--max-view-width) !important;
@media (max-width: common.$media-size-mobile) {
padding: 10px 0;
}
@@ -20,7 +20,7 @@
flex: 0 0 auto;
box-sizing: border-box;
overflow: hidden;
max-width: 100%;
max-width: var(--max-view-width);
background-color: inherit;
.source-selector {
@@ -4,11 +4,13 @@
@include common.container_horz_no_padding();
overflow: hidden;
flex: 1 1 auto;
max-width: var(--max-view-width) !important;
&__content {
@include common.container_horz_nowrap(flex-start, 0);
flex: 1 1 auto;
overflow: hidden;
gap: 0;
}
&__panels {
@@ -34,10 +34,17 @@ export default function StatusView() {
}, [services])
useEffect(() => {
const sub = interval(REQUEST_INTERVAL).subscribe(() => services.status().getServerStatus().pipe(first()).subscribe((data: any) => setStatus(data)));
const fetchData = () => services.status().getServerStatus().pipe(first()).subscribe({
next: (data: any) => setStatus(data),
error: () => {
}
});
fetchData();
const sub = interval(REQUEST_INTERVAL).subscribe(fetchData);
checkIp();
return () => sub.unsubscribe();
}, [services, checkIp]);
}, [services, checkIp])
return <div className={'status-view'}>
{ipCheck && <>
@@ -4,7 +4,7 @@ import {Credentials} from "../../model/server-config";
import useTranslator from "../../hook/use-translator";
import FormView, {FormFieldType} from "../form-view/from-view";
const STATUS_OPTIONS = [
export const STATUS_OPTIONS = [
{value: 'Active', label: 'Active'},
{value: 'Expired', label: 'Expired'},
{value: 'Banned', label: 'Banned'},
@@ -1,6 +1,10 @@
@use '../../scss/common';
@use '../../scss/preferences';
.label {
color: var(--label-color);
}
.proxy-type {
&-reverse {
}
@@ -49,12 +53,8 @@
}
label {
font-weight: bold;
&:not(.target-not-exists) {
color: var(--label-color);
}
&.target-not-exists {
color: var(--label-color);
span.target-not-exists {
color: var(--attention-color);
}
}
@@ -88,6 +88,10 @@
display: flex;
flex-flow: row;
padding: 8px;
gap: 12px;
span {
color: var(--label-color);
}
}
label {
@@ -143,6 +147,9 @@
&-col-header {
padding: 8px;
background-color: var(--background-color);
&-tools {
width: 64px !important;
}
}
&-col {
+79 -45
View File
@@ -1,4 +1,4 @@
import React, {JSX, ReactNode, useCallback, useEffect, useRef, useState} from "react";
import React, {JSX, useCallback, useEffect, useRef, useState} from "react";
import './user-view.scss';
import ServerConfig, {Credentials, TargetUser} from "../../model/server-config";
import {getIconByName} from "../../icons/icons";
@@ -9,7 +9,7 @@ import TabSet, {TabSetTab} from "../tab-set/tab-set";
import useTranslator from "../../hook/use-translator";
import DateUtils from "../../utils/date-utils";
import PlaylistFilter from "../playlist-filter/playlist-filter";
import UserEditor from "../user-editor/user-editor";
import UserEditor, {STATUS_OPTIONS} from "../user-editor/user-editor";
import TextGenerator from "../../utils/text-generator";
const renderExpDate = (value: any, hidden?: boolean) => {
@@ -20,7 +20,8 @@ const renderExpDate = (value: any, hidden?: boolean) => {
};
const renderBool = (value: any, hidden?: boolean) => {
return <span className={'checkbox-' + (value ? 'checked' : 'unchecked') }>{getIconByName(value ? 'CheckMark' : 'Clear' )}</span>;
return <span
className={'checkbox-' + (value ? 'checked' : 'unchecked')}>{getIconByName(value ? 'CheckMark' : 'Clear')}</span>;
};
const renderMaxCon = (value: any, hidden?: boolean) => {
@@ -79,8 +80,8 @@ const prepareTargetUserForSave = (targetUser: TargetUser[]): TargetUser[] => {
return targetUser.map((user) => {
let storeUser = {...user, credentials: user.credentials.map(c => ({...c}))};
storeUser.credentials.forEach((credential) => {
credential.exp_date = DateUtils.toUnixSeconds(credential.exp_date);
credential.created_at = DateUtils.toUnixSeconds(credential.created_at);
credential.exp_date = DateUtils.toUnixSeconds(credential.exp_date);
credential.created_at = DateUtils.toUnixSeconds(credential.created_at);
});
return storeUser;
});
@@ -130,19 +131,19 @@ const createNewUser = (targets: TargetUser[]): Credentials => {
}
const created_at = Date.now();
const exp_date = new Date();
exp_date.setFullYear(exp_date.getFullYear()+1);
exp_date.setFullYear(exp_date.getFullYear() + 1);
return {
username,
password: TextGenerator.generatePassword(),
token: TextGenerator.generatePassword(),
proxy: 'reverse',
created_at,
exp_date: exp_date.getTime(),
max_connections: 1,
status: "Active",
ui_enabled: true,
// @ts-ignore
_ref: undefined, // an indicator for new user
username,
password: TextGenerator.generatePassword(),
token: TextGenerator.generatePassword(),
proxy: 'reverse',
created_at,
exp_date: exp_date.getTime(),
max_connections: 1,
status: "Active",
ui_enabled: true,
// @ts-ignore
_ref: undefined, // an indicator for new user
};
}
@@ -165,6 +166,7 @@ export default function UserView(props: UserViewProps) {
const [filteredUser, setFilteredUser] = useState<Record<string, {
filter: string,
regexp: boolean,
status: undefined,
user: Credentials[]
}>>({});
@@ -291,32 +293,44 @@ export default function UserView(props: UserViewProps) {
setShowHiddenFields((hiddenFields: any) => ({...hiddenFields, [key]: !hiddenFields[key]}));
}, []);
const handleFilter = useCallback((filter: string, regexp: boolean): void => {
let filter_value = regexp ? filter : filter.toLowerCase();
const target = targets.find(t => t.target === activeTarget);
const filterTarget = useCallback((target: TargetUser, filter: string, regexp: boolean, filterForStatus: string): void => {
if (target) {
if (filter_value?.length) {
const filtered = target.credentials.filter((credential: Credentials) => {
if (filter?.length) {
let filtered = target.credentials.filter((credential: Credentials) => {
if (regexp) {
// eslint-disable-next-line eqeqeq
return credential.username.trim().match(filter_value) != undefined;
return credential.username.trim().match(filter) != undefined;
} else {
return (credential.username.trim().toLowerCase().indexOf(filter_value) > -1);
return (credential.username.trim().toLowerCase().indexOf(filter) > -1);
}
}) ?? [];
if (filterForStatus) {
filtered = filtered.filter((c: Credentials) => c.status === filterForStatus);
}
setFilteredUser((filteredUser: any) => ({
...filteredUser,
[activeTarget]: {filter, regexp, user: filtered}
[target.target]: {filter, regexp, status: filterForStatus, user: filtered}
}));
} else {
let filtered = undefined;
if (filterForStatus) {
filtered = {filter: undefined, regexp: false, status: filterForStatus, user: target.credentials.filter((c: Credentials) => c.status === filterForStatus)};
}
setFilteredUser((filteredUser: any) => ({
...filteredUser, [activeTarget]: undefined
...filteredUser, [target.target]: filtered
}));
}
}
}, [activeTarget, targets]);
}, []);
const handleFilter = useCallback((filter: string, regexp: boolean): void => {
let filter_value = regexp ? filter : filter?.toLowerCase();
const target = targets.find(t => t.target === activeTarget);
if (target) {
let currentFilter = filteredUser[target.target];
filterTarget(target, filter_value, regexp, currentFilter?.status);
}
}, [activeTarget, targets, filterTarget, filteredUser]);
const handleUserEditorSubmit = useCallback((user: Credentials, target_name: string): boolean => {
const userRef = (user as any)._ref;
@@ -349,14 +363,25 @@ export default function UserView(props: UserViewProps) {
const renderTargetOptions = useCallback((target: string): JSX.Element => {
let options = targetOptions?.[target];
if (options?.force_redirect?.length > 2) {
return <div className={'user__target-target-options'}>{translate('HINT.CONFIG.USER.TARGET_FORCE_REDIRECT')} {options.force_redirect}</div>;
return <div
className={'user__target-target-options'}>{translate('HINT.CONFIG.USER.TARGET_FORCE_REDIRECT')} {options.force_redirect}</div>;
}
return <></>;
}, [targetOptions, translate]);
return <div className={'user'}>
const handleStatusFilter = useCallback((evt: any) => {
const target_name = evt.target.dataset.target;
let target = targets.find((t) => t.target === target_name);
if (target) {
let value = evt.target.value;
value = value?.length ? value : undefined;
let currentFilter = filteredUser[target_name];
filterTarget(target, currentFilter?.filter, !!currentFilter?.regexp, value)
}
}, [filteredUser, filterTarget, targets]);
<div className={'user__toolbar'}><label>{translate('LABEL.USER')}</label>
return <div className={'user'}>
<div className={'user__toolbar'}><label>{translate('LABEL.TARGETS')} / {translate('LABEL.USER')}</label>
<PlaylistFilter onFilter={handleFilter} options={filteredUser[activeTarget]}></PlaylistFilter>
<button data-tooltip='LABEL.SAVE' onClick={handleSave}>{translate('LABEL.SAVE')}</button>
</div>
@@ -367,10 +392,18 @@ export default function UserView(props: UserViewProps) {
targets?.map(target => <div key={target.target}
className={'user__target' + (activeTarget !== target.target ? ' hidden' : '')}>
<div className={'user__target-target'}>
<label className={(target as any).src ? '' : 'target-not-exists'}>
{target.target}
<label>
{!(target as any).src &&
<span className={'target-not-exists'}>{translate('MESSAGES.TARGET_NOT_EXISTS')}</span>}
{renderTargetOptions(target.target)}
</label>
<div className={'label'}>{translate("LABEL.STATUS")}</div>
<select data-target={target.target} onChange={handleStatusFilter} defaultValue={undefined}>
<option value={undefined}></option>
{STATUS_OPTIONS.map(l => <option key={target.target + l.value} value={l.value}>{l.label}</option>)}
</select>
<div className={'user__target-target-toolbar'}>
<button data-tooltip={'New User'} data-target={target.target}
onClick={handleUserAdd}>{getIconByName('PersonAdd')}</button>
@@ -380,14 +413,25 @@ export default function UserView(props: UserViewProps) {
<div className={'user__target-user-table-container'}>
<div className={'user__target-user-table'}>
<div className={'user__target-user-row user__target-user-table-header'}>
<div
className={'user__target-user-col user__target-user-col-header user__target-user-col-header-tools'}></div>
{COLUMNS.map(col => <div key={col.field}
className={'user__target-user-col user__target-user-col-header'}>
<label>{translate(col.label)}</label></div>)}
<div className={'user__target-user-col user__target-user-col-header'}></div>
</div>
{(filteredUser[target.target]?.user ?? target.credentials).map((usr, idx) =>
<div key={'credential' + idx} className={'user__target-user-row'}>
<div className={'user__target-user-col-toolbar'}>
<span data-target={target.target} data-user={usr.username}
onClick={handleUserRemove}>
{getIconByName('PersonRemove')}
</span>
<span data-target={target.target} data-user={usr.username}
onClick={handleUserEdit}>
{getIconByName('Edit')}
</span>
</div>
{COLUMNS.map(c => <div
key={'target_' + target.target + '_' + c.field + '_' + usr.username}
className={'user__target-user-col'}>
@@ -402,16 +446,6 @@ export default function UserView(props: UserViewProps) {
</div>
</div>)
}
<div className={'user__target-user-col-toolbar'}>
<span data-target={target.target} data-user={usr.username}
onClick={handleUserRemove}>
{getIconByName('PersonRemove')}
</span>
<span data-target={target.target} data-user={usr.username}
onClick={handleUserEdit}>
{getIconByName('Edit')}
</span>
</div>
</div>
)}
</div>
+1 -1
View File
@@ -81,7 +81,7 @@ $media-size-mobile: 600px;
border: 1px solid var(--text-button-border-color);
font-size: 1.2rem;
border-radius: var(--text-button-border-radius);
padding: 8px 24px;
padding: 8px 16px;
background-color: var(--text-button-background-color);
color: var(--text-button-color);
&:focus,
+1
View File
@@ -1,4 +1,5 @@
:root {
--max-view-width: 1920px;
--media-size-mobile: 600px;
--text-color: #ffffff;
--hint-color: #888888;
+6 -5
View File
@@ -9,14 +9,14 @@ use crate::api::model::provider_config::{ProviderConfig, ProviderConfigWrapper};
use crate::utils::{default_grace_period_millis, default_grace_period_timeout_secs};
pub struct ProviderConnectionGuard {
manager: Arc<ActiveProviderManager>,
// manager: Arc<ActiveProviderManager>,
allocation: ProviderAllocation,
}
impl ProviderConnectionGuard {
pub fn new(manager: Arc<ActiveProviderManager>, allocation: ProviderAllocation) -> Self {
pub fn new(_manager: Arc<ActiveProviderManager>, allocation: ProviderAllocation) -> Self {
Self {
manager,
// manager,
allocation,
}
}
@@ -54,10 +54,11 @@ impl Drop for ProviderConnectionGuard {
ProviderAllocation::Exhausted => {}
ProviderAllocation::Available(config) |
ProviderAllocation::GracePeriod(config) => {
let manager = self.manager.clone();
// let manager = self.manager.clone();
let provider_config = Arc::clone(config);
tokio::spawn(async move {
manager.release_connection(&provider_config.name).await;
provider_config.release().await;
// manager.release_connection(&provider_config.name).await;
});
}
}
+26 -18
View File
@@ -9,6 +9,8 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::RwLock;
const USER_CON_TTL: u64 = 10_800; // 3 hours
pub struct UserConnectionGuard {
manager: Arc<ActiveUserManager>,
username: String,
@@ -149,7 +151,12 @@ impl ActiveUserManager {
}
pub async fn active_connections(&self) -> usize {
self.user.read().await.values().map(|c| c.connections as usize).sum()
Self::get_active_connections(&self.user).await
}
#[inline]
async fn get_active_connections(user: &Arc<RwLock<HashMap<String, UserConnectionData>>>) -> usize {
user.read().await.values().map(|c| c.connections as usize).sum()
}
pub async fn add_connection(&self, username: &str, max_connections: u32) -> UserConnectionGuard {
@@ -162,7 +169,7 @@ impl ActiveUserManager {
}
drop(lock);
self.log_active_user().await;
self.log_active_user();
UserConnectionGuard {
manager: Arc::new(self.clone_inner()),
@@ -176,19 +183,16 @@ impl ActiveUserManager {
if connection_data.connections > 0 {
connection_data.connections -= 1;
}
// DO NOT reset granted_grace or grace_ts here!
// We must preserve the grace period state until connection_permission() checks it.
// if connection_data.connections == 0 {
// lock.remove(username);
// } else
// if connection_data.connections < connection_data.max_connections {
// connection_data.token = None;
// }
if connection_data.connections == 0 || connection_data.connections < connection_data.max_connections {
// Grace timeout expired, reset grace counters
connection_data.granted_grace = false;
connection_data.grace_ts = 0;
}
}
drop(lock);
self.log_active_user().await;
self.log_active_user();
}
fn find_user_session(token: u32, sessions: &[UserSession]) -> Option<&UserSession> {
@@ -257,23 +261,27 @@ impl ActiveUserManager {
None
}
async fn log_active_user(&self) {
fn log_active_user(&self) {
if self.log_active_user {
let user_count = self.active_users().await;
let user_connection_count = self.active_connections().await;
info!("Active Users: {user_count}, Active User Connections: {user_connection_count}");
let user = Arc::clone(&self.user);
tokio::spawn(async move {
let user_count = user.read().await.len();
let user_connection_count = Self::get_active_connections(&user).await;
info!("Active Users: {user_count}, Active User Connections: {user_connection_count}");
});
}
}
async fn gc(&self) {
if let Some(gc_ts) = &self.gc_ts {
let ts = gc_ts.load(Ordering::SeqCst);
if current_time_secs() - ts > 10_800 { // 3 hours
let now = current_time_secs();
if now - ts > USER_CON_TTL {
let mut lock = self.user.write().await;
for (_, connection_data) in lock.iter_mut() {
connection_data.sessions.retain(|s| current_time_secs() - s.ts < 10_800);
connection_data.sessions.retain(|s| now - s.ts < USER_CON_TTL);
}
gc_ts.store(current_time_secs(), Ordering::SeqCst);
gc_ts.store(now, Ordering::SeqCst);
}
}
}
+5 -2
View File
@@ -182,12 +182,15 @@ impl ProviderConfig {
}
pub async fn release(&self) {
// DO NOT reset granted_grace or grace_ts here!
// We must preserve the grace period state until allocate() checks it.
let mut guard = self.connection.write().await;
if guard.current_connections > 0 {
guard.current_connections -= 1;
}
if guard.current_connections == 0 || guard.current_connections < self.max_connections {
guard.granted_grace = false;
guard.grace_ts = 0;
}
}
#[inline]
+4 -3
View File
@@ -61,9 +61,10 @@ impl IpCheckConfig {
return Err(M3uFilterError::new(M3uFilterErrorKind::Info, "No url provided!".to_owned()));
}
if self.url.is_some() && (self.url_ipv4.is_some() || self.url_ipv6.is_some()) {
return Err(M3uFilterError::new(M3uFilterErrorKind::Info, "url in combination with ipv4 and/or ipv6 url not allowed!".to_owned()));
}
// TODO allow or do not allow ?
// if self.url.is_some() && (self.url_ipv4.is_some() || self.url_ipv6.is_some()) {
// return Err(M3uFilterError::new(M3uFilterErrorKind::Info, "url in combination with ipv4 and/or ipv6 url not allowed!".to_owned()));
// }
if let Some(p4) = &self.pattern_ipv4 {
self.t_pattern_ipv4 = Some(
-1
View File
@@ -53,7 +53,6 @@ async fn fetch_combined_ips(client: &Arc<Client>, config: &IpCheckConfig, url: &
}
}
/// Fetch both IPv4 and IPv6 addresses, using separate or combined URL(s)
pub async fn get_ips(client: &Arc<Client>, config: &IpCheckConfig) -> Result<(Option<String>, Option<String>), M3uFilterError> {
match (&config.url_ipv4, &config.url_ipv6, &config.url) {