feat: reorder card layout + svc_cred_hint field for credentials note

This commit is contained in:
2026-04-28 12:20:37 +00:00
parent b06620a793
commit a64d49a8c1
4 changed files with 36 additions and 10 deletions
+4 -1
View File
@@ -199,6 +199,7 @@ class Service(Base):
comment: Mapped[str] = mapped_column(Text, default="") comment: Mapped[str] = mapped_column(Text, default="")
svc_login: Mapped[str] = mapped_column(String(256), default="") svc_login: Mapped[str] = mapped_column(String(256), default="")
svc_password: Mapped[str] = mapped_column(String(256), default="") svc_password: Mapped[str] = mapped_column(String(256), default="")
svc_cred_hint: Mapped[str] = mapped_column(Text, default="")
icon_path: Mapped[str] = mapped_column(Text, default="") icon_path: Mapped[str] = mapped_column(Text, default="")
active: Mapped[bool] = mapped_column(Boolean, default=True) active: Mapped[bool] = mapped_column(Boolean, default=True)
warm_pool_size: Mapped[int] = mapped_column(Integer, default=0) warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
@@ -1259,6 +1260,7 @@ def ensure_schema_compatibility() -> None:
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS comment TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS comment TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_login VARCHAR(256) NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_login VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_password VARCHAR(256) NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_password VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_cred_hint TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''"))
conn.execute( conn.execute(
text( text(
@@ -2683,6 +2685,7 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad
comment=payload.get("comment", ""), comment=payload.get("comment", ""),
svc_login=payload.get("svc_login", ""), svc_login=payload.get("svc_login", ""),
svc_password=payload.get("svc_password", ""), svc_password=payload.get("svc_password", ""),
svc_cred_hint=payload.get("svc_cred_hint", ""),
active=payload.get("active", True), active=payload.get("active", True),
warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))), warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))),
) )
@@ -2747,7 +2750,7 @@ def edit_service(service_id: int, payload: dict, request: Request, _: User = Dep
service = db.get(Service, service_id) service = db.get(Service, service_id)
if not service: if not service:
raise HTTPException(status_code=404, detail="Service not found") raise HTTPException(status_code=404, detail="Service not found")
for key in ["name", "slug", "target", "active", "comment", "svc_login", "svc_password"]: for key in ["name", "slug", "target", "active", "comment", "svc_login", "svc_password", "svc_cred_hint"]:
if key in payload: if key in payload:
setattr(service, key, payload[key]) setattr(service, key, payload[key])
if "type" in payload: if "type" in payload:
+7
View File
@@ -890,3 +890,10 @@ button {
.svc-credentials + .tile-comment { margin-top: 0.5rem; } .svc-credentials + .tile-comment { margin-top: 0.5rem; }
.svc-cred-hint {
margin: 0.35rem 0 0;
font-size: 0.78rem;
color: #4a7090;
line-height: 1.35;
}
+20 -6
View File
@@ -123,7 +123,7 @@
<input class="list-search" id="web_search" placeholder="Поиск WEB сервиса..." oninput="filterList('web_search', '#web_list .web-item')" /> <input class="list-search" id="web_search" placeholder="Поиск WEB сервиса..." oninput="filterList('web_search', '#web_list .web-item')" />
<div class="list-box" id="web_list"> <div class="list-box" id="web_list">
{% for s in web_services %} {% for s in web_services %}
<button class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectWebService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}})'> <button class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectWebService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div> <div>
<div>{{s.name}}</div> <div>{{s.name}}</div>
@@ -168,6 +168,11 @@
<input id="w_svc_password" placeholder="Пароль для входа в сервис" /> <input id="w_svc_password" placeholder="Пароль для входа в сервис" />
</label> </label>
<label class="field-col">
<span>Подсказка к логину/паролю (необязательно)</span>
<input id="w_svc_cred_hint" placeholder="Например: учётная запись гостя, сбрасывается раз в месяц" />
</label>
<label class="field-col"> <label class="field-col">
<span>Статус</span> <span>Статус</span>
<select id="w_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="w_active"><option value="true">active</option><option value="false">inactive</option></select>
@@ -243,6 +248,7 @@
<textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea> <textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea>
<input id="new_w_svc_login" placeholder="Логин сервиса (необязательно)" /> <input id="new_w_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_w_svc_password" placeholder="Пароль сервиса (необязательно)" /> <input id="new_w_svc_password" placeholder="Пароль сервиса (необязательно)" />
<input id="new_w_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
</label> </label>
<label class="field-col"> <label class="field-col">
@@ -269,7 +275,7 @@
<input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" /> <input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" />
<div class="list-box" id="rdp_list"> <div class="list-box" id="rdp_list">
{% for s in rdp_services %} {% for s in rdp_services %}
<button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}})'> <button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div> <div>
<div>{{s.name}}</div> <div>{{s.name}}</div>
@@ -302,6 +308,7 @@
<textarea id="r_comment" placeholder="Описание для пользователя"></textarea> <textarea id="r_comment" placeholder="Описание для пользователя"></textarea>
<input id="r_svc_login" placeholder="Логин сервиса (показывается на карточке)" /> <input id="r_svc_login" placeholder="Логин сервиса (показывается на карточке)" />
<input id="r_svc_password" placeholder="Пароль сервиса (показывается на карточке)" /> <input id="r_svc_password" placeholder="Пароль сервиса (показывается на карточке)" />
<input id="r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" /> <input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" />
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div> </div>
@@ -387,6 +394,7 @@
<textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea> <textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea>
<input id="new_r_svc_login" placeholder="Логин сервиса (необязательно)" /> <input id="new_r_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_r_svc_password" placeholder="Пароль сервиса (необязательно)" /> <input id="new_r_svc_password" placeholder="Пароль сервиса (необязательно)" />
<input id="new_r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" /> <input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" />
<select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div> </div>
@@ -725,7 +733,7 @@
location.reload(); location.reload();
} }
function selectWebService(id, name, slug, target, comment, iconPath, active, svcLogin, svcPassword) { function selectWebService(id, name, slug, target, comment, iconPath, active, svcLogin, svcPassword, svcCredHint) {
document.getElementById('w_id').value = id; document.getElementById('w_id').value = id;
document.getElementById('w_name').value = name; document.getElementById('w_name').value = name;
document.getElementById('w_slug').value = slug; document.getElementById('w_slug').value = slug;
@@ -733,6 +741,7 @@
document.getElementById('w_comment').value = comment || ''; document.getElementById('w_comment').value = comment || '';
document.getElementById('w_svc_login').value = svcLogin || ''; document.getElementById('w_svc_login').value = svcLogin || '';
document.getElementById('w_svc_password').value = svcPassword || ''; document.getElementById('w_svc_password').value = svcPassword || '';
document.getElementById('w_svc_cred_hint').value = svcCredHint || '';
document.getElementById('w_active').value = String(active); document.getElementById('w_active').value = String(active);
document.getElementById('w_icon_preview').src = iconPath || placeholderIcon; document.getElementById('w_icon_preview').src = iconPath || placeholderIcon;
setCategoryChecks('.w_cat', serviceCategoryMap[id] || []); setCategoryChecks('.w_cat', serviceCategoryMap[id] || []);
@@ -757,6 +766,7 @@
comment: document.getElementById('new_w_comment').value, comment: document.getElementById('new_w_comment').value,
svc_login: document.getElementById('new_w_svc_login').value, svc_login: document.getElementById('new_w_svc_login').value,
svc_password: document.getElementById('new_w_svc_password').value, svc_password: document.getElementById('new_w_svc_password').value,
svc_cred_hint: document.getElementById('new_w_svc_cred_hint').value,
category_ids: checkedCategoryIds('.new_w_cat'), category_ids: checkedCategoryIds('.new_w_cat'),
active: document.getElementById('new_w_active').value === 'true', active: document.getElementById('new_w_active').value === 'true',
}); });
@@ -775,6 +785,7 @@
comment: document.getElementById('w_comment').value, comment: document.getElementById('w_comment').value,
svc_login: document.getElementById('w_svc_login').value, svc_login: document.getElementById('w_svc_login').value,
svc_password: document.getElementById('w_svc_password').value, svc_password: document.getElementById('w_svc_password').value,
svc_cred_hint: document.getElementById('w_svc_cred_hint').value,
category_ids: checkedCategoryIds('.w_cat'), category_ids: checkedCategoryIds('.w_cat'),
active: document.getElementById('w_active').value === 'true', active: document.getElementById('w_active').value === 'true',
}); });
@@ -782,7 +793,7 @@
} }
function clearWebForm() { function clearWebForm() {
['w_id','w_name','w_slug','w_target','w_comment','w_svc_login','w_svc_password'].forEach(id => document.getElementById(id).value = ''); ['w_id','w_name','w_slug','w_target','w_comment','w_svc_login','w_svc_password','w_svc_cred_hint'].forEach(id => document.getElementById(id).value = '');
document.getElementById('w_active').value = 'true'; document.getElementById('w_active').value = 'true';
setCategoryChecks('.w_cat', []); setCategoryChecks('.w_cat', []);
document.getElementById('w_icon_preview').src = placeholderIcon; document.getElementById('w_icon_preview').src = placeholderIcon;
@@ -865,7 +876,7 @@
location.reload(); location.reload();
} }
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword) { function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword, svcCredHint) {
const cfg = parseRdpTarget(target); const cfg = parseRdpTarget(target);
document.getElementById('r_id').value = id; document.getElementById('r_id').value = id;
document.getElementById('r_name').value = name; document.getElementById('r_name').value = name;
@@ -878,6 +889,7 @@
document.getElementById('r_comment').value = comment || ''; document.getElementById('r_comment').value = comment || '';
document.getElementById('r_svc_login').value = svcLogin || ''; document.getElementById('r_svc_login').value = svcLogin || '';
document.getElementById('r_svc_password').value = svcPassword || ''; document.getElementById('r_svc_password').value = svcPassword || '';
document.getElementById('r_svc_cred_hint').value = svcCredHint || '';
document.getElementById('r_active').value = String(active); document.getElementById('r_active').value = String(active);
document.getElementById('r_pool').value = pool; document.getElementById('r_pool').value = pool;
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []); setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
@@ -899,6 +911,7 @@
comment: document.getElementById('new_r_comment').value, comment: document.getElementById('new_r_comment').value,
svc_login: document.getElementById('new_r_svc_login').value, svc_login: document.getElementById('new_r_svc_login').value,
svc_password: document.getElementById('new_r_svc_password').value, svc_password: document.getElementById('new_r_svc_password').value,
svc_cred_hint: document.getElementById('new_r_svc_cred_hint').value,
category_ids: checkedCategoryIds('.new_r_cat'), category_ids: checkedCategoryIds('.new_r_cat'),
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10), warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
active: document.getElementById('new_r_active').value === 'true', active: document.getElementById('new_r_active').value === 'true',
@@ -918,6 +931,7 @@
comment: document.getElementById('r_comment').value, comment: document.getElementById('r_comment').value,
svc_login: document.getElementById('r_svc_login').value, svc_login: document.getElementById('r_svc_login').value,
svc_password: document.getElementById('r_svc_password').value, svc_password: document.getElementById('r_svc_password').value,
svc_cred_hint: document.getElementById('r_svc_cred_hint').value,
category_ids: checkedCategoryIds('.r_cat'), category_ids: checkedCategoryIds('.r_cat'),
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10), warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true', active: document.getElementById('r_active').value === 'true',
@@ -926,7 +940,7 @@
} }
function clearRdpForm() { function clearRdpForm() {
['r_id','r_name','r_slug','r_target','r_host','r_port','r_domain','r_comment','r_pool','r_svc_login','r_svc_password'].forEach(id => document.getElementById(id).value = ''); ['r_id','r_name','r_slug','r_target','r_host','r_port','r_domain','r_comment','r_pool','r_svc_login','r_svc_password','r_svc_cred_hint'].forEach(id => document.getElementById(id).value = '');
document.getElementById('rdp_slots_box').style.display = 'none'; document.getElementById('rdp_slots_box').style.display = 'none';
document.getElementById('r_sec').value = ''; document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true'; document.getElementById('r_active').value = 'true';
+5 -3
View File
@@ -82,6 +82,7 @@
<div class="tile-icon-box"> <div class="tile-icon-box">
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
</div> </div>
<h3>{{ service.name }}</h3>
{% if service.svc_login or service.svc_password %} {% if service.svc_login or service.svc_password %}
<div class="svc-credentials" onclick="event.preventDefault();event.stopPropagation()"> <div class="svc-credentials" onclick="event.preventDefault();event.stopPropagation()">
{% if service.svc_login %} {% if service.svc_login %}
@@ -98,13 +99,14 @@
<button class="svc-cred-copy" type="button" data-copy="{{ service.svc_password }}" title="Копировать пароль"></button> <button class="svc-cred-copy" type="button" data-copy="{{ service.svc_password }}" title="Копировать пароль"></button>
</div> </div>
{% endif %} {% endif %}
{% if service.svc_cred_hint %}
<p class="svc-cred-hint">{{ service.svc_cred_hint }}</p>
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% if service.comment %} {% if service.comment %}
<small class="tile-comment">{{ service_comment_html.get(service.id, '') }}</small> <div class="tile-comment">{{ service_comment_html.get(service.id, '') }}</div>
{% endif %} {% endif %}
<h3>{{ service.name }}</h3>
<p>Открыть сервис</p>
{% if svc_cats %} {% if svc_cats %}
<div class="service-categories"> <div class="service-categories">
{% for category in svc_cats %} {% for category in svc_cats %}