├── .gitignore ├── .gitattributes ├── requirements.txt ├── config.yaml.example ├── README.md ├── conda_env_streamlit.yml └── main.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.yaml 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pandas 2 | numpy 3 | yaml 4 | sshtunnel 5 | pymysql 6 | altair 7 | streamlit 8 | protobuf>=4.25.8 9 | -------------------------------------------------------------------------------- /config.yaml.example: -------------------------------------------------------------------------------- 1 | credentials: 2 | sql_hostname: '127.0.0.1' 3 | sql_username: 'root' 4 | sql_password: 'password' 5 | sql_main_database: 'marzban' 6 | sql_port: 3306 7 | ssh_host: 'sshhost' 8 | ssh_user: 'root' 9 | ssh_port: 22 10 | sql_ip: 'iporhost' 11 | ssh_pass: 'password' -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Marzban Dashboard Project 2 | 3 | Этот дашборд создан для визуализации статистик в проекте [Marzban](https://github.com/Gozargah/Marzban), используя MySQL для хранения данных. 4 | 5 | Версия для [SQLite тут](https://github.com/lifeindarkside/marzban_sqlite_streamlit) 6 | ![image](https://github.com/lifeindarkside/marzban_mysql_streamlit/assets/66727826/9fcc5f15-90ce-4292-894d-eaa01afd14da) 7 | ![image](https://github.com/lifeindarkside/marzban_mysql_streamlit/assets/66727826/8fcd7d19-1a5f-408d-8f83-7d5afc5da219) 8 | ![image](https://github.com/lifeindarkside/marzban_mysql_streamlit/assets/66727826/f55a79ec-2889-4897-8500-540a44c09b7b) 9 | 10 | 11 | ## Установка 12 | 13 | ### Шаг 1: Подготовка конфигурационного файла 14 | 15 | Первым делом, вам нужно подготовить файл конфигурации. Скопируйте `config.yaml.example` в новый файл с именем `config.yaml` и заполните все необходимые поля соответствующими значениями вашей установки Marzban и базы данных MySQL. 16 | 17 | Пример: 18 | 19 | ```yaml 20 | credentials: 21 | ssh_host: 'your_ssh_host_here' 22 | ssh_port: your_ssh_port_here 23 | ssh_user: 'your_ssh_username_here' 24 | ssh_pass: 'your_ssh_password_here' 25 | sql_hostname: 'your_sql_hostname_here' 26 | sql_port: your_sql_port_here 27 | sql_username: 'your_sql_username_here' 28 | sql_password: 'your_sql_password_here' 29 | sql_main_database: 'your_sql_main_database_here' 30 | ``` 31 | ### Шаг 2: Установка зависимостей 32 | 33 | Перед запуском проекта убедитесь, что у вас установлен Python версии 3.8 или выше. Затем установите все необходимые зависимости, используя следующую команду в корневой директории проекта: 34 | 35 | ```sh 36 | pip install -r requirements.txt 37 | ``` 38 | 39 | Либо установите и активируйте окружение Conda 40 | ```sh 41 | conda env create --name marzban-streamlit --file=conda_env_streamlit.yml 42 | ``` 43 | ### Шаг 3: Запуск проекта 44 | Если вы создали окружение Conda, то сначала активируйте его 45 | ```sh 46 | conda activate marzban-streamlit 47 | ``` 48 | 49 | После того как вы установили все необходимые зависимости, вы можете запустить проект с помощью следующей команды в корневой директории проекта: 50 | 51 | ```sh 52 | streamlit run main.py 53 | ``` 54 | Автоматически откроется страница браузера с адресом http://localhost:8501/ 55 | 56 | Если необходимо сменить порт: 57 | ```sh 58 | streamlit run main.py --server.port 8503 59 | ``` 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /conda_env_streamlit.yml: -------------------------------------------------------------------------------- 1 | name: marzban-streamlit 2 | channels: 3 | - defaults 4 | dependencies: 5 | - bzip2=1.0.8=he774522_0 6 | - ca-certificates=2023.08.22=haa95532_0 7 | - entrypoints=0.4=py311haa95532_0 8 | - libffi=3.4.4=hd77b12b_0 9 | - openssl=3.0.10=h2bbff1b_2 10 | - pip=23.2.1=py311haa95532_0 11 | - python=3.11.5=he1021f5_0 12 | - setuptools=68.0.0=py311haa95532_0 13 | - sqlite=3.41.2=h2bbff1b_0 14 | - tabulate=0.8.10=py311haa95532_0 15 | - tk=8.6.12=h2bbff1b_0 16 | - vc=14.2=h21ff451_1 17 | - vs2015_runtime=14.27.29016=h5e58377_2 18 | - wheel=0.38.4=py311haa95532_0 19 | - xz=5.4.2=h8cc25b3_0 20 | - zlib=1.2.13=h8cc25b3_0 21 | - pip: 22 | - altair==5.1.1 23 | - attrs==23.1.0 24 | - bcrypt==4.0.1 25 | - beautifulsoup4==4.12.2 26 | - blinker==1.6.2 27 | - cachetools==5.3.1 28 | - certifi==2023.7.22 29 | - cffi==1.15.1 30 | - charset-normalizer==3.2.0 31 | - click==8.1.7 32 | - contourpy==1.1.1 33 | - cryptography==41.0.3 34 | - cycler==0.11.0 35 | - extra-streamlit-components==0.1.60 36 | - faker==19.6.1 37 | - favicon==0.7.0 38 | - fonttools==4.42.1 39 | - gitdb==4.0.10 40 | - gitpython==3.1.36 41 | - htbuilder==0.6.2 42 | - idna==3.4 43 | - importlib-metadata==6.8.0 44 | - jinja2==3.1.2 45 | - jsonschema==4.19.0 46 | - jsonschema-specifications==2023.7.1 47 | - kiwisolver==1.4.5 48 | - lxml==4.9.3 49 | - markdown==3.4.4 50 | - markdown-it-py==3.0.0 51 | - markdownlit==0.0.7 52 | - markupsafe==2.1.3 53 | - matplotlib==3.8.0 54 | - mdurl==0.1.2 55 | - more-itertools==10.1.0 56 | - numpy==1.25.2 57 | - pandas==2.1.0 58 | - paramiko==3.3.1 59 | - pillow==9.5.0 60 | - plotly==5.17.0 61 | - protobuf==3.20.3 62 | - pyarrow==13.0.0 63 | - pycparser==2.21 64 | - pydeck==0.8.0 65 | - pyjwt==2.8.0 66 | - pymdown-extensions==10.3 67 | - pympler==1.0.1 68 | - pymysql==1.1.0 69 | - pynacl==1.5.0 70 | - pyparsing==3.1.1 71 | - pytz==2023.3.post1 72 | - pytz-deprecation-shim==0.1.0.post0 73 | - pyyaml==6.0.1 74 | - referencing==0.30.2 75 | - requests==2.31.0 76 | - rich==13.5.2 77 | - rpds-py==0.10.3 78 | - smmap==5.0.0 79 | - soupsieve==2.5 80 | - sshtunnel==0.4.0 81 | - st-annotated-text==4.0.1 82 | - streamlit==1.26.0 83 | - streamlit-authenticator==0.2.3 84 | - streamlit-camera-input-live==0.2.0 85 | - streamlit-card==0.0.61 86 | - streamlit-embedcode==0.1.2 87 | - streamlit-embeded==0.0.1 88 | - streamlit-extras==0.3.2 89 | - streamlit-faker==0.0.2 90 | - streamlit-image-coordinates==0.1.6 91 | - streamlit-keyup==0.2.0 92 | - streamlit-space==0.1.5 93 | - streamlit-toggle==0.1.3 94 | - streamlit-toggle-switch==1.0.2 95 | - streamlit-vertical-slider==1.0.2 96 | - tenacity==8.2.3 97 | - toml==0.10.2 98 | - toolz==0.12.0 99 | - typing-extensions==4.7.1 100 | - tzdata==2023.3 101 | - tzlocal==4.3.1 102 | - urllib3==2.0.4 103 | - validators==0.22.0 104 | - watchdog==3.0.0 105 | - zipp==3.16.2 106 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import yaml 3 | from sshtunnel import SSHTunnelForwarder 4 | import pymysql 5 | import altair as alt 6 | import streamlit as st 7 | import matplotlib.pyplot as plt 8 | 9 | st.set_page_config( 10 | page_title="MarzbanStat", 11 | page_icon="🧊", 12 | layout="wide", 13 | initial_sidebar_state="collapsed" 14 | ) 15 | 16 | 17 | @st.cache_data(ttl=300, show_spinner="Загрузка данных") 18 | def getdata(ssh_host,ssh_port,ssh_user,ssh_pass,sql_hostname,sql_port,sql_username,sql_password,sql_main_database, query): 19 | with SSHTunnelForwarder( 20 | (ssh_host, ssh_port), 21 | ssh_username=ssh_user, 22 | ssh_password=ssh_pass, 23 | remote_bind_address=(sql_hostname, sql_port)) as tunnel: 24 | conn = pymysql.connect(host=sql_hostname, user=sql_username, 25 | passwd=sql_password, db=sql_main_database, 26 | port=tunnel.local_bind_port) 27 | df = pd.read_sql_query(query, conn) 28 | conn.close() 29 | return df 30 | 31 | 32 | def data_from_marzban(query): 33 | with open('config.yaml') as file: 34 | config = yaml.safe_load(file) 35 | df = getdata(config['credentials']['ssh_host'],config['credentials']['ssh_port'],config['credentials']['ssh_user'], 36 | config['credentials']['ssh_pass'],config['credentials']['sql_hostname'],config['credentials']['sql_port'], 37 | config['credentials']['sql_username'],config['credentials']['sql_password'],config['credentials']['sql_main_database'], query) 38 | df["used_traffic_gb"] = df["used_traffic"] / 1073741824 39 | df["used_traffic_gb"] = df["used_traffic_gb"].round(2) 40 | if "created_at" in df.columns: 41 | df["created_at"] = pd.to_datetime(df["created_at"]) 42 | df["hour"] = df["created_at"].dt.hour 43 | return df 44 | 45 | def last_hour_users(df): 46 | df["created_at"] = pd.to_datetime(df["created_at"]) 47 | max_date = df["created_at"].max() 48 | last_hour_users = df[df["created_at"] == max_date] 49 | return last_hour_users 50 | 51 | def users_by_hours(df): 52 | hourly_counts = df.groupby(["hour", "node"])["username"].nunique() 53 | hourly_counts = hourly_counts.reset_index() 54 | hourly_counts = hourly_counts.rename(columns={"username": "Connections"}) 55 | return hourly_counts 56 | 57 | def traffic_by_hours(df): 58 | hourly_counts = df.groupby(["hour", "node"])["used_traffic_gb"].sum().reset_index() 59 | hourly_counts["used_traffic_gb"] = hourly_counts["used_traffic_gb"].round(1) 60 | hourly_counts = hourly_counts.rename(columns={"used_traffic_gb": "traffic"}) 61 | hourly_counts = hourly_counts.sort_values(['hour', 'node', 'traffic'], ascending=[True, True, False]) 62 | return hourly_counts 63 | 64 | def traffic_by_users(df): 65 | user_traffic_data = df.groupby("username")["used_traffic_gb"].agg( 66 | total_traffic_gb = 'sum', 67 | connections = 'count' 68 | ) 69 | user_traffic_data = user_traffic_data.reset_index() 70 | user_traffic_data = user_traffic_data.sort_values(by=['total_traffic_gb', 'connections'], ascending=[False, False]) 71 | return user_traffic_data 72 | 73 | 74 | 75 | df = data_from_marzban(""" 76 | select ( 77 | `a`.`created_at` + interval 3 hour 78 | ) AS `created_at`, 79 | `a`.`used_traffic` AS `used_traffic`, 80 | ifnull(`n`.`name`, 'Main') AS `node`, 81 | `u`.`username` AS `username` 82 | from ( ( 83 | `node_user_usages` `a` 84 | left join `users` `u` on( (`u`.`id` = `a`.`user_id`)) 85 | ) 86 | left join `nodes` `n` on( (`n`.`id` = `a`.`node_id`)) 87 | ) 88 | where ( 89 | `a`.`created_at` >= concat( (curdate() - interval 1 day), 90 | ' 21:00:00' 91 | ) 92 | ) 93 | order by `a`.`created_at` desc 94 | """) 95 | df_last_hour_users = last_hour_users(df) 96 | df_users_by_hours = users_by_hours(df) 97 | stat_by_users_today = traffic_by_users(df) 98 | stat_by_users_last_hour = traffic_by_users(df_last_hour_users) 99 | traffic_by_users_last_hour = traffic_by_users(df_last_hour_users) 100 | traffic_by_hours_today = traffic_by_hours(df) 101 | df_all_dates = data_from_marzban("""select `users_usage`.`username` AS `username`, 102 | count(`users_usage`.`created_at`) AS `cnt_connections`, 103 | sum(`users_usage`.`used_traffic`) AS `used_traffic`, 104 | min(`users_usage`.`created_at`) AS `first_conn`, 105 | max(`users_usage`.`created_at`) AS `last_conn`, ( 106 | to_days( 107 | max(`users_usage`.`created_at`) 108 | ) - to_days( 109 | min(`users_usage`.`created_at`) 110 | ) 111 | ) AS `lifetime_days` 112 | from (select ( 113 | `a`.`created_at` + interval 3 hour 114 | ) AS `created_at`, 115 | `a`.`used_traffic` AS `used_traffic`, 116 | ifnull(`n`.`name`, 'Main') AS `node`, 117 | `u`.`username` AS `username` 118 | from ( ( 119 | `node_user_usages` `a` 120 | left join `users` `u` on( (`u`.`id` = `a`.`user_id`)) 121 | ) 122 | left join `nodes` `n` on( (`n`.`id` = `a`.`node_id`)) 123 | ) 124 | order by `a`.`created_at` desc) as `users_usage` 125 | group by 126 | `users_usage`.`username` 127 | order by 128 | count(`users_usage`.`created_at`) desc""") 129 | df_ttl_with_nodes = data_from_marzban(""" 130 | select ( 131 | `a`.`created_at` + interval 3 hour 132 | ) AS `created_at`, 133 | `a`.`used_traffic` AS `used_traffic`, 134 | ifnull(`n`.`name`, 'Main') AS `node` 135 | from ( ( 136 | `node_user_usages` `a` 137 | left join `users` `u` on( (`u`.`id` = `a`.`user_id`)) 138 | ) 139 | left join `nodes` `n` on( (`n`.`id` = `a`.`node_id`)) 140 | ) 141 | order by `a`.`created_at` desc 142 | """) 143 | 144 | 145 | 146 | 147 | 148 | 149 | st.header("Сегодня по часам") 150 | col1, col2 = st.columns(2) 151 | with col1: 152 | bars = alt.Chart(df_users_by_hours).mark_bar().encode( 153 | x=alt.X('hour:N', axis=alt.Axis(title='Час')), 154 | y=alt.Y('sum(Connections):Q', stack='zero', axis=alt.Axis(title='Подключений')), 155 | color=alt.Color('node:N', legend=alt.Legend(title='Узлы'), title='Узел') 156 | ) 157 | 158 | text = alt.Chart(df_users_by_hours).mark_text(dx=0, dy=-10, align='center', color='white').encode( 159 | x=alt.X('hour:N', axis=alt.Axis(title='Час')), 160 | y=alt.Y('sum(Connections):Q', stack='zero', axis=alt.Axis(title='Подключений')), 161 | text=alt.Text('sum(Connections):Q') 162 | ) 163 | 164 | mean_line = alt.Chart(df_users_by_hours).transform_aggregate( 165 | mean_connections='mean(Connections)' 166 | ).mark_rule(color='lightblue', strokeDash=[10, 5], opacity=0.5).encode( 167 | y='mean(mean_connections):Q' 168 | ) 169 | 170 | st.altair_chart(bars+text+mean_line, use_container_width=True) 171 | 172 | with col2: 173 | #-----------------------траффик 174 | bars = alt.Chart(traffic_by_hours_today).mark_bar().encode( 175 | x=alt.X('hour:N', axis=alt.Axis(title='Час')), 176 | y=alt.Y('sum(traffic):Q', stack='zero', axis=alt.Axis(title='GB')), 177 | color=alt.Color('node:N', legend=alt.Legend(title='Узлы'), title='Узел') 178 | ) 179 | 180 | text = alt.Chart(traffic_by_hours_today).mark_text(dx=0, dy=-10, align='center', color='white').encode( 181 | x=alt.X('hour:N', axis=alt.Axis(title='Час')), 182 | y=alt.Y('sum(traffic):Q', stack='zero', axis=alt.Axis(title='GB')), 183 | text=alt.Text('sum(traffic):Q') 184 | ) 185 | 186 | 187 | mean_line = alt.Chart(traffic_by_hours_today).transform_aggregate( 188 | mean_traffic='mean(traffic)' 189 | ).mark_rule(color='lightblue', strokeDash=[10, 5], opacity=0.5).encode( 190 | y='mean(mean_traffic):Q' 191 | ) 192 | 193 | st.altair_chart(bars+text+mean_line, use_container_width=True) 194 | 195 | 196 | # Переименование колонок 197 | user_traffic_data = stat_by_users_today.rename(columns={"username": "Имя пользователя", "total_traffic_gb": "Трафик (ГБ)", "connections": "Подключения"}) 198 | stat_by_users_last_hour = stat_by_users_last_hour.rename(columns={"username": "Имя пользователя", "total_traffic_gb": "Трафик (ГБ)"}) 199 | 200 | # Получение топ 5 пользователей по подключениям и трафику 201 | top5_connections = user_traffic_data.nlargest(5, 'Подключения')[['Имя пользователя', 'Подключения']].reset_index(drop=True) 202 | top5_traffic = user_traffic_data.nlargest(5, 'Трафик (ГБ)')[['Имя пользователя', 'Трафик (ГБ)']].reset_index(drop=True) 203 | # Получение топ 5 пользователей по трафику за последний час 204 | top5_last_hour_traffic = stat_by_users_last_hour.nlargest(5, 'Трафик (ГБ)')[['Имя пользователя', 'Трафик (ГБ)']].reset_index(drop=True) 205 | 206 | st.subheader("Топ 5 пользователей") 207 | 208 | col1, col2, col3 = st.columns(3) 209 | 210 | 211 | with col1: 212 | st.write("По подключениям за день") 213 | st.dataframe(top5_connections, use_container_width=True) 214 | 215 | 216 | with col2: 217 | st.write("По траффику за день") 218 | st.dataframe(top5_traffic, use_container_width=True) 219 | 220 | with col3: 221 | st.write("По трафику за последний час") 222 | st.dataframe(top5_last_hour_traffic, use_container_width=True) 223 | 224 | st.header("Статистика по узлам") 225 | 226 | total_data = df_ttl_with_nodes.groupby("node")['used_traffic_gb'].sum().round(2).reset_index() 227 | total_data['percentage'] = ((total_data['used_traffic_gb'] / total_data['used_traffic_gb'].sum()) * 100).round(1) 228 | today_data = df.groupby("node")['used_traffic_gb'].sum().round(2).reset_index() 229 | today_data['percentage'] = ((today_data['used_traffic_gb'] / today_data['used_traffic_gb'].sum()) * 100).round(1) 230 | last_hour_data = df_last_hour_users.groupby("node")['used_traffic_gb'].sum().round(2).reset_index() 231 | last_hour_data['percentage'] = ((last_hour_data['used_traffic_gb'] / last_hour_data['used_traffic_gb'].sum()) * 100).round(1) 232 | 233 | col1, col2, col3 = st.columns(3) 234 | with col1: 235 | chart = alt.Chart(total_data).mark_arc(innerRadius=50, outerRadius=100).encode( 236 | theta='used_traffic_gb', 237 | color='node', 238 | tooltip=['node', 'used_traffic_gb', 'percentage'] 239 | ).properties(title='За все время') 240 | st.altair_chart(chart, use_container_width=True) 241 | 242 | with col2: 243 | chart = alt.Chart(today_data).mark_arc(innerRadius=50, outerRadius=100).encode( 244 | theta='used_traffic_gb', 245 | color='node', 246 | tooltip=['node', 'used_traffic_gb', 'percentage'] 247 | ).properties(title='За сегодня') 248 | st.altair_chart(chart, use_container_width=True) 249 | 250 | with col3: 251 | chart = alt.Chart(last_hour_data).mark_arc(innerRadius=50, outerRadius=100).encode( 252 | theta='used_traffic_gb', 253 | color='node', 254 | tooltip=['node', 'used_traffic_gb', 'percentage'] 255 | ).properties(title='За последний час') 256 | st.altair_chart(chart, use_container_width=True) 257 | 258 | 259 | st.header("Общая статистика") 260 | # Переименование колонок 261 | df_all_dates = df_all_dates.rename(columns={ 262 | "username": "Имя пользователя", 263 | "cnt_connections": "Количество подключений", 264 | "lifetime_days": "Время жизни (дни)", 265 | "used_traffic_gb": "Трафик (ГБ)" 266 | }) 267 | 268 | 269 | # Генерация топов и антитопов 270 | top_traffic = df_all_dates.nlargest(10, 'Трафик (ГБ)')[['Имя пользователя', 'Трафик (ГБ)']] 271 | top_connections = df_all_dates.nlargest(10, 'Количество подключений')[['Имя пользователя', 'Количество подключений']] 272 | top_lifetime = df_all_dates.nlargest(10, 'Время жизни (дни)')[['Имя пользователя', 'Время жизни (дни)']] 273 | anti_top_traffic = df_all_dates.nsmallest(10, 'Трафик (ГБ)')[['Имя пользователя', 'Трафик (ГБ)']] 274 | anti_top_connections = df_all_dates.nsmallest(10, 'Количество подключений')[['Имя пользователя', 'Количество подключений']] 275 | anti_top_lifetime = df_all_dates.nsmallest(10, 'Время жизни (дни)')[['Имя пользователя', 'Время жизни (дни)']] 276 | 277 | 278 | # Функция для создания гистограммы 279 | def create_bar_chart(data, x, y): 280 | max_y = data[y].max() 281 | max_y += max_y * 0.1 282 | chart = alt.Chart(data).mark_bar().encode( 283 | x=alt.X(x, title=x), 284 | y=alt.Y(y, title=y, scale=alt.Scale(domain=(0, max_y))) 285 | ) 286 | text = alt.Chart(data).mark_text(dx=0, dy=-10, align='center', color='white').encode( 287 | x=alt.X(x, title=x), 288 | y=alt.Y(y, title=y), 289 | text=alt.Text(y) 290 | ) 291 | return chart+text 292 | 293 | 294 | # Гистограммы для топ 10 пользователей 295 | col3, col4, col5 = st.columns([1, 1, 1]) 296 | 297 | with col3: 298 | st.write("Топ 10 по траффику") 299 | st.altair_chart(create_bar_chart(top_traffic, 'Имя пользователя', 'Трафик (ГБ)'), use_container_width=True) 300 | 301 | with col4: 302 | st.write("Топ 10 по подключениям") 303 | st.altair_chart(create_bar_chart(top_connections, 'Имя пользователя', 'Количество подключений'), use_container_width=True) 304 | 305 | with col5: 306 | st.write("Топ 10 по времени жизни") 307 | st.altair_chart(create_bar_chart(top_lifetime, 'Имя пользователя', 'Время жизни (дни)'), use_container_width=True) 308 | # Колонки для топов и антитопов 309 | col1, col2 = st.columns(2) 310 | 311 | # Топ 5 пользователей 312 | with col1: 313 | st.subheader("Топ 10 Пользователей") 314 | st.write("По траффику") 315 | st.dataframe(top_traffic, use_container_width=True) 316 | st.write("По подключениям") 317 | st.dataframe(top_connections, use_container_width=True) 318 | st.write("По времени жизни") 319 | st.dataframe(top_lifetime, use_container_width=True) 320 | 321 | # Антитоп 5 пользователей 322 | with col2: 323 | st.subheader("Антитоп 10 Пользователей") 324 | st.write("По траффику") 325 | st.dataframe(anti_top_traffic, use_container_width=True) 326 | st.write("По подключениям") 327 | st.dataframe(anti_top_connections, use_container_width=True) 328 | st.write("По времени жизни") 329 | st.dataframe(anti_top_lifetime, use_container_width=True) 330 | 331 | 332 | 333 | 334 | with st.expander("Исходные данные", expanded=False): 335 | st.dataframe(df, use_container_width=True) 336 | st.dataframe(df_last_hour_users, use_container_width=True) 337 | st.dataframe(df_users_by_hours, use_container_width=True) 338 | st.dataframe(stat_by_users_today, use_container_width=True) 339 | st.dataframe(traffic_by_users_last_hour, use_container_width=True) 340 | st.dataframe(df_all_dates, use_container_width=True) 341 | 342 | --------------------------------------------------------------------------------