[4.1-5.4] FAM-UI Streamlit - 事件列表/筛选/明细/AI对话/对话历史/成员命名/统计图表 + 配置
This commit is contained in:
11
fam-ui/config/config.yaml.example
Normal file
11
fam-ui/config/config.yaml.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# FAM-UI 配置文件 (NAS 端)
|
||||||
|
# 复制此文件为 config.yaml 并修改实际值
|
||||||
|
|
||||||
|
core_url: "http://127.0.0.1:8000" # FAM-Core 地址
|
||||||
|
|
||||||
|
database:
|
||||||
|
host: "127.0.0.1"
|
||||||
|
port: 3306
|
||||||
|
user: "root"
|
||||||
|
password: ""
|
||||||
|
database: "sentinel_home_ai"
|
||||||
5
fam-ui/requirements.txt
Normal file
5
fam-ui/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
streamlit>=1.30.0
|
||||||
|
mysql-connector-python>=8.3.0
|
||||||
|
pandas>=2.1.0
|
||||||
|
requests>=2.31.0
|
||||||
|
PyYAML>=6.0
|
||||||
459
fam-ui/src/app.py
Normal file
459
fam-ui/src/app.py
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
"""
|
||||||
|
FAM-UI - 家庭多模态智能监控系统前端
|
||||||
|
|
||||||
|
Streamlit 直读 MariaDB,展示:
|
||||||
|
- 事件列表 + compute_provider 占比 + AI 对话页 + 对话历史 + 成员命名页
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
import streamlit as st
|
||||||
|
import mysql.connector
|
||||||
|
import pandas as pd
|
||||||
|
import json
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
# 添加共享模块路径
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
from config_loader import load_config
|
||||||
|
|
||||||
|
# 加载配置
|
||||||
|
_cfg = load_config()
|
||||||
|
_core_url = _cfg.get('core_url', 'http://127.0.0.1:8000')
|
||||||
|
_db_cfg = _cfg.get('database', {})
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_conn():
|
||||||
|
"""获取数据库连接"""
|
||||||
|
return mysql.connector.connect(
|
||||||
|
host=_db_cfg.get('host', '127.0.0.1'),
|
||||||
|
port=_db_cfg.get('port', 3306),
|
||||||
|
user=_db_cfg.get('user', 'root'),
|
||||||
|
password=_db_cfg.get('password', ''),
|
||||||
|
database=_db_cfg.get('database', 'sentinel_home_ai'),
|
||||||
|
charset='utf8mb4'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_datetime(obj):
|
||||||
|
"""序列化 datetime"""
|
||||||
|
if hasattr(obj, 'isoformat'):
|
||||||
|
return obj.isoformat()
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 页面配置
|
||||||
|
# ============================================================
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="家庭智能监控",
|
||||||
|
page_icon="🏠",
|
||||||
|
layout="wide",
|
||||||
|
initial_sidebar_state="expanded"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 侧边栏导航
|
||||||
|
st.sidebar.title("🏠 家庭智能监控")
|
||||||
|
page = st.sidebar.radio("功能页面", [
|
||||||
|
"📊 事件列表",
|
||||||
|
"💬 AI 对话",
|
||||||
|
"📝 对话历史",
|
||||||
|
"👤 成员命名",
|
||||||
|
"📈 统计图表"
|
||||||
|
])
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 事件列表页
|
||||||
|
# ============================================================
|
||||||
|
if page == "📊 事件列表":
|
||||||
|
st.title("📊 监控事件列表")
|
||||||
|
|
||||||
|
# 日期筛选
|
||||||
|
col1, col2 = st.columns([1, 3])
|
||||||
|
with col1:
|
||||||
|
date_filter = st.date_input("日期筛选", value=None)
|
||||||
|
|
||||||
|
# 分页
|
||||||
|
page_size = 20
|
||||||
|
if 'event_page' not in st.session_state:
|
||||||
|
st.session_state.event_page = 0
|
||||||
|
|
||||||
|
offset = st.session_state.event_page * page_size
|
||||||
|
|
||||||
|
conn = get_db_conn()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
|
||||||
|
# 查询条件
|
||||||
|
date_str = date_filter.isoformat() if date_filter else None
|
||||||
|
|
||||||
|
if date_str:
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time,
|
||||||
|
me.camera_name, me.global_summary, me.compute_provider, me.created_at,
|
||||||
|
(SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count
|
||||||
|
FROM monitor_events me
|
||||||
|
WHERE DATE(me.event_start_time) = %s
|
||||||
|
ORDER BY me.event_start_time DESC
|
||||||
|
LIMIT %s OFFSET %s""",
|
||||||
|
(date_str, page_size, offset)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT me.event_id, me.task_id, me.event_start_time, me.event_end_time,
|
||||||
|
me.camera_name, me.global_summary, me.compute_provider, me.created_at,
|
||||||
|
(SELECT COUNT(*) FROM event_details ed WHERE ed.event_id = me.event_id) AS detail_count
|
||||||
|
FROM monitor_events me
|
||||||
|
ORDER BY me.event_start_time DESC
|
||||||
|
LIMIT %s OFFSET %s""",
|
||||||
|
(page_size, offset)
|
||||||
|
)
|
||||||
|
|
||||||
|
events = cursor.fetchall()
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
st.info("暂无事件数据")
|
||||||
|
else:
|
||||||
|
for ev in events:
|
||||||
|
providers = ev.get('compute_provider', '[]')
|
||||||
|
if isinstance(providers, str):
|
||||||
|
providers = json.loads(providers)
|
||||||
|
|
||||||
|
with st.container():
|
||||||
|
col1, col2, col3 = st.columns([2, 1, 1])
|
||||||
|
with col1:
|
||||||
|
start_time = serialize_datetime(ev['event_start_time'])
|
||||||
|
end_time = serialize_datetime(ev['event_end_time'])
|
||||||
|
st.markdown(f"**{ev['camera_name'] or '未知摄像头'}** | {start_time} ~ {end_time}")
|
||||||
|
st.markdown(f"_{ev['global_summary']}_")
|
||||||
|
with col2:
|
||||||
|
st.markdown(f"明细: {ev['detail_count']} 条")
|
||||||
|
st.markdown(f"模型: {', '.join(providers)}")
|
||||||
|
with col3:
|
||||||
|
if st.button("查看明细", key=f"detail_{ev['event_id']}"):
|
||||||
|
st.session_state.selected_event_id = ev['event_id']
|
||||||
|
|
||||||
|
# 展开明细
|
||||||
|
if st.session_state.get('selected_event_id') == ev['event_id']:
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT frame_index, frame_timestamp, camera_name, person,
|
||||||
|
action, clothing, is_attention_event, source_providers
|
||||||
|
FROM event_details
|
||||||
|
WHERE event_id = %s
|
||||||
|
ORDER BY frame_index ASC""",
|
||||||
|
(ev['event_id'],)
|
||||||
|
)
|
||||||
|
details = cursor.fetchall()
|
||||||
|
df = pd.DataFrame([{
|
||||||
|
'帧号': d['frame_index'],
|
||||||
|
'时间': serialize_datetime(d['frame_timestamp']),
|
||||||
|
'位置': d['camera_name'],
|
||||||
|
'人物': d['person'],
|
||||||
|
'动作': d['action'],
|
||||||
|
'衣着': d['clothing'],
|
||||||
|
'关注': '⚠️' if d['is_attention_event'] else '',
|
||||||
|
'模型来源': json.dumps(d['source_providers']) if isinstance(d['source_providers'], str) else json.dumps(d['source_providers'])
|
||||||
|
} for d in details])
|
||||||
|
st.dataframe(df, use_container_width=True, hide_index=True)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# 分页控制
|
||||||
|
col1, col2, col3 = st.columns([1, 1, 1])
|
||||||
|
with col1:
|
||||||
|
if st.button("上一页") and st.session_state.event_page > 0:
|
||||||
|
st.session_state.event_page -= 1
|
||||||
|
st.rerun()
|
||||||
|
with col2:
|
||||||
|
st.markdown(f"第 {st.session_state.event_page + 1} 页")
|
||||||
|
with col3:
|
||||||
|
if st.button("下一页") and len(events) == page_size:
|
||||||
|
st.session_state.event_page += 1
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# AI 对话页
|
||||||
|
# ============================================================
|
||||||
|
elif page == "💬 AI 对话":
|
||||||
|
st.title("💬 AI 对话")
|
||||||
|
|
||||||
|
# 快捷人物按钮
|
||||||
|
conn = get_db_conn()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute("SELECT DISTINCT real_name FROM family_members WHERE real_name IS NOT NULL AND is_active = TRUE")
|
||||||
|
named = [row['real_name'] for row in cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
col1, col2 = st.columns(2)
|
||||||
|
with col1:
|
||||||
|
queried_person = st.text_input("查询人物", value=named[0] if named else "")
|
||||||
|
with col2:
|
||||||
|
queried_date = st.date_input("查询日期", value=date.today())
|
||||||
|
|
||||||
|
# 快捷提问
|
||||||
|
if named:
|
||||||
|
quick_person = st.selectbox("快捷选择成员", [""] + named)
|
||||||
|
if quick_person:
|
||||||
|
queried_person = quick_person
|
||||||
|
|
||||||
|
quick_questions = [
|
||||||
|
f"{queried_person}今天干嘛了?",
|
||||||
|
f"{queried_person}有没有发生什么需要注意的事情?",
|
||||||
|
f"今天{queried_person}的活动时间线是什么?",
|
||||||
|
]
|
||||||
|
selected_quick = st.selectbox("快捷提问", ["自定义"] + quick_questions)
|
||||||
|
|
||||||
|
user_question = st.text_area("你的问题", value=selected_quick if selected_quick != "自定义" else "")
|
||||||
|
|
||||||
|
if st.button("提问", type="primary"):
|
||||||
|
if not user_question.strip():
|
||||||
|
st.warning("请输入问题")
|
||||||
|
elif not queried_person.strip():
|
||||||
|
st.warning("请输入查询人物")
|
||||||
|
else:
|
||||||
|
with st.spinner("AI 正在思考..."):
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{_core_url}/api/chat/ask",
|
||||||
|
json={
|
||||||
|
"question": user_question,
|
||||||
|
"queried_person": queried_person,
|
||||||
|
"queried_date": queried_date.isoformat()
|
||||||
|
},
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
st.success("回答:")
|
||||||
|
st.markdown(data['answer'])
|
||||||
|
st.caption(f"上下文: {data.get('context_summary', '')}")
|
||||||
|
else:
|
||||||
|
st.error(f"请求失败: {resp.status_code} {resp.text}")
|
||||||
|
except requests.ConnectionError:
|
||||||
|
st.error(f"无法连接 FAM-Core ({_core_url})")
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"异常: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 对话历史页
|
||||||
|
# ============================================================
|
||||||
|
elif page == "📝 对话历史":
|
||||||
|
st.title("📝 对话历史")
|
||||||
|
|
||||||
|
if 'chat_page' not in st.session_state:
|
||||||
|
st.session_state.chat_page = 0
|
||||||
|
|
||||||
|
page_size = 20
|
||||||
|
offset = st.session_state.chat_page * page_size
|
||||||
|
|
||||||
|
conn = get_db_conn()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT chat_id, user_question, ai_answer, context_summary,
|
||||||
|
queried_date, queried_person, created_at
|
||||||
|
FROM chat_history
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT %s OFFSET %s""",
|
||||||
|
(page_size, offset)
|
||||||
|
)
|
||||||
|
history = cursor.fetchall()
|
||||||
|
|
||||||
|
if not history:
|
||||||
|
st.info("暂无对话记录")
|
||||||
|
else:
|
||||||
|
for h in history:
|
||||||
|
with st.container():
|
||||||
|
col1, col2 = st.columns([1, 4])
|
||||||
|
with col1:
|
||||||
|
created = serialize_datetime(h['created_at'])
|
||||||
|
st.markdown(f"**{h['queried_person']}**")
|
||||||
|
st.caption(created)
|
||||||
|
if h['queried_date']:
|
||||||
|
st.caption(f"日期: {serialize_datetime(h['queried_date'])}")
|
||||||
|
with col2:
|
||||||
|
st.markdown(f"**Q:** {h['user_question']}")
|
||||||
|
st.markdown(f"**A:** {h['ai_answer']}")
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# 分页
|
||||||
|
col1, col2, col3 = st.columns([1, 1, 1])
|
||||||
|
with col1:
|
||||||
|
if st.button("上一页") and st.session_state.chat_page > 0:
|
||||||
|
st.session_state.chat_page -= 1
|
||||||
|
st.rerun()
|
||||||
|
with col2:
|
||||||
|
st.markdown(f"第 {st.session_state.chat_page + 1} 页")
|
||||||
|
with col3:
|
||||||
|
if st.button("下一页") and len(history) == page_size:
|
||||||
|
st.session_state.chat_page += 1
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 成员命名页
|
||||||
|
# ============================================================
|
||||||
|
elif page == "👤 成员命名":
|
||||||
|
st.title("👤 家庭成员命名")
|
||||||
|
|
||||||
|
# 未命名成员
|
||||||
|
st.subheader("未命名人物")
|
||||||
|
conn = get_db_conn()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT fm.abstract_label, fm.feature_description, fm.first_seen_at,
|
||||||
|
(SELECT COUNT(*) FROM event_details ed WHERE ed.person = fm.abstract_label) AS event_count
|
||||||
|
FROM family_members fm
|
||||||
|
WHERE fm.real_name IS NULL AND fm.is_active = TRUE
|
||||||
|
ORDER BY fm.first_seen_at ASC"""
|
||||||
|
)
|
||||||
|
unnamed = cursor.fetchall()
|
||||||
|
|
||||||
|
if not unnamed:
|
||||||
|
st.info("所有人物已命名,或暂未发现新人物")
|
||||||
|
else:
|
||||||
|
for m in unnamed:
|
||||||
|
col1, col2, col3 = st.columns([2, 2, 1])
|
||||||
|
with col1:
|
||||||
|
st.markdown(f"**{m['abstract_label']}**")
|
||||||
|
st.text(m['feature_description'] or '无特征描述')
|
||||||
|
first_seen = serialize_datetime(m['first_seen_at'])
|
||||||
|
st.caption(f"首次出现: {first_seen} | 事件数: {m['event_count']}")
|
||||||
|
with col2:
|
||||||
|
real_name = st.text_input(
|
||||||
|
"输入名字", key=f"name_{m['abstract_label']}",
|
||||||
|
placeholder=f"为{m['abstract_label']}命名"
|
||||||
|
)
|
||||||
|
with col3:
|
||||||
|
if st.button("命名", key=f"btn_{m['abstract_label']}"):
|
||||||
|
if real_name.strip():
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{_core_url}/api/member/name",
|
||||||
|
json={
|
||||||
|
"abstract_label": m['abstract_label'],
|
||||||
|
"real_name": real_name.strip(),
|
||||||
|
"named_by": "UI管理员"
|
||||||
|
},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
result = resp.json()
|
||||||
|
st.success(
|
||||||
|
f"命名成功!{m['abstract_label']} -> {real_name},"
|
||||||
|
f"更新明细 {result.get('updated_event_details_count', 0)} 条"
|
||||||
|
)
|
||||||
|
st.rerun()
|
||||||
|
else:
|
||||||
|
st.error(f"命名失败: {resp.status_code} {resp.text}")
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"异常: {e}")
|
||||||
|
else:
|
||||||
|
st.warning("请输入名字")
|
||||||
|
st.divider()
|
||||||
|
|
||||||
|
# 已命名成员
|
||||||
|
st.subheader("已命名成员")
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT abstract_label, real_name, feature_description, first_seen_at, named_at, named_by
|
||||||
|
FROM family_members
|
||||||
|
WHERE real_name IS NOT NULL AND is_active = TRUE
|
||||||
|
ORDER BY named_at DESC"""
|
||||||
|
)
|
||||||
|
named = cursor.fetchall()
|
||||||
|
|
||||||
|
if not named:
|
||||||
|
st.info("暂无已命名成员")
|
||||||
|
else:
|
||||||
|
df = pd.DataFrame([{
|
||||||
|
'抽象标识': m['abstract_label'],
|
||||||
|
'真实名字': m['real_name'],
|
||||||
|
'特征描述': m['feature_description'],
|
||||||
|
'首次出现': serialize_datetime(m['first_seen_at']),
|
||||||
|
'命名时间': serialize_datetime(m['named_at']),
|
||||||
|
'命名人': m['named_by']
|
||||||
|
} for m in named])
|
||||||
|
st.dataframe(df, use_container_width=True, hide_index=True)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 统计图表页
|
||||||
|
# ============================================================
|
||||||
|
elif page == "📈 统计图表":
|
||||||
|
st.title("📈 统计图表")
|
||||||
|
|
||||||
|
conn = get_db_conn()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
|
||||||
|
# compute_provider 分布
|
||||||
|
st.subheader("模型来源分布")
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT JSON_UNQUOTE(JSON_EXTRACT(item, '$')) AS provider, COUNT(*) AS count
|
||||||
|
FROM monitor_events,
|
||||||
|
JSON_TABLE(compute_provider, '$[*]'
|
||||||
|
COLUMNS(item VARCHAR(50) PATH '$')
|
||||||
|
) AS jt
|
||||||
|
GROUP BY provider ORDER BY count DESC
|
||||||
|
""")
|
||||||
|
stats = cursor.fetchall()
|
||||||
|
except Exception:
|
||||||
|
cursor.execute("SELECT compute_provider, COUNT(*) AS count FROM monitor_events GROUP BY compute_provider")
|
||||||
|
stats = cursor.fetchall()
|
||||||
|
|
||||||
|
if stats:
|
||||||
|
df_stats = pd.DataFrame(stats)
|
||||||
|
st.bar_chart(df_stats.set_index('provider')['count'])
|
||||||
|
st.dataframe(df_stats, use_container_width=True, hide_index=True)
|
||||||
|
else:
|
||||||
|
st.info("暂无统计数据")
|
||||||
|
|
||||||
|
# 关注事件统计
|
||||||
|
st.subheader("关注事件统计")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT DATE(frame_timestamp) AS date, person, COUNT(*) AS count
|
||||||
|
FROM event_details
|
||||||
|
WHERE is_attention_event = TRUE
|
||||||
|
GROUP BY DATE(frame_timestamp), person
|
||||||
|
ORDER BY date DESC
|
||||||
|
""")
|
||||||
|
attention = cursor.fetchall()
|
||||||
|
if attention:
|
||||||
|
df_att = pd.DataFrame(attention)
|
||||||
|
st.dataframe(df_att, use_container_width=True, hide_index=True)
|
||||||
|
else:
|
||||||
|
st.info("暂无关注事件")
|
||||||
|
|
||||||
|
# 任务统计
|
||||||
|
st.subheader("任务状态统计")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT status, COUNT(*) AS count
|
||||||
|
FROM process_tasks
|
||||||
|
GROUP BY status
|
||||||
|
""")
|
||||||
|
task_stats = cursor.fetchall()
|
||||||
|
if task_stats:
|
||||||
|
df_tasks = pd.DataFrame(task_stats)
|
||||||
|
st.bar_chart(df_tasks.set_index('status')['count'])
|
||||||
|
st.dataframe(df_tasks, use_container_width=True, hide_index=True)
|
||||||
|
else:
|
||||||
|
st.info("暂无任务数据")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
29
fam-ui/src/config_loader.py
Normal file
29
fam-ui/src/config_loader.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
配置加载器 (FAM-UI 复用)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_env_vars(value):
|
||||||
|
if isinstance(value, str):
|
||||||
|
def replace_env(match):
|
||||||
|
return os.environ.get(match.group(1), match.group(0))
|
||||||
|
return re.sub(r'\$\{(\w+)\}', replace_env, value)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
return {k: _resolve_env_vars(v) for k, v in value.items()}
|
||||||
|
elif isinstance(value, list):
|
||||||
|
return [_resolve_env_vars(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path=None):
|
||||||
|
if config_path is None:
|
||||||
|
config_path = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
'config', 'config.yaml'
|
||||||
|
)
|
||||||
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||||||
|
raw = yaml.safe_load(f)
|
||||||
|
return _resolve_env_vars(raw)
|
||||||
Reference in New Issue
Block a user