Compare commits

...
3 Commits
Author SHA1 Message Date
DiheChen 535136bc7b ci: check readiness and the release version after deploy
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
The ops port serves GET /version, so the smoke test can confirm that the
upgraded build is the one answering.
2026-11-07 08:07:21 +08:00
DiheChen 0345d948ac feat: show the player bar volume as a read-only readout 2026-11-07 07:57:21 +08:00
DiheChen 51ff98eff1 feat: widen the player bar to the page panels and enlarge its buttons 2026-11-07 07:54:17 +08:00
14 changed files with 52 additions and 26 deletions

No files matched your search

+9 -1
View File
@@ -59,6 +59,7 @@ jobs:
shell: bash
env:
KUBECONFIG: ${{ runner.temp }}/k3s-kubeconfig
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
kubectl port-forward --namespace alisten service/alisten-serve 18090:9090 &
@@ -69,4 +70,11 @@ jobs:
--retry-delay 2 \
--retry-connrefused \
--max-time 10 \
http://127.0.0.1:18090/healthz
http://127.0.0.1:18090/readyz
# CI 构建发布镜像时把 tag+commit 写进版本号。
expected="${TAG}+${HEAD_SHA}"
actual="$(curl --silent --show-error --fail --max-time 10 http://127.0.0.1:18090/version)"
if [[ "$actual" != "$expected" ]]; then
printf '线上版本是 %s,期望 %s\n' "$actual" "$expected" >&2
exit 1
fi
+4 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func NewHandler(ready func() bool, metrics prometheus.Gatherer) http.Handler {
func NewHandler(ready func() bool, metrics prometheus.Gatherer, version string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "ok\n")
@@ -20,6 +20,9 @@ func NewHandler(ready func() bool, metrics prometheus.Gatherer) http.Handler {
}
_, _ = io.WriteString(w, "ok\n")
})
mux.HandleFunc("GET /version", func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, version+"\n")
})
mux.Handle("GET /metrics", promhttp.HandlerFor(metrics, promhttp.HandlerOpts{}))
return mux
}
+7 -1
View File
@@ -15,7 +15,7 @@ func TestHandler(t *testing.T) {
registry := prometheus.NewRegistry()
registry.MustRegister(prometheus.NewGauge(prometheus.GaugeOpts{Name: "test_gauge", Help: "测试用指标。"}))
var ready atomic.Bool
handler := NewHandler(ready.Load, registry)
handler := NewHandler(ready.Load, registry, "v1.2.3+abc")
get := func(path string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
@@ -28,6 +28,12 @@ func TestHandler(t *testing.T) {
t.Errorf("/healthz 状态码 = %d,期望 200", rec.Code)
}
})
t.Run("version 返回构建版本", func(t *testing.T) {
rec := get("/version")
if body := rec.Body.String(); rec.Code != http.StatusOK || body != "v1.2.3+abc\n" {
t.Errorf("/version 状态码 = %d,响应 = %q,期望 200 和 \"v1.2.3+abc\\n\"", rec.Code, body)
}
})
t.Run("metrics 输出注册的指标", func(t *testing.T) {
rec := get("/metrics")
body, _ := io.ReadAll(rec.Body)
+1 -1
View File
@@ -12,7 +12,7 @@ import (
func TestHealthcheckFollowsReadiness(t *testing.T) {
var ready atomic.Bool
server := httptest.NewServer(ops.NewHandler(ready.Load, prometheus.NewRegistry()))
server := httptest.NewServer(ops.NewHandler(ready.Load, prometheus.NewRegistry(), version))
defer server.Close()
t.Setenv("ALISTEN_OPS_LISTEN", server.Listener.Addr().String())
+1 -1
View File
@@ -182,7 +182,7 @@ func runServe(base *slog.Logger) (err error) {
opsLogger := base.With("component", "ops")
publicServer := newHTTPServer(p.handler, base.With("component", "http"))
opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, metrics), opsLogger), opsLogger)
opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, metrics, version), opsLogger), opsLogger)
go func() { fatal <- fmt.Errorf("HTTP 服务异常退出:%w", publicServer.Serve(publicListener)) }()
go func() { fatal <- fmt.Errorf("HTTP 服务异常退出:%w", opsServer.Serve(opsListener)) }()
logger.Info("服务已启动", "listen", publicListener.Addr().String(), "ops_listen", opsListener.Addr().String(), "instance", instance)
+1 -1
View File
@@ -275,7 +275,7 @@ func (w mediaWorker) run(ctx context.Context, listener net.Listener) error {
}),
)
opsLogger := w.base.With("component", "ops")
opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, w.metrics), opsLogger), opsLogger)
opsServer := newHTTPServer(recoverPanics(ops.NewHandler(ready, w.metrics, version), opsLogger), opsLogger)
go func() { fatal <- fmt.Errorf("运维端口异常退出:%w", opsServer.Serve(listener)) }()
w.logger.Info("worker 已启动", "ops_listen", listener.Addr().String(), "instance", w.instance)
+1 -1
View File
@@ -137,7 +137,7 @@
:root { --detail-panel-width: 420px; }
}
@media (min-width: 768px) {
.app-shell > .player-bar { justify-self: center; margin-bottom: 12px; }
.app-shell > .player-bar { margin: 0 8px 8px; }
}
/* 通知相对窗口居中,侧栏打开时移到内容区中间,否则会盖住侧栏。窗口太窄时内容区放不下通知,留在原位。 */
@media (min-width: 1024px) {
+5 -4
View File
@@ -15,16 +15,17 @@
.player-tools { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
.player-tools .volume-control { display: flex; align-items: center; gap: 6px; margin-right: 16px; }
.player-tools .volume-slider { width: 90px; accent-color: var(--color-foreground); }
.player-tools .volume-input { box-sizing: border-box; width: 4.5ch; min-width: 0; padding: 4px 3px; text-align: center; font-variant-numeric: tabular-nums; -moz-appearance: textfield; }
.player-tools .volume-input::-webkit-inner-spin-button, .player-tools .volume-input::-webkit-outer-spin-button { margin: 0; -webkit-appearance: none; }
.player-tools .volume-unit { color: var(--color-foreground-subtle); font-size: var(--text-ui-xs); }
.player-tools .volume-readout { min-width: 5ch; color: var(--color-foreground-subtle); font-size: var(--text-ui-sm); font-variant-numeric: tabular-nums; }
.player-panel-toggle { color: var(--color-foreground-subtle); }
.player-panel-toggle:hover:not(:disabled), .player-panel-toggle:focus-visible:not(:disabled), .player-panel-toggle[aria-pressed='true'] { color: var(--color-brand); background: transparent; }
.player-panel-toggle:active:not(:disabled) { color: var(--color-brand); background: transparent; transform: none; }
/* 播放栏在布局里占一行,不压在页面上,所以各页面不用留出底部空间。 */
@media (min-width: 768px) {
.player-bar { --color-hover: var(--color-surface-hover); width: min(920px, calc(100% - 16px)); padding: 10px 14px; border-radius: 16px; background: var(--color-panel); box-shadow: 0 8px 24px var(--color-shadow); }
.player-bar { --color-hover: var(--color-surface-hover); padding: 10px 14px; border-radius: 12px; background: var(--color-panel); }
.player-artwork .artwork { width: 56px; background: var(--color-surface); }
.player-right .icon-button { width: 40px; height: 40px; }
.player-right .icon-button svg { width: 22px; height: 22px; }
.player-center .icon-button svg { width: 24px; height: 24px; }
}
@media (max-width: 1100px) {
.player-bar { gap: 12px; }
+19 -10
View File
@@ -4,7 +4,7 @@ import { roomStore } from '@/core/room'
import { sourceName } from '@/features/queue'
import { formatDuration } from '@/shared/lib/time'
import { useStore } from '@/shared/lib/use-store'
import { Artwork, AsyncIconButton, IconButton, VolumeInput } from '@/shared/ui'
import { Artwork, AsyncIconButton, IconButton } from '@/shared/ui'
import { playerStore } from './player-store'
import { volumeStore } from './volume-store'
import './playback.css'
@@ -64,6 +64,7 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
const connected = useStore(roomStore, s => s.status === 'open')
const volume = useStore(volumeStore, s => s.volume)
const muted = useStore(volumeStore, s => s.muted)
const audibleVolume = muted ? 0 : volume
const meta = nowPlaying?.entry.meta
const artist = meta?.artist || '未知艺术家'
const source = nowPlaying ? sourceName(nowPlaying.entry.track) : undefined
@@ -90,15 +91,23 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
</div>
<div className="player-tools">
<IconButton icon={muted ? VolumeX : Volume2} label={muted ? '取消静音' : '静音'} onClick={() => volumeStore.setMuted(!muted)} />
<VolumeInput
volume={volume}
sliderValue={muted ? 0 : volume}
onCommit={(next) => {
volumeStore.setVolume(next)
if (muted && next > 0)
volumeStore.setMuted(false)
}}
/>
<div className="volume-control">
<input
className="volume-slider"
type="range"
min="0"
max="100"
value={audibleVolume}
aria-label="音量"
onChange={(event) => {
const next = Number(event.target.value)
volumeStore.setVolume(next)
if (muted && next > 0)
volumeStore.setMuted(false)
}}
/>
<span className="volume-readout" aria-hidden="true">{`${audibleVolume}%`}</span>
</div>
<IconButton icon={MessageCircle} label="房间聊天" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={chatOpen} onClick={onChat} />
<IconButton icon={ListMusic} label="播放列表" className="player-panel-toggle" disabled={!canOpenPanels} aria-pressed={queueOpen} onClick={onQueue} />
</div>
+2 -1
View File
@@ -6,10 +6,11 @@ import { errorMessage } from '@/core/errors'
import { historyStore, searchLimits } from '@/features/history'
import { reminderStore, requestNotificationPermission, systemNotificationsSupported, volumeStore } from '@/features/playback'
import { useStore } from '@/shared/lib/use-store'
import { notificationStore, Select, VolumeInput } from '@/shared/ui'
import { notificationStore, Select } from '@/shared/ui'
import { appearanceStore, fontSizes, themes } from './appearance-store'
import { performanceLevels, performanceStore } from './performance-store'
import { profileStore } from './profile-store'
import { VolumeInput } from './volume-input'
import './settings.css'
interface SettingsDraft {
@@ -4,13 +4,12 @@ import { beginVolumeEdit, changeVolumeDraft, commitVolumeDraft, initialVolumeEdi
interface VolumeInputProps {
volume: number
onCommit: (volume: number) => void
sliderValue?: number
id?: string
className?: string
disabled?: boolean
}
export function VolumeInput({ volume, onCommit, sliderValue, id, className = '', disabled = false }: VolumeInputProps) {
export function VolumeInput({ volume, onCommit, id, className = '', disabled = false }: VolumeInputProps) {
const [editor, setEditor] = useState(() => initialVolumeEditor(volume))
const syncedEditor = syncVolumeDraft(editor, volume)
const commit = () => {
@@ -27,7 +26,7 @@ export function VolumeInput({ volume, onCommit, sliderValue, id, className = '',
type="range"
min="0"
max="100"
value={sliderValue ?? volume}
value={volume}
aria-label="音量"
disabled={disabled}
onChange={(event) => {
-1
View File
@@ -16,4 +16,3 @@ export { Skeleton } from './skeleton'
export { SongArtwork } from './song-artwork'
export { SongList } from './song-list'
export type { SongAction, SongItem } from './song-list'
export { VolumeInput } from './volume-input'