feat: habilitar provisionamento desktop e rotas CORS
This commit is contained in:
parent
7569986ffc
commit
152550a9a0
19 changed files with 1806 additions and 211 deletions
|
|
@ -1,6 +1,67 @@
|
|||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { Store } from "@tauri-apps/plugin-store"
|
||||
|
||||
type MachineOs = {
|
||||
name: string
|
||||
version?: string | null
|
||||
architecture?: string | null
|
||||
}
|
||||
|
||||
type MachineMetrics = {
|
||||
collectedAt: string
|
||||
cpuLogicalCores: number
|
||||
cpuPhysicalCores?: number | null
|
||||
cpuUsagePercent: number
|
||||
loadAverageOne?: number | null
|
||||
loadAverageFive?: number | null
|
||||
loadAverageFifteen?: number | null
|
||||
memoryTotalBytes: number
|
||||
memoryUsedBytes: number
|
||||
memoryUsedPercent: number
|
||||
uptimeSeconds: number
|
||||
}
|
||||
|
||||
type MachineInventory = {
|
||||
cpuBrand?: string | null
|
||||
hostIdentifier?: string | null
|
||||
}
|
||||
|
||||
type MachineProfile = {
|
||||
hostname: string
|
||||
os: MachineOs
|
||||
macAddresses: string[]
|
||||
serialNumbers: string[]
|
||||
inventory: MachineInventory
|
||||
metrics: MachineMetrics
|
||||
}
|
||||
|
||||
type MachineRegisterResponse = {
|
||||
machineId: string
|
||||
tenantId?: string | null
|
||||
companyId?: string | null
|
||||
companySlug?: string | null
|
||||
machineToken: string
|
||||
machineEmail?: string | null
|
||||
expiresAt?: number | null
|
||||
}
|
||||
|
||||
type AgentConfig = {
|
||||
machineId: string
|
||||
machineToken: string
|
||||
tenantId?: string | null
|
||||
companySlug?: string | null
|
||||
machineEmail?: string | null
|
||||
apiBaseUrl: string
|
||||
appUrl: string
|
||||
createdAt: number
|
||||
lastSyncedAt?: number | null
|
||||
expiresAt?: number | null
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_URL?: string
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
@ -8,23 +69,370 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
const DEFAULT_URL = "http://localhost:3000";
|
||||
const STORE_FILENAME = "machine-agent.json"
|
||||
const DEFAULT_APP_URL = "http://localhost:3000"
|
||||
|
||||
function resolveTargetUrl() {
|
||||
const fromEnv = import.meta?.env?.VITE_APP_URL;
|
||||
if (fromEnv && fromEnv.trim().length > 0) {
|
||||
return fromEnv.trim();
|
||||
function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) {
|
||||
const trimmed = (value ?? fallback).trim()
|
||||
if (!trimmed.startsWith("http")) {
|
||||
return fallback
|
||||
}
|
||||
return DEFAULT_URL;
|
||||
return trimmed.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function bootstrap() {
|
||||
const targetUrl = resolveTargetUrl();
|
||||
if (!targetUrl.startsWith("http")) {
|
||||
console.error("URL inválida para o app desktop:", targetUrl);
|
||||
return;
|
||||
const appUrl = normalizeUrl(import.meta.env.VITE_APP_URL, DEFAULT_APP_URL)
|
||||
const apiBaseUrl = normalizeUrl(import.meta.env.VITE_API_BASE_URL, appUrl)
|
||||
|
||||
const alertElement = document.getElementById("alert-container") as HTMLDivElement | null
|
||||
const contentElement = document.getElementById("content") as HTMLDivElement | null
|
||||
const statusElement = document.getElementById("status-text") as HTMLParagraphElement | null
|
||||
|
||||
function setAlert(message: string | null, variant: "info" | "error" | "success" = "info") {
|
||||
if (!alertElement) return
|
||||
if (!message) {
|
||||
alertElement.textContent = ""
|
||||
alertElement.className = "alert"
|
||||
return
|
||||
}
|
||||
window.location.replace(targetUrl);
|
||||
alertElement.textContent = message
|
||||
const extra = variant === "info" ? "" : ` ${variant}`
|
||||
alertElement.className = `alert visible${extra}`
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", bootstrap);
|
||||
function setStatus(message: string) {
|
||||
if (statusElement) {
|
||||
statusElement.textContent = message
|
||||
}
|
||||
}
|
||||
|
||||
const store = new Store(STORE_FILENAME)
|
||||
let storeLoaded = false
|
||||
|
||||
async function ensureStoreLoaded() {
|
||||
if (!storeLoaded) {
|
||||
try {
|
||||
await store.load()
|
||||
} catch (error) {
|
||||
console.error("[agent] Falha ao carregar store", error)
|
||||
}
|
||||
storeLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig(): Promise<AgentConfig | null> {
|
||||
try {
|
||||
await ensureStoreLoaded()
|
||||
const record = await store.get<AgentConfig>("config")
|
||||
if (!record) return null
|
||||
return record
|
||||
} catch (error) {
|
||||
console.error("[agent] Falha ao recuperar configuração", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(config: AgentConfig) {
|
||||
await ensureStoreLoaded()
|
||||
await store.set("config", config)
|
||||
await store.save()
|
||||
}
|
||||
|
||||
async function clearConfig() {
|
||||
await ensureStoreLoaded()
|
||||
await store.delete("config")
|
||||
await store.save()
|
||||
}
|
||||
|
||||
async function collectMachineProfile(): Promise<MachineProfile> {
|
||||
return await invoke<MachineProfile>("collect_machine_profile")
|
||||
}
|
||||
|
||||
async function startHeartbeat(config: AgentConfig) {
|
||||
await invoke("start_machine_agent", {
|
||||
baseUrl: config.apiBaseUrl,
|
||||
token: config.machineToken,
|
||||
status: "online",
|
||||
intervalSeconds: 300,
|
||||
})
|
||||
}
|
||||
|
||||
async function stopHeartbeat() {
|
||||
await invoke("stop_machine_agent")
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!bytes || Number.isNaN(bytes)) return "—"
|
||||
const units = ["B", "KB", "MB", "GB", "TB"]
|
||||
let value = bytes
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index += 1
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
if (Number.isNaN(value)) return "—"
|
||||
return `${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatDate(timestamp?: number | null) {
|
||||
if (!timestamp) return "—"
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString()
|
||||
} catch {
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
|
||||
function renderMachineSummary(profile: MachineProfile) {
|
||||
if (!contentElement) return
|
||||
const macs = profile.macAddresses.length > 0 ? profile.macAddresses.join(", ") : "—"
|
||||
const serials = profile.serialNumbers.length > 0 ? profile.serialNumbers.join(", ") : "—"
|
||||
const metrics = profile.metrics
|
||||
const lastCollection = metrics.collectedAt ? new Date(metrics.collectedAt).toLocaleString() : "—"
|
||||
|
||||
return `
|
||||
<div class="machine-summary">
|
||||
<div><strong>Hostname:</strong> ${profile.hostname}</div>
|
||||
<div><strong>Sistema:</strong> ${profile.os.name}${profile.os.version ? ` ${profile.os.version}` : ""} (${profile.os.architecture ?? "?"})</div>
|
||||
<div><strong>Endereços MAC:</strong> ${macs}</div>
|
||||
<div><strong>Identificadores:</strong> ${serials}</div>
|
||||
<div><strong>CPU:</strong> ${metrics.cpuPhysicalCores ?? metrics.cpuLogicalCores} núcleos · uso ${formatPercent(metrics.cpuUsagePercent)}</div>
|
||||
<div><strong>Memória:</strong> ${formatBytes(metrics.memoryUsedBytes)} / ${formatBytes(metrics.memoryTotalBytes)} (${formatPercent(metrics.memoryUsedPercent)})</div>
|
||||
<div><strong>Coletado em:</strong> ${lastCollection}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderRegistered(config: AgentConfig) {
|
||||
if (!contentElement) return
|
||||
const summaryHtml = `
|
||||
<div class="machine-summary">
|
||||
<div><strong>ID da máquina:</strong> ${config.machineId}</div>
|
||||
<div><strong>Email vinculado:</strong> ${config.machineEmail ?? "—"}</div>
|
||||
<div><strong>Tenant:</strong> ${config.tenantId ?? "padrão"}</div>
|
||||
<div><strong>Empresa:</strong> ${config.companySlug ?? "não vinculada"}</div>
|
||||
<div><strong>Token expira em:</strong> ${formatDate(config.expiresAt)}</div>
|
||||
<div><strong>Última sincronização:</strong> ${formatDate(config.lastSyncedAt)}</div>
|
||||
<div><strong>Ambiente:</strong> ${config.appUrl}</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
contentElement.innerHTML = `
|
||||
<p>Esta máquina já está provisionada e com heartbeat ativo.</p>
|
||||
${summaryHtml}
|
||||
<div class="actions">
|
||||
<button id="open-app">Abrir sistema</button>
|
||||
<button class="secondary" id="reset-agent">Reprovisionar</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const openButton = document.getElementById("open-app")
|
||||
const resetButton = document.getElementById("reset-agent")
|
||||
|
||||
openButton?.addEventListener("click", () => redirectToApp(config))
|
||||
resetButton?.addEventListener("click", async () => {
|
||||
await stopHeartbeat().catch(() => undefined)
|
||||
await clearConfig()
|
||||
setAlert("Configuração removida. Reiniciando fluxo de provisionamento.", "success")
|
||||
setTimeout(() => window.location.reload(), 600)
|
||||
})
|
||||
|
||||
setStatus("Máquina provisionada. Redirecionando para a interface web…")
|
||||
setTimeout(() => redirectToApp(config), 1500)
|
||||
}
|
||||
|
||||
function renderProvisionForm(profile: MachineProfile) {
|
||||
if (!contentElement) return
|
||||
|
||||
const summary = renderMachineSummary(profile) ?? ""
|
||||
|
||||
contentElement.innerHTML = `
|
||||
<form id="provision-form">
|
||||
<label>
|
||||
Código de provisionamento
|
||||
<input type="password" name="provisioningSecret" placeholder="Insira o código fornecido" required autocomplete="one-time-code" />
|
||||
</label>
|
||||
<label>
|
||||
Tenant (opcional)
|
||||
<span class="optional">Use apenas se houver múltiplos ambientes</span>
|
||||
<input type="text" name="tenantId" placeholder="Ex.: tenant-atlas" />
|
||||
</label>
|
||||
<label>
|
||||
Empresa (slug opcional)
|
||||
<span class="optional">Informe para vincular à empresa correta</span>
|
||||
<input type="text" name="companySlug" placeholder="Ex.: empresa-exemplo" />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Registrar máquina</button>
|
||||
<button type="button" class="secondary" id="refresh-profile">Atualizar dados</button>
|
||||
</div>
|
||||
</form>
|
||||
${summary}
|
||||
`
|
||||
|
||||
const form = document.getElementById("provision-form") as HTMLFormElement | null
|
||||
const refreshButton = document.getElementById("refresh-profile")
|
||||
|
||||
form?.addEventListener("submit", (event) => {
|
||||
event.preventDefault()
|
||||
handleRegister(profile, form)
|
||||
})
|
||||
|
||||
refreshButton?.addEventListener("click", async () => {
|
||||
setStatus("Recolhendo informações atualizadas da máquina…")
|
||||
try {
|
||||
const updatedProfile = await collectMachineProfile()
|
||||
renderProvisionForm(updatedProfile)
|
||||
setStatus("Dados atualizados. Revise e confirme o provisionamento.")
|
||||
} catch (error) {
|
||||
console.error("[agent] Falha ao atualizar perfil da máquina", error)
|
||||
setAlert("Não foi possível atualizar as informações da máquina.", "error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleRegister(profile: MachineProfile, form: HTMLFormElement) {
|
||||
const submitButton = form.querySelector("button[type=submit]") as HTMLButtonElement | null
|
||||
const formData = new FormData(form)
|
||||
const provisioningSecret = (formData.get("provisioningSecret") as string | null)?.trim()
|
||||
const tenantId = (formData.get("tenantId") as string | null)?.trim()
|
||||
const companySlug = (formData.get("companySlug") as string | null)?.trim()
|
||||
|
||||
if (!provisioningSecret) {
|
||||
setAlert("Informe o código de provisionamento.", "error")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true
|
||||
}
|
||||
setAlert(null)
|
||||
setStatus("Enviando dados de registro da máquina…")
|
||||
|
||||
const payload = {
|
||||
provisioningSecret,
|
||||
tenantId: tenantId && tenantId.length > 0 ? tenantId : undefined,
|
||||
companySlug: companySlug && companySlug.length > 0 ? companySlug : undefined,
|
||||
hostname: profile.hostname,
|
||||
os: profile.os,
|
||||
macAddresses: profile.macAddresses,
|
||||
serialNumbers: profile.serialNumbers,
|
||||
metadata: {
|
||||
inventory: profile.inventory,
|
||||
metrics: profile.metrics,
|
||||
},
|
||||
registeredBy: "desktop-agent",
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/machines/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Falha ao registrar máquina (${response.status})`
|
||||
try {
|
||||
const errorBody = await response.json()
|
||||
if (errorBody?.error) {
|
||||
message = errorBody.error
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MachineRegisterResponse
|
||||
const config: AgentConfig = {
|
||||
machineId: data.machineId,
|
||||
machineToken: data.machineToken,
|
||||
tenantId: data.tenantId ?? null,
|
||||
companySlug: data.companySlug ?? null,
|
||||
machineEmail: data.machineEmail ?? null,
|
||||
apiBaseUrl,
|
||||
appUrl,
|
||||
createdAt: Date.now(),
|
||||
lastSyncedAt: Date.now(),
|
||||
expiresAt: data.expiresAt ?? null,
|
||||
}
|
||||
|
||||
await saveConfig(config)
|
||||
await startHeartbeat(config)
|
||||
|
||||
setAlert("Máquina registrada com sucesso! Abrindo a interface web…", "success")
|
||||
setStatus("Autenticando dispositivo e abrindo o Sistema de Chamados.")
|
||||
|
||||
setTimeout(() => redirectToApp(config), 800)
|
||||
} catch (error) {
|
||||
console.error("[agent] Erro no registro da máquina", error)
|
||||
const message = error instanceof Error ? error.message : "Erro desconhecido ao registrar a máquina."
|
||||
const normalized = message.toLowerCase()
|
||||
if (normalized.includes("failed to fetch") || normalized.includes("load failed") || normalized.includes("network")) {
|
||||
setAlert("Não foi possível se conectar ao servidor. Verifique a conexão e o endereço configurado.", "error")
|
||||
} else if (normalized.includes("401") || normalized.includes("403") || normalized.includes("código de provisionamento inválido")) {
|
||||
setAlert("Código de provisionamento inválido. Confirme o segredo configurado no servidor e tente novamente.", "error")
|
||||
} else {
|
||||
setAlert(message, "error")
|
||||
}
|
||||
setStatus("Revise os dados e tente novamente.")
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function redirectToApp(config: AgentConfig) {
|
||||
const url = `${config.appUrl}/machines/handshake?token=${encodeURIComponent(config.machineToken)}`
|
||||
window.location.replace(url)
|
||||
}
|
||||
|
||||
async function ensureHeartbeat(config: AgentConfig): Promise<AgentConfig> {
|
||||
const adjustedConfig = {
|
||||
...config,
|
||||
apiBaseUrl,
|
||||
appUrl,
|
||||
lastSyncedAt: Date.now(),
|
||||
}
|
||||
|
||||
await saveConfig(adjustedConfig)
|
||||
await startHeartbeat(adjustedConfig)
|
||||
|
||||
return adjustedConfig
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
setStatus("Iniciando agente desktop…")
|
||||
setAlert(null)
|
||||
|
||||
try {
|
||||
const stored = await loadConfig()
|
||||
if (stored?.machineToken) {
|
||||
const updated = await ensureHeartbeat(stored)
|
||||
renderRegistered(updated)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[agent] Falha ao iniciar com configuração existente", error)
|
||||
setAlert("Não foi possível carregar a configuração armazenada. Você poderá reprovisionar abaixo.", "error")
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus("Coletando informações básicas da máquina…")
|
||||
const profile = await collectMachineProfile()
|
||||
renderProvisionForm(profile)
|
||||
setStatus("Informe o código de provisionamento para registrar esta máquina.")
|
||||
} catch (error) {
|
||||
console.error("[agent] Falha ao coletar dados da máquina", error)
|
||||
setAlert("Não foi possível coletar dados da máquina. Verifique permissões do sistema e tente novamente.", "error")
|
||||
setStatus("Interação necessária para continuar.")
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void bootstrap()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,116 +1,235 @@
|
|||
.logo.vite:hover {
|
||||
filter: drop-shadow(0 0 2em #747bff);
|
||||
}
|
||||
|
||||
.logo.typescript:hover {
|
||||
filter: drop-shadow(0 0 2em #2d79c7);
|
||||
}
|
||||
:root {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
color-scheme: light dark;
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #f6f6f6;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
line-height: 1.5;
|
||||
background-color: #f1f5f9;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
padding-top: 10vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: 0.75s;
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.logo.tauri:hover {
|
||||
filter: drop-shadow(0 0 2em #24c8db);
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: radial-gradient(circle at top, rgba(59, 130, 246, 0.12), transparent 60%),
|
||||
radial-gradient(circle at bottom, rgba(16, 185, 129, 0.12), transparent 55%);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.card {
|
||||
width: min(440px, 100%);
|
||||
background-color: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 60px rgba(15, 23, 42, 0.16);
|
||||
padding: 28px;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
a {
|
||||
.card header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: #475569;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-top: 16px;
|
||||
font-size: 0.95rem;
|
||||
color: #0f172a;
|
||||
background-color: #e0f2fe;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alert.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
background-color: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.alert.success {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
label span.optional {
|
||||
font-weight: 400;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #0f0f0f;
|
||||
background-color: #ffffff;
|
||||
transition: border-color 0.25s;
|
||||
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
|
||||
select {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.6);
|
||||
font-size: 1rem;
|
||||
background-color: rgba(241, 245, 249, 0.8);
|
||||
color: inherit;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #396cd8;
|
||||
}
|
||||
button:active {
|
||||
border-color: #396cd8;
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
#greet-input {
|
||||
margin-right: 5px;
|
||||
button {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background-color: #2563eb;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: none;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background-color: #1d4ed8;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.machine-summary {
|
||||
margin-top: 18px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background-color: rgba(15, 23, 42, 0.05);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.machine-summary strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
margin-top: 16px;
|
||||
font-size: 0.95rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(37, 99, 235, 0.14);
|
||||
border-top-color: #2563eb;
|
||||
animation: spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #f6f6f6;
|
||||
background-color: #2f2f2f;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #24c8db;
|
||||
.card {
|
||||
background-color: rgba(15, 23, 42, 0.75);
|
||||
color: #e2e8f0;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.alert {
|
||||
background-color: rgba(37, 99, 235, 0.16);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
background-color: rgba(248, 113, 113, 0.2);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.alert.success {
|
||||
background-color: rgba(34, 197, 94, 0.18);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
color: #ffffff;
|
||||
background-color: #0f0f0f98;
|
||||
select {
|
||||
background-color: rgba(15, 23, 42, 0.5);
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
button:active {
|
||||
background-color: #0f0f0f69;
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.32);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.machine-summary {
|
||||
background-color: rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: #cbd5f5;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue