ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 48s
ci / api (push) Successful in 51s
ci / recommender (push) Canceled after 2s
ci / ts (packages + web) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
Security and robustness: - storage-agent rejects an empty INTERNAL_TOKEN (config notEmpty + middleware) - api takes the client IP only from X-Real-IP (chi RealIP trusted True-Client-IP) - /readyz reports ok/fail and logs the cause; checks run in parallel with a timeout - storage-agent shuts down gracefully; shared httpx.NewServer/Serve with IdleTimeout - goose migrations take a Postgres advisory lock - recommender readyz: per-check timeout, redis socket timeouts Clients: - Android release allows cleartext HTTP for LAN servers (expo-build-properties) - checkServer times out after 10s; connect button is disabled while checking Deploy and CI: - install.sh hands cache/uploads/updates/models to the non-root service users - update.sh refuses downgrades unless PIPU_ALLOW_DOWNGRADE=1 - recommender/analyzer get only the env they need; required secrets fail fast - nginx security headers and CSP; strip True-Client-IP - dev compose binds ports to 127.0.0.1; postgres/redis images pinned - version.mjs fails on a malformed explicit tag instead of releasing 0.0.0-dev - contract check catches untracked generated files - final releases fail without a release keystore; changelog skips rc tags Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
4.3 KiB
TypeScript
123 lines
4.3 KiB
TypeScript
import { createApiClient } from '@pipu/api-client';
|
||
import { checkServer, normalizeServerUrl, type ServerCheck } from '@pipu/core';
|
||
import Constants from 'expo-constants';
|
||
import { useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||
|
||
import { colors, radius } from './theme';
|
||
|
||
const appVersionCode = Number(Constants.expoConfig?.extra?.versionCode ?? 0);
|
||
|
||
type State = { kind: 'idle' } | { kind: 'checking' } | { kind: 'invalid' } | ({ kind: 'done' } & ServerCheck);
|
||
|
||
/** Экран подключения к серверу (docs/mobile.md). В M0 — только проверка /server-info. */
|
||
export function ConnectScreen() {
|
||
const { t } = useTranslation();
|
||
const [address, setAddress] = useState('');
|
||
const [state, setState] = useState<State>({ kind: 'idle' });
|
||
|
||
const checking = state.kind === 'checking';
|
||
|
||
async function onCheck() {
|
||
if (checking) return;
|
||
const baseUrl = normalizeServerUrl(address);
|
||
if (!baseUrl) {
|
||
setState({ kind: 'invalid' });
|
||
return;
|
||
}
|
||
setState({ kind: 'checking' });
|
||
const res = await checkServer(createApiClient({ baseUrl }), appVersionCode);
|
||
setState({ kind: 'done', ...res });
|
||
}
|
||
|
||
return (
|
||
<View style={styles.screen}>
|
||
<View style={styles.card}>
|
||
<Text style={styles.title}>{t('app.name')}</Text>
|
||
<Text style={styles.muted}>{t('app.tagline')}</Text>
|
||
|
||
<Text style={styles.label}>{t('server.address')}</Text>
|
||
<TextInput
|
||
value={address}
|
||
onChangeText={setAddress}
|
||
onSubmitEditing={onCheck}
|
||
editable={!checking}
|
||
placeholder="https://music.home.lan"
|
||
placeholderTextColor={colors.text3}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
keyboardType="url"
|
||
style={styles.input}
|
||
/>
|
||
|
||
{state.kind === 'checking' && (
|
||
<View style={[styles.status, { backgroundColor: colors.surface2 }]}>
|
||
<ActivityIndicator color={colors.text2} />
|
||
<Text style={{ color: colors.text2 }}>{t('server.checking')}</Text>
|
||
</View>
|
||
)}
|
||
{state.kind === 'invalid' && <Status ok={false} text={t('server.invalid_address')} />}
|
||
{state.kind === 'done' && state.status === 'ok' && (
|
||
<Status ok text={t('server.online', { version: state.info.version })} />
|
||
)}
|
||
{state.kind === 'done' && state.status === 'unsupported_app' && (
|
||
<Status ok={false} text={t('server.unsupported_app')} />
|
||
)}
|
||
{state.kind === 'done' && state.status === 'offline' && <Status ok={false} text={t('server.offline')} />}
|
||
|
||
<Pressable
|
||
onPress={onCheck}
|
||
disabled={checking}
|
||
accessibilityState={{ disabled: checking, busy: checking }}
|
||
style={({ pressed }) => [styles.button, (pressed || checking) && { opacity: 0.6 }]}
|
||
>
|
||
<Text style={styles.buttonText}>{t('server.check')}</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function Status({ ok, text }: { ok: boolean; text: string }) {
|
||
return (
|
||
<View style={[styles.status, { backgroundColor: ok ? colors['ok-soft'] : colors['danger-soft'] }]}>
|
||
<Text style={{ color: ok ? colors.ok : colors.danger, fontWeight: '500' }}>{text}</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
screen: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: 16 },
|
||
card: {
|
||
gap: 12,
|
||
padding: 24,
|
||
borderRadius: radius.xl,
|
||
backgroundColor: colors.surface,
|
||
borderWidth: 1,
|
||
borderColor: colors.line2,
|
||
},
|
||
title: { color: colors.text, fontSize: 26, fontWeight: '600' },
|
||
muted: { color: colors.text2, lineHeight: 21 },
|
||
label: { color: colors.text2, fontSize: 13, fontWeight: '500', marginTop: 8 },
|
||
input: {
|
||
height: 48,
|
||
paddingHorizontal: 14,
|
||
borderRadius: radius.md,
|
||
backgroundColor: colors.surface2,
|
||
borderWidth: 1,
|
||
borderColor: colors.line2,
|
||
color: colors.text,
|
||
fontSize: 15,
|
||
},
|
||
status: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12, borderRadius: 10 },
|
||
button: {
|
||
height: 48,
|
||
borderRadius: radius.md,
|
||
backgroundColor: colors.accent,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 15 },
|
||
});
|