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
+3
View File
@@ -6,6 +6,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router';
import { fetchGuild, guildQueryKey } from '@/api/guilds';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { GatewayBridge } from '@/components/GatewayBridge';
import { VoiceSpeakingNotice } from '@/components/voice/VoiceSpeakingNotice';
import { useCurrentUser, useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
@@ -193,6 +194,8 @@ export default function AppLayout() {
</div>
<CreateGuildModal />
{/* Подсказка «вы говорите с выключенным микрофоном» — поверх любого экрана. */}
<VoiceSpeakingNotice />
</div>
);
}
+14 -5
View File
@@ -12,6 +12,7 @@ import { Field, SelectField } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { VoiceChannelItem } from '@/components/voice/VoiceChannelItem';
import { groupChannels } from '@/lib/channels';
import { canAdministerGuild, canManageGuild } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
@@ -196,11 +197,19 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
<ul className="gl-stagger flex flex-col gap-0.5">
{group.channels.map((channel) => (
<li key={channel.id}>
<ChannelLink
guildId={guildId}
channel={channel}
selected={channel.id === channelId}
/>
{channel.type === 'voice' ? (
<VoiceChannelItem
guildId={guildId}
channel={channel}
selected={channel.id === channelId}
/>
) : (
<ChannelLink
guildId={guildId}
channel={channel}
selected={channel.id === channelId}
/>
)}
</li>
))}
</ul>
+3 -10
View File
@@ -7,14 +7,14 @@ import { ChatView } from '@/components/chat/ChatView';
import { PinsPanel } from '@/components/chat/PinsPanel';
import { SearchPanel } from '@/components/chat/SearchPanel';
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { Card } from '@/components/ui/primitives';
import { VoiceRoomPanel } from '@/components/voice/VoiceRoomPanel';
import { canManageMessages, canSendMessages } from '@/lib/identity';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
/**
* Область контента комнаты: для текстовых комнат — чат (лента, поиск, пины),
* для голосовых сохраняется заглушка до следующих фаз.
* для голосовых — панель голосовой комнаты (AGENT.md 7.14).
*/
export default function GuildView() {
const { t } = useTranslation();
@@ -116,14 +116,7 @@ export default function GuildView() {
</header>
{isVoice ? (
<div className="flex flex-1 items-center justify-center p-6">
<Card className="max-w-md text-center">
<h3 className="text-lg font-semibold">{t('channels.voicePlaceholder.title')}</h3>
<p className="mt-2 text-sm text-fg-muted">
{t('channels.voicePlaceholder.body', { name: channel.name })}
</p>
</Card>
</div>
<VoiceRoomPanel guildId={guild?.id ?? ''} channel={channel} />
) : (
<>
<SearchPanel
+43 -15
View File
@@ -18,6 +18,7 @@ import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
import { useVoiceStore } from '@/stores/voice';
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
import { SettingsModal } from '@/pages/settings/SettingsModal';
/** Статусы, которые можно выставить из мини-профиля: «не в сети» сервер выводит сам. */
@@ -52,6 +53,10 @@ interface PanelControlProps {
toggleLabel: string;
/** Подпись стрелки и поповера. */
menuLabel: string;
/** Заблокировано модератором: переключать нельзя. */
disabled?: boolean;
/** Почему кнопка заблокирована (озвучивается вместо подписи). */
disabledReason?: string;
onToggle: () => void;
open: boolean;
onOpenMenu: () => void;
@@ -69,6 +74,8 @@ function PanelControl({
enabled,
toggleLabel,
menuLabel,
disabled = false,
disabledReason,
onToggle,
open,
onOpenMenu,
@@ -78,6 +85,7 @@ function PanelControl({
const anchorRef: RefObject<HTMLButtonElement | null> = useRef(null);
// Группа «стрелка + кнопка»: поповер прижимается к её краю.
const groupRef: RefObject<HTMLDivElement | null> = useRef(null);
const label = disabled && disabledReason !== undefined ? disabledReason : toggleLabel;
return (
<div ref={groupRef} className="relative flex h-8 w-9 shrink-0 items-stretch gap-0">
@@ -100,10 +108,11 @@ function PanelControl({
ref={anchorRef}
type="button"
aria-pressed={enabled}
aria-label={toggleLabel}
title={toggleLabel}
aria-label={label}
title={label}
disabled={disabled}
onClick={onToggle}
className={`flex min-w-0 flex-1 items-center justify-center rounded-r-[var(--radius-sm)] px-1 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 ${
className={`flex min-w-0 flex-1 items-center justify-center rounded-r-[var(--radius-sm)] px-1 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 disabled:cursor-not-allowed disabled:opacity-60 ${
enabled ? 'text-fg' : 'text-danger'
}`}
>
@@ -144,9 +153,6 @@ export function UserPanel() {
const micEnabled = useVoiceStore((state) => state.micEnabled);
const cameraEnabled = useVoiceStore((state) => state.cameraEnabled);
const headphonesEnabled = useVoiceStore((state) => state.headphonesEnabled);
const toggleMic = useVoiceStore((state) => state.toggleMic);
const toggleCamera = useVoiceStore((state) => state.toggleCamera);
const toggleHeadphones = useVoiceStore((state) => state.toggleHeadphones);
const micVolume = useVoiceStore((state) => state.micVolume);
const outputVolume = useVoiceStore((state) => state.outputVolume);
const setMicVolume = useVoiceStore((state) => state.setMicVolume);
@@ -155,6 +161,22 @@ export function UserPanel() {
const camera = useDeviceChoice('videoinput');
const output = useDeviceChoice('audiooutput');
// Пока не подключены к голосовой комнате, кнопки хранят локальные
// предпочтения; в комнате они управляют настоящей сессией LiveKit.
const connectionStatus = useVoiceConnectionStore((state) => state.status);
const serverMuted = useVoiceConnectionStore((state) => state.serverMuted);
const serverDeafened = useVoiceConnectionStore((state) => state.serverDeafened);
const connectionMic = useVoiceConnectionStore((state) => state.micEnabled);
const connectionCamera = useVoiceConnectionStore((state) => state.cameraEnabled);
const connectionDeafened = useVoiceConnectionStore((state) => state.deafened);
const setSessionMicEnabled = useVoiceConnectionStore((state) => state.setMicEnabled);
const setSessionCameraEnabled = useVoiceConnectionStore((state) => state.setCameraEnabled);
const setSessionDeafened = useVoiceConnectionStore((state) => state.setDeafened);
const connected = connectionStatus === 'connected' || connectionStatus === 'reconnecting';
const micOn = connected ? connectionMic : micEnabled;
const cameraOn = connected ? connectionCamera : cameraEnabled;
const soundOn = connected ? !connectionDeafened : headphonesEnabled;
const [profileOpen, setProfileOpen] = useState(false);
/** Какое устройство сейчас раскрыто в поповере: одновременно только одно. */
const [openControl, setOpenControl] = useState<'mic' | 'camera' | 'headphones' | null>(null);
@@ -175,6 +197,8 @@ export function UserPanel() {
onSuccess: () => {
// Сессия в cookie уже погашена — сбрасываем локальное состояние.
disconnect();
// Голосовую комнату покидаем без запросов: сессии больше нет.
useVoiceConnectionStore.getState().reset();
useSessionStore.getState().reset();
// Сообщения тоже привязаны к пользователю: чужой истории в памяти быть не должно.
useMessagesStore.getState().reset();
@@ -262,10 +286,12 @@ export function UserPanel() {
<PanelControl
glyph="🎤"
enabled={micEnabled}
toggleLabel={t(micEnabled ? 'user.voice.micOff' : 'user.voice.micOn')}
enabled={micOn && !serverMuted}
toggleLabel={t(micOn ? 'user.voice.micOff' : 'user.voice.micOn')}
menuLabel={t('user.voice.micSettings')}
onToggle={toggleMic}
disabled={serverMuted}
{...(serverMuted ? { disabledReason: t('voice.serverMuted') } : {})}
onToggle={() => void setSessionMicEnabled(!micOn)}
open={openControl === 'mic'}
onOpenMenu={() => toggleControl('mic')}
onCloseMenu={() => setOpenControl(null)}
@@ -286,10 +312,10 @@ export function UserPanel() {
<PanelControl
glyph="🎥"
enabled={cameraEnabled}
toggleLabel={t(cameraEnabled ? 'user.voice.cameraOff' : 'user.voice.cameraOn')}
enabled={cameraOn}
toggleLabel={t(cameraOn ? 'user.voice.cameraOff' : 'user.voice.cameraOn')}
menuLabel={t('user.voice.cameraSettings')}
onToggle={toggleCamera}
onToggle={() => void setSessionCameraEnabled(!cameraOn)}
open={openControl === 'camera'}
onOpenMenu={() => toggleControl('camera')}
onCloseMenu={() => setOpenControl(null)}
@@ -305,10 +331,12 @@ export function UserPanel() {
<PanelControl
glyph="🎧"
enabled={headphonesEnabled}
toggleLabel={t(headphonesEnabled ? 'user.voice.soundOff' : 'user.voice.soundOn')}
enabled={soundOn && !serverDeafened}
toggleLabel={t(soundOn ? 'user.voice.soundOff' : 'user.voice.soundOn')}
menuLabel={t('user.voice.soundSettings')}
onToggle={toggleHeadphones}
disabled={serverDeafened}
{...(serverDeafened ? { disabledReason: t('voice.serverDeafened') } : {})}
onToggle={() => void setSessionDeafened(soundOn)}
open={openControl === 'headphones'}
onOpenMenu={() => toggleControl('headphones')}
onCloseMenu={() => setOpenControl(null)}