release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
58
app/admin/__tests__/AdminOverview.test.tsx
Normal file
58
app/admin/__tests__/AdminOverview.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { render, screen, cleanup, waitFor } from '@testing-library/react';
|
||||
import AdminOverview from '../components/AdminOverview';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** Every admin endpoint answers 404 except gallery.yaml, which holds `gallery`. */
|
||||
function stubGallery(gallery: unknown) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) =>
|
||||
url === '/api/admin/gallery'
|
||||
? new Response(JSON.stringify({ gallery, version: 'v' }), { status: 200 })
|
||||
: new Response(JSON.stringify({ error: 'nope' }), { status: 404 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The "Pages" tile: its number and the note under it, once loaded. */
|
||||
async function pagesTile() {
|
||||
const label = await screen.findByText('Pages');
|
||||
const tile = label.closest('a')!;
|
||||
const value = () => tile.querySelector('.overview-stat-value')!.textContent;
|
||||
await waitFor(() => expect(value()).not.toBe('–'));
|
||||
return { value: value(), note: tile.querySelector('.overview-stat-note')!.textContent };
|
||||
}
|
||||
|
||||
describe('AdminOverview — Pages tile', () => {
|
||||
it('counts subpages written in the older map form of gallery.yaml', async () => {
|
||||
// `subpages:` as a map of name → albums (or name → object) is still a
|
||||
// valid gallery.yaml — getConfig() and the page builder both read it.
|
||||
// The overview called .filter() on it, and the first admin screen crashed.
|
||||
stubGallery({
|
||||
subpages: {
|
||||
Weddings: ['11111111-1111-4111-8111-111111111111'],
|
||||
Travel: { albums: ['22222222-2222-4222-8222-222222222222'], enabled: false },
|
||||
},
|
||||
});
|
||||
render(<AdminOverview />);
|
||||
expect(await pagesTile()).toEqual({ value: '1', note: '1 switched off' });
|
||||
});
|
||||
|
||||
it('leaves content-page menu entries out of the list form', async () => {
|
||||
stubGallery({
|
||||
subpages: [
|
||||
{ name: 'Weddings', albums: ['11111111-1111-4111-8111-111111111111'] },
|
||||
{ page: 'pricing' },
|
||||
{ name: 'Old', albums: [], enabled: false },
|
||||
],
|
||||
});
|
||||
render(<AdminOverview />);
|
||||
expect(await pagesTile()).toEqual({ value: '1', note: '1 switched off' });
|
||||
});
|
||||
});
|
||||
76
app/admin/__tests__/AssetPicker.test.tsx
Normal file
76
app/admin/__tests__/AssetPicker.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
|
||||
import AssetPicker from '../components/AssetPicker';
|
||||
|
||||
/**
|
||||
* A fetch whose answers the test hands out by hand, in any order — the
|
||||
* situation of a slow request still running when the operator moves on.
|
||||
*/
|
||||
function deferredFetch() {
|
||||
const pending: Array<{ url: string; resolve: (body: unknown) => void }> = [];
|
||||
const fetchMock = vi.fn(
|
||||
(url: string) =>
|
||||
new Promise<Response>((resolve) => {
|
||||
pending.push({
|
||||
url,
|
||||
resolve: (body) =>
|
||||
resolve({ ok: true, status: 200, json: async () => body } as unknown as Response),
|
||||
});
|
||||
}),
|
||||
);
|
||||
/** Answer the first open request whose URL contains `match`. */
|
||||
const answer = async (match: string, body: unknown) => {
|
||||
const i = pending.findIndex((p) => p.url.includes(match));
|
||||
if (i === -1) throw new Error(`no pending request for ${match}`);
|
||||
const [req] = pending.splice(i, 1);
|
||||
await act(async () => req.resolve(body));
|
||||
};
|
||||
return { fetchMock, answer };
|
||||
}
|
||||
|
||||
const asset = (id: string, name: string) => ({
|
||||
id,
|
||||
originalFileName: name,
|
||||
fileCreatedAt: '2024-05-17T10:00:00.000Z',
|
||||
isFavorite: false,
|
||||
});
|
||||
|
||||
let net: ReturnType<typeof deferredFetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
net = deferredFetch();
|
||||
vi.stubGlobal('fetch', net.fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('AssetPicker', () => {
|
||||
it('shows the tab it is on when an older request answers last', async () => {
|
||||
render(<AssetPicker onSelect={() => {}} onClose={() => {}} />);
|
||||
// Opens on Favorites; the operator switches to All Photos before it loads.
|
||||
fireEvent.click(screen.getByRole('button', { name: /All Photos/ }));
|
||||
|
||||
await net.answer('favorites=false', { assets: [asset('a1', 'all.jpg')], nextPage: null });
|
||||
await net.answer('favorites=true', { assets: [asset('f1', 'fav.jpg')], nextPage: null });
|
||||
|
||||
expect(screen.getByRole('button', { name: /all\.jpg/ })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /fav\.jpg/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the spinner until the current request answers', async () => {
|
||||
render(<AssetPicker onSelect={() => {}} onClose={() => {}} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /All Photos/ }));
|
||||
|
||||
// The stale Favorites answer must not end the All Photos loading state.
|
||||
await net.answer('favorites=true', { assets: [asset('f1', 'fav.jpg')], nextPage: null });
|
||||
expect(screen.queryByRole('button', { name: /fav\.jpg/ })).toBeNull();
|
||||
expect(screen.queryByText(/No favorite photos found/)).toBeNull();
|
||||
|
||||
await net.answer('favorites=false', { assets: [asset('a1', 'all.jpg')], nextPage: null });
|
||||
expect(screen.getByRole('button', { name: /all\.jpg/ })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { ConfirmProvider, useConfirm } from '../components/ConfirmDialog';
|
||||
import { ConfirmProvider, useChoice, useConfirm } from '../components/ConfirmDialog';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -60,3 +60,46 @@ describe('ConfirmDialog', () => {
|
||||
await waitFor(() => expect(results).toEqual([false, false]));
|
||||
});
|
||||
});
|
||||
|
||||
/** The concurrent-edit prompt (#601): more than two ways out. */
|
||||
function ChoiceTrigger({ onResult }: { onResult: (id: string | null) => void }) {
|
||||
const choose = useChoice();
|
||||
return (
|
||||
<button
|
||||
onClick={async () =>
|
||||
onResult(
|
||||
await choose({
|
||||
title: 'Changed elsewhere',
|
||||
actions: [
|
||||
{ id: 'overwrite', label: 'Overwrite anyway' },
|
||||
{ id: 'reload', label: 'Reload (discard mine)' },
|
||||
],
|
||||
cancelLabel: 'Keep editing',
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
choose
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useChoice', () => {
|
||||
it('resolves to the picked action, or null on cancel', async () => {
|
||||
const results: (string | null)[] = [];
|
||||
render(
|
||||
<ConfirmProvider>
|
||||
<ChoiceTrigger onResult={(id) => results.push(id)} />
|
||||
</ConfirmProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('choose'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload (discard mine)' }));
|
||||
await waitFor(() => expect(results).toEqual(['reload']));
|
||||
fireEvent.click(screen.getByText('choose'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Overwrite anyway' }));
|
||||
await waitFor(() => expect(results).toEqual(['reload', 'overwrite']));
|
||||
fireEvent.click(screen.getByText('choose'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Keep editing' }));
|
||||
await waitFor(() => expect(results).toEqual(['reload', 'overwrite', null]));
|
||||
});
|
||||
});
|
||||
|
||||
57
app/admin/__tests__/NewPageDialog.test.tsx
Normal file
57
app/admin/__tests__/NewPageDialog.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import NewPageDialog from '../components/page-builder/NewPageDialog';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const taken = { subpages: [], albums: [], journal: [] };
|
||||
|
||||
function setup() {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<NewPageDialog
|
||||
taken={taken}
|
||||
existingPageSlugs={[]}
|
||||
creating={false}
|
||||
onCreate={onCreate}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
return { onCreate, slug: screen.getByLabelText('URL') as HTMLInputElement };
|
||||
}
|
||||
|
||||
/** Type one key at a time into a controlled input, as a person does. */
|
||||
function typeKeys(input: HTMLInputElement, text: string) {
|
||||
for (const ch of text) fireEvent.change(input, { target: { value: input.value + ch } });
|
||||
}
|
||||
|
||||
describe('NewPageDialog slug field', () => {
|
||||
it('takes a hyphen typed between two words', () => {
|
||||
const { slug, onCreate } = setup();
|
||||
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'About' } });
|
||||
// Select all and retype: the first key replaces the derived slug.
|
||||
fireEvent.change(slug, { target: { value: 'a' } });
|
||||
typeKeys(slug, 'bout-us');
|
||||
expect(slug.value).toBe('about-us');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create page' }));
|
||||
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ slug: 'about-us' }));
|
||||
});
|
||||
|
||||
it('creates the finished slug when the field ends in a hyphen', () => {
|
||||
const { slug, onCreate } = setup();
|
||||
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Pricing' } });
|
||||
typeKeys(slug, '-');
|
||||
expect(slug.value).toBe('pricing-');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create page' }));
|
||||
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ slug: 'pricing' }));
|
||||
});
|
||||
|
||||
it('derives a readable slug from a German title', () => {
|
||||
const { slug } = setup();
|
||||
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Preise für Paare' } });
|
||||
expect(slug.value).toBe('preise-fur-paare');
|
||||
});
|
||||
});
|
||||
35
app/admin/__tests__/analyticsDays.test.ts
Normal file
35
app/admin/__tests__/analyticsDays.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { lastDays } from '../components/analyticsDays';
|
||||
|
||||
/**
|
||||
* "Last 14 Days" used to show the last 14 days that had any views, so quiet
|
||||
* days vanished and the chart could span a month (QA A-16).
|
||||
*/
|
||||
describe('lastDays', () => {
|
||||
const days = {
|
||||
'2026-08-28': { pageviews: 4 },
|
||||
'2026-09-20': { pageviews: 6 },
|
||||
'2026-09-29': { pageviews: 16 },
|
||||
};
|
||||
|
||||
it('returns every calendar day up to today, with 0 for days without views', () => {
|
||||
const out = lastDays(days, 14, new Date('2026-09-29T12:00:00Z'));
|
||||
expect(out).toHaveLength(14);
|
||||
expect(out[0]).toEqual({ date: '2026-09-16', pageviews: 0 });
|
||||
expect(out[4]).toEqual({ date: '2026-09-20', pageviews: 6 });
|
||||
expect(out[13]).toEqual({ date: '2026-09-29', pageviews: 16 });
|
||||
// Older recorded days are outside the window, not pulled into it.
|
||||
expect(out.find((d) => d.date === '2026-08-28')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('counts days in UTC, the way the tracker keys them', () => {
|
||||
// 23:30 UTC on the 29th is already the 30th in Berlin.
|
||||
const out = lastDays(days, 2, new Date('2026-09-29T23:30:00Z'));
|
||||
expect(out.map((d) => d.date)).toEqual(['2026-09-28', '2026-09-29']);
|
||||
});
|
||||
|
||||
it('crosses a month boundary without skipping or repeating a day', () => {
|
||||
const out = lastDays({}, 3, new Date('2026-10-01T00:00:00Z'));
|
||||
expect(out.map((d) => d.date)).toEqual(['2026-09-29', '2026-09-30', '2026-10-01']);
|
||||
});
|
||||
});
|
||||
@@ -110,3 +110,67 @@ describe('reading', () => {
|
||||
expect(ops.followMovedIndex(open, from, to)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('menu with content pages (#722)', () => {
|
||||
const withPages = (): GalleryState => ({
|
||||
...state(),
|
||||
// pricing before Japan, faq between Trips and Korea.
|
||||
pageRefs: [
|
||||
{ slug: 'pricing', position: 0 },
|
||||
{ slug: 'faq', position: 2 },
|
||||
],
|
||||
});
|
||||
const labels = (g: GalleryState) =>
|
||||
ops.menuItems(g).map((i) => (i.kind === 'page' ? `page:${i.slug}` : g.subpages[i.index].name));
|
||||
|
||||
it('interleaves pages and subpages', () => {
|
||||
expect(labels(withPages())).toEqual(['page:pricing', 'Japan', 'Trips', 'page:faq', 'Korea']);
|
||||
});
|
||||
|
||||
it('moves a page between subpages', () => {
|
||||
const g = ops.moveMenuItem(withPages(), 0, 2);
|
||||
expect(labels(g)).toEqual(['Japan', 'Trips', 'page:pricing', 'page:faq', 'Korea']);
|
||||
});
|
||||
|
||||
it('moves a subpage past a page and follows the open sheet', () => {
|
||||
const before = withPages();
|
||||
// Japan (row 1) to the end (row 4).
|
||||
expect(ops.followMenuMove(before, 0, 1, 4)).toBe(2);
|
||||
const g = ops.moveMenuItem(before, 1, 4);
|
||||
expect(labels(g)).toEqual(['page:pricing', 'Trips', 'page:faq', 'Korea', 'Japan']);
|
||||
expect(g.subpages.map((s) => s.name)).toEqual(['Trips', 'Korea', 'Japan']);
|
||||
});
|
||||
|
||||
it('puts a page in the menu at the end, or before a row, and takes it out', () => {
|
||||
let g = ops.setPageInMenu(state(), 'about-me', true);
|
||||
expect(labels(g)).toEqual(['Japan', 'Trips', 'Korea', 'page:about-me']);
|
||||
g = ops.setPageInMenu(g, 'rates', true, 1);
|
||||
expect(labels(g)).toEqual(['Japan', 'page:rates', 'Trips', 'Korea', 'page:about-me']);
|
||||
g = ops.setPageInMenu(g, 'rates', false);
|
||||
expect(labels(g)).toEqual(['Japan', 'Trips', 'Korea', 'page:about-me']);
|
||||
expect(ops.isPageInMenu(g, 'rates')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps pages in place when a subpage is removed', () => {
|
||||
const g = ops.removeSubpage(withPages(), 0);
|
||||
expect(labels(g)).toEqual(['page:pricing', 'Trips', 'page:faq', 'Korea']);
|
||||
});
|
||||
|
||||
it('follows a rename', () => {
|
||||
const g = ops.renamePageRef(withPages(), 'faq', 'questions');
|
||||
expect(labels(g)).toContain('page:questions');
|
||||
expect(labels(g)).not.toContain('page:faq');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addHeroes (#602)', () => {
|
||||
it('appends a multi-pick in pick order, skipping duplicates', () => {
|
||||
expect(ops.addHeroes(state(), ['h9', 'h2', 'h8', 'h9']).hero).toEqual([
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h9',
|
||||
'h8',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,3 +160,29 @@ describe('subpage keys', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('content page references (#722)', () => {
|
||||
it('keeps `- page:` entries where they were among the subpages', () => {
|
||||
const yaml = {
|
||||
subpages: [
|
||||
{ page: 'pricing' },
|
||||
{ name: 'Japan', albums: [A] },
|
||||
{ page: 'faq' },
|
||||
{ name: 'Korea', albums: [B] },
|
||||
{ page: 'contact-me' },
|
||||
],
|
||||
};
|
||||
expect(roundTrip(yaml)).toEqual(yaml);
|
||||
});
|
||||
|
||||
it('parses page references apart from the subpages', () => {
|
||||
const g = parseGalleryYaml({ subpages: [{ name: 'Japan', albums: [A] }, { page: 'faq' }] });
|
||||
expect(g.subpages.map((s) => s.name)).toEqual(['Japan']);
|
||||
expect(g.pageRefs).toEqual([{ slug: 'faq', position: 1 }]);
|
||||
});
|
||||
|
||||
it('writes a menu of pages only', () => {
|
||||
const yaml = { subpages: [{ page: 'pricing' }] };
|
||||
expect(roundTrip(yaml)).toEqual(yaml);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { JournalBlock } from '@/lib/journal';
|
||||
import { serializeJournalMarkdown, parseJournalMarkdown } from '@/lib/journal';
|
||||
import { createBlock, moveBlock, isLegacyAssetRef } from '../components/journal/blockOps';
|
||||
import {
|
||||
createBlock,
|
||||
createPhotoBlocks,
|
||||
fillAssetSlots,
|
||||
moveBlock,
|
||||
isLegacyAssetRef,
|
||||
} from '../components/journal/blockOps';
|
||||
import {
|
||||
clampSplit,
|
||||
splitFromPointer,
|
||||
@@ -124,3 +130,21 @@ describe('split pane', () => {
|
||||
expect(parseStoredSplit('abc')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-pick into photo blocks (#602)', () => {
|
||||
it('fills empty slots first, then appends', () => {
|
||||
expect(fillAssetSlots(['a', '', ''], ['x', 'y', 'z'])).toEqual(['a', 'x', 'y', 'z']);
|
||||
});
|
||||
|
||||
it('never grows past a capacity', () => {
|
||||
expect(fillAssetSlots(['', ''], ['x', 'y', 'z'], 2)).toEqual(['x', 'y']);
|
||||
expect(fillAssetSlots(['a', ''], ['x'], 2)).toEqual(['a', 'x']);
|
||||
});
|
||||
|
||||
it('creates one contained photo block per asset', () => {
|
||||
expect(createPhotoBlocks(['x', 'y'])).toEqual([
|
||||
{ type: 'photo', assetId: 'x', caption: '', layout: 'contained' },
|
||||
{ type: 'photo', assetId: 'y', caption: '', layout: 'contained' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
24
app/admin/__tests__/page-link-slug.test.ts
Normal file
24
app/admin/__tests__/page-link-slug.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// `?page=<slug>` in /admin/pages selects a page, and the page panel turns the
|
||||
// slug into its "open live page" link. A slug such as `/evil.example` would
|
||||
// make that link `//evil.example`, which leaves the site. Both ends guard it.
|
||||
const read = (file: string) => readFileSync(path.join(process.cwd(), file), 'utf8');
|
||||
|
||||
describe('page link from ?page=', () => {
|
||||
it('only selects a page for a valid slug', () => {
|
||||
const src = read('app/admin/components/PageBuilder.tsx');
|
||||
const fn = src.slice(src.indexOf('function openPageFromLink'));
|
||||
const body = fn.slice(0, fn.indexOf('\n }\n'));
|
||||
expect(body).toMatch(/if \(!isValidSlug\(slug\)\) return;/);
|
||||
expect(body.indexOf('isValidSlug(slug)')).toBeLessThan(body.indexOf('setSelectedPage(slug)'));
|
||||
});
|
||||
|
||||
it('encodes the slug in the live-page link', () => {
|
||||
const src = read('app/admin/components/page-builder/PagePanel.tsx');
|
||||
expect(src).toContain('href={`/${encodeURIComponent(slug)}`}');
|
||||
expect(src).not.toContain('href={`/${slug}`}');
|
||||
});
|
||||
});
|
||||
@@ -50,9 +50,9 @@ describe('systemHealth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a doctor warning as neutral', () => {
|
||||
it('reports a doctor warning in the warning tone, not the neutral one', () => {
|
||||
expect(health({ doctorLevel: 'warn' })).toEqual({
|
||||
tone: 'unknown',
|
||||
tone: 'warning',
|
||||
label: 'Check Diagnostics',
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,7 @@ describe('systemHealth', () => {
|
||||
'Setup Incomplete': 'unknown',
|
||||
'System Degraded': 'disconnected',
|
||||
'Needs Attention': 'disconnected',
|
||||
'Check Diagnostics': 'unknown',
|
||||
'Check Diagnostics': 'warning',
|
||||
'System OK': 'connected',
|
||||
'Status Unknown': 'unknown',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user