7 Commits
0.1.1 ... 0.1.3

Author SHA1 Message Date
75f8106ca5 Merge pull request 'Merge Fixes and Technical changes from development into main branch' (#1) from development into main
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Docker Publish (Release) / Build and Push Docker Images (release) Successful in 4m30s
Reviewed-on: #1
2026-02-13 09:13:04 +00:00
4e4f8ad5d4 Update NEXAPG version to 0.1.3
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG15 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (pull_request) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (pull_request) Successful in 8s
This increments the application version from 0.1.2 to 0.1.3. It likely reflects bug fixes, improvements, or minor feature additions.
2026-02-13 10:11:00 +01:00
5c5d51350f Fix stale refresh usage in QueryInsightsPage effect hooks
Replaced `refresh` with `useRef` to ensure the latest value is always used in async operations. Added cleanup logic to handle component unmounts during API calls, preventing state updates on unmounted components.
2026-02-13 10:06:56 +01:00
ba1559e790 Improve agentless mode messaging for host-level metrics
Updated the messaging and UI to clarify unavailability of host-level metrics, such as CPU, RAM, and disk space, in agentless mode. Added clear formatting and new functions to handle missing metrics gracefully in the frontend.
2026-02-13 10:01:24 +01:00
ab9d03be8a Add GitHub Actions workflow for Docker image release
This workflow automates building and publishing Docker images upon a release or manual trigger. It includes steps for version resolution, Docker Hub login, and caching to optimize builds for both backend and frontend images.
2026-02-13 09:55:08 +01:00
07a7236282 Add user password change functionality
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 9s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 7s
Introduced a backend API endpoint for changing user passwords with validation. Added a new "User Settings" page in the frontend to allow users to update their passwords, including a matching UI update for navigation and styles.
2026-02-13 09:32:54 +01:00
bd53bce231 Add service update notification and version check enhancements
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 9s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 8s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 8s
Introduced a front-end mechanism to notify users of available service updates and enhanced the service info page to reflect update status dynamically. Removed backend audit log writes for version checks to streamline operations and improve performance. Updated styling to visually highlight update notifications.
2026-02-13 09:24:53 +01:00
13 changed files with 459 additions and 43 deletions

91
.github/workflows/docker-release.yml vendored Normal file
View File

@@ -0,0 +1,91 @@
name: Docker Publish (Release)
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: "Version tag to publish (e.g. 0.1.2 or v0.1.2)"
required: false
type: string
jobs:
publish:
name: Build and Push Docker Images
runs-on: ubuntu-latest
permissions:
contents: read
env:
# Optional repo variable. If unset, DOCKERHUB_USERNAME is used.
IMAGE_NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version/tag
id: ver
shell: bash
run: |
RAW_TAG="${{ github.event.release.tag_name }}"
if [ -z "$RAW_TAG" ]; then
RAW_TAG="${{ inputs.version }}"
fi
if [ -z "$RAW_TAG" ]; then
RAW_TAG="${GITHUB_REF_NAME}"
fi
CLEAN_TAG="${RAW_TAG#v}"
echo "raw=$RAW_TAG" >> "$GITHUB_OUTPUT"
echo "clean=$CLEAN_TAG" >> "$GITHUB_OUTPUT"
- name: Set image namespace
id: ns
shell: bash
run: |
NS="${IMAGE_NAMESPACE}"
if [ -z "$NS" ]; then
NS="${{ secrets.DOCKERHUB_USERNAME }}"
fi
if [ -z "$NS" ]; then
echo "Missing Docker Hub namespace. Set repo var DOCKERHUB_NAMESPACE or secret DOCKERHUB_USERNAME."
exit 1
fi
echo "value=$NS" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push backend image
uses: docker/build-push-action@v6
with:
context: ./backend
file: ./backend/Dockerfile
push: true
tags: |
${{ steps.ns.outputs.value }}/nexapg-backend:${{ steps.ver.outputs.clean }}
${{ steps.ns.outputs.value }}/nexapg-backend:latest
cache-from: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-backend:buildcache
cache-to: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-backend:buildcache,mode=max
- name: Build and push frontend image
uses: docker/build-push-action@v6
with:
context: ./frontend
file: ./frontend/Dockerfile
push: true
build-args: |
VITE_API_URL=/api/v1
tags: |
${{ steps.ns.outputs.value }}/nexapg-frontend:${{ steps.ver.outputs.clean }}
${{ steps.ns.outputs.value }}/nexapg-frontend:latest
cache-from: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-frontend:buildcache
cache-to: type=registry,ref=${{ steps.ns.outputs.value }}/nexapg-frontend:buildcache,mode=max

View File

@@ -1,7 +1,11 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.deps import get_current_user
from app.core.security import hash_password, verify_password
from app.models.models import User
from app.schemas.user import UserOut
from app.schemas.user import UserOut, UserPasswordChange
from app.services.audit import write_audit_log
router = APIRouter()
@@ -9,3 +13,21 @@ router = APIRouter()
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)) -> UserOut:
return UserOut.model_validate(user)
@router.post("/me/password")
async def change_password(
payload: UserPasswordChange,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> dict:
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect")
if verify_password(payload.new_password, user.password_hash):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="New password must be different")
user.password_hash = hash_password(payload.new_password)
await db.commit()
await write_audit_log(db, action="auth.password_change", user_id=user.id, payload={})
return {"status": "ok"}

View File

@@ -11,7 +11,6 @@ from app.core.db import get_db
from app.core.deps import get_current_user
from app.models.models import ServiceInfoSettings, User
from app.schemas.service_info import ServiceInfoCheckResult, ServiceInfoOut
from app.services.audit import write_audit_log
from app.services.service_info import (
UPSTREAM_REPO_WEB,
fetch_latest_from_upstream,
@@ -71,6 +70,7 @@ async def check_service_version(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ServiceInfoCheckResult:
_ = user
row = await _get_or_create_service_settings(db)
check_time = utcnow()
latest, latest_ref, error = await fetch_latest_from_upstream()
@@ -85,17 +85,6 @@ async def check_service_version(
row.update_available = False
await db.commit()
await db.refresh(row)
await write_audit_log(
db,
"service.info.check",
user.id,
{
"latest_version": row.latest_version,
"latest_ref": row.release_check_url,
"update_available": row.update_available,
"last_check_error": row.last_check_error,
},
)
return ServiceInfoCheckResult(
latest_version=row.latest_version,
latest_ref=(row.release_check_url or None),

View File

@@ -2,7 +2,7 @@ from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
NEXAPG_VERSION = "0.1.1"
NEXAPG_VERSION = "0.1.3"
class Settings(BaseSettings):

View File

@@ -1,5 +1,5 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, field_validator
class UserOut(BaseModel):
@@ -21,3 +21,15 @@ class UserUpdate(BaseModel):
email: EmailStr | None = None
password: str | None = None
role: str | None = None
class UserPasswordChange(BaseModel):
current_password: str
new_password: str
@field_validator("new_password")
@classmethod
def validate_new_password(cls, value: str) -> str:
if len(value) < 8:
raise ValueError("new_password must be at least 8 characters")
return value

View File

@@ -17,10 +17,10 @@ class DiskSpaceProvider:
class NullDiskSpaceProvider(DiskSpaceProvider):
async def get_free_bytes(self, target_host: str) -> DiskSpaceProbeResult:
return DiskSpaceProbeResult(
source="none",
source="agentless",
status="unavailable",
free_bytes=None,
message=f"No infra probe configured for host {target_host}. Add SSH/Agent provider later.",
message=f"Agentless mode: host-level free disk is not available for {target_host}.",
)

View File

@@ -9,6 +9,7 @@ import { QueryInsightsPage } from "./pages/QueryInsightsPage";
import { AlertsPage } from "./pages/AlertsPage";
import { AdminUsersPage } from "./pages/AdminUsersPage";
import { ServiceInfoPage } from "./pages/ServiceInfoPage";
import { UserSettingsPage } from "./pages/UserSettingsPage";
function Protected({ children }) {
const { tokens } = useAuth();
@@ -18,7 +19,7 @@ function Protected({ children }) {
}
function Layout({ children }) {
const { me, logout, uiMode, setUiMode, alertToasts, dismissAlertToast } = useAuth();
const { me, logout, uiMode, setUiMode, alertToasts, dismissAlertToast, serviceUpdateAvailable } = useAuth();
const navigate = useNavigate();
const navClass = ({ isActive }) => `nav-btn${isActive ? " active" : ""}`;
@@ -62,7 +63,10 @@ function Layout({ children }) {
</span>
<span className="nav-label">Alerts</span>
</NavLink>
<NavLink to="/service-info" className={navClass}>
<NavLink
to="/service-info"
className={({ isActive }) => `nav-btn${isActive ? " active" : ""}${serviceUpdateAvailable ? " update-available" : ""}`}
>
<span className="nav-icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zm0-11v6m0-10h.01" />
@@ -99,6 +103,9 @@ function Layout({ children }) {
</div>
<div>{me?.email}</div>
<div className="role">{me?.role}</div>
<NavLink to="/user-settings" className={({ isActive }) => `profile-btn${isActive ? " active" : ""}`}>
User Settings
</NavLink>
<button className="logout-btn" onClick={logout}>Logout</button>
</div>
</aside>
@@ -160,6 +167,7 @@ export function App() {
<Route path="/query-insights" element={<QueryInsightsPage />} />
<Route path="/alerts" element={<AlertsPage />} />
<Route path="/service-info" element={<ServiceInfoPage />} />
<Route path="/user-settings" element={<UserSettingsPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
</Routes>
</Layout>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { apiFetch } from "../api";
import { useAuth } from "../state";
@@ -62,6 +62,7 @@ function buildQueryTips(row) {
export function QueryInsightsPage() {
const { tokens, refresh } = useAuth();
const refreshRef = useRef(refresh);
const [targets, setTargets] = useState([]);
const [targetId, setTargetId] = useState("");
const [rows, setRows] = useState([]);
@@ -71,6 +72,10 @@ export function QueryInsightsPage() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
useEffect(() => {
(async () => {
try {
@@ -89,17 +94,26 @@ export function QueryInsightsPage() {
useEffect(() => {
if (!targetId) return;
let active = true;
(async () => {
try {
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refresh);
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refreshRef.current);
if (!active) return;
setRows(data);
setSelectedQuery(data[0] || null);
setPage(1);
setSelectedQuery((prev) => {
if (!prev) return data[0] || null;
const keep = data.find((row) => row.queryid === prev.queryid);
return keep || data[0] || null;
});
setPage((prev) => (prev === 1 ? prev : 1));
} catch (e) {
setError(String(e.message || e));
if (active) setError(String(e.message || e));
}
})();
}, [targetId, tokens, refresh]);
return () => {
active = false;
};
}, [targetId, tokens?.accessToken, tokens?.refreshToken]);
const dedupedByQueryId = [...rows].reduce((acc, row) => {
if (!row?.queryid) return acc;

View File

@@ -14,7 +14,7 @@ function formatUptime(seconds) {
}
export function ServiceInfoPage() {
const { tokens, refresh } = useAuth();
const { tokens, refresh, serviceInfo } = useAuth();
const [info, setInfo] = useState(null);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
@@ -30,6 +30,10 @@ export function ServiceInfoPage() {
load().catch((e) => setError(String(e.message || e)));
}, []);
useEffect(() => {
if (serviceInfo) setInfo(serviceInfo);
}, [serviceInfo]);
const checkNow = async () => {
try {
setBusy(true);
@@ -56,14 +60,28 @@ export function ServiceInfoPage() {
}
return (
<div>
<div className="service-page">
<h2>Service Information</h2>
<p className="muted">Runtime details, installed version, and update check status for this NexaPG instance.</p>
{error && <div className="card error">{error}</div>}
{message && <div className="test-connection-result ok">{message}</div>}
{message && <div className="test-connection-result ok service-msg">{message}</div>}
<div className={`card service-hero ${info.update_available ? "update" : "ok"}`}>
<div>
<strong className="service-hero-title">
{info.update_available ? `Update available: ${info.latest_version}` : "Service is up to date"}
</strong>
<p className="muted service-hero-sub">
Automatic release checks run every 30 seconds. Source: official NexaPG upstream releases.
</p>
</div>
<button type="button" className="secondary-btn" disabled={busy} onClick={checkNow}>
Check Now
</button>
</div>
<div className="grid three">
<div className="card">
<div className="card service-card">
<h3>Application</h3>
<div className="overview-kv">
<span>App Name</span>
@@ -74,7 +92,7 @@ export function ServiceInfoPage() {
<strong>{info.api_prefix}</strong>
</div>
</div>
<div className="card">
<div className="card service-card">
<h3>Runtime</h3>
<div className="overview-kv">
<span>Host</span>
@@ -85,7 +103,7 @@ export function ServiceInfoPage() {
<strong>{formatUptime(info.uptime_seconds)}</strong>
</div>
</div>
<div className="card">
<div className="card service-card">
<h3>Version Status</h3>
<div className="overview-kv">
<span>Current NexaPG Version</span>
@@ -93,21 +111,16 @@ export function ServiceInfoPage() {
<span>Latest Known Version</span>
<strong>{info.latest_version || "-"}</strong>
<span>Update Status</span>
<strong className={info.update_available ? "lag-bad" : "pill primary"}>
<strong className={info.update_available ? "service-status-update" : "service-status-ok"}>
{info.update_available ? "Update available" : "Up to date"}
</strong>
<span>Last Check</span>
<strong>{info.last_checked_at ? new Date(info.last_checked_at).toLocaleString() : "never"}</strong>
</div>
<div className="form-actions" style={{ marginTop: 12 }}>
<button type="button" className="secondary-btn" disabled={busy} onClick={checkNow}>
Check for Updates
</button>
</div>
</div>
</div>
<div className="card">
<div className="card service-card">
<h3>Release Source</h3>
<p className="muted">
Update checks run against the official NexaPG repository. This source is fixed in code and cannot be changed
@@ -121,7 +134,7 @@ export function ServiceInfoPage() {
</div>
</div>
<div className="card">
<div className="card service-card">
<h3>Version Control Policy</h3>
<p className="muted">
Version and update-source settings are not editable in the app. Only code maintainers of the official NexaPG

View File

@@ -41,6 +41,19 @@ function formatNumber(value, digits = 2) {
return Number(value).toFixed(digits);
}
function formatHostMetricUnavailable() {
return "N/A (agentless)";
}
function formatDiskSpaceAgentless(diskSpace) {
if (!diskSpace) return formatHostMetricUnavailable();
if (diskSpace.free_bytes !== null && diskSpace.free_bytes !== undefined) {
return formatBytes(diskSpace.free_bytes);
}
if (diskSpace.status === "unavailable") return formatHostMetricUnavailable();
return "-";
}
function MetricsTooltip({ active, payload, label }) {
if (!active || !payload || payload.length === 0) return null;
const row = payload[0]?.payload || {};
@@ -346,6 +359,9 @@ export function TargetDetailPage() {
{uiMode === "dba" && overview && (
<div className="card">
<h3>Database Overview</h3>
<p className="muted" style={{ marginTop: 2 }}>
Agentless mode: host-level CPU, RAM, and free-disk metrics are not available.
</p>
<div className="grid three overview-kv">
<div><span>PostgreSQL Version</span><strong>{overview.instance.server_version || "-"}</strong></div>
<div>
@@ -366,8 +382,8 @@ export function TargetDetailPage() {
<div title="Total WAL directory size (when available)">
<span>WAL Size</span><strong>{formatBytes(overview.storage.wal_directory_size_bytes)}</strong>
</div>
<div title="Optional metric via future Agent/SSH provider">
<span>Free Disk</span><strong>{formatBytes(overview.storage.disk_space.free_bytes)}</strong>
<div title={overview.storage.disk_space?.message || "Agentless mode: host-level free disk is unavailable."}>
<span>Free Disk</span><strong>{formatDiskSpaceAgentless(overview.storage.disk_space)}</strong>
</div>
<div title="Replication replay delay on standby">
<span>Replay Lag</span>
@@ -378,6 +394,12 @@ export function TargetDetailPage() {
<div><span>Replication Slots</span><strong>{overview.replication.replication_slots_count ?? "-"}</strong></div>
<div><span>Repl Clients</span><strong>{overview.replication.active_replication_clients ?? "-"}</strong></div>
<div><span>Autovacuum Workers</span><strong>{overview.performance.autovacuum_workers ?? "-"}</strong></div>
<div title="Host CPU requires OS-level telemetry">
<span>Host CPU</span><strong>{formatHostMetricUnavailable()}</strong>
</div>
<div title="Host RAM requires OS-level telemetry">
<span>Host RAM</span><strong>{formatHostMetricUnavailable()}</strong>
</div>
</div>
<div className="grid two">

View File

@@ -0,0 +1,100 @@
import React, { useState } from "react";
import { apiFetch } from "../api";
import { useAuth } from "../state";
export function UserSettingsPage() {
const { tokens, refresh } = useAuth();
const [form, setForm] = useState({
current_password: "",
new_password: "",
confirm_password: "",
});
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
setMessage("");
setError("");
if (form.new_password.length < 8) {
setError("New password must be at least 8 characters.");
return;
}
if (form.new_password !== form.confirm_password) {
setError("Password confirmation does not match.");
return;
}
try {
setBusy(true);
await apiFetch(
"/me/password",
{
method: "POST",
body: JSON.stringify({
current_password: form.current_password,
new_password: form.new_password,
}),
},
tokens,
refresh
);
setForm({ current_password: "", new_password: "", confirm_password: "" });
setMessage("Password changed successfully.");
} catch (e) {
setError(String(e.message || e));
} finally {
setBusy(false);
}
};
return (
<div className="user-settings-page">
<h2>User Settings</h2>
<p className="muted">Manage your personal account security settings.</p>
{error && <div className="card error">{error}</div>}
{message && <div className="test-connection-result ok">{message}</div>}
<div className="card user-settings-card">
<h3>Change Password</h3>
<form className="grid two" onSubmit={submit}>
<div className="admin-field field-full">
<label>Current password</label>
<input
type="password"
value={form.current_password}
onChange={(e) => setForm({ ...form, current_password: e.target.value })}
required
/>
</div>
<div className="admin-field">
<label>New password</label>
<input
type="password"
value={form.new_password}
onChange={(e) => setForm({ ...form, new_password: e.target.value })}
minLength={8}
required
/>
</div>
<div className="admin-field">
<label>Confirm new password</label>
<input
type="password"
value={form.confirm_password}
onChange={(e) => setForm({ ...form, confirm_password: e.target.value })}
minLength={8}
required
/>
</div>
<div className="form-actions field-full">
<button className="primary-btn" type="submit" disabled={busy}>
{busy ? "Saving..." : "Update Password"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -29,6 +29,7 @@ export function AuthProvider({ children }) {
const [uiMode, setUiModeState] = useState(loadUiMode);
const [alertStatus, setAlertStatus] = useState({ warnings: [], alerts: [], warning_count: 0, alert_count: 0 });
const [alertToasts, setAlertToasts] = useState([]);
const [serviceInfo, setServiceInfo] = useState(null);
const knownAlertKeysRef = useRef(new Set());
const hasAlertSnapshotRef = useRef(false);
@@ -175,6 +176,49 @@ export function AuthProvider({ children }) {
};
}, [tokens?.accessToken, tokens?.refreshToken]);
useEffect(() => {
if (!tokens?.accessToken) {
setServiceInfo(null);
return;
}
let mounted = true;
const request = async (path, method = "GET") => {
const doFetch = async (accessToken) =>
fetch(`${API_URL}${path}`, {
method,
headers: { Authorization: `Bearer ${accessToken}` },
});
let res = await doFetch(tokens.accessToken);
if (res.status === 401 && tokens.refreshToken) {
const refreshed = await refresh();
if (refreshed?.accessToken) {
res = await doFetch(refreshed.accessToken);
}
}
if (!res.ok) return null;
return res.json();
};
const runServiceCheck = async () => {
await request("/service/info/check", "POST");
const info = await request("/service/info", "GET");
if (mounted && info) setServiceInfo(info);
};
runServiceCheck().catch(() => {});
const timer = setInterval(() => {
runServiceCheck().catch(() => {});
}, 30000);
return () => {
mounted = false;
clearInterval(timer);
};
}, [tokens?.accessToken, tokens?.refreshToken]);
const setUiMode = (nextMode) => {
const mode = nextMode === "easy" ? "easy" : "dba";
setUiModeState(mode);
@@ -193,8 +237,10 @@ export function AuthProvider({ children }) {
alertStatus,
alertToasts,
dismissAlertToast,
serviceInfo,
serviceUpdateAvailable: !!serviceInfo?.update_available,
}),
[tokens, me, uiMode, alertStatus, alertToasts]
[tokens, me, uiMode, alertStatus, alertToasts, serviceInfo]
);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}

View File

@@ -114,6 +114,27 @@ a {
background: linear-gradient(180deg, #74e8ff, #25bdf3);
}
.nav-btn.update-available {
border-color: #c7962f;
background: linear-gradient(180deg, #3e2f14, #2f240f);
color: #ffecc4;
box-shadow: inset 0 0 0 1px #f6c75a38, 0 8px 20px #2d1d0680;
}
.nav-btn.update-available .nav-icon {
border-color: #d3a240;
background: linear-gradient(180deg, #5a441a, #433312);
}
.nav-btn.update-available:hover {
border-color: #ffd46e;
background: linear-gradient(180deg, #523d18, #3b2d12);
}
.nav-btn.update-available::before {
background: linear-gradient(180deg, #ffe4a3, #e0ac3e);
}
.nav-btn.admin-nav {
border-color: #5b4da1;
background: linear-gradient(180deg, #1c2a58, #18224a);
@@ -1094,6 +1115,39 @@ button {
border-color: #38bdf8;
}
.profile-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 8px;
border: 1px solid #3a63a1;
border-radius: 10px;
background: linear-gradient(180deg, #15315d, #11274c);
color: #e7f2ff;
min-height: 40px;
font-weight: 650;
}
.profile-btn:hover {
border-color: #58b0e8;
background: linear-gradient(180deg, #1a427a, #15335f);
}
.profile-btn.active {
border-color: #66c7f4;
box-shadow: inset 0 0 0 1px #66c7f455;
}
.user-settings-page h2 {
margin-top: 4px;
margin-bottom: 4px;
}
.user-settings-card {
max-width: 760px;
}
table {
width: 100%;
border-collapse: collapse;
@@ -1279,6 +1333,51 @@ td {
color: #9eb8d6;
}
.service-page .service-msg {
margin-bottom: 10px;
}
.service-hero {
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.service-hero.ok {
border-color: #2f8f63;
background: linear-gradient(90deg, #123827, #102e42);
}
.service-hero.update {
border-color: #dfab3e;
background: linear-gradient(90deg, #4a3511, #2f2452);
box-shadow: 0 12px 28px #2b1f066b;
}
.service-hero-title {
display: inline-block;
font-size: 18px;
margin-bottom: 3px;
}
.service-hero-sub {
margin: 0;
}
.service-card {
box-shadow: 0 10px 24px #0416343d;
}
.service-status-ok {
color: #6ef0ad;
}
.service-status-update {
color: #ffd77e;
}
.alerts-subtitle {
margin-top: 2px;
color: #a6c0df;