refactor: consolidate frontend reuse and eliminate lint warnings

This commit is contained in:
Juan Denis committed 2026-09-05 05:24:35 -04:00
1 parent f490d75bee
commit 92a3ffe6c3
448 files changed
+6371 -6822

No files matched your search

+3 -1
View File
@@ -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>
+5 -6
View File
@@ -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|$|-)/]',
+6 -2
View File
@@ -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
View File
@@ -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)
+211
View File
@@ -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 -13
View File
@@ -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
+51
View File
@@ -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' }] },
],
});
+108
View File
@@ -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()}
+29
View File
@@ -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;
}
+23
View File
@@ -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 @@
{}
+40
View File
@@ -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.`);
}
+144
View File
@@ -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();
}
+2 -1
View File
@@ -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';
+6 -5
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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 -2
View File
@@ -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>
);
}
+36 -35
View File
@@ -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>
</>
);
+2 -2
View File
@@ -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>
)}
+17 -20
View File
@@ -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>
)}
+12 -10
View File
@@ -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>
);
};
+2 -2
View File
@@ -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
+37 -33
View File
@@ -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>
+4 -3
View File
@@ -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">
+14 -13
View File
@@ -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>
+2 -2
View File
@@ -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';
+16 -15
View File
@@ -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 -1
View File
@@ -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);
+3 -2
View File
@@ -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>
);
}
+3 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+5 -4
View File
@@ -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>
+10 -9
View File
@@ -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>
);
}
+36 -35
View File
@@ -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>
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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
+10 -10
View File
@@ -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,
+2 -2
View File
@@ -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';
+3 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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>
);
+4 -3
View File
@@ -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>
+5 -4
View File
@@ -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>
+6 -5
View File
@@ -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>
);
+4 -3
View File
@@ -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>
);
+4 -3
View File
@@ -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 ? (
+25 -27
View File
@@ -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">&times;</button>
<Button variant="unstyled" type="button" onClick={() => setError(null)} className="alert-close">&times;</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;
+21 -26
View File
@@ -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">&times;</button>
<Button variant="unstyled" type="button" onClick={() => setError(null)} className="alert-close">&times;</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')}
+3 -3
View File
@@ -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';
+4 -11
View File
@@ -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