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

View File

@@ -0,0 +1,8 @@
/**
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@lossless.zone/objectstorage',
version: '1.4.0',
description: 'object storage server with management UI powered by smartstorage'
}

589
ts_web/appstate.ts Normal file
View File

@@ -0,0 +1,589 @@
import * as plugins from './plugins.js';
import * as interfaces from '../ts_interfaces/index.js';
// ============================================================================
// Smartstate instance
// ============================================================================
export const appState = new plugins.domtools.plugins.smartstate.Smartstate();
// ============================================================================
// State Part Interfaces
// ============================================================================
export interface ILoginState {
identity: interfaces.data.IIdentity | null;
isLoggedIn: boolean;
}
export interface IServerState {
status: interfaces.data.IServerStatus | null;
connectionInfo: interfaces.data.IConnectionInfo | null;
}
export interface IBucketsState {
buckets: interfaces.data.IBucketInfo[];
}
export interface IObjectsState {
result: interfaces.data.IObjectListResult | null;
currentBucket: string;
currentPrefix: string;
}
export interface ICredentialsState {
credentials: interfaces.data.IObjstCredential[];
}
export interface IPoliciesState {
policies: interfaces.data.INamedPolicy[];
}
export interface IConfigState {
config: interfaces.data.IServerConfig | null;
}
export interface IUiState {
activeView: string;
autoRefresh: boolean;
refreshInterval: number;
}
// ============================================================================
// State Parts
// ============================================================================
export const loginStatePart = await appState.getStatePart<ILoginState>(
'login',
{
identity: null,
isLoggedIn: false,
},
'persistent',
);
export const serverStatePart = await appState.getStatePart<IServerState>(
'server',
{
status: null,
connectionInfo: null,
},
'soft',
);
export const bucketsStatePart = await appState.getStatePart<IBucketsState>(
'buckets',
{
buckets: [],
},
'soft',
);
export const objectsStatePart = await appState.getStatePart<IObjectsState>(
'objects',
{
result: null,
currentBucket: '',
currentPrefix: '',
},
'soft',
);
export const credentialsStatePart = await appState.getStatePart<ICredentialsState>(
'credentials',
{
credentials: [],
},
'soft',
);
export const policiesStatePart = await appState.getStatePart<IPoliciesState>(
'policies',
{
policies: [],
},
'soft',
);
export const configStatePart = await appState.getStatePart<IConfigState>(
'config',
{
config: null,
},
'soft',
);
export const uiStatePart = await appState.getStatePart<IUiState>(
'ui',
{
activeView: 'overview',
autoRefresh: true,
refreshInterval: 30000,
},
);
// ============================================================================
// Helpers
// ============================================================================
interface IActionContext {
identity: interfaces.data.IIdentity | null;
}
const getActionContext = (): IActionContext => {
return { identity: loginStatePart.getState().identity };
};
// ============================================================================
// Login Actions
// ============================================================================
export const loginAction = loginStatePart.createAction<{
username: string;
password: string;
}>(async (statePartArg, dataArg) => {
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_AdminLoginWithUsernameAndPassword
>('/typedrequest', 'adminLoginWithUsernameAndPassword');
const response = await typedRequest.fire({
username: dataArg.username,
password: dataArg.password,
});
return {
identity: response.identity || null,
isLoggedIn: !!response.identity,
};
} catch (err) {
console.error('Login failed:', err);
return { identity: null, isLoggedIn: false };
}
});
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
if (context.identity) {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_AdminLogout
>('/typedrequest', 'adminLogout');
await typedRequest.fire({ identity: context.identity });
}
} catch (err) {
console.error('Logout error:', err);
}
return { identity: null, isLoggedIn: false };
});
// ============================================================================
// Server Status Actions
// ============================================================================
export const fetchServerStatusAction = serverStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetServerStatus
>('/typedrequest', 'getServerStatus');
const response = await typedRequest.fire({ identity: context.identity! });
return { status: response.status, connectionInfo: response.connectionInfo };
} catch (err) {
console.error('Failed to fetch server status:', err);
return statePartArg.getState();
}
});
// ============================================================================
// Buckets Actions
// ============================================================================
export const fetchBucketsAction = bucketsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListBuckets
>('/typedrequest', 'listBuckets');
const response = await typedRequest.fire({ identity: context.identity! });
return { buckets: response.buckets };
} catch (err) {
console.error('Failed to fetch buckets:', err);
return statePartArg.getState();
}
});
export const createBucketAction = bucketsStatePart.createAction<{ bucketName: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateBucket
>('/typedrequest', 'createBucket');
await typedRequest.fire({ identity: context.identity!, bucketName: dataArg.bucketName });
// Re-fetch buckets list
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListBuckets
>('/typedrequest', 'listBuckets');
const listResp = await listReq.fire({ identity: context.identity! });
return { buckets: listResp.buckets };
} catch (err) {
console.error('Failed to create bucket:', err);
return statePartArg.getState();
}
},
);
export const deleteBucketAction = bucketsStatePart.createAction<{ bucketName: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteBucket
>('/typedrequest', 'deleteBucket');
await typedRequest.fire({ identity: context.identity!, bucketName: dataArg.bucketName });
const state = statePartArg.getState();
return {
buckets: state.buckets.filter((b) => b.name !== dataArg.bucketName),
};
} catch (err) {
console.error('Failed to delete bucket:', err);
return statePartArg.getState();
}
},
);
// ============================================================================
// Named Policies Actions
// ============================================================================
export const fetchPoliciesAction = policiesStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListNamedPolicies
>('/typedrequest', 'listNamedPolicies');
const response = await typedRequest.fire({ identity: context.identity! });
return { policies: response.policies };
} catch (err) {
console.error('Failed to fetch policies:', err);
return statePartArg.getState();
}
});
export const createPolicyAction = policiesStatePart.createAction<{
name: string;
description: string;
statements: interfaces.data.IObjstStatement[];
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateNamedPolicy
>('/typedrequest', 'createNamedPolicy');
await typedRequest.fire({
identity: context.identity!,
name: dataArg.name,
description: dataArg.description,
statements: dataArg.statements,
});
// Re-fetch
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListNamedPolicies
>('/typedrequest', 'listNamedPolicies');
const listResp = await listReq.fire({ identity: context.identity! });
return { policies: listResp.policies };
} catch (err) {
console.error('Failed to create policy:', err);
return statePartArg.getState();
}
});
export const updatePolicyAction = policiesStatePart.createAction<{
policyId: string;
name: string;
description: string;
statements: interfaces.data.IObjstStatement[];
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateNamedPolicy
>('/typedrequest', 'updateNamedPolicy');
await typedRequest.fire({
identity: context.identity!,
policyId: dataArg.policyId,
name: dataArg.name,
description: dataArg.description,
statements: dataArg.statements,
});
// Re-fetch
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListNamedPolicies
>('/typedrequest', 'listNamedPolicies');
const listResp = await listReq.fire({ identity: context.identity! });
return { policies: listResp.policies };
} catch (err) {
console.error('Failed to update policy:', err);
return statePartArg.getState();
}
});
export const deletePolicyAction = policiesStatePart.createAction<{ policyId: string }>(
async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteNamedPolicy
>('/typedrequest', 'deleteNamedPolicy');
await typedRequest.fire({ identity: context.identity!, policyId: dataArg.policyId });
const state = statePartArg.getState();
return { policies: state.policies.filter((p) => p.id !== dataArg.policyId) };
} catch (err) {
console.error('Failed to delete policy:', err);
return statePartArg.getState();
}
},
);
// Standalone async functions for policy-bucket management
export const getBucketNamedPolicies = async (bucketName: string) => {
const context = getActionContext();
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetBucketNamedPolicies
>('/typedrequest', 'getBucketNamedPolicies');
return await typedRequest.fire({ identity: context.identity!, bucketName });
};
export const attachPolicyToBucket = async (policyId: string, bucketName: string) => {
const context = getActionContext();
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_AttachPolicyToBucket
>('/typedrequest', 'attachPolicyToBucket');
return await typedRequest.fire({ identity: context.identity!, policyId, bucketName });
};
export const detachPolicyFromBucket = async (policyId: string, bucketName: string) => {
const context = getActionContext();
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DetachPolicyFromBucket
>('/typedrequest', 'detachPolicyFromBucket');
return await typedRequest.fire({ identity: context.identity!, policyId, bucketName });
};
export const getPolicyBuckets = async (policyId: string) => {
const context = getActionContext();
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetPolicyBuckets
>('/typedrequest', 'getPolicyBuckets');
return await typedRequest.fire({ identity: context.identity!, policyId });
};
export const setPolicyBuckets = async (policyId: string, bucketNames: string[]) => {
const context = getActionContext();
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_SetPolicyBuckets
>('/typedrequest', 'setPolicyBuckets');
return await typedRequest.fire({ identity: context.identity!, policyId, bucketNames });
};
// ============================================================================
// Objects Actions
// ============================================================================
export const fetchObjectsAction = objectsStatePart.createAction<{
bucketName: string;
prefix?: string;
delimiter?: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListObjects
>('/typedrequest', 'listObjects');
const response = await typedRequest.fire({
identity: context.identity!,
bucketName: dataArg.bucketName,
prefix: dataArg.prefix || '',
delimiter: dataArg.delimiter || '/',
});
return {
result: response.result,
currentBucket: dataArg.bucketName,
currentPrefix: dataArg.prefix || '',
};
} catch (err) {
console.error('Failed to fetch objects:', err);
return statePartArg.getState();
}
});
export const deleteObjectAction = objectsStatePart.createAction<{
bucketName: string;
key: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteObject
>('/typedrequest', 'deleteObject');
await typedRequest.fire({
identity: context.identity!,
bucketName: dataArg.bucketName,
key: dataArg.key,
});
// Re-fetch objects
const state = statePartArg.getState();
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListObjects
>('/typedrequest', 'listObjects');
const listResp = await listReq.fire({
identity: context.identity!,
bucketName: state.currentBucket,
prefix: state.currentPrefix,
delimiter: '/',
});
return {
result: listResp.result,
currentBucket: state.currentBucket,
currentPrefix: state.currentPrefix,
};
} catch (err) {
console.error('Failed to delete object:', err);
return statePartArg.getState();
}
});
// ============================================================================
// Credentials Actions
// ============================================================================
export const fetchCredentialsAction = credentialsStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetCredentials
>('/typedrequest', 'getCredentials');
const response = await typedRequest.fire({ identity: context.identity! });
return { credentials: response.credentials };
} catch (err) {
console.error('Failed to fetch credentials:', err);
return statePartArg.getState();
}
});
export const addCredentialAction = credentialsStatePart.createAction<{
accessKeyId: string;
secretAccessKey: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_AddCredential
>('/typedrequest', 'addCredential');
await typedRequest.fire({
identity: context.identity!,
accessKeyId: dataArg.accessKeyId,
secretAccessKey: dataArg.secretAccessKey,
});
// Re-fetch credentials
const listReq = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetCredentials
>('/typedrequest', 'getCredentials');
const listResp = await listReq.fire({ identity: context.identity! });
return { credentials: listResp.credentials };
} catch (err) {
console.error('Failed to add credential:', err);
return statePartArg.getState();
}
});
export const removeCredentialAction = credentialsStatePart.createAction<{
accessKeyId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RemoveCredential
>('/typedrequest', 'removeCredential');
await typedRequest.fire({
identity: context.identity!,
accessKeyId: dataArg.accessKeyId,
});
const state = statePartArg.getState();
return {
credentials: state.credentials.filter((c) => c.accessKeyId !== dataArg.accessKeyId),
};
} catch (err) {
console.error('Failed to remove credential:', err);
return statePartArg.getState();
}
});
// ============================================================================
// Config Actions
// ============================================================================
export const fetchConfigAction = configStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
try {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetServerConfig
>('/typedrequest', 'getServerConfig');
const response = await typedRequest.fire({ identity: context.identity! });
return { config: response.config };
} catch (err) {
console.error('Failed to fetch config:', err);
return statePartArg.getState();
}
});
// ============================================================================
// UI Actions
// ============================================================================
export const setActiveViewAction = uiStatePart.createAction<{ view: string }>(
async (statePartArg, dataArg) => {
return { ...statePartArg.getState(), activeView: dataArg.view };
},
);
// ============================================================================
// Auto-refresh system
// ============================================================================
let refreshIntervalHandle: ReturnType<typeof setInterval> | null = null;
const dispatchCombinedRefreshAction = async () => {
const loginState = loginStatePart.getState();
if (!loginState.isLoggedIn) return;
try {
await serverStatePart.dispatchAction(fetchServerStatusAction, null);
} catch (_err) {
// Silently fail on auto-refresh
}
};
const startAutoRefresh = () => {
const uiState = uiStatePart.getState();
const loginState = loginStatePart.getState();
if (uiState.autoRefresh && loginState.isLoggedIn) {
if (refreshIntervalHandle) {
clearInterval(refreshIntervalHandle);
}
refreshIntervalHandle = setInterval(() => {
dispatchCombinedRefreshAction();
}, uiState.refreshInterval);
} else {
if (refreshIntervalHandle) {
clearInterval(refreshIntervalHandle);
refreshIntervalHandle = null;
}
}
};
uiStatePart.select((s) => s).subscribe(() => startAutoRefresh());
loginStatePart.select((s) => s).subscribe(() => startAutoRefresh());
startAutoRefresh();

115
ts_web/dataprovider.ts Normal file
View File

@@ -0,0 +1,115 @@
import * as plugins from './plugins.js';
import * as appstate from './appstate.js';
import * as interfaces from '../ts_interfaces/index.js';
import type { IS3DataProvider } from '@design.estate/dees-catalog';
const getIdentity = (): interfaces.data.IIdentity => {
return appstate.loginStatePart.getState().identity!;
};
export const createDataProvider = (): IS3DataProvider => ({
async listObjects(bucket: string, prefix?: string, delimiter?: string) {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListObjects
>('/typedrequest', 'listObjects');
const response = await typedRequest.fire({
identity: getIdentity(),
bucketName: bucket,
prefix: prefix || '',
delimiter: delimiter || '/',
});
return {
objects: response.result.objects.map((obj) => ({
key: obj.key,
size: obj.size,
lastModified: new Date(obj.lastModified).toISOString(),
})),
prefixes: response.result.commonPrefixes,
};
},
async getObject(bucket: string, key: string) {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetObject
>('/typedrequest', 'getObject');
return await typedRequest.fire({
identity: getIdentity(),
bucketName: bucket,
key,
});
},
async putObject(bucket: string, key: string, base64Content: string, contentType: string) {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_PutObject
>('/typedrequest', 'putObject');
const response = await typedRequest.fire({
identity: getIdentity(),
bucketName: bucket,
key,
base64Content,
contentType,
});
return response.ok;
},
async deleteObject(bucket: string, key: string) {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteObject
>('/typedrequest', 'deleteObject');
const response = await typedRequest.fire({
identity: getIdentity(),
bucketName: bucket,
key,
});
return response.ok;
},
async deletePrefix(bucket: string, prefix: string) {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeletePrefix
>('/typedrequest', 'deletePrefix');
const response = await typedRequest.fire({
identity: getIdentity(),
bucketName: bucket,
prefix,
});
return response.ok;
},
async getObjectUrl(bucket: string, key: string) {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetObjectUrl
>('/typedrequest', 'getObjectUrl');
const response = await typedRequest.fire({
identity: getIdentity(),
bucketName: bucket,
key,
});
return response.url;
},
async moveObject(bucket: string, sourceKey: string, destKey: string) {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_MoveObject
>('/typedrequest', 'moveObject');
return await typedRequest.fire({
identity: getIdentity(),
bucketName: bucket,
sourceKey,
destKey,
});
},
async movePrefix(bucket: string, sourcePrefix: string, destPrefix: string) {
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_MovePrefix
>('/typedrequest', 'movePrefix');
return await typedRequest.fire({
identity: getIdentity(),
bucketName: bucket,
sourcePrefix,
destPrefix,
});
},
});

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>`;
}
}

7
ts_web/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import * as plugins from './plugins.js';
import { html } from '@design.estate/dees-element';
import { appRouter } from './router.js';
import './elements/index.js';
appRouter.init();
plugins.deesElement.render(html`<objst-app-shell></objst-app-shell>`, document.body);

4
ts_web/plugins.ts Normal file
View File

@@ -0,0 +1,4 @@
import * as deesElement from '@design.estate/dees-element';
import * as deesCatalog from '@design.estate/dees-catalog';
export { deesElement, deesCatalog };
export const domtools = deesElement.domtools;

109
ts_web/router.ts Normal file
View File

@@ -0,0 +1,109 @@
import * as plugins from './plugins.js';
import * as appstate from './appstate.js';
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
export const validViews = ['overview', 'buckets', 'browser', 'policies', 'config', 'access keys'] as const;
export type TValidView = typeof validViews[number];
class AppRouter {
private router: InstanceType<typeof SmartRouter>;
private initialized = false;
private suppressStateUpdate = false;
constructor() {
this.router = new SmartRouter({ debug: false });
}
public init(): void {
if (this.initialized) return;
this.setupRoutes();
this.setupStateSync();
this.handleInitialRoute();
this.initialized = true;
}
private viewToPath(view: string): string {
return `/${view.replace(/\s+/g, '-')}`;
}
private pathToView(path: string): string {
const segment = path.split('/').filter(Boolean)[0] || '';
return segment.replace(/-/g, ' ');
}
private setupRoutes(): void {
for (const view of validViews) {
this.router.on(this.viewToPath(view), async () => {
this.updateViewState(view);
});
}
// Root redirect
this.router.on('/', async () => {
this.navigateTo(this.viewToPath('overview'));
});
}
private setupStateSync(): void {
appstate.uiStatePart.state.subscribe((uiState) => {
if (this.suppressStateUpdate) return;
const currentPath = window.location.pathname;
const expectedPath = this.viewToPath(uiState.activeView);
if (currentPath !== expectedPath) {
this.suppressStateUpdate = true;
this.router.pushUrl(expectedPath);
this.suppressStateUpdate = false;
}
});
}
private handleInitialRoute(): void {
const path = window.location.pathname;
if (!path || path === '/') {
this.router.pushUrl(this.viewToPath('overview'));
} else {
const view = this.pathToView(path);
if (validViews.includes(view as TValidView)) {
this.updateViewState(view as TValidView);
} else {
this.router.pushUrl(this.viewToPath('overview'));
}
}
}
private updateViewState(view: string): void {
this.suppressStateUpdate = true;
const currentState = appstate.uiStatePart.getState();
if (currentState.activeView !== view) {
appstate.uiStatePart.setState({
...currentState,
activeView: view,
});
}
this.suppressStateUpdate = false;
}
public navigateTo(path: string): void {
this.router.pushUrl(path);
}
public navigateToView(view: string): void {
if (validViews.includes(view as TValidView)) {
this.navigateTo(this.viewToPath(view));
} else {
this.navigateTo(this.viewToPath('overview'));
}
}
public destroy(): void {
this.router.destroy();
this.initialized = false;
}
}
export const appRouter = new AppRouter();