mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
refactor: consolidate frontend reuse and eliminate lint warnings
This commit is contained in:
1 parent
f490d75bee
commit
92a3ffe6c3
448 files changed
+6371
-6822
No files matched your search
@@ -16,7 +16,9 @@ cd frontend || exit 0
|
||||
[ -d node_modules ] || exit 0
|
||||
|
||||
rel=$(echo "$files" | sed 's#^frontend/##')
|
||||
echo "$rel" | xargs npx eslint
|
||||
# Keep each invocation below Windows' command-line limit on broad migrations.
|
||||
# xargs still returns a failure if any batch fails.
|
||||
echo "$rel" | xargs -n 20 npx eslint
|
||||
status=$?
|
||||
|
||||
# Theme-token guard (plan 60): whitelist/validators/alias expansion/seed themes.
|
||||
|
||||
@@ -254,3 +254,22 @@
|
||||
}
|
||||
.ra-svc__right { width: 100%; justify-content: space-between; }
|
||||
}
|
||||
|
||||
// Semantic layouts and controls; declarations preserved from the legacy compatibility layer.
|
||||
.ra-service-form > * + * { margin-top: 1rem; }
|
||||
.ra-service-field > * + * { margin-top: 0.375rem; }
|
||||
.ra-service-hint { font-size: 0.75rem; color: $text-tertiary; }
|
||||
.ra-service-routing { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.75rem; }
|
||||
@media (min-width: $breakpoint-sm) {
|
||||
.ra-service-routing { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: $breakpoint-sm) {
|
||||
.ra-service-field--host { grid-column: span 2 / span 2; }
|
||||
}
|
||||
.ra-service-field--host > * + * { margin-top: 0.375rem; }
|
||||
.ra-service-option { display: flex; align-items: center; justify-content: space-between; }
|
||||
.ra-service-credentials { display: grid; grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 0.75rem; }
|
||||
@media (min-width: $breakpoint-sm) {
|
||||
.ra-service-credentials { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
.ra-service-description { font-size: 0.875rem; color: $text-tertiary; }
|
||||
@@ -409,18 +409,18 @@ export function WalkthroughStudioPage() {
|
||||
<div className="wts-library__list">
|
||||
{loading && <p className="wts-muted">{t('common.loading', 'Loading…')}</p>}
|
||||
{!loading && library.length === 0 && (
|
||||
<button type="button" className="wts-library__empty" onClick={newGuide}>
|
||||
<Button variant="unstyled" type="button" className="wts-library__empty" onClick={newGuide}>
|
||||
<Sparkles size={18} />
|
||||
<strong>{t('walkthroughStudio.createFirst', 'Create the first custom guide')}</strong>
|
||||
<span>{t('walkthroughStudio.noCodeRequired', 'No application code required')}</span>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{library.map((guide) => (
|
||||
<div key={guide.id} className={`wts-library__item${originalId === guide.id ? ' is-active' : ''}`}>
|
||||
<button type="button" onClick={() => selectGuide(guide)}>
|
||||
<Button variant="unstyled" type="button" onClick={() => selectGuide(guide)}>
|
||||
<strong>{guide.title}</strong>
|
||||
<span>{guide.steps.length} {t('walkthroughStudio.steps', 'steps')}</span>
|
||||
</button>
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => deleteGuide(guide)} aria-label={t('common.actions.delete', 'Delete')}>
|
||||
<Trash2 size={13} />
|
||||
</Button>
|
||||
@@ -486,7 +486,7 @@ export function WalkthroughStudioPage() {
|
||||
<div className="wts-step-workbench">
|
||||
<div className="wts-step-rail">
|
||||
{draft.steps.map((step, index) => (
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
key={`${step.id}-${index}`}
|
||||
type="button"
|
||||
className={index === stepIndex ? 'is-active' : ''}
|
||||
@@ -496,7 +496,7 @@ export function WalkthroughStudioPage() {
|
||||
<span>{String(index + 1).padStart(2, '0')}</span>
|
||||
<strong>{step.title || t('walkthroughStudio.untitledStep', 'Untitled step')}</strong>
|
||||
<small>{step.completion?.type || 'manual'}</small>
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import globals from 'globals';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import noStaticInlineStyles from './scripts/eslint-rules/no-static-inline-styles.mjs';
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist/**', 'node_modules/**'] },
|
||||
@@ -26,6 +27,7 @@ export default [
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
serverkit: { rules: { 'no-static-inline-styles': noStaticInlineStyles } },
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
@@ -35,21 +37,18 @@ export default [
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'react/prop-types': 'off',
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'serverkit/no-static-inline-styles': 'warn',
|
||||
|
||||
// Discourage inline styles — prefer SCSS classes and shared components.
|
||||
// Prefer shared controls; static styles are checked separately above.
|
||||
'no-restricted-syntax': [
|
||||
'warn',
|
||||
{
|
||||
selector: 'JSXAttribute[name.name="style"]',
|
||||
message: 'Inline styles are discouraged. Use SCSS classes or a shared primitive instead.',
|
||||
},
|
||||
{
|
||||
selector: 'JSXOpeningElement[name.name="button"]',
|
||||
message: 'Use the shared Button component (or IconButton for icon-only actions).',
|
||||
},
|
||||
{
|
||||
// Match the legacy card family (.card, .card-header, .card-body, …) as a
|
||||
// LEADING class token — not unrelated compounds like `settings-card`,
|
||||
// class token — not unrelated compounds like `settings-card`,
|
||||
// `sk-spec-card`, or `wp-site-card-skeleton`, which the old `\bcard\b`
|
||||
// pattern flagged as false positives.
|
||||
selector: 'JSXOpeningElement[name.name="div"] > JSXAttribute[name.name="className"] > Literal[value=/(^|\\s)card(\\s|$|-)/]',
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "node --test",
|
||||
"test:browser": "node scripts/settings-browser.mjs",
|
||||
"lint": "eslint . && node scripts/check-font-integrity.mjs && node scripts/check-settings-index.mjs && node scripts/check-theme-tokens.mjs && node scripts/check-frontend-boundaries.mjs && node scripts/check-style-ownership.mjs && node scripts/check-status-one-door.mjs && node scripts/check-status-scss.mjs && node scripts/check-i18n-literals.mjs && node scripts/check-intl-door.mjs && node scripts/extract-i18n.mjs --check && node scripts/check-i18n.mjs && node scripts/check-logical-properties.mjs && node scripts/generate-vendor-shims.mjs --check && node ../scripts/check-html-sinks.mjs",
|
||||
"test:browser": "node scripts/settings-browser.mjs",
|
||||
"lint": "node scripts/lint.mjs && node scripts/check-font-integrity.mjs && node scripts/check-settings-index.mjs && node scripts/check-theme-tokens.mjs && node scripts/check-frontend-boundaries.mjs && node scripts/check-style-ownership.mjs && node scripts/check-status-one-door.mjs && node scripts/check-status-scss.mjs && node scripts/check-i18n-literals.mjs && node scripts/check-intl-door.mjs && node scripts/extract-i18n.mjs --check && node scripts/check-i18n.mjs && node scripts/check-logical-properties.mjs && node scripts/generate-vendor-shims.mjs --check && node ../scripts/check-html-sinks.mjs",
|
||||
"lint:baseline": "node scripts/lint.mjs --update",
|
||||
"test:controls": "node scripts/controls-browser.mjs",
|
||||
"test:metrics": "node scripts/metrics-browser.mjs",
|
||||
"test:hooks": "node scripts/hooks-browser.mjs",
|
||||
"lint:settings-index": "node scripts/check-settings-index.mjs",
|
||||
"lint:fonts": "node scripts/check-font-integrity.mjs",
|
||||
"lint:status": "node scripts/check-status-one-door.mjs",
|
||||
|
||||
@@ -86,9 +86,9 @@ export const PluginSlot = m.PluginSlot;
|
||||
export const ResourceListPage = m.ResourceListPage;
|
||||
export const useLogsDrawer = m.useLogsDrawer;
|
||||
export const RepoConnectForm = m.RepoConnectForm;
|
||||
export const RepoProviderStrip = m.RepoProviderStrip;
|
||||
export const GIT_PROVIDERS = m.GIT_PROVIDERS;
|
||||
export const detectProvider = m.detectProvider;
|
||||
export const RepoProviderStrip = m.RepoProviderStrip;
|
||||
export const ProtectionPanel = m.ProtectionPanel;
|
||||
export const DataTableFooter = m.DataTableFooter;
|
||||
export const ListToolbar = m.ListToolbar;
|
||||
|
||||
@@ -1 +1 @@
|
||||
114
|
||||
0
|
||||
@@ -92,8 +92,7 @@ const LEGACY_POLLERS = new Map(Object.entries({
|
||||
'components/dashboard/widgets/renderers.jsx': 1,
|
||||
'components/deploy-console/SuccessBanner.jsx': 1,
|
||||
'components/server/OnboardingWizard.jsx': 1,
|
||||
'hooks/useMetrics.js': 1,
|
||||
'pages/Dashboard.jsx': 2,
|
||||
'pages/Dashboard.jsx': 1,
|
||||
'pages/DeployConsole.jsx': 1,
|
||||
'pages/Monitors.jsx': 1,
|
||||
'plugins/serverkit-gui/components/ServerGui.jsx': 1,
|
||||
|
||||
@@ -20,7 +20,6 @@ const ALLOWED = new Set([
|
||||
'components/backups/BackupCalendar.jsx', // STATUS_RANK: severity ordering, not colors
|
||||
'components/databases/SourceTree.jsx', // STATUS_LABEL: labels only
|
||||
'pages/DeployConsole.jsx', // STATUS_META: labels+icons+css classes (D4 territory)
|
||||
'components/dashboard/widgets/renderers.jsx', // raw CSS-var renderer (D4 territory)
|
||||
'components/NotificationBell.jsx', // SEVERITY_DOT hex dots (D4 territory)
|
||||
'pages/Notifications.jsx', // SEVERITY_DOT hex dots (D4 territory)
|
||||
'components/LinkedAppsSection.jsx', // css-class map (D4 territory)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// Before a stylesheet ownership change:
|
||||
// node scripts/check-style-cascade.mjs --capture /tmp/serverkit-before.css
|
||||
// After the change:
|
||||
// node scripts/check-style-cascade.mjs --baseline /tmp/serverkit-before.css
|
||||
//
|
||||
// Unlike the ownership census, this checks rendered cascade behavior. Fixtures
|
||||
// cover the legacy shared classes, their descendant/state selectors, and actual
|
||||
// static JSX class combinations. It is a supplement to real-page visual review,
|
||||
// not a replacement for it. Requires Playwright's installed Chromium browser.
|
||||
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { compile } from 'sass';
|
||||
import postcss from 'postcss';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2);
|
||||
const option = (name) => args.includes(name) ? args[args.indexOf(name) + 1] : undefined;
|
||||
const capturePath = option('--capture');
|
||||
const baselinePath = option('--baseline');
|
||||
if (!capturePath && !baselinePath) {
|
||||
throw new Error('Provide --capture <css-file> before editing, or --baseline <css-file> afterward.');
|
||||
}
|
||||
const currentCSS = compile(resolve(root, 'src/styles/main.scss'), { logger: { warn() {} } }).css;
|
||||
if (capturePath) {
|
||||
writeFileSync(resolve(capturePath), currentCSS);
|
||||
console.log(`Captured CSS: ${resolve(capturePath)}`);
|
||||
process.exit(0);
|
||||
}
|
||||
const baselineCSS = readFileSync(resolve(baselinePath), 'utf8');
|
||||
const classNames = [
|
||||
'text-secondary', 'text-tertiary', 'font-medium', 'font-semibold', 'font-bold',
|
||||
'mono', 'truncate', 'top-bar', 'loading', 'loading-state', 'overview-grid',
|
||||
'btn-ghost', 'btn-link', 'btn-icon', 'empty-state', 'error-banner', 'modal-lg',
|
||||
'form-row', 'status-badge', 'status-dot', 'badge', 'badge-warning', 'tab-btn',
|
||||
'info-list', 'info-item', 'info-label', 'info-value', 'env-list', 'env-item',
|
||||
'services-grid', 'logs-viewer', 'wp-list', 'legend-item', 'loading-sm',
|
||||
'deploy-tab', 'btn-xs', 'checkbox-label', 'settings-nav-spacer',
|
||||
'permission-checkbox', 'sk-modal', 'sk-kpiband-wrap', 'conn-status',
|
||||
'spinner-inline', 'data-table', 'monitor-detail', 'settings-form',
|
||||
'events-tab', 'settings-tab', 'metrics-tab', 'spin',
|
||||
];
|
||||
|
||||
function splitSelectors(selector) {
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
const parts = [];
|
||||
for (let i = 0; i < selector.length; i += 1) {
|
||||
if ('(['.includes(selector[i])) depth += 1;
|
||||
if (')]'.includes(selector[i])) depth -= 1;
|
||||
if (!depth && selector[i] === ',') {
|
||||
parts.push(selector.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(selector.slice(start).trim());
|
||||
return parts.filter(Boolean);
|
||||
}
|
||||
|
||||
const selectors = new Set();
|
||||
for (const css of [baselineCSS, currentCSS]) {
|
||||
postcss.parse(css).walkRules((rule) => {
|
||||
for (const selector of splitSelectors(rule.selector)) {
|
||||
if (classNames.some((name) => selector.includes(`.${name}`))) selectors.add(selector);
|
||||
}
|
||||
});
|
||||
}
|
||||
function walk(directory) {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = resolve(directory, entry.name);
|
||||
return entry.isDirectory() ? walk(path) : [path];
|
||||
});
|
||||
}
|
||||
for (const path of walk(resolve(root, 'src')).filter((file) => /\.[jt]sx?$/.test(file))) {
|
||||
for (const match of readFileSync(path, 'utf8').matchAll(/className\s*=\s*["']([^"']+)["']/g)) {
|
||||
if (match[1].split(/\s+/).some((name) => classNames.includes(name))) {
|
||||
selectors.add(`.${match[1].trim().replace(/\s+/g, '.')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const size of ['', 'btn-sm', 'btn-lg', 'btn-xs']) {
|
||||
for (const variant of ['', 'btn-primary', 'btn-secondary', 'btn-ghost', 'btn-danger', 'btn-link']) {
|
||||
for (const state of ['', ':hover', ':disabled', ':hover:disabled']) {
|
||||
for (const icon of ['', 'btn-icon']) {
|
||||
selectors.add(['button.btn', icon, size, variant].filter(Boolean).join('.') + state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Materialize interaction selectors as classes so every state can be checked
|
||||
// at once, independently of the mouse position or focus of adjacent fixtures.
|
||||
function simulatedStates(css) {
|
||||
return css.replaceAll(':hover', '.test-hover')
|
||||
.replaceAll(':focus-visible', '.test-focus-visible')
|
||||
.replaceAll(':focus-within', '.test-focus-within')
|
||||
.replace(/:focus(?![-\w])/g, '.test-focus');
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(`<style id="target"></style><style>
|
||||
.fixture-case { width:600px; height:320px; position:relative; overflow:hidden; margin:12px; contain:layout }
|
||||
*,*::before,*::after { animation-play-state:paused!important; transition:none!important }
|
||||
</style><main></main>`);
|
||||
await page.evaluate((selectors) => {
|
||||
function parts(selector) {
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
const result = [];
|
||||
for (let i = 0; i < selector.length; i += 1) {
|
||||
if ('(['.includes(selector[i])) depth += 1;
|
||||
if (')]'.includes(selector[i])) depth -= 1;
|
||||
if (!depth && /[ >+~]/.test(selector[i])) {
|
||||
if (selector.slice(start, i).trim()) result.push(selector.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (selector.slice(start).trim()) result.push(selector.slice(start).trim());
|
||||
return result;
|
||||
}
|
||||
function make(token) {
|
||||
token = token.replace(/:not\([^)]*\)/g, '')
|
||||
.replace(/:(is|where)\(([^)]*)\)/g, (_, name, value) => value.split(',')[0])
|
||||
.replaceAll(':hover', '.test-hover')
|
||||
.replaceAll(':focus-visible', '.test-focus-visible')
|
||||
.replaceAll(':focus-within', '.test-focus-within')
|
||||
.replace(/:focus(?![-\w])/g, '.test-focus');
|
||||
const has = token.match(/:has\(([^)]*)\)/);
|
||||
token = token.replace(/:has\([^)]*\)/g, '');
|
||||
const element = document.createElement(token.match(/^[A-Za-z][\w-]*/)?.[0] || 'div');
|
||||
for (const match of token.matchAll(/\.([\w-]+)/g)) element.classList.add(match[1]);
|
||||
for (const match of token.matchAll(/\[([\w-]+)(?:[~|^$*]?=['"]?([^'"\]]+)['"]?)?\]/g)) {
|
||||
element.setAttribute(match[1], match[2] ?? '');
|
||||
}
|
||||
if (token.includes(':disabled')) element.setAttribute('disabled', '');
|
||||
if (token.includes(':checked')) element.checked = true;
|
||||
if (has) {
|
||||
let parent = element;
|
||||
for (const part of parts(has[1])) {
|
||||
const child = make(part);
|
||||
parent.append(child);
|
||||
parent = child;
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
for (const selector of selectors) {
|
||||
const wrapper = document.createElement('section');
|
||||
wrapper.className = 'fixture-case';
|
||||
wrapper.dataset.selector = selector;
|
||||
let parent = wrapper;
|
||||
for (const part of parts(selector)) {
|
||||
const element = make(part);
|
||||
parent.append(element);
|
||||
parent = element;
|
||||
}
|
||||
if (!parent.children.length && !['INPUT', 'IMG', 'BR', 'HR', 'SVG'].includes(parent.tagName)) {
|
||||
parent.textContent = 'ServerKit sample';
|
||||
}
|
||||
for (const tag of ['span', 'svg', 'h3', 'p', 'input']) {
|
||||
const child = document.createElement(tag);
|
||||
if (tag === 'input') child.type = 'checkbox';
|
||||
else child.textContent = tag === 'svg' ? '' : 'Example';
|
||||
parent.append(child);
|
||||
}
|
||||
document.querySelector('main').append(wrapper);
|
||||
}
|
||||
}, [...selectors]);
|
||||
|
||||
const differences = [];
|
||||
for (const width of [390, 640, 768, 1024, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
for (const theme of ['dark', 'light']) {
|
||||
await page.evaluate((theme) => document.documentElement.setAttribute('data-theme', theme), theme);
|
||||
await page.evaluate((css) => { document.querySelector('#target').textContent = css; }, simulatedStates(baselineCSS));
|
||||
// Keep snapshots inside Chromium: serializing every CSS property
|
||||
// across the automation boundary is much slower than comparing here.
|
||||
await page.evaluate(() => {
|
||||
window.fixtureNodes = [...document.querySelectorAll('.fixture-case *')];
|
||||
window.styleBaseline = window.fixtureNodes.map((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return Object.fromEntries([...style].filter((property) => !property.startsWith('--')).map((property) => [property, style.getPropertyValue(property)]));
|
||||
});
|
||||
});
|
||||
await page.evaluate((css) => { document.querySelector('#target').textContent = css; }, simulatedStates(currentCSS));
|
||||
const changes = await page.evaluate(() => window.fixtureNodes.flatMap((element, index) => {
|
||||
const style = getComputedStyle(element);
|
||||
const before = window.styleBaseline[index];
|
||||
const changed = Object.fromEntries(Object.entries(before).flatMap(([property, value]) => {
|
||||
const after = style.getPropertyValue(property);
|
||||
return value !== after ? [[property, [value, after]]] : [];
|
||||
}));
|
||||
return Object.keys(changed).length ? [{ selector: element.closest('.fixture-case').dataset.selector, element: element.tagName + '.' + element.className, changed }] : [];
|
||||
}));
|
||||
differences.push(...changes.map((change) => ({ width, theme, ...change })));
|
||||
console.log(`${width}px ${theme}: ${changes.length} differing elements`);
|
||||
}
|
||||
}
|
||||
if (differences.length) {
|
||||
const output = option('--output');
|
||||
if (output) writeFileSync(resolve(output), JSON.stringify(differences, null, 2));
|
||||
console.error(JSON.stringify(differences.slice(0, 10), null, 2));
|
||||
throw new Error(`${differences.length} rendered differences across ${selectors.size} fixtures.`);
|
||||
}
|
||||
console.log(`Style cascade preserved across ${selectors.size} fixtures, five widths, and two themes.`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -6,19 +6,12 @@
|
||||
// the rendered element is a composite nobody wrote and editing any one file
|
||||
// changes only the properties that file happens to win.
|
||||
//
|
||||
// `.empty-state` is the worked example. Three partials define it —
|
||||
// components/_empty-state.scss (flex column, min-height 200px),
|
||||
// components/_cards.scss (card background, solid border, radius) and
|
||||
// components/_users.scss (padding, colour) — and the compiled stylesheet
|
||||
// carries five competing top-level rules for it. What actually renders takes
|
||||
// its background and dashed border from one, its padding and display from
|
||||
// another, and its colour from a third.
|
||||
//
|
||||
// This is deliberately a ratchet and not a migration. Merging these correctly
|
||||
// means reproducing the computed result of the cascade, and there is no
|
||||
// byte-identical-output proof available the way there was for @keyframes — so
|
||||
// it needs eyes on the affected pages (plan invariant 9: no repo-wide
|
||||
// mechanical rewrite without behavioural tests). The count may only go down.
|
||||
// The September 2026 cleanup consolidated 114 competing definitions across
|
||||
// 50 class names, including `.empty-state`, and reduced the ceiling to zero.
|
||||
// Browser fixture comparisons checked the effective cascade across themes,
|
||||
// viewport widths, and interaction states (see check-style-cascade.mjs).
|
||||
// Keep that ownership intact: shared classes belong in their shared partial;
|
||||
// page-specific variants should be scoped to the page.
|
||||
//
|
||||
// Usage (from frontend/):
|
||||
// node scripts/check-style-ownership.mjs # check against the ceiling
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Verify that shared-control adoption preserves custom SCSS and native form,
|
||||
// disabled, keyboard, ref and asChild behavior. No application backend required.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'vite';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const server = await createServer({ server: { host: '127.0.0.1', port: 0, strictPort: false, open: false } });
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(`${server.resolvedUrls.local[0]}tests/browser/controls.html`);
|
||||
await page.locator('#shared-submit').waitFor();
|
||||
for (const theme of ['dark', 'light']) {
|
||||
await page.evaluate((value) => { document.documentElement.dataset.theme = value; }, theme);
|
||||
for (const suffix of ['submit', 'custom', 'disabled', 'card', 'header', 'content']) {
|
||||
const pair = await page.evaluate((name) => {
|
||||
const props = ['display', 'height', 'minWidth', 'padding', 'margin', 'border', 'borderRadius',
|
||||
'backgroundColor', 'backgroundImage', 'color', 'fontFamily', 'fontSize', 'fontWeight',
|
||||
'opacity', 'cursor', 'pointerEvents', 'boxShadow', 'gap'];
|
||||
return ['native', 'shared'].map((prefix) => {
|
||||
const element = document.getElementById(`${prefix}-${name}`);
|
||||
const css = getComputedStyle(element);
|
||||
return { tag: element.tagName, classes: [...new Set(element.classList)].sort(),
|
||||
css: Object.fromEntries(props.map((property) => [property, css[property]])) };
|
||||
});
|
||||
}, suffix);
|
||||
assert.deepEqual(pair[1], pair[0], `${theme}: ${suffix} changed its DOM/CSS contract`);
|
||||
}
|
||||
}
|
||||
assert.equal(await page.evaluate(() => window.controlRef === document.getElementById('shared-custom')), true);
|
||||
await page.locator('#shared-custom').click();
|
||||
assert.deepEqual(await page.evaluate(() => window.controlEvents), { submits: 0, clicks: 1 });
|
||||
await page.locator('#shared-custom').press('Enter');
|
||||
assert.deepEqual(await page.evaluate(() => window.controlEvents), { submits: 0, clicks: 2 });
|
||||
await page.locator('#shared-submit').click();
|
||||
await page.locator('#native-submit').click();
|
||||
assert.equal(await page.evaluate(() => window.controlEvents.submits), 2, 'implicit submit behavior changed');
|
||||
await page.locator('#shared-disabled').evaluate((node) => node.click());
|
||||
assert.equal(await page.evaluate(() => window.controlEvents.clicks), 2, 'disabled button fired');
|
||||
assert.equal(await page.locator('#shared-link').evaluate((node) => node.tagName), 'A');
|
||||
assert.equal(await page.locator('#shared-link').getAttribute('href'), '#destination');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('Controls: dark/light CSS equivalence, refs, form submission, keyboard, disabled and asChild contracts passed.');
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Dynamic geometry and runtime colors belong in React; fixed presentation
|
||||
// belongs in SCSS. Report once per style prop, including conditional objects.
|
||||
const isStaticValue = (node) => {
|
||||
if (!node) return false;
|
||||
if (node.type === 'Literal') return true;
|
||||
if (node.type === 'TemplateLiteral') return node.expressions.every(isStaticValue);
|
||||
if (node.type === 'UnaryExpression') return isStaticValue(node.argument);
|
||||
if (node.type === 'ConditionalExpression') {
|
||||
return isStaticValue(node.consequent) && isStaticValue(node.alternate);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function staticProperties(node, names = new Set()) {
|
||||
if (!node) return names;
|
||||
if (node.type === 'ObjectExpression') {
|
||||
for (const property of node.properties) {
|
||||
if (property.type === 'Property' && isStaticValue(property.value)) {
|
||||
names.add(property.key.name || String(property.key.value));
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'ConditionalExpression') {
|
||||
staticProperties(node.consequent, names);
|
||||
staticProperties(node.alternate, names);
|
||||
} else if (node.type === 'LogicalExpression') {
|
||||
staticProperties(node.left, names);
|
||||
staticProperties(node.right, names);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
schema: [],
|
||||
messages: { static: 'Move fixed inline presentation ({{properties}}) to SCSS; keep only computed values in style.' },
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
'JSXAttribute[name.name="style"]'(node) {
|
||||
const names = staticProperties(node.value?.expression);
|
||||
if (node.value?.type === 'Literal') names.add('style');
|
||||
if (names.size) context.report({ node, messageId: 'static', data: { properties: [...names].join(', ') } });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { RuleTester } from 'eslint';
|
||||
import rule from './no-static-inline-styles.mjs';
|
||||
|
||||
const tester = new RuleTester({ languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } });
|
||||
tester.run('no-static-inline-styles', rule, {
|
||||
valid: [
|
||||
'const view = <div style={{ width: `${progress}%` }} />;',
|
||||
'const view = <div style={{ height: rows * rowHeight, color: tone }} />;',
|
||||
'const view = <div style={style} />;',
|
||||
'const view = <div style={width ? { width } : undefined} />;',
|
||||
'const view = <div style={{ "--columns": columns }} />;',
|
||||
'const view = <div className={expanded ? "section section--expanded" : "section"} />;',
|
||||
],
|
||||
invalid: [
|
||||
{ code: 'const view = <div style={{ marginTop: 8 }} />;', errors: [{ messageId: 'static' }] },
|
||||
{ code: 'const view = <div style={{ width, maxWidth: "95vw" }} />;', errors: [{ messageId: 'static' }] },
|
||||
{ code: 'const view = <div style={open ? { color: "red" } : undefined} />;', errors: [{ messageId: 'static' }] },
|
||||
{ code: 'const view = <div style={{ color: active ? "red" : "blue" }} />;', errors: [{ messageId: 'static' }] },
|
||||
{ code: 'const view = <div style={open && { padding: 12 }} />;', errors: [{ messageId: 'static' }] },
|
||||
{ code: 'const view = <div style={{ margin: -4, padding: 8 }} />;', errors: [{ messageId: 'static' }] },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createServer } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
// Exercise the real login/SSO pages and layout hooks under React StrictMode.
|
||||
// Only authentication, translation, and HTTP boundaries are replaced by
|
||||
// controlled fixtures; no backend server or account credentials are needed.
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const fixture = `
|
||||
import React, {useState, useRef, useEffect} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {MemoryRouter, Routes, Route} from 'react-router-dom';
|
||||
import Login from '/src/pages/Login.jsx';
|
||||
import SSOCallback from '/src/pages/SSOCallback.jsx';
|
||||
import {useOverflowItems} from '/src/hooks/useOverflowItems.js';
|
||||
import useFocusTrap from '/src/hooks/ai/useFocusTrap.js';
|
||||
window.calls=[]; window.users=[]; window.reads=0;
|
||||
window.onUser=user=>window.users.push(user);
|
||||
window.testApi={
|
||||
getDemoInfo: async()=>({enabled:false}), setTokens:()=>{},
|
||||
verify2FA:(token,code)=>{window.calls.push({type:'verify',token,code});return new Promise((resolve,reject)=>{window.finish=resolve;window.fail=reject})},
|
||||
completeSSOAuth:(provider,code,state)=>{window.calls.push({type:'sso',provider,code,state});return new Promise(resolve=>{window.finish=resolve})}
|
||||
};
|
||||
const mode=new URL(location.href).searchParams.get('mode');
|
||||
function Overflow(){
|
||||
const [count,setCount]=useState(4);const [extra,setExtra]=useState(0);
|
||||
const {containerRef,itemRefs,hiddenIndices}=useOverflowItems({count,gap:0,moreWidth:20,deps:[extra]});
|
||||
window.changeExtra=()=>setExtra(n=>n+1);window.changeCount=setCount;
|
||||
return <><div ref={containerRef} style={{width:180,display:'flex'}}>{Array.from({length:count},(_,i)=><div key={i} ref={el=>{itemRefs.current[i]=el;if(el)Object.defineProperty(el,'offsetWidth',{configurable:true,get(){window.reads++;return 80}})}} style={{width:80,flexShrink:0}}>Item</div>)}</div><output>{JSON.stringify(hiddenIndices)}</output></>
|
||||
}
|
||||
function Focus(){
|
||||
const [active,setActive]=useState(true);const box=useRef(null);const first=useRef(null);const second=useRef(null);const restore=useRef(null);
|
||||
useEffect(()=>{restore.current=first.current;window.deactivate=()=>{restore.current=second.current;setActive(false)}},[]);
|
||||
useFocusTrap(box,{active,restoreFocusRef:restore});
|
||||
return <><button id="first" ref={first}>first</button><button id="second" ref={second}>second</button><div ref={box}><button>inside</button></div></>
|
||||
}
|
||||
function App(){
|
||||
const [,setTick]=useState(0);window.bump=()=>setTick(n=>n+1);
|
||||
if(mode==='overflow')return <Overflow/>;if(mode==='focus')return <Focus/>;
|
||||
const entry=mode==='sso'?'/login/callback/test?code=code1&state=state1':{pathname:'/login',state:{requires2FA:true,tempToken:'challenge1'}};
|
||||
return <MemoryRouter initialEntries={[entry]}><Routes><Route path="/login" element={<Login/>}/><Route path="/login/callback/:provider" element={<SSOCallback/>}/><Route path="*" element={<div id="done">done</div>}/></Routes></MemoryRouter>
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(<React.StrictMode><App/></React.StrictMode>);
|
||||
`;
|
||||
const server = await createServer({
|
||||
root, configFile:false, logLevel:'error', resolve:{alias:{'@':path.join(root,'src')}},
|
||||
cacheDir: path.join(root, 'node_modules/.vite-hooks-regression'),
|
||||
optimizeDeps: { entries: [] },
|
||||
server:{host:'127.0.0.1',port:0},
|
||||
plugins:[{
|
||||
name:'hook-regression-fixtures',enforce:'pre',
|
||||
resolveId(id){if(id==='react-i18next')return '\0test-i18n';if(id.endsWith('/hook-fixture.jsx'))return path.join(root,'hook-fixture.jsx')},
|
||||
load(id){
|
||||
const normalized=id.replaceAll('\\','/');
|
||||
if(id==='\0test-i18n')return `const t=(key,fallback)=>typeof fallback==='string'?fallback:key;export const useTranslation=()=>({t});export const Trans=({children})=>children;`;
|
||||
if(normalized.endsWith('/src/contexts/useAuth.js'))return `export const useAuth=()=>({setUser:window.onUser,ssoProviders:[],passwordLoginEnabled:true,publicTitle:'Control panel'});`;
|
||||
if(normalized.endsWith('/src/pages/auth/AuthLayout.jsx'))return `export default function AuthLayout({children}){return children}`;
|
||||
if(normalized.endsWith('/src/services/api.js')||normalized.endsWith('/src/services/api/index.js'))return `export default new Proxy({}, {get:(_,key)=>(...args)=>window.testApi[key](...args)});`;
|
||||
if(normalized.endsWith('/hook-fixture.jsx'))return fixture;
|
||||
},
|
||||
configureServer(server){server.middlewares.use('/hook-check',async(_req,res)=>{res.setHeader('Content-Type','text/html');res.end(await server.transformIndexHtml('/hook-check','<div id="root"></div><script type="module" src="/hook-fixture.jsx"></script>'))})}
|
||||
},react()],
|
||||
});
|
||||
const executablePath = [
|
||||
process.env.CHROME_PATH,
|
||||
chromium.executablePath(),
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
].find(candidate => candidate && existsSync(candidate));
|
||||
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();const origin=`http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
browser=await chromium.launch({ ...(executablePath ? { executablePath } : {}), headless: true });const page=await browser.newPage();
|
||||
const errors=[];page.on('pageerror',error=>errors.push(error.message));
|
||||
await page.goto(origin+'/hook-check?mode=login');await page.locator('.totp-input').first().waitFor();
|
||||
async function digits(){for(let i=0;i<6;i++)await page.locator('.totp-input').nth(i).fill(String(i+1))}
|
||||
await digits();await page.waitForFunction(()=>window.calls.length===1);
|
||||
await page.evaluate(()=>{window.bump();document.querySelector('form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}))});
|
||||
await page.waitForTimeout(100);assert.equal(await page.evaluate(()=>window.calls.length),1);
|
||||
await page.evaluate(()=>window.fail(new Error('Invalid verification code')));
|
||||
await page.waitForFunction(()=>[...document.querySelectorAll('.totp-input')].every(el=>el.value===''));
|
||||
await digits();await page.waitForFunction(()=>window.calls.length===2);
|
||||
await page.evaluate(()=>window.finish({access_token:'a',refresh_token:'r',user:{id:7}}));
|
||||
await page.locator('#done').waitFor();assert.equal(await page.evaluate(()=>window.calls.length),2);
|
||||
console.log('PASS TOTP single in-flight request, rerender guard, retry after rejection and successful navigation');
|
||||
await page.goto(origin+'/hook-check?mode=sso');await page.waitForFunction(()=>window.calls.length===1);
|
||||
await page.evaluate(()=>window.bump());await page.waitForTimeout(100);
|
||||
assert.equal(await page.evaluate(()=>window.calls.length),1);
|
||||
await page.evaluate(()=>window.finish({requires_2fa:true,temp_token:'sso-challenge'}));
|
||||
await page.locator('.totp-input').first().waitFor();assert.equal(await page.evaluate(()=>window.calls.length),1);
|
||||
console.log('PASS SSO exchanges once under StrictMode and rerender, then forwards MFA challenge');
|
||||
await page.goto(origin+'/hook-check?mode=overflow');await page.locator('output').waitFor();await page.waitForTimeout(200);
|
||||
assert.equal(await page.locator('output').textContent(),'[2,3]');
|
||||
const reads=await page.evaluate(()=>window.reads);await page.evaluate(()=>window.bump());await page.waitForTimeout(100);
|
||||
assert.equal(await page.evaluate(()=>window.reads),reads);
|
||||
await page.evaluate(()=>window.changeExtra());await page.waitForFunction(n=>window.reads>n,reads);
|
||||
await page.evaluate(()=>window.changeCount(1));await page.waitForFunction(()=>document.querySelector('output').textContent==='[]');
|
||||
console.log('PASS overflow measurement skips identical dependency values and responds to changed values/count');
|
||||
await page.goto(origin+'/hook-check?mode=focus');await page.locator('#first').waitFor();await page.waitForTimeout(100);
|
||||
await page.evaluate(()=>window.deactivate());await page.waitForFunction(()=>document.activeElement.id==='first');
|
||||
console.log('PASS focus restores activation target after ref changes');
|
||||
assert.deepEqual(errors,[]);
|
||||
} finally {await browser?.close();await server.close()}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { relative } from 'node:path';
|
||||
|
||||
export function warningInventory(results, root, sourceFiles) {
|
||||
const inventory = {};
|
||||
for (const result of results) {
|
||||
if (!sourceFiles.has(result.filePath)) continue;
|
||||
const file = relative(root, result.filePath).replaceAll('\\', '/');
|
||||
for (const message of result.messages) {
|
||||
if (message.severity !== 1) continue;
|
||||
const rule = message.ruleId || 'unused-disable';
|
||||
inventory[file] ||= {};
|
||||
inventory[file][rule] = (inventory[file][rule] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(Object.entries(inventory).sort().map(([file, rules]) => [
|
||||
file, Object.fromEntries(Object.entries(rules).sort()),
|
||||
]));
|
||||
}
|
||||
|
||||
export function warningRegressions(current, baseline) {
|
||||
const regressions = [];
|
||||
for (const [file, rules] of Object.entries(current)) {
|
||||
for (const [rule, count] of Object.entries(rules)) {
|
||||
const ceiling = baseline[file]?.[rule] || 0;
|
||||
if (count > ceiling) regressions.push(`${file}: ${rule} has ${count} warnings (ceiling ${ceiling})`);
|
||||
}
|
||||
}
|
||||
return regressions;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { warningInventory, warningRegressions } from './lint-inventory.mjs';
|
||||
|
||||
test('warning baseline excludes installed copies but includes newly added source', () => {
|
||||
const root = resolve('fixture');
|
||||
const file = resolve(root, 'src/new.jsx');
|
||||
const installed = resolve(root, 'src/plugins/installed.jsx');
|
||||
const messages = [{ severity: 1, ruleId: 'no-unused-vars' }, { severity: 2, ruleId: 'no-undef' }];
|
||||
assert.deepEqual(warningInventory([{ filePath: file, messages }, { filePath: installed, messages }], root, new Set([file])), {
|
||||
'src/new.jsx': { 'no-unused-vars': 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test('new files, new rules, and growing counts fail; reductions pass', () => {
|
||||
const baseline = { 'src/page.jsx': { 'no-unused-vars': 2 } };
|
||||
assert.equal(warningRegressions({ 'src/page.jsx': { 'no-unused-vars': 3 } }, baseline).length, 1);
|
||||
assert.equal(warningRegressions({ 'src/page.jsx': { 'react-hooks/exhaustive-deps': 1 } }, baseline).length, 1);
|
||||
assert.equal(warningRegressions({ 'src/new.jsx': { 'no-unused-vars': 1 } }, baseline).length, 1);
|
||||
assert.deepEqual(warningRegressions({ 'src/page.jsx': { 'no-unused-vars': 1 } }, baseline), []);
|
||||
assert.deepEqual(warningRegressions({}, baseline), []);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Run normal ESLint, then prevent tracked-source warnings growing by file/rule.
|
||||
// Installed, ignored extensions still receive normal lint diagnostics, but do
|
||||
// not change the checked-in baseline on one developer's machine.
|
||||
import { ESLint } from 'eslint';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { warningInventory, warningRegressions } from './lint-inventory.mjs';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repo = resolve(root, '..');
|
||||
const baselineFile = resolve(root, 'scripts/lint-warning-baseline.json');
|
||||
const lint = new ESLint({ cwd: root });
|
||||
const results = await lint.lintFiles(['.']);
|
||||
const formatter = await lint.loadFormatter('stylish');
|
||||
const output = formatter.format(results);
|
||||
if (output) console.log(output);
|
||||
if (results.some((result) => result.errorCount > 0)) process.exit(1);
|
||||
|
||||
// Include new, non-ignored source files so new warnings cannot slip through
|
||||
// before the file is first staged. Git paths are repository-relative here.
|
||||
const paths = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', 'frontend'], {
|
||||
cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).split('\0').filter(Boolean);
|
||||
const sourceFiles = new Set(paths.map((file) => resolve(repo, file)));
|
||||
const inventory = warningInventory(results, root, sourceFiles);
|
||||
if (process.argv.includes('--update')) {
|
||||
writeFileSync(baselineFile, `${JSON.stringify(inventory, null, 2)}\n`);
|
||||
console.log('Updated tracked-source warning baseline; review the diff before committing.');
|
||||
} else {
|
||||
const baseline = JSON.parse(readFileSync(baselineFile, 'utf8'));
|
||||
const regressions = warningRegressions(inventory, baseline);
|
||||
if (regressions.length) {
|
||||
console.error(`New ESLint warnings:\n ${regressions.join('\n ')}\nFix new warnings; keep the baseline shrinking.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const count = Object.values(inventory).reduce((total, rules) => total + Object.values(rules).reduce((sum, n) => sum + n, 0), 0);
|
||||
console.log(`Tracked-source warnings: ${count}; no file/rule exceeds its reviewed baseline.`);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { chromium } from 'playwright';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
// Real React mount with controlled HTTP/socket adapters and visibility events.
|
||||
const fixture = `
|
||||
import React, { useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { useMetrics } from '/src/hooks/useMetrics.js';
|
||||
import api from '/src/services/api/index.js';
|
||||
import socket from '/src/services/socket.js';
|
||||
import { WorkspaceProvider } from '/src/contexts/WorkspaceContext.jsx';
|
||||
import { workspaceStore } from '/src/services/workspaceStore.js';
|
||||
import { useMetricHistory } from '/src/components/dashboard/widgets/useWidgetData.js';
|
||||
const handlers = new Map();
|
||||
let hidden = false;
|
||||
const state = { calls: 0, pending: [] };
|
||||
api.getSystemMetrics = () => {
|
||||
state.calls += 1;
|
||||
return new Promise((resolve) => state.pending.push(resolve));
|
||||
};
|
||||
socket.socket = { connected: false };
|
||||
socket.connect = () => {};
|
||||
socket.on = (name, fn) => { handlers.set(name, fn); return () => handlers.delete(name); };
|
||||
socket.subscribeMetrics = () => {};
|
||||
socket.unsubscribeMetrics = () => {};
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => hidden ? 'hidden' : 'visible' });
|
||||
function App() {
|
||||
const [options, setOptions] = useState({ enabled: true, autoRefresh: true });
|
||||
const metrics = useMetrics(true, 60, options);
|
||||
window.metricsFixture = {
|
||||
state, setOptions, metrics,
|
||||
emit: (name, data) => handlers.get(name)?.(data),
|
||||
resolve: () => state.pending.splice(0).forEach((resolve) => resolve({ cpu: 42 })),
|
||||
hide: (value) => { hidden = value; document.dispatchEvent(new Event('visibilitychange')); },
|
||||
};
|
||||
return React.createElement('output', {}, metrics.loading ? 'loading' : 'ready');
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(React.createElement(App));
|
||||
const widgetState = { calls: 0, pending: [], snapshots: [] };
|
||||
api.getMetricsHistory = () => {
|
||||
widgetState.calls += 1;
|
||||
return new Promise((resolve) => widgetState.pending.push(resolve));
|
||||
};
|
||||
function Widget({ index, tick }) {
|
||||
widgetState.snapshots[index] = useMetricHistory('local', '1h', tick);
|
||||
return null;
|
||||
}
|
||||
function Widgets() {
|
||||
const [tick, setTick] = useState(0);
|
||||
window.widgetFixture = {
|
||||
state: widgetState, refresh: () => setTick((value) => value + 1),
|
||||
resolve: () => widgetState.pending.splice(0).forEach((resolve) => resolve({ points: [widgetState.calls] })),
|
||||
workspace: () => workspaceStore.setActiveWorkspace({ id: 'other-workspace', name: 'Other' }),
|
||||
};
|
||||
return React.createElement(React.Fragment, {},
|
||||
React.createElement(Widget, { index: 0, tick }), React.createElement(Widget, { index: 1, tick }));
|
||||
}
|
||||
createRoot(document.getElementById('widgets')).render(React.createElement(WorkspaceProvider, {}, React.createElement(Widgets)));
|
||||
`;
|
||||
const server = await createServer({
|
||||
server: { host: '127.0.0.1', port: 0, open: false },
|
||||
plugins: [{
|
||||
name: 'metrics-regression-fixture',
|
||||
resolveId(id) { if (id === 'virtual:metrics-regression') return '\0metrics-regression'; },
|
||||
load(id) { if (id === '\0metrics-regression') return fixture; },
|
||||
configureServer(vite) {
|
||||
vite.middlewares.use('/__metrics-regression', async (_req, res) => {
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end(await vite.transformIndexHtml('/__metrics-regression',
|
||||
'<div id="root"></div><div id="widgets"></div><script type="module">import "virtual:metrics-regression"</script>'));
|
||||
});
|
||||
},
|
||||
}],
|
||||
});
|
||||
await server.listen();
|
||||
const executablePath = [
|
||||
process.env.CHROME_PATH, chromium.executablePath(),
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
].find((path) => path && existsSync(path));
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ ...(executablePath ? { executablePath } : {}), headless: true });
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__metrics-regression`);
|
||||
await page.waitForFunction(() => window.metricsFixture?.state.calls === 1);
|
||||
const calls = () => page.evaluate(() => window.metricsFixture.state.calls);
|
||||
const action = (fn) => page.evaluate(fn);
|
||||
await page.waitForTimeout(240);
|
||||
assert.equal(await calls(), 1, 'slow initial response cannot overlap fallback');
|
||||
await action(() => { window.metricsFixture.metrics.refresh(); window.metricsFixture.metrics.refresh(); });
|
||||
assert.equal(await calls(), 1, 'manual refresh shares initial request');
|
||||
await action(() => window.metricsFixture.emit('connected'));
|
||||
await action(() => window.metricsFixture.resolve());
|
||||
await page.waitForTimeout(180);
|
||||
assert.equal(await calls(), 1, 'connected socket stops fallback');
|
||||
await action(() => window.metricsFixture.emit('disconnected'));
|
||||
await page.waitForFunction(() => window.metricsFixture.state.calls === 2);
|
||||
await action(() => window.metricsFixture.hide(true));
|
||||
await action(() => window.metricsFixture.resolve());
|
||||
await page.waitForTimeout(180);
|
||||
assert.equal(await calls(), 2, 'hidden tabs suspend fallback');
|
||||
await action(() => window.metricsFixture.hide(false));
|
||||
await page.waitForFunction(() => window.metricsFixture.state.calls === 3);
|
||||
await action(() => window.metricsFixture.setOptions({ enabled: false, autoRefresh: true }));
|
||||
await page.waitForTimeout(30);
|
||||
await action(() => window.metricsFixture.resolve());
|
||||
await page.waitForTimeout(180);
|
||||
assert.equal(await calls(), 3, 'remote selection stops local HTTP work');
|
||||
await action(() => window.metricsFixture.setOptions({ enabled: true, autoRefresh: false }));
|
||||
await page.waitForFunction(() => window.metricsFixture.state.calls === 4);
|
||||
await action(() => window.metricsFixture.resolve());
|
||||
await page.waitForTimeout(180);
|
||||
assert.equal(await calls(), 4, 'refresh off loads one snapshot without polling');
|
||||
await action(() => { window.metricsFixture.metrics.refresh(); window.metricsFixture.metrics.refresh(); });
|
||||
await page.waitForFunction(() => window.metricsFixture.state.calls === 5);
|
||||
await action(() => window.metricsFixture.resolve());
|
||||
await page.waitForTimeout(120);
|
||||
assert.equal(await calls(), 5, 'manual refresh while off does not restart timer');
|
||||
await page.waitForFunction(() => window.widgetFixture?.state.calls === 1);
|
||||
await action(() => window.widgetFixture.resolve());
|
||||
await page.waitForFunction(() => window.widgetFixture.state.snapshots.every((item) => !item.loading));
|
||||
await action(() => window.widgetFixture.refresh());
|
||||
await page.waitForFunction(() => window.widgetFixture.state.calls === 2);
|
||||
assert.deepEqual(await action(() => window.widgetFixture.state.snapshots.map((item) => item.data)),
|
||||
[{ points: [1] }, { points: [1] }], 'refresh retains both widgets while sharing one next request');
|
||||
await action(() => window.widgetFixture.resolve());
|
||||
await page.waitForFunction(() => window.widgetFixture.state.snapshots.every((item) => !item.loading));
|
||||
await action(() => window.widgetFixture.workspace());
|
||||
await page.waitForFunction(() => window.widgetFixture.state.calls === 3);
|
||||
assert.deepEqual(await action(() => window.widgetFixture.state.snapshots.map((item) => item.data)),
|
||||
[null, null], 'workspace switching cannot display another workspace payload');
|
||||
await action(() => window.widgetFixture.resolve());
|
||||
await page.waitForFunction(() => window.widgetFixture.state.snapshots.every((item) => !item.loading));
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('Metrics browser regression passed: slow response, manual sharing, socket loss/reconnect, hidden tab, remote selection, refresh off; widget sharing, refresh retention, workspace isolation.');
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation, useParams } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { useAuth } from './contexts/useAuth.js';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { LocaleProvider } from './contexts/LocaleContext';
|
||||
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
CommandItem,
|
||||
} from '@/components/ui/command';
|
||||
import { useContributions } from '../plugins/contributions';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useShellDock } from '../contexts/ShellDockContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useAuth } from '../contexts/useAuth.js';
|
||||
import { useShellDock } from '../contexts/useShellDock.js';
|
||||
import { useTheme } from '../contexts/useTheme.js';
|
||||
import { useWalkthroughs } from '../contexts/walkthroughContextValue';
|
||||
import usePaletteAuthz from '../hooks/usePaletteAuthz';
|
||||
import { CREATE_ITEMS } from '../data/createItems';
|
||||
@@ -30,6 +30,7 @@ import { DOCS_LINKS } from '../utils/docsLinks';
|
||||
import { scoreItem } from '../utils/paletteScore';
|
||||
import { frecencyScore, recordUse, recentIds } from '../utils/paletteFrecency';
|
||||
import { getFavorites, getRecents } from '../utils/recents';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Group order in the list, plus a per-category icon and a scoring weight so the
|
||||
// headline categories (Settings, Pages, Actions) outrank raw entity hits on ties.
|
||||
@@ -427,7 +428,7 @@ const CommandPalette = ({ open, onClose }) => {
|
||||
);
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="command-palette__more"
|
||||
onClick={() => setShowMoreCreates((value) => !value)}
|
||||
@@ -439,7 +440,7 @@ const CommandPalette = ({ open, onClose }) => {
|
||||
? t('palette.moreIncludingExtensions', '{{count}} more · including extensions', { count: hiddenCount })
|
||||
: t('palette.moreCreates', '{{count}} more', { count: hiddenCount })}</>
|
||||
)}
|
||||
</button>
|
||||
</SharedButton>
|
||||
)}
|
||||
</CommandGroup>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookOpen } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useTheme } from '../contexts/useTheme.js';
|
||||
import { docsUrl } from '../utils/docsLinks';
|
||||
|
||||
// Small "Docs" link (book icon) that opens a serverkit.ai docs page in a new
|
||||
|
||||
@@ -4,7 +4,7 @@ import api from '../services/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useToast } from '../contexts/useToast.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const FIELD_LABELS = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Copy, Download, Eye, EyeOff, History, Pencil, Plus, Trash2, Upload, Variable,
|
||||
} from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useToast } from '../contexts/useToast.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -70,6 +70,7 @@ const ENV_VIEWS = [
|
||||
const EnvironmentVariables = ({ appId }) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const toastError = toast.error;
|
||||
const { confirm } = useConfirm();
|
||||
const { copy } = useClipboard();
|
||||
const [envVars, setEnvVars] = useState([]);
|
||||
@@ -110,12 +111,7 @@ const EnvironmentVariables = ({ appId }) => {
|
||||
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadEnvVars();
|
||||
loadComposeServices();
|
||||
}, [appId]);
|
||||
|
||||
async function loadComposeServices() {
|
||||
const loadComposeServices = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getComposeServices(appId);
|
||||
setComposeServices(data.services || []);
|
||||
@@ -123,20 +119,26 @@ const EnvironmentVariables = ({ appId }) => {
|
||||
// Non-compose apps or errors: keep single-container UX (no selector).
|
||||
setComposeServices([]);
|
||||
}
|
||||
}
|
||||
}, [appId]);
|
||||
|
||||
async function loadEnvVars() {
|
||||
const loadEnvVars = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getEnvVars(appId);
|
||||
setEnvVars(data.env_vars || []);
|
||||
} catch (err) {
|
||||
toast.error(t('app.environmentVariables.failedToLoadEnvironmentVariables', 'Failed to load environment variables'));
|
||||
toastError(t('app.environmentVariables.failedToLoadEnvironmentVariables', 'Failed to load environment variables'));
|
||||
console.error('Failed to load env vars:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [appId, t, toastError]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEnvVars();
|
||||
loadComposeServices();
|
||||
}, [loadEnvVars, loadComposeServices]);
|
||||
|
||||
|
||||
function openAddModal() {
|
||||
setNewKey('');
|
||||
@@ -250,7 +252,7 @@ const EnvironmentVariables = ({ appId }) => {
|
||||
const data = await api.exportEnvFile(appId, includeSecrets);
|
||||
downloadBlob(data.content, data.filename || 'app.env');
|
||||
toast.success(t('app.environmentVariables.environmentFileExported', 'Environment file exported'));
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error(t('app.environmentVariables.failedToExport', 'Failed to export'));
|
||||
}
|
||||
}
|
||||
@@ -291,7 +293,7 @@ const EnvironmentVariables = ({ appId }) => {
|
||||
const data = await api.getEnvVarHistory(appId);
|
||||
setHistory(data.history || []);
|
||||
setShowHistoryModal(true);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error(t('app.environmentVariables.failedToLoadHistory', 'Failed to load history'));
|
||||
}
|
||||
}
|
||||
@@ -312,7 +314,7 @@ const EnvironmentVariables = ({ appId }) => {
|
||||
await api.clearEnvVars(appId);
|
||||
toast.success(t('app.environmentVariables.allEnvironmentVariablesCleared', 'All environment variables cleared'));
|
||||
loadEnvVars();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error(t('app.environmentVariables.failedToClear', 'Failed to clear'));
|
||||
}
|
||||
}
|
||||
@@ -437,38 +439,38 @@ const EnvironmentVariables = ({ appId }) => {
|
||||
cellClassName: 'actions-cell',
|
||||
render: (ev) => (
|
||||
<>
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => toggleShowValue(ev.id)}
|
||||
title={showValues[ev.id] ? t('app.environmentVariables.hideValue', 'Hide value') : t('app.environmentVariables.showValue', 'Show value')}
|
||||
>
|
||||
{showValues[ev.id] ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => copy(ev.value)}
|
||||
title={t('app.environmentVariables.copyValue', 'Copy value')}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => startEditing(ev)}
|
||||
title={t('common.actions.edit', 'Edit')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="btn-icon btn-danger"
|
||||
onClick={() => handleDelete(ev.key)}
|
||||
title={t('common.actions.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Star } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isFavorite, toggleFavorite } from '@/utils/recents';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Pin/unpin an entity as a favorite (surfaced in the command palette's
|
||||
// Favorites section). Sits in detail-page title areas.
|
||||
@@ -17,7 +18,7 @@ export function FavoriteStar({ type, id, path, label, className }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={cn('fav-star', fav && 'is-on', className)}
|
||||
onClick={toggle}
|
||||
@@ -26,7 +27,7 @@ export function FavoriteStar({ type, id, path, label, className }) {
|
||||
aria-pressed={fav}
|
||||
>
|
||||
<Star size={15} fill={fav ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,16 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import api from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useNotifications } from '../contexts/NotificationsContext';
|
||||
import { useAuth } from '../contexts/useAuth.js';
|
||||
import { useNotifications } from '../contexts/useNotifications.js';
|
||||
import { useOperations } from '../contexts/OperationsContext';
|
||||
import { useServerkitAI } from '../contexts/AIContext';
|
||||
import { useShellDock } from '../contexts/ShellDockContext';
|
||||
import { useServerkitAI } from '../contexts/useServerkitAI.js';
|
||||
import { useShellDock } from '../contexts/useShellDock.js';
|
||||
import { useWalkthroughs } from '../contexts/walkthroughContextValue';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
import { useWorkspace } from '../contexts/useWorkspace.js';
|
||||
import { timeAgo } from '../utils/time';
|
||||
import ShellDockTabs from './ShellDockTabs';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
const SERVER_SCOPE_KEY = 'serverkit.activeServerScope';
|
||||
|
||||
@@ -49,7 +50,7 @@ function ScopeMenu({ label, options, value, onPick, wide = false }) {
|
||||
>
|
||||
<div className="statusbar-menu__head mono">{label}</div>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="option"
|
||||
@@ -65,7 +66,7 @@ function ScopeMenu({ label, options, value, onPick, wide = false }) {
|
||||
{String(option.id) === String(value) && (
|
||||
<Check size={15} className="statusbar-menu__check" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</SharedButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -107,7 +108,7 @@ function AlertsPanel({ onClose }) {
|
||||
<ShellDockTabs />
|
||||
</header>
|
||||
<div className="shell-panel__filters">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={view === 'attention' ? 'is-active' : ''}
|
||||
onClick={() => setView('attention')}
|
||||
@@ -115,8 +116,8 @@ function AlertsPanel({ onClose }) {
|
||||
aria-selected={view === 'attention'}
|
||||
>
|
||||
{t('app.operationsDock.needsAttention', 'Needs attention')}
|
||||
</button>
|
||||
<button
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={view === 'all' ? 'is-active' : ''}
|
||||
onClick={() => setView('all')}
|
||||
@@ -124,12 +125,12 @@ function AlertsPanel({ onClose }) {
|
||||
aria-selected={view === 'all'}
|
||||
>
|
||||
{t('notifications.everything', 'Everything')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
<span className="shell-panel__spacer" />
|
||||
{items.some((item) => item.kind !== 'notice' && !item.read) && (
|
||||
<button type="button" className="shell-panel__action" onClick={markAllRead}>
|
||||
<SharedButton variant="unstyled" type="button" className="shell-panel__action" onClick={markAllRead}>
|
||||
<Check size={13} /> {t('notifications.markAllRead', 'Mark all read')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -143,7 +144,7 @@ function AlertsPanel({ onClose }) {
|
||||
key={item.delivery_id || item.notice_id}
|
||||
className={`shell-alerts__row is-${alertTone(item)}${item.read ? ' is-read' : ''}`}
|
||||
>
|
||||
<button type="button" className="shell-alerts__hit" onClick={() => openItem(item)}>
|
||||
<SharedButton variant="unstyled" type="button" className="shell-alerts__hit" onClick={() => openItem(item)}>
|
||||
<span className="shell-alerts__dot" aria-hidden="true" />
|
||||
<span className="shell-alerts__copy">
|
||||
<strong>{item.title}</strong>
|
||||
@@ -154,16 +155,16 @@ function AlertsPanel({ onClose }) {
|
||||
? item.action_label || t('notifications.review', 'Review')
|
||||
: timeAgo(item.created_at)}
|
||||
</span>
|
||||
</button>
|
||||
</SharedButton>
|
||||
{item.kind === 'notice' && (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="shell-alerts__dismiss"
|
||||
onClick={() => dismissNotice?.(item.notice_id)}
|
||||
aria-label={t('notifications.dismissItem', 'Dismiss {{title}}', { title: item.title })}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -171,9 +172,9 @@ function AlertsPanel({ onClose }) {
|
||||
|
||||
<footer className="shell-panel__footer">
|
||||
<span>{t('notifications.recentActivity', 'Recent system and delivery activity')}</span>
|
||||
<button type="button" onClick={() => { navigate('/notifications'); onClose(); }}>
|
||||
<SharedButton variant="unstyled" type="button" onClick={() => { navigate('/notifications'); onClose(); }}>
|
||||
{t('notifications.seeAll', 'See all notifications')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
@@ -304,7 +305,7 @@ export default function GlobalStatusBar({ onOpenPalette }) {
|
||||
onPick={pickWorkspace}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`statusbar-select statusbar-select--workspace${scopeMenu === 'workspace' ? ' is-open' : ''}`}
|
||||
onClick={() => setScopeMenu(scopeMenu === 'workspace' ? null : 'workspace')}
|
||||
@@ -315,7 +316,7 @@ export default function GlobalStatusBar({ onOpenPalette }) {
|
||||
<Building2 size={13} aria-hidden="true" />
|
||||
<span className="statusbar-select__value">{workspaceName}</span>
|
||||
<ChevronUp size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
</span>
|
||||
|
||||
<span className="global-statusbar__separator" aria-hidden="true" />
|
||||
@@ -329,7 +330,7 @@ export default function GlobalStatusBar({ onOpenPalette }) {
|
||||
onPick={pickServer}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`statusbar-select${scopeMenu === 'server' ? ' is-open' : ''}`}
|
||||
onClick={() => setScopeMenu(scopeMenu === 'server' ? null : 'server')}
|
||||
@@ -341,12 +342,12 @@ export default function GlobalStatusBar({ onOpenPalette }) {
|
||||
<Server size={13} className="global-statusbar__mobile-icon" aria-hidden="true" />
|
||||
<span className="statusbar-select__value mono">{selectedServer.name || selectedServer.id}</span>
|
||||
<ChevronUp size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
</span>
|
||||
|
||||
<span className="global-statusbar__separator" aria-hidden="true" />
|
||||
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`global-statusbar__segment${activeTab === 'ops' ? ' is-active' : ''}`}
|
||||
onClick={() => toggleTab('ops')}
|
||||
@@ -361,10 +362,10 @@ export default function GlobalStatusBar({ onOpenPalette }) {
|
||||
: t('app.statusbar.idle', 'idle')}
|
||||
</span>
|
||||
{attentionCount > 0 && <span className="global-statusbar__pip is-warning" aria-hidden="true" />}
|
||||
</button>
|
||||
</SharedButton>
|
||||
|
||||
<span className="global-statusbar__separator" aria-hidden="true" />
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`global-statusbar__segment${activeTab === 'recipes' ? ' is-active' : ''}`}
|
||||
onClick={() => toggleTab('recipes')}
|
||||
@@ -376,12 +377,12 @@ export default function GlobalStatusBar({ onOpenPalette }) {
|
||||
{activeWalkthrough && (
|
||||
<span className="global-statusbar__muted mono">{activeProgress?.count || 0}/{activeProgress?.total || 0}</span>
|
||||
)}
|
||||
</button>
|
||||
</SharedButton>
|
||||
|
||||
<span className="global-statusbar__spacer" />
|
||||
|
||||
{setupSummary && (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="global-statusbar__segment global-statusbar__setup"
|
||||
onClick={() => navigate('/monitoring/doctor')}
|
||||
@@ -392,12 +393,12 @@ export default function GlobalStatusBar({ onOpenPalette }) {
|
||||
{t('app.statusbar.setupScore', 'setup {{score}}%', { score: setupSummary.score })}
|
||||
</span>
|
||||
<progress max="100" value={setupSummary.score} aria-label={t('app.setupHealthWidget.progress', 'Setup health progress')} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
)}
|
||||
|
||||
<span className="global-statusbar__separator" aria-hidden="true" />
|
||||
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`global-statusbar__segment${activeTab === 'alerts' ? ' is-active' : ''}`}
|
||||
onClick={() => toggleTab('alerts')}
|
||||
@@ -408,11 +409,11 @@ export default function GlobalStatusBar({ onOpenPalette }) {
|
||||
<span>{t('app.statusbar.alerts', 'Alerts')}</span>
|
||||
{unreadCount > 0 && <span className="global-statusbar__muted mono">{unreadCount}</span>}
|
||||
{unreadCount > 0 && <span className="global-statusbar__pip is-critical" aria-hidden="true" />}
|
||||
</button>
|
||||
</SharedButton>
|
||||
|
||||
<span className="global-statusbar__separator" aria-hidden="true" />
|
||||
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`global-statusbar__segment${activeTab === 'assistant' ? ' is-active' : ''}`}
|
||||
onClick={() => toggleTab('assistant')}
|
||||
@@ -422,11 +423,11 @@ export default function GlobalStatusBar({ onOpenPalette }) {
|
||||
<Sparkles size={13} />
|
||||
<span>{t('app.ai.assistant', 'Assistant')}</span>
|
||||
{assistantUnread > 0 && <span className="global-statusbar__muted mono">{assistantUnread}</span>}
|
||||
</button>
|
||||
</SharedButton>
|
||||
|
||||
<span className="global-statusbar__separator" aria-hidden="true" />
|
||||
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="global-statusbar__segment global-statusbar__command"
|
||||
onClick={onOpenPalette}
|
||||
@@ -435,7 +436,7 @@ export default function GlobalStatusBar({ onOpenPalette }) {
|
||||
>
|
||||
<Command size={12} />
|
||||
<span className="mono">{t('app.statusbar.commandShortcut', 'Ctrl K')}</span>
|
||||
</button>
|
||||
</SharedButton>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -46,13 +46,13 @@ export function JournalControls({
|
||||
{showQuickUnits && quickUnits.length > 0 && (
|
||||
<div className="quick-units">
|
||||
{quickUnits.map(u => (
|
||||
<button type="button"
|
||||
<Button variant="unstyled" type="button"
|
||||
key={u}
|
||||
className={`unit-chip ${unit === u ? 'active' : ''}`}
|
||||
onClick={() => onUnitChange?.(unit === u ? '' : u)}
|
||||
>
|
||||
{u}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Link2, GitBranch, AlertCircle, Check } from 'lucide-react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { GitBranch, AlertCircle, Check } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import Modal from './Modal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
const LinkAppModal = ({ app, onClose, onLinked }) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,11 +17,7 @@ const LinkAppModal = ({ app, onClose, onLinked }) => {
|
||||
const [tablePrefix, setTablePrefix] = useState('');
|
||||
const [propagateCredentials, setPropagateCredentials] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadCompatibleApps();
|
||||
}, [app]);
|
||||
|
||||
async function loadCompatibleApps() {
|
||||
const loadCompatibleApps = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getApps();
|
||||
// Filter to same type, not already linked, not self
|
||||
@@ -31,12 +28,17 @@ const LinkAppModal = ({ app, onClose, onLinked }) => {
|
||||
a.environment_type === 'standalone'
|
||||
);
|
||||
setApps(compatible);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError('Failed to load applications');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [app.id, app.app_type]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCompatibleApps();
|
||||
}, [loadCompatibleApps]);
|
||||
|
||||
|
||||
async function handleLink(e) {
|
||||
e.preventDefault();
|
||||
@@ -64,11 +66,6 @@ const LinkAppModal = ({ app, onClose, onLinked }) => {
|
||||
|
||||
const selectedApp = apps.find(a => a.id === parseInt(selectedAppId));
|
||||
|
||||
const envDescriptions = {
|
||||
development: 'This app will be the DEVELOPMENT environment',
|
||||
production: 'This app will be the PRODUCTION environment',
|
||||
staging: 'This app will be the STAGING environment'
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={true} onClose={onClose} title={t('app.linkAppModal.linkApplication', 'Link Application')} className="link-app-modal">
|
||||
@@ -88,9 +85,9 @@ const LinkAppModal = ({ app, onClose, onLinked }) => {
|
||||
<p>
|
||||
{t('app.linkAppModal.thereAreNoOther', 'There are no other')} {app.app_type} {t('app.linkAppModal.applicationsAvailableToLinkCreateAnother', 'applications available to link. Create another')} {app.app_type} {t('app.linkAppModal.appFirstOrEnsureExistingApps', 'app first, or ensure existing apps are not already linked.')}
|
||||
</p>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
<SharedButton variant="outline" type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
{t('common.actions.close', 'Close')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleLink}>
|
||||
@@ -195,10 +192,10 @@ const LinkAppModal = ({ app, onClose, onLinked }) => {
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
<SharedButton variant="outline" type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
{t('common.actions.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={linking || !selectedAppId}>
|
||||
</SharedButton>
|
||||
<SharedButton variant="primary" type="submit" className="btn btn-primary" disabled={linking || !selectedAppId}>
|
||||
{linking ? (
|
||||
<>{t('app.linkAppModal.linking', 'Linking…')}</>
|
||||
) : (
|
||||
@@ -207,7 +204,7 @@ const LinkAppModal = ({ app, onClose, onLinked }) => {
|
||||
{t('app.linkAppModal.linkApps', 'Link Apps')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { GitBranch, Link2, Unlink, ExternalLink, Server } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
import { Card as SharedCard } from '@/components/ui/card';
|
||||
|
||||
const LinkedAppsSection = ({
|
||||
app,
|
||||
@@ -33,21 +35,21 @@ const LinkedAppsSection = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card linked-apps-section">
|
||||
<SharedCard variant="legacy" className="card linked-apps-section">
|
||||
<div className="linked-apps-header">
|
||||
<h3>
|
||||
<GitBranch size={18} />
|
||||
{t('app.linkedAppsSection.environmentLinking', 'Environment Linking')}
|
||||
</h3>
|
||||
{!app.has_linked_app && (
|
||||
<button type="button"
|
||||
<SharedButton variant="outline" type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={onLink}
|
||||
disabled={loading}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
{t('app.linkedAppsSection.linkApp', 'Link App')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,21 +91,21 @@ const LinkedAppsSection = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="linked-app-actions">
|
||||
<button type="button"
|
||||
<SharedButton variant="outline" type="button"
|
||||
className="btn btn-secondary btn-sm btn-icon"
|
||||
onClick={() => onNavigate(linkedApp.id)}
|
||||
title={t('app.linkedAppsSection.viewApp', 'View app')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
<button type="button"
|
||||
</SharedButton>
|
||||
<SharedButton variant="outline" type="button"
|
||||
className="btn btn-secondary btn-sm btn-icon"
|
||||
onClick={onUnlink}
|
||||
disabled={loading}
|
||||
title={t('app.linkedAppsSection.unlinkApps', 'Unlink apps')}
|
||||
>
|
||||
<Unlink size={14} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -111,14 +113,14 @@ const LinkedAppsSection = ({
|
||||
) : app.environment_type !== 'standalone' ? (
|
||||
<div className="linked-apps-empty">
|
||||
<p>{t('app.linkedAppsSection.noLinkedAppsLinkAnotherApp', 'No linked apps. Link another app to share database resources.')}</p>
|
||||
<button type="button"
|
||||
<SharedButton variant="outline" type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={onLink}
|
||||
disabled={loading}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
{t('app.linkedAppsSection.linkApp', 'Link App')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="linked-apps-info">
|
||||
@@ -144,7 +146,7 @@ const LinkedAppsSection = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SharedCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLocale } from '../contexts/LocaleContext';
|
||||
import { useAuth } from '../contexts/useAuth.js';
|
||||
import { useLocale } from '../contexts/useLocale.js';
|
||||
import api from '../services/api';
|
||||
|
||||
// Bridges auth -> locale (plan 79 B1/B2). LocaleProvider sits above
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, ResponsiveContainer, Area, AreaChart
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { Cpu, MemoryStick, HardDrive, TrendingUp } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Chart series colors — redesign "infra console" palette (see
|
||||
// docs/REDESIGN_MAP.md). Kept as hex (not CSS var()) on purpose: var() does not
|
||||
@@ -53,11 +54,7 @@ const MetricsGraph = ({ compact = false, timezone, serverId }) => {
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, [period, serverId]);
|
||||
|
||||
async function loadHistory() {
|
||||
const loadHistory = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = serverId
|
||||
@@ -70,26 +67,33 @@ const MetricsGraph = ({ compact = false, timezone, serverId }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [period, serverId]);
|
||||
|
||||
function formatTimestamp(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const tz = safeTimeZone(timezone);
|
||||
if (period === '1h' || period === '6h' || period === '24h') {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: tz });
|
||||
} else if (period === '7d') {
|
||||
return date.toLocaleDateString([], { weekday: 'short', hour: '2-digit', timeZone: tz });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric', timeZone: tz });
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, [loadHistory]);
|
||||
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
function formatTimestamp(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const tz = safeTimeZone(timezone);
|
||||
if (period === '1h' || period === '6h' || period === '24h') {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: tz });
|
||||
} else if (period === '7d') {
|
||||
return date.toLocaleDateString([], { weekday: 'short', hour: '2-digit', timeZone: tz });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric', timeZone: tz });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = data?.data?.map(point => ({
|
||||
time: formatTimestamp(point.timestamp),
|
||||
cpu: point.cpu?.percent ?? point.cpu_percent ?? 0,
|
||||
memory: point.memory?.percent ?? point.memory_percent ?? 0,
|
||||
disk: point.disk?.percent ?? point.disk_percent ?? 0
|
||||
})) || [];
|
||||
return data?.data?.map(point => ({
|
||||
time: formatTimestamp(point.timestamp),
|
||||
cpu: point.cpu?.percent ?? point.cpu_percent ?? 0,
|
||||
memory: point.memory?.percent ?? point.memory_percent ?? 0,
|
||||
disk: point.disk?.percent ?? point.disk_percent ?? 0
|
||||
})) || [];
|
||||
}, [data, timezone, period]);
|
||||
|
||||
// Auto-zoom: compute Y-axis ceiling from visible metrics
|
||||
const yDomain = useMemo(() => {
|
||||
@@ -171,13 +175,13 @@ const MetricsGraph = ({ compact = false, timezone, serverId }) => {
|
||||
</div>
|
||||
<div className="period-selector">
|
||||
{periods.map(p => (
|
||||
<button type="button"
|
||||
<SharedButton variant="unstyled" type="button"
|
||||
key={p}
|
||||
className={`period-btn ${period === p ? 'active' : ''}`}
|
||||
onClick={() => setPeriod(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</SharedButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,34 +233,34 @@ const MetricsGraph = ({ compact = false, timezone, serverId }) => {
|
||||
<span>{t('app.metricsGraph.realTimePerformance', 'Real-time Performance')}</span>
|
||||
</div>
|
||||
<div className="metrics-filter-legend">
|
||||
<button type="button"
|
||||
<SharedButton variant="unstyled" type="button"
|
||||
className={`filter-btn cpu ${visibleMetrics.cpu ? 'active' : ''}`}
|
||||
onClick={() => toggleMetric('cpu')}
|
||||
>
|
||||
CPU
|
||||
</button>
|
||||
<button type="button"
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled" type="button"
|
||||
className={`filter-btn memory ${visibleMetrics.memory ? 'active' : ''}`}
|
||||
onClick={() => toggleMetric('memory')}
|
||||
>
|
||||
RAM
|
||||
</button>
|
||||
<button type="button"
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled" type="button"
|
||||
className={`filter-btn disk ${visibleMetrics.disk ? 'active' : ''}`}
|
||||
onClick={() => toggleMetric('disk')}
|
||||
>
|
||||
{t('common.labels.disk', 'Disk')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
<div className="period-selector">
|
||||
{periods.map(p => (
|
||||
<button type="button"
|
||||
<SharedButton variant="unstyled" type="button"
|
||||
key={p}
|
||||
className={`period-btn ${period === p ? 'active' : ''}`}
|
||||
onClick={() => setPeriod(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</SharedButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { t } from '../i18n/t';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useTheme } from '../contexts/useTheme.js';
|
||||
import ServerKitLogo from './ServerKitLogo';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import QuickCreate from './QuickCreate';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Fixed header shown only on narrow viewports (< 768px). Houses the
|
||||
// hamburger toggle that opens the sidebar as an off-canvas drawer, since
|
||||
@@ -16,7 +17,7 @@ const MobileTopBar = ({ navOpen, onToggle }) => {
|
||||
|
||||
return (
|
||||
<header className="mobile-topbar">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="mobile-topbar__toggle"
|
||||
aria-label={navOpen
|
||||
@@ -27,7 +28,7 @@ const MobileTopBar = ({ navOpen, onToggle }) => {
|
||||
onClick={onToggle}
|
||||
>
|
||||
{navOpen ? <X size={22} aria-hidden="true" /> : <Menu size={22} aria-hidden="true" />}
|
||||
</button>
|
||||
</SharedButton>
|
||||
<div className="mobile-topbar__brand">
|
||||
{!branded && (
|
||||
<span className="mobile-topbar__logo">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bell, Check, X } from 'lucide-react';
|
||||
import { useNotifications } from '../contexts/NotificationsContext';
|
||||
import { useNotifications } from '../contexts/useNotifications.js';
|
||||
import { timeAgo } from '../utils/time';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Severity → dot color, mirroring the email/brand palette.
|
||||
const SEVERITY_DOT = {
|
||||
@@ -65,7 +66,7 @@ export default function NotificationBell() {
|
||||
|
||||
return (
|
||||
<div className="sk-notif" ref={ref}>
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="sk-notif__bell"
|
||||
onClick={toggle}
|
||||
@@ -77,16 +78,16 @@ export default function NotificationBell() {
|
||||
>
|
||||
<Bell size={16} aria-hidden="true" />
|
||||
{unreadCount > 0 && <span className="sk-notif__badge">{badge}</span>}
|
||||
</button>
|
||||
</SharedButton>
|
||||
|
||||
{open && (
|
||||
<div className="sk-notif__panel" role="menu" aria-label={t('notifications.bell', 'Notifications')}>
|
||||
<div className="sk-notif__head">
|
||||
<span className="sk-notif__heading">{t('notifications.heading', 'Notifications')}</span>
|
||||
{unreadCount > 0 && (
|
||||
<button type="button" className="sk-notif__markall" onClick={markAllRead}>
|
||||
<SharedButton variant="unstyled" type="button" className="sk-notif__markall" onClick={markAllRead}>
|
||||
<Check size={13} aria-hidden="true" /> {t('notifications.markAllRead', 'Mark all read')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -97,7 +98,7 @@ export default function NotificationBell() {
|
||||
items.map((item) => (
|
||||
item.kind === 'notice' ? (
|
||||
<div key={item.delivery_id} className="sk-notif__item is-notice">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="sk-notif__hit"
|
||||
onClick={() => onItemClick(item)}
|
||||
@@ -114,18 +115,18 @@ export default function NotificationBell() {
|
||||
<span className="sk-notif__time">{item.action_label} →</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="sk-notif__dismiss"
|
||||
onClick={() => dismissNotice && dismissNotice(item.notice_id)}
|
||||
aria-label={t('notifications.dismissItem', 'Dismiss {{title}}', { title: item.title })}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
key={item.delivery_id}
|
||||
type="button"
|
||||
className={`sk-notif__item${item.read ? '' : ' is-unread'}`}
|
||||
@@ -144,19 +145,19 @@ export default function NotificationBell() {
|
||||
{item.action_path && <span className="sk-notif__cta"> · {item.action_label || 'Open'} →</span>}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</SharedButton>
|
||||
)
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="sk-notif__seeall"
|
||||
onClick={() => { setOpen(false); navigate('/notifications'); }}
|
||||
>
|
||||
{t('notifications.seeAll', 'See all notifications')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useOperations } from '../contexts/OperationsContext';
|
||||
import { useShellDock } from '../contexts/ShellDockContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useShellDock } from '../contexts/useShellDock.js';
|
||||
import { useToast } from '../contexts/useToast.js';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import { usePolling } from '../hooks/usePolling';
|
||||
import { useShortcut } from '../hooks/useShortcut';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useToast } from '../contexts/useToast.js';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import { useClipboard } from '../hooks/useClipboard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
const PrivateURLSection = ({ app, onUpdate }) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -128,13 +129,13 @@ const PrivateURLSection = ({ app, onUpdate }) => {
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
<SharedButton variant="primary"
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Enabling...' : 'Enable Private URL'}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</form>
|
||||
<p className="slug-hint">
|
||||
{t('app.privateURLSection.leaveEmptyToAutoGenerateA', 'Leave empty to auto-generate a random slug, or enter your own custom slug.')}
|
||||
@@ -148,7 +149,7 @@ const PrivateURLSection = ({ app, onUpdate }) => {
|
||||
<code className="url-value">{privateUrl}</code>
|
||||
</div>
|
||||
<div className="url-actions">
|
||||
<button type="button"
|
||||
<SharedButton variant="outline" type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={copyToClipboard}
|
||||
title={t('app.privateURLSection.copyToClipboard', 'Copy to clipboard')}
|
||||
@@ -158,8 +159,8 @@ const PrivateURLSection = ({ app, onUpdate }) => {
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
{t('common.actions.copy', 'Copy')}
|
||||
</button>
|
||||
<button type="button"
|
||||
</SharedButton>
|
||||
<SharedButton variant="outline" type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleRegenerate}
|
||||
disabled={loading}
|
||||
@@ -171,7 +172,7 @@ const PrivateURLSection = ({ app, onUpdate }) => {
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
{t('app.privateURLSection.regenerate', 'Regenerate')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,14 +191,14 @@ const PrivateURLSection = ({ app, onUpdate }) => {
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
<SharedButton variant="primary"
|
||||
type="submit"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={loading || !customSlug}
|
||||
>
|
||||
{t('common.actions.save', 'Save')}
|
||||
</button>
|
||||
<button
|
||||
</SharedButton>
|
||||
<SharedButton variant="outline"
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => {
|
||||
@@ -206,25 +207,25 @@ const PrivateURLSection = ({ app, onUpdate }) => {
|
||||
}}
|
||||
>
|
||||
{t('common.actions.cancel', 'Cancel')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</form>
|
||||
) : (
|
||||
<button type="button"
|
||||
<SharedButton variant="unstyled" type="button"
|
||||
className="btn-link"
|
||||
onClick={() => setEditMode(true)}
|
||||
>
|
||||
{t('app.privateURLSection.changeSlug', 'Change slug')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
)}
|
||||
|
||||
<div className="private-url-footer">
|
||||
<button type="button"
|
||||
<SharedButton variant="danger" type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={handleDisable}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('app.privateURLSection.disablePrivateUrl3', 'Disable Private URL')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { procUser } from './processData';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { processStateVariant } from '@/components/ds/status';
|
||||
import { X, AlertTriangle } from 'lucide-react';
|
||||
@@ -78,7 +79,6 @@ const PROCESS_VIEWS = [
|
||||
// psutil reports the owner as `username`; the per-PID detail endpoint and the
|
||||
// style-guide fixtures call it `user`. Read both so the column is never blank
|
||||
// against a real payload.
|
||||
export const procUser = (p) => p.user || p.username || '';
|
||||
|
||||
export function UsageCell({ percent = 0, variant = 'cpu', label }) {
|
||||
const clamped = Math.min(Number(percent) || 0, 100);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Plus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Global quick-create (the "+" button beside the brand). It used to carry its
|
||||
// own little dropdown; now it opens the command palette, whose empty state
|
||||
@@ -13,7 +14,7 @@ export function QuickCreate({ className, variant = 'icon' }) {
|
||||
// create button beside the ServerKit mark and GitHub star.
|
||||
const header = variant === 'header';
|
||||
return (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={cn('quick-create', header && 'quick-create--header', className)}
|
||||
title={t('app.quickCreate.createOrSearch', 'Create or search — Ctrl K')}
|
||||
@@ -21,7 +22,7 @@ export function QuickCreate({ className, variant = 'icon' }) {
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('serverkit:open-palette'))}
|
||||
>
|
||||
<Plus size={header ? 15 : 16} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import '@xterm/xterm/css/xterm.css';
|
||||
import api from '../services/api';
|
||||
import socketService from '../services/socket';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* RemoteTerminal - Interactive terminal component for remote server access
|
||||
@@ -261,7 +262,7 @@ export default function RemoteTerminal({ serverId, onClose }) {
|
||||
{sessionId && <span className="session-id">{sessionId}</span>}
|
||||
</div>
|
||||
<div className="terminal-actions">
|
||||
<button type="button"
|
||||
<SharedButton variant="unstyled" type="button"
|
||||
className="terminal-close-btn"
|
||||
onClick={handleClose}
|
||||
title={t('app.remoteTerminal.closeTerminal', 'Close terminal')}
|
||||
@@ -269,7 +270,7 @@ export default function RemoteTerminal({ serverId, onClose }) {
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" fill="none" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Boxes, Terminal } from 'lucide-react';
|
||||
import { useResourceTier } from '../contexts/ResourceTierContext';
|
||||
import { useResourceTier } from '../contexts/useResourceTier.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useResourceTier } from '../contexts/ResourceTierContext';
|
||||
import { useResourceTier } from '../contexts/useResourceTier.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Approximate steady-state RSS per workload, mirroring WORKLOAD_FOOTPRINTS_MB
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, AlertTriangle } from 'lucide-react';
|
||||
import { SegControl } from '@/components/ds';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// SchedulePicker — one friendly cron schedule picker shared by CronJobs, the
|
||||
// Backups ScheduleCard, the server-detail Cron tab, and extensions (via the SDK).
|
||||
@@ -258,7 +259,7 @@ export default function SchedulePicker({ value = '', onChange, compact = false,
|
||||
{mode === 'presets' && (
|
||||
<div className="schedule-picker__presets">
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
key={p.cron}
|
||||
type="button"
|
||||
className={`schedule-picker__preset${value.trim() === p.cron ? ' is-active' : ''}`}
|
||||
@@ -266,7 +267,7 @@ export default function SchedulePicker({ value = '', onChange, compact = false,
|
||||
>
|
||||
<span className="schedule-picker__preset-label">{p.label}</span>
|
||||
<span className="schedule-picker__preset-desc">{p.desc}</span>
|
||||
</button>
|
||||
</SharedButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -332,14 +333,14 @@ export default function SchedulePicker({ value = '', onChange, compact = false,
|
||||
<label>{t('app.schedulePicker.onDays', 'On days')}</label>
|
||||
<div className="schedule-picker__days">
|
||||
{WEEKDAYS.map((name, d) => (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
key={name}
|
||||
type="button"
|
||||
className={`schedule-picker__day${builder.weekdays.includes(d) ? ' is-active' : ''}`}
|
||||
onClick={() => toggleWeekday(d)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
</SharedButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Clock, CheckCircle2, AlertCircle, History } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { Pill, Drawer } from '@/components/ds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Read-only summary of an app's scheduled (cron) jobs. Rendered on the service
|
||||
// detail and WordPress detail Overview tabs. The backend endpoint is
|
||||
@@ -74,14 +75,14 @@ const ScheduledTasksCard = ({ appId }) => {
|
||||
<div className="scheduled-tasks-card__meta">
|
||||
<span className="scheduled-tasks-card__next">{formatWhen(job.next_run)}</span>
|
||||
<StatusHint job={job} />
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="scheduled-tasks-card__runs-btn"
|
||||
onClick={() => openRuns(job)}
|
||||
title={t('app.scheduledTasksCard.viewRecentRuns', 'View recent runs')}
|
||||
>
|
||||
<History size={13} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
<Pill kind={job.enabled ? 'green' : 'gray'}>
|
||||
{job.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Pill>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Maximize2, Minimize2, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useNotifications } from '../contexts/NotificationsContext';
|
||||
import { useNotifications } from '../contexts/useNotifications.js';
|
||||
import { useOperations } from '../contexts/OperationsContext';
|
||||
import { useServerkitAI } from '../contexts/AIContext';
|
||||
import { useShellDock } from '../contexts/ShellDockContext';
|
||||
import { useServerkitAI } from '../contexts/useServerkitAI.js';
|
||||
import { useShellDock } from '../contexts/useShellDock.js';
|
||||
import { useWalkthroughs } from '../contexts/walkthroughContextValue';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Shared header strip for every shell console panel. Wherever a panel opens
|
||||
// (Operations, Alerts, Recipes bottom console; Assistant right drawer), the
|
||||
@@ -38,7 +39,7 @@ export default function ShellDockTabs({ controls = null, expandable = true }) {
|
||||
return (
|
||||
<div className="shell-dock-tabs" role="tablist" aria-label={t('app.statusbar.consolePanels', 'Console panels')}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -48,12 +49,12 @@ export default function ShellDockTabs({ controls = null, expandable = true }) {
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge ? <span className="shell-dock-tabs__badge mono">{tab.badge}</span> : null}
|
||||
</button>
|
||||
</SharedButton>
|
||||
))}
|
||||
<span className="shell-dock-tabs__spacer" />
|
||||
{controls}
|
||||
{expandable && (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="shell-dock-tabs__control"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
@@ -66,9 +67,9 @@ export default function ShellDockTabs({ controls = null, expandable = true }) {
|
||||
: t('app.statusbar.expandPanel', 'Expand panel')}
|
||||
>
|
||||
{expanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
</SharedButton>
|
||||
)}
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="shell-dock-tabs__control"
|
||||
onClick={close}
|
||||
@@ -76,7 +77,7 @@ export default function ShellDockTabs({ controls = null, expandable = true }) {
|
||||
title={t('app.statusbar.closeEsc', 'Close (Esc)')}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useLayout } from '../contexts/LayoutContext';
|
||||
import { Star, Settings, LogOut, Sun, Moon, Monitor, ChevronRight, ChevronDown, ChevronUp, Layers, Palette, PanelLeft, PanelLeftClose, PanelTop, Check, X, Server } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/useAuth.js';
|
||||
import { useTheme } from '../contexts/useTheme.js';
|
||||
import { useLayout } from '../contexts/useLayout.js';
|
||||
import { Star, Settings, LogOut, Sun, Moon, Monitor, ChevronRight, ChevronUp, Layers, Palette, PanelLeft, PanelLeftClose, PanelTop, Check, X, Server } from 'lucide-react';
|
||||
import { api } from '../services/api';
|
||||
import { SIDEBAR_CATEGORIES, SIDEBAR_CATEGORY_LABELS, SIDEBAR_PRESETS, getHiddenItemIds, getVisibleItems, applyWorkspaceNavPermissions } from './sidebarItems';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -13,13 +13,14 @@ import { sanitizeSvgInner } from '../utils/sanitizeSvg';
|
||||
import useModules from '../hooks/useModules';
|
||||
import useDevMode from '../hooks/useDevMode';
|
||||
import QuickCreate from './QuickCreate';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
import { useWorkspace } from '../contexts/useWorkspace.js';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {} }) => {
|
||||
const { t } = useTranslation();
|
||||
const label = useLabel();
|
||||
const { user, logout, updateUser, hasPermission } = useAuth();
|
||||
const { theme, resolvedTheme, setTheme, whiteLabel } = useTheme();
|
||||
const { theme, setTheme, whiteLabel } = useTheme();
|
||||
const { layout, setLayout } = useLayout();
|
||||
const { activeWorkspace } = useWorkspace();
|
||||
const navigate = useNavigate();
|
||||
@@ -209,7 +210,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
// and it defines a nav map. This lets a workspace restrict which sidebar
|
||||
// items its members see based on their effective workspace role.
|
||||
return applyWorkspaceNavPermissions(items, activeWorkspace, user);
|
||||
}, [user?.sidebar_config, pluginNav, pluginTabs, wpInstalled, gpuAvailable, wordpressEnabled, devMode, user, hasPermission, activeWorkspace]);
|
||||
}, [pluginNav, pluginTabs, wpInstalled, gpuAvailable, wordpressEnabled, devMode, user, hasPermission, activeWorkspace]);
|
||||
|
||||
// Group visible items by category
|
||||
const groupedItems = useMemo(() => {
|
||||
@@ -269,7 +270,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
{label(item)}
|
||||
</NavLink>
|
||||
{visibleSubs.length > 0 && (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`nav-expand-btn ${isExpanded ? 'expanded' : ''}`}
|
||||
aria-expanded={isExpanded}
|
||||
@@ -279,7 +280,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(item.id); }}
|
||||
>
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && visibleSubs.map(sub => (
|
||||
@@ -307,14 +308,14 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
aria-label={t('nav.mainNavigation', 'Main navigation')}
|
||||
>
|
||||
{isMobile && (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="sidebar__close"
|
||||
aria-label={t('nav.closeMenu', 'Close navigation menu')}
|
||||
onClick={onMobileClose}
|
||||
>
|
||||
<X size={20} aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
)}
|
||||
{whiteLabel.enabled ? (
|
||||
<div className="brand-section brand-section--custom">
|
||||
@@ -442,7 +443,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
<div className="context-menu-section">
|
||||
<div className="context-menu-label" id="theme-switcher-label">{t('nav.theme', 'Theme')}</div>
|
||||
<div className="theme-switcher" role="group" aria-labelledby="theme-switcher-label">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`theme-btn ${theme === 'dark' ? 'active' : ''}`}
|
||||
onClick={() => setTheme('dark')}
|
||||
@@ -451,8 +452,8 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
title={t('nav.themeDark', 'Dark')}
|
||||
>
|
||||
<Moon size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`theme-btn ${theme === 'light' ? 'active' : ''}`}
|
||||
onClick={() => setTheme('light')}
|
||||
@@ -461,8 +462,8 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
title={t('nav.themeLight', 'Light')}
|
||||
>
|
||||
<Sun size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`theme-btn ${theme === 'system' ? 'active' : ''}`}
|
||||
onClick={() => setTheme('system')}
|
||||
@@ -471,13 +472,13 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
title={t('nav.themeSystem', 'System')}
|
||||
>
|
||||
<Monitor size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-menu-section">
|
||||
<div className="context-menu-label" id="layout-switcher-label">{t('nav.layout', 'Layout')}</div>
|
||||
<div className="theme-switcher" role="group" aria-labelledby="layout-switcher-label">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`theme-btn ${layout === 'sidebar' ? 'active' : ''}`}
|
||||
onClick={() => setLayout('sidebar')}
|
||||
@@ -486,8 +487,8 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
title={t('nav.layoutSidebar', 'Sidebar')}
|
||||
>
|
||||
<PanelLeft size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`theme-btn ${layout === 'rail' ? 'active' : ''}`}
|
||||
onClick={() => setLayout('rail')}
|
||||
@@ -496,8 +497,8 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
title={t('nav.layoutCompact', 'Compact')}
|
||||
>
|
||||
<PanelLeftClose size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`theme-btn ${layout === 'topbar' ? 'active' : ''}`}
|
||||
onClick={() => setLayout('topbar')}
|
||||
@@ -506,14 +507,14 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
title={t('nav.layoutTopbar', 'Top bar')}
|
||||
>
|
||||
<PanelTop size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-menu-section">
|
||||
<div className="context-menu-label" id="sidebar-view-label">{t('nav.sidebarView', 'Sidebar View')}</div>
|
||||
<div className="view-switcher" role="group" aria-labelledby="sidebar-view-label">
|
||||
{Object.entries(SIDEBAR_PRESETS).map(([key, preset]) => (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
key={key}
|
||||
type="button"
|
||||
className={`view-btn ${currentPreset === key ? 'active' : ''}`}
|
||||
@@ -523,12 +524,12 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
>
|
||||
{label(preset)}
|
||||
{currentPreset === key && <Check size={10} aria-hidden="true" />}
|
||||
</button>
|
||||
</SharedButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="context-menu-item"
|
||||
onClick={() => { navigate('/settings/appearance'); setMenuOpen(false); }}
|
||||
@@ -536,8 +537,8 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
<Palette size={15} aria-hidden="true" />
|
||||
{t('nav.appearance', 'Appearance')}
|
||||
<ChevronRight size={14} className="context-menu-arrow" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="context-menu-item"
|
||||
onClick={() => { navigate('/settings/sidebar'); setMenuOpen(false); }}
|
||||
@@ -545,8 +546,8 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
<PanelLeft size={15} aria-hidden="true" />
|
||||
{t('nav.customizeSidebar', 'Customize Sidebar')}
|
||||
<ChevronRight size={14} className="context-menu-arrow" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="context-menu-item"
|
||||
onClick={() => { navigate('/settings'); setMenuOpen(false); }}
|
||||
@@ -554,16 +555,16 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
<Settings size={15} aria-hidden="true" />
|
||||
{t('nav.allSettings', 'All Settings')}
|
||||
<ChevronRight size={14} className="context-menu-arrow" aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
<div className="context-menu-divider" />
|
||||
<button type="button" className="context-menu-item danger" onClick={logout}>
|
||||
<SharedButton variant="unstyled" type="button" className="context-menu-item danger" onClick={logout}>
|
||||
<LogOut size={15} aria-hidden="true" />
|
||||
{t('common.actions.logOut', 'Log out')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-footer__row">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="user-mini"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
@@ -579,7 +580,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {
|
||||
<span className="user-status">{t('nav.online', 'Online')}</span>
|
||||
</span>
|
||||
<ChevronUp size={14} className={`user-menu-arrow ${menuOpen ? 'open' : ''}`} aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -82,14 +82,14 @@ export default function SystemNotices() {
|
||||
<Settings size={13} />
|
||||
)}
|
||||
</Button>
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="system-notice__dismiss"
|
||||
onClick={() => handleDismiss(notice.id)}
|
||||
aria-label={t('app.systemNotices.dismiss', 'Dismiss {{title}}', { title: notice.title })}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useAuth } from '../contexts/useAuth.js';
|
||||
import { useTheme } from '../contexts/useTheme.js';
|
||||
import api from '../services/api';
|
||||
|
||||
// Bridges auth → theme (plan 60). ThemeProvider sits above AuthProvider, so it
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useShellDock } from '../contexts/ShellDockContext';
|
||||
import { useShellDock } from '../contexts/useShellDock.js';
|
||||
import { useWalkthroughs } from '../contexts/walkthroughContextValue';
|
||||
import { getWalkthroughProgress } from '../services/walkthroughState';
|
||||
import Pill from './ds/Pill';
|
||||
@@ -59,7 +59,7 @@ function RecipeLibrary({ walkthroughs, state, onStart, onStop, t }) {
|
||||
const completed = status === 'completed';
|
||||
return (
|
||||
<div key={walkthrough.id} className={`shell-recipes__card${active ? ' is-active' : ''}`}>
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="shell-recipes__card-hit"
|
||||
onClick={() => onStart(walkthrough.id)}
|
||||
@@ -67,7 +67,7 @@ function RecipeLibrary({ walkthroughs, state, onStart, onStop, t }) {
|
||||
<span className={`shell-recipes__card-icon is-${walkthrough.tone}`}><Icon size={16} /></span>
|
||||
<span className="shell-recipes__card-name">{walkthrough.title}</span>
|
||||
<span className="shell-recipes__card-desc">{walkthrough.description}</span>
|
||||
</button>
|
||||
</Button>
|
||||
<span className="shell-recipes__card-meta mono">
|
||||
<Clock3 size={11} aria-hidden="true" />
|
||||
{t('app.walkthroughs.stepsMeta', '{{count}} steps · {{duration}}', {
|
||||
@@ -80,20 +80,20 @@ function RecipeLibrary({ walkthroughs, state, onStart, onStop, t }) {
|
||||
<Pill kind="neutral">{walkthrough.origin.plugin.replace(/^serverkit-/, '')}</Pill>
|
||||
)}
|
||||
{active && (
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="shell-recipes__card-stop"
|
||||
onClick={() => onStop(walkthrough.id)}
|
||||
>
|
||||
<Square size={10} aria-hidden="true" /> {t('app.walkthroughs.stopShort', 'Stop')}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{secondary.length > 0 && (
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="shell-recipes__more"
|
||||
onClick={() => setShowMore((current) => !current)}
|
||||
@@ -110,7 +110,7 @@ function RecipeLibrary({ walkthroughs, state, onStart, onStop, t }) {
|
||||
: t('app.walkthroughs.moreGuidesCount', '{{count}} optional guides', { count: secondary.length })}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -142,7 +142,7 @@ export default function WalkthroughHub() {
|
||||
setBrowse(false);
|
||||
}, [activeWalkthrough?.id]);
|
||||
|
||||
const steps = activeWalkthrough?.steps || [];
|
||||
const steps = useMemo(() => activeWalkthrough?.steps || [], [activeWalkthrough?.steps]);
|
||||
const viewStep = useMemo(() => {
|
||||
const chosen = steps.find((step) => step.id === viewStepId);
|
||||
return chosen || currentStep || steps[steps.length - 1] || null;
|
||||
@@ -221,7 +221,7 @@ export default function WalkthroughHub() {
|
||||
const done = completedSteps.includes(step.id);
|
||||
const viewing = viewStep && step.id === viewStep.id;
|
||||
return (
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
key={step.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -233,7 +233,7 @@ export default function WalkthroughHub() {
|
||||
{done ? <Check size={12} /> : <span className="mono">{index + 1}</span>}
|
||||
</span>
|
||||
<span className="shell-recipes__step-title">{step.title}</span>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Building2 } from 'lucide-react';
|
||||
import { api } from '../services/api';
|
||||
import { useWorkspace } from '../contexts/WorkspaceContext';
|
||||
import { useWorkspace } from '../contexts/useWorkspace.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Select,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useServerkitAI } from '../../contexts/AIContext';
|
||||
import { useAuth } from '../../contexts/useAuth.js';
|
||||
import { useServerkitAI } from '../../contexts/useServerkitAI.js';
|
||||
import ChatBubble from './ChatBubble';
|
||||
import ChatDrawer from './ChatDrawer';
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { MessageSquare, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Intercom-style launcher. `raised` lifts it above the serverkit-gui FAB on
|
||||
// server-detail routes so the two don't overlap.
|
||||
const ChatBubble = ({ open, unread, streaming, raised, onToggle }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={[
|
||||
'sk-ai-bubble',
|
||||
@@ -23,7 +24,7 @@ const ChatBubble = ({ open, unread, streaming, raised, onToggle }) => {
|
||||
>
|
||||
{open ? <X size={22} /> : <MessageSquare size={22} />}
|
||||
{!open && unread > 0 ? <span className="sk-ai-bubble__dot" aria-hidden="true" /> : null}
|
||||
</button>
|
||||
</SharedButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useServerkitAI } from '../../contexts/AIContext';
|
||||
import { useServerkitAI } from '../../contexts/useServerkitAI.js';
|
||||
import useMediaQuery from '../../hooks/useMediaQuery';
|
||||
import { useLockBodyScroll } from '../../hooks/useLockBodyScroll';
|
||||
import useFocusTrap from '../../hooks/ai/useFocusTrap';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Paperclip, Send, Square } from 'lucide-react';
|
||||
import { useServerkitAI } from '../../contexts/AIContext';
|
||||
import { useServerkitAI } from '../../contexts/useServerkitAI.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ResourcePicker from '../ResourcePicker';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { useServerkitAI } from '../../contexts/AIContext';
|
||||
import { useServerkitAI } from '../../contexts/useServerkitAI.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
const formatParams = (params) => {
|
||||
if (!params || !Object.keys(params).length) return null;
|
||||
@@ -33,21 +34,21 @@ const ConfirmActionCard = () => {
|
||||
<pre className="sk-ai-code sk-ai-confirm__params"><code>{params}</code></pre>
|
||||
) : null}
|
||||
<div className="sk-ai-confirm__actions">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
ref={denyRef}
|
||||
type="button"
|
||||
className="sk-ai-btn sk-ai-btn--ghost"
|
||||
onClick={() => confirmAction('deny')}
|
||||
>
|
||||
{t('app.confirmActionCard.deny', 'Deny')}
|
||||
</button>
|
||||
<button
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="sk-ai-btn sk-ai-btn--danger"
|
||||
onClick={() => confirmAction('approve')}
|
||||
>
|
||||
{t('app.confirmActionCard.approveRun', 'Approve & run')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MapPin } from 'lucide-react';
|
||||
import { useServerkitAI } from '../../contexts/AIContext';
|
||||
import { useServerkitAI } from '../../contexts/useServerkitAI.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Shows the page the assistant is aware of, and lets the user toggle whether
|
||||
// that context is attached to the next message. Assistant mode only.
|
||||
@@ -10,7 +11,7 @@ const ContextChip = () => {
|
||||
if (mode !== 'assistant') return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`sk-ai-context-chip${includeContext ? ' is-on' : ' is-off'}`}
|
||||
aria-pressed={includeContext}
|
||||
@@ -21,7 +22,7 @@ const ContextChip = () => {
|
||||
>
|
||||
<MapPin size={13} />
|
||||
<span>{includeContext ? `Asking about: ${pageContext.label}` : 'No page context'}</span>
|
||||
</button>
|
||||
</SharedButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { History, Plus, Trash2 } from 'lucide-react';
|
||||
import { useServerkitAI } from '../../contexts/AIContext';
|
||||
import { useServerkitAI } from '../../contexts/useServerkitAI.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
const ConversationMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -23,7 +24,7 @@ const ConversationMenu = () => {
|
||||
|
||||
return (
|
||||
<div className="sk-ai-convo" ref={wrapRef}>
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="sk-ai-iconbtn"
|
||||
aria-label={t('app.conversationMenu.conversations', 'Conversations')}
|
||||
@@ -31,16 +32,16 @@ const ConversationMenu = () => {
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<History size={16} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
{open ? (
|
||||
<div className="sk-ai-convo__menu" role="menu">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="sk-ai-convo__new"
|
||||
onClick={() => { newConversation(); setOpen(false); }}
|
||||
>
|
||||
<Plus size={14} /> {t('app.conversationMenu.newChat', 'New chat')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
<div className="sk-ai-convo__list">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="sk-ai-convo__empty">{t('app.conversationMenu.noPastConversations', 'No past conversations')}</div>
|
||||
@@ -49,22 +50,22 @@ const ConversationMenu = () => {
|
||||
key={c.id}
|
||||
className={`sk-ai-convo__item${c.id === activeId ? ' is-active' : ''}`}
|
||||
>
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="sk-ai-convo__title"
|
||||
onClick={() => { switchConversation(c.id); setOpen(false); }}
|
||||
title={c.title}
|
||||
>
|
||||
{c.title}
|
||||
</button>
|
||||
<button
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="sk-ai-convo__del"
|
||||
aria-label={t('app.conversationMenu.deleteConversation', 'Delete conversation')}
|
||||
onClick={() => deleteConversation(c.id)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useServerkitAI } from '../../contexts/AIContext';
|
||||
import { useAuth } from '../../contexts/useAuth.js';
|
||||
import { useServerkitAI } from '../../contexts/useServerkitAI.js';
|
||||
import ModeToggle from './ModeToggle';
|
||||
import ConversationMenu from './ConversationMenu';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Slim toolbar under the shared console tabs. Close lives in the tab strip
|
||||
// above, so this row carries only the assistant-specific controls.
|
||||
@@ -23,14 +24,14 @@ const DrawerHeader = () => {
|
||||
<ModeToggle />
|
||||
<ConversationMenu />
|
||||
{isAdmin ? (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="sk-ai-iconbtn"
|
||||
aria-label={t('app.drawerHeader.aiSettings', 'AI settings')}
|
||||
onClick={() => { close(); navigate('/settings/ai'); }}
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ArrowDown, Sparkles } from 'lucide-react';
|
||||
import { useServerkitAI } from '../../contexts/AIContext';
|
||||
import { useServerkitAI } from '../../contexts/useServerkitAI.js';
|
||||
import { useContributions } from '../../plugins/contributions';
|
||||
import useAutoScroll from '../../hooks/ai/useAutoScroll';
|
||||
import Message from './Message';
|
||||
import TypingIndicator from './TypingIndicator';
|
||||
import ConfirmActionCard from './ConfirmActionCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
const routeMatches = (pattern, route) => {
|
||||
if (!pattern || pattern === '*') return true;
|
||||
@@ -51,14 +52,14 @@ const MessageList = () => {
|
||||
) : (
|
||||
<div className="sk-ai-empty__prompts">
|
||||
{suggestions.map((p) => (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
key={p.label}
|
||||
type="button"
|
||||
className="sk-ai-suggested"
|
||||
onClick={() => ask(p.prompt, { open: true })}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
</SharedButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -72,9 +73,9 @@ const MessageList = () => {
|
||||
)}
|
||||
|
||||
{!isPinned && !isEmpty ? (
|
||||
<button type="button" className="sk-ai-jump" onClick={scrollToBottom} aria-label={t('app.messageList.jumpToLatest', 'Jump to latest')}>
|
||||
<SharedButton variant="unstyled" type="button" className="sk-ai-jump" onClick={scrollToBottom} aria-label={t('app.messageList.jumpToLatest', 'Jump to latest')}>
|
||||
<ArrowDown size={16} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useServerkitAI } from '../../contexts/AIContext';
|
||||
import { useServerkitAI } from '../../contexts/useServerkitAI.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
const MODES = [
|
||||
{ id: 'assistant', labelKey: 'app.modeToggle.assistant', label: 'Assistant' },
|
||||
@@ -13,7 +14,7 @@ const ModeToggle = () => {
|
||||
return (
|
||||
<div className="sk-ai-modes" role="tablist" aria-label={t('app.modeToggle.chatMode', 'Chat mode')}>
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
key={m.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -22,7 +23,7 @@ const ModeToggle = () => {
|
||||
onClick={() => setMode(m.id)}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
</SharedButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Wrench, Check, AlertTriangle, Loader2, ChevronDown } from 'lucide-react';
|
||||
import { useServerkitAI } from '../../contexts/AIContext';
|
||||
import { useServerkitAI } from '../../contexts/useServerkitAI.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Strip the "<prefix>__" namespace for display (e.g. core__list_apps -> list_apps).
|
||||
const displayName = (qualified) => {
|
||||
@@ -34,7 +35,7 @@ const ToolCallCard = ({ call }) => {
|
||||
|
||||
return (
|
||||
<div className={`sk-ai-tool-card${call.isError ? ' sk-ai-tool-card--error' : ''}`}>
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="sk-ai-tool-card__head"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
@@ -46,7 +47,7 @@ const ToolCallCard = ({ call }) => {
|
||||
{statusIcon}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`sk-ai-tool-card__chev${expanded ? ' is-open' : ''}`} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
{expanded && (
|
||||
<div className="sk-ai-tool-card__body">
|
||||
{Custom ? (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import EmptyState from '../EmptyState';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { InfoList, InfoItem } from '../InfoList';
|
||||
import BuildpackPreview from '../buildpack/BuildpackPreview';
|
||||
@@ -11,23 +11,21 @@ import { Input } from '@/components/ui/input';
|
||||
import { Pill, statusKind } from '@/components/ds';
|
||||
import Modal from '@/components/Modal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card as SharedCard } from '@/components/ui/card';
|
||||
import { CardHeader as SharedCardHeader, CardFooter as SharedCardFooter } from '@/components/ui/card';
|
||||
|
||||
const BuildTab = ({ appId, appPath, app }) => {
|
||||
const BuildTab = ({ appId, app }) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { confirm: confirmBuild } = useConfirm();
|
||||
const [buildConfig, setBuildConfig] = useState(null);
|
||||
const [detection, setDetection] = useState(null);
|
||||
const [deployments, setDeployments] = useState([]);
|
||||
const [currentDeployment, setCurrentDeployment] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [showConfigModal, setShowConfigModal] = useState(false);
|
||||
const [showLogsModal, setShowLogsModal] = useState(false);
|
||||
const [selectedLog, setSelectedLog] = useState(null);
|
||||
const [buildLogs, setBuildLogs] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [bpDockerfile, setBpDockerfile] = useState(null);
|
||||
|
||||
@@ -53,11 +51,7 @@ const BuildTab = ({ appId, appPath, app }) => {
|
||||
keepDeployments: 5
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [appId]);
|
||||
|
||||
async function loadData() {
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [configRes, detectRes, deploymentsRes] = await Promise.all([
|
||||
@@ -82,14 +76,18 @@ const BuildTab = ({ appId, appPath, app }) => {
|
||||
}
|
||||
|
||||
setDeployments(deploymentsRes.deployments || []);
|
||||
setCurrentDeployment(deploymentsRes.current);
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [appId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
|
||||
async function handleConfigureBuild(e) {
|
||||
e.preventDefault();
|
||||
@@ -191,12 +189,12 @@ const BuildTab = ({ appId, appPath, app }) => {
|
||||
{error && (
|
||||
<div className="alert alert-danger">
|
||||
{error}
|
||||
<button type="button" onClick={() => setError(null)} className="alert-close">×</button>
|
||||
<Button variant="unstyled" type="button" onClick={() => setError(null)} className="alert-close">×</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detection && (
|
||||
<div className="card">
|
||||
<SharedCard variant="legacy" className="card">
|
||||
<h3>{t('app.buildTab.autoDetectionResults', 'Auto-Detection Results')}</h3>
|
||||
<div className="detection-results">
|
||||
<div className="detection-item">
|
||||
@@ -216,27 +214,27 @@ const BuildTab = ({ appId, appPath, app }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SharedCard>
|
||||
)}
|
||||
|
||||
{app?.buildpack_plan && (
|
||||
<div className="card">
|
||||
<SharedCard variant="legacy" className="card">
|
||||
<h3>{t('app.buildTab.buildPack', 'Build Pack')}</h3>
|
||||
<BuildpackPreview
|
||||
plan={app.buildpack_plan}
|
||||
dockerfile={bpDockerfile}
|
||||
overrides={app.buildpack_overrides || {}}
|
||||
/>
|
||||
</div>
|
||||
</SharedCard>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header-row">
|
||||
<SharedCard variant="legacy" className="card">
|
||||
<SharedCardHeader variant="legacy-row" className="card-header-row">
|
||||
<h3>{t('app.buildTab.buildConfiguration', 'Build Configuration')}</h3>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowConfigModal(true)}>
|
||||
{t('app.buildTab.configure', 'Configure')}
|
||||
</Button>
|
||||
</div>
|
||||
</SharedCardHeader>
|
||||
{buildConfig ? (
|
||||
<InfoList>
|
||||
<InfoItem label={t('app.buildTab.method', 'Method')} value={buildConfig.build_method} />
|
||||
@@ -245,7 +243,7 @@ const BuildTab = ({ appId, appPath, app }) => {
|
||||
) : (
|
||||
<p className="hint">{t('app.buildTab.noBuildConfigurationClickConfigureTo', 'No build configuration. Click Configure to set up.')}</p>
|
||||
)}
|
||||
<div className="card-actions">
|
||||
<SharedCardFooter variant="legacy" className="card-actions">
|
||||
<Button
|
||||
onClick={() => handleDeploy(false)}
|
||||
disabled={deploying || building}
|
||||
@@ -259,11 +257,11 @@ const BuildTab = ({ appId, appPath, app }) => {
|
||||
>
|
||||
{building ? 'Building...' : 'Build Only'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SharedCardFooter>
|
||||
</SharedCard>
|
||||
|
||||
{deployments.length > 0 && (
|
||||
<div className="card">
|
||||
<SharedCard variant="legacy" className="card">
|
||||
<h3>{t('app.buildTab.deploymentHistory', 'Deployment History')}</h3>
|
||||
<div className="deployments-list">
|
||||
{deployments.map(dep => (
|
||||
@@ -289,7 +287,7 @@ const BuildTab = ({ appId, appPath, app }) => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SharedCard>
|
||||
)}
|
||||
|
||||
<Modal open={showConfigModal} onClose={() => setShowConfigModal(false)} title={t('app.buildTab.buildConfiguration', 'Build Configuration')}>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import api from '../../services/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const CommandsTab = ({ appId, appType }) => {
|
||||
const { t } = useTranslation();
|
||||
const [command, setCommand] = useState('');
|
||||
const [output, setOutput] = useState(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const quickCommands = appType === 'django' ? [
|
||||
{ labelKey: 'app.commandsTab.runMigrations', label: 'Run Migrations', cmd: 'python manage.py migrate' },
|
||||
{ labelKey: 'app.commandsTab.collectStatic', label: 'Collect Static', cmd: 'python manage.py collectstatic --noinput' },
|
||||
{ labelKey: 'app.commandsTab.createSuperuser', label: 'Create Superuser', cmd: 'python manage.py createsuperuser' },
|
||||
{ labelKey: 'app.commandsTab.shell', label: 'Shell', cmd: 'python manage.py shell' },
|
||||
{ labelKey: 'app.commandsTab.check', label: 'Check', cmd: 'python manage.py check' },
|
||||
] : [
|
||||
{ labelKey: 'app.commandsTab.flaskRoutes', label: 'Flask Routes', cmd: 'flask routes' },
|
||||
{ labelKey: 'app.commandsTab.flaskShell', label: 'Flask Shell', cmd: 'flask shell' },
|
||||
{ labelKey: 'app.commandsTab.dbUpgrade', label: 'DB Upgrade', cmd: 'flask db upgrade' },
|
||||
{ labelKey: 'app.commandsTab.dbMigrate', label: 'DB Migrate', cmd: 'flask db migrate' },
|
||||
];
|
||||
|
||||
async function handleRun(cmd) {
|
||||
const commandToRun = cmd || command;
|
||||
if (!commandToRun.trim()) return;
|
||||
|
||||
setRunning(true);
|
||||
setOutput(null);
|
||||
|
||||
try {
|
||||
const result = await api.runPythonCommand(appId, commandToRun);
|
||||
setOutput(result);
|
||||
} catch (err) {
|
||||
setOutput({ success: false, stderr: err.message });
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="app-eyebrow">{t('app.commandsTab.runCommands', 'Run Commands')}</h3>
|
||||
<p className="hint">{t('app.commandsTab.commandsRunInTheAppS', 'Commands run in the app\'s virtual environment context.')}</p>
|
||||
|
||||
<div className="quick-commands">
|
||||
{quickCommands.map(({ label, cmd }) => (
|
||||
<Button
|
||||
key={cmd}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRun(cmd)}
|
||||
disabled={running}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="command-input">
|
||||
<Input
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder={t('app.commandsTab.enterCommand', 'Enter command…')}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRun()}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleRun()}
|
||||
disabled={running}
|
||||
>
|
||||
{running ? 'Running...' : 'Run'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{output && (
|
||||
<div className={`command-output ${output.success ? '' : 'error'}`}>
|
||||
{output.stdout && <pre>{output.stdout}</pre>}
|
||||
{output.stderr && <pre className="stderr">{output.stderr}</pre>}
|
||||
{!output.stdout && !output.stderr && (
|
||||
<pre>{output.success ? 'Command completed successfully' : 'Command failed'}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandsTab;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { GitMerge } from 'lucide-react';
|
||||
import api from '../../services/api';
|
||||
import EmptyState from '../EmptyState';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { InfoList, InfoItem } from '../InfoList';
|
||||
import DeploymentTimeline from '../deployments/DeploymentTimeline';
|
||||
@@ -12,24 +12,23 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Pill, statusKind } from '@/components/ds';
|
||||
import Modal from '@/components/Modal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card as SharedCard } from '@/components/ui/card';
|
||||
import { CardFooter as SharedCardFooter } from '@/components/ui/card';
|
||||
|
||||
// `embedded` renders this inside the Settings → Git & Deploy section, where the
|
||||
// shared RepoConnectForm already owns the connect/disconnect + repo identity. In
|
||||
// that mode we drop the empty-state CTA and the repo-config fields (repo/branch/
|
||||
// auto-deploy) and surface only the deploy pipeline: run actions, deploy scripts,
|
||||
// history and config checkpoints.
|
||||
const DeployTab = ({ appId, appPath, embedded = false }) => {
|
||||
const DeployTab = ({ appId, embedded = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { confirm: confirmDeploy } = useConfirm();
|
||||
const [config, setConfig] = useState(null);
|
||||
const [gitStatus, setGitStatus] = useState(null);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [showConfigModal, setShowConfigModal] = useState(false);
|
||||
const [loadingBranches, setLoadingBranches] = useState(false);
|
||||
const [branches, setBranches] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [configForm, setConfigForm] = useState({
|
||||
@@ -40,11 +39,7 @@ const DeployTab = ({ appId, appPath, embedded = false }) => {
|
||||
postDeployScript: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [appId]);
|
||||
|
||||
async function loadData() {
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [configRes, historyRes] = await Promise.all([
|
||||
@@ -61,10 +56,6 @@ const DeployTab = ({ appId, appPath, embedded = false }) => {
|
||||
preDeployScript: configRes.config.pre_deploy_script || '',
|
||||
postDeployScript: configRes.config.post_deploy_script || ''
|
||||
});
|
||||
try {
|
||||
const statusRes = await api.getAppGitStatus(appId);
|
||||
setGitStatus(statusRes);
|
||||
} catch { /* git status is optional context for the deploy tab */ }
|
||||
} else {
|
||||
setConfig(null);
|
||||
}
|
||||
@@ -75,7 +66,12 @@ const DeployTab = ({ appId, appPath, embedded = false }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [appId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
|
||||
async function handleConfigureDeployment(e) {
|
||||
e.preventDefault();
|
||||
@@ -101,7 +97,6 @@ const DeployTab = ({ appId, appPath, embedded = false }) => {
|
||||
try {
|
||||
await api.removeDeployment(appId);
|
||||
setConfig(null);
|
||||
setGitStatus(null);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -153,7 +148,7 @@ const DeployTab = ({ appId, appPath, embedded = false }) => {
|
||||
{error && (
|
||||
<div className="alert alert-danger">
|
||||
{error}
|
||||
<button type="button" onClick={() => setError(null)} className="alert-close">×</button>
|
||||
<Button variant="unstyled" type="button" onClick={() => setError(null)} className="alert-close">×</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -212,7 +207,7 @@ const DeployTab = ({ appId, appPath, embedded = false }) => {
|
||||
</div>
|
||||
|
||||
<div className="deploy-grid">
|
||||
<div className="card">
|
||||
<SharedCard variant="legacy" className="card">
|
||||
<h3>{embedded ? 'Deploy Scripts' : 'Configuration'}</h3>
|
||||
{embedded ? (
|
||||
<InfoList>
|
||||
@@ -226,7 +221,7 @@ const DeployTab = ({ appId, appPath, embedded = false }) => {
|
||||
<InfoItem label={t('app.deployTab.autoDeploy', 'Auto Deploy')} value={config.auto_deploy ? 'Enabled' : 'Disabled'} />
|
||||
</InfoList>
|
||||
)}
|
||||
<div className="card-actions">
|
||||
<SharedCardFooter variant="legacy" className="card-actions">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowConfigModal(true)}>
|
||||
{embedded ? 'Edit Scripts' : 'Edit'}
|
||||
</Button>
|
||||
@@ -235,11 +230,11 @@ const DeployTab = ({ appId, appPath, embedded = false }) => {
|
||||
{t('common.actions.remove', 'Remove')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SharedCardFooter>
|
||||
</SharedCard>
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="card">
|
||||
<SharedCard variant="legacy" className="card">
|
||||
<h3>{t('app.deployTab.deploymentHistory', 'Deployment History')}</h3>
|
||||
<div className="deployments-list">
|
||||
{history.slice(0, 5).map((dep, idx) => (
|
||||
@@ -249,7 +244,7 @@ const DeployTab = ({ appId, appPath, embedded = false }) => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SharedCard>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -257,13 +252,13 @@ const DeployTab = ({ appId, appPath, embedded = false }) => {
|
||||
|
||||
{/* Config snapshot timeline + diff — additive, independent of git
|
||||
config so it shows the deploy history & config changes for any app. */}
|
||||
<div className="card deploy-timeline-card">
|
||||
<SharedCard variant="legacy" className="card deploy-timeline-card">
|
||||
<h3>{t('app.deployTab.configCheckpoints', 'Config Checkpoints')}</h3>
|
||||
<p className="deploy-timeline-card__hint">
|
||||
{t('app.deployTab.anImmutableConfigCheckpointEnvKeys', 'An immutable config checkpoint (env keys, domains, image, build method, volumes) is captured before each deployment. Secret values are masked. Open a checkpoint to diff it against the previous one or restore it.')}
|
||||
</p>
|
||||
<DeploymentTimeline appId={appId} />
|
||||
</div>
|
||||
</SharedCard>
|
||||
|
||||
<Modal open={showConfigModal} onClose={() => setShowConfigModal(false)} title={embedded ? t('app.deployTab.editDeployScripts', 'Edit Deploy Scripts') : t('app.deployTab.configureDeployment', 'Configure Deployment')}>
|
||||
<form onSubmit={handleConfigureDeployment}>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../../services/api';
|
||||
import EmptyState from '../EmptyState';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const GunicornTab = ({ appId }) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [config, setConfig] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [appId]);
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const data = await api.getGunicornConfig(appId);
|
||||
setConfig(data.content || '');
|
||||
} catch (err) {
|
||||
console.error('Failed to load config:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateGunicornConfig(appId, config);
|
||||
toast.success(t('app.gunicornTab.configurationSavedRestartTheAppTo', 'Configuration saved. Restart the app to apply changes.'));
|
||||
} catch (err) {
|
||||
toast.error(t('app.gunicornTab.failedToSaveConfiguration', 'Failed to save configuration'));
|
||||
console.error('Failed to save config:', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <EmptyState loading loadingVariant="form" title={t('app.gunicornTab.loadingGunicornConfiguration', 'Loading Gunicorn configuration…')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="section-header">
|
||||
<h3>{t('app.gunicornTab.gunicornConfiguration', 'Gunicorn Configuration')}</h3>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
className="code-editor"
|
||||
value={config}
|
||||
onChange={(e) => setConfig(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GunicornTab;
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { logsToText } from '@/utils/logText';
|
||||
import api from '../../services/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePolling } from '../../hooks/usePolling';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const LOG_REFRESH_MS = 5000;
|
||||
|
||||
const LogsTab = ({ app }) => {
|
||||
const { t } = useTranslation();
|
||||
const [logs, setLogs] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
|
||||
const isDockerApp = app.app_type === 'docker';
|
||||
const isPythonApp = ['flask', 'django'].includes(app.app_type);
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, [app.id]);
|
||||
|
||||
// immediate: false — the effect above already loads on mount and on app
|
||||
// change; this adds only the repeat while auto-refresh is on.
|
||||
usePolling(loadLogs, LOG_REFRESH_MS, { enabled: autoRefresh, immediate: false });
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
let data;
|
||||
if (isDockerApp) {
|
||||
data = await api.getDockerAppLogs(app.id, 200);
|
||||
} else if (isPythonApp) {
|
||||
data = await api.getPythonAppLogs(app.id, 200);
|
||||
} else {
|
||||
data = { logs: 'Logs not available for this app type.' };
|
||||
}
|
||||
setLogs(logsToText(data) || 'No logs available');
|
||||
} catch (err) {
|
||||
console.error('Failed to load logs:', err);
|
||||
setLogs('Failed to load logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="logs-tab">
|
||||
<div className="logs-header">
|
||||
<h3>{t('app.logsTab.applicationLogs', 'Application Logs')}</h3>
|
||||
<div className="logs-controls">
|
||||
{isPythonApp && (
|
||||
<span className="hint">{t('app.logsTab.gunicornSystemdLogs', 'Gunicorn/systemd Logs')}</span>
|
||||
)}
|
||||
{isDockerApp && (
|
||||
<span className="hint">{t('app.logsTab.dockerComposeLogs', 'Docker Compose Logs')}</span>
|
||||
)}
|
||||
<label className="checkbox-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
/>
|
||||
{t('app.logsTab.autoRefresh', 'Auto-refresh')}
|
||||
</label>
|
||||
<Button variant="outline" size="sm" onClick={loadLogs}>
|
||||
{t('common.actions.refresh', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="log-viewer">{loading ? 'Loading...' : logs}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsTab;
|
||||
@@ -1,372 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import { formatBytes } from '@/utils/formatBytes';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import PrivateURLSection from '../PrivateURLSection';
|
||||
import LinkedAppsSection from '../LinkedAppsSection';
|
||||
import LinkAppModal from '../LinkAppModal';
|
||||
import TagsPanel from '../shared/TagsPanel';
|
||||
import EnvironmentVariablesPanel from '../shared/EnvironmentVariablesPanel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Overview Tab with new grid layout
|
||||
const OverviewTab = ({ app, onUpdate }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { confirm: confirmOverview } = useConfirm();
|
||||
const [status, setStatus] = useState(null);
|
||||
const [appStatus, setAppStatus] = useState(null);
|
||||
const [linkedApps, setLinkedApps] = useState([]);
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const [linkLoading, setLinkLoading] = useState(false);
|
||||
const [containerStats, setContainerStats] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (['flask', 'django'].includes(app.app_type)) {
|
||||
loadStatus();
|
||||
}
|
||||
loadAppStatus();
|
||||
loadLinkedApps();
|
||||
if (app.app_type === 'docker') {
|
||||
loadContainerStats();
|
||||
}
|
||||
}, [app]);
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const data = await api.getPythonAppStatus(app.id);
|
||||
setStatus(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load status:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppStatus() {
|
||||
try {
|
||||
const data = await api.getAppStatus(app.id);
|
||||
setAppStatus(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load app status:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLinkedApps() {
|
||||
try {
|
||||
const data = await api.getLinkedApps(app.id);
|
||||
setLinkedApps(data.linked_apps || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load linked apps:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContainerStats() {
|
||||
try {
|
||||
// Try to get container stats for docker apps
|
||||
const containers = await api.getContainers(true);
|
||||
const appContainer = containers.containers?.find(c =>
|
||||
c.name?.includes(app.name) || c.name?.includes(app.slug)
|
||||
);
|
||||
if (appContainer && appContainer.state === 'running') {
|
||||
const stats = await api.getContainerStats(appContainer.id);
|
||||
setContainerStats(stats.stats);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load container stats:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnlink() {
|
||||
const confirmed = await confirmOverview({ titleKey: 'app.overviewTab.unlinkApps', title: 'Unlink Apps', messageKey: 'app.overviewTab.areYouSureYouWantTo', message: 'Are you sure you want to unlink these apps? Database credentials will remain unchanged.', variant: 'warning' });
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setLinkLoading(true);
|
||||
try {
|
||||
await api.unlinkApp(app.id);
|
||||
onUpdate();
|
||||
loadLinkedApps();
|
||||
} catch (err) {
|
||||
console.error('Failed to unlink apps:', err);
|
||||
} finally {
|
||||
setLinkLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLinked() {
|
||||
onUpdate();
|
||||
loadLinkedApps();
|
||||
}
|
||||
|
||||
function parseResourceValue(value) {
|
||||
if (!value) return 0;
|
||||
return parseFloat(value.replace('%', '')) || 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-overview-grid">
|
||||
{/* Left Column */}
|
||||
<div className="app-overview-left">
|
||||
{/* Application Info Panel */}
|
||||
<div className="app-panel">
|
||||
<div className="app-panel-header">{t('app.overviewTab.applicationInfo', 'Application Info')}</div>
|
||||
<div className="app-panel-body">
|
||||
<div className="app-info-grid">
|
||||
<div className="app-info-item">
|
||||
<span className="app-info-label">{t('common.labels.type', 'Type')}</span>
|
||||
<span className="app-info-value">{app.app_type === 'docker' ? 'Docker Container' : app.app_type.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="app-info-item">
|
||||
<span className="app-info-label">{t('common.labels.port', 'Port')}</span>
|
||||
<span className="app-info-value">
|
||||
{app.port || '-'}
|
||||
{appStatus && app.port && (
|
||||
<span className={`port-indicator ${appStatus.port_accessible ? 'accessible' : ''}`}>
|
||||
{appStatus.port_accessible ? ' (accessible)' : ' (not accessible)'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{app.python_version && (
|
||||
<div className="app-info-item">
|
||||
<span className="app-info-label">{t('app.overviewTab.pythonVersion', 'Python Version')}</span>
|
||||
<span className="app-info-value">{app.python_version}</span>
|
||||
</div>
|
||||
)}
|
||||
{app.php_version && (
|
||||
<div className="app-info-item">
|
||||
<span className="app-info-label">{t('app.overviewTab.phpVersion', 'PHP Version')}</span>
|
||||
<span className="app-info-value">{app.php_version}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="app-info-item full-width">
|
||||
<span className="app-info-label">{t('app.overviewTab.rootPath', 'Root Path')}</span>
|
||||
<div><span className="app-path-value">{app.root_path || `/var/serverkit/apps/${app.name}`}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Routing Diagnostics Panel (Docker apps only) */}
|
||||
{app.app_type === 'docker' && (
|
||||
<RoutingDiagnosticsPanel appId={app.id} />
|
||||
)}
|
||||
|
||||
{/* Environment Linking Panel */}
|
||||
<div className="app-panel">
|
||||
<div className="app-panel-header">{t('app.overviewTab.environmentLinking', 'Environment Linking')}</div>
|
||||
<div className="app-panel-body">
|
||||
<p className="app-panel-hint">
|
||||
{t('app.overviewTab.linkThisAppToAnotherTo', 'Link this app to another to create a production/development pair. Linked apps can share database credentials.')}
|
||||
</p>
|
||||
<LinkedAppsSection
|
||||
app={app}
|
||||
linkedApps={linkedApps}
|
||||
onLink={() => setShowLinkModal(true)}
|
||||
onUnlink={handleUnlink}
|
||||
onNavigate={(appId) => navigate(`/apps/${appId}`)}
|
||||
loading={linkLoading}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Process Status (Python apps) */}
|
||||
{status && (
|
||||
<div className="app-panel">
|
||||
<div className="app-panel-header">{t('app.overviewTab.processStatus', 'Process Status')}</div>
|
||||
<div className="app-panel-body">
|
||||
<div className="app-info-grid">
|
||||
<div className="app-info-item">
|
||||
<span className="app-info-label">{t('common.labels.service', 'Service')}</span>
|
||||
<span className="app-info-value mono">{status.service_name}</span>
|
||||
</div>
|
||||
<div className="app-info-item">
|
||||
<span className="app-info-label">{t('common.labels.state', 'State')}</span>
|
||||
<span className="app-info-value">{status.active_state} ({status.sub_state})</span>
|
||||
</div>
|
||||
{status.main_pid !== '0' && (
|
||||
<div className="app-info-item">
|
||||
<span className="app-info-label">PID</span>
|
||||
<span className="app-info-value mono">{status.main_pid}</span>
|
||||
</div>
|
||||
)}
|
||||
{status.memory && status.memory !== '0' && (
|
||||
<div className="app-info-item">
|
||||
<span className="app-info-label">{t('common.labels.memory', 'Memory')}</span>
|
||||
<span className="app-info-value">{formatBytes(parseInt(status.memory))}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domains Panel */}
|
||||
{app.domains && app.domains.length > 0 && (
|
||||
<div className="app-panel">
|
||||
<div className="app-panel-header">{t('common.labels.domains', 'Domains')}</div>
|
||||
<div className="app-panel-body">
|
||||
<div className="domains-list">
|
||||
{app.domains.map(domain => (
|
||||
<div key={domain.id} className="domain-item">
|
||||
<a href={`https://${domain.name}`} target="_blank" rel="noopener noreferrer">
|
||||
{domain.name}
|
||||
</a>
|
||||
{domain.ssl_enabled && (
|
||||
<span className="ssl-badge">SSL</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="app-overview-right">
|
||||
{/* Private URL Panel (Docker apps with port) */}
|
||||
{app.app_type === 'docker' && app.port && (
|
||||
<PrivateURLSection app={app} onUpdate={onUpdate} />
|
||||
)}
|
||||
|
||||
{/* Live Resources Panel */}
|
||||
{app.app_type === 'docker' && (
|
||||
<div className="app-panel">
|
||||
<div className="app-panel-header">{t('app.overviewTab.liveResources', 'Live Resources')}</div>
|
||||
<div className="app-panel-body">
|
||||
<div className="resource-bar-container">
|
||||
<div className="resource-bar-header">
|
||||
<span className="resource-bar-label">{t('app.overviewTab.cpuLoad', 'CPU Load')}</span>
|
||||
<span className="resource-bar-value">
|
||||
{containerStats ? `${parseResourceValue(containerStats.CPUPerc).toFixed(0)}%` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="resource-bar-track">
|
||||
<div
|
||||
className="resource-bar-fill cpu"
|
||||
style={{ width: `${containerStats ? parseResourceValue(containerStats.CPUPerc) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="resource-bar-container">
|
||||
<div className="resource-bar-header">
|
||||
<span className="resource-bar-label">{t('app.overviewTab.ramUsage', 'RAM Usage')}</span>
|
||||
<span className="resource-bar-value">
|
||||
{containerStats?.MemUsage || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="resource-bar-track">
|
||||
<div
|
||||
className="resource-bar-fill ram"
|
||||
style={{ width: `${containerStats ? parseResourceValue(containerStats.MemPerc) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!containerStats && app.status !== 'running' && (
|
||||
<p className="resource-hint">{t('app.overviewTab.startTheContainerToSeeLive', 'Start the container to see live resources.')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags Panel (polymorphic shared resource) */}
|
||||
<div className="app-panel">
|
||||
<div className="app-panel-header">{t('app.overviewTab.tags', 'Tags')}</div>
|
||||
<div className="app-panel-body">
|
||||
<TagsPanel resourceType="application" resourceId={app.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shared Variables Panel (resolved from attached groups) */}
|
||||
<div className="app-panel">
|
||||
<div className="app-panel-body">
|
||||
<EnvironmentVariablesPanel resourceType="application" resourceId={app.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLinkModal && (
|
||||
<LinkAppModal
|
||||
app={app}
|
||||
onClose={() => setShowLinkModal(false)}
|
||||
onLinked={handleLinked}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Routing Diagnostics Panel
|
||||
const RoutingDiagnosticsPanel = ({ appId }) => {
|
||||
const { t } = useTranslation();
|
||||
const [diagnostics, setDiagnostics] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastChecked, setLastChecked] = useState(null);
|
||||
|
||||
async function runDiagnostics() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getDomainRoutingDiagnostics(appId);
|
||||
setDiagnostics(data);
|
||||
setLastChecked(new Date());
|
||||
} catch (err) {
|
||||
console.error('Failed to run diagnostics:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isHealthy = diagnostics?.health?.overall;
|
||||
|
||||
return (
|
||||
<div className="app-panel">
|
||||
<div className="app-panel-header">
|
||||
<span>{t('app.overviewTab.routingDiagnostics', 'Routing Diagnostics')}</span>
|
||||
<span className="app-panel-header-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={runDiagnostics}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Checking...' : 'Run Diagnostics'}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="app-panel-body">
|
||||
{diagnostics ? (
|
||||
<>
|
||||
<div className={`diag-status ${isHealthy ? 'healthy' : 'unhealthy'}`}>
|
||||
<div className={`diag-icon ${isHealthy ? 'healthy' : 'unhealthy'}`}>
|
||||
{isHealthy ? '✓' : '✗'}
|
||||
</div>
|
||||
<div className="diag-text">
|
||||
<h4>{isHealthy ? 'Configuration Healthy' : 'Issues Detected'}</h4>
|
||||
<p>
|
||||
{lastChecked && `Last checked: ${Math.round((Date.now() - lastChecked) / 60000)} minutes ago. `}
|
||||
{isHealthy ? 'No routing issues detected.' : 'Some checks failed.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{diagnostics.health?.issues?.length > 0 && (
|
||||
<ul className="diag-issues">
|
||||
{diagnostics.health.issues.map((issue, i) => (
|
||||
<li key={i}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="app-panel-hint">
|
||||
{t('app.overviewTab.clickRunDiagnosticsToCheckRouting', 'Click "Run Diagnostics" to check routing configuration and identify issues.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverviewTab;
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../../services/api';
|
||||
import EmptyState from '../EmptyState';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PackagesTab = ({ appId }) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [packages, setPackages] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [newPackage, setNewPackage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadPackages();
|
||||
}, [appId]);
|
||||
|
||||
async function loadPackages() {
|
||||
try {
|
||||
const data = await api.getPythonPackages(appId);
|
||||
setPackages(data.packages || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load packages:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstall(e) {
|
||||
e.preventDefault();
|
||||
if (!newPackage.trim()) return;
|
||||
|
||||
setInstalling(true);
|
||||
try {
|
||||
await api.installPythonPackages(appId, [newPackage.trim()]);
|
||||
setNewPackage('');
|
||||
loadPackages();
|
||||
} catch (err) {
|
||||
console.error('Failed to install package:', err);
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFreeze() {
|
||||
try {
|
||||
await api.freezePythonRequirements(appId);
|
||||
toast.success(t('app.packagesTab.requirementsTxtUpdated', 'requirements.txt updated'));
|
||||
} catch (err) {
|
||||
toast.error(t('app.packagesTab.failedToFreezeRequirements', 'Failed to freeze requirements'));
|
||||
console.error('Failed to freeze requirements:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <EmptyState loading loadingVariant="table" title={t('app.packagesTab.loadingPackages', 'Loading packages…')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="section-header">
|
||||
<h3>{t('app.packagesTab.installedPackages', 'Installed Packages')}</h3>
|
||||
<Button variant="outline" size="sm" onClick={handleFreeze}>
|
||||
{t('app.packagesTab.freezeToRequirementsTxt', 'Freeze to requirements.txt')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form className="install-form" onSubmit={handleInstall}>
|
||||
<Input
|
||||
type="text"
|
||||
value={newPackage}
|
||||
onChange={(e) => setNewPackage(e.target.value)}
|
||||
placeholder={t('app.packagesTab.packageNameEGRequestsFlask', 'Package name (e.g., requests, flask==2.0.0)')}
|
||||
/>
|
||||
<Button type="submit" disabled={installing}>
|
||||
{installing ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="packages-list">
|
||||
{packages.map(pkg => (
|
||||
<div key={pkg.name} className="package-item">
|
||||
<span className="package-name">{pkg.name}</span>
|
||||
<span className="package-version">{pkg.version}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PackagesTab;
|
||||
@@ -6,6 +6,7 @@ import { DangerZone } from '../DangerZone';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EnvTag } from '@/components/ds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card as SharedCard } from '@/components/ui/card';
|
||||
|
||||
const SettingsTab = ({ app, onUpdate }) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -85,7 +86,7 @@ const SettingsTab = ({ app, onUpdate }) => {
|
||||
<div>
|
||||
<h3 className="app-eyebrow">{t('app.settingsTab.applicationSettings', 'Application Settings')}</h3>
|
||||
|
||||
<div className="card settings-section">
|
||||
<SharedCard variant="legacy" className="card settings-section">
|
||||
<h4>{t('app.settingsTab.environmentConfiguration', 'Environment Configuration')}</h4>
|
||||
<div className="settings-row">
|
||||
<div className="settings-label">
|
||||
@@ -137,7 +138,7 @@ const SettingsTab = ({ app, onUpdate }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SharedCard>
|
||||
|
||||
<DangerZone
|
||||
title={t('app.settingsTab.dangerZone', 'Danger Zone')}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ShieldCheck, ListChecks, X, RefreshCw } from 'lucide-react';
|
||||
import api from '../../services/api';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { Pill, SegControl } from '../ds';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -314,14 +314,14 @@ const AppWafPanel = ({ app, onChanged }) => {
|
||||
{policy.disabled_rule_ids.map((id) => (
|
||||
<span key={id} className="waf-panel__chip">
|
||||
<span className="mono">{id}</span>
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="waf-panel__chip-remove"
|
||||
onClick={() => handleRemoveRule(id)}
|
||||
aria-label={t('app.appWafPanel.removeRule', 'Remove rule {{id}}', { id: id })}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { statusKind } from '@/components/ds/status';
|
||||
import { RefreshCw, ArrowUpCircle, Moon, Sun, Gauge as GaugeIcon, Boxes } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { Pill } from '../ds';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import api from '../../services/api';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Paste-in .htaccess -> nginx converter for the per-site custom nginx rules
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Zap, Eraser } from 'lucide-react';
|
||||
import api from '../../services/api';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Gauge } from 'lucide-react';
|
||||
import api from '../../services/api';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { formatBytes } from '@/utils/formatBytes';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { HardDrive, Plus, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import api from '../../services/api';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { Pill } from '../ds';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -9,14 +10,6 @@ import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes == null) return '—';
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
return `${(bytes / 1024 ** i).toFixed(i ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
// A single volume row with an inline detach confirm (the "also delete data"
|
||||
// checkbox is off by default and disabled while the app is running, so a detach
|
||||
// can never nuke live data by accident).
|
||||
@@ -43,7 +36,7 @@ function VolumeRow({ volume, appRunning, onDetach }) {
|
||||
<strong>{volume.name}</strong>
|
||||
<span className="app-volumes__mono">{volume.mount_path}</span>
|
||||
</div>
|
||||
<div className="app-volumes__cell">{formatBytes(volume.size_bytes)}</div>
|
||||
<div className="app-volumes__cell">{formatBytes(volume.size_bytes, { defaultValue: '—' })}</div>
|
||||
<div className="app-volumes__cell app-volumes__pills">
|
||||
{volume.read_only && <Pill kind="gray">read-only</Pill>}
|
||||
<Pill kind={volume.present === false ? 'red' : 'green'}>
|
||||
@@ -96,7 +89,7 @@ const VolumesPanel = ({ app, onChanged }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [app.id, toast]);
|
||||
}, [app.id, toast, t]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { statusKind } from './format';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Card of the backup "Protection" panel: a Monday-first month grid of backup
|
||||
// runs. Pairs with the .backup-calendar styles. Uses native Date math (no libs).
|
||||
@@ -103,17 +104,17 @@ export default function BackupCalendar({ runs, onDayClick }) {
|
||||
return (
|
||||
<div className="backup-calendar">
|
||||
<div className="backup-calendar__head">
|
||||
<button type="button" className="backup-calendar__nav" onClick={prevMonth} aria-label={t('app.backupCalendar.previousMonth', 'Previous month')}><ChevronLeft size={16} /></button>
|
||||
<SharedButton variant="unstyled" type="button" className="backup-calendar__nav" onClick={prevMonth} aria-label={t('app.backupCalendar.previousMonth', 'Previous month')}><ChevronLeft size={16} /></SharedButton>
|
||||
<span className="backup-calendar__month">{monthLabel}</span>
|
||||
<button type="button" className="backup-calendar__nav" onClick={nextMonth} aria-label={t('app.backupCalendar.nextMonth', 'Next month')}><ChevronRight size={16} /></button>
|
||||
<button type="button" className="backup-calendar__today" onClick={goToday}>{t('app.backupCalendar.today', 'Today')}</button>
|
||||
<SharedButton variant="unstyled" type="button" className="backup-calendar__nav" onClick={nextMonth} aria-label={t('app.backupCalendar.nextMonth', 'Next month')}><ChevronRight size={16} /></SharedButton>
|
||||
<SharedButton variant="unstyled" type="button" className="backup-calendar__today" onClick={goToday}>{t('app.backupCalendar.today', 'Today')}</SharedButton>
|
||||
</div>
|
||||
<div className="backup-calendar__grid">
|
||||
{WEEKDAYS.map((d) => <div key={d} className="backup-calendar__dow">{d}</div>)}
|
||||
{cells.map((cell, i) => (
|
||||
cell
|
||||
? (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
key={i}
|
||||
className={`backup-calendar__cell ${cell.count ? `backup-calendar__cell--has is-${cell.worst}` : ''}`}
|
||||
@@ -122,7 +123,7 @@ export default function BackupCalendar({ runs, onDayClick }) {
|
||||
>
|
||||
<span className="backup-calendar__daynum">{cell.day}</span>
|
||||
{cell.count > 0 && <span className="backup-calendar__badge">{cell.count}</span>}
|
||||
</button>
|
||||
</SharedButton>
|
||||
)
|
||||
: <span key={i} className="backup-calendar__cell backup-calendar__cell--empty" />
|
||||
))}
|
||||
|
||||
@@ -78,7 +78,7 @@ function RestoreDrills({ drills }) {
|
||||
const isOpen = expanded === drill.id;
|
||||
return (
|
||||
<li key={drill.id} className="backup-drills__item">
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="backup-drills__row"
|
||||
onClick={() => setExpanded(isOpen ? null : drill.id)}
|
||||
@@ -96,7 +96,7 @@ function RestoreDrills({ drills }) {
|
||||
{drill.bytes_restored != null && (
|
||||
<span className="backup-drills__meta">{humanSize(drill.bytes_restored)}</span>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div className="backup-drills__detail">
|
||||
{drill.error && <p className="backup-drills__error">{drill.error}</p>}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { nextFire, untilLabel } from '@/utils/backupSchedule';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
AlertTriangle, Archive, ChevronRight, Cloud, Database, Globe, HardDrive,
|
||||
@@ -55,46 +57,6 @@ function formatDuration(seconds) {
|
||||
return s ? `${m}m ${s}s` : `${m}m`;
|
||||
}
|
||||
|
||||
// "in 1h 12m" / "in 3d" — the shape the mock's Next-scheduled tile uses.
|
||||
function untilLabel(date) {
|
||||
const ms = date.getTime() - Date.now();
|
||||
if (ms <= 0) return 'due now';
|
||||
const mins = Math.round(ms / 60000);
|
||||
if (mins < 60) return `in ${mins}m`;
|
||||
const h = Math.floor(mins / 60);
|
||||
if (h < 24) return `in ${h}h ${mins % 60}m`;
|
||||
return `in ${Math.round(h / 24)}d`;
|
||||
}
|
||||
|
||||
// Next fire of a legacy schedule row ({ schedule_time: '02:00', days: [...] }).
|
||||
// Deliberately reads the same list the Schedules tab renders rather than
|
||||
// parsing policy crons — the number on the tile then matches the rows you'd
|
||||
// click through to. Returns null for anything it can't place.
|
||||
const DAY_INDEX = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
||||
|
||||
function nextFire(schedule) {
|
||||
const [hh, mm] = String(schedule.schedule_time || '').split(':').map(Number);
|
||||
if (Number.isNaN(hh) || Number.isNaN(mm)) return null;
|
||||
|
||||
const days = Array.isArray(schedule.days) ? schedule.days : ['daily'];
|
||||
const daily = days.length === 0 || days.includes('daily');
|
||||
const wanted = daily
|
||||
? null
|
||||
: new Set(days.map((d) => DAY_INDEX[String(d).slice(0, 3).toLowerCase()]).filter((n) => n != null));
|
||||
if (!daily && (!wanted || wanted.size === 0)) return null;
|
||||
|
||||
// Walk forward a week at most; the first slot that is still in the future
|
||||
// on an allowed weekday wins.
|
||||
for (let offset = 0; offset <= 7; offset += 1) {
|
||||
const candidate = new Date();
|
||||
candidate.setDate(candidate.getDate() + offset);
|
||||
candidate.setHours(hh, mm, 0, 0);
|
||||
if (candidate.getTime() <= Date.now()) continue;
|
||||
if (daily || wanted.has(candidate.getDay())) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// DataTable columns for the "Latest snapshots" digest. The hand-rolled table
|
||||
// had no thead; the shared primitive always renders one, so the cells keep
|
||||
// their exact markup (.sk-cell-name, .bk-ico, .sk-cell-sub) and the columns
|
||||
@@ -148,6 +110,7 @@ export default function BackupsOverview({
|
||||
stats, storageConfig, costSummary, schedules = [], backups = [], onGo,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const now = new Date();
|
||||
const [policies, setPolicies] = useState(null);
|
||||
const [runs, setRuns] = useState(null);
|
||||
|
||||
@@ -283,14 +246,14 @@ export default function BackupsOverview({
|
||||
return { rows, peak, count: rows.length };
|
||||
}, [stats, storageConfig, costSummary]);
|
||||
|
||||
const nextScheduled = useMemo(() => {
|
||||
const nextScheduled = (() => {
|
||||
const upcoming = (schedules || [])
|
||||
.filter((s) => s.enabled !== false)
|
||||
.map((s) => ({ schedule: s, at: nextFire(s) }))
|
||||
.map((s) => ({ schedule: s, at: nextFire(s, now) }))
|
||||
.filter((x) => x.at)
|
||||
.sort((a, b) => a.at - b.at);
|
||||
return upcoming[0] || null;
|
||||
}, [schedules]);
|
||||
})();
|
||||
|
||||
const totalBytes = (stats?.total_size || 0) + (stats?.remote_size || 0);
|
||||
const latest = events.slice(0, 5);
|
||||
@@ -332,7 +295,7 @@ export default function BackupsOverview({
|
||||
<MetricCard
|
||||
tone="violet"
|
||||
icon={<Timer size={16} />}
|
||||
value={nextScheduled ? untilLabel(nextScheduled.at) : 'Not set'}
|
||||
value={nextScheduled ? untilLabel(nextScheduled.at, now) : 'Not set'}
|
||||
label={t('app.backupsOverview.nextScheduled', 'Next scheduled')}
|
||||
>
|
||||
<div className="sk-kpi__sub">
|
||||
@@ -389,9 +352,9 @@ export default function BackupsOverview({
|
||||
<section className="bk-panel">
|
||||
<div className="bk-panel__head">
|
||||
<h3>{t('app.backupsOverview.recentActivity', 'Recent activity')}</h3>
|
||||
<button type="button" className="bk-link" onClick={() => onGo('snapshots')}>
|
||||
<Button variant="unstyled" type="button" className="bk-link" onClick={() => onGo('snapshots')}>
|
||||
{t('app.backupsOverview.allSnapshots', 'All snapshots')} <ChevronRight size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="bk-hint">{t('app.backupsOverview.nothingHasRunYetTheFirst', 'Nothing has run yet — the first backup will show up here.')}</p>
|
||||
@@ -427,9 +390,9 @@ export default function BackupsOverview({
|
||||
<section className="bk-panel">
|
||||
<div className="bk-panel__head">
|
||||
<h3>{t('app.backupsOverview.storageDestinations', 'Storage destinations')}</h3>
|
||||
<button type="button" className="bk-link" onClick={() => onGo('storage')}>
|
||||
<Button variant="unstyled" type="button" className="bk-link" onClick={() => onGo('storage')}>
|
||||
{t('app.backupsOverview.manage', 'Manage')} <ChevronRight size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{destinations.rows.map((row) => (
|
||||
<div key={row.key} className="bk-dest__row">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { History, RefreshCw, Loader2, List, CalendarDays, X } from 'lucide-react';
|
||||
|
||||
import api from '@/services/api';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { useToast } from '@/contexts/useToast.js';
|
||||
import { useOperations } from '@/contexts/OperationsContext';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -80,7 +80,7 @@ export default function ProtectionPanel({ targetType, targetId, targetName, show
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [targetType, targetId, toast]);
|
||||
}, [targetId, targetType, toast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -113,7 +113,7 @@ export default function ProtectionPanel({ targetType, targetId, targetName, show
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [targetType, targetId, toast]);
|
||||
}, [targetType, targetId, toast, t]);
|
||||
|
||||
const handleSaveSchedule = useCallback(async (fields) => {
|
||||
setSaving(true);
|
||||
@@ -126,7 +126,7 @@ export default function ProtectionPanel({ targetType, targetId, targetName, show
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [targetType, targetId, toast]);
|
||||
}, [targetType, targetId, toast, t]);
|
||||
|
||||
const handleBackupNow = useCallback(async () => {
|
||||
setBackingUp(true);
|
||||
@@ -140,7 +140,7 @@ export default function ProtectionPanel({ targetType, targetId, targetName, show
|
||||
} finally {
|
||||
setBackingUp(false);
|
||||
}
|
||||
}, [targetType, targetId, toast, scheduleReloads, openRun]);
|
||||
}, [targetType, targetId, openRun, toast, t, scheduleReloads]);
|
||||
|
||||
const handleRunDrill = useCallback(async () => {
|
||||
setDrilling(true);
|
||||
@@ -153,7 +153,7 @@ export default function ProtectionPanel({ targetType, targetId, targetName, show
|
||||
} finally {
|
||||
setDrilling(false);
|
||||
}
|
||||
}, [targetType, targetId, toast, scheduleReloads]);
|
||||
}, [targetType, targetId, toast, t, scheduleReloads]);
|
||||
|
||||
// Restore is always initiated from a run — opening the restore drawer.
|
||||
const openRestore = useCallback((run) => setRestoreRun(run), []);
|
||||
@@ -172,7 +172,7 @@ export default function ProtectionPanel({ targetType, targetId, targetName, show
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('app.protectionPanel.failedToStartRestore', 'Failed to start restore'));
|
||||
}
|
||||
}, [restoreRun, targetType, targetId, showMaintenanceModeOption, toast, scheduleReloads]);
|
||||
}, [restoreRun, targetType, targetId, showMaintenanceModeOption, toast, t, scheduleReloads]);
|
||||
|
||||
const handleVerify = useCallback(async (run) => {
|
||||
try {
|
||||
@@ -183,7 +183,7 @@ export default function ProtectionPanel({ targetType, targetId, targetName, show
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('app.protectionPanel.verificationFailed', 'Verification failed'));
|
||||
}
|
||||
}, [targetType, targetId, toast, load]);
|
||||
}, [targetType, targetId, toast, t, load]);
|
||||
|
||||
const handleDelete = useCallback(async (run) => {
|
||||
const ok = await confirm({
|
||||
@@ -201,7 +201,7 @@ export default function ProtectionPanel({ targetType, targetId, targetName, show
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('app.protectionPanel.failedToDeleteBackup', 'Failed to delete backup'));
|
||||
}
|
||||
}, [confirm, targetType, targetId, toast, load, detailRun]);
|
||||
}, [confirm, t, targetType, targetId, toast, detailRun?.id, load]);
|
||||
|
||||
const visibleRuns = useMemo(
|
||||
() => (dayFilter ? runs.filter((r) => sameLocalDay(r.started_at, dayFilter)) : runs),
|
||||
@@ -236,31 +236,31 @@ export default function ProtectionPanel({ targetType, targetId, targetName, show
|
||||
<span>{t('app.protectionPanel.backupHistory', 'Backup history')}</span>
|
||||
<span className="app-panel-header-actions backup-history-card__tools">
|
||||
{dayFilter && (
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="backup-history-card__chip"
|
||||
onClick={() => setDayFilter(null)}
|
||||
>
|
||||
{dayFilter.toLocaleDateString()} <X size={12} />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<div className="backup-history-card__toggle">
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className={historyView === 'list' ? 'is-active' : ''}
|
||||
onClick={() => setHistoryView('list')}
|
||||
aria-label={t('app.protectionPanel.listView', 'List view')}
|
||||
>
|
||||
<List size={14} />
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className={historyView === 'calendar' ? 'is-active' : ''}
|
||||
onClick={() => setHistoryView('calendar')}
|
||||
aria-label={t('app.protectionPanel.calendarView', 'Calendar view')}
|
||||
>
|
||||
<CalendarDays size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={load} disabled={loading} title={t('common.actions.refresh', 'Refresh')}>
|
||||
{loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { nextFire, untilLabel, frequencyLabel } from '@/utils/backupSchedule';
|
||||
import { Archive, Database, Globe, HardDrive, Package, Play, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { DataTable, DataTableFooter } from '@/components/ds';
|
||||
@@ -11,40 +13,6 @@ const KIND_ICON = {
|
||||
server: HardDrive,
|
||||
};
|
||||
|
||||
const DAY_INDEX = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
||||
const DAY_LABEL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
// Next fire of a schedule row. Deliberately the same walk-forward the overview
|
||||
// tile uses, so "in 4h 48m" here and on Overview can never disagree.
|
||||
function nextFire(schedule) {
|
||||
const [hh, mm] = String(schedule.schedule_time || '').split(':').map(Number);
|
||||
if (Number.isNaN(hh) || Number.isNaN(mm)) return null;
|
||||
const days = Array.isArray(schedule.days) ? schedule.days : ['daily'];
|
||||
const daily = days.length === 0 || days.includes('daily');
|
||||
const wanted = daily
|
||||
? null
|
||||
: new Set(days.map((d) => DAY_INDEX[String(d).slice(0, 3).toLowerCase()]).filter((n) => n != null));
|
||||
if (!daily && (!wanted || wanted.size === 0)) return null;
|
||||
for (let offset = 0; offset <= 7; offset += 1) {
|
||||
const candidate = new Date();
|
||||
candidate.setDate(candidate.getDate() + offset);
|
||||
candidate.setHours(hh, mm, 0, 0);
|
||||
if (candidate.getTime() <= Date.now()) continue;
|
||||
if (daily || wanted.has(candidate.getDay())) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function untilLabel(date) {
|
||||
const ms = date.getTime() - Date.now();
|
||||
if (ms <= 0) return 'due now';
|
||||
const mins = Math.round(ms / 60000);
|
||||
if (mins < 60) return `in ${mins}m`;
|
||||
const h = Math.floor(mins / 60);
|
||||
if (h < 24) return `in ${h}h ${mins % 60}m`;
|
||||
return `in ${Math.round(h / 24)}d`;
|
||||
}
|
||||
|
||||
function agoLabel(iso) {
|
||||
if (!iso) return 'Never';
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
@@ -55,18 +23,6 @@ function agoLabel(iso) {
|
||||
return `${Math.round(diff / 86400)}d ago`;
|
||||
}
|
||||
|
||||
// Frequency in the mock's phrasing: "Daily · 03:00" / "Weekly · Sun 02:00".
|
||||
function frequencyLabel(schedule) {
|
||||
const time = schedule.schedule_time || '—';
|
||||
const days = Array.isArray(schedule.days) ? schedule.days : ['daily'];
|
||||
if (days.length === 0 || days.includes('daily')) return `Daily · ${time}`;
|
||||
if (days.length === 1) {
|
||||
const idx = DAY_INDEX[String(days[0]).slice(0, 3).toLowerCase()];
|
||||
return `Weekly · ${idx == null ? days[0] : DAY_LABEL[idx]} ${time}`;
|
||||
}
|
||||
return `${days.length}×/week · ${time}`;
|
||||
}
|
||||
|
||||
// Policy list in the design mock's shape: what runs, how often, how long it is
|
||||
// kept, where it lands, and whether it is on. The columns the mock fills from
|
||||
// its fixtures but this panel has no source for (per-policy size, run-now) are
|
||||
@@ -75,6 +31,7 @@ export default function SchedulesTable({
|
||||
schedules, retentionDays, remoteLabel, onToggle, onRemove, onRun,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const now = new Date();
|
||||
// DataTable columns. Cell markup and classNames are identical to the
|
||||
// hand-rolled table they replace, so _backups.scss keeps applying
|
||||
// (.bk-name, .bk-ret, .bk-lastrun, .bk-paused, .bk-col-on, .bk-actions).
|
||||
@@ -151,14 +108,14 @@ export default function SchedulesTable({
|
||||
headerKey: 'app.schedulesTable.next', header: 'Next',
|
||||
sortable: true,
|
||||
sortValue: (s) => {
|
||||
const next = s.enabled ? nextFire(s) : null;
|
||||
const next = s.enabled ? nextFire(s, now) : null;
|
||||
return next ? next.getTime() : null;
|
||||
},
|
||||
cellClassName: 'sk-cell-mono',
|
||||
render: (schedule) => {
|
||||
const next = schedule.enabled ? nextFire(schedule) : null;
|
||||
const next = schedule.enabled ? nextFire(schedule, now) : null;
|
||||
return schedule.enabled
|
||||
? (next ? untilLabel(next) : '—')
|
||||
? (next ? untilLabel(next, now) : '—')
|
||||
: <span className="bk-paused">paused</span>;
|
||||
},
|
||||
},
|
||||
@@ -187,7 +144,7 @@ export default function SchedulesTable({
|
||||
render: (schedule) => (
|
||||
<div className="bk-actions">
|
||||
{onRun && (
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="bk-iconbtn"
|
||||
onClick={() => onRun(schedule)}
|
||||
@@ -195,9 +152,9 @@ export default function SchedulesTable({
|
||||
aria-label={t('app.schedulesTable.runNow', 'Run {{name}} now', { name: schedule.name })}
|
||||
>
|
||||
<Play size={15} />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="bk-iconbtn bk-iconbtn--danger"
|
||||
onClick={() => onRemove(schedule.id)}
|
||||
@@ -205,7 +162,7 @@ export default function SchedulesTable({
|
||||
aria-label={t('app.schedulesTable.delete', 'Delete {{name}}', { name: schedule.name })}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -118,10 +118,10 @@ export default function StorageDestinations({
|
||||
|
||||
{/* Only one remote can be active at a time, so this reveals the
|
||||
configuration form below rather than adding a second card. */}
|
||||
<button type="button" className="bk-destcard bk-destcard--add" onClick={onAdd}>
|
||||
<Button variant="unstyled" type="button" className="bk-destcard bk-destcard--add" onClick={onAdd}>
|
||||
<Plus size={24} />
|
||||
<span>{remote ? 'Change destination' : 'Add destination'}</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Boxes, ChevronDown, FileCode2, Layers, Terminal, Zap } from 'lucide-rea
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Map a plan's language to the primary version-override field.
|
||||
const VERSION_FIELD = {
|
||||
@@ -179,7 +180,7 @@ function BuildpackPreview({ plan, dockerfile, overrides = {}, onChange, loading
|
||||
|
||||
{dockerfile && !present && (
|
||||
<div className="buildpack-preview__dockerfile">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="buildpack-preview__dockerfile-toggle"
|
||||
onClick={() => setDockerfileOpen((open) => !open)}
|
||||
@@ -193,7 +194,7 @@ function BuildpackPreview({ plan, dockerfile, overrides = {}, onChange, loading
|
||||
size={16}
|
||||
className={dockerfileOpen ? 'buildpack-preview__chevron--open' : ''}
|
||||
/>
|
||||
</button>
|
||||
</SharedButton>
|
||||
{dockerfileOpen && (
|
||||
<pre className="buildpack-preview__code">
|
||||
<code>{dockerfile}</code>
|
||||
|
||||
@@ -65,9 +65,9 @@ const AccountSecurityNudge = () => {
|
||||
<Button size="sm" onClick={() => navigate(item.fix?.to || '/settings/security')}>
|
||||
{t('app.accountSecurityNudge.addAPasskeyOr2fa', 'Add a passkey or 2FA')}
|
||||
</Button>
|
||||
<button type="button" className="account-security-nudge__close" onClick={dismiss} aria-label={t('common.actions.dismiss', 'Dismiss')}>
|
||||
<Button variant="unstyled" type="button" className="account-security-nudge__close" onClick={dismiss} aria-label={t('common.actions.dismiss', 'Dismiss')}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,7 +57,7 @@ const SetupHealthWidget = () => {
|
||||
// Clean → slim "all set" line, not a big empty card.
|
||||
if (openCount === 0) {
|
||||
return (
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="setup-health-widget setup-health-widget--clean"
|
||||
onClick={() => navigate('/monitoring/doctor')}
|
||||
@@ -67,7 +67,7 @@ const SetupHealthWidget = () => {
|
||||
{t('app.setupHealthWidget.allSet', 'All set —')} {summary.score}{t('app.setupHealthWidget.setupHealth2', '% setup health')}
|
||||
</span>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ const SetupHealthWidget = () => {
|
||||
>
|
||||
{t('app.setupHealthWidget.review', 'Review')}
|
||||
</Button>
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="setup-health-widget__dismiss"
|
||||
onClick={dismiss}
|
||||
@@ -108,7 +108,7 @@ const SetupHealthWidget = () => {
|
||||
title={t('app.setupHealthWidget.dismissHint', 'Dismiss — setup health stays in the status bar')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { QUICK_ACTION_OPTIONS, WidgetBody } from '../widgets/renderers';
|
||||
import { deriveWidgetTitle } from '../widgets/registry';
|
||||
import { GRID_COLS, GRID_GAP, GRID_ROW } from './layout';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Tallest a widget may be dragged/stepped to. Twelve columns wide is the board;
|
||||
// height is only bounded so a stepper can't run away.
|
||||
@@ -133,7 +134,7 @@ function Stepper({ label, value, onStep, suffix }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="skwe-stepper">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="skwe-stepper__btn"
|
||||
title={t('app.widgetEditor.decrease', 'Decrease {{label}}', { label: label })}
|
||||
@@ -141,9 +142,9 @@ function Stepper({ label, value, onStep, suffix }) {
|
||||
onClick={() => onStep(-1)}
|
||||
>
|
||||
<Minus size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
<span className="skwe-stepper__val mono">{value}{suffix ? ` ${suffix}` : ''}</span>
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="skwe-stepper__btn"
|
||||
title={t('app.widgetEditor.increase', 'Increase {{label}}', { label: label })}
|
||||
@@ -151,7 +152,7 @@ function Stepper({ label, value, onStep, suffix }) {
|
||||
onClick={() => onStep(1)}
|
||||
>
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -184,7 +185,7 @@ function SeriesColor({ value, fallback, index, onChange }) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="skwe-edit__color"
|
||||
aria-label={t('app.widgetEditor.seriesColour', 'Series {{value}} colour', { value: index + 1 })}
|
||||
@@ -194,7 +195,7 @@ function SeriesColor({ value, fallback, index, onChange }) {
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" sideOffset={6} className="skwe-palette">
|
||||
{SERIES_COLORS.map(([option, name]) => (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
key={option || 'default'}
|
||||
type="button"
|
||||
aria-label={name}
|
||||
@@ -437,7 +438,7 @@ export function WidgetEditor({
|
||||
onChange={(value) => setSeries(index, 'metric', value)}
|
||||
options={metricOptionsFor(series.resource, series.metric)}
|
||||
/>
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="skwe-edit__del"
|
||||
title={t('app.widgetEditor.removeSeries', 'Remove series {{value}}', { value: index + 1 })}
|
||||
@@ -448,10 +449,10 @@ export function WidgetEditor({
|
||||
)}
|
||||
>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="skwe-edit__add"
|
||||
onClick={() => set('series', [
|
||||
@@ -460,7 +461,7 @@ export function WidgetEditor({
|
||||
])}
|
||||
>
|
||||
<Plus size={12} aria-hidden="true" /> {t('app.widgetEditor.addSeries', 'Add series')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={t('app.widgetEditor.legend', 'Legend')} inline>
|
||||
@@ -627,16 +628,16 @@ export function WidgetEditor({
|
||||
</div>
|
||||
|
||||
<div className="skwe-edit__foot">
|
||||
<button type="button" className="btn btn-sm" onClick={onDuplicate}>
|
||||
<SharedButton variant="unstyled" type="button" className="btn btn-sm" onClick={onDuplicate}>
|
||||
<Copy size={13} aria-hidden="true" /> {t('app.widgetEditor.duplicate', 'Duplicate')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={onRemove}>
|
||||
</SharedButton>
|
||||
<SharedButton variant="danger" type="button" className="btn btn-sm btn-danger" onClick={onRemove}>
|
||||
<Trash2 size={13} aria-hidden="true" /> {t('common.actions.remove', 'Remove')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
<span className="skwe-edit__spacer" />
|
||||
<button type="button" className="btn btn-sm btn-primary" onClick={onClose}>
|
||||
<SharedButton variant="primary" type="button" className="btn btn-sm btn-primary" onClick={onClose}>
|
||||
{t('common.actions.done', 'Done')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { WidgetBody } from '../widgets/renderers';
|
||||
import { deriveWidgetTitle } from '../widgets/registry';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Chrome around one placed widget: a header (drag handle + title + actions),
|
||||
// the renderer body, and — in edit mode — the bottom-right resize grip.
|
||||
@@ -74,7 +75,7 @@ export function WidgetFrame({
|
||||
{widget.type === 'logs' && <span className="skw-frame__live" aria-hidden="true" />}
|
||||
<div className="skw-frame__actions">
|
||||
{edit && (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="skw-iconbtn skw-iconbtn--bare"
|
||||
title={t('app.widgetFrame.configureWidget', 'Configure widget')}
|
||||
@@ -82,9 +83,9 @@ export function WidgetFrame({
|
||||
onClick={(event) => { event.stopPropagation(); fire('config'); }}
|
||||
>
|
||||
<SlidersHorizontal size={13} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
)}
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="skw-iconbtn skw-iconbtn--bare"
|
||||
title={t('app.widgetFrame.viewFullscreen', 'View fullscreen')}
|
||||
@@ -92,9 +93,9 @@ export function WidgetFrame({
|
||||
onClick={(event) => { event.stopPropagation(); fire('full'); }}
|
||||
>
|
||||
<Maximize2 size={13} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
<div className="skw-frame__menu-wrap" ref={menuRef}>
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="skw-iconbtn skw-iconbtn--bare"
|
||||
title={t('app.widgetFrame.widgetMenu', 'Widget menu')}
|
||||
@@ -104,23 +105,23 @@ export function WidgetFrame({
|
||||
onClick={(event) => { event.stopPropagation(); setMenuOpen((open) => !open); }}
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
{menuOpen && (
|
||||
<div className="skw-menu" role="menu" onClick={(event) => event.stopPropagation()}>
|
||||
<button type="button" className="skw-menu__item" role="menuitem" onClick={() => fire('config')}>
|
||||
<SharedButton variant="unstyled" type="button" className="skw-menu__item" role="menuitem" onClick={() => fire('config')}>
|
||||
<span className="skw-menu__ic"><SlidersHorizontal size={14} /></span>
|
||||
{t('app.widgetFrame.configure', 'Configure')}
|
||||
</button>
|
||||
<button type="button" className="skw-menu__item" role="menuitem" onClick={() => fire('dup')}>
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled" type="button" className="skw-menu__item" role="menuitem" onClick={() => fire('dup')}>
|
||||
<span className="skw-menu__ic"><Copy size={14} /></span>
|
||||
{t('app.widgetFrame.duplicate', 'Duplicate')}
|
||||
</button>
|
||||
<button type="button" className="skw-menu__item" role="menuitem" onClick={() => fire('full')}>
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled" type="button" className="skw-menu__item" role="menuitem" onClick={() => fire('full')}>
|
||||
<span className="skw-menu__ic"><Maximize2 size={14} /></span>
|
||||
{t('app.widgetFrame.viewFullscreen', 'View fullscreen')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
<div className="skw-menu__sep" role="separator" />
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="skw-menu__item skw-menu__item--danger"
|
||||
role="menuitem"
|
||||
@@ -128,7 +129,7 @@ export function WidgetFrame({
|
||||
>
|
||||
<span className="skw-menu__ic"><Trash2 size={14} /></span>
|
||||
{t('common.actions.remove', 'Remove')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { X } from 'lucide-react';
|
||||
import { deriveWidgetTitle, getWidgetType, useWidgetTypes } from '../widgets/registry';
|
||||
import { WidgetBody } from '../widgets/renderers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* Modal that blows one widget up to a comfortable reading size. Escape or a
|
||||
@@ -45,7 +46,7 @@ export function WidgetFullscreen({ widget, type, ctx, onClose }) {
|
||||
<div className="skw-frame__head">
|
||||
<span className="skw-frame__title">{title}</span>
|
||||
<div className="skw-frame__actions">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="skw-iconbtn skw-iconbtn--bare"
|
||||
title={t('common.actions.close', 'Close')}
|
||||
@@ -53,7 +54,7 @@ export function WidgetFullscreen({ widget, type, ctx, onClose }) {
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="skw-frame__body">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { LayoutGrid, Plus } from 'lucide-react';
|
||||
import { Drawer, SearchField } from '@/components/ds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// Registry entries carry a lucide component in `icon`. Guard against a type
|
||||
// that shipped without one (or with a plain string) so the card still renders.
|
||||
@@ -252,16 +253,16 @@ export function WidgetLibrary({ types = [], onAdd, onClose }) {
|
||||
placeholder={t('app.widgetLibrary.searchWidgets', 'Search widgets…')}
|
||||
/>
|
||||
<div className="skw-lib__chips">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`skw-lib__chip${category ? '' : ' skw-lib__chip--on'}`}
|
||||
aria-pressed={!category}
|
||||
onClick={() => setCategory(null)}
|
||||
>
|
||||
{t('common.labels.all', 'All')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
{categories.map((name) => (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
key={name}
|
||||
type="button"
|
||||
className={`skw-lib__chip${category === name ? ' skw-lib__chip--on' : ''}`}
|
||||
@@ -269,7 +270,7 @@ export function WidgetLibrary({ types = [], onAdd, onClose }) {
|
||||
onClick={() => setCategory(category === name ? null : name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
</SharedButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { statusColor } from '@/components/ds/status';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatRelativeTime } from '@/utils/time';
|
||||
/**
|
||||
* The 13 core dashboard widget renderers (plan 62).
|
||||
*
|
||||
@@ -23,7 +26,7 @@ import {
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { Gauge, Pill } from '@/components/ds';
|
||||
import { Gauge, Pill, serviceStatusKind, statusKind, alertStatusKind } from '@/components/ds';
|
||||
import { getWidgetType } from './registry';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { t } from '../../../i18n/t';
|
||||
@@ -80,28 +83,6 @@ function Failed({ error, subject = 'data' }) {
|
||||
);
|
||||
}
|
||||
|
||||
function relTime(value) {
|
||||
if (!value) return '';
|
||||
const stamp = new Date(value).getTime();
|
||||
if (Number.isNaN(stamp)) return '';
|
||||
const minutes = Math.floor(Math.max(0, Date.now() - stamp) / 60000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
const OK_STATE = /^(running|healthy|online|succeeded|success|completed|up|active)$/i;
|
||||
const BAD_STATE = /^(failed|error|down|exited|stopped|offline|cancelled)$/i;
|
||||
|
||||
function pillKind(state) {
|
||||
const value = String(state || '');
|
||||
if (OK_STATE.test(value)) return 'green';
|
||||
if (BAD_STATE.test(value)) return 'red';
|
||||
return 'amber';
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-series area chart. Hand-rolled rather than reusing ds/AreaChart
|
||||
* because a widget needs the fill toggle and a height that tracks its cell.
|
||||
@@ -537,12 +518,12 @@ function WTable({ cfg, ctx }) {
|
||||
if (key === 'status' || key === 'state') {
|
||||
return (
|
||||
<td key={label}>
|
||||
<Pill kind={pillKind(value)}>{value || 'unknown'}</Pill>
|
||||
<Pill kind={(cfg.source === 'deploys' ? statusKind(value) : serviceStatusKind(value))}>{value || 'unknown'}</Pill>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
const isName = key === 'name' || key === 'app_name';
|
||||
const text = key === 'created_at' ? relTime(value) : value;
|
||||
const text = key === 'created_at' ? formatRelativeTime(value) : value;
|
||||
return (
|
||||
<td key={label} className={isName ? 'nm' : 'mono'}>
|
||||
{text === null || text === undefined || text === '' ? '—' : String(text)}
|
||||
@@ -608,13 +589,6 @@ function WLogs({ cfg, ctx }) {
|
||||
|
||||
/* ----------------------------------------------------------------- deploys */
|
||||
|
||||
const DEPLOY_COLOR = {
|
||||
succeeded: 'var(--green)',
|
||||
completed: 'var(--green)',
|
||||
failed: 'var(--red)',
|
||||
running: 'var(--amber)',
|
||||
};
|
||||
|
||||
// A job reports total_steps + current_step; derive each dot's state from those
|
||||
// rather than inventing per-step records the API does not return.
|
||||
function stepState(index, job) {
|
||||
@@ -643,7 +617,7 @@ function WDeploys({ cfg, ctx }) {
|
||||
>
|
||||
<div className="skw-dep__top">
|
||||
<span className="skw-dep__name">{job.app_name || job.kind || 'deployment'}</span>
|
||||
<span className="skw-dep__state mono" style={{ color: DEPLOY_COLOR[job.status] || 'var(--text-faint)' }}>
|
||||
<span className="skw-dep__state mono" style={{ color: statusColor(job.status) }}>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
@@ -655,7 +629,7 @@ function WDeploys({ cfg, ctx }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="skw-dep__meta mono">
|
||||
{job.trigger || 'manual'} · {job.target_server_name || 'local'} · {relTime(job.created_at) || '—'}
|
||||
{job.trigger || 'manual'} · {job.target_server_name || 'local'} · {formatRelativeTime(job.created_at) || '—'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -665,8 +639,6 @@ function WDeploys({ cfg, ctx }) {
|
||||
|
||||
/* ------------------------------------------------------------------ alerts */
|
||||
|
||||
const SEVERITY_COLOR = { critical: 'var(--red)', warning: 'var(--amber)' };
|
||||
|
||||
function WAlerts({ cfg, ctx }) {
|
||||
const { t } = useTranslation();
|
||||
const { alerts, loading, error } = useOpenAlerts(cfg.limit, ctx.tick);
|
||||
@@ -683,17 +655,17 @@ function WAlerts({ cfg, ctx }) {
|
||||
return (
|
||||
<div className="skw-list">
|
||||
{shown.map((alert) => {
|
||||
const color = SEVERITY_COLOR[alert.severity] || 'var(--cyan)';
|
||||
const color = statusColor(alert.severity);
|
||||
return (
|
||||
<div className="skw-alert" key={alert.id} onClick={() => ctx.navigate?.('/monitoring')}>
|
||||
<span className="skw-alert__sev" style={{ background: color, boxShadow: `0 0 7px ${color}` }} />
|
||||
<div className="skw-alert__body">
|
||||
<div className="skw-alert__title">{alert.title}</div>
|
||||
<div className="skw-alert__meta mono">
|
||||
{alert.target}{alert.time ? ` · ${relTime(alert.time)}` : ''}
|
||||
{alert.target}{alert.time ? ` · ${formatRelativeTime(alert.time)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Pill kind={alert.state === 'firing' ? 'red' : 'amber'}>{alert.state}</Pill>
|
||||
<Pill kind={alertStatusKind(alert.state)}>{alert.state}</Pill>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -703,8 +675,6 @@ function WAlerts({ cfg, ctx }) {
|
||||
|
||||
/* ------------------------------------------------------------------ status */
|
||||
|
||||
const STATE_COLOR = { up: 'var(--green)', down: 'var(--red)', unknown: 'var(--amber)' };
|
||||
|
||||
function WStatus({ cfg, ctx }) {
|
||||
const { t } = useTranslation();
|
||||
const { cells, loading, error, source } = useStatusCells(cfg.source, cfg.limit, ctx.tick);
|
||||
@@ -718,7 +688,7 @@ function WStatus({ cfg, ctx }) {
|
||||
return (
|
||||
<div className="skw-status">
|
||||
{cells.map((cell) => {
|
||||
const color = STATE_COLOR[cell.state] || 'var(--amber)';
|
||||
const color = statusColor(cell.state);
|
||||
return (
|
||||
<div
|
||||
className="skw-status__cell"
|
||||
@@ -768,7 +738,7 @@ function WFeed({ cfg, ctx }) {
|
||||
<span className="skw-feed__dot" />
|
||||
<div className="skw-feed__body">
|
||||
<div className="skw-feed__text">{describeLog(entry)}</div>
|
||||
<div className="skw-feed__time mono">{relTime(entry.created_at)}</div>
|
||||
<div className="skw-feed__time mono">{formatRelativeTime(entry.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -811,10 +781,10 @@ function WActions({ cfg, ctx }) {
|
||||
{items.map((key) => {
|
||||
const [label, Icon, href] = ACTIONS[key];
|
||||
return (
|
||||
<button type="button" className="skw-actions__row" key={key} onClick={() => ctx.navigate?.(href)}>
|
||||
<Button variant="unstyled" type="button" className="skw-actions__row" key={key} onClick={() => ctx.navigate?.(href)}>
|
||||
<span className="skw-actions__label">{label}</span>
|
||||
<span className="skw-actions__icon"><Icon size={15} /></span>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
*
|
||||
* De-duplication: a board can easily hold ten stat widgets pointed at the same
|
||||
* server and range. Each one asks for the SAME history payload, so requests are
|
||||
* collapsed through a tiny module-level promise cache keyed by
|
||||
* collapsed through the shared query client, with workspace-scoped keys for
|
||||
* `<kind>|<resource>|<metric>|<range>|<tick>`. Ten widgets on one tick produce
|
||||
* one HTTP request; the next tick makes a new key and refetches.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import api from '@/services/api';
|
||||
import { queryClient } from '@/services/queryClient';
|
||||
import { fetchShared, widgetQueryKey, pruneWidgetQueries } from '@/services/widgetQueries';
|
||||
import { useWorkspace } from '@/contexts/useWorkspace.js';
|
||||
import {
|
||||
RANGE_IDS,
|
||||
getMetric,
|
||||
@@ -21,44 +24,6 @@ import {
|
||||
stampsFrom,
|
||||
} from './metrics';
|
||||
|
||||
// Requests for the same key inside this window share one in-flight promise.
|
||||
const CACHE_TTL_MS = 4000;
|
||||
const CACHE_MAX_ENTRIES = 96;
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
function prune(now) {
|
||||
if (cache.size <= CACHE_MAX_ENTRIES) return;
|
||||
for (const [key, entry] of cache) {
|
||||
if (now - entry.at > CACHE_TTL_MS) cache.delete(key);
|
||||
}
|
||||
// Still oversized (a burst of distinct keys) — drop oldest-first.
|
||||
while (cache.size > CACHE_MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next();
|
||||
if (oldest.done) break;
|
||||
cache.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Share one in-flight promise per key. Exported for tests and for the
|
||||
* fullscreen view, which mounts a second copy of the same widget. */
|
||||
export function fetchShared(key, loader) {
|
||||
const now = Date.now();
|
||||
const hit = cache.get(key);
|
||||
if (hit && now - hit.at < CACHE_TTL_MS) return hit.promise;
|
||||
|
||||
const promise = Promise.resolve().then(loader);
|
||||
cache.set(key, { at: now, promise });
|
||||
// A failure must not be replayed to a later mount, and must not surface as
|
||||
// an unhandled rejection when no consumer is attached yet.
|
||||
promise.catch(() => {
|
||||
const current = cache.get(key);
|
||||
if (current && current.promise === promise) cache.delete(key);
|
||||
});
|
||||
prune(now);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a thrown API error into what a widget needs to render.
|
||||
* ApiClient attaches `status`, so a permission problem (403 from
|
||||
@@ -80,31 +45,45 @@ function toWidgetError(err) {
|
||||
* effect — the key is the identity.
|
||||
*/
|
||||
function useShared(key, loader, enabled = true) {
|
||||
const { activeWorkspaceId } = useWorkspace();
|
||||
const queryKey = useMemo(() => widgetQueryKey(key, activeWorkspaceId), [key, activeWorkspaceId]);
|
||||
const loaderRef = useRef(loader);
|
||||
loaderRef.current = loader;
|
||||
|
||||
const [state, setState] = useState({ data: null, loading: Boolean(enabled), error: null });
|
||||
const subscribe = useCallback((listener) => {
|
||||
// Fullscreen can remount while a request is pending. Keep that request
|
||||
// shared until it settles, then bound the unobserved retained results.
|
||||
const unsubscribe = queryClient.subscribe(queryKey, listener, { cancelOnUnsubscribe: false });
|
||||
return () => { unsubscribe(); pruneWidgetQueries(); };
|
||||
}, [queryKey]);
|
||||
const getSnapshot = useCallback(() => queryClient.getSnapshot(queryKey), [queryKey]);
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const previous = useRef(null);
|
||||
// Tick is the trailing numeric segment of refreshable widget keys. Keep
|
||||
// the last successful payload during a board refresh, but never carry it
|
||||
// across a changed resource, range, or workspace.
|
||||
const resourceKey = key.replace(/\|\d+$/, '');
|
||||
if (state.status === 'success') {
|
||||
previous.current = { resourceKey, workspaceId: activeWorkspaceId, data: state.data };
|
||||
} else if (state.status === 'error') {
|
||||
previous.current = null;
|
||||
}
|
||||
const previousData = previous.current?.resourceKey === resourceKey
|
||||
&& previous.current?.workspaceId === activeWorkspaceId
|
||||
? previous.current.data : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setState({ data: null, loading: false, error: null });
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
setState((prev) => ({ data: prev.data, loading: true, error: null }));
|
||||
if (!enabled) return;
|
||||
fetchShared(key, () => loaderRef.current(), activeWorkspaceId).catch(() => {
|
||||
// The shared snapshot owns errors; keep permission failures visible.
|
||||
});
|
||||
}, [key, activeWorkspaceId, enabled, state.revision]);
|
||||
|
||||
fetchShared(key, () => loaderRef.current())
|
||||
.then((data) => {
|
||||
if (!cancelled) setState({ data, loading: false, error: null });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setState({ data: null, loading: false, error: toWidgetError(err) });
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [key, enabled]);
|
||||
|
||||
return state;
|
||||
if (!enabled) return { data: null, loading: false, error: null };
|
||||
return {
|
||||
data: state.status === 'error' ? null : state.data ?? previousData,
|
||||
loading: state.status === 'idle' || state.status === 'loading' || state.isFetching,
|
||||
error: state.error ? toWidgetError(state.error) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function safeRange(range) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import api from '../../services/api';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useTableSort } from '@/hooks/useTableSort';
|
||||
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// The backend reports the engine as the bare wire name it derived from the
|
||||
// filename prefix; these are what the row (and therefore every rule) says.
|
||||
@@ -169,9 +170,9 @@ export default function BackupsTab() {
|
||||
className: 'text-right',
|
||||
cellClassName: 'dbx-grid-actions',
|
||||
render: (b) => (
|
||||
<button type="button" className="dbx-icon-btn is-danger" onClick={() => remove(b.filename)} aria-label={t('app.backupsTab.delete', 'Delete {{filename}}', { filename: b.filename })}>
|
||||
<SharedButton variant="unstyled" type="button" className="dbx-icon-btn is-danger" onClick={() => remove(b.filename)} aria-label={t('app.backupsTab.delete', 'Delete {{filename}}', { filename: b.filename })}>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</SharedButton>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { RefreshCw, Undo2, Wand2 } from 'lucide-react';
|
||||
import api from '../../services/api';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Play, History, Download, Eraser, Lock, Unlock, Clock } from 'lucide-react';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { runQuery, connKey } from './dbAdapter';
|
||||
import ResultsGrid from './ResultsGrid';
|
||||
import SqlEditor from './SqlEditor';
|
||||
import { downloadBlob } from '@/utils/downloadBlob';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
const HISTORY_KEY = 'serverkit_query_history';
|
||||
const MAX_HISTORY = 50;
|
||||
@@ -116,7 +117,7 @@ export default function ConsoleTab({ conn, tabId, active, isAdmin, initialQuery
|
||||
return (
|
||||
<div className="dbx-console">
|
||||
<div className="dbx-console-toolbar">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="dbx-run"
|
||||
onClick={execute}
|
||||
@@ -126,10 +127,10 @@ export default function ConsoleTab({ conn, tabId, active, isAdmin, initialQuery
|
||||
<Play size={14} aria-hidden="true" />
|
||||
{loading ? 'Running…' : 'Run'}
|
||||
<kbd>{MOD_KEY} ↵</kbd>
|
||||
</button>
|
||||
</SharedButton>
|
||||
|
||||
{isAdmin && (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`dbx-toggle ${readonly ? '' : 'is-write'}`}
|
||||
onClick={() => setReadonly((r) => !r)}
|
||||
@@ -138,25 +139,25 @@ export default function ConsoleTab({ conn, tabId, active, isAdmin, initialQuery
|
||||
>
|
||||
{readonly ? <Lock size={13} aria-hidden="true" /> : <Unlock size={13} aria-hidden="true" />}
|
||||
{readonly ? 'Read-only' : 'Writes on'}
|
||||
</button>
|
||||
</SharedButton>
|
||||
)}
|
||||
|
||||
<div className="dbx-console-toolbar-spacer" />
|
||||
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`dbx-chip ${showHistory ? 'is-active' : ''}`}
|
||||
onClick={() => setShowHistory((s) => !s)}
|
||||
aria-pressed={showHistory}
|
||||
>
|
||||
<History size={14} aria-hidden="true" /> {t('app.consoleTab.history', 'History')}
|
||||
</button>
|
||||
<button type="button" className="dbx-chip" onClick={() => { setQuery(''); editorRef.current?.focus(); }}>
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled" type="button" className="dbx-chip" onClick={() => { setQuery(''); editorRef.current?.focus(); }}>
|
||||
<Eraser size={14} aria-hidden="true" /> {t('common.actions.clear', 'Clear')}
|
||||
</button>
|
||||
<button type="button" className="dbx-chip" onClick={exportCsv} disabled={!results?.rows?.length}>
|
||||
</SharedButton>
|
||||
<SharedButton variant="unstyled" type="button" className="dbx-chip" onClick={exportCsv} disabled={!results?.rows?.length}>
|
||||
<Download size={14} aria-hidden="true" /> {t('app.consoleTab.export', 'Export')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
</div>
|
||||
|
||||
<div className="dbx-console-split">
|
||||
@@ -185,7 +186,7 @@ export default function ConsoleTab({ conn, tabId, active, isAdmin, initialQuery
|
||||
<ul>
|
||||
{history.map((item, idx) => (
|
||||
<li key={idx}>
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
onClick={() => { setQuery(item.query); setShowHistory(false); editorRef.current?.focus(); }}
|
||||
>
|
||||
@@ -194,7 +195,7 @@ export default function ConsoleTab({ conn, tabId, active, isAdmin, initialQuery
|
||||
<Clock size={11} aria-hidden="true" />
|
||||
{new Date(item.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
</SharedButton>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -286,9 +286,9 @@ export default function CreateTableModal({ preset, engines = [], isAdmin = false
|
||||
? 'ServerKit could not reach any database server. Check that MySQL or PostgreSQL is running, then try again.'
|
||||
: 'Create a database first — the builder writes into an existing one.'}
|
||||
</p>
|
||||
<button type="button" className="dbx-inline-link" onClick={load}>
|
||||
<Button variant="unstyled" type="button" className="dbx-inline-link" onClick={load}>
|
||||
<RefreshCw size={13} aria-hidden="true" /> {t('app.createTableModal.tryAgain', 'Try again')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -413,7 +413,7 @@ export default function CreateTableModal({ preset, engines = [], isAdmin = false
|
||||
</select>
|
||||
<ChevronDown size={12} aria-hidden="true" />
|
||||
</div>
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className={`dbx-cbox${c.pk ? ' is-on' : ''}`}
|
||||
onClick={() => setKeyCol(i)}
|
||||
@@ -421,8 +421,8 @@ export default function CreateTableModal({ preset, engines = [], isAdmin = false
|
||||
aria-label={t('app.createTableModal.onColumn', '{{key}} on column {{value}}', { key: words.key, value: i + 1 })}
|
||||
>
|
||||
{c.pk && <Check size={12} aria-hidden="true" />}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className={`dbx-cbox${c.notNull ? ' is-on' : ''}`}
|
||||
onClick={() => setCol(i, 'notNull', !c.notNull)}
|
||||
@@ -430,7 +430,7 @@ export default function CreateTableModal({ preset, engines = [], isAdmin = false
|
||||
aria-label={t('app.createTableModal.onColumn2', '{{required}} on column {{value}}', { required: words.required, value: i + 1 })}
|
||||
>
|
||||
{c.notNull && <Check size={12} aria-hidden="true" />}
|
||||
</button>
|
||||
</Button>
|
||||
<div className="dbx-input dbx-input--sm">
|
||||
<input
|
||||
value={c.def}
|
||||
@@ -440,7 +440,7 @@ export default function CreateTableModal({ preset, engines = [], isAdmin = false
|
||||
aria-label={t('app.createTableModal.defaultForColumn', 'Default for column {{value}}', { value: i + 1 })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="dbx-icon-xs"
|
||||
onClick={() => removeCol(i)}
|
||||
@@ -448,13 +448,13 @@ export default function CreateTableModal({ preset, engines = [], isAdmin = false
|
||||
aria-label={t('app.createTableModal.removeColumn', 'Remove column {{value}}', { value: i + 1 })}
|
||||
>
|
||||
<Trash2 size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="dbx-inline-link dbx-colgrid__add" onClick={addCol}>
|
||||
<Button variant="unstyled" type="button" className="dbx-inline-link dbx-colgrid__add" onClick={addCol}>
|
||||
<Plus size={13} aria-hidden="true" /> {t('common.actions.add', 'Add')} {target?.dialect ? 'column' : 'field'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="dbx-field-hint">
|
||||
{target?.dialect ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Copy, Plus, Trash2, UserRound } from 'lucide-react';
|
||||
import api from '../../services/api';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
@@ -31,7 +31,7 @@ export default function DbUsersPanel({ databaseId }) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [databaseId, toast]);
|
||||
}, [databaseId, t, toast]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
import api from '../../services/api';
|
||||
import { Drawer, SearchField } from '@/components/ds';
|
||||
import EmptyState from '../EmptyState';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import EngineGlyph from './EngineGlyph';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
engineInstanceKey, engineLocation, engineMeta, engineTreeStatus, familiesOf,
|
||||
formatMemory,
|
||||
} from './engineHelpers';
|
||||
import { Button as SharedButton } from '@/components/ui/button';
|
||||
|
||||
// "Add a database engine" — the browse half of the install flow.
|
||||
//
|
||||
@@ -80,7 +81,7 @@ function EngineCard({ entry, onPick }) {
|
||||
|
||||
return (
|
||||
<li className="dbx-engine-card">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="dbx-engine-card__hit"
|
||||
onClick={() => onPick(entry)}
|
||||
@@ -106,7 +107,7 @@ function EngineCard({ entry, onPick }) {
|
||||
{entry.installed_count > 0 ? 'Add another' : 'Install'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</SharedButton>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -198,16 +199,16 @@ export default function EngineCatalogDrawer({
|
||||
/>
|
||||
{families.length > 0 && (
|
||||
<div className="dbx-cat-chips" role="group" aria-label={t('app.engineCatalogDrawer.filterByFamily', 'Filter by family')}>
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className={`dbx-cat-chip${family ? '' : ' is-on'}`}
|
||||
onClick={() => setFamily(null)}
|
||||
aria-pressed={!family}
|
||||
>
|
||||
{t('common.labels.all', 'All')}
|
||||
</button>
|
||||
</SharedButton>
|
||||
{families.map((f) => (
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
key={f}
|
||||
type="button"
|
||||
className={`dbx-cat-chip${family === f ? ' is-on' : ''}`}
|
||||
@@ -215,7 +216,7 @@ export default function EngineCatalogDrawer({
|
||||
aria-pressed={family === f}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
</SharedButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -266,7 +267,7 @@ export default function EngineCatalogDrawer({
|
||||
</div>
|
||||
|
||||
<footer className="dbx-drawer__foot dbx-drawer__foot--split">
|
||||
<button
|
||||
<SharedButton variant="unstyled"
|
||||
type="button"
|
||||
className="dbx-inline-link"
|
||||
onClick={syncRepos}
|
||||
@@ -274,7 +275,7 @@ export default function EngineCatalogDrawer({
|
||||
>
|
||||
<RefreshCw size={13} className={syncing ? 'dbx-spin' : undefined} aria-hidden="true" />
|
||||
{syncing ? 'Syncing…' : 'Sync template repositories'}
|
||||
</button>
|
||||
</SharedButton>
|
||||
<Link
|
||||
className="dbx-inline-link"
|
||||
to="/templates"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { EngineIcon, hasBrandIcon } from '../icons/DatabaseBrands';
|
||||
import { EngineIcon } from '../icons/DatabaseBrands';
|
||||
import { hasBrandIcon } from '../icons/databaseBrandData';
|
||||
import { engineMeta } from './engineHelpers';
|
||||
|
||||
// The engine icon, resolved in three steps: the brand glyph we ship, then the
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Drawer, SegControl } from '@/components/ds';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import EngineGlyph from './EngineGlyph';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -49,7 +49,7 @@ function SecretRow({ password, onRegenerate, disabled }) {
|
||||
<div className="dbx-secret">
|
||||
<span className="dbx-secret__value">{revealed ? password : '•'.repeat(20)}</span>
|
||||
<span className="dbx-secret__actions">
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="dbx-icon-xs"
|
||||
onClick={() => setRevealed((r) => !r)}
|
||||
@@ -57,9 +57,9 @@ function SecretRow({ password, onRegenerate, disabled }) {
|
||||
title={revealed ? t('app.engineInstallDrawer.hide', 'Hide') : t('app.engineInstallDrawer.reveal', 'Reveal')}
|
||||
>
|
||||
{revealed ? <EyeOff size={13} aria-hidden="true" /> : <Eye size={13} aria-hidden="true" />}
|
||||
</button>
|
||||
</Button>
|
||||
{onRegenerate && (
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="dbx-icon-xs"
|
||||
onClick={() => { onRegenerate(); setRevealed(true); }}
|
||||
@@ -68,9 +68,9 @@ function SecretRow({ password, onRegenerate, disabled }) {
|
||||
title={t('app.engineInstallDrawer.regenerate', 'Regenerate')}
|
||||
>
|
||||
<RefreshCw size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="dbx-icon-xs"
|
||||
onClick={copy}
|
||||
@@ -78,7 +78,7 @@ function SecretRow({ password, onRegenerate, disabled }) {
|
||||
title={t('common.actions.copy', 'Copy')}
|
||||
>
|
||||
<Copy size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -316,13 +316,13 @@ export default function EngineInstallDrawer({ entry, open, onOpenChange, onInsta
|
||||
{suggestedPort && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
type="button"
|
||||
className="dbx-inline-link"
|
||||
onClick={() => { setPort(String(suggestedPort)); setError(''); }}
|
||||
>
|
||||
{t('app.engineInstallDrawer.usePort', 'Use port')} {suggestedPort}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -175,9 +175,9 @@ export default function ImportDumpModal({ preset, isAdmin = false, onClose, onIm
|
||||
? 'Neither MySQL nor PostgreSQL answered. Check that the server is running, then try again.'
|
||||
: `Importing is only wired for ${RESTORABLE.map((e) => ENGINE_META[e].short).join(' and ')} databases on this host — SQLite files and containerised databases have no restore route.`}
|
||||
</p>
|
||||
<button type="button" className="dbx-inline-link" onClick={load}>
|
||||
<Button variant="unstyled" type="button" className="dbx-inline-link" onClick={load}>
|
||||
<RefreshCw size={13} aria-hidden="true" /> {t('app.importDumpModal.tryAgain', 'Try again')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -217,7 +217,7 @@ export default function ImportDumpModal({ preset, isAdmin = false, onClose, onIm
|
||||
<span className="dbx-field__label">{t('app.importDumpModal.dumpFile', 'Dump file')}</span>
|
||||
<div className="dbx-choice" role="group" aria-label={t('app.importDumpModal.whereTheDumpLives', 'Where the dump lives')}>
|
||||
{SOURCES.map((s) => (
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={`dbx-choice__btn${source === s.id ? ' is-on' : ''}`}
|
||||
@@ -225,7 +225,7 @@ export default function ImportDumpModal({ preset, isAdmin = false, onClose, onIm
|
||||
aria-pressed={source === s.id}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -241,7 +241,7 @@ export default function ImportDumpModal({ preset, isAdmin = false, onClose, onIm
|
||||
{t('app.importDumpModal.noDumpsInServerkitSBackup', 'No dumps in ServerKit\'s backup directory yet. Back up a database, or point at a path below.')}
|
||||
</p>
|
||||
) : backups.map((b) => (
|
||||
<button
|
||||
<Button variant="unstyled"
|
||||
key={b.filename}
|
||||
type="button"
|
||||
className={`dbx-dump${picked === b.filename ? ' is-on' : ''}`}
|
||||
@@ -258,7 +258,7 @@ export default function ImportDumpModal({ preset, isAdmin = false, onClose, onIm
|
||||
<span className={`dbx-dump__tag is-${b.type}`}>
|
||||
{ENGINE_META[b.type]?.short || b.type}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Copy, ShieldCheck, Trash2, RefreshCw, Link2, Users } from 'lucide-react';
|
||||
import api from '../../services/api';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useToast } from '../../contexts/useToast.js';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pill } from '../ds';
|
||||
@@ -32,7 +32,7 @@ export default function ManagedDatabasesPanel() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
}, [t, toast]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
|
||||
Loaded 100 of 448 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user