Add admin restart flow and loading indicators

This commit is contained in:
2026-07-08 13:34:43 +03:00
parent ae13070eba
commit 838dea0e03
11 changed files with 639 additions and 127 deletions

View File

@@ -149,6 +149,15 @@ impl CommandError {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminStatusResponse {
pub is_windows: bool,
pub is_elevated: bool,
pub can_restart_elevated: bool,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ValidationIssue { pub struct ValidationIssue {
@@ -542,6 +551,18 @@ pub async fn get_status(
.map_err(background_task_error)? .map_err(background_task_error)?
} }
#[tauri::command]
pub fn get_admin_status() -> AdminStatusResponse {
admin_status()
}
#[tauri::command]
pub fn restart_as_admin(app: tauri::AppHandle) -> Result<(), CommandError> {
launch_app_as_admin()?;
app.exit(0);
Ok(())
}
#[tauri::command] #[tauri::command]
pub fn get_saved_state( pub fn get_saved_state(
state: tauri::State<'_, CommandState>, state: tauri::State<'_, CommandState>,
@@ -814,6 +835,87 @@ pub async fn uninstall_singbox() -> Result<ComponentStatusDto, CommandError> {
.map_err(background_task_error)? .map_err(background_task_error)?
} }
pub fn admin_status() -> AdminStatusResponse {
let is_windows = cfg!(windows);
let is_elevated = is_running_elevated();
let message = if !is_windows {
"Проверка прав администратора нужна только в Windows.".to_string()
} else if is_elevated {
"ProxyWarden уже запущен от имени администратора.".to_string()
} else {
"Для установки компонентов и управления службами можно перезапустить ProxyWarden от имени администратора один раз.".to_string()
};
AdminStatusResponse {
is_windows,
is_elevated,
can_restart_elevated: is_windows && !is_elevated,
message,
}
}
fn launch_app_as_admin() -> Result<(), CommandError> {
if !cfg!(windows) {
return Err(CommandError::new(
"admin_restart_unsupported",
"Перезапуск от имени администратора доступен только в Windows.",
));
}
if is_running_elevated() {
return Ok(());
}
let exe_path = env::current_exe().map_err(|error| {
CommandError::new(
"admin_restart_failed",
format!("Не удалось определить путь текущего приложения: {error}"),
)
})?;
let working_dir = env::current_dir().ok();
let working_dir_arg = working_dir
.as_ref()
.map(|path| {
format!(
" -WorkingDirectory '{}'",
escape_powershell_single(&path.display().to_string())
)
})
.unwrap_or_default();
let script = format!(
r#"
$ErrorActionPreference = 'Stop'
try {{
Start-Process -FilePath '{}' -Verb RunAs{}
exit 0
}} catch {{
Write-Error ($_ | Out-String)
exit 1
}}
"#,
escape_powershell_single(&exe_path.display().to_string()),
working_dir_arg
);
let output = run_powershell_command(&script).map_err(|error| {
CommandError::new(
"admin_restart_failed",
format!("Не удалось запросить права администратора: {error}"),
)
})?;
if output.status.success() {
return Ok(());
}
Err(CommandError::new(
"admin_restart_failed",
powershell_output_message(
&output,
"Перезапуск от имени администратора отменен или не был запущен.",
),
))
}
pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandError> { pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandError> {
let profiles = storage.read_profiles().map_err(storage_error)?; let profiles = storage.read_profiles().map_err(storage_error)?;
let targets = storage.read_targets().map_err(storage_error)?; let targets = storage.read_targets().map_err(storage_error)?;
@@ -1933,16 +2035,11 @@ fn run_elevated_singbox_service_command(
"$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode", "$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode",
escape_powershell_single(&script_path.display().to_string()) escape_powershell_single(&script_path.display().to_string())
); );
let output = Command::new("powershell") let output = if is_running_elevated() {
.args([ run_powershell_file(&script_path)
"-NoProfile", } else {
"-NonInteractive", run_powershell_command(&launch_script)
"-ExecutionPolicy", };
"Bypass",
"-Command",
launch_script.as_str(),
])
.output();
let _ = fs::remove_file(&script_path); let _ = fs::remove_file(&script_path);
@@ -2218,16 +2315,11 @@ try {{
escape_powershell_single(&result_path.display().to_string()), escape_powershell_single(&result_path.display().to_string()),
escape_powershell_single(&runner_path.display().to_string()) escape_powershell_single(&runner_path.display().to_string())
); );
let output = Command::new("powershell") let output = if is_running_elevated() {
.args([ run_powershell_file(&runner_path)
"-NoProfile", } else {
"-NonInteractive", run_powershell_command(&launch_script)
"-ExecutionPolicy", };
"Bypass",
"-Command",
launch_script.as_str(),
])
.output();
let _ = fs::remove_file(&installer_path); let _ = fs::remove_file(&installer_path);
let _ = fs::remove_file(&runner_path); let _ = fs::remove_file(&runner_path);
@@ -2781,16 +2873,11 @@ fn run_elevated_proxifyre_service_command(
"$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode", "$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode",
escape_powershell_single(&script_path.display().to_string()) escape_powershell_single(&script_path.display().to_string())
); );
let output = Command::new("powershell") let output = if is_running_elevated() {
.args([ run_powershell_file(&script_path)
"-NoProfile", } else {
"-NonInteractive", run_powershell_command(&launch_script)
"-ExecutionPolicy", };
"Bypass",
"-Command",
launch_script.as_str(),
])
.output();
let _ = fs::remove_file(&script_path); let _ = fs::remove_file(&script_path);
@@ -3225,16 +3312,11 @@ try {{
escape_powershell_single(&result_path.display().to_string()), escape_powershell_single(&result_path.display().to_string()),
escape_powershell_single(&script_path.display().to_string()) escape_powershell_single(&script_path.display().to_string())
); );
let output = Command::new("powershell") let output = if is_running_elevated() {
.args([ run_powershell_file(&script_path)
"-NoProfile", } else {
"-NonInteractive", run_powershell_command(&launch_script)
"-ExecutionPolicy", };
"Bypass",
"-Command",
launch_script.as_str(),
])
.output();
let _ = fs::remove_file(&script_path); let _ = fs::remove_file(&script_path);
@@ -3296,6 +3378,62 @@ fn write_powershell_script(path: &Path, script: &str) -> std::io::Result<()> {
fs::write(path, bytes) fs::write(path, bytes)
} }
fn run_powershell_command(script: &str) -> std::io::Result<Output> {
Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
])
.output()
}
fn run_powershell_file(script_path: &Path) -> std::io::Result<Output> {
Command::new("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(script_path)
.output()
}
fn is_running_elevated() -> bool {
if !cfg!(windows) {
return false;
}
let script = r#"([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#;
let Ok(output) = run_powershell_command(script) else {
return false;
};
output.status.success()
&& String::from_utf8_lossy(&output.stdout)
.trim()
.eq_ignore_ascii_case("true")
}
fn powershell_output_message(output: &Output, fallback: &str) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
return stderr;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stdout.is_empty() {
return stdout;
}
fallback.to_string()
}
pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String { pub(crate) fn install_proxifyre_script(generated_config_path: &Path) -> String {
let mut script = String::new(); let mut script = String::new();
script.push_str(&format!( script.push_str(&format!(

View File

@@ -36,6 +36,8 @@ fn main() {
.manage(commands::CommandState::default()) .manage(commands::CommandState::default())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::get_status, commands::get_status,
commands::get_admin_status,
commands::restart_as_admin,
commands::get_profiles, commands::get_profiles,
commands::get_saved_state, commands::get_saved_state,
commands::save_profile, commands::save_profile,

View File

@@ -30,6 +30,13 @@ export interface StatusResponse {
generatedConfigPath: string; generatedConfigPath: string;
} }
export interface AdminStatusResponse {
isWindows: boolean;
isElevated: boolean;
canRestartElevated: boolean;
message: string;
}
export interface SavedStateResponse { export interface SavedStateResponse {
profiles: Profile[]; profiles: Profile[];
targets: Target[]; targets: Target[];
@@ -135,6 +142,14 @@ export function getStatus(): Promise<StatusResponse> {
return invoke<StatusResponse>('get_status'); return invoke<StatusResponse>('get_status');
} }
export function getAdminStatus(): Promise<AdminStatusResponse> {
return invoke<AdminStatusResponse>('get_admin_status');
}
export function restartAsAdmin(): Promise<void> {
return invoke<void>('restart_as_admin');
}
export function getSavedState(): Promise<SavedStateResponse> { export function getSavedState(): Promise<SavedStateResponse> {
return invoke<SavedStateResponse>('get_saved_state'); return invoke<SavedStateResponse>('get_saved_state');
} }

View File

@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Power, Trash2, Wand2 } from 'lucide-react'; import { Cpu, FileCode2, FolderOpen, Gauge, Link2, Power, ShieldAlert, Trash2, Wand2 } from 'lucide-react';
import { import {
applyProfiles, applyProfiles,
fetchSingBoxSubscription, fetchSingBoxSubscription,
forgetSingBoxSubscription, forgetSingBoxSubscription,
generateSingBoxConfig, generateSingBoxConfig,
getAdminStatus,
getComponents, getComponents,
getProxiFyreSetupStatus, getProxiFyreSetupStatus,
getSavedState, getSavedState,
@@ -16,6 +17,7 @@ import {
pingAllSingBoxServers, pingAllSingBoxServers,
pingProxyTarget, pingProxyTarget,
pingSingBoxServer, pingSingBoxServer,
restartAsAdmin,
saveProfile, saveProfile,
saveSingBoxSubscription, saveSingBoxSubscription,
saveTarget, saveTarget,
@@ -26,6 +28,7 @@ import {
stopSingBoxService, stopSingBoxService,
uninstallProxiFyre, uninstallProxiFyre,
uninstallSingBox, uninstallSingBox,
type AdminStatusResponse,
type ApplyProfilesResponse, type ApplyProfilesResponse,
type LocalSingBoxStatusResponse, type LocalSingBoxStatusResponse,
type PingServerResponse, type PingServerResponse,
@@ -35,7 +38,7 @@ import {
type SingBoxSetupStatus, type SingBoxSetupStatus,
} from '../api/tauriCommands'; } from '../api/tauriCommands';
import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types'; import type { ComponentStatus, Profile, ProfileItemInput, ProfileItemType, SubscriptionServer, Target } from '../domain/types';
import { Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui'; import { BusyRing, Button, DetailsPopover, IconButton, LogDock, ServiceControlRow, Tabs } from '../ui';
import { getApplyReadiness } from './readiness'; import { getApplyReadiness } from './readiness';
import { serviceControlState } from './viewModel'; import { serviceControlState } from './viewModel';
@@ -180,6 +183,7 @@ export function App() {
const [subscriptionInput, setSubscriptionInput] = useState(''); const [subscriptionInput, setSubscriptionInput] = useState('');
const [serverPings, setServerPings] = useState<Record<string, PingServerResponse>>({}); const [serverPings, setServerPings] = useState<Record<string, PingServerResponse>>({});
const [proxyCheck, setProxyCheck] = useState<ProxyTargetCheckResponse | null>(null); const [proxyCheck, setProxyCheck] = useState<ProxyTargetCheckResponse | null>(null);
const [adminStatus, setAdminStatus] = useState<AdminStatusResponse | null>(null);
const [generatedConfigPath, setGeneratedConfigPath] = useState(''); const [generatedConfigPath, setGeneratedConfigPath] = useState('');
const [logEntries, setLogEntries] = useState<LogEntry[]>([]); const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [activeLogId, setActiveLogId] = useState<string | null>(null); const [activeLogId, setActiveLogId] = useState<string | null>(null);
@@ -187,6 +191,7 @@ export function App() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isDetectingComponents, setIsDetectingComponents] = useState(true); const [isDetectingComponents, setIsDetectingComponents] = useState(true);
const [isApplying, setIsApplying] = useState(false); const [isApplying, setIsApplying] = useState(false);
const [isRestartingAsAdmin, setIsRestartingAsAdmin] = useState(false);
const [isProxyChecking, setIsProxyChecking] = useState(false); const [isProxyChecking, setIsProxyChecking] = useState(false);
const [serverPingTag, setServerPingTag] = useState<string | null>(null); const [serverPingTag, setServerPingTag] = useState<string | null>(null);
const [serviceAction, setServiceAction] = useState<ProxiFyreAction | null>(null); const [serviceAction, setServiceAction] = useState<ProxiFyreAction | null>(null);
@@ -259,6 +264,7 @@ export function App() {
}, [activeLogId]); }, [activeLogId]);
async function refresh() { async function refresh() {
void refreshAdminStatus();
setIsLoading(true); setIsLoading(true);
try { try {
const saved = await getSavedState(); const saved = await getSavedState();
@@ -276,6 +282,30 @@ export function App() {
void refreshComponents(); void refreshComponents();
} }
async function refreshAdminStatus() {
try {
const status = await getAdminStatus();
setAdminStatus(status);
} catch {
setAdminStatus(null);
}
}
async function restartApplicationAsAdmin() {
setIsRestartingAsAdmin(true);
try {
await restartAsAdmin();
setIsRestartingAsAdmin(false);
} catch (error) {
setIsRestartingAsAdmin(false);
showNotice({
kind: 'error',
title: 'Перезапуск отменен',
text: errorMessage(error),
});
}
}
async function refreshComponents() { async function refreshComponents() {
setIsDetectingComponents(true); setIsDetectingComponents(true);
try { try {
@@ -933,6 +963,33 @@ export function App() {
setActiveLogId(entry.id); setActiveLogId(entry.id);
} }
function renderAdminPrompt() {
if (!adminStatus?.canRestartElevated) return null;
return (
<aside className="admin-prompt" aria-label="Права администратора">
<span className="admin-prompt-icon" aria-hidden="true">
<ShieldAlert size={18} strokeWidth={1.9} />
</span>
<div className="admin-prompt-copy">
<strong>Один запрос прав вместо серии UAC</strong>
<span>{adminStatus.message}</span>
</div>
<Button
type="button"
variant="primary"
size="sm"
className="admin-prompt-action"
onClick={() => void restartApplicationAsAdmin()}
loading={isRestartingAsAdmin}
loadingLabel="Открываю UAC"
>
Перезапустить с правами
</Button>
</aside>
);
}
function renderTabs() { function renderTabs() {
const tabs: Array<{ id: PanelId; label: string }> = [ const tabs: Array<{ id: PanelId; label: string }> = [
{ id: 'summary', label: 'Сводка' }, { id: 'summary', label: 'Сводка' },
@@ -1176,6 +1233,7 @@ export function App() {
return ( return (
<div className={`summary-status-control ${systemSummary.tone} ${running ? 'on' : 'off'}`}> <div className={`summary-status-control ${systemSummary.tone} ${running ? 'on' : 'off'}`}>
{systemSummary.tone === 'checking' ? <BusyRing /> : null}
<span className={`summary-status-dot ${systemSummary.tone}`} aria-hidden="true" /> <span className={`summary-status-dot ${systemSummary.tone}`} aria-hidden="true" />
<div className="summary-status-copy"> <div className="summary-status-copy">
<span>Состояние маршрута</span> <span>Состояние маршрута</span>
@@ -1504,6 +1562,7 @@ export function App() {
return ( return (
<section className={`proxy-overview ${check.tone}`} aria-label="Состояние прокси"> <section className={`proxy-overview ${check.tone}`} aria-label="Состояние прокси">
{check.tone === 'checking' ? <BusyRing /> : null}
{renderConnectionCheck(check)} {renderConnectionCheck(check)}
</section> </section>
); );
@@ -1607,6 +1666,7 @@ export function App() {
</header> </header>
{renderTabs()} {renderTabs()}
{renderAdminPrompt()}
<div className={`tab-panel-frame swipe-${tabTransitionDirection}`} key={activePanel}> <div className={`tab-panel-frame swipe-${tabTransitionDirection}`} key={activePanel}>
{renderActivePanel()} {renderActivePanel()}
</div> </div>

View File

@@ -10,6 +10,10 @@
--motion-panel: 220ms; --motion-panel: 220ms;
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-standard: cubic-bezier(0.22, 0.72, 0.18, 1); --ease-standard: cubic-bezier(0.22, 0.72, 0.18, 1);
--busy-ring-thickness: 2px;
--busy-ring-speed: 1.6s;
--busy-ring-long: 108px;
--busy-ring-short: 64px;
--surface-canvas: #101216; --surface-canvas: #101216;
--surface-panel: #131720; --surface-panel: #131720;
--surface-raised: #151923; --surface-raised: #151923;
@@ -91,6 +95,8 @@ button:disabled {
.ui-button, .ui-button,
.ui-icon-button { .ui-icon-button {
appearance: none; appearance: none;
position: relative;
isolation: isolate;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -102,6 +108,7 @@ button:disabled {
cursor: pointer; cursor: pointer;
font-weight: 750; font-weight: 750;
line-height: 1; line-height: 1;
overflow: visible;
text-decoration: none; text-decoration: none;
transition: transition:
background-color var(--motion-fast) var(--ease-out), background-color var(--motion-fast) var(--ease-out),
@@ -140,6 +147,18 @@ button:disabled {
transform: none; transform: none;
} }
.ui-button.is-loading:disabled,
.ui-icon-button.is-loading:disabled {
cursor: progress;
opacity: 1;
}
.ui-button > :not(.ui-busy-ring),
.ui-icon-button > :not(.ui-busy-ring) {
position: relative;
z-index: 1;
}
.ui-button--sm { .ui-button--sm {
min-height: 32px; min-height: 32px;
padding: 6px 10px; padding: 6px 10px;
@@ -313,13 +332,58 @@ button:disabled {
min-width: 0; min-width: 0;
} }
.ui-button-spinner { .ui-busy-ring {
width: 14px; position: absolute;
height: 14px; display: block;
border: 2px solid currentColor; z-index: 2;
border-top-color: transparent; inset: 0;
border-radius: 999px; overflow: hidden;
animation: spin 0.75s linear infinite; border-radius: inherit;
pointer-events: none;
}
.ui-busy-ring-segment {
position: absolute;
display: block;
background: #93c5fd;
box-shadow:
0 0 8px rgba(96, 165, 250, 0.95),
0 0 16px rgba(34, 197, 94, 0.36);
opacity: 0;
}
.ui-busy-ring-segment.top,
.ui-busy-ring-segment.bottom {
width: var(--busy-ring-long);
height: var(--busy-ring-thickness);
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.ui-busy-ring-segment.right,
.ui-busy-ring-segment.left {
width: var(--busy-ring-thickness);
height: var(--busy-ring-short);
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
}
.ui-busy-ring-segment.top {
top: 0;
animation: finder-border-top var(--busy-ring-speed) linear infinite;
}
.ui-busy-ring-segment.right {
right: 0;
animation: finder-border-right var(--busy-ring-speed) linear infinite;
}
.ui-busy-ring-segment.bottom {
bottom: 0;
animation: finder-border-bottom var(--busy-ring-speed) linear infinite;
}
.ui-busy-ring-segment.left {
left: 0;
animation: finder-border-left var(--busy-ring-speed) linear infinite;
} }
.ui-tabs { .ui-tabs {
@@ -387,6 +451,8 @@ button:disabled {
} }
.ui-status-pill { .ui-status-pill {
position: relative;
isolation: isolate;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
width: fit-content; width: fit-content;
@@ -399,6 +465,11 @@ button:disabled {
font-weight: 750; font-weight: 750;
} }
.ui-status-pill > :not(.ui-busy-ring) {
position: relative;
z-index: 1;
}
.ui-status-pill--ok { .ui-status-pill--ok {
border-color: rgba(34, 197, 94, 0.38); border-color: rgba(34, 197, 94, 0.38);
color: #86efac; color: #86efac;
@@ -466,10 +537,8 @@ button:disabled {
} }
.ui-action-menu-popover { .ui-action-menu-popover {
position: absolute; position: fixed;
top: calc(100% + 6px); z-index: 80;
right: 0;
z-index: 8;
min-width: 172px; min-width: 172px;
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
border-radius: 4px; border-radius: 4px;
@@ -480,6 +549,10 @@ button:disabled {
animation: ui-popover-in var(--motion-standard) var(--ease-out); animation: ui-popover-in var(--motion-standard) var(--ease-out);
} }
.ui-action-menu-popover[data-placement="top"] {
transform-origin: bottom right;
}
.ui-action-menu-popover button { .ui-action-menu-popover button {
width: 100%; width: 100%;
min-height: 34px; min-height: 34px;
@@ -563,36 +636,36 @@ button:disabled {
.ui-service-border-glow-segment.top, .ui-service-border-glow-segment.top,
.ui-service-border-glow-segment.bottom { .ui-service-border-glow-segment.bottom {
width: 108px; width: var(--busy-ring-long);
height: 2px; height: var(--busy-ring-thickness);
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
} }
.ui-service-border-glow-segment.right, .ui-service-border-glow-segment.right,
.ui-service-border-glow-segment.left { .ui-service-border-glow-segment.left {
width: 2px; width: var(--busy-ring-thickness);
height: 64px; height: var(--busy-ring-short);
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
} }
.ui-service-border-glow-segment.top { .ui-service-border-glow-segment.top {
top: 0; top: 0;
animation: finder-border-top 1.6s linear infinite; animation: finder-border-top var(--busy-ring-speed) linear infinite;
} }
.ui-service-border-glow-segment.right { .ui-service-border-glow-segment.right {
right: 0; right: 0;
animation: finder-border-right 1.6s linear infinite; animation: finder-border-right var(--busy-ring-speed) linear infinite;
} }
.ui-service-border-glow-segment.bottom { .ui-service-border-glow-segment.bottom {
bottom: 0; bottom: 0;
animation: finder-border-bottom 1.6s linear infinite; animation: finder-border-bottom var(--busy-ring-speed) linear infinite;
} }
.ui-service-border-glow-segment.left { .ui-service-border-glow-segment.left {
left: 0; left: 0;
animation: finder-border-left 1.6s linear infinite; animation: finder-border-left var(--busy-ring-speed) linear infinite;
} }
.ui-service-dot { .ui-service-dot {
@@ -621,11 +694,8 @@ button:disabled {
} }
.ui-service-row--checking .ui-service-dot { .ui-service-row--checking .ui-service-dot {
border: 2px solid var(--focus-ring); background: #60a5fa;
border-top-color: transparent; box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14);
background: transparent;
box-shadow: none;
animation: spin 0.75s linear infinite;
} }
.ui-service-text { .ui-service-text {
@@ -835,6 +905,54 @@ button:disabled {
content: ""; content: "";
} }
.admin-prompt {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-width: 0;
border: 1px solid rgba(245, 158, 11, 0.44);
border-radius: 4px;
background: rgba(120, 53, 15, 0.18);
margin-bottom: 10px;
padding: 8px 10px;
}
.admin-prompt-icon {
display: grid;
place-items: center;
width: 32px;
min-width: 32px;
height: 32px;
border: 1px solid rgba(245, 158, 11, 0.28);
border-radius: 4px;
background: rgba(15, 23, 42, 0.42);
color: #fcd34d;
}
.admin-prompt-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-prompt-copy strong {
color: #f8fafc;
font-size: 13px;
font-weight: 800;
}
.admin-prompt-copy span {
color: #c7b489;
font-size: 12px;
overflow-wrap: anywhere;
}
.admin-prompt-action {
min-width: 230px;
white-space: nowrap;
}
.tab-panel-frame { .tab-panel-frame {
min-width: 0; min-width: 0;
will-change: opacity, transform; will-change: opacity, transform;
@@ -887,6 +1005,8 @@ button:disabled {
} }
.proxy-overview { .proxy-overview {
position: relative;
isolation: isolate;
display: block; display: block;
border: 1px solid #2b3342; border: 1px solid #2b3342;
border-radius: 4px; border-radius: 4px;
@@ -894,6 +1014,11 @@ button:disabled {
padding: 8px 10px; padding: 8px 10px;
} }
.proxy-overview > :not(.ui-busy-ring) {
position: relative;
z-index: 1;
}
.proxy-overview.ok { .proxy-overview.ok {
border-color: rgba(34, 197, 94, 0.36); border-color: rgba(34, 197, 94, 0.36);
} }
@@ -915,6 +1040,8 @@ button:disabled {
} }
.summary-status-control { .summary-status-control {
position: relative;
isolation: isolate;
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px; gap: 12px;
@@ -925,6 +1052,11 @@ button:disabled {
padding: 13px 14px; padding: 13px 14px;
} }
.summary-status-control > :not(.ui-busy-ring) {
position: relative;
z-index: 1;
}
.summary-status-control.ok { .summary-status-control.ok {
border-color: rgba(34, 197, 94, 0.44); border-color: rgba(34, 197, 94, 0.44);
} }
@@ -965,10 +1097,8 @@ button:disabled {
} }
.summary-status-dot.checking { .summary-status-dot.checking {
border: 2px solid #3b82f6; background: #60a5fa;
border-top-color: transparent; box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14);
background: transparent;
animation: spin 0.75s linear infinite;
} }
.summary-status-copy span, .summary-status-copy span,
@@ -1070,6 +1200,8 @@ button:disabled {
} }
.summary-card { .summary-card {
position: relative;
isolation: isolate;
display: grid; display: grid;
gap: 5px; gap: 5px;
align-content: start; align-content: start;
@@ -1082,6 +1214,11 @@ button:disabled {
text-align: left; text-align: left;
} }
.summary-card > :not(.ui-busy-ring) {
position: relative;
z-index: 1;
}
button.summary-card { button.summary-card {
cursor: pointer; cursor: pointer;
} }
@@ -1227,36 +1364,36 @@ button.summary-card:hover {
.finder-border-glow-segment.top, .finder-border-glow-segment.top,
.finder-border-glow-segment.bottom { .finder-border-glow-segment.bottom {
width: 108px; width: var(--busy-ring-long);
height: 2px; height: var(--busy-ring-thickness);
background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); background: linear-gradient(90deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
} }
.finder-border-glow-segment.right, .finder-border-glow-segment.right,
.finder-border-glow-segment.left { .finder-border-glow-segment.left {
width: 2px; width: var(--busy-ring-thickness);
height: 64px; height: var(--busy-ring-short);
background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent); background: linear-gradient(180deg, transparent, #60a5fa 24%, #bbf7d0 54%, transparent);
} }
.finder-border-glow-segment.top { .finder-border-glow-segment.top {
top: 0; top: 0;
animation: finder-border-top 1.6s linear infinite; animation: finder-border-top var(--busy-ring-speed) linear infinite;
} }
.finder-border-glow-segment.right { .finder-border-glow-segment.right {
right: 0; right: 0;
animation: finder-border-right 1.6s linear infinite; animation: finder-border-right var(--busy-ring-speed) linear infinite;
} }
.finder-border-glow-segment.bottom { .finder-border-glow-segment.bottom {
bottom: 0; bottom: 0;
animation: finder-border-bottom 1.6s linear infinite; animation: finder-border-bottom var(--busy-ring-speed) linear infinite;
} }
.finder-border-glow-segment.left { .finder-border-glow-segment.left {
left: 0; left: 0;
animation: finder-border-left 1.6s linear infinite; animation: finder-border-left var(--busy-ring-speed) linear infinite;
} }
.finder-text, .finder-text,
@@ -1305,11 +1442,8 @@ button.summary-card:hover {
} }
.finder-card.checking .status-light { .finder-card.checking .status-light {
border: 2px solid #3b82f6; background: #60a5fa;
border-top-color: transparent; box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14);
background: transparent;
box-shadow: none;
animation: spin 0.75s linear infinite;
} }
.setup-strip { .setup-strip {
@@ -1838,6 +1972,8 @@ button.summary-card:hover {
} }
.route-chain-segment { .route-chain-segment {
position: relative;
isolation: isolate;
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas: grid-template-areas:
@@ -1856,6 +1992,11 @@ button.summary-card:hover {
width: 100%; width: 100%;
} }
.route-chain-segment > :not(.ui-busy-ring) {
position: relative;
z-index: 1;
}
.route-chain-segment:hover { .route-chain-segment:hover {
border-color: #3b82f6; border-color: #3b82f6;
background: #141d2b; background: #141d2b;
@@ -1909,11 +2050,8 @@ button.summary-card:hover {
} }
.route-chain-segment.checking .route-chain-dot { .route-chain-segment.checking .route-chain-dot {
border: 2px solid #3b82f6; background: #60a5fa;
border-top-color: transparent; box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.14);
background: transparent;
box-shadow: none;
animation: spin 0.75s linear infinite;
} }
.apps-section { .apps-section {
@@ -2196,9 +2334,17 @@ button.summary-card:hover {
} }
.tile-loading { .tile-loading {
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
min-height: 30px;
border: 1px solid rgba(59, 130, 246, 0.42);
border-radius: 4px;
background: #111720;
color: #dbeafe; color: #dbeafe;
font-weight: 800; font-weight: 800;
animation: pulse 1s ease-in-out infinite; padding: 6px 9px;
} }
.app-add-skeleton { .app-add-skeleton {
@@ -2327,14 +2473,12 @@ button.summary-card:hover {
} }
.list-skeleton span { .list-skeleton span {
position: relative;
isolation: isolate;
min-height: 56px; min-height: 56px;
border: 1px solid #2b3342; border: 1px solid #2b3342;
border-radius: 4px; border-radius: 4px;
background: background: #131720;
linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.12), transparent),
#131720;
background-size: 220% 100%;
animation: shimmer 1.15s linear infinite;
} }
.subscription-line .ui-button { .subscription-line .ui-button {
@@ -2514,18 +2658,6 @@ button.summary-card:hover {
color: #b6c2d4; color: #b6c2d4;
} }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
50% {
opacity: 0.45;
}
}
@keyframes finder-border-top { @keyframes finder-border-top {
0% { 0% {
left: -116px; left: -116px;
@@ -2600,16 +2732,6 @@ button.summary-card:hover {
} }
} }
@keyframes shimmer {
from {
background-position: 220% 0;
}
to {
background-position: -220% 0;
}
}
@keyframes panel-swipe-right { @keyframes panel-swipe-right {
from { from {
opacity: 0; opacity: 0;
@@ -2637,12 +2759,14 @@ button.summary-card:hover {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.tab-panel-frame, .tab-panel-frame,
.tab-panel, .tab-panel,
.ui-busy-ring,
.ui-busy-ring-segment,
.ui-service-border-glow,
.finder-card .finder-border-glow, .finder-card .finder-border-glow,
.finder-border-glow-segment, .finder-border-glow-segment,
.ui-service-border-glow-segment,
.status-light, .status-light,
.summary-status-dot.checking, .summary-status-dot.checking {
.list-skeleton span,
.tile-loading {
animation: none; animation: none;
transition: none; transition: none;
} }
@@ -2662,6 +2786,18 @@ button.summary-card:hover {
padding: 12px 12px 16px; padding: 12px 12px 16px;
} }
.admin-prompt {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 8px;
}
.admin-prompt-action {
grid-column: 1 / -1;
width: 100%;
min-width: 0;
}
.app-row { .app-row {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;

View File

@@ -1,4 +1,13 @@
import { MoreHorizontal } from 'lucide-react'; import { MoreHorizontal } from 'lucide-react';
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from 'react';
import { createPortal } from 'react-dom';
import { IconButton } from './IconButton'; import { IconButton } from './IconButton';
export interface ActionMenuItem { export interface ActionMenuItem {
@@ -16,6 +25,17 @@ export interface ActionMenuProps {
disabled?: boolean; disabled?: boolean;
} }
interface ActionMenuPosition {
top: number;
left: number;
width: number;
placement: 'top' | 'bottom';
}
const MENU_WIDTH = 190;
const VIEWPORT_MARGIN = 12;
const MENU_OFFSET = 6;
export function ActionMenu({ export function ActionMenu({
open, open,
onOpenChange, onOpenChange,
@@ -23,30 +43,137 @@ export function ActionMenu({
items, items,
disabled, disabled,
}: ActionMenuProps) { }: ActionMenuProps) {
const menuId = useId();
const triggerRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<ActionMenuPosition>({
top: 0,
left: 0,
width: MENU_WIDTH,
placement: 'bottom',
});
useEffect(() => {
if (disabled && open) onOpenChange(false);
}, [disabled, onOpenChange, open]);
useLayoutEffect(() => {
if (!open) return;
const updatePosition = () => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const width = Math.min(MENU_WIDTH, Math.max(180, window.innerWidth - VIEWPORT_MARGIN * 2));
const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
const left = Math.max(
VIEWPORT_MARGIN,
Math.min(rect.right - width, window.innerWidth - width - VIEWPORT_MARGIN),
);
let top = rect.bottom + MENU_OFFSET;
let placement: ActionMenuPosition['placement'] = 'bottom';
if (
popoverHeight
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
&& rect.top > popoverHeight + VIEWPORT_MARGIN + MENU_OFFSET
) {
top = rect.top - popoverHeight - MENU_OFFSET;
placement = 'top';
}
const maxTop = popoverHeight
? window.innerHeight - popoverHeight - VIEWPORT_MARGIN
: window.innerHeight - VIEWPORT_MARGIN;
setPosition({
top: Math.max(VIEWPORT_MARGIN, Math.min(top, maxTop)),
left,
width,
placement,
});
};
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [open]);
useEffect(() => {
if (!open) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target)) return;
if (popoverRef.current?.contains(target)) return;
onOpenChange(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
onOpenChange(false);
};
document.addEventListener('pointerdown', closeOnOutsidePointer);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('pointerdown', closeOnOutsidePointer);
document.removeEventListener('keydown', closeOnEscape);
};
}, [onOpenChange, open]);
const popoverStyle = {
top: position.top,
left: position.left,
width: position.width,
} as CSSProperties;
return ( return (
<div className="ui-action-menu"> <div className="ui-action-menu" ref={triggerRef}>
<IconButton <IconButton
label={label} label={label}
icon={<MoreHorizontal size={20} strokeWidth={2} />} icon={<MoreHorizontal size={20} strokeWidth={2} />}
onClick={() => onOpenChange(!open)} onClick={() => onOpenChange(!open)}
disabled={disabled} disabled={disabled}
aria-controls={open ? menuId : undefined}
aria-expanded={open} aria-expanded={open}
aria-haspopup="menu"
/> />
{open ? ( {open && typeof document !== 'undefined' ? createPortal(
<div className="ui-action-menu-popover" role="menu"> <div
className="ui-action-menu-popover"
data-placement={position.placement}
id={menuId}
ref={popoverRef}
role="menu"
style={popoverStyle}
>
{items.map((item) => ( {items.map((item) => (
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className={item.danger ? 'is-danger' : ''} className={item.danger ? 'is-danger' : ''}
onClick={item.onClick} onClick={() => {
onOpenChange(false);
item.onClick();
}}
disabled={item.disabled} disabled={item.disabled}
key={item.label} key={item.label}
> >
{item.label} {item.label}
</button> </button>
))} ))}
</div> </div>,
document.body,
) : null} ) : null}
</div> </div>
); );

16
src/ui/BusyRing.tsx Normal file
View File

@@ -0,0 +1,16 @@
export interface BusyRingProps {
className?: string;
}
export function BusyRing({ className }: BusyRingProps) {
const classes = ['ui-busy-ring', className ?? ''].filter(Boolean).join(' ');
return (
<span className={classes} aria-hidden="true">
<span className="ui-busy-ring-segment top" />
<span className="ui-busy-ring-segment right" />
<span className="ui-busy-ring-segment bottom" />
<span className="ui-busy-ring-segment left" />
</span>
);
}

View File

@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { BusyRing } from './BusyRing';
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger'; export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg'; export type ButtonSize = 'sm' | 'md' | 'lg';
@@ -37,8 +38,10 @@ export function Button({
{...props} {...props}
className={classes} className={classes}
disabled={disabled || loading} disabled={disabled || loading}
aria-busy={loading || undefined}
> >
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : leftIcon ? ( {loading ? <BusyRing /> : null}
{!loading && leftIcon ? (
<span className="ui-button-icon" aria-hidden="true">{leftIcon}</span> <span className="ui-button-icon" aria-hidden="true">{leftIcon}</span>
) : null} ) : null}
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span> <span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>

View File

@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { BusyRing } from './BusyRing';
export type IconButtonVariant = 'neutral' | 'add' | 'danger'; export type IconButtonVariant = 'neutral' | 'add' | 'danger';
@@ -36,8 +37,10 @@ export function IconButton({
aria-label={label} aria-label={label}
data-tooltip={tooltipText} data-tooltip={tooltipText}
disabled={disabled || loading} disabled={disabled || loading}
aria-busy={loading || undefined}
> >
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon} {loading ? <BusyRing /> : null}
{icon}
</button> </button>
); );
} }

View File

@@ -1,3 +1,5 @@
import { BusyRing } from './BusyRing';
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted'; export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
export interface StatusPillProps { export interface StatusPillProps {
@@ -6,6 +8,14 @@ export interface StatusPillProps {
} }
export function StatusPill({ tone = 'muted', children }: StatusPillProps) { export function StatusPill({ tone = 'muted', children }: StatusPillProps) {
return <span className={`ui-status-pill ui-status-pill--${tone}`}>{children}</span>; return (
<span
className={`ui-status-pill ui-status-pill--${tone}`}
aria-busy={tone === 'checking' || undefined}
>
{tone === 'checking' ? <BusyRing /> : null}
{children}
</span>
);
} }

View File

@@ -1,5 +1,7 @@
export { ActionMenu } from './ActionMenu'; export { ActionMenu } from './ActionMenu';
export type { ActionMenuItem } from './ActionMenu'; export type { ActionMenuItem } from './ActionMenu';
export { BusyRing } from './BusyRing';
export type { BusyRingProps } from './BusyRing';
export { Button } from './Button'; export { Button } from './Button';
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button'; export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
export { DetailsPopover } from './DetailsPopover'; export { DetailsPopover } from './DetailsPopover';