feat(core): rebrand to @lossless.zone/objectstorage

- Rename from @lossless.zone/s3container to @lossless.zone/objectstorage
- Replace @push.rocks/smarts3 with @push.rocks/smartstorage
- Change env var prefix from S3_ to OBJST_
- Rename S3Container class to ObjectStorageContainer
- Update web component prefix from s3c- to objst-
- Update UI labels, CLI flags, documentation, and Docker config
This commit is contained in:
2026-03-14 23:56:02 +00:00
commit 1f281bd7c8
76 changed files with 16765 additions and 0 deletions

8
ts_web/elements/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export * from './shared/index.js';
export * from './objst-app-shell.js';
export * from './objst-view-overview.js';
export * from './objst-view-buckets.js';
export * from './objst-view-objects.js';
export * from './objst-view-policies.js';
export * from './objst-view-config.js';
export * from './objst-view-credentials.js';

View File

@@ -0,0 +1,200 @@
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as interfaces from '../../ts_interfaces/index.js';
import { appRouter } from '../router.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
import type { ObjstViewOverview } from './objst-view-overview.js';
import type { ObjstViewBuckets } from './objst-view-buckets.js';
import type { ObjstViewObjects } from './objst-view-objects.js';
import type { ObjstViewPolicies } from './objst-view-policies.js';
import type { ObjstViewConfig } from './objst-view-config.js';
import type { ObjstViewCredentials } from './objst-view-credentials.js';
@customElement('objst-app-shell')
export class ObjstAppShell extends DeesElement {
@state()
accessor loginState: appstate.ILoginState = { identity: null, isLoggedIn: false };
@state()
accessor uiState: appstate.IUiState = {
activeView: 'overview',
autoRefresh: true,
refreshInterval: 30000,
};
private viewTabs = [
{ name: 'Overview', iconName: 'lucide:layoutDashboard', element: (async () => (await import('./objst-view-overview.js')).ObjstViewOverview)() },
{ name: 'Buckets', iconName: 'lucide:database', element: (async () => (await import('./objst-view-buckets.js')).ObjstViewBuckets)() },
{ name: 'Browser', iconName: 'lucide:folderOpen', element: (async () => (await import('./objst-view-objects.js')).ObjstViewObjects)() },
{ name: 'Policies', iconName: 'lucide:shield', element: (async () => (await import('./objst-view-policies.js')).ObjstViewPolicies)() },
{ name: 'Config', iconName: 'lucide:settings', element: (async () => (await import('./objst-view-config.js')).ObjstViewConfig)() },
{ name: 'Access Keys', iconName: 'lucide:key', element: (async () => (await import('./objst-view-credentials.js')).ObjstViewCredentials)() },
];
private resolvedViewTabs: Array<{ name: string; iconName: string; element: any }> = [];
constructor() {
super();
document.title = 'ObjectStorage';
const loginSubscription = appstate.loginStatePart
.select((stateArg) => stateArg)
.subscribe((loginState) => {
this.loginState = loginState;
if (loginState.isLoggedIn) {
appstate.serverStatePart.dispatchAction(appstate.fetchServerStatusAction, null);
}
});
this.rxSubscriptions.push(loginSubscription);
const uiSubscription = appstate.uiStatePart
.select((stateArg) => stateArg)
.subscribe((uiState) => {
this.uiState = uiState;
this.syncAppdashView(uiState.activeView);
});
this.rxSubscriptions.push(uiSubscription);
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
}
.maincontainer {
width: 100%;
height: 100vh;
}
`,
];
public render(): TemplateResult {
return html`
<div class="maincontainer">
<dees-simple-login name="ObjectStorage">
<dees-simple-appdash
name="ObjectStorage"
.viewTabs=${this.resolvedViewTabs}
>
</dees-simple-appdash>
</dees-simple-login>
</div>
`;
}
public async firstUpdated() {
// Resolve async view tab imports
this.resolvedViewTabs = await Promise.all(
this.viewTabs.map(async (tab) => ({
name: tab.name,
iconName: tab.iconName,
element: await tab.element,
})),
);
this.requestUpdate();
await this.updateComplete;
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
if (simpleLogin) {
simpleLogin.addEventListener('login', (e: CustomEvent) => {
this.login(e.detail.data.username, e.detail.data.password);
});
}
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any;
if (appDash) {
appDash.addEventListener('view-select', (e: CustomEvent) => {
const viewName = e.detail.view.name.toLowerCase();
appRouter.navigateToView(viewName);
});
appDash.addEventListener('logout', async () => {
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
});
}
// Load the initial view on the appdash now that tabs are resolved
if (appDash && this.resolvedViewTabs.length > 0) {
const initialView = this.resolvedViewTabs.find(
(t) => t.name.toLowerCase() === this.uiState.activeView,
) || this.resolvedViewTabs[0];
await appDash.loadView(initialView);
}
// Check for stored session (persistent login state)
const loginState = appstate.loginStatePart.getState();
if (loginState.identity?.jwt) {
if (loginState.identity.expiresAt > Date.now()) {
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetServerStatus
>('/typedrequest', 'getServerStatus');
const response = await typedRequest.fire({ identity: loginState.identity });
appstate.serverStatePart.setState({
status: response.status,
connectionInfo: response.connectionInfo,
});
this.loginState = loginState;
if (simpleLogin) {
await simpleLogin.switchToSlottedContent();
}
} catch (err) {
console.warn('Stored session invalid, returning to login:', err);
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
}
} else {
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
}
}
}
private async login(username: string, password: string) {
const domtools = await this.domtoolsPromise;
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
const form = simpleLogin?.shadowRoot?.querySelector('dees-form') as any;
if (form) {
form.setStatus('pending', 'Logging in...');
}
const newState = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
username,
password,
});
if (newState.identity) {
if (form) {
form.setStatus('success', 'Logged in!');
}
if (simpleLogin) {
await simpleLogin.switchToSlottedContent();
}
await appstate.serverStatePart.dispatchAction(appstate.fetchServerStatusAction, null);
} else {
if (form) {
form.setStatus('error', 'Login failed!');
await domtools.convenience.smartdelay.delayFor(2000);
form.reset();
}
}
}
private syncAppdashView(viewName: string): void {
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
if (!appDash || this.resolvedViewTabs.length === 0) return;
const targetTab = this.resolvedViewTabs.find((t) => t.name.toLowerCase() === viewName);
if (!targetTab) return;
appDash.loadView(targetTab);
}
}

View File

@@ -0,0 +1,271 @@
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import { appRouter } from '../router.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
import { DeesModal } from '@design.estate/dees-catalog';
@customElement('objst-view-buckets')
export class ObjstViewBuckets extends DeesElement {
@state()
accessor bucketsState: appstate.IBucketsState = { buckets: [] };
constructor() {
super();
const sub = appstate.bucketsStatePart
.select((s) => s)
.subscribe((bucketsState) => {
this.bucketsState = bucketsState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
super.connectedCallback();
appstate.bucketsStatePart.dispatchAction(appstate.fetchBucketsAction, null);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
];
public render(): TemplateResult {
return html`
<objst-sectionheading>Buckets</objst-sectionheading>
<dees-table
heading1="Buckets"
heading2="Manage your storage buckets"
.data=${this.bucketsState.buckets}
.dataName=${'bucket'}
.searchable=${true}
.displayFunction=${(item: any) => ({
name: item.name,
objects: String(item.objectCount),
size: this.formatBytes(item.totalSizeBytes),
created: new Date(item.creationDate).toLocaleDateString(),
})}
.dataActions=${[
{
name: 'Create Bucket',
iconName: 'lucide:plus',
type: ['header'] as any[],
actionFunc: async () => {
await this.showCreateBucketModal();
},
},
{
name: 'Browse',
iconName: 'lucide:folderOpen',
type: ['inRow'] as any[],
actionFunc: async (args: any) => {
await appstate.objectsStatePart.dispatchAction(appstate.fetchObjectsAction, {
bucketName: args.item.name,
prefix: '',
delimiter: '/',
});
appRouter.navigateToView('browser');
},
},
{
name: 'Policy',
iconName: 'lucide:shield',
type: ['inRow'] as any[],
actionFunc: async (args: any) => {
await this.showPolicyModal(args.item.name);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any[],
actionFunc: async (args: any) => {
await this.showDeleteBucketModal(args.item.name);
},
},
]}
></dees-table>
`;
}
private async showCreateBucketModal(): Promise<void> {
await DeesModal.createAndShow({
heading: 'Create Bucket',
content: html`
<dees-form>
<dees-input-text .key=${'bucketName'} .label=${'Bucket Name'} .required=${true}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Cancel',
action: async (modal: any) => { modal.destroy(); },
},
{
name: 'Create',
action: async (modal: any) => {
const form = modal.shadowRoot.querySelector('dees-form') as any;
const data = await form.collectFormData();
if (data.bucketName) {
await appstate.bucketsStatePart.dispatchAction(appstate.createBucketAction, {
bucketName: data.bucketName,
});
}
modal.destroy();
},
},
],
});
}
private async showDeleteBucketModal(bucketName: string): Promise<void> {
await DeesModal.createAndShow({
heading: 'Delete Bucket',
content: html`<p>Are you sure you want to delete bucket <strong>${bucketName}</strong>? This action cannot be undone.</p>`,
menuOptions: [
{
name: 'Cancel',
action: async (modal: any) => { modal.destroy(); },
},
{
name: 'Delete',
action: async (modal: any) => {
await appstate.bucketsStatePart.dispatchAction(appstate.deleteBucketAction, {
bucketName,
});
modal.destroy();
},
},
],
});
}
private async showPolicyModal(bucketName: string): Promise<void> {
const data = await appstate.getBucketNamedPolicies(bucketName);
let modalRef: any = null;
const renderContent = (
attached: typeof data.attachedPolicies,
available: typeof data.availablePolicies,
) => html`
<style>
.policy-lists { display: flex; flex-direction: column; gap: 16px; }
.policy-section h4 {
margin: 0 0 8px 0;
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.policy-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 6px;
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a2e')};
margin-bottom: 4px;
color: ${cssManager.bdTheme('#333', '#eee')};
}
.policy-item-info { display: flex; flex-direction: column; gap: 2px; }
.policy-item-name { font-size: 14px; font-weight: 500; }
.policy-item-desc { font-size: 12px; color: ${cssManager.bdTheme('#666', '#999')}; }
.policy-item button {
padding: 4px 12px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.btn-detach {
background: ${cssManager.bdTheme('#ffebee', '#3e1a1a')};
color: ${cssManager.bdTheme('#c62828', '#ef5350')};
}
.btn-detach:hover { opacity: 0.8; }
.btn-attach {
background: ${cssManager.bdTheme('#e3f2fd', '#1a2a3e')};
color: ${cssManager.bdTheme('#1565c0', '#64b5f6')};
}
.btn-attach:hover { opacity: 0.8; }
.empty-note {
color: ${cssManager.bdTheme('#999', '#666')};
font-size: 13px;
font-style: italic;
padding: 8px 0;
}
</style>
<div class="policy-lists">
<div class="policy-section">
<h4>Attached Policies (${attached.length})</h4>
${attached.length > 0
? attached.map((policy) => html`
<div class="policy-item">
<div class="policy-item-info">
<span class="policy-item-name">${policy.name}</span>
${policy.description ? html`<span class="policy-item-desc">${policy.description}</span>` : ''}
</div>
<button class="btn-detach" @click=${async (e: Event) => {
(e.target as HTMLButtonElement).disabled = true;
await appstate.detachPolicyFromBucket(policy.id, bucketName);
const fresh = await appstate.getBucketNamedPolicies(bucketName);
if (modalRef) {
modalRef.content = renderContent(fresh.attachedPolicies, fresh.availablePolicies);
}
}}>Detach</button>
</div>
`)
: html`<p class="empty-note">No policies attached to this bucket.</p>`}
</div>
<div class="policy-section">
<h4>Available Policies (${available.length})</h4>
${available.length > 0
? available.map((policy) => html`
<div class="policy-item">
<div class="policy-item-info">
<span class="policy-item-name">${policy.name}</span>
${policy.description ? html`<span class="policy-item-desc">${policy.description}</span>` : ''}
</div>
<button class="btn-attach" @click=${async (e: Event) => {
(e.target as HTMLButtonElement).disabled = true;
await appstate.attachPolicyToBucket(policy.id, bucketName);
const fresh = await appstate.getBucketNamedPolicies(bucketName);
if (modalRef) {
modalRef.content = renderContent(fresh.attachedPolicies, fresh.availablePolicies);
}
}}>Attach</button>
</div>
`)
: html`<p class="empty-note">${attached.length > 0
? 'All policies are already attached.'
: 'No policies defined yet. Create policies in the Policies view.'
}</p>`}
</div>
</div>
`;
modalRef = await DeesModal.createAndShow({
heading: `Policies for: ${bucketName}`,
content: renderContent(data.attachedPolicies, data.availablePolicies),
menuOptions: [
{ name: 'Done', action: async (modal: any) => { modal.destroy(); } },
],
});
}
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
}

View File

@@ -0,0 +1,111 @@
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
import { type IStatsTile } from '@design.estate/dees-catalog';
@customElement('objst-view-config')
export class ObjstViewConfig extends DeesElement {
@state()
accessor configState: appstate.IConfigState = { config: null };
constructor() {
super();
const sub = appstate.configStatePart
.select((s) => s)
.subscribe((configState) => {
this.configState = configState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
super.connectedCallback();
appstate.configStatePart.dispatchAction(appstate.fetchConfigAction, null);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
];
public render(): TemplateResult {
const config = this.configState.config;
const tiles: IStatsTile[] = [
{
id: 'objstPort',
title: 'Storage API Port',
value: config?.objstPort ?? '--',
type: 'number',
icon: 'lucide:network',
color: '#2196f3',
},
{
id: 'uiPort',
title: 'UI Port',
value: config?.uiPort ?? '--',
type: 'number',
icon: 'lucide:monitor',
color: '#00bcd4',
},
{
id: 'region',
title: 'Region',
value: config?.region ?? '--',
type: 'text',
icon: 'lucide:globe',
color: '#607d8b',
},
{
id: 'storageDir',
title: 'Storage Directory',
value: config?.storageDirectory ?? '--',
type: 'text',
icon: 'lucide:hardDrive',
color: '#9c27b0',
},
{
id: 'auth',
title: 'Authentication',
value: config?.authEnabled ? 'Enabled' : 'Disabled',
type: 'text',
icon: 'lucide:shield',
color: config?.authEnabled ? '#4caf50' : '#f44336',
},
{
id: 'cors',
title: 'CORS',
value: config?.corsEnabled ? 'Enabled' : 'Disabled',
type: 'text',
icon: 'lucide:globe2',
color: config?.corsEnabled ? '#4caf50' : '#ff9800',
},
];
return html`
<objst-sectionheading>Configuration</objst-sectionheading>
<dees-statsgrid
.tiles=${tiles}
.gridActions=${[
{
name: 'Refresh',
iconName: 'lucide:refreshCw',
action: async () => {
await appstate.configStatePart.dispatchAction(appstate.fetchConfigAction, null);
},
},
]}
></dees-statsgrid>
`;
}
}

View File

@@ -0,0 +1,128 @@
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
import { DeesModal } from '@design.estate/dees-catalog';
@customElement('objst-view-credentials')
export class ObjstViewCredentials extends DeesElement {
@state()
accessor credentialsState: appstate.ICredentialsState = { credentials: [] };
constructor() {
super();
const sub = appstate.credentialsStatePart
.select((s) => s)
.subscribe((credentialsState) => {
this.credentialsState = credentialsState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
super.connectedCallback();
appstate.credentialsStatePart.dispatchAction(appstate.fetchCredentialsAction, null);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
];
public render(): TemplateResult {
return html`
<objst-sectionheading>Access Keys</objst-sectionheading>
<dees-table
heading1="Access Credentials"
heading2="Manage access keys for API authentication"
.data=${this.credentialsState.credentials}
.dataName=${'credential'}
.displayFunction=${(item: any) => ({
'Access Key ID': item.accessKeyId,
'Secret Access Key': item.secretAccessKey,
})}
.dataActions=${[
{
name: 'Add Key',
iconName: 'lucide:plus',
type: ['header'] as any[],
actionFunc: async () => {
await this.showAddKeyModal();
},
},
{
name: 'Remove',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any[],
actionFunc: async (args: any) => {
await this.showRemoveKeyModal(args.item.accessKeyId);
},
},
]}
></dees-table>
`;
}
private async showAddKeyModal(): Promise<void> {
await DeesModal.createAndShow({
heading: 'Add Access Key',
content: html`
<dees-form>
<dees-input-text .key=${'accessKeyId'} .label=${'Access Key ID'} .required=${true}></dees-input-text>
<dees-input-text .key=${'secretAccessKey'} .label=${'Secret Access Key'} .required=${true}></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Cancel',
action: async (modal: any) => { modal.destroy(); },
},
{
name: 'Add',
action: async (modal: any) => {
const form = modal.shadowRoot.querySelector('dees-form') as any;
const data = await form.collectFormData();
if (data.accessKeyId && data.secretAccessKey) {
await appstate.credentialsStatePart.dispatchAction(appstate.addCredentialAction, {
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
});
}
modal.destroy();
},
},
],
});
}
private async showRemoveKeyModal(accessKeyId: string): Promise<void> {
await DeesModal.createAndShow({
heading: 'Remove Access Key',
content: html`<p>Are you sure you want to remove access key <strong>${accessKeyId}</strong>?</p>`,
menuOptions: [
{
name: 'Cancel',
action: async (modal: any) => { modal.destroy(); },
},
{
name: 'Remove',
action: async (modal: any) => {
await appstate.credentialsStatePart.dispatchAction(appstate.removeCredentialAction, {
accessKeyId,
});
modal.destroy();
},
},
],
});
}
}

View File

@@ -0,0 +1,149 @@
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import { createDataProvider } from '../dataprovider.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('objst-view-objects')
export class ObjstViewObjects extends DeesElement {
@state()
accessor bucketsState: appstate.IBucketsState = { buckets: [] };
@state()
accessor selectedBucket: string = '';
private dataProvider = createDataProvider();
constructor() {
super();
const bucketsSub = appstate.bucketsStatePart
.select((s) => s)
.subscribe((bucketsState) => {
this.bucketsState = bucketsState;
});
this.rxSubscriptions.push(bucketsSub);
// Track current bucket from objects state
const objSub = appstate.objectsStatePart
.select((s) => s.currentBucket)
.subscribe((currentBucket) => {
if (currentBucket) {
this.selectedBucket = currentBucket;
}
});
this.rxSubscriptions.push(objSub);
}
async connectedCallback() {
super.connectedCallback();
if (this.bucketsState.buckets.length === 0) {
appstate.bucketsStatePart.dispatchAction(appstate.fetchBucketsAction, null);
}
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
box-sizing: border-box;
}
.bucket-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
flex-wrap: wrap;
}
.bucket-bar label {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.bucket-chip {
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
cursor: pointer;
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a2e')};
color: ${cssManager.bdTheme('#333', '#ccc')};
transition: all 0.15s ease;
}
.bucket-chip:hover {
border-color: ${cssManager.bdTheme('#2196f3', '#64b5f6')};
color: ${cssManager.bdTheme('#2196f3', '#64b5f6')};
}
.bucket-chip.active {
background: ${cssManager.bdTheme('#2196f3', '#1565c0')};
color: white;
border-color: ${cssManager.bdTheme('#2196f3', '#1565c0')};
}
.browser-container {
flex: 1;
min-height: 0;
overflow: hidden;
}
.noBucket {
text-align: center;
padding: 64px 0;
color: ${cssManager.bdTheme('#666', '#999')};
font-size: 16px;
}
`,
];
public render(): TemplateResult {
return html`
<div class="bucket-bar">
<label>Bucket:</label>
${this.bucketsState.buckets.map(
(bucket) => html`
<span
class="bucket-chip ${this.selectedBucket === bucket.name ? 'active' : ''}"
@click=${() => this.selectBucket(bucket.name)}
>${bucket.name}</span>
`,
)}
${this.bucketsState.buckets.length === 0
? html`<span style="color: #999; font-size: 13px;">No buckets found</span>`
: ''}
</div>
${this.selectedBucket
? html`
<div class="browser-container">
<dees-s3-browser
.dataProvider=${this.dataProvider}
.bucketName=${this.selectedBucket}
></dees-s3-browser>
</div>
`
: html`
<div class="noBucket">
<p>Select a bucket above to browse objects.</p>
</div>
`}
`;
}
private selectBucket(bucketName: string): void {
this.selectedBucket = bucketName;
// Update objects state for tracking
appstate.objectsStatePart.dispatchAction(appstate.fetchObjectsAction, {
bucketName,
prefix: '',
delimiter: '/',
});
}
}

View File

@@ -0,0 +1,201 @@
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
import { type IStatsTile } from '@design.estate/dees-catalog';
@customElement('objst-view-overview')
export class ObjstViewOverview extends DeesElement {
@state()
accessor serverState: appstate.IServerState = { status: null, connectionInfo: null };
@state()
accessor bucketsState: appstate.IBucketsState = { buckets: [] };
constructor() {
super();
const serverSub = appstate.serverStatePart
.select((s) => s)
.subscribe((serverState) => {
this.serverState = serverState;
});
this.rxSubscriptions.push(serverSub);
const bucketsSub = appstate.bucketsStatePart
.select((s) => s)
.subscribe((bucketsState) => {
this.bucketsState = bucketsState;
});
this.rxSubscriptions.push(bucketsSub);
}
async connectedCallback() {
super.connectedCallback();
appstate.serverStatePart.dispatchAction(appstate.fetchServerStatusAction, null);
appstate.bucketsStatePart.dispatchAction(appstate.fetchBucketsAction, null);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css`
.connectionInfo {
margin-top: 32px;
padding: 24px;
border-radius: 8px;
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a2e')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a4a')};
}
.connectionInfo h2 {
margin: 0 0 16px 0;
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.connectionInfo .row {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
}
.connectionInfo .label {
width: 120px;
font-weight: 500;
color: ${cssManager.bdTheme('#666', '#999')};
}
.connectionInfo .value {
font-family: monospace;
color: ${cssManager.bdTheme('#333', '#e0e0e0')};
padding: 4px 8px;
border-radius: 4px;
background: ${cssManager.bdTheme('#e8e8e8', '#252540')};
}
`,
];
public render(): TemplateResult {
const status = this.serverState.status;
const connInfo = this.serverState.connectionInfo;
const statsTiles: IStatsTile[] = [
{
id: 'status',
title: 'Server Status',
value: status?.running ? 'Online' : 'Offline',
type: 'text',
icon: 'lucide:server',
color: status?.running ? '#4caf50' : '#f44336',
description: status ? `Uptime: ${this.formatUptime(status.uptime)}` : 'Loading...',
},
{
id: 'buckets',
title: 'Buckets',
value: status?.bucketCount ?? 0,
type: 'number',
icon: 'lucide:database',
color: '#2196f3',
},
{
id: 'objects',
title: 'Total Objects',
value: status?.totalObjectCount ?? 0,
type: 'number',
icon: 'lucide:file',
color: '#ff9800',
},
{
id: 'storage',
title: 'Storage Used',
value: status ? this.formatBytes(status.totalStorageBytes) : '0 B',
type: 'text',
icon: 'lucide:hardDrive',
color: '#9c27b0',
},
{
id: 'storagePort',
title: 'Storage Port',
value: status?.objstPort ?? 9000,
type: 'number',
icon: 'lucide:network',
color: '#00bcd4',
},
{
id: 'region',
title: 'Region',
value: status?.region ?? 'us-east-1',
type: 'text',
icon: 'lucide:globe',
color: '#607d8b',
},
];
return html`
<objst-sectionheading>Overview</objst-sectionheading>
<dees-statsgrid
.tiles=${statsTiles}
.gridActions=${[
{
name: 'Refresh',
iconName: 'lucide:refreshCw',
action: async () => {
await appstate.serverStatePart.dispatchAction(
appstate.fetchServerStatusAction,
null,
);
await appstate.bucketsStatePart.dispatchAction(appstate.fetchBucketsAction, null);
},
},
]}
></dees-statsgrid>
${connInfo
? html`
<div class="connectionInfo">
<h2>Connection Info</h2>
<div class="row">
<span class="label">Endpoint</span>
<span class="value">${connInfo.endpoint}:${connInfo.port}</span>
</div>
<div class="row">
<span class="label">Protocol</span>
<span class="value">${connInfo.useSsl ? 'HTTPS' : 'HTTP'}</span>
</div>
<div class="row">
<span class="label">Access Key</span>
<span class="value">${connInfo.accessKey}</span>
</div>
<div class="row">
<span class="label">Region</span>
<span class="value">${connInfo.region}</span>
</div>
</div>
`
: ''}
`;
}
private formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
}

View File

@@ -0,0 +1,394 @@
import * as plugins from '../plugins.js';
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import * as interfaces from '../../ts_interfaces/index.js';
import {
DeesElement,
customElement,
html,
state,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
import { DeesModal } from '@design.estate/dees-catalog';
@customElement('objst-view-policies')
export class ObjstViewPolicies extends DeesElement {
@state()
accessor policiesState: appstate.IPoliciesState = { policies: [] };
constructor() {
super();
const sub = appstate.policiesStatePart
.select((s) => s)
.subscribe((policiesState) => {
this.policiesState = policiesState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
super.connectedCallback();
appstate.policiesStatePart.dispatchAction(appstate.fetchPoliciesAction, null);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
];
public render(): TemplateResult {
return html`
<objst-sectionheading>Policies</objst-sectionheading>
<dees-table
heading1="Named Policies"
heading2="Create reusable policies and attach them to buckets"
.data=${this.policiesState.policies}
.dataName=${'policy'}
.displayFunction=${(item: any) => ({
Name: item.name,
Description: item.description,
Statements: String(item.statements.length),
Updated: new Date(item.updatedAt).toLocaleDateString(),
})}
.dataActions=${[
{
name: 'Create Policy',
iconName: 'lucide:plus',
type: ['header'] as any[],
actionFunc: async () => {
await this.showCreatePolicyModal();
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow'] as any[],
actionFunc: async (args: any) => {
await this.showEditPolicyModal(args.item);
},
},
{
name: 'Buckets',
iconName: 'lucide:database',
type: ['inRow'] as any[],
actionFunc: async (args: any) => {
await this.showPolicyBucketsModal(args.item);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any[],
actionFunc: async (args: any) => {
await this.showDeletePolicyModal(args.item);
},
},
]}
></dees-table>
`;
}
private getDefaultStatements(): interfaces.data.IObjstStatement[] {
return [
{
Sid: 'PublicRead',
Effect: 'Allow',
Principal: '*',
Action: 's3:GetObject',
Resource: 'arn:aws:s3:::${bucket}/*',
},
];
}
private async showCreatePolicyModal(): Promise<void> {
const defaultJson = JSON.stringify(this.getDefaultStatements(), null, 2);
await DeesModal.createAndShow({
heading: 'Create Policy',
content: html`
<style>
.policy-form { display: flex; flex-direction: column; gap: 12px; }
.policy-form label { font-size: 13px; font-weight: 600; color: ${cssManager.bdTheme('#333', '#ccc')}; }
.policy-form input, .policy-form textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
border-radius: 6px;
background: ${cssManager.bdTheme('#fff', '#111')};
color: ${cssManager.bdTheme('#333', '#eee')};
font-size: 14px;
box-sizing: border-box;
}
.policy-form textarea {
font-family: monospace;
font-size: 12px;
min-height: 200px;
resize: vertical;
}
.policy-form input:focus, .policy-form textarea:focus {
outline: none;
border-color: ${cssManager.bdTheme('#2196f3', '#64b5f6')};
}
.policy-error { color: #ef5350; font-size: 13px; min-height: 20px; }
.policy-hint { color: ${cssManager.bdTheme('#666', '#999')}; font-size: 12px; }
</style>
<div class="policy-form">
<div>
<label>Name</label>
<input type="text" class="policy-name" placeholder="e.g. Public Read Access" />
</div>
<div>
<label>Description</label>
<input type="text" class="policy-description" placeholder="e.g. Allow anonymous read access to objects" />
</div>
<div>
<label>Statements (JSON array)</label>
<p class="policy-hint">Use \${bucket} in Resource ARNs — it will be replaced with the actual bucket name when applied.</p>
<textarea class="policy-statements">${defaultJson}</textarea>
</div>
<div class="policy-error"></div>
</div>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: 'Create',
action: async (modal: any) => {
const root = modal.shadowRoot;
const name = (root.querySelector('.policy-name') as HTMLInputElement)?.value?.trim();
const description = (root.querySelector('.policy-description') as HTMLInputElement)?.value?.trim();
const statementsText = (root.querySelector('.policy-statements') as HTMLTextAreaElement)?.value?.trim();
const errorDiv = root.querySelector('.policy-error') as HTMLElement;
if (!name) { errorDiv.textContent = 'Name is required.'; return; }
if (!statementsText) { errorDiv.textContent = 'Statements are required.'; return; }
let statements: interfaces.data.IObjstStatement[];
try {
statements = JSON.parse(statementsText);
if (!Array.isArray(statements)) throw new Error('Must be an array');
} catch (err: any) {
errorDiv.textContent = `Invalid JSON: ${err.message}`;
return;
}
await appstate.policiesStatePart.dispatchAction(appstate.createPolicyAction, {
name,
description: description || '',
statements,
});
modal.destroy();
},
},
],
});
}
private async showEditPolicyModal(policy: interfaces.data.INamedPolicy): Promise<void> {
const statementsJson = JSON.stringify(policy.statements, null, 2);
await DeesModal.createAndShow({
heading: `Edit Policy: ${policy.name}`,
content: html`
<style>
.policy-form { display: flex; flex-direction: column; gap: 12px; }
.policy-form label { font-size: 13px; font-weight: 600; color: ${cssManager.bdTheme('#333', '#ccc')}; }
.policy-form input, .policy-form textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
border-radius: 6px;
background: ${cssManager.bdTheme('#fff', '#111')};
color: ${cssManager.bdTheme('#333', '#eee')};
font-size: 14px;
box-sizing: border-box;
}
.policy-form textarea {
font-family: monospace;
font-size: 12px;
min-height: 200px;
resize: vertical;
}
.policy-form input:focus, .policy-form textarea:focus {
outline: none;
border-color: ${cssManager.bdTheme('#2196f3', '#64b5f6')};
}
.policy-error { color: #ef5350; font-size: 13px; min-height: 20px; }
.policy-hint { color: ${cssManager.bdTheme('#666', '#999')}; font-size: 12px; }
</style>
<div class="policy-form">
<div>
<label>Name</label>
<input type="text" class="policy-name" .value=${policy.name} />
</div>
<div>
<label>Description</label>
<input type="text" class="policy-description" .value=${policy.description} />
</div>
<div>
<label>Statements (JSON array)</label>
<p class="policy-hint">Use \${bucket} in Resource ARNs — it will be replaced with the actual bucket name when applied.</p>
<textarea class="policy-statements">${statementsJson}</textarea>
</div>
<div class="policy-error"></div>
</div>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: 'Save',
action: async (modal: any) => {
const root = modal.shadowRoot;
const name = (root.querySelector('.policy-name') as HTMLInputElement)?.value?.trim();
const description = (root.querySelector('.policy-description') as HTMLInputElement)?.value?.trim();
const statementsText = (root.querySelector('.policy-statements') as HTMLTextAreaElement)?.value?.trim();
const errorDiv = root.querySelector('.policy-error') as HTMLElement;
if (!name) { errorDiv.textContent = 'Name is required.'; return; }
if (!statementsText) { errorDiv.textContent = 'Statements are required.'; return; }
let statements: interfaces.data.IObjstStatement[];
try {
statements = JSON.parse(statementsText);
if (!Array.isArray(statements)) throw new Error('Must be an array');
} catch (err: any) {
errorDiv.textContent = `Invalid JSON: ${err.message}`;
return;
}
await appstate.policiesStatePart.dispatchAction(appstate.updatePolicyAction, {
policyId: policy.id,
name,
description: description || '',
statements,
});
modal.destroy();
},
},
],
});
}
private async showDeletePolicyModal(policy: interfaces.data.INamedPolicy): Promise<void> {
await DeesModal.createAndShow({
heading: 'Delete Policy',
content: html`
<p>Are you sure you want to delete policy <strong>${policy.name}</strong>?</p>
<p style="color: ${cssManager.bdTheme('#666', '#999')}; font-size: 13px;">
This will also detach the policy from all buckets it is currently applied to.
</p>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
{
name: 'Delete',
action: async (modal: any) => {
await appstate.policiesStatePart.dispatchAction(appstate.deletePolicyAction, {
policyId: policy.id,
});
modal.destroy();
},
},
],
});
}
private async showPolicyBucketsModal(policy: interfaces.data.INamedPolicy): Promise<void> {
const data = await appstate.getPolicyBuckets(policy.id);
let modalRef: any = null;
const renderContent = (attached: string[], available: string[]) => html`
<style>
.bucket-lists { display: flex; flex-direction: column; gap: 16px; }
.bucket-section h4 {
margin: 0 0 8px 0;
font-size: 13px;
font-weight: 600;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.bucket-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 6px;
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a2e')};
margin-bottom: 4px;
font-size: 14px;
color: ${cssManager.bdTheme('#333', '#eee')};
}
.bucket-item button {
padding: 4px 12px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.btn-detach {
background: ${cssManager.bdTheme('#ffebee', '#3e1a1a')};
color: ${cssManager.bdTheme('#c62828', '#ef5350')};
}
.btn-detach:hover { opacity: 0.8; }
.btn-attach {
background: ${cssManager.bdTheme('#e3f2fd', '#1a2a3e')};
color: ${cssManager.bdTheme('#1565c0', '#64b5f6')};
}
.btn-attach:hover { opacity: 0.8; }
.empty-note {
color: ${cssManager.bdTheme('#999', '#666')};
font-size: 13px;
font-style: italic;
padding: 8px 0;
}
</style>
<div class="bucket-lists">
<div class="bucket-section">
<h4>Attached Buckets (${attached.length})</h4>
${attached.length > 0
? attached.map((bucket) => html`
<div class="bucket-item">
<span>${bucket}</span>
<button class="btn-detach" @click=${async (e: Event) => {
(e.target as HTMLButtonElement).disabled = true;
await appstate.detachPolicyFromBucket(policy.id, bucket);
const fresh = await appstate.getPolicyBuckets(policy.id);
if (modalRef) {
modalRef.content = renderContent(fresh.attachedBuckets, fresh.availableBuckets);
}
}}>Detach</button>
</div>
`)
: html`<p class="empty-note">No buckets attached to this policy.</p>`}
</div>
<div class="bucket-section">
<h4>Available Buckets (${available.length})</h4>
${available.length > 0
? available.map((bucket) => html`
<div class="bucket-item">
<span>${bucket}</span>
<button class="btn-attach" @click=${async (e: Event) => {
(e.target as HTMLButtonElement).disabled = true;
await appstate.attachPolicyToBucket(policy.id, bucket);
const fresh = await appstate.getPolicyBuckets(policy.id);
if (modalRef) {
modalRef.content = renderContent(fresh.attachedBuckets, fresh.availableBuckets);
}
}}>Attach</button>
</div>
`)
: html`<p class="empty-note">All buckets already have this policy.</p>`}
</div>
</div>
`;
modalRef = await DeesModal.createAndShow({
heading: `Buckets for: ${policy.name}`,
content: renderContent(data.attachedBuckets, data.availableBuckets),
menuOptions: [
{ name: 'Done', action: async (modal: any) => { modal.destroy(); } },
],
});
}
}

View File

@@ -0,0 +1,10 @@
import { css } from '@design.estate/dees-element';
export const viewHostCss = css`
:host {
display: block;
margin: auto;
max-width: 1280px;
padding: 16px 16px;
}
`;

View File

@@ -0,0 +1,2 @@
export * from './css.js';
export * from './objst-sectionheading.js';

View File

@@ -0,0 +1,31 @@
import {
DeesElement,
customElement,
html,
css,
cssManager,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('objst-sectionheading')
export class ObjstSectionHeading extends DeesElement {
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
margin-bottom: 24px;
}
h1 {
font-size: 24px;
font-weight: 600;
margin: 0;
color: ${cssManager.bdTheme('#1a1a2e', '#e0e0e0')};
}
`,
];
public render(): TemplateResult {
return html`<h1><slot></slot></h1>`;
}
}