fix(install): LiveKit перезапускается при изменении конфига

LiveKit читает livekit.yaml только при старте, а compose не замечает правок в
примонтированном файле — из-за этого вебхуки не включались после обновления.
Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
This commit is contained in:
2026-09-20 18:38:54 +03:00
parent 9e7d023f7e
commit ecd7b3be10
27 changed files with 2637 additions and 37 deletions
@@ -0,0 +1,100 @@
import { useCallback, useRef, useState, type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar } from '@/components/ui/Avatar';
import { VoiceFlags, VoiceQualityDot, speakingRingClass } from '@/components/voice/VoiceIndicators';
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
import {
useVoiceChannels,
useVoiceModeration,
type VoiceOccupant,
} from '@/components/voice/useVoiceOccupants';
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
/**
* Строка участника голосовой комнаты в сайдбаре: аватар с кольцом речи,
* имя, флаги и точка качества связи. Клик открывает меню с громкостью и
* действиями модератора; при праве MOVE_MEMBERS строку можно перетащить
* на другую голосовую комнату.
*/
export function VoiceOccupantRow({
occupant,
guildId,
channelId,
}: {
occupant: VoiceOccupant;
guildId: string;
channelId: string;
}) {
const { t } = useTranslation();
const permissions = useVoiceModeration(guildId);
const moveTargets = useVoiceChannels(guildId).filter((channel) => channel.id !== channelId);
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLButtonElement | null>(null);
const close = useCallback(() => setOpen(false), []);
const label = t('voice.menu.label', { name: occupant.name });
const canDrag = permissions.move && !occupant.isLocal;
const onDragStart = (event: DragEvent<HTMLButtonElement>): void => {
event.dataTransfer.setData(VOICE_DRAG_MIME, occupant.userId);
event.dataTransfer.effectAllowed = 'move';
};
return (
<li className="flex items-center">
<button
ref={anchorRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={label}
title={label}
draggable={canDrag}
onDragStart={canDrag ? onDragStart : undefined}
onClick={(event) => {
event.stopPropagation();
setOpen((value) => !value);
}}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-[var(--radius-sm)] px-2 py-0.5 text-left text-xs text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
>
<span className={`shrink-0 rounded-full ${speakingRingClass(occupant.isSpeaking)}`}>
<Avatar
name={occupant.name}
seed={occupant.userId}
fileId={occupant.avatarFileId}
size="sm"
/>
</span>
<span className="min-w-0 flex-1 truncate">{occupant.name}</span>
{occupant.isSpeaking ? <span className="sr-only">{t('voice.speaking')}</span> : null}
<VoiceFlags
className="shrink-0"
selfMute={occupant.selfMute}
selfDeaf={occupant.selfDeaf}
serverMute={occupant.serverMute}
serverDeaf={occupant.serverDeaf}
camera={occupant.camera}
screen={occupant.screen}
/>
<VoiceQualityDot quality={occupant.quality} />
</button>
<VoiceOccupantMenu
open={open}
occupant={occupant}
anchorRef={anchorRef}
onClose={close}
permissions={permissions}
moveTargets={moveTargets}
onMute={(userId, muted) => void setParticipantMuted(userId, muted)}
onDeafen={(userId, deafened) => void setParticipantDeafened(userId, deafened)}
onMove={(userId, targetId) => void moveParticipant(userId, targetId)}
/>
</li>
);
}