Add admin restart flow and loading indicators
This commit is contained in:
@@ -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!(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
16
src/ui/BusyRing.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user